From 42c52d7f290e77dd51810aa65d8c278628cfd0ea Mon Sep 17 00:00:00 2001 From: frag223 Date: Mon, 29 Jun 2026 20:57:37 +1000 Subject: [PATCH 01/72] adding stage reset --- internal/ui/components/ai_team_list.go | 5 +++++ internal/ui/components/playbook_list.go | 5 +++++ internal/ui/page_new_battle_selection.go | 12 +++++++++++ internal/ui/page_new_battle_selection_test.go | 20 +++++++++++++++++++ 4 files changed, 42 insertions(+) diff --git a/internal/ui/components/ai_team_list.go b/internal/ui/components/ai_team_list.go index 54f0655..85e96cd 100644 --- a/internal/ui/components/ai_team_list.go +++ b/internal/ui/components/ai_team_list.go @@ -97,6 +97,11 @@ func (l *AITeamList) Len() int { return len(l.list.Items()) } +func (l *AITeamList) Reset() { + l.list.Select(0) + l.list.FilterInput.Reset() +} + func (l *AITeamList) IsFiltering() bool { return l.list.FilterState() == list.Filtering } diff --git a/internal/ui/components/playbook_list.go b/internal/ui/components/playbook_list.go index 75006c6..a182948 100644 --- a/internal/ui/components/playbook_list.go +++ b/internal/ui/components/playbook_list.go @@ -97,6 +97,11 @@ func (l *PlaybookList) Len() int { return len(l.list.Items()) } +func (l *PlaybookList) Reset() { + l.list.Select(0) + l.list.FilterInput.Reset() +} + func (l *PlaybookList) IsFiltering() bool { return l.list.FilterState() == list.Filtering } diff --git a/internal/ui/page_new_battle_selection.go b/internal/ui/page_new_battle_selection.go index 7146255..0bcc4ed 100644 --- a/internal/ui/page_new_battle_selection.go +++ b/internal/ui/page_new_battle_selection.go @@ -90,6 +90,7 @@ func NewModelNewBattleSelection(globalState *GlobalState, teamsSvc *teams.Servic } func (m *ModelNewBattleSelection) Init() tea.Cmd { + m.reset() return m.loadData } @@ -211,12 +212,23 @@ func (m *ModelNewBattleSelection) createGame() tea.Msg { return err } + m.reset() + return MsgSwitchPage{ NewPage: PageGame, GameID: game.ID(), } } +func (m *ModelNewBattleSelection) 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 { if m.width == 0 || m.height == 0 { return tea.NewView("Initializing...") diff --git a/internal/ui/page_new_battle_selection_test.go b/internal/ui/page_new_battle_selection_test.go index b0189c5..1b0e0b9 100644 --- a/internal/ui/page_new_battle_selection_test.go +++ b/internal/ui/page_new_battle_selection_test.go @@ -98,6 +98,26 @@ func TestModelNewBattleSelection_Rendering(t *testing.T) { } }) + group.Test("should reset state on Init", func(t *testing.T) { + state := &GlobalState{ + Team: &teams.Team{ID: 1, Name: "My Team"}, + } + m := NewModelNewBattleSelection(state, nil, nil, nil) + + // 1. Set some state + m.state = stateConfirming + 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) + }) + err := group.Run() odize.AssertNoError(t, err) } From c2e0207f7d279781b5b439b62b23c732daf5756a Mon Sep 17 00:00:00 2001 From: frag223 Date: Mon, 29 Jun 2026 21:12:33 +1000 Subject: [PATCH 02/72] wip moving to styles --- .junie/guidelines.md | 1 + internal/ui/app.go | 5 +++-- internal/ui/app_test.go | 3 ++- internal/ui/page_create_coach.go | 5 +++-- internal/ui/page_game.go | 5 +++-- internal/ui/page_game_complete.go | 5 +++-- internal/ui/page_locker_playbooks_create.go | 5 +++-- internal/ui/page_locker_playbooks_edit.go | 5 +++-- internal/ui/page_locker_playbooks_list.go | 5 +++-- internal/ui/page_locker_players.go | 5 +++-- internal/ui/page_locker_room.go | 5 +++-- internal/ui/page_new_battle_selection.go | 5 +++-- internal/ui/page_new_tournament.go | 7 +++++-- internal/ui/page_title.go | 3 ++- internal/ui/page_title_settings.go | 7 +++++-- internal/ui/{ => styles}/theme.go | 2 +- 16 files changed, 46 insertions(+), 27 deletions(-) rename internal/ui/{ => styles}/theme.go (98%) 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/internal/ui/app.go b/internal/ui/app.go index 97c47e7..e313e71 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -7,6 +7,7 @@ import ( "github.com/code-gorilla-au/rush/internal/games" "github.com/code-gorilla-au/rush/internal/playbooks" "github.com/code-gorilla-au/rush/internal/teams" + "github.com/code-gorilla-au/rush/internal/ui/styles" ) type MsgStateUpdated struct { @@ -50,7 +51,7 @@ type RootModel struct { ctx context.Context width int height int - theme IceTheme + theme styles.IceTheme currentPage Page pageTitle tea.Model pageCreateCoach tea.Model @@ -82,7 +83,7 @@ func New(deps Dependencies) *RootModel { return &RootModel{ ctx: context.Background(), - theme: NewIceTheme(), + theme: styles.NewIceTheme(), currentPage: PageTitle, pageTitle: NewModelTitle(state), pageCreateCoach: NewModelCreateCoach(state, deps.TeamsSvc), diff --git a/internal/ui/app_test.go b/internal/ui/app_test.go index 57adced..5cbeef7 100644 --- a/internal/ui/app_test.go +++ b/internal/ui/app_test.go @@ -10,6 +10,7 @@ import ( "github.com/code-gorilla-au/rush/internal/games" "github.com/code-gorilla-au/rush/internal/playbooks" "github.com/code-gorilla-au/rush/internal/teams" + "github.com/code-gorilla-au/rush/internal/ui/styles" ) func setupServices(t *testing.T) (*teams.Service, *playbooks.Service, *games.Service) { @@ -35,7 +36,7 @@ func TestTheme(t *testing.T) { 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) diff --git a/internal/ui/page_create_coach.go b/internal/ui/page_create_coach.go index 5c6e9bd..ddf02a1 100644 --- a/internal/ui/page_create_coach.go +++ b/internal/ui/page_create_coach.go @@ -7,6 +7,7 @@ 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" ) type createCoachKeyMap struct { @@ -64,7 +65,7 @@ func newCreateCoachKeyMap() createCoachKeyMap { type ModelCreateCoach struct { width int height int - theme IceTheme + theme styles.IceTheme globalState *GlobalState teamsSvc *teams.Service @@ -95,7 +96,7 @@ func NewModelCreateCoach(state *GlobalState, teamsSvc *teams.Service) *ModelCrea teamsSvc: teamsSvc, coachInput: c, teamInput: t, - theme: NewIceTheme(), + theme: styles.NewIceTheme(), keys: keys, footer: components.NewFooter(keys), } diff --git a/internal/ui/page_game.go b/internal/ui/page_game.go index d279f06..64d5933 100644 --- a/internal/ui/page_game.go +++ b/internal/ui/page_game.go @@ -7,12 +7,13 @@ 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" ) type PageGameModel struct { width int height int - theme IceTheme + theme styles.IceTheme globalState *GlobalState gameSvc *games.Service gameID int64 @@ -22,7 +23,7 @@ type PageGameModel struct { func NewModelGame(state *GlobalState, gameSvc *games.Service) *PageGameModel { return &PageGameModel{ - theme: NewIceTheme(), + theme: styles.NewIceTheme(), globalState: state, gameSvc: gameSvc, } diff --git a/internal/ui/page_game_complete.go b/internal/ui/page_game_complete.go index 3aa2e2b..c0a63e3 100644 --- a/internal/ui/page_game_complete.go +++ b/internal/ui/page_game_complete.go @@ -8,12 +8,13 @@ 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" ) type PageGameCompleteModel struct { width int height int - theme IceTheme + theme styles.IceTheme globalState *GlobalState teamsSvc *teams.Service gameSvc *games.Service @@ -26,7 +27,7 @@ type PageGameCompleteModel struct { func NewPageGameComplete(state *GlobalState, teamsSvc *teams.Service, gameSvc *games.Service) *PageGameCompleteModel { return &PageGameCompleteModel{ - theme: NewIceTheme(), + theme: styles.NewIceTheme(), globalState: state, teamsSvc: teamsSvc, gameSvc: gameSvc, diff --git a/internal/ui/page_locker_playbooks_create.go b/internal/ui/page_locker_playbooks_create.go index aed4a28..4125707 100644 --- a/internal/ui/page_locker_playbooks_create.go +++ b/internal/ui/page_locker_playbooks_create.go @@ -8,6 +8,7 @@ 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" ) type lockerPlaybooksCreateKeyMap struct { @@ -43,7 +44,7 @@ func newLockerPlaybooksCreateKeyMap() lockerPlaybooksCreateKeyMap { type ModelLockerPlaybooksCreate struct { width int height int - theme IceTheme + theme styles.IceTheme globalState *GlobalState playbookSvc *playbooks.Service keys lockerPlaybooksCreateKeyMap @@ -56,7 +57,7 @@ type ModelLockerPlaybooksCreate struct { func NewModelLockerPlaybooksCreate(state *GlobalState, playbookSvc *playbooks.Service) *ModelLockerPlaybooksCreate { return &ModelLockerPlaybooksCreate{ - theme: NewIceTheme(), + theme: styles.NewIceTheme(), globalState: state, playbookSvc: playbookSvc, keys: newLockerPlaybooksCreateKeyMap(), diff --git a/internal/ui/page_locker_playbooks_edit.go b/internal/ui/page_locker_playbooks_edit.go index 234b958..33501a2 100644 --- a/internal/ui/page_locker_playbooks_edit.go +++ b/internal/ui/page_locker_playbooks_edit.go @@ -8,6 +8,7 @@ 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" ) type lockerPlaybooksEditKeyMap struct { @@ -48,7 +49,7 @@ func newLockerPlaybooksEditKeyMap() lockerPlaybooksEditKeyMap { type ModelLockerPlaybooksEdit struct { width int height int - theme IceTheme + theme styles.IceTheme globalState *GlobalState playbookSvc *playbooks.Service keys lockerPlaybooksEditKeyMap @@ -65,7 +66,7 @@ type ModelLockerPlaybooksEdit struct { func NewModelLockerPlaybooksEdit(state *GlobalState, playbookSvc *playbooks.Service) *ModelLockerPlaybooksEdit { return &ModelLockerPlaybooksEdit{ - theme: NewIceTheme(), + theme: styles.NewIceTheme(), globalState: state, playbookSvc: playbookSvc, keys: newLockerPlaybooksEditKeyMap(), diff --git a/internal/ui/page_locker_playbooks_list.go b/internal/ui/page_locker_playbooks_list.go index 8a2056a..af86c25 100644 --- a/internal/ui/page_locker_playbooks_list.go +++ b/internal/ui/page_locker_playbooks_list.go @@ -8,6 +8,7 @@ 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" ) type lockerPlaybooksListKeyMap struct { @@ -52,7 +53,7 @@ func newLockerPlaybooksListKeyMap() lockerPlaybooksListKeyMap { type ModelLockerPlaybooksList struct { width int height int - theme IceTheme + theme styles.IceTheme globalState *GlobalState playbookSvc *playbooks.Service keys lockerPlaybooksListKeyMap @@ -64,7 +65,7 @@ type ModelLockerPlaybooksList struct { func NewModelLockerPlaybooksList(state *GlobalState, playbookSvc *playbooks.Service) *ModelLockerPlaybooksList { return &ModelLockerPlaybooksList{ - theme: NewIceTheme(), + theme: styles.NewIceTheme(), globalState: state, playbookSvc: playbookSvc, keys: newLockerPlaybooksListKeyMap(), diff --git a/internal/ui/page_locker_players.go b/internal/ui/page_locker_players.go index 276ced6..bbe2693 100644 --- a/internal/ui/page_locker_players.go +++ b/internal/ui/page_locker_players.go @@ -3,6 +3,7 @@ package ui 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" "charm.land/bubbles/v2/key" tea "charm.land/bubbletea/v2" @@ -53,7 +54,7 @@ func newLockerPlayersKeyMap() lockerPlayersKeyMap { type ModelLockerPlayers struct { width int height int - theme IceTheme + theme styles.IceTheme globalState *GlobalState teamsSvc *teams.Service keys lockerPlayersKeyMap @@ -64,7 +65,7 @@ type ModelLockerPlayers struct { func NewModelLockerPlayers(state *GlobalState, teamsSvc *teams.Service) *ModelLockerPlayers { keys := newLockerPlayersKeyMap() return &ModelLockerPlayers{ - theme: NewIceTheme(), + theme: styles.NewIceTheme(), globalState: state, teamsSvc: teamsSvc, keys: keys, diff --git a/internal/ui/page_locker_room.go b/internal/ui/page_locker_room.go index f13b081..ebcc036 100644 --- a/internal/ui/page_locker_room.go +++ b/internal/ui/page_locker_room.go @@ -2,6 +2,7 @@ package ui import ( "github.com/code-gorilla-au/rush/internal/ui/components" + "github.com/code-gorilla-au/rush/internal/ui/styles" "charm.land/bubbles/v2/key" tea "charm.land/bubbletea/v2" @@ -41,7 +42,7 @@ func newLockerRoomKeyMap() lockerRoomKeyMap { type ModelLockerRoom struct { width int height int - theme IceTheme + theme styles.IceTheme globalState *GlobalState keys lockerRoomKeyMap footer components.Footer @@ -54,7 +55,7 @@ func NewModelLockerRoom(globalState *GlobalState) *ModelLockerRoom { globalState: globalState, keys: keys, footer: components.NewFooter(keys), - theme: NewIceTheme(), + theme: styles.NewIceTheme(), list: components.NewLockerRoomList(), } } diff --git a/internal/ui/page_new_battle_selection.go b/internal/ui/page_new_battle_selection.go index 0bcc4ed..f578f06 100644 --- a/internal/ui/page_new_battle_selection.go +++ b/internal/ui/page_new_battle_selection.go @@ -7,6 +7,7 @@ import ( "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" "charm.land/bubbles/v2/key" tea "charm.land/bubbletea/v2" @@ -59,7 +60,7 @@ const ( type ModelNewBattleSelection struct { width int height int - theme IceTheme + theme styles.IceTheme globalState *GlobalState teamsSvc *teams.Service playbookSvc *playbooks.Service @@ -81,7 +82,7 @@ func NewModelNewBattleSelection(globalState *GlobalState, teamsSvc *teams.Servic teamsSvc: teamsSvc, playbookSvc: playbookSvc, gameSvc: gameSvc, - theme: NewIceTheme(), + theme: styles.NewIceTheme(), keys: keys, footer: components.NewFooter(keys), playbookList: components.NewPlaybookList(nil), diff --git a/internal/ui/page_new_tournament.go b/internal/ui/page_new_tournament.go index 4589a5c..622be05 100644 --- a/internal/ui/page_new_tournament.go +++ b/internal/ui/page_new_tournament.go @@ -1,11 +1,14 @@ package ui -import tea "charm.land/bubbletea/v2" +import ( + tea "charm.land/bubbletea/v2" + "github.com/code-gorilla-au/rush/internal/ui/styles" +) type ModelNewTournament struct { width int height int - theme IceTheme + theme styles.IceTheme globalState *GlobalState } diff --git a/internal/ui/page_title.go b/internal/ui/page_title.go index ff329be..9644b59 100644 --- a/internal/ui/page_title.go +++ b/internal/ui/page_title.go @@ -4,6 +4,7 @@ import ( "strings" "github.com/code-gorilla-au/rush/internal/ui/components" + "github.com/code-gorilla-au/rush/internal/ui/styles" "charm.land/bubbles/v2/key" tea "charm.land/bubbletea/v2" @@ -38,7 +39,7 @@ func newTitleKeyMap() titleKeyMap { type ModelTitle struct { width int height int - theme IceTheme + theme styles.IceTheme globalState *GlobalState keys titleKeyMap footer components.Footer diff --git a/internal/ui/page_title_settings.go b/internal/ui/page_title_settings.go index 9a162a6..3d3b344 100644 --- a/internal/ui/page_title_settings.go +++ b/internal/ui/page_title_settings.go @@ -1,11 +1,14 @@ package ui -import tea "charm.land/bubbletea/v2" +import ( + tea "charm.land/bubbletea/v2" + "github.com/code-gorilla-au/rush/internal/ui/styles" +) type ModelTitleSettings struct { width int height int - theme IceTheme + theme styles.IceTheme globalState *GlobalState } diff --git a/internal/ui/theme.go b/internal/ui/styles/theme.go similarity index 98% rename from internal/ui/theme.go rename to internal/ui/styles/theme.go index 1dfbd67..e78c248 100644 --- a/internal/ui/theme.go +++ b/internal/ui/styles/theme.go @@ -1,4 +1,4 @@ -package ui +package styles import "charm.land/lipgloss/v2" From 47dc487f250f91f8f1e6322bb222f13aca1af287 Mon Sep 17 00:00:00 2001 From: frag223 Date: Mon, 29 Jun 2026 21:34:16 +1000 Subject: [PATCH 03/72] wip --- internal/ui/app.go | 27 +++--- internal/ui/components/ai_team_list.go | 27 ++---- internal/ui/components/coach_avatar.go | 7 +- internal/ui/components/coach_avatar_test.go | 11 +-- internal/ui/components/coach_complete.go | 17 ++-- internal/ui/components/coach_complete_test.go | 8 +- internal/ui/components/footer.go | 6 +- internal/ui/components/footer_test.go | 6 +- internal/ui/components/formation_list.go | 18 ++-- internal/ui/components/game.go | 27 ++---- internal/ui/components/game_test.go | 6 +- internal/ui/components/locker_room_list.go | 8 +- internal/ui/components/playbook_list.go | 27 ++---- internal/ui/components/player_list.go | 8 +- internal/ui/components/round.go | 29 ++---- internal/ui/components/round_test.go | 4 +- internal/ui/components/title_menu.go | 8 +- internal/ui/page_create_coach.go | 10 +- internal/ui/page_game.go | 6 +- internal/ui/page_game_complete.go | 16 ++-- internal/ui/page_game_complete_test.go | 7 +- internal/ui/page_game_test.go | 10 +- internal/ui/page_locker_playbooks_create.go | 6 +- internal/ui/page_locker_playbooks_edit.go | 16 ++-- internal/ui/page_locker_playbooks_list.go | 10 +- internal/ui/page_locker_players.go | 8 +- internal/ui/page_locker_room.go | 8 +- internal/ui/page_locker_room_test.go | 7 +- internal/ui/page_new_battle_selection.go | 16 ++-- internal/ui/page_new_battle_selection_test.go | 13 ++- internal/ui/page_new_tournament.go | 3 +- internal/ui/page_title.go | 7 +- internal/ui/page_title_settings.go | 3 +- internal/ui/page_title_test.go | 7 +- internal/ui/styles/theme.go | 95 +++++++++++++++++-- 35 files changed, 272 insertions(+), 220 deletions(-) diff --git a/internal/ui/app.go b/internal/ui/app.go index e313e71..cf6d4d7 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -80,23 +80,24 @@ type Dependencies struct { // New returns a new UI model. func New(deps Dependencies) *RootModel { state := &GlobalState{} + theme := styles.NewIceTheme() return &RootModel{ ctx: context.Background(), - theme: styles.NewIceTheme(), + theme: theme, 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), + pageTitle: NewModelTitle(state, theme), + pageCreateCoach: NewModelCreateCoach(state, deps.TeamsSvc, theme), + pageLockerRoom: NewModelLockerRoom(state, theme), + pageLockerPlayers: NewModelLockerPlayers(state, deps.TeamsSvc, theme), + pageLockerPlaybooksList: NewModelLockerPlaybooksList(state, deps.PlaybookSvc, theme), + pageLockerPlaybooksCreate: NewModelLockerPlaybooksCreate(state, deps.PlaybookSvc, theme), + pageLockerPlaybooksEdit: NewModelLockerPlaybooksEdit(state, deps.PlaybookSvc, theme), + pageNewTournament: NewModelNewTournament(state, theme), + pageNewBattleSelection: NewModelNewBattleSelection(state, deps.TeamsSvc, deps.PlaybookSvc, deps.GameSvc, theme), + pageTitleSettings: NewModelTitleSettings(state, theme), + pageGame: NewModelGame(state, deps.GameSvc, theme), + pageGameComplete: NewPageGameComplete(state, deps.TeamsSvc, deps.GameSvc, theme), globalState: state, teamsSvc: deps.TeamsSvc, playbookSvc: deps.PlaybookSvc, diff --git a/internal/ui/components/ai_team_list.go b/internal/ui/components/ai_team_list.go index 85e96cd..57a8d2e 100644 --- a/internal/ui/components/ai_team_list.go +++ b/internal/ui/components/ai_team_list.go @@ -3,8 +3,8 @@ 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 { @@ -20,21 +20,21 @@ type AITeamList struct { active bool } -func NewAITeamList(items []teams.AITeam) AITeamList { +func NewAITeamList(items []teams.AITeam, theme styles.IceTheme) 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")) + delegate.Styles.SelectedTitle = theme.SelectedTitle + delegate.Styles.SelectedDesc = theme.SelectedDesc 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) + l.Styles.Title = theme.Title return AITeamList{ list: l, @@ -48,22 +48,11 @@ func (l *AITeamList) Update(msg tea.Msg) (AITeamList, tea.Cmd) { 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")) - +func (l *AITeamList) View(theme styles.IceTheme) string { if l.active { - return activeStyle.Render(l.list.View()) + return theme.ActiveBorder.Render(l.list.View()) } - return inactiveStyle.Render(l.list.View()) + return theme.InactiveBorder.Render(l.list.View()) } func (l *AITeamList) SetActive(active bool) { diff --git a/internal/ui/components/coach_avatar.go b/internal/ui/components/coach_avatar.go index 5820584..621444c 100644 --- a/internal/ui/components/coach_avatar.go +++ b/internal/ui/components/coach_avatar.go @@ -5,6 +5,7 @@ import ( "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 +23,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(fmt.Sprintf("Coach: %s", c.Coach.Name)) return lipgloss.JoinVertical(lipgloss.Left, teamName, coachName) } diff --git a/internal/ui/components/coach_avatar_test.go b/internal/ui/components/coach_avatar_test.go 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..8046539 100644 --- a/internal/ui/components/coach_complete.go +++ b/internal/ui/components/coach_complete.go @@ -6,6 +6,7 @@ import ( "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 +24,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). + winnerHeader := theme.SecondaryHeader. Render(fmt.Sprintf("Winner: %s", c.Team.Name)) - coachInfo := coachStyle.Render(fmt.Sprintf("%s (Human Coach)", c.Coach.Name)) + coachInfo := theme.CoachName.Render(fmt.Sprintf("%s (Human Coach)", c.Coach.Name)) players := make([]string, len(c.Team.Players)) for i, p := range c.Team.Players { @@ -66,17 +65,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). + winnerHeader := theme.SecondaryHeader. Render(fmt.Sprintf("Winner: %s", c.Team.Name)) - coachInfo := coachStyle.Render(fmt.Sprintf("%s (AI Coach)", c.Coach.Name)) + coachInfo := theme.CoachName.Render(fmt.Sprintf("%s (AI Coach)", c.Coach.Name)) 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..27b1a41 100644 --- a/internal/ui/components/footer.go +++ b/internal/ui/components/footer.go @@ -4,7 +4,7 @@ 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,8 +30,8 @@ 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)) +func (f Footer) View(theme styles.IceTheme) string { + return theme.Footer.Render(f.Help.View(f.KeyMap)) } // CommonKeys defines keys that are shared across many pages. diff --git a/internal/ui/components/footer_test.go b/internal/ui/components/footer_test.go index fbef367..d8a18af 100644 --- a/internal/ui/components/footer_test.go +++ b/internal/ui/components/footer_test.go @@ -5,8 +5,8 @@ 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" ) func TestFooter(t *testing.T) { @@ -26,9 +26,9 @@ func TestFooter(t *testing.T) { }). Test("View should render help text", func(t *testing.T) { footer := NewFooter(NewCommonKeys()) - style := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")) + theme := styles.NewIceTheme() - rendered := footer.View(style) + rendered := footer.View(theme) odize.AssertTrue(t, len(rendered) > 0) odize.AssertTrue(t, strings.Contains(rendered, "q")) diff --git a/internal/ui/components/formation_list.go b/internal/ui/components/formation_list.go index 6c91942..f5f37a9 100644 --- a/internal/ui/components/formation_list.go +++ b/internal/ui/components/formation_list.go @@ -5,8 +5,8 @@ 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 FormationItem struct { @@ -37,7 +37,7 @@ type FormationListConfig struct { ShowDescription bool } -func NewFormationList(config FormationListConfig) FormationList { +func NewFormationList(config FormationListConfig, theme styles.IceTheme) FormationList { items := make([]list.Item, len(config.Items)) for i, f := range config.Items { items[i] = FormationItem{ @@ -47,15 +47,14 @@ func NewFormationList(config FormationListConfig) FormationList { } 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")) + delegate.Styles.SelectedTitle = theme.SelectedTitle + delegate.Styles.SelectedDesc = theme.SelectedDesc 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) + l.Styles.Title = theme.Title return FormationList{ list: l, @@ -71,12 +70,11 @@ func (l *FormationList) Update(msg tea.Msg) (FormationList, tea.Cmd) { return *l, cmd } -func (l *FormationList) View() string { - style := lipgloss.NewStyle().Padding(1).Border(lipgloss.RoundedBorder()) +func (l *FormationList) View(theme styles.IceTheme) string { if l.active { - style = style.BorderForeground(lipgloss.Color("#A5F2F3")) + return theme.ActiveBorder.Render(l.list.View()) } - return style.Render(l.list.View()) + return theme.InactiveBorder.Render(l.list.View()) } func (l *FormationList) SelectedItem() playbooks.Formation { diff --git a/internal/ui/components/game.go b/internal/ui/components/game.go index 74e5925..9ac63b5 100644 --- a/internal/ui/components/game.go +++ b/internal/ui/components/game.go @@ -8,6 +8,7 @@ import ( 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. @@ -92,20 +93,15 @@ 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)) var footer string if g.resolved { @@ -116,23 +112,16 @@ func (g *Game) View() string { 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, diff --git a/internal/ui/components/game_test.go b/internal/ui/components/game_test.go index e06e0d8..f70380d 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 { @@ -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, @@ -60,7 +62,7 @@ func TestGameComponent(t *testing.T) { // 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...")) @@ -70,7 +72,7 @@ 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, "WINNER: Team A")) odize.AssertTrue(t, strings.Contains(view, "Press Enter for next round...")) diff --git a/internal/ui/components/locker_room_list.go b/internal/ui/components/locker_room_list.go index 6b8c8ba..df9b896 100644 --- a/internal/ui/components/locker_room_list.go +++ b/internal/ui/components/locker_room_list.go @@ -2,7 +2,7 @@ package components import ( tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" + "github.com/code-gorilla-au/rush/internal/ui/styles" ) type LockerRoomItem int @@ -52,13 +52,13 @@ func (l *LockerRoomList) Update(msg tea.Msg) { } } -func (l *LockerRoomList) View(itemStyle lipgloss.Style, selectedStyle lipgloss.Style) string { +func (l *LockerRoomList) View(theme styles.IceTheme) string { var s string for i, item := range l.items { if i == l.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(l.items)-1 { s += "\n" diff --git a/internal/ui/components/playbook_list.go b/internal/ui/components/playbook_list.go index a182948..6569846 100644 --- a/internal/ui/components/playbook_list.go +++ b/internal/ui/components/playbook_list.go @@ -3,8 +3,8 @@ 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 { @@ -20,21 +20,21 @@ type PlaybookList struct { active bool } -func NewPlaybookList(items []playbooks.Playbook) PlaybookList { +func NewPlaybookList(items []playbooks.Playbook, theme styles.IceTheme) 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")) + delegate.Styles.SelectedTitle = theme.SelectedTitle + delegate.Styles.SelectedDesc = theme.SelectedDesc 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) + l.Styles.Title = theme.Title return PlaybookList{ list: l, @@ -48,22 +48,11 @@ func (l *PlaybookList) Update(msg tea.Msg) (PlaybookList, tea.Cmd) { 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")) - +func (l *PlaybookList) View(theme styles.IceTheme) string { if l.active { - return activeStyle.Render(l.list.View()) + return theme.ActiveBorder.Render(l.list.View()) } - return inactiveStyle.Render(l.list.View()) + return theme.InactiveBorder.Render(l.list.View()) } func (l *PlaybookList) SetActive(active bool) { diff --git a/internal/ui/components/player_list.go b/internal/ui/components/player_list.go index 4a64449..1041578 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 { @@ -84,7 +84,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 +95,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..b0477f5 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,35 +23,29 @@ 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 { renderPlayers := func(count int, align lipgloss.Position) string { dots := strings.TrimSpace(strings.Repeat("● ", count)) - content := playerStyle.Render(dots) + content := theme.Player.Render(dots) return lipgloss.NewStyle().Width(10).Align(align).Render(content) } header := lipgloss.JoinHorizontal(lipgloss.Top, - teamAStyle.Render(r.teamAName), - separatorStyle.Render(" | "), - teamBStyle.Render(r.teamBName), + theme.TeamA.Render(r.teamAName), + theme.Separator.Render(" | "), + theme.TeamB.Render(r.teamBName), ) - divider := separatorStyle.Render(strings.Repeat("-", 10) + "-|-" + strings.Repeat("-", 10)) + divider := theme.Separator.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)), ) } @@ -63,9 +58,5 @@ func (r Round) View() string { 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..d522de3 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) { @@ -37,7 +38,8 @@ func TestRound(t *testing.T) { }). Test("View should render team names and 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")) diff --git a/internal/ui/components/title_menu.go b/internal/ui/components/title_menu.go index a51d1a1..78d31c9 100644 --- a/internal/ui/components/title_menu.go +++ b/internal/ui/components/title_menu.go @@ -2,7 +2,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 @@ -64,13 +64,13 @@ func (m *TitleMenu) Update(msg tea.Msg) { } } -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" diff --git a/internal/ui/page_create_coach.go b/internal/ui/page_create_coach.go index ddf02a1..841e894 100644 --- a/internal/ui/page_create_coach.go +++ b/internal/ui/page_create_coach.go @@ -77,7 +77,7 @@ type ModelCreateCoach struct { footer components.Footer } -func NewModelCreateCoach(state *GlobalState, teamsSvc *teams.Service) *ModelCreateCoach { +func NewModelCreateCoach(state *GlobalState, teamsSvc *teams.Service, theme styles.IceTheme) *ModelCreateCoach { c := textinput.New() c.Placeholder = "Coach Name" c.Focus() @@ -96,7 +96,7 @@ func NewModelCreateCoach(state *GlobalState, teamsSvc *teams.Service) *ModelCrea teamsSvc: teamsSvc, coachInput: c, teamInput: t, - theme: styles.NewIceTheme(), + theme: theme, keys: keys, footer: components.NewFooter(keys), } @@ -227,13 +227,13 @@ func (m *ModelCreateCoach) View() tea.View { 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), + m.footer.View(m.theme), ) centeredContent := lipgloss.Place( diff --git a/internal/ui/page_game.go b/internal/ui/page_game.go index 64d5933..f66479b 100644 --- a/internal/ui/page_game.go +++ b/internal/ui/page_game.go @@ -21,9 +21,9 @@ type PageGameModel struct { gameComp components.Game } -func NewModelGame(state *GlobalState, gameSvc *games.Service) *PageGameModel { +func NewModelGame(state *GlobalState, gameSvc *games.Service, theme styles.IceTheme) *PageGameModel { return &PageGameModel{ - theme: styles.NewIceTheme(), + theme: theme, globalState: state, gameSvc: gameSvc, } @@ -118,7 +118,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/page_game_complete.go index c0a63e3..329aaf5 100644 --- a/internal/ui/page_game_complete.go +++ b/internal/ui/page_game_complete.go @@ -25,9 +25,9 @@ type PageGameCompleteModel struct { err error } -func NewPageGameComplete(state *GlobalState, teamsSvc *teams.Service, gameSvc *games.Service) *PageGameCompleteModel { +func NewPageGameComplete(state *GlobalState, teamsSvc *teams.Service, gameSvc *games.Service, theme styles.IceTheme) *PageGameCompleteModel { return &PageGameCompleteModel{ - theme: styles.NewIceTheme(), + theme: theme, globalState: state, teamsSvc: teamsSvc, gameSvc: gameSvc, @@ -132,23 +132,19 @@ func (m *PageGameCompleteModel) View() tea.View { } func (m *PageGameCompleteModel) renderMainContent(content string) string { - winMsg := lipgloss.NewStyle(). - Foreground(lipgloss.Color("#A5F2F3")). - Bold(true). + 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) } } diff --git a/internal/ui/page_game_complete_test.go b/internal/ui/page_game_complete_test.go index 43f15e7..ac04a1b 100644 --- a/internal/ui/page_game_complete_test.go +++ b/internal/ui/page_game_complete_test.go @@ -10,6 +10,7 @@ import ( "github.com/code-gorilla-au/rush/internal/games" "github.com/code-gorilla-au/rush/internal/playbooks" "github.com/code-gorilla-au/rush/internal/teams" + "github.com/code-gorilla-au/rush/internal/ui/styles" _ "modernc.org/sqlite" ) @@ -115,7 +116,8 @@ func TestPageGameCompleteModel(t *testing.T) { _, 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,7 +131,8 @@ 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", }) diff --git a/internal/ui/page_game_test.go b/internal/ui/page_game_test.go index bdebb6e..5413fcf 100644 --- a/internal/ui/page_game_test.go +++ b/internal/ui/page_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/ui/components" + "github.com/code-gorilla-au/rush/internal/ui/styles" ) type pageGameMockStore struct { @@ -51,7 +52,8 @@ func TestPageGameModel(t *testing.T) { 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 +68,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,7 +95,8 @@ 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}) diff --git a/internal/ui/page_locker_playbooks_create.go b/internal/ui/page_locker_playbooks_create.go index 4125707..717bc3d 100644 --- a/internal/ui/page_locker_playbooks_create.go +++ b/internal/ui/page_locker_playbooks_create.go @@ -55,9 +55,9 @@ type ModelLockerPlaybooksCreate struct { err error } -func NewModelLockerPlaybooksCreate(state *GlobalState, playbookSvc *playbooks.Service) *ModelLockerPlaybooksCreate { +func NewModelLockerPlaybooksCreate(state *GlobalState, playbookSvc *playbooks.Service, theme styles.IceTheme) *ModelLockerPlaybooksCreate { return &ModelLockerPlaybooksCreate{ - theme: styles.NewIceTheme(), + theme: theme, globalState: state, playbookSvc: playbookSvc, keys: newLockerPlaybooksCreateKeyMap(), @@ -162,7 +162,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/page_locker_playbooks_edit.go b/internal/ui/page_locker_playbooks_edit.go index 33501a2..e4c1336 100644 --- a/internal/ui/page_locker_playbooks_edit.go +++ b/internal/ui/page_locker_playbooks_edit.go @@ -64,9 +64,9 @@ type ModelLockerPlaybooksEdit struct { err error } -func NewModelLockerPlaybooksEdit(state *GlobalState, playbookSvc *playbooks.Service) *ModelLockerPlaybooksEdit { +func NewModelLockerPlaybooksEdit(state *GlobalState, playbookSvc *playbooks.Service, theme styles.IceTheme) *ModelLockerPlaybooksEdit { return &ModelLockerPlaybooksEdit{ - theme: styles.NewIceTheme(), + theme: theme, globalState: state, playbookSvc: playbookSvc, keys: newLockerPlaybooksEditKeyMap(), @@ -76,13 +76,13 @@ func NewModelLockerPlaybooksEdit(state *GlobalState, playbookSvc *playbooks.Serv 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), } } @@ -249,11 +249,11 @@ func (m *ModelLockerPlaybooksEdit) View() tea.View { content = lipgloss.JoinHorizontal( lipgloss.Top, - m.formationList.View(), + m.formationList.View(m.theme), lipgloss.NewStyle().Width(2).Render(""), - m.selectedFormationList.View(), + m.selectedFormationList.View(m.theme), ) - content += "\n\n" + m.theme.Footer.Render(fmt.Sprintf("%d/10 formations • Tab: switch • Enter: add/remove • 's': save", len(m.newFormations))) + content += "\n\n" + m.theme.Muted.Render(fmt.Sprintf("%d/10 formations • Tab: switch • Enter: add/remove • 's': save", len(m.newFormations))) } mainContent := lipgloss.JoinVertical( @@ -262,7 +262,7 @@ func (m *ModelLockerPlaybooksEdit) View() tea.View { "", content, "", - m.footer.View(m.theme.Footer), + m.footer.View(m.theme), ) centeredContent := lipgloss.Place( diff --git a/internal/ui/page_locker_playbooks_list.go b/internal/ui/page_locker_playbooks_list.go index af86c25..c0eb143 100644 --- a/internal/ui/page_locker_playbooks_list.go +++ b/internal/ui/page_locker_playbooks_list.go @@ -63,9 +63,9 @@ type ModelLockerPlaybooksList struct { err error } -func NewModelLockerPlaybooksList(state *GlobalState, playbookSvc *playbooks.Service) *ModelLockerPlaybooksList { +func NewModelLockerPlaybooksList(state *GlobalState, playbookSvc *playbooks.Service, theme styles.IceTheme) *ModelLockerPlaybooksList { return &ModelLockerPlaybooksList{ - theme: styles.NewIceTheme(), + theme: theme, globalState: state, playbookSvc: playbookSvc, keys: newLockerPlaybooksListKeyMap(), @@ -101,7 +101,7 @@ func (m *ModelLockerPlaybooksList) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.globalState.Team = msg.Team case MsgPlaybooksLoaded: m.playbooksLoaded = true - m.playbookList = components.NewPlaybookList(msg.Playbooks) + m.playbookList = components.NewPlaybookList(msg.Playbooks, m.theme) m.playbookList.SetSize(m.width, m.height-10) case MsgSwitchPage: if msg.NewPage == PageLockerPlaybooksList { @@ -205,7 +205,7 @@ func (m *ModelLockerPlaybooksList) View() tea.View { if m.playbookList.Len() == 0 { content = "No playbooks yet. Press 'n' to create one." } else { - content = m.playbookList.View() + content = m.playbookList.View(m.theme) if !m.playbookList.IsFiltering() { content += "\n\nPress 'n' to create new playbook" } @@ -218,7 +218,7 @@ func (m *ModelLockerPlaybooksList) View() tea.View { "", content, "", - m.footer.View(m.theme.Footer), + m.footer.View(m.theme), ) centeredContent := lipgloss.Place( diff --git a/internal/ui/page_locker_players.go b/internal/ui/page_locker_players.go index bbe2693..d516ea3 100644 --- a/internal/ui/page_locker_players.go +++ b/internal/ui/page_locker_players.go @@ -62,10 +62,10 @@ type ModelLockerPlayers struct { playerList components.PlayerList } -func NewModelLockerPlayers(state *GlobalState, teamsSvc *teams.Service) *ModelLockerPlayers { +func NewModelLockerPlayers(state *GlobalState, teamsSvc *teams.Service, theme styles.IceTheme) *ModelLockerPlayers { keys := newLockerPlayersKeyMap() return &ModelLockerPlayers{ - theme: styles.NewIceTheme(), + theme: theme, globalState: state, teamsSvc: teamsSvc, keys: keys, @@ -138,7 +138,7 @@ 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( @@ -147,7 +147,7 @@ func (m *ModelLockerPlayers) View() tea.View { "", 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/page_locker_room.go index ebcc036..44a75cf 100644 --- a/internal/ui/page_locker_room.go +++ b/internal/ui/page_locker_room.go @@ -49,13 +49,13 @@ type ModelLockerRoom struct { list components.LockerRoomList } -func NewModelLockerRoom(globalState *GlobalState) *ModelLockerRoom { +func NewModelLockerRoom(globalState *GlobalState, theme styles.IceTheme) *ModelLockerRoom { keys := newLockerRoomKeyMap() return &ModelLockerRoom{ globalState: globalState, keys: keys, footer: components.NewFooter(keys), - theme: styles.NewIceTheme(), + theme: theme, list: components.NewLockerRoomList(), } } @@ -108,9 +108,9 @@ func (m *ModelLockerRoom) View() tea.View { lipgloss.Center, m.theme.Logo.Render("LOCKER ROOM"), "", - 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/page_locker_room_test.go b/internal/ui/page_locker_room_test.go index edd95d5..7a31367 100644 --- a/internal/ui/page_locker_room_test.go +++ b/internal/ui/page_locker_room_test.go @@ -6,6 +6,7 @@ import ( 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" ) func TestModelLockerRoom_Selection(t *testing.T) { @@ -13,7 +14,8 @@ func TestModelLockerRoom_Selection(t *testing.T) { group.Test("should route to locker players when players item is selected", func(t *testing.T) { state := &GlobalState{} - m := NewModelLockerRoom(state) + theme := styles.NewIceTheme() + m := NewModelLockerRoom(state, theme) // Ensure ItemPlayers is selected (it is by default) odize.AssertEqual(t, components.ItemPlayers, m.list.SelectedItem()) @@ -33,7 +35,8 @@ func TestModelLockerRoom_Selection(t *testing.T) { group.Test("should route to locker playbooks when playbooks item is selected", func(t *testing.T) { state := &GlobalState{} - m := NewModelLockerRoom(state) + theme := styles.NewIceTheme() + m := NewModelLockerRoom(state, theme) // Select Playbooks (it's the second item) m.Update(tea.KeyPressMsg{Text: "down"}) diff --git a/internal/ui/page_new_battle_selection.go b/internal/ui/page_new_battle_selection.go index f578f06..5a2f7bf 100644 --- a/internal/ui/page_new_battle_selection.go +++ b/internal/ui/page_new_battle_selection.go @@ -75,18 +75,18 @@ type ModelNewBattleSelection struct { err error } -func NewModelNewBattleSelection(globalState *GlobalState, teamsSvc *teams.Service, playbookSvc *playbooks.Service, gameSvc *games.Service) *ModelNewBattleSelection { +func NewModelNewBattleSelection(globalState *GlobalState, teamsSvc *teams.Service, playbookSvc *playbooks.Service, gameSvc *games.Service, theme styles.IceTheme) *ModelNewBattleSelection { keys := newBattleSelectionKeyMap() return &ModelNewBattleSelection{ globalState: globalState, teamsSvc: teamsSvc, playbookSvc: playbookSvc, gameSvc: gameSvc, - theme: styles.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), } } @@ -260,11 +260,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), ), ) @@ -278,9 +278,9 @@ func (m *ModelNewBattleSelection) View() tea.View { func (m *ModelNewBattleSelection) viewSelection() string { return lipgloss.JoinHorizontal(lipgloss.Top, - m.playbookList.View(), + m.playbookList.View(m.theme), lipgloss.NewStyle().Width(2).Render(""), - m.aiTeamList.View(), + m.aiTeamList.View(m.theme), ) } diff --git a/internal/ui/page_new_battle_selection_test.go b/internal/ui/page_new_battle_selection_test.go index 1b0e0b9..7bf331a 100644 --- a/internal/ui/page_new_battle_selection_test.go +++ b/internal/ui/page_new_battle_selection_test.go @@ -8,6 +8,7 @@ import ( "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" ) func TestModelNewBattleSelection_Rendering(t *testing.T) { @@ -17,8 +18,9 @@ func TestModelNewBattleSelection_Rendering(t *testing.T) { state := &GlobalState{ Team: &teams.Team{ID: 1, Name: "My Team"}, } + theme := styles.NewIceTheme() - m := NewModelNewBattleSelection(state, nil, nil, nil) + m := NewModelNewBattleSelection(state, nil, nil, nil, theme) m.width = 100 m.height = 40 m.Update(tea.WindowSizeMsg{Width: 100, Height: 40}) @@ -49,7 +51,8 @@ func TestModelNewBattleSelection_Rendering(t *testing.T) { state := &GlobalState{ Team: &teams.Team{ID: 1, Name: "My Team"}, } - m := NewModelNewBattleSelection(state, nil, nil, nil) + theme := styles.NewIceTheme() + m := NewModelNewBattleSelection(state, nil, nil, nil, theme) m.Update(tea.WindowSizeMsg{Width: 100, Height: 40}) m.Update(msgDataLoaded{ playbooks: []playbooks.Playbook{{ID: 1, Name: "Playbook 1"}}, @@ -84,7 +87,8 @@ func TestModelNewBattleSelection_Rendering(t *testing.T) { group.Test("should handle back navigation to title", func(t *testing.T) { state := &GlobalState{} - m := NewModelNewBattleSelection(state, nil, nil, nil) + theme := styles.NewIceTheme() + m := NewModelNewBattleSelection(state, nil, nil, nil, theme) _, cmd := m.Update(tea.KeyPressMsg{Text: "esc"}) @@ -102,7 +106,8 @@ func TestModelNewBattleSelection_Rendering(t *testing.T) { state := &GlobalState{ Team: &teams.Team{ID: 1, Name: "My Team"}, } - m := NewModelNewBattleSelection(state, nil, nil, nil) + theme := styles.NewIceTheme() + m := NewModelNewBattleSelection(state, nil, nil, nil, theme) // 1. Set some state m.state = stateConfirming diff --git a/internal/ui/page_new_tournament.go b/internal/ui/page_new_tournament.go index 622be05..0458dec 100644 --- a/internal/ui/page_new_tournament.go +++ b/internal/ui/page_new_tournament.go @@ -12,9 +12,10 @@ type ModelNewTournament struct { globalState *GlobalState } -func NewModelNewTournament(globalState *GlobalState) *ModelNewTournament { +func NewModelNewTournament(globalState *GlobalState, theme styles.IceTheme) *ModelNewTournament { return &ModelNewTournament{ globalState: globalState, + theme: theme, } } diff --git a/internal/ui/page_title.go b/internal/ui/page_title.go index 9644b59..b6eea82 100644 --- a/internal/ui/page_title.go +++ b/internal/ui/page_title.go @@ -46,13 +46,14 @@ type ModelTitle struct { menu components.TitleMenu } -func NewModelTitle(globalState *GlobalState) *ModelTitle { +func NewModelTitle(globalState *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, } } @@ -120,7 +121,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, @@ -128,7 +129,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 index 3d3b344..f281f66 100644 --- a/internal/ui/page_title_settings.go +++ b/internal/ui/page_title_settings.go @@ -12,9 +12,10 @@ type ModelTitleSettings struct { globalState *GlobalState } -func NewModelTitleSettings(globalState *GlobalState) *ModelTitleSettings { +func NewModelTitleSettings(globalState *GlobalState, theme styles.IceTheme) *ModelTitleSettings { return &ModelTitleSettings{ globalState: globalState, + theme: theme, } } diff --git a/internal/ui/page_title_test.go b/internal/ui/page_title_test.go index 4d7a84a..15c6879 100644 --- a/internal/ui/page_title_test.go +++ b/internal/ui/page_title_test.go @@ -6,6 +6,7 @@ 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" ) func TestModelTitle(t *testing.T) { @@ -13,7 +14,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(&GlobalState{Coach: nil}, theme) m.width = 100 m.height = 50 @@ -29,7 +31,8 @@ func TestModelTitle(t *testing.T) { } }). 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(&GlobalState{Coach: &teams.Coach{Name: "Coach Carter"}}, theme) m.width = 100 m.height = 50 diff --git a/internal/ui/styles/theme.go b/internal/ui/styles/theme.go index e78c248..4801f1b 100644 --- a/internal/ui/styles/theme.go +++ b/internal/ui/styles/theme.go @@ -4,14 +4,30 @@ 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 + 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. @@ -21,6 +37,11 @@ func NewIceTheme() IceTheme { 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(). @@ -48,8 +69,64 @@ func NewIceTheme() IceTheme { Foreground(iceBlue). Bold(true), CoachName: lipgloss.NewStyle(). - Foreground(lipgloss.Color("#555555")). + 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). + BorderForeground(iceBlue), + SelectedDesc: lipgloss.NewStyle(). + Foreground(skyBlue). + BorderForeground(iceBlue), + 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), + RoundBorder: lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(skyBlue). + Padding(1), } } From 9b4d5d2ce1de46066ddf1b8c5fdb3b81c3c4860d Mon Sep 17 00:00:00 2001 From: frag223 Date: Mon, 29 Jun 2026 22:04:16 +1000 Subject: [PATCH 04/72] wip --- internal/ui/components/ai_team_list.go | 14 +++++++------ internal/ui/components/formation_list.go | 12 ++++++----- internal/ui/components/game_test.go | 2 +- internal/ui/components/locker_room_list.go | 4 ++-- internal/ui/components/playbook_list.go | 12 ++++++----- internal/ui/components/player_list.go | 4 ++-- internal/ui/components/title_menu.go | 4 ++-- internal/ui/page_locker_playbooks_edit.go | 21 ++++++++++++++++++-- internal/ui/page_new_battle_selection.go | 23 +++++++++++++++++++--- internal/ui/styles/theme.go | 13 ++++++++---- 10 files changed, 77 insertions(+), 32 deletions(-) diff --git a/internal/ui/components/ai_team_list.go b/internal/ui/components/ai_team_list.go index 57a8d2e..afa3e3d 100644 --- a/internal/ui/components/ai_team_list.go +++ b/internal/ui/components/ai_team_list.go @@ -27,13 +27,18 @@ func NewAITeamList(items []teams.AITeam, theme styles.IceTheme) AITeamList { } 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(listItems, delegate, 0, 0) - l.Title = "AI Teams" + l.Title = "" l.SetShowStatusBar(false) l.SetFilteringEnabled(true) + l.SetShowHelp(false) l.Styles.Title = theme.Title return AITeamList{ @@ -49,17 +54,14 @@ func (l *AITeamList) Update(msg tea.Msg) (AITeamList, tea.Cmd) { } func (l *AITeamList) View(theme styles.IceTheme) string { - if l.active { - return theme.ActiveBorder.Render(l.list.View()) - } - return theme.InactiveBorder.Render(l.list.View()) + return l.list.View() } func (l *AITeamList) SetActive(active bool) { l.active = active } -func (l *AITeamList) SelectedAITeam() *teams.AITeam { +func (l *AITeamList) SelectedItem() *teams.AITeam { if item, ok := l.list.SelectedItem().(AITeamItem); ok { return &item.team } diff --git a/internal/ui/components/formation_list.go b/internal/ui/components/formation_list.go index f5f37a9..bca8a3d 100644 --- a/internal/ui/components/formation_list.go +++ b/internal/ui/components/formation_list.go @@ -47,13 +47,18 @@ func NewFormationList(config FormationListConfig, theme styles.IceTheme) Formati } 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.Title = "" l.SetShowStatusBar(false) l.SetFilteringEnabled(config.EnableFiltering) + l.SetShowHelp(false) l.Styles.Title = theme.Title return FormationList{ @@ -71,10 +76,7 @@ func (l *FormationList) Update(msg tea.Msg) (FormationList, tea.Cmd) { } func (l *FormationList) View(theme styles.IceTheme) string { - if l.active { - return theme.ActiveBorder.Render(l.list.View()) - } - return theme.InactiveBorder.Render(l.list.View()) + return l.list.View() } func (l *FormationList) SelectedItem() playbooks.Formation { diff --git a/internal/ui/components/game_test.go b/internal/ui/components/game_test.go index f70380d..700da68 100644 --- a/internal/ui/components/game_test.go +++ b/internal/ui/components/game_test.go @@ -64,7 +64,7 @@ func TestGameComponent(t *testing.T) { odize.AssertFalse(t, gComp.resolved) 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, "Dual in progress...")) // Handle MsgResolveRound cmd := gComp.Update(MsgResolveRound{}) diff --git a/internal/ui/components/locker_room_list.go b/internal/ui/components/locker_room_list.go index df9b896..87650cb 100644 --- a/internal/ui/components/locker_room_list.go +++ b/internal/ui/components/locker_room_list.go @@ -56,9 +56,9 @@ func (l *LockerRoomList) View(theme styles.IceTheme) string { var s string for i, item := range l.items { if i == l.cursor { - s += theme.ListSelected.Render("> " + item.String()) + s += theme.ListSelected.Render("> " + item.String()) } else { - s += theme.Base.Render(" " + item.String()) + s += theme.Base.Render(" " + item.String()) } if i < len(l.items)-1 { s += "\n" diff --git a/internal/ui/components/playbook_list.go b/internal/ui/components/playbook_list.go index 6569846..0bef940 100644 --- a/internal/ui/components/playbook_list.go +++ b/internal/ui/components/playbook_list.go @@ -27,13 +27,18 @@ func NewPlaybookList(items []playbooks.Playbook, theme styles.IceTheme) Playbook } 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(listItems, delegate, 0, 0) - l.Title = "Playbooks" + l.Title = "" l.SetShowStatusBar(false) l.SetFilteringEnabled(true) + l.SetShowHelp(false) l.Styles.Title = theme.Title return PlaybookList{ @@ -49,10 +54,7 @@ func (l *PlaybookList) Update(msg tea.Msg) (PlaybookList, tea.Cmd) { } func (l *PlaybookList) View(theme styles.IceTheme) string { - if l.active { - return theme.ActiveBorder.Render(l.list.View()) - } - return theme.InactiveBorder.Render(l.list.View()) + return l.list.View() } func (l *PlaybookList) SetActive(active bool) { diff --git a/internal/ui/components/player_list.go b/internal/ui/components/player_list.go index 1041578..ea0c191 100644 --- a/internal/ui/components/player_list.go +++ b/internal/ui/components/player_list.go @@ -95,9 +95,9 @@ func (l *PlayerList) View(theme styles.IceTheme) string { } if i == l.cursor { - s += theme.ListSelected.Render("> " + content) + s += theme.ListSelected.Render("> " + content) } else { - s += theme.Base.Render(" " + content) + s += theme.Base.Render(" " + content) } if i < len(l.Items)-1 { s += "\n" diff --git a/internal/ui/components/title_menu.go b/internal/ui/components/title_menu.go index 78d31c9..445ef1c 100644 --- a/internal/ui/components/title_menu.go +++ b/internal/ui/components/title_menu.go @@ -68,9 +68,9 @@ func (m *TitleMenu) View(theme styles.IceTheme) string { var s string for i, item := range m.items { if i == m.cursor { - s += theme.ListSelected.Render("> " + item.String()) + s += theme.ListSelected.Render("> " + item.String()) } else { - s += theme.Base.Render(" " + item.String()) + s += theme.Base.Render(" " + item.String()) } if i < len(m.items)-1 { s += "\n" diff --git a/internal/ui/page_locker_playbooks_edit.go b/internal/ui/page_locker_playbooks_edit.go index e4c1336..39e8bf4 100644 --- a/internal/ui/page_locker_playbooks_edit.go +++ b/internal/ui/page_locker_playbooks_edit.go @@ -247,11 +247,28 @@ func (m *ModelLockerPlaybooksEdit) View() tea.View { m.formationList.SetSize(m.width/2-4, m.height-15) m.selectedFormationList.SetSize(m.width/2-4, m.height-15) + availableView := m.formationList.View(m.theme) + selectedView := m.selectedFormationList.View(m.theme) + + if m.activeList == 0 { + 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, - m.formationList.View(m.theme), + lipgloss.JoinVertical(lipgloss.Left, + m.theme.SecondaryHeader.Render("AVAILABLE FORMATIONS"), + availableView, + ), lipgloss.NewStyle().Width(2).Render(""), - m.selectedFormationList.View(m.theme), + lipgloss.JoinVertical(lipgloss.Left, + m.theme.SecondaryHeader.Render("SELECTED FORMATIONS (MAX 10)"), + selectedView, + ), ) content += "\n\n" + m.theme.Muted.Render(fmt.Sprintf("%d/10 formations • Tab: switch • Enter: add/remove • 's': save", len(m.newFormations))) } diff --git a/internal/ui/page_new_battle_selection.go b/internal/ui/page_new_battle_selection.go index 5a2f7bf..3c0f1d9 100644 --- a/internal/ui/page_new_battle_selection.go +++ b/internal/ui/page_new_battle_selection.go @@ -177,7 +177,7 @@ 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 } @@ -277,10 +277,27 @@ func (m *ModelNewBattleSelection) View() tea.View { } func (m *ModelNewBattleSelection) 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(m.theme), + lipgloss.JoinVertical(lipgloss.Left, + m.theme.SecondaryHeader.Render("PLAYBOOKS"), + playbookView, + ), lipgloss.NewStyle().Width(2).Render(""), - m.aiTeamList.View(m.theme), + lipgloss.JoinVertical(lipgloss.Left, + m.theme.SecondaryHeader.Render("AI TEAMS"), + aiTeamView, + ), ) } diff --git a/internal/ui/styles/theme.go b/internal/ui/styles/theme.go index 4801f1b..15e797c 100644 --- a/internal/ui/styles/theme.go +++ b/internal/ui/styles/theme.go @@ -51,7 +51,6 @@ func NewIceTheme() IceTheme { Foreground(skyBlue). Italic(true), Base: lipgloss.NewStyle(). - Background(black). Foreground(white), Button: lipgloss.NewStyle(). Foreground(black). @@ -97,10 +96,15 @@ func NewIceTheme() IceTheme { Foreground(grey), SelectedTitle: lipgloss.NewStyle(). Foreground(iceBlue). - BorderForeground(iceBlue), + Bold(true). + Border(lipgloss.NormalBorder(), false, false, false, true). + BorderForeground(iceBlue). + PaddingLeft(2), SelectedDesc: lipgloss.NewStyle(). Foreground(skyBlue). - BorderForeground(iceBlue), + Border(lipgloss.NormalBorder(), false, false, false, true). + BorderForeground(iceBlue). + PaddingLeft(2), TeamA: lipgloss.NewStyle(). Foreground(iceBlue). Bold(true). @@ -123,7 +127,8 @@ func NewIceTheme() IceTheme { Bold(true), SecondaryHeader: lipgloss.NewStyle(). Foreground(white). - Bold(true), + Bold(true). + MarginBottom(1), RoundBorder: lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). BorderForeground(skyBlue). From 9c39e9c53165823dd96dafa98eb9f97cb4ea3458 Mon Sep 17 00:00:00 2001 From: frag223 Date: Mon, 29 Jun 2026 22:17:49 +1000 Subject: [PATCH 05/72] wip --- internal/ui/components/ai_team_list.go | 83 +++--------- internal/ui/components/formation_list.go | 113 +++++------------ internal/ui/components/list.go | 118 ++++++++++++++++++ internal/ui/components/locker_room_list.go | 48 +++---- .../ui/components/locker_room_list_test.go | 30 ++--- internal/ui/components/playbook_list.go | 83 +++--------- internal/ui/page_locker_room.go | 2 +- 7 files changed, 219 insertions(+), 258 deletions(-) create mode 100644 internal/ui/components/list.go diff --git a/internal/ui/components/ai_team_list.go b/internal/ui/components/ai_team_list.go index afa3e3d..4d5dff5 100644 --- a/internal/ui/components/ai_team_list.go +++ b/internal/ui/components/ai_team_list.go @@ -1,98 +1,53 @@ package components import ( - "charm.land/bubbles/v2/list" tea "charm.land/bubbletea/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, theme styles.IceTheme) AITeamList { - listItems := make([]list.Item, len(items)) - for i, item := range items { - listItems[i] = AITeamItem{team: 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(listItems, delegate, 0, 0) - l.Title = "" - l.SetShowStatusBar(false) - l.SetFilteringEnabled(true) - l.SetShowHelp(false) - l.Styles.Title = theme.Title - 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, + }, 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(theme styles.IceTheme) string { - return l.list.View() -} - -func (l *AITeamList) SetActive(active bool) { - l.active = active + return l.List.View() } func (l *AITeamList) SelectedItem() *teams.AITeam { - if item, ok := l.list.SelectedItem().(AITeamItem); ok { - return &item.team + 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) Reset() { - l.list.Select(0) - l.list.FilterInput.Reset() -} - -func (l *AITeamList) IsFiltering() bool { - return l.list.FilterState() == list.Filtering + l.Model.Title = title } diff --git a/internal/ui/components/formation_list.go b/internal/ui/components/formation_list.go index bca8a3d..4c4a239 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" "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 { @@ -38,89 +21,51 @@ type FormationListConfig struct { } func NewFormationList(config FormationListConfig, theme styles.IceTheme) 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() - 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 = "" - l.SetShowStatusBar(false) - l.SetFilteringEnabled(config.EnableFiltering) - l.SetShowHelp(false) - l.Styles.Title = theme.Title - - return FormationList{ - list: l, + l := FormationList{ + List: NewList(ListConfig[playbooks.Formation]{ + Title: config.Title, + Items: config.Items, + EnableFiltering: config.EnableFiltering, + 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(theme styles.IceTheme) string { - return l.list.View() + 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/list.go b/internal/ui/components/list.go new file mode 100644 index 0000000..01b9936 --- /dev/null +++ b/internal/ui/components/list.go @@ -0,0 +1,118 @@ +package components + +import ( + "charm.land/bubbles/v2/list" + tea "charm.land/bubbletea/v2" + "github.com/code-gorilla-au/rush/internal/ui/styles" +) + +type List[T any] struct { + Model list.Model + Active bool + ItemMapper func(T) ListItem[T] +} + +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 +} + +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.SetShowStatusBar(false) + l.SetFilteringEnabled(config.EnableFiltering) + l.SetShowHelp(false) + l.Styles.Title = theme.Title + + return List[T]{ + Model: l, + Active: true, + ItemMapper: config.ItemMapper, + } +} + +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) + return *l, cmd +} + +func (l *List[T]) View() string { + return l.Model.View() +} + +func (l *List[T]) SetSize(width, height int) { + l.Model.SetSize(width, height) +} + +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 +} diff --git a/internal/ui/components/locker_room_list.go b/internal/ui/components/locker_room_list.go index 87650cb..0f12d77 100644 --- a/internal/ui/components/locker_room_list.go +++ b/internal/ui/components/locker_room_list.go @@ -26,47 +26,35 @@ func (i LockerRoomItem) String() string { } 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, ItemPlaybooks, ItemSettings}, + ItemMapper: func(i LockerRoomItem) ListItem[LockerRoomItem] { + return ListItem[LockerRoomItem]{ + Data: i, + TitleVal: i.String(), + } + }, + EnableFiltering: false, + }, 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++ - } - } - } + l.List.Update(msg) } func (l *LockerRoomList) View(theme styles.IceTheme) string { - var s string - for i, item := range l.items { - if i == l.cursor { - s += theme.ListSelected.Render("> " + item.String()) - } else { - s += theme.Base.Render(" " + item.String()) - } - if i < len(l.items)-1 { - s += "\n" - } - } - return s + 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..bc32c7b 100644 --- a/internal/ui/components/locker_room_list_test.go +++ b/internal/ui/components/locker_room_list_test.go @@ -5,46 +5,46 @@ 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.cursor) + odize.AssertEqual(t, 1, 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 0bef940..7409d13 100644 --- a/internal/ui/components/playbook_list.go +++ b/internal/ui/components/playbook_list.go @@ -1,98 +1,53 @@ package components import ( - "charm.land/bubbles/v2/list" tea "charm.land/bubbletea/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, theme styles.IceTheme) PlaybookList { - listItems := make([]list.Item, len(items)) - for i, item := range items { - listItems[i] = PlaybookItem{playbook: 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(listItems, delegate, 0, 0) - l.Title = "" - l.SetShowStatusBar(false) - l.SetFilteringEnabled(true) - l.SetShowHelp(false) - l.Styles.Title = theme.Title - 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, + }, 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(theme styles.IceTheme) string { - return l.list.View() -} - -func (l *PlaybookList) SetActive(active bool) { - l.active = active + 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) Reset() { - l.list.Select(0) - l.list.FilterInput.Reset() -} - -func (l *PlaybookList) IsFiltering() bool { - return l.list.FilterState() == list.Filtering + l.Model.Title = title } diff --git a/internal/ui/page_locker_room.go b/internal/ui/page_locker_room.go index 44a75cf..0ef3921 100644 --- a/internal/ui/page_locker_room.go +++ b/internal/ui/page_locker_room.go @@ -56,7 +56,7 @@ func NewModelLockerRoom(globalState *GlobalState, theme styles.IceTheme) *ModelL keys: keys, footer: components.NewFooter(keys), theme: theme, - list: components.NewLockerRoomList(), + list: components.NewLockerRoomList(theme), } } From 82e26581c9a1dcaae4902134f1be8de017ea43b4 Mon Sep 17 00:00:00 2001 From: frag223 Date: Mon, 29 Jun 2026 22:55:31 +1000 Subject: [PATCH 06/72] fixing list --- internal/ui/app.go | 2 +- internal/ui/components/locker_room_list.go | 6 ++++-- internal/ui/components/title_menu.go | 23 +++++++++------------- internal/ui/page_locker_room.go | 9 +++++++-- internal/ui/page_title.go | 19 +++++++++++++++--- 5 files changed, 37 insertions(+), 22 deletions(-) diff --git a/internal/ui/app.go b/internal/ui/app.go index cf6d4d7..3b1f254 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -114,7 +114,7 @@ func (m *RootModel) Init() tea.Cmd { team, err := m.teamsSvc.GetTeamByCoachID(m.ctx, coach.ID) if err != nil { - return MsgStateUpdated{Coach: nil} + return MsgStateUpdated{Coach: &coach, Team: nil} } return MsgStateUpdated{Coach: &coach, Team: &team} diff --git a/internal/ui/components/locker_room_list.go b/internal/ui/components/locker_room_list.go index 0f12d77..956856f 100644 --- a/internal/ui/components/locker_room_list.go +++ b/internal/ui/components/locker_room_list.go @@ -44,8 +44,10 @@ func NewLockerRoomList(theme styles.IceTheme) LockerRoomList { } } -func (l *LockerRoomList) Update(msg tea.Msg) { - l.List.Update(msg) +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(theme styles.IceTheme) string { diff --git a/internal/ui/components/title_menu.go b/internal/ui/components/title_menu.go index 445ef1c..fdd10b6 100644 --- a/internal/ui/components/title_menu.go +++ b/internal/ui/components/title_menu.go @@ -1,7 +1,6 @@ package components import ( - tea "charm.land/bubbletea/v2" "github.com/code-gorilla-au/rush/internal/ui/styles" ) @@ -48,19 +47,15 @@ func NewTitleMenu(hasCoach bool) TitleMenu { } } -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++ } } diff --git a/internal/ui/page_locker_room.go b/internal/ui/page_locker_room.go index 0ef3921..76d1a6d 100644 --- a/internal/ui/page_locker_room.go +++ b/internal/ui/page_locker_room.go @@ -65,7 +65,7 @@ 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: @@ -94,10 +94,15 @@ func (m *ModelLockerRoom) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 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 { diff --git a/internal/ui/page_title.go b/internal/ui/page_title.go index b6eea82..770505b 100644 --- a/internal/ui/page_title.go +++ b/internal/ui/page_title.go @@ -13,22 +13,32 @@ import ( type titleKeyMap struct { components.CommonKeys + Up key.Binding + Down key.Binding Enter key.Binding } 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(), + Up: key.NewBinding( + key.WithKeys("up", "k"), + key.WithHelp("↑/k", "up"), + ), + Down: key.NewBinding( + key.WithKeys("down", "j"), + key.WithHelp("↓/j", "down"), + ), Enter: key.NewBinding( key.WithKeys("enter"), key.WithHelp("enter", "select"), @@ -71,6 +81,10 @@ func (m *ModelTitle) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch { case key.Matches(vMsg, m.keys.Quit): return m, tea.Quit + case key.Matches(vMsg, m.keys.Up): + m.menu.MoveUp() + case key.Matches(vMsg, m.keys.Down): + m.menu.MoveDown() case key.Matches(vMsg, m.keys.Enter): selected := m.menu.SelectedItem() switch selected { @@ -96,7 +110,6 @@ func (m *ModelTitle) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } } - m.menu.Update(vMsg) case tea.WindowSizeMsg: m.width = vMsg.Width m.height = vMsg.Height From 6b41ca7837b5f87e6fcc0357e7df860560e35294 Mon Sep 17 00:00:00 2001 From: frag223 Date: Tue, 30 Jun 2026 06:51:02 +1000 Subject: [PATCH 07/72] fixing theme --- internal/ui/styles/theme.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/ui/styles/theme.go b/internal/ui/styles/theme.go index 15e797c..9b91af8 100644 --- a/internal/ui/styles/theme.go +++ b/internal/ui/styles/theme.go @@ -101,7 +101,7 @@ func NewIceTheme() IceTheme { BorderForeground(iceBlue). PaddingLeft(2), SelectedDesc: lipgloss.NewStyle(). - Foreground(skyBlue). + Foreground(mutedGrey). Border(lipgloss.NormalBorder(), false, false, false, true). BorderForeground(iceBlue). PaddingLeft(2), From 15f9dfd78c8133d5d7b3dd1ea54bd94b3ad00f6b Mon Sep 17 00:00:00 2001 From: frag223 Date: Tue, 30 Jun 2026 07:23:20 +1000 Subject: [PATCH 08/72] wip migrating --- internal/ui/app.go | 106 ++++++------------ internal/ui/locker.go | 113 ++++++++++++++++++++ internal/ui/locker_test.go | 34 ++++++ internal/ui/page_locker_playbooks_create.go | 10 +- internal/ui/page_locker_playbooks_edit.go | 10 +- internal/ui/page_locker_playbooks_list.go | 10 +- internal/ui/page_locker_players.go | 4 +- internal/ui/page_locker_room.go | 4 +- internal/ui/page_locker_room_test.go | 10 +- 9 files changed, 206 insertions(+), 95 deletions(-) create mode 100644 internal/ui/locker.go create mode 100644 internal/ui/locker_test.go diff --git a/internal/ui/app.go b/internal/ui/app.go index 3b1f254..41b8323 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -27,10 +27,6 @@ const ( PageTitle Page = iota + 1 PageCreateCoach PageLockerRoom - PageLockerPlayers - PageLockerPlaybooksList - PageLockerPlaybooksCreate - PageLockerPlaybooksEdit PageNewTournament PageNewBattleSelection PageTitleSettings @@ -48,27 +44,23 @@ func (m *GlobalState) Context() context.Context { } type RootModel struct { - ctx context.Context - width int - height int - theme styles.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 Page + pageTitle tea.Model + pageCreateCoach tea.Model + pageLocker 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 } type Dependencies struct { @@ -83,25 +75,21 @@ func New(deps Dependencies) *RootModel { theme := styles.NewIceTheme() return &RootModel{ - ctx: context.Background(), - theme: theme, - currentPage: PageTitle, - pageTitle: NewModelTitle(state, theme), - pageCreateCoach: NewModelCreateCoach(state, deps.TeamsSvc, theme), - pageLockerRoom: NewModelLockerRoom(state, theme), - pageLockerPlayers: NewModelLockerPlayers(state, deps.TeamsSvc, theme), - pageLockerPlaybooksList: NewModelLockerPlaybooksList(state, deps.PlaybookSvc, theme), - pageLockerPlaybooksCreate: NewModelLockerPlaybooksCreate(state, deps.PlaybookSvc, theme), - pageLockerPlaybooksEdit: NewModelLockerPlaybooksEdit(state, deps.PlaybookSvc, theme), - pageNewTournament: NewModelNewTournament(state, theme), - pageNewBattleSelection: NewModelNewBattleSelection(state, deps.TeamsSvc, deps.PlaybookSvc, deps.GameSvc, theme), - pageTitleSettings: NewModelTitleSettings(state, theme), - pageGame: NewModelGame(state, deps.GameSvc, theme), - pageGameComplete: NewPageGameComplete(state, deps.TeamsSvc, deps.GameSvc, theme), - globalState: state, - teamsSvc: deps.TeamsSvc, - playbookSvc: deps.PlaybookSvc, - gameSvc: deps.GameSvc, + ctx: context.Background(), + theme: theme, + currentPage: PageTitle, + pageTitle: NewModelTitle(state, theme), + pageCreateCoach: NewModelCreateCoach(state, deps.TeamsSvc, theme), + pageLocker: NewLockerModel(state, deps.TeamsSvc, deps.PlaybookSvc, theme), + pageNewTournament: NewModelNewTournament(state, theme), + pageNewBattleSelection: NewModelNewBattleSelection(state, deps.TeamsSvc, deps.PlaybookSvc, deps.GameSvc, theme), + pageTitleSettings: NewModelTitleSettings(state, theme), + pageGame: NewModelGame(state, deps.GameSvc, theme), + pageGameComplete: NewPageGameComplete(state, deps.TeamsSvc, deps.GameSvc, theme), + globalState: state, + teamsSvc: deps.TeamsSvc, + playbookSvc: deps.PlaybookSvc, + gameSvc: deps.GameSvc, } } @@ -159,15 +147,7 @@ func (m *RootModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds = append(cmds, cmd) m.pageCreateCoach, cmd = m.pageCreateCoach.Update(msg) cmds = append(cmds, cmd) - m.pageLockerRoom, cmd = m.pageLockerRoom.Update(msg) - cmds = append(cmds, cmd) - m.pageLockerPlayers, cmd = m.pageLockerPlayers.Update(msg) - cmds = append(cmds, cmd) - m.pageLockerPlaybooksList, cmd = m.pageLockerPlaybooksList.Update(msg) - cmds = append(cmds, cmd) - m.pageLockerPlaybooksCreate, cmd = m.pageLockerPlaybooksCreate.Update(msg) - cmds = append(cmds, cmd) - m.pageLockerPlaybooksEdit, cmd = m.pageLockerPlaybooksEdit.Update(msg) + m.pageLocker, cmd = m.pageLocker.Update(msg) cmds = append(cmds, cmd) m.pageNewTournament, cmd = m.pageNewTournament.Update(msg) cmds = append(cmds, cmd) @@ -189,15 +169,7 @@ func (m *RootModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case PageCreateCoach: m.pageCreateCoach, cmd = m.pageCreateCoach.Update(msg) case PageLockerRoom: - m.pageLockerRoom, cmd = m.pageLockerRoom.Update(msg) - case PageLockerPlayers: - m.pageLockerPlayers, cmd = m.pageLockerPlayers.Update(msg) - case PageLockerPlaybooksList: - m.pageLockerPlaybooksList, cmd = m.pageLockerPlaybooksList.Update(msg) - case PageLockerPlaybooksCreate: - m.pageLockerPlaybooksCreate, cmd = m.pageLockerPlaybooksCreate.Update(msg) - case PageLockerPlaybooksEdit: - m.pageLockerPlaybooksEdit, cmd = m.pageLockerPlaybooksEdit.Update(msg) + m.pageLocker, cmd = m.pageLocker.Update(msg) case PageNewTournament: m.pageNewTournament, cmd = m.pageNewTournament.Update(msg) case PageNewBattleSelection: @@ -225,15 +197,7 @@ func (m *RootModel) View() tea.View { case PageCreateCoach: return m.pageCreateCoach.View() case PageLockerRoom: - return m.pageLockerRoom.View() - case PageLockerPlayers: - return m.pageLockerPlayers.View() - case PageLockerPlaybooksList: - return m.pageLockerPlaybooksList.View() - case PageLockerPlaybooksCreate: - return m.pageLockerPlaybooksCreate.View() - case PageLockerPlaybooksEdit: - return m.pageLockerPlaybooksEdit.View() + return m.pageLocker.View() case PageNewTournament: return m.pageNewTournament.View() case PageNewBattleSelection: diff --git a/internal/ui/locker.go b/internal/ui/locker.go new file mode 100644 index 0000000..5ed9a73 --- /dev/null +++ b/internal/ui/locker.go @@ -0,0 +1,113 @@ +package ui + +import ( + tea "charm.land/bubbletea/v2" + "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" +) + +type SubPageLocker int + +const ( + SubPageLockerRoom SubPageLocker = iota + SubPageLockerPlayers + 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 + subPageLocker tea.Model + subPageLockerRoom tea.Model + subPageLockerPlayers tea.Model + subPageLockerPlaybooksList tea.Model + subPageLockerPlaybooksCreate tea.Model + subPageLockerPlaybooksEdit tea.Model + globalState *GlobalState + theme styles.IceTheme +} + +// NewLockerModel returns a new LockerModel. +func NewLockerModel(state *GlobalState, teamsSvc *teams.Service, playbookSvc *playbooks.Service, theme styles.IceTheme) *LockerModel { + return &LockerModel{ + globalState: state, + theme: theme, + subPageLockerRoom: NewModelLockerRoom(state, theme), + subPageLockerPlayers: NewModelLockerPlayers(state, teamsSvc, 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) { + var cmds []tea.Cmd + + switch msg := msg.(type) { + case MsgSwitchLockerPage: + switch msg.NewPage { + case SubPageLockerRoom, SubPageLockerPlayers, SubPageLockerPlaybooksList, SubPageLockerPlaybooksCreate, SubPageLockerPlaybooksEdit: + m.currentPage = msg.NewPage + } + + case tea.WindowSizeMsg: + 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.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) + } + + 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 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) + } + cmds = append(cmds, cmd) + + return m, tea.Batch(cmds...) +} + +func (m *LockerModel) View() tea.View { + switch m.currentPage { + case SubPageLockerRoom: + return m.subPageLockerRoom.View() + case SubPageLockerPlayers: + return m.subPageLockerPlayers.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/locker_test.go b/internal/ui/locker_test.go new file mode 100644 index 0000000..461801b --- /dev/null +++ b/internal/ui/locker_test.go @@ -0,0 +1,34 @@ +package ui + +import ( + "testing" + + "github.com/code-gorilla-au/odize" + "github.com/code-gorilla-au/rush/internal/ui/styles" +) + +func TestLockerModel_SwitchPage(t *testing.T) { + group := odize.NewGroup(t, nil) + + state := &GlobalState{} + theme := styles.NewIceTheme() + ts, ps, _ := setupServices(t) + m := NewLockerModel(state, ts, ps, 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: 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(MsgSwitchPage{NewPage: PageTitle}) + odize.AssertEqual(t, SubPageLockerRoom, m.currentPage) + }) + + err := group.Run() + odize.AssertNoError(t, err) +} diff --git a/internal/ui/page_locker_playbooks_create.go b/internal/ui/page_locker_playbooks_create.go index 717bc3d..810e22c 100644 --- a/internal/ui/page_locker_playbooks_create.go +++ b/internal/ui/page_locker_playbooks_create.go @@ -77,8 +77,8 @@ func (m *ModelLockerPlaybooksCreate) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case MsgStateUpdated: m.globalState.Coach = msg.Coach m.globalState.Team = msg.Team - case MsgSwitchPage: - if msg.NewPage == PageLockerPlaybooksCreate { + case MsgSwitchLockerPage: + if msg.NewPage == SubPageLockerPlaybooksCreate { if msg.Playbook != nil { m.load(msg.Playbook) } else { @@ -93,14 +93,14 @@ func (m *ModelLockerPlaybooksCreate) 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: PageLockerPlaybooksList} + return MsgSwitchLockerPage{NewPage: SubPageLockerPlaybooksList} } case key.Matches(msg, m.keys.Enter): name, description := m.playbookForm.Values() if name != "" { return m, func() tea.Msg { - return MsgSwitchPage{ - NewPage: PageLockerPlaybooksEdit, + return MsgSwitchLockerPage{ + NewPage: SubPageLockerPlaybooksEdit, Playbook: &playbooks.Playbook{ ID: m.playbookID, Name: name, diff --git a/internal/ui/page_locker_playbooks_edit.go b/internal/ui/page_locker_playbooks_edit.go index 39e8bf4..b23288a 100644 --- a/internal/ui/page_locker_playbooks_edit.go +++ b/internal/ui/page_locker_playbooks_edit.go @@ -97,8 +97,8 @@ func (m *ModelLockerPlaybooksEdit) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case MsgStateUpdated: m.globalState.Coach = msg.Coach m.globalState.Team = msg.Team - case MsgSwitchPage: - if msg.NewPage == PageLockerPlaybooksEdit { + case MsgSwitchLockerPage: + if msg.NewPage == SubPageLockerPlaybooksEdit { if msg.Playbook != nil { m.load(msg.Playbook) } else { @@ -116,8 +116,8 @@ func (m *ModelLockerPlaybooksEdit) Update(msg tea.Msg) (tea.Model, tea.Cmd) { break } return m, func() tea.Msg { - return MsgSwitchPage{ - NewPage: PageLockerPlaybooksCreate, + return MsgSwitchLockerPage{ + NewPage: SubPageLockerPlaybooksCreate, Playbook: &playbooks.Playbook{ ID: m.playbookID, Name: m.playbookName, @@ -231,7 +231,7 @@ func (m *ModelLockerPlaybooksEdit) savePlaybook() tea.Msg { if err != nil { return err } - return MsgSwitchPage{NewPage: PageLockerPlaybooksList} + return MsgSwitchLockerPage{NewPage: SubPageLockerPlaybooksList} } func (m *ModelLockerPlaybooksEdit) View() tea.View { diff --git a/internal/ui/page_locker_playbooks_list.go b/internal/ui/page_locker_playbooks_list.go index c0eb143..c26a18f 100644 --- a/internal/ui/page_locker_playbooks_list.go +++ b/internal/ui/page_locker_playbooks_list.go @@ -103,8 +103,8 @@ func (m *ModelLockerPlaybooksList) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.playbooksLoaded = true m.playbookList = components.NewPlaybookList(msg.Playbooks, m.theme) m.playbookList.SetSize(m.width, m.height-10) - case MsgSwitchPage: - if msg.NewPage == PageLockerPlaybooksList { + case MsgSwitchLockerPage: + if msg.NewPage == SubPageLockerPlaybooksList { cmds = append(cmds, m.loadPlaybooks) } case error: @@ -128,7 +128,7 @@ func (m *ModelLockerPlaybooksList) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case key.Matches(msg, m.keys.New): if !m.playbookList.IsFiltering() { return m, func() tea.Msg { - return MsgSwitchPage{NewPage: PageLockerPlaybooksCreate} + return MsgSwitchLockerPage{NewPage: SubPageLockerPlaybooksCreate} } } case key.Matches(msg, m.keys.Delete): @@ -163,8 +163,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 diff --git a/internal/ui/page_locker_players.go b/internal/ui/page_locker_players.go index d516ea3..a77d7b5 100644 --- a/internal/ui/page_locker_players.go +++ b/internal/ui/page_locker_players.go @@ -85,8 +85,8 @@ func (m *ModelLockerPlayers) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.globalState.Team != nil { m.playerList = components.NewPlayerList(m.globalState.Team.Players) } - case MsgSwitchPage: - if msg.NewPage == PageLockerPlayers && m.globalState.Team != nil { + case MsgSwitchLockerPage: + if msg.NewPage == SubPageLockerPlayers && m.globalState.Team != nil { m.playerList = components.NewPlayerList(m.globalState.Team.Players) } case components.MsgPlayerUpdated: diff --git a/internal/ui/page_locker_room.go b/internal/ui/page_locker_room.go index 76d1a6d..7685e76 100644 --- a/internal/ui/page_locker_room.go +++ b/internal/ui/page_locker_room.go @@ -83,11 +83,11 @@ func (m *ModelLockerRoom) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch m.list.SelectedItem() { case components.ItemPlayers: return m, func() tea.Msg { - return MsgSwitchPage{NewPage: PageLockerPlayers} + return MsgSwitchLockerPage{NewPage: SubPageLockerPlayers} } case components.ItemPlaybooks: return m, func() tea.Msg { - return MsgSwitchPage{NewPage: PageLockerPlaybooksList} + return MsgSwitchLockerPage{NewPage: SubPageLockerPlaybooksList} } } } diff --git a/internal/ui/page_locker_room_test.go b/internal/ui/page_locker_room_test.go index 7a31367..06498c2 100644 --- a/internal/ui/page_locker_room_test.go +++ b/internal/ui/page_locker_room_test.go @@ -26,8 +26,8 @@ func TestModelLockerRoom_Selection(t *testing.T) { odize.AssertTrue(t, cmd != nil) msg := cmd() switch v := msg.(type) { - case MsgSwitchPage: - odize.AssertEqual(t, PageLockerPlayers, v.NewPage) + case MsgSwitchLockerPage: + odize.AssertEqual(t, SubPageLockerPlayers, v.NewPage) default: t.Fatalf("expected MsgSwitchPage, got %T", msg) } @@ -48,10 +48,10 @@ func TestModelLockerRoom_Selection(t *testing.T) { odize.AssertTrue(t, cmd != nil) msg := cmd() switch v := msg.(type) { - case MsgSwitchPage: - odize.AssertEqual(t, PageLockerPlaybooksList, v.NewPage) + case MsgSwitchLockerPage: + odize.AssertEqual(t, SubPageLockerPlaybooksList, v.NewPage) default: - t.Fatalf("expected MsgSwitchPage, got %T", msg) + t.Fatalf("expected MsgSwitchLockerPage, got %T", msg) } }) From 8ad7f1ac68cbfb0fa56851764841e03c350985a8 Mon Sep 17 00:00:00 2001 From: frag223 Date: Tue, 30 Jun 2026 17:00:11 +1000 Subject: [PATCH 09/72] fixing locker --- internal/ui/page_locker_players.go | 2 +- internal/ui/{locker.go => root_locker.go} | 0 internal/ui/{locker_test.go => root_locker_test.go} | 0 3 files changed, 1 insertion(+), 1 deletion(-) rename internal/ui/{locker.go => root_locker.go} (100%) rename internal/ui/{locker_test.go => root_locker_test.go} (100%) diff --git a/internal/ui/page_locker_players.go b/internal/ui/page_locker_players.go index a77d7b5..34ed099 100644 --- a/internal/ui/page_locker_players.go +++ b/internal/ui/page_locker_players.go @@ -97,7 +97,7 @@ func (m *ModelLockerPlayers) 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: PageLockerRoom} + return MsgSwitchLockerPage{NewPage: SubPageLockerRoom} } } case tea.WindowSizeMsg: diff --git a/internal/ui/locker.go b/internal/ui/root_locker.go similarity index 100% rename from internal/ui/locker.go rename to internal/ui/root_locker.go diff --git a/internal/ui/locker_test.go b/internal/ui/root_locker_test.go similarity index 100% rename from internal/ui/locker_test.go rename to internal/ui/root_locker_test.go From 32414d227514403ee37a2d5c5bdefbae9ece91cc Mon Sep 17 00:00:00 2001 From: frag223 Date: Tue, 30 Jun 2026 17:12:03 +1000 Subject: [PATCH 10/72] wip --- internal/ui/page_locker_playbooks_list.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/ui/page_locker_playbooks_list.go b/internal/ui/page_locker_playbooks_list.go index c26a18f..9156670 100644 --- a/internal/ui/page_locker_playbooks_list.go +++ b/internal/ui/page_locker_playbooks_list.go @@ -118,7 +118,7 @@ func (m *ModelLockerPlaybooksList) Update(msg tea.Msg) (tea.Model, tea.Cmd) { break } return m, func() tea.Msg { - return MsgSwitchPage{NewPage: PageLockerRoom} + return MsgSwitchLockerPage{NewPage: SubPageLockerRoom} } case key.Matches(msg, m.keys.Enter): model, cmd, done := m.handleRouteEditPlaybook() From de89cc650fdef558f9f4e3965978c17f38b8548f Mon Sep 17 00:00:00 2001 From: frag223 Date: Tue, 30 Jun 2026 20:36:16 +1000 Subject: [PATCH 11/72] extracting ui into sub packages --- internal/ui/app.go | 93 +++++++------------ internal/ui/app_test.go | 32 +------ internal/ui/page_create_coach.go | 15 +-- internal/ui/page_game.go | 11 ++- internal/ui/page_game_complete.go | 7 +- internal/ui/page_game_complete_test.go | 36 ++----- internal/ui/page_game_test.go | 3 +- internal/ui/page_new_battle_selection.go | 11 ++- internal/ui/page_new_battle_selection_test.go | 13 +-- internal/ui/page_new_tournament.go | 7 +- internal/ui/page_title.go | 17 ++-- internal/ui/page_title_settings.go | 7 +- internal/ui/page_title_test.go | 13 +-- .../page_locker_playbooks_create.go | 10 +- .../page_locker_playbooks_edit.go | 9 +- .../page_locker_playbooks_list.go | 9 +- .../ui/{ => uilocker}/page_locker_players.go | 9 +- .../ui/{ => uilocker}/page_locker_room.go | 11 ++- .../{ => uilocker}/page_locker_room_test.go | 7 +- internal/ui/{ => uilocker}/root_locker.go | 9 +- .../ui/{ => uilocker}/root_locker_test.go | 10 +- internal/ui/uistate/state.go | 41 ++++++++ internal/ui/uitest/helper.go | 35 +++++++ 23 files changed, 214 insertions(+), 201 deletions(-) rename internal/ui/{ => uilocker}/page_locker_playbooks_create.go (93%) rename internal/ui/{ => uilocker}/page_locker_playbooks_edit.go (96%) rename internal/ui/{ => uilocker}/page_locker_playbooks_list.go (94%) rename internal/ui/{ => uilocker}/page_locker_players.go (93%) rename internal/ui/{ => uilocker}/page_locker_room.go (90%) rename internal/ui/{ => uilocker}/page_locker_room_test.go (92%) rename internal/ui/{ => uilocker}/root_locker.go (91%) rename internal/ui/{ => uilocker}/root_locker_test.go (76%) create mode 100644 internal/ui/uistate/state.go create mode 100644 internal/ui/uitest/helper.go diff --git a/internal/ui/app.go b/internal/ui/app.go index 41b8323..464eb48 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -8,47 +8,16 @@ import ( "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/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 - 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 styles.IceTheme - currentPage Page + currentPage uistate.Page pageTitle tea.Model pageCreateCoach tea.Model pageLocker tea.Model @@ -57,7 +26,7 @@ type RootModel struct { pageTitleSettings tea.Model pageGame tea.Model pageGameComplete tea.Model - globalState *GlobalState + globalState *uistate.GlobalState teamsSvc *teams.Service playbookSvc *playbooks.Service gameSvc *games.Service @@ -71,16 +40,16 @@ 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: theme, - currentPage: PageTitle, + currentPage: uistate.PageTitle, pageTitle: NewModelTitle(state, theme), pageCreateCoach: NewModelCreateCoach(state, deps.TeamsSvc, theme), - pageLocker: NewLockerModel(state, deps.TeamsSvc, deps.PlaybookSvc, theme), + pageLocker: uilocker.NewLockerModel(state, deps.TeamsSvc, deps.PlaybookSvc, theme), pageNewTournament: NewModelNewTournament(state, theme), pageNewBattleSelection: NewModelNewBattleSelection(state, deps.TeamsSvc, deps.PlaybookSvc, deps.GameSvc, theme), pageTitleSettings: NewModelTitleSettings(state, theme), @@ -97,15 +66,15 @@ 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: &coach, Team: nil} + return uistate.MsgStateUpdated{Coach: &coach, Team: nil} } - return MsgStateUpdated{Coach: &coach, Team: &team} + return uistate.MsgStateUpdated{Coach: &coach, Team: &team} } } @@ -113,7 +82,7 @@ func (m *RootModel) 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.KeyMsg: @@ -121,18 +90,18 @@ func (m *RootModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 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: + case uistate.PageNewBattleSelection: cmd = m.pageNewBattleSelection.Init() - case PageGame: + case uistate.PageGame: if page, ok := m.pageGame.(*PageGameModel); ok { page.SetGameID(msg.GameID) } cmd = m.pageGame.Init() - case PageGameComplete: + case uistate.PageGameComplete: if page, ok := m.pageGameComplete.(*PageGameCompleteModel); ok { page.SetGameID(msg.GameID) } @@ -164,21 +133,21 @@ func (m *RootModel) Update(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: + case uistate.PageLockerRoom: m.pageLocker, cmd = m.pageLocker.Update(msg) - case PageNewTournament: + case uistate.PageNewTournament: m.pageNewTournament, cmd = m.pageNewTournament.Update(msg) - case PageNewBattleSelection: + case uistate.PageNewBattleSelection: m.pageNewBattleSelection, cmd = m.pageNewBattleSelection.Update(msg) - case PageTitleSettings: + case uistate.PageTitleSettings: m.pageTitleSettings, cmd = m.pageTitleSettings.Update(msg) - case PageGame: + case uistate.PageGame: m.pageGame, cmd = m.pageGame.Update(msg) - case PageGameComplete: + case uistate.PageGameComplete: m.pageGameComplete, cmd = m.pageGameComplete.Update(msg) } cmds = append(cmds, cmd) @@ -192,21 +161,21 @@ 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: + case uistate.PageLockerRoom: return m.pageLocker.View() - case PageNewTournament: + case uistate.PageNewTournament: return m.pageNewTournament.View() - case PageNewBattleSelection: + case uistate.PageNewBattleSelection: return m.pageNewBattleSelection.View() - case PageTitleSettings: + case uistate.PageTitleSettings: return m.pageTitleSettings.View() - case PageGame: + case uistate.PageGame: return m.pageGame.View() - case PageGameComplete: + case uistate.PageGameComplete: return m.pageGameComplete.View() } diff --git a/internal/ui/app_test.go b/internal/ui/app_test.go index 5cbeef7..106e5ee 100644 --- a/internal/ui/app_test.go +++ b/internal/ui/app_test.go @@ -1,36 +1,14 @@ 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/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) @@ -53,7 +31,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, @@ -62,7 +40,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, @@ -72,7 +50,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, @@ -85,7 +63,7 @@ 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, diff --git a/internal/ui/page_create_coach.go b/internal/ui/page_create_coach.go index 841e894..aab7430 100644 --- a/internal/ui/page_create_coach.go +++ b/internal/ui/page_create_coach.go @@ -8,6 +8,7 @@ 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" ) type createCoachKeyMap struct { @@ -66,7 +67,7 @@ type ModelCreateCoach struct { width int height int theme styles.IceTheme - globalState *GlobalState + globalState *uistate.GlobalState teamsSvc *teams.Service coachInput textinput.Model @@ -77,7 +78,7 @@ type ModelCreateCoach struct { footer components.Footer } -func NewModelCreateCoach(state *GlobalState, teamsSvc *teams.Service, theme styles.IceTheme) *ModelCreateCoach { +func NewModelCreateCoach(state *uistate.GlobalState, teamsSvc *teams.Service, theme styles.IceTheme) *ModelCreateCoach { c := textinput.New() c.Placeholder = "Coach Name" c.Focus() @@ -118,7 +119,7 @@ func (m *ModelCreateCoach) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, cmd } - case MsgStateUpdated: + case uistate.MsgStateUpdated: return m.handleStateUpdated(msg) } @@ -156,19 +157,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} } } @@ -212,7 +213,7 @@ func (m *ModelCreateCoach) submit() tea.Cmd { return err } - return MsgStateUpdated{ + return uistate.MsgStateUpdated{ Coach: &coach, Team: &team, } diff --git a/internal/ui/page_game.go b/internal/ui/page_game.go index f66479b..7a20279 100644 --- a/internal/ui/page_game.go +++ b/internal/ui/page_game.go @@ -8,20 +8,21 @@ import ( "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 styles.IceTheme - globalState *GlobalState + globalState *uistate.GlobalState gameSvc *games.Service gameID int64 game *games.Game gameComp components.Game } -func NewModelGame(state *GlobalState, gameSvc *games.Service, theme styles.IceTheme) *PageGameModel { +func NewModelGame(state *uistate.GlobalState, gameSvc *games.Service, theme styles.IceTheme) *PageGameModel { return &PageGameModel{ theme: theme, globalState: state, @@ -55,7 +56,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: @@ -87,8 +88,8 @@ func (m *PageGameModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if err != nil { return MsgGameError{Err: err} } - return MsgSwitchPage{ - NewPage: PageGameComplete, + return uistate.MsgSwitchPage{ + NewPage: uistate.PageGameComplete, GameID: m.game.ID(), } }) diff --git a/internal/ui/page_game_complete.go b/internal/ui/page_game_complete.go index 329aaf5..c754957 100644 --- a/internal/ui/page_game_complete.go +++ b/internal/ui/page_game_complete.go @@ -9,13 +9,14 @@ 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" ) type PageGameCompleteModel struct { width int height int theme styles.IceTheme - globalState *GlobalState + globalState *uistate.GlobalState teamsSvc *teams.Service gameSvc *games.Service gameID int64 @@ -25,7 +26,7 @@ type PageGameCompleteModel struct { err error } -func NewPageGameComplete(state *GlobalState, teamsSvc *teams.Service, gameSvc *games.Service, theme styles.IceTheme) *PageGameCompleteModel { +func NewPageGameComplete(state *uistate.GlobalState, teamsSvc *teams.Service, gameSvc *games.Service, theme styles.IceTheme) *PageGameCompleteModel { return &PageGameCompleteModel{ theme: theme, globalState: state, @@ -90,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} } } } diff --git a/internal/ui/page_game_complete_test.go b/internal/ui/page_game_complete_test.go index ac04a1b..1eac318 100644 --- a/internal/ui/page_game_complete_test.go +++ b/internal/ui/page_game_complete_test.go @@ -1,7 +1,6 @@ package ui import ( - "database/sql" "testing" tea "charm.land/bubbletea/v2" @@ -11,44 +10,25 @@ import ( "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" - _ "modernc.org/sqlite" + "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. @@ -139,9 +119,9 @@ func TestPageGameCompleteModel(t *testing.T) { 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/page_game_test.go index 5413fcf..ff85269 100644 --- a/internal/ui/page_game_test.go +++ b/internal/ui/page_game_test.go @@ -11,6 +11,7 @@ import ( "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 { @@ -47,7 +48,7 @@ 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) diff --git a/internal/ui/page_new_battle_selection.go b/internal/ui/page_new_battle_selection.go index 3c0f1d9..e645930 100644 --- a/internal/ui/page_new_battle_selection.go +++ b/internal/ui/page_new_battle_selection.go @@ -8,6 +8,7 @@ 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" @@ -61,7 +62,7 @@ type ModelNewBattleSelection struct { width int height int theme styles.IceTheme - globalState *GlobalState + globalState *uistate.GlobalState teamsSvc *teams.Service playbookSvc *playbooks.Service gameSvc *games.Service @@ -75,7 +76,7 @@ type ModelNewBattleSelection struct { err error } -func NewModelNewBattleSelection(globalState *GlobalState, teamsSvc *teams.Service, playbookSvc *playbooks.Service, gameSvc *games.Service, theme styles.IceTheme) *ModelNewBattleSelection { +func NewModelNewBattleSelection(globalState *uistate.GlobalState, teamsSvc *teams.Service, playbookSvc *playbooks.Service, gameSvc *games.Service, theme styles.IceTheme) *ModelNewBattleSelection { keys := newBattleSelectionKeyMap() return &ModelNewBattleSelection{ globalState: globalState, @@ -166,7 +167,7 @@ func (m *ModelNewBattleSelection) handleKey(msg tea.KeyMsg) (*ModelNewBattleSele 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 { @@ -215,8 +216,8 @@ func (m *ModelNewBattleSelection) createGame() tea.Msg { m.reset() - return MsgSwitchPage{ - NewPage: PageGame, + return uistate.MsgSwitchPage{ + NewPage: uistate.PageGame, GameID: game.ID(), } } diff --git a/internal/ui/page_new_battle_selection_test.go b/internal/ui/page_new_battle_selection_test.go index 7bf331a..4095802 100644 --- a/internal/ui/page_new_battle_selection_test.go +++ b/internal/ui/page_new_battle_selection_test.go @@ -9,13 +9,14 @@ import ( "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 TestModelNewBattleSelection_Rendering(t *testing.T) { group := odize.NewGroup(t, nil) group.Test("should load data and render selection", func(t *testing.T) { - state := &GlobalState{ + state := &uistate.GlobalState{ Team: &teams.Team{ID: 1, Name: "My Team"}, } theme := styles.NewIceTheme() @@ -48,7 +49,7 @@ func TestModelNewBattleSelection_Rendering(t *testing.T) { }) group.Test("should handle state transitions", func(t *testing.T) { - state := &GlobalState{ + state := &uistate.GlobalState{ Team: &teams.Team{ID: 1, Name: "My Team"}, } theme := styles.NewIceTheme() @@ -86,7 +87,7 @@ func TestModelNewBattleSelection_Rendering(t *testing.T) { }) group.Test("should handle back navigation to title", func(t *testing.T) { - state := &GlobalState{} + state := &uistate.GlobalState{} theme := styles.NewIceTheme() m := NewModelNewBattleSelection(state, nil, nil, nil, theme) @@ -95,15 +96,15 @@ func TestModelNewBattleSelection_Rendering(t *testing.T) { odize.AssertTrue(t, cmd != nil) msg := cmd() switch v := msg.(type) { - case MsgSwitchPage: - odize.AssertEqual(t, PageTitle, v.NewPage) + 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 := &GlobalState{ + state := &uistate.GlobalState{ Team: &teams.Team{ID: 1, Name: "My Team"}, } theme := styles.NewIceTheme() diff --git a/internal/ui/page_new_tournament.go b/internal/ui/page_new_tournament.go index 0458dec..e1c1667 100644 --- a/internal/ui/page_new_tournament.go +++ b/internal/ui/page_new_tournament.go @@ -3,16 +3,17 @@ package ui 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 styles.IceTheme - globalState *GlobalState + globalState *uistate.GlobalState } -func NewModelNewTournament(globalState *GlobalState, theme styles.IceTheme) *ModelNewTournament { +func NewModelNewTournament(globalState *uistate.GlobalState, theme styles.IceTheme) *ModelNewTournament { return &ModelNewTournament{ globalState: globalState, theme: theme, @@ -28,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 770505b..6a77b39 100644 --- a/internal/ui/page_title.go +++ b/internal/ui/page_title.go @@ -5,6 +5,7 @@ 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" @@ -50,13 +51,13 @@ type ModelTitle struct { width int height int theme styles.IceTheme - globalState *GlobalState + globalState *uistate.GlobalState keys titleKeyMap footer components.Footer menu components.TitleMenu } -func NewModelTitle(globalState *GlobalState, theme styles.IceTheme) *ModelTitle { +func NewModelTitle(globalState *uistate.GlobalState, theme styles.IceTheme) *ModelTitle { keys := newTitleKeyMap() return &ModelTitle{ globalState: globalState, @@ -73,7 +74,7 @@ func (m *ModelTitle) Init() tea.Cmd { func (m *ModelTitle) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch vMsg := msg.(type) { - case MsgStateUpdated: + case uistate.MsgStateUpdated: m.globalState.Coach = vMsg.Coach m.globalState.Team = vMsg.Team m.menu.SetHasCoach(m.globalState.Coach != nil) @@ -90,23 +91,23 @@ func (m *ModelTitle) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch selected { case components.TitleItemCreateCoach: return m, func() tea.Msg { - return MsgSwitchPage{NewPage: PageCreateCoach} + return uistate.MsgSwitchPage{NewPage: uistate.PageCreateCoach} } case components.TitleItemLockerRoom: return m, func() tea.Msg { - return MsgSwitchPage{NewPage: PageLockerRoom} + return uistate.MsgSwitchPage{NewPage: uistate.PageLockerRoom} } case components.TitleItemNewTournament: return m, func() tea.Msg { - return MsgSwitchPage{NewPage: PageNewTournament} + return uistate.MsgSwitchPage{NewPage: uistate.PageNewTournament} } case components.TitleItemNewBattleSelection: return m, func() tea.Msg { - return MsgSwitchPage{NewPage: PageNewBattleSelection} + return uistate.MsgSwitchPage{NewPage: uistate.PageNewBattleSelection} } case components.TitleItemSettings: return m, func() tea.Msg { - return MsgSwitchPage{NewPage: PageTitleSettings} + return uistate.MsgSwitchPage{NewPage: uistate.PageTitleSettings} } } } diff --git a/internal/ui/page_title_settings.go b/internal/ui/page_title_settings.go index f281f66..bdd2f77 100644 --- a/internal/ui/page_title_settings.go +++ b/internal/ui/page_title_settings.go @@ -3,16 +3,17 @@ package ui 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 ModelTitleSettings struct { width int height int theme styles.IceTheme - globalState *GlobalState + globalState *uistate.GlobalState } -func NewModelTitleSettings(globalState *GlobalState, theme styles.IceTheme) *ModelTitleSettings { +func NewModelTitleSettings(globalState *uistate.GlobalState, theme styles.IceTheme) *ModelTitleSettings { return &ModelTitleSettings{ globalState: globalState, theme: theme, @@ -28,7 +29,7 @@ func (m *ModelTitleSettings) 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_test.go b/internal/ui/page_title_test.go index 15c6879..f6c5308 100644 --- a/internal/ui/page_title_test.go +++ b/internal/ui/page_title_test.go @@ -7,6 +7,7 @@ import ( "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) { @@ -15,7 +16,7 @@ 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) { theme := styles.NewIceTheme() - m := NewModelTitle(&GlobalState{Coach: nil}, theme) + m := NewModelTitle(&uistate.GlobalState{Coach: nil}, theme) m.width = 100 m.height = 50 @@ -24,15 +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) { theme := styles.NewIceTheme() - m := NewModelTitle(&GlobalState{Coach: &teams.Coach{Name: "Coach Carter"}}, theme) + m := NewModelTitle(&uistate.GlobalState{Coach: &teams.Coach{Name: "Coach Carter"}}, theme) m.width = 100 m.height = 50 @@ -41,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/page_locker_playbooks_create.go b/internal/ui/uilocker/page_locker_playbooks_create.go similarity index 93% rename from internal/ui/page_locker_playbooks_create.go rename to internal/ui/uilocker/page_locker_playbooks_create.go index 810e22c..e51322f 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" @@ -9,6 +9,7 @@ import ( "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 { @@ -45,7 +46,6 @@ type ModelLockerPlaybooksCreate struct { width int height int theme styles.IceTheme - globalState *GlobalState playbookSvc *playbooks.Service keys lockerPlaybooksCreateKeyMap footer components.Footer @@ -55,10 +55,9 @@ type ModelLockerPlaybooksCreate struct { err error } -func NewModelLockerPlaybooksCreate(state *GlobalState, playbookSvc *playbooks.Service, theme styles.IceTheme) *ModelLockerPlaybooksCreate { +func NewModelLockerPlaybooksCreate(state *uistate.GlobalState, playbookSvc *playbooks.Service, theme styles.IceTheme) *ModelLockerPlaybooksCreate { return &ModelLockerPlaybooksCreate{ theme: theme, - globalState: state, playbookSvc: playbookSvc, keys: newLockerPlaybooksCreateKeyMap(), footer: components.NewFooter(newLockerPlaybooksCreateKeyMap()), @@ -74,9 +73,6 @@ 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 MsgSwitchLockerPage: if msg.NewPage == SubPageLockerPlaybooksCreate { if msg.Playbook != nil { diff --git a/internal/ui/page_locker_playbooks_edit.go b/internal/ui/uilocker/page_locker_playbooks_edit.go similarity index 96% rename from internal/ui/page_locker_playbooks_edit.go rename to internal/ui/uilocker/page_locker_playbooks_edit.go index b23288a..9a87606 100644 --- a/internal/ui/page_locker_playbooks_edit.go +++ b/internal/ui/uilocker/page_locker_playbooks_edit.go @@ -1,4 +1,4 @@ -package ui +package uilocker import ( "fmt" @@ -9,6 +9,7 @@ import ( "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 { @@ -50,7 +51,7 @@ type ModelLockerPlaybooksEdit struct { width int height int theme styles.IceTheme - globalState *GlobalState + globalState *uistate.GlobalState playbookSvc *playbooks.Service keys lockerPlaybooksEditKeyMap footer components.Footer @@ -64,7 +65,7 @@ type ModelLockerPlaybooksEdit struct { err error } -func NewModelLockerPlaybooksEdit(state *GlobalState, playbookSvc *playbooks.Service, theme styles.IceTheme) *ModelLockerPlaybooksEdit { +func NewModelLockerPlaybooksEdit(state *uistate.GlobalState, playbookSvc *playbooks.Service, theme styles.IceTheme) *ModelLockerPlaybooksEdit { return &ModelLockerPlaybooksEdit{ theme: theme, globalState: state, @@ -94,7 +95,7 @@ func (m *ModelLockerPlaybooksEdit) 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 MsgSwitchLockerPage: diff --git a/internal/ui/page_locker_playbooks_list.go b/internal/ui/uilocker/page_locker_playbooks_list.go similarity index 94% rename from internal/ui/page_locker_playbooks_list.go rename to internal/ui/uilocker/page_locker_playbooks_list.go index 9156670..dfce743 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" @@ -9,6 +9,7 @@ import ( "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 { @@ -54,7 +55,7 @@ type ModelLockerPlaybooksList struct { width int height int theme styles.IceTheme - globalState *GlobalState + globalState *uistate.GlobalState playbookSvc *playbooks.Service keys lockerPlaybooksListKeyMap footer components.Footer @@ -63,7 +64,7 @@ type ModelLockerPlaybooksList struct { err error } -func NewModelLockerPlaybooksList(state *GlobalState, playbookSvc *playbooks.Service, theme styles.IceTheme) *ModelLockerPlaybooksList { +func NewModelLockerPlaybooksList(state *uistate.GlobalState, playbookSvc *playbooks.Service, theme styles.IceTheme) *ModelLockerPlaybooksList { return &ModelLockerPlaybooksList{ theme: theme, globalState: state, @@ -96,7 +97,7 @@ 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: diff --git a/internal/ui/page_locker_players.go b/internal/ui/uilocker/page_locker_players.go similarity index 93% rename from internal/ui/page_locker_players.go rename to internal/ui/uilocker/page_locker_players.go index 34ed099..b68c590 100644 --- a/internal/ui/page_locker_players.go +++ b/internal/ui/uilocker/page_locker_players.go @@ -1,9 +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" @@ -55,14 +56,14 @@ type ModelLockerPlayers struct { width int height int theme styles.IceTheme - globalState *GlobalState + globalState *uistate.GlobalState teamsSvc *teams.Service keys lockerPlayersKeyMap footer components.Footer playerList components.PlayerList } -func NewModelLockerPlayers(state *GlobalState, teamsSvc *teams.Service, theme styles.IceTheme) *ModelLockerPlayers { +func NewModelLockerPlayers(state *uistate.GlobalState, teamsSvc *teams.Service, theme styles.IceTheme) *ModelLockerPlayers { keys := newLockerPlayersKeyMap() return &ModelLockerPlayers{ theme: theme, @@ -81,7 +82,7 @@ func (m *ModelLockerPlayers) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmds []tea.Cmd switch msg := msg.(type) { - case MsgStateUpdated: + case uistate.MsgStateUpdated: if m.globalState.Team != nil { m.playerList = components.NewPlayerList(m.globalState.Team.Players) } diff --git a/internal/ui/page_locker_room.go b/internal/ui/uilocker/page_locker_room.go similarity index 90% rename from internal/ui/page_locker_room.go rename to internal/ui/uilocker/page_locker_room.go index 7685e76..7e11062 100644 --- a/internal/ui/page_locker_room.go +++ b/internal/ui/uilocker/page_locker_room.go @@ -1,8 +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" @@ -43,13 +44,13 @@ type ModelLockerRoom struct { width int height int theme styles.IceTheme - globalState *GlobalState + globalState *uistate.GlobalState keys lockerRoomKeyMap footer components.Footer list components.LockerRoomList } -func NewModelLockerRoom(globalState *GlobalState, theme styles.IceTheme) *ModelLockerRoom { +func NewModelLockerRoom(globalState *uistate.GlobalState, theme styles.IceTheme) *ModelLockerRoom { keys := newLockerRoomKeyMap() return &ModelLockerRoom{ globalState: globalState, @@ -68,7 +69,7 @@ func (m *ModelLockerRoom) 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.KeyMsg: @@ -77,7 +78,7 @@ 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() { diff --git a/internal/ui/page_locker_room_test.go b/internal/ui/uilocker/page_locker_room_test.go similarity index 92% rename from internal/ui/page_locker_room_test.go rename to internal/ui/uilocker/page_locker_room_test.go index 06498c2..ae41d1a 100644 --- a/internal/ui/page_locker_room_test.go +++ b/internal/ui/uilocker/page_locker_room_test.go @@ -1,4 +1,4 @@ -package ui +package uilocker import ( "testing" @@ -7,13 +7,14 @@ import ( "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 := &GlobalState{} + state := &uistate.GlobalState{} theme := styles.NewIceTheme() m := NewModelLockerRoom(state, theme) @@ -34,7 +35,7 @@ func TestModelLockerRoom_Selection(t *testing.T) { }) group.Test("should route to locker playbooks when playbooks item is selected", func(t *testing.T) { - state := &GlobalState{} + state := &uistate.GlobalState{} theme := styles.NewIceTheme() m := NewModelLockerRoom(state, theme) diff --git a/internal/ui/root_locker.go b/internal/ui/uilocker/root_locker.go similarity index 91% rename from internal/ui/root_locker.go rename to internal/ui/uilocker/root_locker.go index 5ed9a73..58683cb 100644 --- a/internal/ui/root_locker.go +++ b/internal/ui/uilocker/root_locker.go @@ -1,10 +1,11 @@ -package ui +package uilocker import ( tea "charm.land/bubbletea/v2" "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 @@ -32,15 +33,11 @@ type LockerModel struct { subPageLockerPlaybooksList tea.Model subPageLockerPlaybooksCreate tea.Model subPageLockerPlaybooksEdit tea.Model - globalState *GlobalState - theme styles.IceTheme } // NewLockerModel returns a new LockerModel. -func NewLockerModel(state *GlobalState, teamsSvc *teams.Service, playbookSvc *playbooks.Service, theme styles.IceTheme) *LockerModel { +func NewLockerModel(state *uistate.GlobalState, teamsSvc *teams.Service, playbookSvc *playbooks.Service, theme styles.IceTheme) *LockerModel { return &LockerModel{ - globalState: state, - theme: theme, subPageLockerRoom: NewModelLockerRoom(state, theme), subPageLockerPlayers: NewModelLockerPlayers(state, teamsSvc, theme), subPageLockerPlaybooksList: NewModelLockerPlaybooksList(state, playbookSvc, theme), diff --git a/internal/ui/root_locker_test.go b/internal/ui/uilocker/root_locker_test.go similarity index 76% rename from internal/ui/root_locker_test.go rename to internal/ui/uilocker/root_locker_test.go index 461801b..7f8e7d3 100644 --- a/internal/ui/root_locker_test.go +++ b/internal/ui/uilocker/root_locker_test.go @@ -1,18 +1,20 @@ -package ui +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 := &GlobalState{} + state := &uistate.GlobalState{} theme := styles.NewIceTheme() - ts, ps, _ := setupServices(t) + ts, ps, _ := uitest.SetupServices(t) m := NewLockerModel(state, ts, ps, theme) group.Test("should update current page on MsgSwitchPage", func(t *testing.T) { @@ -25,7 +27,7 @@ func TestLockerModel_SwitchPage(t *testing.T) { group.Test("should not update current page for non-locker pages", func(t *testing.T) { m.currentPage = SubPageLockerRoom - m.Update(MsgSwitchPage{NewPage: PageTitle}) + m.Update(uistate.MsgSwitchPage{NewPage: uistate.PageTitle}) odize.AssertEqual(t, SubPageLockerRoom, m.currentPage) }) diff --git a/internal/ui/uistate/state.go b/internal/ui/uistate/state.go new file mode 100644 index 0000000..96fd9c0 --- /dev/null +++ b/internal/ui/uistate/state.go @@ -0,0 +1,41 @@ +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 + PageNewBattleSelection + PageTitleSettings + PageGame + PageGameComplete +) + +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 +} From 8d3f2bae76675b3ccbc74066818d7b052c627933 Mon Sep 17 00:00:00 2001 From: frag223 Date: Tue, 30 Jun 2026 20:44:06 +1000 Subject: [PATCH 12/72] clean up ui --- internal/ui/components/footer.go | 28 ------ internal/ui/components/footer_test.go | 17 ++-- internal/ui/page_create_coach.go | 34 +------ internal/ui/page_new_battle_selection.go | 14 +-- internal/ui/page_title.go | 19 +--- .../uilocker/page_locker_playbooks_create.go | 14 +-- .../ui/uilocker/page_locker_playbooks_edit.go | 19 +--- .../ui/uilocker/page_locker_playbooks_list.go | 23 +---- internal/ui/uilocker/page_locker_players.go | 24 +---- internal/ui/uilocker/page_locker_room.go | 14 +-- internal/ui/uistate/keys.go | 90 +++++++++++++++++++ 11 files changed, 115 insertions(+), 181 deletions(-) create mode 100644 internal/ui/uistate/keys.go diff --git a/internal/ui/components/footer.go b/internal/ui/components/footer.go index 27b1a41..56a55bc 100644 --- a/internal/ui/components/footer.go +++ b/internal/ui/components/footer.go @@ -2,7 +2,6 @@ package components import ( "charm.land/bubbles/v2/help" - "charm.land/bubbles/v2/key" tea "charm.land/bubbletea/v2" "github.com/code-gorilla-au/rush/internal/ui/styles" ) @@ -33,30 +32,3 @@ func (f *Footer) Update(msg tea.Msg) { func (f Footer) View(theme styles.IceTheme) string { return theme.Footer.Render(f.Help.View(f.KeyMap)) } - -// CommonKeys defines keys that are shared across many pages. -type CommonKeys struct { - Quit key.Binding -} - -// ShortHelp returns keybindings to be shown in the mini help view. -func (k CommonKeys) ShortHelp() []key.Binding { - return []key.Binding{k.Quit} -} - -// FullHelp returns keybindings for the expanded help view. -func (k CommonKeys) FullHelp() [][]key.Binding { - return [][]key.Binding{ - {k.Quit}, - } -} - -// NewCommonKeys returns a default set of common keys. -func NewCommonKeys() CommonKeys { - return CommonKeys{ - Quit: key.NewBinding( - key.WithKeys("q", "ctrl+c"), - key.WithHelp("q", "quit"), - ), - } -} diff --git a/internal/ui/components/footer_test.go b/internal/ui/components/footer_test.go index d8a18af..bafa61e 100644 --- a/internal/ui/components/footer_test.go +++ b/internal/ui/components/footer_test.go @@ -7,6 +7,7 @@ import ( tea "charm.land/bubbletea/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,18 +15,18 @@ 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()) + footer := NewFooter(uistate.NewKeyMap()) theme := styles.NewIceTheme() rendered := footer.View(theme) @@ -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/page_create_coach.go b/internal/ui/page_create_coach.go index aab7430..a357a3f 100644 --- a/internal/ui/page_create_coach.go +++ b/internal/ui/page_create_coach.go @@ -12,13 +12,7 @@ import ( ) 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 { @@ -35,31 +29,7 @@ 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(), } } diff --git a/internal/ui/page_new_battle_selection.go b/internal/ui/page_new_battle_selection.go index e645930..060e006 100644 --- a/internal/ui/page_new_battle_selection.go +++ b/internal/ui/page_new_battle_selection.go @@ -21,9 +21,7 @@ type msgDataLoaded struct { } type battleSelectionKeyMap struct { - components.CommonKeys - Back key.Binding - Select key.Binding + uistate.KeyMap } func (k battleSelectionKeyMap) ShortHelp() []key.Binding { @@ -38,15 +36,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(), } } diff --git a/internal/ui/page_title.go b/internal/ui/page_title.go index 6a77b39..be7877d 100644 --- a/internal/ui/page_title.go +++ b/internal/ui/page_title.go @@ -13,10 +13,7 @@ import ( ) type titleKeyMap struct { - components.CommonKeys - Up key.Binding - Down key.Binding - Enter key.Binding + uistate.KeyMap } func (k titleKeyMap) ShortHelp() []key.Binding { @@ -31,19 +28,7 @@ func (k titleKeyMap) FullHelp() [][]key.Binding { func newTitleKeyMap() titleKeyMap { return titleKeyMap{ - CommonKeys: components.NewCommonKeys(), - Up: key.NewBinding( - key.WithKeys("up", "k"), - key.WithHelp("↑/k", "up"), - ), - Down: key.NewBinding( - key.WithKeys("down", "j"), - key.WithHelp("↓/j", "down"), - ), - Enter: key.NewBinding( - key.WithKeys("enter"), - key.WithHelp("enter", "select"), - ), + KeyMap: uistate.NewKeyMap(), } } diff --git a/internal/ui/uilocker/page_locker_playbooks_create.go b/internal/ui/uilocker/page_locker_playbooks_create.go index e51322f..6b580d5 100644 --- a/internal/ui/uilocker/page_locker_playbooks_create.go +++ b/internal/ui/uilocker/page_locker_playbooks_create.go @@ -13,9 +13,7 @@ import ( ) type lockerPlaybooksCreateKeyMap struct { - components.CommonKeys - Back key.Binding - Enter key.Binding + uistate.KeyMap } func (k lockerPlaybooksCreateKeyMap) ShortHelp() []key.Binding { @@ -30,15 +28,7 @@ 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(), } } diff --git a/internal/ui/uilocker/page_locker_playbooks_edit.go b/internal/ui/uilocker/page_locker_playbooks_edit.go index 9a87606..530daf5 100644 --- a/internal/ui/uilocker/page_locker_playbooks_edit.go +++ b/internal/ui/uilocker/page_locker_playbooks_edit.go @@ -13,10 +13,7 @@ import ( ) type lockerPlaybooksEditKeyMap struct { - components.CommonKeys - Back key.Binding - Enter key.Binding - Select key.Binding + uistate.KeyMap } func (k lockerPlaybooksEditKeyMap) ShortHelp() []key.Binding { @@ -31,19 +28,7 @@ func (k lockerPlaybooksEditKeyMap) FullHelp() [][]key.Binding { 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"), - ), + KeyMap: uistate.NewKeyMap(), } } diff --git a/internal/ui/uilocker/page_locker_playbooks_list.go b/internal/ui/uilocker/page_locker_playbooks_list.go index dfce743..c1c307b 100644 --- a/internal/ui/uilocker/page_locker_playbooks_list.go +++ b/internal/ui/uilocker/page_locker_playbooks_list.go @@ -13,11 +13,7 @@ import ( ) 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 { @@ -32,22 +28,7 @@ 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(), } } diff --git a/internal/ui/uilocker/page_locker_players.go b/internal/ui/uilocker/page_locker_players.go index b68c590..8b8ca4d 100644 --- a/internal/ui/uilocker/page_locker_players.go +++ b/internal/ui/uilocker/page_locker_players.go @@ -12,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 { @@ -32,23 +28,7 @@ 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(), } } diff --git a/internal/ui/uilocker/page_locker_room.go b/internal/ui/uilocker/page_locker_room.go index 7e11062..d508db0 100644 --- a/internal/ui/uilocker/page_locker_room.go +++ b/internal/ui/uilocker/page_locker_room.go @@ -11,9 +11,7 @@ import ( ) type lockerRoomKeyMap struct { - components.CommonKeys - Back key.Binding - Select key.Binding + uistate.KeyMap } func (k lockerRoomKeyMap) ShortHelp() []key.Binding { @@ -28,15 +26,7 @@ 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(), } } 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"), + ), + } +} From 5addbde8efb9557658cf1507e13cbb76efb59b61 Mon Sep 17 00:00:00 2001 From: frag223 Date: Tue, 30 Jun 2026 21:54:05 +1000 Subject: [PATCH 13/72] wip battle selection --- internal/ui/app.go | 28 +---- internal/ui/{ => iugame}/page_game.go | 6 +- .../ui/{ => iugame}/page_game_complete.go | 2 +- .../{ => iugame}/page_game_complete_test.go | 2 +- internal/ui/{ => iugame}/page_game_test.go | 2 +- internal/ui/iugame/root_game.go | 106 ++++++++++++++++++ internal/ui/page_new_battle_selection.go | 4 +- internal/ui/uistate/state.go | 1 - 8 files changed, 117 insertions(+), 34 deletions(-) rename internal/ui/{ => iugame}/page_game.go (97%) rename internal/ui/{ => iugame}/page_game_complete.go (99%) rename internal/ui/{ => iugame}/page_game_complete_test.go (99%) rename internal/ui/{ => iugame}/page_game_test.go (99%) create mode 100644 internal/ui/iugame/root_game.go diff --git a/internal/ui/app.go b/internal/ui/app.go index 464eb48..3b4809d 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -7,6 +7,7 @@ import ( "github.com/code-gorilla-au/rush/internal/games" "github.com/code-gorilla-au/rush/internal/playbooks" "github.com/code-gorilla-au/rush/internal/teams" + "github.com/code-gorilla-au/rush/internal/ui/iugame" "github.com/code-gorilla-au/rush/internal/ui/styles" "github.com/code-gorilla-au/rush/internal/ui/uilocker" "github.com/code-gorilla-au/rush/internal/ui/uistate" @@ -25,7 +26,6 @@ type RootModel struct { pageNewBattleSelection tea.Model pageTitleSettings tea.Model pageGame tea.Model - pageGameComplete tea.Model globalState *uistate.GlobalState teamsSvc *teams.Service playbookSvc *playbooks.Service @@ -53,8 +53,7 @@ func New(deps Dependencies) *RootModel { pageNewTournament: NewModelNewTournament(state, theme), pageNewBattleSelection: NewModelNewBattleSelection(state, deps.TeamsSvc, deps.PlaybookSvc, deps.GameSvc, theme), pageTitleSettings: NewModelTitleSettings(state, theme), - pageGame: NewModelGame(state, deps.GameSvc, theme), - pageGameComplete: NewPageGameComplete(state, deps.TeamsSvc, deps.GameSvc, theme), + pageGame: iugame.NewGameModel(state, deps.TeamsSvc, deps.GameSvc, theme), globalState: state, teamsSvc: deps.TeamsSvc, playbookSvc: deps.PlaybookSvc, @@ -92,22 +91,7 @@ func (m *RootModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } case uistate.MsgSwitchPage: m.currentPage = msg.NewPage - var cmd tea.Cmd - switch m.currentPage { - case uistate.PageNewBattleSelection: - cmd = m.pageNewBattleSelection.Init() - case uistate.PageGame: - if page, ok := m.pageGame.(*PageGameModel); ok { - page.SetGameID(msg.GameID) - } - cmd = m.pageGame.Init() - case uistate.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 @@ -126,8 +110,6 @@ func (m *RootModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 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...) } @@ -147,8 +129,6 @@ func (m *RootModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.pageTitleSettings, cmd = m.pageTitleSettings.Update(msg) case uistate.PageGame: m.pageGame, cmd = m.pageGame.Update(msg) - case uistate.PageGameComplete: - m.pageGameComplete, cmd = m.pageGameComplete.Update(msg) } cmds = append(cmds, cmd) @@ -175,8 +155,6 @@ func (m *RootModel) View() tea.View { return m.pageTitleSettings.View() case uistate.PageGame: return m.pageGame.View() - case uistate.PageGameComplete: - return m.pageGameComplete.View() } return tea.NewView("unknown page") diff --git a/internal/ui/page_game.go b/internal/ui/iugame/page_game.go similarity index 97% rename from internal/ui/page_game.go rename to internal/ui/iugame/page_game.go index 7a20279..0070fab 100644 --- a/internal/ui/page_game.go +++ b/internal/ui/iugame/page_game.go @@ -1,4 +1,4 @@ -package ui +package iugame import ( "strings" @@ -88,8 +88,8 @@ func (m *PageGameModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if err != nil { return MsgGameError{Err: err} } - return uistate.MsgSwitchPage{ - NewPage: uistate.PageGameComplete, + return MsgSwitchGamePage{ + NewPage: SubPageGameComplete, GameID: m.game.ID(), } }) diff --git a/internal/ui/page_game_complete.go b/internal/ui/iugame/page_game_complete.go similarity index 99% rename from internal/ui/page_game_complete.go rename to internal/ui/iugame/page_game_complete.go index c754957..64d81ef 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" diff --git a/internal/ui/page_game_complete_test.go b/internal/ui/iugame/page_game_complete_test.go similarity index 99% rename from internal/ui/page_game_complete_test.go rename to internal/ui/iugame/page_game_complete_test.go index 1eac318..1e50da0 100644 --- a/internal/ui/page_game_complete_test.go +++ b/internal/ui/iugame/page_game_complete_test.go @@ -1,4 +1,4 @@ -package ui +package iugame import ( "testing" diff --git a/internal/ui/page_game_test.go b/internal/ui/iugame/page_game_test.go similarity index 99% rename from internal/ui/page_game_test.go rename to internal/ui/iugame/page_game_test.go index ff85269..0f6fca7 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" diff --git a/internal/ui/iugame/root_game.go b/internal/ui/iugame/root_game.go new file mode 100644 index 0000000..4e8fd83 --- /dev/null +++ b/internal/ui/iugame/root_game.go @@ -0,0 +1,106 @@ +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 + subPageGame tea.Model + 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 MsgSwitchGamePage: + switch msg.NewPage { + case SubPageGameRoot, SubPageGameComplete: + m.currentPage = msg.NewPage + } + if msg.GameID != 0 { + m.SetGameID(msg.GameID) + } + + 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/page_new_battle_selection.go b/internal/ui/page_new_battle_selection.go index 060e006..f1e5e64 100644 --- a/internal/ui/page_new_battle_selection.go +++ b/internal/ui/page_new_battle_selection.go @@ -91,7 +91,7 @@ func (m *ModelNewBattleSelection) loadData() tea.Msg { return fmt.Errorf("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 } @@ -102,7 +102,7 @@ func (m *ModelNewBattleSelection) loadData() tea.Msg { } return msgDataLoaded{ - playbooks: playbooks, + playbooks: pb, aiTeams: aiTeams, } } diff --git a/internal/ui/uistate/state.go b/internal/ui/uistate/state.go index 96fd9c0..ad1d01b 100644 --- a/internal/ui/uistate/state.go +++ b/internal/ui/uistate/state.go @@ -28,7 +28,6 @@ const ( PageNewBattleSelection PageTitleSettings PageGame - PageGameComplete ) type GlobalState struct { From f50aa170652143c14bff3e27064eaa2db9784a51 Mon Sep 17 00:00:00 2001 From: frag223 Date: Tue, 30 Jun 2026 22:17:24 +1000 Subject: [PATCH 14/72] wip breaking out game component --- internal/ui/iugame/root_game.go | 5 +++++ internal/ui/page_create_coach.go | 4 ++++ internal/ui/page_new_battle_selection.go | 8 ++++++-- internal/ui/page_new_battle_selection_test.go | 18 ++++++++++++++++++ internal/ui/uilocker/root_locker.go | 5 +++++ 5 files changed, 38 insertions(+), 2 deletions(-) diff --git a/internal/ui/iugame/root_game.go b/internal/ui/iugame/root_game.go index 4e8fd83..5f32795 100644 --- a/internal/ui/iugame/root_game.go +++ b/internal/ui/iugame/root_game.go @@ -75,6 +75,11 @@ 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.SetGameID(msg.GameID) + return m, m.Init() + } case MsgSwitchGamePage: switch msg.NewPage { case SubPageGameRoot, SubPageGameComplete: diff --git a/internal/ui/page_create_coach.go b/internal/ui/page_create_coach.go index a357a3f..f715b28 100644 --- a/internal/ui/page_create_coach.go +++ b/internal/ui/page_create_coach.go @@ -79,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 diff --git a/internal/ui/page_new_battle_selection.go b/internal/ui/page_new_battle_selection.go index f1e5e64..f961bf6 100644 --- a/internal/ui/page_new_battle_selection.go +++ b/internal/ui/page_new_battle_selection.go @@ -112,14 +112,18 @@ func (m *ModelNewBattleSelection) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmds []tea.Cmd switch msg := msg.(type) { + case uistate.MsgSwitchPage: + if msg.NewPage == uistate.PageNewBattleSelection { + return m, m.Init() + } 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) + cmds = append(cmds, m.playbookList.SetItems(msg.playbooks)) + cmds = append(cmds, m.aiTeamList.SetItems(msg.aiTeams)) case error: m.err = msg case tea.KeyMsg: diff --git a/internal/ui/page_new_battle_selection_test.go b/internal/ui/page_new_battle_selection_test.go index 4095802..6cbbcee 100644 --- a/internal/ui/page_new_battle_selection_test.go +++ b/internal/ui/page_new_battle_selection_test.go @@ -124,6 +124,24 @@ func TestModelNewBattleSelection_Rendering(t *testing.T) { 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() + m := NewModelNewBattleSelection(state, nil, nil, nil, theme) + + // 1. Set some state to verify reset + m.state = stateConfirming + + // 2. Send MsgSwitchPage + _, cmd := m.Update(uistate.MsgSwitchPage{NewPage: uistate.PageNewBattleSelection}) + + // 3. Verify it's reset and returns loadData command + odize.AssertEqual(t, stateSelectingPlaybook, m.state) + odize.AssertTrue(t, cmd != nil) + }) + err := group.Run() odize.AssertNoError(t, err) } diff --git a/internal/ui/uilocker/root_locker.go b/internal/ui/uilocker/root_locker.go index 58683cb..7474db4 100644 --- a/internal/ui/uilocker/root_locker.go +++ b/internal/ui/uilocker/root_locker.go @@ -54,6 +54,11 @@ func (m *LockerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmds []tea.Cmd switch msg := msg.(type) { + case uistate.MsgSwitchPage: + if msg.NewPage == uistate.PageLockerRoom { + m.currentPage = SubPageLockerRoom + return m, m.Init() + } case MsgSwitchLockerPage: switch msg.NewPage { case SubPageLockerRoom, SubPageLockerPlayers, SubPageLockerPlaybooksList, SubPageLockerPlaybooksCreate, SubPageLockerPlaybooksEdit: From 6c7c8b914c0b12fffe959ac73d9c79fa5aa370fa Mon Sep 17 00:00:00 2001 From: frag223 Date: Wed, 1 Jul 2026 19:53:04 +1000 Subject: [PATCH 15/72] refactoring --- internal/ui/iugame/root_game.go | 3 ++ internal/ui/iugame/root_game_test.go | 75 ++++++++++++++++++++++++++++ internal/ui/page_title.go | 53 ++++++++++++-------- 3 files changed, 109 insertions(+), 22 deletions(-) create mode 100644 internal/ui/iugame/root_game_test.go diff --git a/internal/ui/iugame/root_game.go b/internal/ui/iugame/root_game.go index 5f32795..1b5b1f5 100644 --- a/internal/ui/iugame/root_game.go +++ b/internal/ui/iugame/root_game.go @@ -85,10 +85,13 @@ func (m *GameModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 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) diff --git a/internal/ui/iugame/root_game_test.go b/internal/ui/iugame/root_game_test.go new file mode 100644 index 0000000..020c86e --- /dev/null +++ b/internal/ui/iugame/root_game_test.go @@ -0,0 +1,75 @@ +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) + }). + Run() + + odize.AssertNoError(t, err) +} diff --git a/internal/ui/page_title.go b/internal/ui/page_title.go index be7877d..2993a58 100644 --- a/internal/ui/page_title.go +++ b/internal/ui/page_title.go @@ -72,28 +72,9 @@ func (m *ModelTitle) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case key.Matches(vMsg, m.keys.Down): m.menu.MoveDown() case key.Matches(vMsg, m.keys.Enter): - selected := m.menu.SelectedItem() - switch selected { - case components.TitleItemCreateCoach: - return m, func() tea.Msg { - return uistate.MsgSwitchPage{NewPage: uistate.PageCreateCoach} - } - case components.TitleItemLockerRoom: - return m, func() tea.Msg { - return uistate.MsgSwitchPage{NewPage: uistate.PageLockerRoom} - } - case components.TitleItemNewTournament: - return m, func() tea.Msg { - return uistate.MsgSwitchPage{NewPage: uistate.PageNewTournament} - } - case components.TitleItemNewBattleSelection: - return m, func() tea.Msg { - return uistate.MsgSwitchPage{NewPage: uistate.PageNewBattleSelection} - } - case components.TitleItemSettings: - return m, func() tea.Msg { - return uistate.MsgSwitchPage{NewPage: uistate.PageTitleSettings} - } + model, cmd, done := m.handleMenuSelect() + if done { + return model, cmd } } case tea.WindowSizeMsg: @@ -105,6 +86,34 @@ func (m *ModelTitle) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } +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.PageNewBattleSelection} + }, true + case components.TitleItemSettings: + return m, func() tea.Msg { + return uistate.MsgSwitchPage{NewPage: uistate.PageTitleSettings} + }, true + } + + return nil, nil, false +} + const logo = ` ____ _ _ ____ _ _ | _ \| | | / ___|| | | | From 249131749eba6e2cf9a9409ace4dbfc02ba40a31 Mon Sep 17 00:00:00 2001 From: frag223 Date: Wed, 1 Jul 2026 20:23:46 +1000 Subject: [PATCH 16/72] wip --- internal/ui/app.go | 4 ++++ internal/ui/page_create_coach.go | 2 +- internal/ui/page_title.go | 4 ++++ 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/internal/ui/app.go b/internal/ui/app.go index 3b4809d..ec19962 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -84,6 +84,10 @@ func (m *RootModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case uistate.MsgStateUpdated: m.globalState.Coach = msg.Coach m.globalState.Team = msg.Team + var cmd tea.Cmd + m.pageTitle, cmd = m.pageTitle.Update(msg) + cmds = append(cmds, cmd) + return m, tea.Batch(cmds...) case tea.KeyMsg: switch msg.String() { case "q", "ctrl+c": diff --git a/internal/ui/page_create_coach.go b/internal/ui/page_create_coach.go index f715b28..404dede 100644 --- a/internal/ui/page_create_coach.go +++ b/internal/ui/page_create_coach.go @@ -58,7 +58,7 @@ func NewModelCreateCoach(state *uistate.GlobalState, teamsSvc *teams.Service, th t := textinput.New() t.Placeholder = "Team Name" t.CharLimit = 156 - c.SetWidth(20) + t.SetWidth(20) keys := newCreateCoachKeyMap() diff --git a/internal/ui/page_title.go b/internal/ui/page_title.go index 2993a58..d2b79b4 100644 --- a/internal/ui/page_title.go +++ b/internal/ui/page_title.go @@ -63,6 +63,10 @@ func (m *ModelTitle) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.globalState.Coach = vMsg.Coach m.globalState.Team = vMsg.Team m.menu.SetHasCoach(m.globalState.Coach != nil) + case uistate.MsgSwitchPage: + if vMsg.NewPage == uistate.PageTitle { + m.menu.SetHasCoach(m.globalState.Coach != nil) + } case tea.KeyMsg: switch { case key.Matches(vMsg, m.keys.Quit): From 1da35fc153a46fafb0990b9b3e86623ed2687c9b Mon Sep 17 00:00:00 2001 From: frag223 Date: Wed, 1 Jul 2026 21:03:16 +1000 Subject: [PATCH 17/72] extracting --- internal/ui/app.go | 3 +- .../page_battle_selection.go} | 48 +++++++++---------- .../page_battle_selection_test.go} | 35 ++++++++------ internal/ui/uibattle/root_battle.go | 45 +++++++++++++++++ 4 files changed, 89 insertions(+), 42 deletions(-) rename internal/ui/{page_new_battle_selection.go => uibattle/page_battle_selection.go} (84%) rename internal/ui/{page_new_battle_selection_test.go => uibattle/page_battle_selection_test.go} (79%) create mode 100644 internal/ui/uibattle/root_battle.go diff --git a/internal/ui/app.go b/internal/ui/app.go index ec19962..1604345 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -9,6 +9,7 @@ import ( "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" ) @@ -51,7 +52,7 @@ func New(deps Dependencies) *RootModel { pageCreateCoach: NewModelCreateCoach(state, deps.TeamsSvc, theme), pageLocker: uilocker.NewLockerModel(state, deps.TeamsSvc, deps.PlaybookSvc, theme), pageNewTournament: NewModelNewTournament(state, theme), - pageNewBattleSelection: NewModelNewBattleSelection(state, deps.TeamsSvc, deps.PlaybookSvc, deps.GameSvc, theme), + pageNewBattleSelection: uibattle.NewBattleModel(state, deps.TeamsSvc, deps.PlaybookSvc, deps.GameSvc, theme), pageTitleSettings: NewModelTitleSettings(state, theme), pageGame: iugame.NewGameModel(state, deps.TeamsSvc, deps.GameSvc, theme), globalState: state, diff --git a/internal/ui/page_new_battle_selection.go b/internal/ui/uibattle/page_battle_selection.go similarity index 84% rename from internal/ui/page_new_battle_selection.go rename to internal/ui/uibattle/page_battle_selection.go index f961bf6..514eba2 100644 --- a/internal/ui/page_new_battle_selection.go +++ b/internal/ui/uibattle/page_battle_selection.go @@ -1,4 +1,4 @@ -package ui +package uibattle import ( "fmt" @@ -15,9 +15,9 @@ import ( "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 { @@ -48,7 +48,7 @@ const ( stateConfirming ) -type ModelNewBattleSelection struct { +type PageBattleSelectionModel struct { width int height int theme styles.IceTheme @@ -66,9 +66,9 @@ type ModelNewBattleSelection struct { err error } -func NewModelNewBattleSelection(globalState *uistate.GlobalState, teamsSvc *teams.Service, playbookSvc *playbooks.Service, gameSvc *games.Service, theme styles.IceTheme) *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, @@ -81,12 +81,12 @@ func NewModelNewBattleSelection(globalState *uistate.GlobalState, teamsSvc *team } } -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") } @@ -101,29 +101,25 @@ func (m *ModelNewBattleSelection) loadData() tea.Msg { return err } - return msgDataLoaded{ - playbooks: pb, - 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 switch msg := msg.(type) { - case uistate.MsgSwitchPage: - if msg.NewPage == uistate.PageNewBattleSelection { - return m, m.Init() - } 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: - cmds = append(cmds, m.playbookList.SetItems(msg.playbooks)) - cmds = append(cmds, m.aiTeamList.SetItems(msg.aiTeams)) + 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: @@ -147,7 +143,7 @@ func (m *ModelNewBattleSelection) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 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 @@ -185,7 +181,7 @@ func (m *ModelNewBattleSelection) handleKey(msg tea.KeyMsg) (*ModelNewBattleSele return m, nil } -func (m *ModelNewBattleSelection) createGame() tea.Msg { +func (m *PageBattleSelectionModel) createGame() tea.Msg { if m.selectedPlaybook == nil || m.selectedAITeam == nil { return fmt.Errorf("missing playbook or opponent") } @@ -216,7 +212,7 @@ func (m *ModelNewBattleSelection) createGame() tea.Msg { } } -func (m *ModelNewBattleSelection) reset() { +func (m *PageBattleSelectionModel) reset() { m.state = stateSelectingPlaybook m.selectedPlaybook = nil m.selectedAITeam = nil @@ -225,7 +221,7 @@ func (m *ModelNewBattleSelection) 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...") } @@ -271,7 +267,7 @@ 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) @@ -296,7 +292,7 @@ func (m *ModelNewBattleSelection) viewSelection() string { ) } -func (m *ModelNewBattleSelection) viewConfirmation() string { +func (m *PageBattleSelectionModel) viewConfirmation() string { return lipgloss.JoinVertical(lipgloss.Center, m.theme.Logo.Render("CONFIRM BATTLE"), "", diff --git a/internal/ui/page_new_battle_selection_test.go b/internal/ui/uibattle/page_battle_selection_test.go similarity index 79% rename from internal/ui/page_new_battle_selection_test.go rename to internal/ui/uibattle/page_battle_selection_test.go index 6cbbcee..0fa703f 100644 --- a/internal/ui/page_new_battle_selection_test.go +++ b/internal/ui/uibattle/page_battle_selection_test.go @@ -1,4 +1,4 @@ -package ui +package uibattle import ( "strings" @@ -12,7 +12,7 @@ import ( "github.com/code-gorilla-au/rush/internal/ui/uistate" ) -func TestModelNewBattleSelection_Rendering(t *testing.T) { +func TestPageBattleSelectionModel_Rendering(t *testing.T) { group := odize.NewGroup(t, nil) group.Test("should load data and render selection", func(t *testing.T) { @@ -21,14 +21,14 @@ func TestModelNewBattleSelection_Rendering(t *testing.T) { } theme := styles.NewIceTheme() - m := NewModelNewBattleSelection(state, nil, nil, nil, theme) + m := NewModelBattleSelection(state, nil, nil, nil, theme) 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{ + 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"}, @@ -53,11 +53,11 @@ func TestModelNewBattleSelection_Rendering(t *testing.T) { Team: &teams.Team{ID: 1, Name: "My Team"}, } theme := styles.NewIceTheme() - m := NewModelNewBattleSelection(state, nil, nil, nil, theme) + m := NewModelBattleSelection(state, nil, nil, nil, theme) m.Update(tea.WindowSizeMsg{Width: 100, Height: 40}) - m.Update(msgDataLoaded{ - playbooks: []playbooks.Playbook{{ID: 1, Name: "Playbook 1"}}, - aiTeams: []teams.AITeam{ + m.Update(MsgBattleSelectionDataLoaded{ + Playbooks: []playbooks.Playbook{{ID: 1, Name: "Playbook 1"}}, + AITeams: []teams.AITeam{ { Team: teams.Team{ID: 2, Name: "Team A"}, }, @@ -89,7 +89,7 @@ func TestModelNewBattleSelection_Rendering(t *testing.T) { group.Test("should handle back navigation to title", func(t *testing.T) { state := &uistate.GlobalState{} theme := styles.NewIceTheme() - m := NewModelNewBattleSelection(state, nil, nil, nil, theme) + m := NewModelBattleSelection(state, nil, nil, nil, theme) _, cmd := m.Update(tea.KeyPressMsg{Text: "esc"}) @@ -108,7 +108,7 @@ func TestModelNewBattleSelection_Rendering(t *testing.T) { Team: &teams.Team{ID: 1, Name: "My Team"}, } theme := styles.NewIceTheme() - m := NewModelNewBattleSelection(state, nil, nil, nil, theme) + m := NewModelBattleSelection(state, nil, nil, nil, theme) // 1. Set some state m.state = stateConfirming @@ -129,16 +129,21 @@ func TestModelNewBattleSelection_Rendering(t *testing.T) { Team: &teams.Team{ID: 1, Name: "My Team"}, } theme := styles.NewIceTheme() - m := NewModelNewBattleSelection(state, nil, nil, nil, theme) + // We test BattleModel here as it now handles MsgSwitchPage + m := NewBattleModel(state, nil, nil, nil, theme) // 1. Set some state to verify reset - m.state = stateConfirming + if p, ok := m.subPageBattleSelection.(*PageBattleSelectionModel); ok { + p.state = stateConfirming + } // 2. Send MsgSwitchPage _, cmd := m.Update(uistate.MsgSwitchPage{NewPage: uistate.PageNewBattleSelection}) // 3. Verify it's reset and returns loadData command - odize.AssertEqual(t, stateSelectingPlaybook, m.state) + if p, ok := m.subPageBattleSelection.(*PageBattleSelectionModel); ok { + odize.AssertEqual(t, stateSelectingPlaybook, p.state) + } odize.AssertTrue(t, cmd != nil) }) diff --git a/internal/ui/uibattle/root_battle.go b/internal/ui/uibattle/root_battle.go new file mode 100644 index 0000000..6dc5ed4 --- /dev/null +++ b/internal/ui/uibattle/root_battle.go @@ -0,0 +1,45 @@ +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 BattleModel struct { + subPageBattleSelection 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), + } +} + +func (m *BattleModel) Init() tea.Cmd { + return m.subPageBattleSelection.Init() +} + +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.PageNewBattleSelection { + return m, m.Init() + } + } + + var cmd tea.Cmd + m.subPageBattleSelection, cmd = m.subPageBattleSelection.Update(msg) + cmds = append(cmds, cmd) + + return m, tea.Batch(cmds...) +} + +func (m *BattleModel) View() tea.View { + return m.subPageBattleSelection.View() +} From 281617985317e79a6f4f2d93272921c3e6b5a13a Mon Sep 17 00:00:00 2001 From: frag223 Date: Wed, 1 Jul 2026 21:14:10 +1000 Subject: [PATCH 18/72] moving to battle confirmation --- internal/ui/uibattle/page_battle_confirm.go | 150 ++++++++++++++++++ .../ui/uibattle/page_battle_confirm_test.go | 59 +++++++ internal/ui/uibattle/page_battle_selection.go | 62 +------- .../ui/uibattle/page_battle_selection_test.go | 26 +-- internal/ui/uibattle/root_battle.go | 61 ++++++- 5 files changed, 289 insertions(+), 69 deletions(-) create mode 100644 internal/ui/uibattle/page_battle_confirm.go create mode 100644 internal/ui/uibattle/page_battle_confirm_test.go diff --git a/internal/ui/uibattle/page_battle_confirm.go b/internal/ui/uibattle/page_battle_confirm.go new file mode 100644 index 0000000..680e3e5 --- /dev/null +++ b/internal/ui/uibattle/page_battle_confirm.go @@ -0,0 +1,150 @@ +package uibattle + +import ( + "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) + var cmds []tea.Cmd + + 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, tea.Batch(cmds...) +} + +func (m *PageBattleConfirmModel) 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 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 = 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", + ) + } + + 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 +} 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..1a23e9c --- /dev/null +++ b/internal/ui/uibattle/page_battle_confirm_test.go @@ -0,0 +1,59 @@ +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) { + group := odize.NewGroup(t, nil) + + group.Test("should render confirmation details", func(t *testing.T) { + state := &uistate.GlobalState{ + Team: &teams.Team{ID: 1, Name: "My Team"}, + } + theme := styles.NewIceTheme() + m := NewPageBattleConfirm(state, nil, theme) + m.width = 100 + m.height = 40 + + m.SetData( + &playbooks.Playbook{Name: "Playbook 1"}, + &teams.AITeam{Team: teams.Team{Name: "Opponent A"}}, + ) + + view := m.View() + content := view.Content + + odize.AssertTrue(t, strings.Contains(content, "CONFIRM BATTLE")) + odize.AssertTrue(t, strings.Contains(content, "Playbook 1")) + odize.AssertTrue(t, strings.Contains(content, "Opponent A")) + }) + + 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/uibattle/page_battle_selection.go b/internal/ui/uibattle/page_battle_selection.go index 514eba2..8eb6882 100644 --- a/internal/ui/uibattle/page_battle_selection.go +++ b/internal/ui/uibattle/page_battle_selection.go @@ -45,7 +45,6 @@ type selectionState int const ( stateSelectingPlaybook selectionState = iota stateSelectingOpponent - stateConfirming ) type PageBattleSelectionModel struct { @@ -152,10 +151,6 @@ func (m *PageBattleSelectionModel) handleKey(msg tea.KeyMsg) (*PageBattleSelecti m.state = stateSelectingPlaybook return m, nil } - if m.state == stateConfirming { - m.state = stateSelectingOpponent - return m, nil - } return m, func() tea.Msg { return uistate.MsgSwitchPage{NewPage: uistate.PageTitle} } @@ -170,48 +165,20 @@ func (m *PageBattleSelectionModel) handleKey(msg tea.KeyMsg) (*PageBattleSelecti if m.state == stateSelectingOpponent { 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 *PageBattleSelectionModel) 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 - } - - m.reset() - - return uistate.MsgSwitchPage{ - NewPage: uistate.PageGame, - GameID: game.ID(), - } -} - func (m *PageBattleSelectionModel) reset() { m.state = stateSelectingPlaybook m.selectedPlaybook = nil @@ -234,9 +201,6 @@ func (m *PageBattleSelectionModel) 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 { @@ -291,15 +255,3 @@ func (m *PageBattleSelectionModel) viewSelection() string { ), ) } - -func (m *PageBattleSelectionModel) 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", - ) -} diff --git a/internal/ui/uibattle/page_battle_selection_test.go b/internal/ui/uibattle/page_battle_selection_test.go index 0fa703f..d9f509d 100644 --- a/internal/ui/uibattle/page_battle_selection_test.go +++ b/internal/ui/uibattle/page_battle_selection_test.go @@ -72,16 +72,20 @@ func TestPageBattleSelectionModel_Rendering(t *testing.T) { 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) + // 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) + } - // 5. Back from selecting opponent -> selecting playbook + // 4. Back from selecting opponent -> selecting playbook m.Update(tea.KeyPressMsg{Text: "esc"}) odize.AssertEqual(t, stateSelectingPlaybook, m.state) }) @@ -111,7 +115,7 @@ func TestPageBattleSelectionModel_Rendering(t *testing.T) { m := NewModelBattleSelection(state, nil, nil, nil, theme) // 1. Set some state - m.state = stateConfirming + m.state = stateSelectingOpponent m.selectedPlaybook = &playbooks.Playbook{ID: 1, Name: "Playbook 1"} m.selectedAITeam = &teams.AITeam{Team: teams.Team{ID: 2, Name: "Team A"}} @@ -134,7 +138,7 @@ func TestPageBattleSelectionModel_Rendering(t *testing.T) { // 1. Set some state to verify reset if p, ok := m.subPageBattleSelection.(*PageBattleSelectionModel); ok { - p.state = stateConfirming + p.state = stateSelectingOpponent } // 2. Send MsgSwitchPage diff --git a/internal/ui/uibattle/root_battle.go b/internal/ui/uibattle/root_battle.go index 6dc5ed4..fefa8d2 100644 --- a/internal/ui/uibattle/root_battle.go +++ b/internal/ui/uibattle/root_battle.go @@ -9,18 +9,48 @@ import ( "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 { - return m.subPageBattleSelection.Init() + 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) { @@ -29,17 +59,42 @@ func (m *BattleModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case uistate.MsgSwitchPage: if msg.NewPage == uistate.PageNewBattleSelection { + 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 - m.subPageBattleSelection, cmd = m.subPageBattleSelection.Update(msg) + 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 { - return m.subPageBattleSelection.View() + switch m.currentPage { + case SubPageBattleSelection: + return m.subPageBattleSelection.View() + case SubPageBattleConfirm: + return m.subPageBattleConfirm.View() + default: + return tea.NewView("unknown battle page") + } } From bf52aa3f866832b7b3032c5fb34cfd476d8b7595 Mon Sep 17 00:00:00 2001 From: frag223 Date: Wed, 1 Jul 2026 21:44:29 +1000 Subject: [PATCH 19/72] adding clean slate --- internal/games/game.go | 3 ++- internal/ui/iugame/page_game.go | 1 + internal/ui/iugame/root_game.go | 1 + internal/ui/iugame/root_game_test.go | 17 +++++++++++++++++ 4 files changed, 21 insertions(+), 1 deletion(-) diff --git a/internal/games/game.go b/internal/games/game.go index 79a2934..53497a6 100644 --- a/internal/games/game.go +++ b/internal/games/game.go @@ -51,8 +51,9 @@ 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 diff --git a/internal/ui/iugame/page_game.go b/internal/ui/iugame/page_game.go index 0070fab..ee55212 100644 --- a/internal/ui/iugame/page_game.go +++ b/internal/ui/iugame/page_game.go @@ -43,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 { diff --git a/internal/ui/iugame/root_game.go b/internal/ui/iugame/root_game.go index 1b5b1f5..52517fc 100644 --- a/internal/ui/iugame/root_game.go +++ b/internal/ui/iugame/root_game.go @@ -77,6 +77,7 @@ func (m *GameModel) Update(msg tea.Msg) (tea.Model, 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() } diff --git a/internal/ui/iugame/root_game_test.go b/internal/ui/iugame/root_game_test.go index 020c86e..5c89296 100644 --- a/internal/ui/iugame/root_game_test.go +++ b/internal/ui/iugame/root_game_test.go @@ -69,6 +69,23 @@ func TestGameModel(t *testing.T) { _, 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) From a6d11f759fed330e9e4691cd120547891ecdaf0c Mon Sep 17 00:00:00 2001 From: frag223 Date: Wed, 1 Jul 2026 22:01:52 +1000 Subject: [PATCH 20/72] fixing name --- internal/ui/app.go | 70 +++++++++---------- internal/ui/page_title.go | 2 +- .../ui/uibattle/page_battle_selection_test.go | 2 +- internal/ui/uibattle/root_battle.go | 2 +- internal/ui/uistate/state.go | 2 +- 5 files changed, 39 insertions(+), 39 deletions(-) diff --git a/internal/ui/app.go b/internal/ui/app.go index 1604345..6ebd8c0 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -15,22 +15,22 @@ import ( ) type RootModel struct { - 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 - pageNewBattleSelection tea.Model - pageTitleSettings tea.Model - pageGame tea.Model - globalState *uistate.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 + pageTitleSettings tea.Model + pageGame tea.Model + globalState *uistate.GlobalState + teamsSvc *teams.Service + playbookSvc *playbooks.Service + gameSvc *games.Service } type Dependencies struct { @@ -45,20 +45,20 @@ func New(deps Dependencies) *RootModel { theme := styles.NewIceTheme() return &RootModel{ - 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, theme), - pageNewTournament: NewModelNewTournament(state, theme), - pageNewBattleSelection: uibattle.NewBattleModel(state, deps.TeamsSvc, deps.PlaybookSvc, deps.GameSvc, theme), - pageTitleSettings: NewModelTitleSettings(state, theme), - pageGame: iugame.NewGameModel(state, deps.TeamsSvc, deps.GameSvc, theme), - 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, theme), + pageNewTournament: NewModelNewTournament(state, theme), + pageNewBattle: uibattle.NewBattleModel(state, deps.TeamsSvc, deps.PlaybookSvc, deps.GameSvc, theme), + pageTitleSettings: NewModelTitleSettings(state, theme), + pageGame: iugame.NewGameModel(state, deps.TeamsSvc, deps.GameSvc, theme), + globalState: state, + teamsSvc: deps.TeamsSvc, + playbookSvc: deps.PlaybookSvc, + gameSvc: deps.GameSvc, } } @@ -109,7 +109,7 @@ func (m *RootModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds = append(cmds, cmd) m.pageNewTournament, cmd = m.pageNewTournament.Update(msg) cmds = append(cmds, cmd) - m.pageNewBattleSelection, cmd = m.pageNewBattleSelection.Update(msg) + m.pageNewBattle, cmd = m.pageNewBattle.Update(msg) cmds = append(cmds, cmd) m.pageTitleSettings, cmd = m.pageTitleSettings.Update(msg) cmds = append(cmds, cmd) @@ -128,8 +128,8 @@ func (m *RootModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.pageLocker, cmd = m.pageLocker.Update(msg) case uistate.PageNewTournament: m.pageNewTournament, cmd = m.pageNewTournament.Update(msg) - case uistate.PageNewBattleSelection: - m.pageNewBattleSelection, cmd = m.pageNewBattleSelection.Update(msg) + case uistate.PageNewBattle: + m.pageNewBattle, cmd = m.pageNewBattle.Update(msg) case uistate.PageTitleSettings: m.pageTitleSettings, cmd = m.pageTitleSettings.Update(msg) case uistate.PageGame: @@ -154,8 +154,8 @@ func (m *RootModel) View() tea.View { return m.pageLocker.View() case uistate.PageNewTournament: return m.pageNewTournament.View() - case uistate.PageNewBattleSelection: - return m.pageNewBattleSelection.View() + case uistate.PageNewBattle: + return m.pageNewBattle.View() case uistate.PageTitleSettings: return m.pageTitleSettings.View() case uistate.PageGame: diff --git a/internal/ui/page_title.go b/internal/ui/page_title.go index d2b79b4..69d2925 100644 --- a/internal/ui/page_title.go +++ b/internal/ui/page_title.go @@ -107,7 +107,7 @@ func (m *ModelTitle) handleMenuSelect() (tea.Model, tea.Cmd, bool) { }, true case components.TitleItemNewBattleSelection: return m, func() tea.Msg { - return uistate.MsgSwitchPage{NewPage: uistate.PageNewBattleSelection} + return uistate.MsgSwitchPage{NewPage: uistate.PageNewBattle} }, true case components.TitleItemSettings: return m, func() tea.Msg { diff --git a/internal/ui/uibattle/page_battle_selection_test.go b/internal/ui/uibattle/page_battle_selection_test.go index d9f509d..19f9cba 100644 --- a/internal/ui/uibattle/page_battle_selection_test.go +++ b/internal/ui/uibattle/page_battle_selection_test.go @@ -142,7 +142,7 @@ func TestPageBattleSelectionModel_Rendering(t *testing.T) { } // 2. Send MsgSwitchPage - _, cmd := m.Update(uistate.MsgSwitchPage{NewPage: uistate.PageNewBattleSelection}) + _, cmd := m.Update(uistate.MsgSwitchPage{NewPage: uistate.PageNewBattle}) // 3. Verify it's reset and returns loadData command if p, ok := m.subPageBattleSelection.(*PageBattleSelectionModel); ok { diff --git a/internal/ui/uibattle/root_battle.go b/internal/ui/uibattle/root_battle.go index fefa8d2..bd4928b 100644 --- a/internal/ui/uibattle/root_battle.go +++ b/internal/ui/uibattle/root_battle.go @@ -58,7 +58,7 @@ func (m *BattleModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case uistate.MsgSwitchPage: - if msg.NewPage == uistate.PageNewBattleSelection { + if msg.NewPage == uistate.PageNewBattle { m.currentPage = SubPageBattleSelection return m, m.Init() } diff --git a/internal/ui/uistate/state.go b/internal/ui/uistate/state.go index ad1d01b..2f148d8 100644 --- a/internal/ui/uistate/state.go +++ b/internal/ui/uistate/state.go @@ -25,7 +25,7 @@ const ( PageCreateCoach PageLockerRoom PageNewTournament - PageNewBattleSelection + PageNewBattle PageTitleSettings PageGame ) From 5e609ec9c0216d139c0711e5e6677c2ca5cc1c17 Mon Sep 17 00:00:00 2001 From: frag223 Date: Wed, 1 Jul 2026 22:16:05 +1000 Subject: [PATCH 21/72] fixing name --- internal/ui/uilocker/page_locker_players.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/ui/uilocker/page_locker_players.go b/internal/ui/uilocker/page_locker_players.go index 8b8ca4d..62b620e 100644 --- a/internal/ui/uilocker/page_locker_players.go +++ b/internal/ui/uilocker/page_locker_players.go @@ -124,7 +124,7 @@ func (m *ModelLockerPlayers) View() tea.View { content := lipgloss.JoinVertical( lipgloss.Center, - m.theme.Logo.Render("PLAYERS"), + m.theme.Logo.Render(m.globalState.Team.Name), "", playersView, "", From efa3fbf4dca5c95b10beca8cabc4e18bc1c35456 Mon Sep 17 00:00:00 2001 From: frag223 Date: Wed, 1 Jul 2026 22:18:30 +1000 Subject: [PATCH 22/72] fixing naming --- internal/ui/uilocker/page_locker_room.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/ui/uilocker/page_locker_room.go b/internal/ui/uilocker/page_locker_room.go index d508db0..083cb1c 100644 --- a/internal/ui/uilocker/page_locker_room.go +++ b/internal/ui/uilocker/page_locker_room.go @@ -102,7 +102,7 @@ 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), "", From 9a5dcb93435c82928f527c66272a19cf6c805cd8 Mon Sep 17 00:00:00 2001 From: frag223 Date: Thu, 2 Jul 2026 06:23:38 +1000 Subject: [PATCH 23/72] remove unused page title --- docs/features.md | 4 +- docs/rules.md | 4 +- internal/ui/app.go | 8 ---- internal/ui/components/locker_room_list.go | 5 +-- .../ui/components/locker_room_list_test.go | 8 ++-- internal/ui/components/title_menu.go | 7 +--- internal/ui/page_title.go | 4 -- internal/ui/page_title_settings.go | 42 ------------------- internal/ui/uilocker/root_locker.go | 1 - internal/ui/uistate/state.go | 1 - 10 files changed, 12 insertions(+), 72 deletions(-) delete mode 100644 internal/ui/page_title_settings.go diff --git a/docs/features.md b/docs/features.md index 809c1c2..8154676 100644 --- a/docs/features.md +++ b/docs/features.md @@ -2,7 +2,7 @@ List of features intended for the project. -- [ ] Single battle +- [X] Single battle - [ ] Tournament mode -- [ ] Record battles +- [X] Record battles - [ ] Team statistics \ No newline at end of file diff --git a/docs/rules.md b/docs/rules.md index 1b0fd05..a16d340 100644 --- a/docs/rules.md +++ b/docs/rules.md @@ -9,5 +9,7 @@ Game rules. - 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. +- If the total number of remaining players on each team is equal, the round is considered a draw. - 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 +- After ten rounds, the coach with the most points wins. +- If both teams have the same number of points, the game is considered a draw. \ No newline at end of file diff --git a/internal/ui/app.go b/internal/ui/app.go index 6ebd8c0..2cc0c40 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -25,7 +25,6 @@ type RootModel struct { pageLocker tea.Model pageNewTournament tea.Model pageNewBattle tea.Model - pageTitleSettings tea.Model pageGame tea.Model globalState *uistate.GlobalState teamsSvc *teams.Service @@ -53,7 +52,6 @@ func New(deps Dependencies) *RootModel { pageLocker: uilocker.NewLockerModel(state, deps.TeamsSvc, deps.PlaybookSvc, theme), pageNewTournament: NewModelNewTournament(state, theme), pageNewBattle: uibattle.NewBattleModel(state, deps.TeamsSvc, deps.PlaybookSvc, deps.GameSvc, theme), - pageTitleSettings: NewModelTitleSettings(state, theme), pageGame: iugame.NewGameModel(state, deps.TeamsSvc, deps.GameSvc, theme), globalState: state, teamsSvc: deps.TeamsSvc, @@ -111,8 +109,6 @@ func (m *RootModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds = append(cmds, cmd) m.pageNewBattle, cmd = m.pageNewBattle.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) return m, tea.Batch(cmds...) @@ -130,8 +126,6 @@ func (m *RootModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.pageNewTournament, cmd = m.pageNewTournament.Update(msg) case uistate.PageNewBattle: m.pageNewBattle, cmd = m.pageNewBattle.Update(msg) - case uistate.PageTitleSettings: - m.pageTitleSettings, cmd = m.pageTitleSettings.Update(msg) case uistate.PageGame: m.pageGame, cmd = m.pageGame.Update(msg) } @@ -156,8 +150,6 @@ func (m *RootModel) View() tea.View { return m.pageNewTournament.View() case uistate.PageNewBattle: return m.pageNewBattle.View() - case uistate.PageTitleSettings: - return m.pageTitleSettings.View() case uistate.PageGame: return m.pageGame.View() } diff --git a/internal/ui/components/locker_room_list.go b/internal/ui/components/locker_room_list.go index 956856f..f19a8da 100644 --- a/internal/ui/components/locker_room_list.go +++ b/internal/ui/components/locker_room_list.go @@ -10,7 +10,6 @@ type LockerRoomItem int const ( ItemPlayers LockerRoomItem = iota ItemPlaybooks - ItemSettings ) func (i LockerRoomItem) String() string { @@ -19,8 +18,6 @@ func (i LockerRoomItem) String() string { return "Players" case ItemPlaybooks: return "Playbooks" - case ItemSettings: - return "Settings" } return "" } @@ -32,7 +29,7 @@ type LockerRoomList struct { func NewLockerRoomList(theme styles.IceTheme) LockerRoomList { return LockerRoomList{ List: NewList(ListConfig[LockerRoomItem]{ - Items: []LockerRoomItem{ItemPlayers, ItemPlaybooks, ItemSettings}, + Items: []LockerRoomItem{ItemPlayers, ItemPlaybooks}, ItemMapper: func(i LockerRoomItem) ListItem[LockerRoomItem] { return ListItem[LockerRoomItem]{ Data: i, diff --git a/internal/ui/components/locker_room_list_test.go b/internal/ui/components/locker_room_list_test.go index bc32c7b..68a4919 100644 --- a/internal/ui/components/locker_room_list_test.go +++ b/internal/ui/components/locker_room_list_test.go @@ -12,9 +12,9 @@ func TestLockerRoomList(t *testing.T) { group := odize.NewGroup(t, nil) theme := styles.NewIceTheme() - group.Test("NewLockerRoomList should have 3 items", func(t *testing.T) { + group.Test("NewLockerRoomList should have 2 items", func(t *testing.T) { l := NewLockerRoomList(theme) - odize.AssertEqual(t, 3, len(l.Model.Items())) + odize.AssertEqual(t, 2, len(l.Model.Items())) odize.AssertEqual(t, ItemPlayers, l.SelectedItem()) }) @@ -42,9 +42,9 @@ func TestLockerRoomList(t *testing.T) { l.Update(tea.KeyPressMsg{Text: "up"}) odize.AssertEqual(t, 0, l.Model.Index()) - l.Model.Select(2) + l.Model.Select(1) l.Update(tea.KeyPressMsg{Text: "down"}) - odize.AssertEqual(t, 2, l.Model.Index()) + odize.AssertEqual(t, 1, l.Model.Index()) }) err := group.Run() diff --git a/internal/ui/components/title_menu.go b/internal/ui/components/title_menu.go index fdd10b6..7db6ee8 100644 --- a/internal/ui/components/title_menu.go +++ b/internal/ui/components/title_menu.go @@ -11,7 +11,6 @@ const ( TitleItemLockerRoom TitleItemNewTournament TitleItemNewBattleSelection - TitleItemSettings ) func (i TitleItem) String() string { @@ -24,8 +23,6 @@ func (i TitleItem) String() string { return "New Tournament" case TitleItemNewBattleSelection: return "New Battle" - case TitleItemSettings: - return "Settings" } return "" } @@ -40,7 +37,7 @@ 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, @@ -86,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_title.go b/internal/ui/page_title.go index 69d2925..40a76c0 100644 --- a/internal/ui/page_title.go +++ b/internal/ui/page_title.go @@ -109,10 +109,6 @@ func (m *ModelTitle) handleMenuSelect() (tea.Model, tea.Cmd, bool) { return m, func() tea.Msg { return uistate.MsgSwitchPage{NewPage: uistate.PageNewBattle} }, true - case components.TitleItemSettings: - return m, func() tea.Msg { - return uistate.MsgSwitchPage{NewPage: uistate.PageTitleSettings} - }, true } return nil, nil, false diff --git a/internal/ui/page_title_settings.go b/internal/ui/page_title_settings.go deleted file mode 100644 index bdd2f77..0000000 --- a/internal/ui/page_title_settings.go +++ /dev/null @@ -1,42 +0,0 @@ -package ui - -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 ModelTitleSettings struct { - width int - height int - theme styles.IceTheme - globalState *uistate.GlobalState -} - -func NewModelTitleSettings(globalState *uistate.GlobalState, theme styles.IceTheme) *ModelTitleSettings { - return &ModelTitleSettings{ - globalState: globalState, - theme: theme, - } -} - -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 uistate.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/uilocker/root_locker.go b/internal/ui/uilocker/root_locker.go index 7474db4..d10a10a 100644 --- a/internal/ui/uilocker/root_locker.go +++ b/internal/ui/uilocker/root_locker.go @@ -27,7 +27,6 @@ type MsgSwitchLockerPage struct { // LockerModel handles all locker room related pages. type LockerModel struct { currentPage SubPageLocker - subPageLocker tea.Model subPageLockerRoom tea.Model subPageLockerPlayers tea.Model subPageLockerPlaybooksList tea.Model diff --git a/internal/ui/uistate/state.go b/internal/ui/uistate/state.go index 2f148d8..006fa2e 100644 --- a/internal/ui/uistate/state.go +++ b/internal/ui/uistate/state.go @@ -26,7 +26,6 @@ const ( PageLockerRoom PageNewTournament PageNewBattle - PageTitleSettings PageGame ) From 2d1edb068b4826a07e26f1924461749578151567 Mon Sep 17 00:00:00 2001 From: frag223 Date: Thu, 2 Jul 2026 06:26:31 +1000 Subject: [PATCH 24/72] update feat --- docs/features.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/features.md b/docs/features.md index 8154676..e3c7731 100644 --- a/docs/features.md +++ b/docs/features.md @@ -3,6 +3,8 @@ List of features intended for the project. - [X] Single battle +- [ ] Events emitted for lane battles +- [ ] Settings to clear game data - [ ] Tournament mode - [X] Record battles - [ ] Team statistics \ No newline at end of file From f4cd0c22189bfa1168f3d72b9efd870633dd678f Mon Sep 17 00:00:00 2001 From: frag223 Date: Thu, 2 Jul 2026 06:36:17 +1000 Subject: [PATCH 25/72] wip --- internal/ui/components/ai_team_list.go | 3 +- internal/ui/components/formation_list.go | 7 +- internal/ui/components/list.go | 70 +++++++++-- internal/ui/components/list_test.go | 130 +++++++++++++++++++++ internal/ui/components/locker_room_list.go | 3 +- internal/ui/components/playbook_list.go | 3 +- 6 files changed, 198 insertions(+), 18 deletions(-) create mode 100644 internal/ui/components/list_test.go diff --git a/internal/ui/components/ai_team_list.go b/internal/ui/components/ai_team_list.go index 4d5dff5..24e965e 100644 --- a/internal/ui/components/ai_team_list.go +++ b/internal/ui/components/ai_team_list.go @@ -22,7 +22,8 @@ func NewAITeamList(items []teams.AITeam, theme styles.IceTheme) AITeamList { FilterVal: item.Team.Name, } }, - EnableFiltering: true, + EnableFiltering: true, + DisableAutoResize: true, }, theme), } } diff --git a/internal/ui/components/formation_list.go b/internal/ui/components/formation_list.go index 4c4a239..827fb58 100644 --- a/internal/ui/components/formation_list.go +++ b/internal/ui/components/formation_list.go @@ -23,9 +23,10 @@ type FormationListConfig struct { func NewFormationList(config FormationListConfig, theme styles.IceTheme) FormationList { l := FormationList{ List: NewList(ListConfig[playbooks.Formation]{ - Title: config.Title, - Items: config.Items, - EnableFiltering: config.EnableFiltering, + Title: config.Title, + Items: config.Items, + EnableFiltering: config.EnableFiltering, + DisableAutoResize: true, ItemMapper: func(f playbooks.Formation) ListItem[playbooks.Formation] { desc := "" if config.ShowDescription { diff --git a/internal/ui/components/list.go b/internal/ui/components/list.go index 01b9936..6c5b17a 100644 --- a/internal/ui/components/list.go +++ b/internal/ui/components/list.go @@ -1,15 +1,25 @@ package components import ( + "cmp" + "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 - ItemMapper func(T) ListItem[T] + 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 { @@ -24,10 +34,15 @@ 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 + 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] { @@ -52,9 +67,14 @@ func NewList[T any](config ListConfig[T], theme styles.IceTheme) List[T] { l.Styles.Title = theme.Title return List[T]{ - Model: l, - Active: true, - ItemMapper: config.ItemMapper, + Model: l, + Active: true, + AutoResize: !config.DisableAutoResize, + ItemMapper: config.ItemMapper, + topPadding: cmp.Or(config.TopPadding, 1), + bottomPadding: cmp.Or(config.BottomPadding, 1), + leftPadding: cmp.Or(config.LeftPadding, 2), + rightPadding: cmp.Or(config.RightPadding, 2), } } @@ -63,16 +83,42 @@ func (l *List[T]) Update(msg tea.Msg) (List[T], tea.Cmd) { return *l, nil } var cmd tea.Cmd + + switch msg := msg.(type) { + case tea.WindowSizeMsg: + if l.AutoResize { + l.SetSize(msg.Width, msg.Height) + } + } + l.Model, cmd = l.Model.Update(msg) return *l, cmd } func (l *List[T]) View() string { - return l.Model.View() + return lipgloss.NewStyle(). + Padding(l.topPadding, l.rightPadding, l.bottomPadding, l.leftPadding). + Render(l.Model.View()) } func (l *List[T]) SetSize(width, height int) { - l.Model.SetSize(width, height) + 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 { diff --git a/internal/ui/components/list_test.go b/internal/ui/components/list_test.go new file mode 100644 index 0000000..204ee1b --- /dev/null +++ b/internal/ui/components/list_test.go @@ -0,0 +1,130 @@ +package components + +import ( + "testing" + + tea "charm.land/bubbletea/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 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: left=2, right=2, top=1, bottom=1 + // Total X padding = 4, Total Y padding = 2 + odize.AssertEqual(t, 96, l.Model.Width()) + odize.AssertEqual(t, 48, 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, 76, newModel.Model.Width()) + odize.AssertEqual(t, 38, 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) + }). + Run() + + odize.AssertNoError(t, err) +} diff --git a/internal/ui/components/locker_room_list.go b/internal/ui/components/locker_room_list.go index f19a8da..0b689c3 100644 --- a/internal/ui/components/locker_room_list.go +++ b/internal/ui/components/locker_room_list.go @@ -36,7 +36,8 @@ func NewLockerRoomList(theme styles.IceTheme) LockerRoomList { TitleVal: i.String(), } }, - EnableFiltering: false, + EnableFiltering: false, + DisableAutoResize: true, }, theme), } } diff --git a/internal/ui/components/playbook_list.go b/internal/ui/components/playbook_list.go index 7409d13..ebdd7b5 100644 --- a/internal/ui/components/playbook_list.go +++ b/internal/ui/components/playbook_list.go @@ -22,7 +22,8 @@ func NewPlaybookList(items []playbooks.Playbook, theme styles.IceTheme) Playbook FilterVal: item.Name, } }, - EnableFiltering: true, + EnableFiltering: true, + DisableAutoResize: true, }, theme), } } From 0d5a20c7812d692197314ecb75004c4f1c4670a4 Mon Sep 17 00:00:00 2001 From: frag223 Date: Thu, 2 Jul 2026 07:05:15 +1000 Subject: [PATCH 26/72] finally, what a waste --- internal/ui/components/list.go | 36 +++++++++------ internal/ui/components/list_test.go | 46 ++++++++++++++++--- internal/ui/uibattle/page_battle_selection.go | 6 ++- 3 files changed, 66 insertions(+), 22 deletions(-) diff --git a/internal/ui/components/list.go b/internal/ui/components/list.go index 6c5b17a..6e5675a 100644 --- a/internal/ui/components/list.go +++ b/internal/ui/components/list.go @@ -1,8 +1,6 @@ package components import ( - "cmp" - "charm.land/bubbles/v2/list" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" @@ -61,6 +59,7 @@ func NewList[T any](config ListConfig[T], theme styles.IceTheme) List[T] { 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) @@ -71,10 +70,10 @@ func NewList[T any](config ListConfig[T], theme styles.IceTheme) List[T] { Active: true, AutoResize: !config.DisableAutoResize, ItemMapper: config.ItemMapper, - topPadding: cmp.Or(config.TopPadding, 1), - bottomPadding: cmp.Or(config.BottomPadding, 1), - leftPadding: cmp.Or(config.LeftPadding, 2), - rightPadding: cmp.Or(config.RightPadding, 2), + topPadding: config.TopPadding, + bottomPadding: config.BottomPadding, + leftPadding: config.LeftPadding, + rightPadding: config.RightPadding, } } @@ -84,21 +83,31 @@ func (l *List[T]) Update(msg tea.Msg) (List[T], tea.Cmd) { } var cmd tea.Cmd - switch msg := msg.(type) { - case tea.WindowSizeMsg: + l.Model, cmd = l.Model.Update(msg) + + if wm, ok := msg.(tea.WindowSizeMsg); ok { if l.AutoResize { - l.SetSize(msg.Width, msg.Height) + l.SetSize(wm.Width, wm.Height) + } else { + l.SetSize(l.width, l.height) } } - l.Model, cmd = l.Model.Update(msg) return *l, cmd } func (l *List[T]) View() string { - return lipgloss.NewStyle(). - Padding(l.topPadding, l.rightPadding, l.bottomPadding, l.leftPadding). - Render(l.Model.View()) + 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) { @@ -161,4 +170,5 @@ func (l *List[T]) SetActive(active bool) { 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 index 204ee1b..8c2e489 100644 --- a/internal/ui/components/list_test.go +++ b/internal/ui/components/list_test.go @@ -1,9 +1,11 @@ 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" ) @@ -33,7 +35,7 @@ func TestList(t *testing.T) { odize.AssertTrue(t, ok) odize.AssertEqual(t, "Item 1", selected) }). - Test("SetSize updates dimensions with default padding", func(t *testing.T) { + Test("SetSize updates dimensions with no default padding", func(t *testing.T) { items := []string{"Item 1"} config := ListConfig[string]{ Items: items, @@ -44,10 +46,9 @@ func TestList(t *testing.T) { l := NewList(config, theme) l.SetSize(100, 50) - // Defaults: left=2, right=2, top=1, bottom=1 - // Total X padding = 4, Total Y padding = 2 - odize.AssertEqual(t, 96, l.Model.Width()) - odize.AssertEqual(t, 48, l.Model.Height()) + // 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"} @@ -62,8 +63,8 @@ func TestList(t *testing.T) { newModel, _ := l.Update(tea.WindowSizeMsg{Width: 80, Height: 40}) - odize.AssertEqual(t, 76, newModel.Model.Width()) - odize.AssertEqual(t, 38, newModel.Model.Height()) + 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"} @@ -124,6 +125,37 @@ func TestList(t *testing.T) { // 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/uibattle/page_battle_selection.go b/internal/ui/uibattle/page_battle_selection.go index 8eb6882..c9d0d2d 100644 --- a/internal/ui/uibattle/page_battle_selection.go +++ b/internal/ui/uibattle/page_battle_selection.go @@ -114,8 +114,10 @@ func (m *PageBattleSelectionModel) 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) + 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)) From 195c2f18a865d5e362201ae1503b843b8b66a610 Mon Sep 17 00:00:00 2001 From: frag223 Date: Thu, 2 Jul 2026 12:33:21 +1000 Subject: [PATCH 27/72] update features.md --- docs/features.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/features.md b/docs/features.md index e3c7731..50936fc 100644 --- a/docs/features.md +++ b/docs/features.md @@ -3,6 +3,7 @@ List of features intended for the project. - [X] Single battle +- [ ] Battle confirmation upgrade - [ ] Events emitted for lane battles - [ ] Settings to clear game data - [ ] Tournament mode From a375369a36cfab3a7a2352c4261c7e672803f3f2 Mon Sep 17 00:00:00 2001 From: frag223 Date: Thu, 2 Jul 2026 12:37:55 +1000 Subject: [PATCH 28/72] update features.md --- internal/ui/components/team_tile.go | 79 +++++++++++++++++++ internal/ui/components/team_tile_test.go | 41 ++++++++++ internal/ui/uibattle/page_battle_confirm.go | 77 +++++++++++++++--- .../ui/uibattle/page_battle_confirm_test.go | 37 ++++++++- 4 files changed, 223 insertions(+), 11 deletions(-) create mode 100644 internal/ui/components/team_tile.go create mode 100644 internal/ui/components/team_tile_test.go diff --git a/internal/ui/components/team_tile.go b/internal/ui/components/team_tile.go new file mode 100644 index 0000000..38e4a72 --- /dev/null +++ b/internal/ui/components/team_tile.go @@ -0,0 +1,79 @@ +package components + +import ( + "fmt" + "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 { + renderField := func(label, value string) string { + return lipgloss.JoinHorizontal( + lipgloss.Top, + theme.Muted.Render(fmt.Sprintf("%s: ", label)), + theme.Base.Render(nonEmpty(value, "Unknown")), + ) + } + + players := make([]string, 0, len(t.PlayerNames)) + for _, playerName := range t.PlayerNames { + trimmedName := strings.TrimSpace(playerName) + if trimmedName == "" { + continue + } + + players = append(players, theme.Player.Render(fmt.Sprintf("• %s", trimmedName))) + } + + playerView := theme.Muted.Render("No players") + if len(players) > 0 { + playerView = lipgloss.JoinVertical(lipgloss.Left, players...) + } + + tileBody := lipgloss.JoinVertical( + lipgloss.Left, + theme.SecondaryHeader.Render(nonEmpty(t.TeamName, "Unknown Team")), + renderField("Coach", t.CoachName), + renderField("Playbook", t.PlaybookName), + "", + theme.Muted.Render("Players"), + playerView, + ) + + tileStyle := theme.RoundBorder + tileStyle.Align(lipgloss.Left) + + if width > 0 { + tileStyle = tileStyle.Width(width) + } + + return tileStyle.Render(tileBody) +} + +func nonEmpty(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/uibattle/page_battle_confirm.go b/internal/ui/uibattle/page_battle_confirm.go index 680e3e5..b41164e 100644 --- a/internal/ui/uibattle/page_battle_confirm.go +++ b/internal/ui/uibattle/page_battle_confirm.go @@ -117,15 +117,7 @@ func (m *PageBattleConfirmModel) View() tea.View { content = m.theme.Logo.Render(fmt.Sprintf("Error: %v", m.err)) header = "ERROR" } else { - content = 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", - ) + content = m.viewConfirmation() } centeredContent := lipgloss.Place( @@ -148,3 +140,70 @@ func (m *PageBattleConfirmModel) View() tea.View { return view } + +func (m *PageBattleConfirmModel) viewConfirmation() string { + userTeamName := "" + userCoachName := "" + userPlayerNames := []string{} + if m.globalState.Team != nil { + userTeamName = m.globalState.Team.Name + userPlayerNames = getPlayerNames(m.globalState.Team.Players) + } + if m.globalState.Coach != nil { + userCoachName = m.globalState.Coach.Name + } + + userPlaybookName := "" + if m.selectedPlaybook != nil { + userPlaybookName = m.selectedPlaybook.Name + } + + opponentTeamName := "" + opponentCoachName := "" + opponentPlaybookName := "" + opponentPlayerNames := []string{} + if m.selectedAITeam != nil { + opponentTeamName = m.selectedAITeam.Team.Name + opponentCoachName = m.selectedAITeam.Coach.Name + opponentPlaybookName = m.selectedAITeam.Playbook.Name + opponentPlayerNames = getPlayerNames(m.selectedAITeam.Team.Players) + } + + yourTile := components.NewTeamTile(userTeamName, userCoachName, userPlaybookName, userPlayerNames) + opponentTile := components.NewTeamTile(opponentTeamName, opponentCoachName, opponentPlaybookName, opponentPlayerNames) + + 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 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 getPlayerNames(players []teams.Player) []string { + names := make([]string, len(players)) + for i, player := range players { + names[i] = player.Name + } + + return names +} diff --git a/internal/ui/uibattle/page_battle_confirm_test.go b/internal/ui/uibattle/page_battle_confirm_test.go index 1a23e9c..5c2846f 100644 --- a/internal/ui/uibattle/page_battle_confirm_test.go +++ b/internal/ui/uibattle/page_battle_confirm_test.go @@ -13,11 +13,16 @@ import ( ) 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{ - Team: &teams.Team{ID: 1, Name: "My Team"}, + 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) @@ -26,15 +31,43 @@ func TestPageBattleConfirmModel(t *testing.T) { m.SetData( &playbooks.Playbook{Name: "Playbook 1"}, - &teams.AITeam{Team: teams.Team{Name: "Opponent A"}}, + &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) { From 8d265dbfa42ac39c2b725d6b1a3e6c74ce8813b0 Mon Sep 17 00:00:00 2001 From: frag223 Date: Thu, 2 Jul 2026 12:39:37 +1000 Subject: [PATCH 29/72] simplification --- internal/ui/components/team_tile.go | 66 ++++++++++++++--------------- 1 file changed, 33 insertions(+), 33 deletions(-) diff --git a/internal/ui/components/team_tile.go b/internal/ui/components/team_tile.go index 38e4a72..1e7f7c6 100644 --- a/internal/ui/components/team_tile.go +++ b/internal/ui/components/team_tile.go @@ -1,7 +1,6 @@ package components import ( - "fmt" "slices" "strings" @@ -26,41 +25,16 @@ func NewTeamTile(teamName, coachName, playbookName string, playerNames []string) } func (t TeamTile) View(theme styles.IceTheme, width int) string { - renderField := func(label, value string) string { - return lipgloss.JoinHorizontal( - lipgloss.Top, - theme.Muted.Render(fmt.Sprintf("%s: ", label)), - theme.Base.Render(nonEmpty(value, "Unknown")), - ) - } - - players := make([]string, 0, len(t.PlayerNames)) - for _, playerName := range t.PlayerNames { - trimmedName := strings.TrimSpace(playerName) - if trimmedName == "" { - continue - } - - players = append(players, theme.Player.Render(fmt.Sprintf("• %s", trimmedName))) - } - - playerView := theme.Muted.Render("No players") - if len(players) > 0 { - playerView = lipgloss.JoinVertical(lipgloss.Left, players...) - } - - tileBody := lipgloss.JoinVertical( - lipgloss.Left, - theme.SecondaryHeader.Render(nonEmpty(t.TeamName, "Unknown Team")), - renderField("Coach", t.CoachName), - renderField("Playbook", t.PlaybookName), + 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"), - playerView, + renderPlayers(theme, t.PlayerNames), ) - tileStyle := theme.RoundBorder - tileStyle.Align(lipgloss.Left) + tileStyle := theme.RoundBorder.Copy().Align(lipgloss.Left) if width > 0 { tileStyle = tileStyle.Width(width) @@ -69,7 +43,33 @@ func (t TeamTile) View(theme styles.IceTheme, width int) string { return tileStyle.Render(tileBody) } -func nonEmpty(value, fallback string) string { +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 From c82751627091c59bd055fe97094ab319d85aa012 Mon Sep 17 00:00:00 2001 From: frag223 Date: Thu, 2 Jul 2026 12:42:12 +1000 Subject: [PATCH 30/72] simplification --- internal/ui/uibattle/page_battle_confirm.go | 93 +++++++++++++-------- 1 file changed, 56 insertions(+), 37 deletions(-) diff --git a/internal/ui/uibattle/page_battle_confirm.go b/internal/ui/uibattle/page_battle_confirm.go index b41164e..162d20f 100644 --- a/internal/ui/uibattle/page_battle_confirm.go +++ b/internal/ui/uibattle/page_battle_confirm.go @@ -50,7 +50,6 @@ func (m *PageBattleConfirmModel) Init() tea.Cmd { func (m *PageBattleConfirmModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.footer.Update(msg) - var cmds []tea.Cmd switch msg := msg.(type) { case tea.WindowSizeMsg: @@ -71,12 +70,16 @@ func (m *PageBattleConfirmModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } - return m, tea.Batch(cmds...) + return m, nil } func (m *PageBattleConfirmModel) createGame() tea.Msg { - if m.selectedPlaybook == nil || m.selectedAITeam == nil { - return fmt.Errorf("missing playbook or opponent") + if m.globalState == nil || m.globalState.Team == nil || m.selectedPlaybook == nil || m.selectedAITeam == nil { + return fmt.Errorf("missing team, playbook, or opponent") + } + + if m.gameSvc == nil { + return fmt.Errorf("game service is not configured") } params := games.NewGameParams{ @@ -142,36 +145,30 @@ func (m *PageBattleConfirmModel) View() tea.View { } func (m *PageBattleConfirmModel) viewConfirmation() string { - userTeamName := "" - userCoachName := "" - userPlayerNames := []string{} - if m.globalState.Team != nil { - userTeamName = m.globalState.Team.Name - userPlayerNames = getPlayerNames(m.globalState.Team.Players) - } - if m.globalState.Coach != nil { - userCoachName = m.globalState.Coach.Name + var userTeam *teams.Team + var userCoach *teams.Coach + if m.globalState != nil { + userTeam = m.globalState.Team + userCoach = m.globalState.Coach } - userPlaybookName := "" - if m.selectedPlaybook != nil { - userPlaybookName = m.selectedPlaybook.Name - } + opponentTeam, opponentCoach, opponentPlaybook := m.opponentSelection() - opponentTeamName := "" - opponentCoachName := "" - opponentPlaybookName := "" - opponentPlayerNames := []string{} - if m.selectedAITeam != nil { - opponentTeamName = m.selectedAITeam.Team.Name - opponentCoachName = m.selectedAITeam.Coach.Name - opponentPlaybookName = m.selectedAITeam.Playbook.Name - opponentPlayerNames = getPlayerNames(m.selectedAITeam.Team.Players) - } + yourTile := newTeamTile(userTeam, userCoach, m.selectedPlaybook) + opponentTile := newTeamTile(opponentTeam, opponentCoach, opponentPlaybook) + tiles := m.viewVSTiles(yourTile, opponentTile) - yourTile := components.NewTeamTile(userTeamName, userCoachName, userPlaybookName, userPlayerNames) - opponentTile := components.NewTeamTile(opponentTeamName, opponentCoachName, opponentPlaybookName, opponentPlayerNames) + 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) @@ -188,15 +185,37 @@ func (m *PageBattleConfirmModel) viewConfirmation() string { ) tiles := lipgloss.JoinHorizontal(lipgloss.Top, leftTileView, vsView, rightTileView) + return tiles +} - 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) 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 { From a05de81b59c741a25868728e726a3e5d72053af1 Mon Sep 17 00:00:00 2001 From: frag223 Date: Thu, 2 Jul 2026 12:53:27 +1000 Subject: [PATCH 31/72] update --- docs/features.md | 3 ++- internal/teams/service.go | 14 +++++++++++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/docs/features.md b/docs/features.md index 50936fc..6d9d369 100644 --- a/docs/features.md +++ b/docs/features.md @@ -3,7 +3,8 @@ List of features intended for the project. - [X] Single battle -- [ ] Battle confirmation upgrade +- [X] Battle confirmation upgrade +- [ ] Better player names - [ ] Events emitted for lane battles - [ ] Settings to clear game data - [ ] Tournament mode diff --git a/internal/teams/service.go b/internal/teams/service.go index 7208127..697e329 100644 --- a/internal/teams/service.go +++ b/internal/teams/service.go @@ -7,6 +7,7 @@ import ( "fmt" "github.com/code-gorilla-au/rush/internal/database" + "github.com/go-faker/faker/v4" ) type Service struct { @@ -175,17 +176,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) } From 42b546c9641e463f017e3cdeb8d74d298c60581d Mon Sep 17 00:00:00 2001 From: frag223 Date: Thu, 2 Jul 2026 20:27:58 +1000 Subject: [PATCH 32/72] fixing locker room --- internal/teams/service.go | 4 +- internal/ui/app.go | 4 + internal/ui/app_test.go | 22 ++ internal/ui/page_create_coach.go | 10 + internal/ui/page_create_coach_test.go | 69 +++++++ .../ui/uilocker/page_locker_playbooks_edit.go | 191 ++++++++++-------- 6 files changed, 219 insertions(+), 81 deletions(-) create mode 100644 internal/ui/page_create_coach_test.go diff --git a/internal/teams/service.go b/internal/teams/service.go index 697e329..15a7ce8 100644 --- a/internal/teams/service.go +++ b/internal/teams/service.go @@ -177,7 +177,7 @@ func (s *Service) CreateTeam(ctx context.Context, name string, coachID int64, is } type createPlayerParams struct { - name string `faker:"name"` + Name string `faker:"name"` } func (s *Service) createPlayers(ctx context.Context, teamID int64) ([]database.Player, error) { @@ -191,7 +191,7 @@ func (s *Service) createPlayers(ctx context.Context, teamID int64) ([]database.P } model, err := s.store.CreatePlayer(ctx, database.CreatePlayerParams{ - Name: playerName.name, + Name: playerName.Name, TeamID: sql.NullInt64{ Int64: teamID, Valid: true, diff --git a/internal/ui/app.go b/internal/ui/app.go index 2cc0c40..c92176e 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -86,6 +86,10 @@ func (m *RootModel) Update(msg tea.Msg) (tea.Model, 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) + } return m, tea.Batch(cmds...) case tea.KeyMsg: switch msg.String() { diff --git a/internal/ui/app_test.go b/internal/ui/app_test.go index 106e5ee..4358016 100644 --- a/internal/ui/app_test.go +++ b/internal/ui/app_test.go @@ -5,7 +5,9 @@ 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" "github.com/code-gorilla-au/rush/internal/ui/uitest" ) @@ -74,6 +76,26 @@ func TestNew(t *testing.T) { 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/page_create_coach.go b/internal/ui/page_create_coach.go index 404dede..f085bfe 100644 --- a/internal/ui/page_create_coach.go +++ b/internal/ui/page_create_coach.go @@ -93,6 +93,9 @@ func (m *ModelCreateCoach) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, cmd } + case error: + m.err = msg + case uistate.MsgStateUpdated: return m.handleStateUpdated(msg) } @@ -198,6 +201,11 @@ 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"), @@ -208,6 +216,8 @@ func (m *ModelCreateCoach) View() tea.View { m.theme.SecondaryHeader.Render("Team Details"), m.teamInput.View(), "", + errorView, + "", m.footer.View(m.theme), ) 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/uilocker/page_locker_playbooks_edit.go b/internal/ui/uilocker/page_locker_playbooks_edit.go index 530daf5..205f49b 100644 --- a/internal/ui/uilocker/page_locker_playbooks_edit.go +++ b/internal/ui/uilocker/page_locker_playbooks_edit.go @@ -32,6 +32,12 @@ func newLockerPlaybooksEditKeyMap() lockerPlaybooksEditKeyMap { } } +const ( + availableFormationListIndex = iota + selectedFormationListIndex + maxFormationsPerPlaybook = 10 +) + type ModelLockerPlaybooksEdit struct { width int height int @@ -51,12 +57,13 @@ type ModelLockerPlaybooksEdit struct { } func NewModelLockerPlaybooksEdit(state *uistate.GlobalState, playbookSvc *playbooks.Service, theme styles.IceTheme) *ModelLockerPlaybooksEdit { - return &ModelLockerPlaybooksEdit{ + keys := newLockerPlaybooksEditKeyMap() + model := &ModelLockerPlaybooksEdit{ theme: theme, globalState: state, playbookSvc: playbookSvc, - keys: newLockerPlaybooksEditKeyMap(), - footer: components.NewFooter(newLockerPlaybooksEditKeyMap()), + keys: keys, + footer: components.NewFooter(keys), formationList: components.NewFormationList(components.FormationListConfig{ Title: "Available Formations", Items: playbooks.Formations(), @@ -70,13 +77,16 @@ func NewModelLockerPlaybooksEdit(state *uistate.GlobalState, playbookSvc *playbo ShowDescription: false, }, theme), } + model.setActiveList(availableFormationListIndex) + return model } func (m *ModelLockerPlaybooksEdit) Init() tea.Cmd { - return nil + 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) { @@ -86,115 +96,137 @@ func (m *ModelLockerPlaybooksEdit) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case MsgSwitchLockerPage: if msg.NewPage == SubPageLockerPlaybooksEdit { if msg.Playbook != nil { - m.load(msg.Playbook) + cmds = append(cmds, m.load(msg.Playbook)) } else { - m.reset() + cmds = append(cmds, 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 MsgSwitchLockerPage{ - NewPage: SubPageLockerPlaybooksCreate, - Playbook: &playbooks.Playbook{ - ID: m.playbookID, - Name: m.playbookName, - Description: m.playbookDescription, - Formations: m.newFormations, - }, - } - } + 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 - m.formationList.SetSize(msg.Width/2-4, msg.Height-15) - m.selectedFormationList.SetSize(msg.Width/2-4, msg.Height-15) - m.footer.Update(msg) + listWidth := (m.width - 10) / 2 + listHeight := m.height - 20 + m.formationList.SetSize(listWidth, listHeight) + m.selectedFormationList.SetSize(listWidth, listHeight) } - cmds = append(cmds, m.updateAddFormations(msg)) + 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() { +func (m *ModelLockerPlaybooksEdit) reset() tea.Cmd { m.newFormations = nil m.playbookID = 0 m.playbookName = "" m.playbookDescription = "" - m.selectedFormationList.SetItems(nil) + m.formationList.Reset() + m.selectedFormationList.Reset() + m.setActiveList(availableFormationListIndex) m.err = nil + return m.selectedFormationList.SetItems(nil) } -func (m *ModelLockerPlaybooksEdit) load(p *playbooks.Playbook) { +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.selectedFormationList.SetItems(m.newFormations) + m.formationList.Reset() + m.selectedFormationList.Reset() + m.setActiveList(availableFormationListIndex) m.err = nil + return m.selectedFormationList.SetItems(m.newFormations) } -func (m *ModelLockerPlaybooksEdit) updateAddFormations(msg tea.Msg) tea.Cmd { - var cmds []tea.Cmd - - switch msg := msg.(type) { - case tea.KeyMsg: +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): if m.formationList.IsFiltering() { - break + return m, nil, false } - 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 + 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 + case key.Matches(msg, m.keys.Tab): + if m.formationList.IsFiltering() { + return m, nil, false + } + if m.activeList == availableFormationListIndex { + m.setActiveList(selectedFormationListIndex) + } else { + m.setActiveList(availableFormationListIndex) + } + return m, nil, true + 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 } } - m.formationList.SetActive(m.activeList == 0) - m.selectedFormationList.SetActive(m.activeList == 1) + return m, nil, false +} - var cmd tea.Cmd - m.formationList, cmd = m.formationList.Update(msg) - cmds = append(cmds, cmd) +func (m *ModelLockerPlaybooksEdit) setActiveList(activeList int) { + m.activeList = activeList + m.formationList.SetActive(activeList == availableFormationListIndex) + m.selectedFormationList.SetActive(activeList == selectedFormationListIndex) +} - m.selectedFormationList, cmd = m.selectedFormationList.Update(msg) - cmds = append(cmds, cmd) +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 + } - return tea.Batch(cmds...) + 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 { @@ -221,6 +253,10 @@ func (m *ModelLockerPlaybooksEdit) savePlaybook() tea.Msg { } func (m *ModelLockerPlaybooksEdit) View() tea.View { + if m.width == 0 || m.height == 0 { + return tea.NewView("Initializing...") + } + view := tea.NewView("") view.AltScreen = true @@ -230,13 +266,10 @@ func (m *ModelLockerPlaybooksEdit) View() tea.View { 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) - availableView := m.formationList.View(m.theme) selectedView := m.selectedFormationList.View(m.theme) - if m.activeList == 0 { + if m.activeList == availableFormationListIndex { availableView = m.theme.ActiveBorder.Render(availableView) selectedView = m.theme.InactiveBorder.Render(selectedView) } else { @@ -256,7 +289,7 @@ func (m *ModelLockerPlaybooksEdit) View() tea.View { selectedView, ), ) - content += "\n\n" + m.theme.Muted.Render(fmt.Sprintf("%d/10 formations • Tab: switch • Enter: add/remove • 's': save", len(m.newFormations))) + 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( From 1a597ae693e159558d98dd380f3b7c52c749496e Mon Sep 17 00:00:00 2001 From: frag223 Date: Thu, 2 Jul 2026 20:33:27 +1000 Subject: [PATCH 33/72] adding feature --- docs/features.md | 3 ++- internal/ui/uilocker/page_locker_playbooks_list.go | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/features.md b/docs/features.md index 6d9d369..620ee89 100644 --- a/docs/features.md +++ b/docs/features.md @@ -4,7 +4,8 @@ List of features intended for the project. - [X] Single battle - [X] Battle confirmation upgrade -- [ ] Better player names +- [X] Better player names +- [ ] Better coach creation journey - [ ] Events emitted for lane battles - [ ] Settings to clear game data - [ ] Tournament mode diff --git a/internal/ui/uilocker/page_locker_playbooks_list.go b/internal/ui/uilocker/page_locker_playbooks_list.go index c1c307b..b3a81d6 100644 --- a/internal/ui/uilocker/page_locker_playbooks_list.go +++ b/internal/ui/uilocker/page_locker_playbooks_list.go @@ -84,7 +84,7 @@ func (m *ModelLockerPlaybooksList) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case MsgPlaybooksLoaded: m.playbooksLoaded = true m.playbookList = components.NewPlaybookList(msg.Playbooks, m.theme) - m.playbookList.SetSize(m.width, m.height-10) + m.playbookList.SetSize(m.width/2, m.height-10) case MsgSwitchLockerPage: if msg.NewPage == SubPageLockerPlaybooksList { cmds = append(cmds, m.loadPlaybooks) From 33603aa912028dcdd2d4d0637ead1e8e34231d69 Mon Sep 17 00:00:00 2001 From: frag223 Date: Thu, 2 Jul 2026 20:46:09 +1000 Subject: [PATCH 34/72] adding random generator --- internal/teams/ai_teams.go | 7 +- internal/teams/name_generator.go | 111 ++++++++++++++++++++++++++ internal/teams/name_generator_test.go | 42 ++++++++++ internal/teams/service_test.go | 2 +- 4 files changed, 160 insertions(+), 2 deletions(-) create mode 100644 internal/teams/name_generator.go create mode 100644 internal/teams/name_generator_test.go diff --git a/internal/teams/ai_teams.go b/internal/teams/ai_teams.go index 85e1723..f4b9db9 100644 --- a/internal/teams/ai_teams.go +++ b/internal/teams/ai_teams.go @@ -12,7 +12,7 @@ const totalTeams = 12 type AIGenerationParams struct { CoachName string `faker:"name"` - TeamName string `faker:"username"` + TeamName string Persona string Formations []playbooks.Formation } @@ -230,6 +230,9 @@ func generateAITeams() ([]AIGenerationParams, error) { formationMap[f.Name] = f } + nameGen := NewTeamNameGenerator() + teamNames := nameGen.GenerateUnique(totalTeams) + for i := 0; i < totalTeams; i++ { tmpTeam := AIGenerationParams{} @@ -237,6 +240,8 @@ 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 diff --git a/internal/teams/name_generator.go b/internal/teams/name_generator.go new file mode 100644 index 0000000..f52a1d8 --- /dev/null +++ b/internal/teams/name_generator.go @@ -0,0 +1,111 @@ +package teams + +import ( + "fmt" + "math/rand/v2" + "strings" +) + +type TeamNameGenerator struct { + rng *rand.Rand +} + +func NewTeamNameGenerator() *TeamNameGenerator { + // Using a fixed seed for determinism across runs if desired, + // or we could pass one in. For now, let's just use a new RNG. + return &TeamNameGenerator{ + 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", + } +) + +func (g *TeamNameGenerator) Generate() string { + templates := []string{ + "The %s %s", // The {Adjective} {Noun}s + "%s %s", // {Place} {Noun}s + "%s %s", // {Adjective} {Creature}s (reusing Nouns) + "%s of %s", // {Noun} of {DoomThing} + "%s-%s", // {DockerAdjective}-{DockerNoun} + "%s %s", // {Profession} {PluralNoun} (reusing Nouns) + } + + templateIdx := g.rng.IntN(len(templates)) + template := templates[templateIdx] + + switch templateIdx { + case 0: // The {Adjective} {Noun}s + return fmt.Sprintf(template, g.randomString(adjectives), g.randomString(teamNouns)) + case 1: // {Place} {Noun}s + return fmt.Sprintf(template, g.randomString(places), g.randomString(teamNouns)) + case 2: // {Adjective} {Creature}s + return fmt.Sprintf(template, g.randomString(adjectives), g.randomString(teamNouns)) + case 3: // {Noun} of {DoomThing} + // Singularize noun for "X of Y"? Or keep plural? Example "Snacklords of Doom". + // Let's singularize some nouns if they end in 's'. + noun := g.randomString(teamNouns) + if strings.HasSuffix(noun, "s") { + noun = strings.TrimSuffix(noun, "s") + } + return fmt.Sprintf(template, noun, g.randomString(doomThings)) + case 4: // {DockerAdjective}-{DockerNoun} + return fmt.Sprintf(template, g.randomString(dockerAdjectives), g.randomString(dockerNouns)) + case 5: // {Profession} {PluralNoun} + return fmt.Sprintf(template, g.randomString(professions), g.randomString(teamNouns)) + default: + return "Unknown Team" + } +} + +func (g *TeamNameGenerator) randomString(list []string) string { + return list[g.rng.IntN(len(list))] +} + +func (g *TeamNameGenerator) GenerateUnique(count int) []string { + seen := make(map[string]bool) + names := make([]string, 0, count) + + for len(names) < count { + name := g.Generate() + if !seen[name] { + seen[name] = true + 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_test.go b/internal/teams/service_test.go index 70362df..9b4319a 100644 --- a/internal/teams/service_test.go +++ b/internal/teams/service_test.go @@ -222,7 +222,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() From db9b4d7467accac8b3d74d8cb1ef09a8f838890b Mon Sep 17 00:00:00 2001 From: frag223 Date: Thu, 2 Jul 2026 20:50:47 +1000 Subject: [PATCH 35/72] fixing --- internal/teams/name_generator.go | 82 ++++++++++++++++---------------- 1 file changed, 40 insertions(+), 42 deletions(-) diff --git a/internal/teams/name_generator.go b/internal/teams/name_generator.go index f52a1d8..812c0f4 100644 --- a/internal/teams/name_generator.go +++ b/internal/teams/name_generator.go @@ -1,7 +1,6 @@ package teams import ( - "fmt" "math/rand/v2" "strings" ) @@ -11,8 +10,6 @@ type TeamNameGenerator struct { } func NewTeamNameGenerator() *TeamNameGenerator { - // Using a fixed seed for determinism across runs if desired, - // or we could pass one in. For now, let's just use a new RNG. return &TeamNameGenerator{ rng: rand.New(rand.NewPCG(42, 42)), } @@ -53,59 +50,60 @@ var ( professions = []string{ "Cook", "Smith", "Baker", "Butcher", "Slayer", "Reaper", "Whacker", "Crusher", "Believer", "Denier", } -) -func (g *TeamNameGenerator) Generate() string { - templates := []string{ - "The %s %s", // The {Adjective} {Noun}s - "%s %s", // {Place} {Noun}s - "%s %s", // {Adjective} {Creature}s (reusing Nouns) - "%s of %s", // {Noun} of {DoomThing} - "%s-%s", // {DockerAdjective}-{DockerNoun} - "%s %s", // {Profession} {PluralNoun} (reusing Nouns) + 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) + }, } +) - templateIdx := g.rng.IntN(len(templates)) - template := templates[templateIdx] - - switch templateIdx { - case 0: // The {Adjective} {Noun}s - return fmt.Sprintf(template, g.randomString(adjectives), g.randomString(teamNouns)) - case 1: // {Place} {Noun}s - return fmt.Sprintf(template, g.randomString(places), g.randomString(teamNouns)) - case 2: // {Adjective} {Creature}s - return fmt.Sprintf(template, g.randomString(adjectives), g.randomString(teamNouns)) - case 3: // {Noun} of {DoomThing} - // Singularize noun for "X of Y"? Or keep plural? Example "Snacklords of Doom". - // Let's singularize some nouns if they end in 's'. - noun := g.randomString(teamNouns) - if strings.HasSuffix(noun, "s") { - noun = strings.TrimSuffix(noun, "s") - } - return fmt.Sprintf(template, noun, g.randomString(doomThings)) - case 4: // {DockerAdjective}-{DockerNoun} - return fmt.Sprintf(template, g.randomString(dockerAdjectives), g.randomString(dockerNouns)) - case 5: // {Profession} {PluralNoun} - return fmt.Sprintf(template, g.randomString(professions), g.randomString(teamNouns)) - default: - return "Unknown Team" - } +func (g *TeamNameGenerator) Generate() string { + pattern := namePatterns[g.rng.IntN(len(namePatterns))] + return pattern(g) } -func (g *TeamNameGenerator) randomString(list []string) string { +func (g *TeamNameGenerator) pick(list []string) string { return list[g.rng.IntN(len(list))] } func (g *TeamNameGenerator) GenerateUnique(count int) []string { - seen := make(map[string]bool) + if count <= 0 { + return []string{} + } + + seen := make(map[string]struct{}, count) names := make([]string, 0, count) for len(names) < count { name := g.Generate() - if !seen[name] { - seen[name] = true - names = append(names, name) + if _, exists := seen[name]; exists { + continue } + + seen[name] = struct{}{} + names = append(names, name) } + return names } From 69695f3b1e4ecc6d40f9bd180184358696147100 Mon Sep 17 00:00:00 2001 From: frag223 Date: Thu, 2 Jul 2026 21:23:16 +1000 Subject: [PATCH 36/72] update battle zone --- internal/games/game.go | 22 ++++++++++++++++++++++ internal/ui/components/game.go | 4 ++++ internal/ui/components/game_test.go | 2 ++ internal/ui/components/round.go | 18 +++++------------- internal/ui/components/round_test.go | 10 +++++----- 5 files changed, 38 insertions(+), 18 deletions(-) diff --git a/internal/games/game.go b/internal/games/game.go index 53497a6..b848f3c 100644 --- a/internal/games/game.go +++ b/internal/games/game.go @@ -97,6 +97,28 @@ func (g *Game) Name() string { return g.name } +func (g *Game) TeamAScore() int { + teamA := filterResultsByTeam(ResultTeamA, g.results) + return len(teamA) +} + +func (g *Game) TeamBScore() int { + teamB := filterResultsByTeam(ResultTeamB, g.results) + return len(teamB) +} + +func filterResultsByTeam(team ResultOutcome, results []Result) []Result { + var filteredResults []Result + + 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 diff --git a/internal/ui/components/game.go b/internal/ui/components/game.go index 9ac63b5..f2833cd 100644 --- a/internal/ui/components/game.go +++ b/internal/ui/components/game.go @@ -102,6 +102,9 @@ func (g *Game) View(theme styles.IceTheme) string { } 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 { @@ -126,6 +129,7 @@ func (g *Game) View(theme styles.IceTheme) string { 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 700da68..c62cb2e 100644 --- a/internal/ui/components/game_test.go +++ b/internal/ui/components/game_test.go @@ -64,6 +64,7 @@ func TestGameComponent(t *testing.T) { odize.AssertFalse(t, gComp.resolved) view := gComp.View(theme) odize.AssertTrue(t, strings.Contains(view, "ROUND 1")) + odize.AssertTrue(t, strings.Contains(view, "Team A 0 - 0 Team B")) odize.AssertTrue(t, strings.Contains(view, "Dual in progress...")) // Handle MsgResolveRound @@ -73,6 +74,7 @@ func TestGameComponent(t *testing.T) { // Resolved state 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/round.go b/internal/ui/components/round.go index b0477f5..2673845 100644 --- a/internal/ui/components/round.go +++ b/internal/ui/components/round.go @@ -24,26 +24,20 @@ func NewRound(round games.Round, teamAName, teamBName string) Round { } 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)) + dots := strings.TrimSpace(strings.Repeat("⬤ ", count)) content := theme.Player.Render(dots) - return lipgloss.NewStyle().Width(10).Align(align).Render(content) + return lipgloss.NewStyle().Width(laneWidth).Align(align).Render(content) } - header := lipgloss.JoinHorizontal(lipgloss.Top, - theme.TeamA.Render(r.teamAName), - theme.Separator.Render(" | "), - theme.TeamB.Render(r.teamBName), - ) - - divider := theme.Separator.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, - theme.Separator.Render(" | "), + theme.Separator.Render(" ┃ "), bPlayers, theme.Label.Render(fmt.Sprintf("Lane %d", laneNum)), ) @@ -51,8 +45,6 @@ func (r Round) View(theme styles.IceTheme) string { view := lipgloss.JoinVertical( lipgloss.Left, - header, - divider, renderLane(1), renderLane(2), renderLane(3), diff --git a/internal/ui/components/round_test.go b/internal/ui/components/round_test.go index d522de3..7e66818 100644 --- a/internal/ui/components/round_test.go +++ b/internal/ui/components/round_test.go @@ -36,19 +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") 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() From 1b6d0cfae58c2e9b2c73ec5ecf185d658c7d9b62 Mon Sep 17 00:00:00 2001 From: frag223 Date: Thu, 2 Jul 2026 21:47:03 +1000 Subject: [PATCH 37/72] adding statistics --- internal/database/game.sql.gen.go | 49 +++++ internal/database/queries/game.sql | 9 +- internal/games/interfaces.go | 1 + internal/games/service.go | 51 +++++ internal/games/service_test.go | 129 ++++++++++++ internal/games/types.go | 13 ++ internal/ui/app.go | 2 +- internal/ui/components/locker_room_list.go | 5 +- .../ui/components/locker_room_list_test.go | 12 +- internal/ui/uilocker/page_locker_room.go | 4 + internal/ui/uilocker/page_locker_room_test.go | 23 ++- .../uilocker/page_locker_team_statistics.go | 185 ++++++++++++++++++ internal/ui/uilocker/root_locker.go | 14 +- internal/ui/uilocker/root_locker_test.go | 7 +- 14 files changed, 492 insertions(+), 12 deletions(-) create mode 100644 internal/games/service_test.go create mode 100644 internal/ui/uilocker/page_locker_team_statistics.go 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/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/games/interfaces.go b/internal/games/interfaces.go index 13d4887..fa0d800 100644 --- a/internal/games/interfaces.go +++ b/internal/games/interfaces.go @@ -9,5 +9,6 @@ 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) } diff --git a/internal/games/service.go b/internal/games/service.go index 1e01a6c..3750e08 100644 --- a/internal/games/service.go +++ b/internal/games/service.go @@ -108,3 +108,54 @@ 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 { + 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 []Result + if err := json.Unmarshal(model.ResultsLog, &results); err != nil { + return TeamStatistics{}, fmt.Errorf("parsing game %d results: %w", model.ID, err) + } + + teamRoundsWon := len(filterResultsByTeam(ResultTeamA, results)) + teamRoundsLost := len(filterResultsByTeam(ResultTeamB, results)) + if model.TeamB.Valid && model.TeamB.Int64 == teamID { + teamRoundsWon, teamRoundsLost = teamRoundsLost, teamRoundsWon + } + + stats.RoundsWon += teamRoundsWon + stats.RoundsLost += teamRoundsLost + } + + 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 +} diff --git a/internal/games/service_test.go b/internal/games/service_test.go new file mode 100644 index 0000000..e8e8535 --- /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, []Result{ + {Outcome: ResultTeamA}, + {Outcome: ResultTeamA}, + {Outcome: ResultTeamB}, + }) + + persistCompletedGame(t, gameSvc, ctx, opponentCfg, targetCfg, opponentTeam.ID, []Result{ + {Outcome: ResultTeamA}, + {Outcome: ResultTeamB}, + {Outcome: ResultTeamA}, + }) + + persistCompletedGame(t, gameSvc, ctx, targetCfg, opponentCfg, 0, []Result{ + {Outcome: ResultTeamA}, + {Outcome: ResultTeamB}, + }) + + 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 []Result) { + 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 = new(winner) + + _, err = gameSvc.UpdateGame(ctx, game) + odize.AssertNoError(t, err) +} diff --git a/internal/games/types.go b/internal/games/types.go index e859014..f0089bd 100644 --- a/internal/games/types.go +++ b/internal/games/types.go @@ -47,6 +47,19 @@ type Round struct { TeamB TeamFormation } +type TeamStatistics struct { + GamesPlayed int + Wins int + Draws int + Losses int + WinRate float64 + RoundsWon int + RoundsLost int + RoundDifferential int + AverageRoundsWon float64 + AverageRoundsLost float64 +} + type TeamConfig struct { TeamID int64 TeamName string diff --git a/internal/ui/app.go b/internal/ui/app.go index c92176e..429bc59 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -49,7 +49,7 @@ func New(deps Dependencies) *RootModel { currentPage: uistate.PageTitle, pageTitle: NewModelTitle(state, theme), pageCreateCoach: NewModelCreateCoach(state, deps.TeamsSvc, theme), - pageLocker: uilocker.NewLockerModel(state, deps.TeamsSvc, deps.PlaybookSvc, 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), diff --git a/internal/ui/components/locker_room_list.go b/internal/ui/components/locker_room_list.go index 0b689c3..cf15c63 100644 --- a/internal/ui/components/locker_room_list.go +++ b/internal/ui/components/locker_room_list.go @@ -9,6 +9,7 @@ type LockerRoomItem int const ( ItemPlayers LockerRoomItem = iota + ItemTeamStatistics ItemPlaybooks ) @@ -16,6 +17,8 @@ func (i LockerRoomItem) String() string { switch i { case ItemPlayers: return "Players" + case ItemTeamStatistics: + return "Team Statistics" case ItemPlaybooks: return "Playbooks" } @@ -29,7 +32,7 @@ type LockerRoomList struct { func NewLockerRoomList(theme styles.IceTheme) LockerRoomList { return LockerRoomList{ List: NewList(ListConfig[LockerRoomItem]{ - Items: []LockerRoomItem{ItemPlayers, ItemPlaybooks}, + Items: []LockerRoomItem{ItemPlayers, ItemTeamStatistics, ItemPlaybooks}, ItemMapper: func(i LockerRoomItem) ListItem[LockerRoomItem] { return ListItem[LockerRoomItem]{ Data: i, diff --git a/internal/ui/components/locker_room_list_test.go b/internal/ui/components/locker_room_list_test.go index 68a4919..6b21b86 100644 --- a/internal/ui/components/locker_room_list_test.go +++ b/internal/ui/components/locker_room_list_test.go @@ -12,9 +12,9 @@ func TestLockerRoomList(t *testing.T) { group := odize.NewGroup(t, nil) theme := styles.NewIceTheme() - group.Test("NewLockerRoomList should have 2 items", func(t *testing.T) { + group.Test("NewLockerRoomList should have 3 items", func(t *testing.T) { l := NewLockerRoomList(theme) - odize.AssertEqual(t, 2, len(l.Model.Items())) + odize.AssertEqual(t, 3, len(l.Model.Items())) odize.AssertEqual(t, ItemPlayers, l.SelectedItem()) }) @@ -24,6 +24,10 @@ func TestLockerRoomList(t *testing.T) { 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, 2, l.Model.Index()) odize.AssertEqual(t, ItemPlaybooks, l.SelectedItem()) }) @@ -42,9 +46,9 @@ func TestLockerRoomList(t *testing.T) { l.Update(tea.KeyPressMsg{Text: "up"}) odize.AssertEqual(t, 0, l.Model.Index()) - l.Model.Select(1) + l.Model.Select(2) l.Update(tea.KeyPressMsg{Text: "down"}) - odize.AssertEqual(t, 1, l.Model.Index()) + odize.AssertEqual(t, 2, l.Model.Index()) }) err := group.Run() diff --git a/internal/ui/uilocker/page_locker_room.go b/internal/ui/uilocker/page_locker_room.go index 083cb1c..8e075fe 100644 --- a/internal/ui/uilocker/page_locker_room.go +++ b/internal/ui/uilocker/page_locker_room.go @@ -76,6 +76,10 @@ func (m *ModelLockerRoom) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, func() tea.Msg { 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 MsgSwitchLockerPage{NewPage: SubPageLockerPlaybooksList} diff --git a/internal/ui/uilocker/page_locker_room_test.go b/internal/ui/uilocker/page_locker_room_test.go index ae41d1a..a9f1ae2 100644 --- a/internal/ui/uilocker/page_locker_room_test.go +++ b/internal/ui/uilocker/page_locker_room_test.go @@ -39,7 +39,8 @@ func TestModelLockerRoom_Selection(t *testing.T) { theme := styles.NewIceTheme() m := NewModelLockerRoom(state, theme) - // Select Playbooks (it's the second item) + // 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()) @@ -56,6 +57,26 @@ func TestModelLockerRoom_Selection(t *testing.T) { } }) + 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 index d10a10a..fa9ef57 100644 --- a/internal/ui/uilocker/root_locker.go +++ b/internal/ui/uilocker/root_locker.go @@ -2,6 +2,7 @@ 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" @@ -13,6 +14,7 @@ type SubPageLocker int const ( SubPageLockerRoom SubPageLocker = iota SubPageLockerPlayers + SubPageLockerTeamStatistics SubPageLockerPlaybooksList SubPageLockerPlaybooksCreate SubPageLockerPlaybooksEdit @@ -29,16 +31,18 @@ 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, theme styles.IceTheme) *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), @@ -60,7 +64,7 @@ func (m *LockerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } case MsgSwitchLockerPage: switch msg.NewPage { - case SubPageLockerRoom, SubPageLockerPlayers, SubPageLockerPlaybooksList, SubPageLockerPlaybooksCreate, SubPageLockerPlaybooksEdit: + case SubPageLockerRoom, SubPageLockerPlayers, SubPageLockerTeamStatistics, SubPageLockerPlaybooksList, SubPageLockerPlaybooksCreate, SubPageLockerPlaybooksEdit: m.currentPage = msg.NewPage } @@ -70,6 +74,8 @@ func (m *LockerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 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) @@ -84,6 +90,8 @@ func (m *LockerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 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: @@ -102,6 +110,8 @@ func (m *LockerModel) View() tea.View { return m.subPageLockerRoom.View() case SubPageLockerPlayers: return m.subPageLockerPlayers.View() + case SubPageLockerTeamStatistics: + return m.subPageLockerTeamStatistics.View() case SubPageLockerPlaybooksList: return m.subPageLockerPlaybooksList.View() case SubPageLockerPlaybooksCreate: diff --git a/internal/ui/uilocker/root_locker_test.go b/internal/ui/uilocker/root_locker_test.go index 7f8e7d3..46e1f75 100644 --- a/internal/ui/uilocker/root_locker_test.go +++ b/internal/ui/uilocker/root_locker_test.go @@ -14,13 +14,16 @@ func TestLockerModel_SwitchPage(t *testing.T) { state := &uistate.GlobalState{} theme := styles.NewIceTheme() - ts, ps, _ := uitest.SetupServices(t) - m := NewLockerModel(state, ts, ps, theme) + 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) }) From da99e93a26f02d9003627000aa3c3aaef8ac18ee Mon Sep 17 00:00:00 2001 From: frag223 Date: Thu, 2 Jul 2026 21:50:54 +1000 Subject: [PATCH 38/72] update --- docs/features.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/features.md b/docs/features.md index 620ee89..6b172b7 100644 --- a/docs/features.md +++ b/docs/features.md @@ -10,4 +10,4 @@ List of features intended for the project. - [ ] Settings to clear game data - [ ] Tournament mode - [X] Record battles -- [ ] Team statistics \ No newline at end of file +- [X] Team statistics \ No newline at end of file From 7575ce72fb3b663b1ad828a06687220d5ea1b451 Mon Sep 17 00:00:00 2001 From: frag223 Date: Thu, 2 Jul 2026 22:07:04 +1000 Subject: [PATCH 39/72] update --- docs/rules.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/docs/rules.md b/docs/rules.md index a16d340..cb144f8 100644 --- a/docs/rules.md +++ b/docs/rules.md @@ -2,14 +2,18 @@ Game rules. +## Background + +Rush is a basic five-player lane battle game. Rush takes some elements from risk and battle arena games. + ## Game - The game is ten rounds. -- Coaches chosen at random to go first; at round 5, coaches swap over. - Players in the same lane dual and roll a 1d6 die. Highest score wins. -- Loosing player is eliminated. Lane duals continue until no opposing players remain in the lane. +- If dice rolls are equal, immediate re-roll. +- Loosing player is eliminated for the round. 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. - If the total number of remaining players on each team is equal, the round is considered a draw. -- Players remaining in all three lanes score 1 point for the round. +- Team with the highest remaining players 1 point for the round; if equal, round is a draw. - After ten rounds, the coach with the most points wins. - If both teams have the same number of points, the game is considered a draw. \ No newline at end of file From 37996f6db7b73902986b9243561f4bd5894254b5 Mon Sep 17 00:00:00 2001 From: frag223 Date: Thu, 2 Jul 2026 22:23:14 +1000 Subject: [PATCH 40/72] adding more depth to rules --- docs/rules.md | 76 +++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 65 insertions(+), 11 deletions(-) diff --git a/docs/rules.md b/docs/rules.md index cb144f8..5de2fd3 100644 --- a/docs/rules.md +++ b/docs/rules.md @@ -1,19 +1,73 @@ -# Rules +# Rules Game rules. ## Background -Rush is a basic five-player lane battle game. Rush takes some elements from risk and battle arena games. +Rush is a five-player lane battle game. Rush takes some elements from risk and battle arena games. -## Game +## Match setup + +- A match is ten rounds. +- At the battle selection screen, a human coach picks an opponent. +- 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. + +## 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. **Lane assignment** + - 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 -- The game is ten rounds. -- Players in the same lane dual and roll a 1d6 die. Highest score wins. -- If dice rolls are equal, immediate re-roll. -- Loosing player is eliminated for the round. 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. -- If the total number of remaining players on each team is equal, the round is considered a draw. -- Team with the highest remaining players 1 point for the round; if equal, round is a draw. - After ten rounds, the coach with the most points wins. -- If both teams have the same number of points, the game is considered a draw. \ No newline at end of file +- 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 resources per match + +- Give each coach a small tactical resource pool (for example, `2` tokens per match). +- A token can be spent on effects such as: + - `+1` to a single duel roll (declared before rolling), or + - one re-roll in a chosen lane. +- Limited resources force long-term planning across ten rounds. + +### 4) 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 From 03830120b8a0487dbfb41e0a3fc10afc622e9542 Mon Sep 17 00:00:00 2001 From: frag223 Date: Fri, 3 Jul 2026 19:21:02 +1000 Subject: [PATCH 41/72] wip proposal --- docs/proposal.md | 57 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 docs/proposal.md diff --git a/docs/proposal.md b/docs/proposal.md new file mode 100644 index 0000000..c84d4d8 --- /dev/null +++ b/docs/proposal.md @@ -0,0 +1,57 @@ +### Proposal: Advanced Tactical Mechanics + +This proposal introduces two major strategic layers to **Rush**: specialized tactical tokens and D&D-style Armor Class (AC) stats for players. + +--- + +### 1. Tactical Resources (Single-Use Tokens) + +Each coach receives two unique tokens per game to influence critical duels. + +#### Second Chance (Automatic Re-roll) +* **Effect**: The round is augmented such that if a roll is less than the opponent's, a re-roll triggers automatically. +* **Strategy**: A "Safety Net." Ideal for preserving a critical lane lead or protecting a high-value player. +* **Probability**: Increases Win Chance from **50% to 75%** in even duels. + +#### Twist of Fate (Advantage) +* **Effect**: The first roll of the round is done with 2 dice, and the highest of the two is taken. +* **Strategy**: A "Power Surge." Best for aggressive tempo play to secure an early elimination and gain momentum. +* **Probability**: Increases Win Chance from **50% to ~69%** on the first roll. + +--- + +### 2. Attack / Defense (AC Style) Stats + +To add depth to roster building, players are assigned **Attack (ATK)** and **Defense (DEF)** ratings. + +#### The Mechanic +* **Attack Check**: Attacker Rolls `1d6 + ATK`. +* **Hit Condition**: If the total is **equal to or greater than** the opponent's **DEF**, the opponent is eliminated. +* **Simultaneous Resolution**: Both players roll at the same time. + * **Both Hit**: Both are eliminated. + * **One Hits**: The winner survives, the loser is eliminated. + * **Neither Hits**: Stalemate (re-roll or stay in lane). + +#### Player Archetypes + +| Archetype | ATK | DEF | Profile | +| :--- | :---: | :---: | :--- | +| **Tank** | -1 | 5 | Hard to kill (33% hit chance), but low threat (33% hit chance). | +| **Standard** | +0 | 4 | Balanced (50% hit chance). The baseline for all lanes. | +| **Striker** | +1 | 3 | Glass Cannon (83% hit chance against strikers), high threat (66%+ hit chance). | + +#### Matchup Win Probabilities (Attacker vs. Defender) + +| Attacker ↓ vs Defender → | Tank (DEF 5) | Standard (DEF 4) | Striker (DEF 3) | +| :--- | :---: | :---: | :---: | +| **Tank** (ATK -1) | 16% | 33% | 50% | +| **Standard** (ATK 0) | 33% | 50% | 66% | +| **Striker** (ATK +1) | 50% | 66% | 83% | + +--- + +### 3. Tactical Implications + +* **Tank Stalling**: Place Tanks in a lane you expect to lose to delay the opponent's advance. +* **Striker Rush**: Use Strikers in priority lanes to clear them quickly, potentially paired with a **Twist of Fate** token. +* **Counter-Picking**: Strikers are necessary to efficiently clear opposing Tanks, while Tanks can neutralize Standard players. From 799434f0d5ae5c4c15ddea77d64d10c9907354d8 Mon Sep 17 00:00:00 2001 From: frag223 Date: Fri, 3 Jul 2026 19:48:40 +1000 Subject: [PATCH 42/72] fixing dice roll function --- internal/games/rounds.go | 9 ++++++++- internal/ui/components/game.go | 5 +---- internal/ui/iugame/page_game.go | 4 ++-- 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/internal/games/rounds.go b/internal/games/rounds.go index 4e4a096..72ee136 100644 --- a/internal/games/rounds.go +++ b/internal/games/rounds.go @@ -1,6 +1,9 @@ package games -import "errors" +import ( + "errors" + "math/rand/v2" +) func NewRound() Round { return Round{ @@ -133,3 +136,7 @@ func (s *TeamFormation) LaneFill(lane int, players int) { s.Lanes[lane] = append(s.Lanes[lane], i) } } + +func DiceRoll() int { + return rand.IntN(6) + 1 +} diff --git a/internal/ui/components/game.go b/internal/ui/components/game.go index f2833cd..45f9113 100644 --- a/internal/ui/components/game.go +++ b/internal/ui/components/game.go @@ -2,7 +2,6 @@ package components import ( "fmt" - "math/rand/v2" "time" tea "charm.land/bubbletea/v2" @@ -31,9 +30,7 @@ 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 - } + rollFn = games.DiceRoll } currentRoundIdx := game.CurrentRound() diff --git a/internal/ui/iugame/page_game.go b/internal/ui/iugame/page_game.go index ee55212..d89bd5f 100644 --- a/internal/ui/iugame/page_game.go +++ b/internal/ui/iugame/page_game.go @@ -67,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.DiceRoll) cmds = append(cmds, m.gameComp.Init()) case components.MsgResolveRound: cmds = append(cmds, m.gameComp.Update(msg)) @@ -81,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.DiceRoll) cmds = append(cmds, m.gameComp.Init()) } else { cmds = append(cmds, func() tea.Msg { From 64fdaace7066b5813a5719b7fe2e5f30e24ac561 Mon Sep 17 00:00:00 2001 From: frag223 Date: Fri, 3 Jul 2026 19:58:11 +1000 Subject: [PATCH 43/72] clean up --- internal/games/service.go | 53 ++++++++++++++++++++++----------------- 1 file changed, 30 insertions(+), 23 deletions(-) diff --git a/internal/games/service.go b/internal/games/service.go index 3750e08..e0b7f81 100644 --- a/internal/games/service.go +++ b/internal/games/service.go @@ -122,30 +122,9 @@ func (s *Service) GetTeamStatistics(ctx context.Context, teamID int64) (TeamStat stats := TeamStatistics{} for _, model := range models { - stats.GamesPlayed++ - - switch { - case !model.Winner.Valid || model.Winner.Int64 == 0: - stats.Draws++ - case model.Winner.Int64 == teamID: - stats.Wins++ - default: - stats.Losses++ + if err = s.processGameForStats(&stats, model, teamID); err != nil { + return TeamStatistics{}, err } - - var results []Result - if err := json.Unmarshal(model.ResultsLog, &results); err != nil { - return TeamStatistics{}, fmt.Errorf("parsing game %d results: %w", model.ID, err) - } - - teamRoundsWon := len(filterResultsByTeam(ResultTeamA, results)) - teamRoundsLost := len(filterResultsByTeam(ResultTeamB, results)) - if model.TeamB.Valid && model.TeamB.Int64 == teamID { - teamRoundsWon, teamRoundsLost = teamRoundsLost, teamRoundsWon - } - - stats.RoundsWon += teamRoundsWon - stats.RoundsLost += teamRoundsLost } if stats.GamesPlayed == 0 { @@ -159,3 +138,31 @@ func (s *Service) GetTeamStatistics(ctx context.Context, teamID int64) (TeamStat 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 []Result + if err := json.Unmarshal(model.ResultsLog, &results); err != nil { + return fmt.Errorf("parsing game %d results: %w", model.ID, err) + } + + teamRoundsWon := len(filterResultsByTeam(ResultTeamA, results)) + teamRoundsLost := len(filterResultsByTeam(ResultTeamB, results)) + if model.TeamB.Valid && model.TeamB.Int64 == teamID { + teamRoundsWon, teamRoundsLost = teamRoundsLost, teamRoundsWon + } + + stats.RoundsWon += teamRoundsWon + stats.RoundsLost += teamRoundsLost + return nil +} From 04fcc3d38df4ca90a66b22004939e0bb44896fcf Mon Sep 17 00:00:00 2001 From: frag223 Date: Fri, 3 Jul 2026 20:21:22 +1000 Subject: [PATCH 44/72] update --- docs/proposal.md | 103 ++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 89 insertions(+), 14 deletions(-) diff --git a/docs/proposal.md b/docs/proposal.md index c84d4d8..7506259 100644 --- a/docs/proposal.md +++ b/docs/proposal.md @@ -1,22 +1,75 @@ -### Proposal: Advanced Tactical Mechanics +### Proposal: Advanced Tactical Mechanics (Rules-Ready Draft) -This proposal introduces two major strategic layers to **Rush**: specialized tactical tokens and D&D-style Armor Class (AC) stats for players. +This proposal introduces two strategic layers to **Rush**: tactical tokens and Attack/Defense stats. +This revision defines exact timing, constraints, and probability framing for simultaneous resolution. --- ### 1. Tactical Resources (Single-Use Tokens) -Each coach receives two unique tokens per game to influence critical duels. +Each coach receives exactly two tokens per game: +* `Second Chance` ×1 +* `Twist of Fate` ×1 -#### Second Chance (Automatic Re-roll) -* **Effect**: The round is augmented such that if a roll is less than the opponent's, a re-roll triggers automatically. -* **Strategy**: A "Safety Net." Ideal for preserving a critical lane lead or protecting a high-value player. -* **Probability**: Increases Win Chance from **50% to 75%** in even duels. +Token constraints: +* Max **1 token per coach per duel**. +* Tokens do **not** stack on the same check. +* Both coaches may use a token in the same duel. +* If both coaches use tokens, both effects apply using the duel sequence below. + +#### Second Chance (Reactive Re-roll) +* **Timing**: Declare in the Reaction Window, after the first reveal, only if your check missed. +* **Effect**: Re-roll your own attack check once. The second result replaces the first. +* **Strategy**: A "Safety Net" for high-value lanes when the first roll fails. +* **Probability Note**: In a baseline 50% hit check, one re-roll on miss raises hit chance to **75%**. #### Twist of Fate (Advantage) -* **Effect**: The first roll of the round is done with 2 dice, and the highest of the two is taken. +* **Timing**: Declare in the Pre-roll Window. +* **Effect**: Roll `2d6` for your attack check and keep the highest die. * **Strategy**: A "Power Surge." Best for aggressive tempo play to secure an early elimination and gain momentum. -* **Probability**: Increases Win Chance from **50% to ~69%** on the first roll. +* **Probability Note**: In a baseline 50% hit check, advantage raises hit chance to **75%**. + +#### Additional Token Mechanics (Expansion Candidates) +These are optional candidates for future playtests and are **not** part of the current baseline token set. +To preserve pacing, test with **2 tokens per coach total** by replacing an existing token, not adding extra total uses. + +##### Power Play (Flat Attack Boost) +* **Timing**: Declare in the Pre-roll Window. +* **Effect**: Gain `+1 ATK` for this cycle only. +* **Strategy**: Reliable pressure tool; lower variance than advantage. +* **Probability Note**: In a baseline 50% hit check, `+1 ATK` raises hit chance to **66%**. + +##### Brace (Temporary Defense Boost) +* **Timing**: Declare in the Pre-roll Window. +* **Effect**: Gain `+1 DEF` for this cycle only. +* **Strategy**: Defensive stabilization in high-value lanes; strongest as a denial token. +* **Probability Note**: Against a baseline 50% enemy hit check, `+1 DEF` lowers enemy hit chance to **33%**. + +##### Precision Strike (Near-Miss Conversion) +* **Timing**: Declare in the Reaction Window, after reveal, only if your total missed by exactly `1`. +* **Effect**: Add `+1` to your revealed total (turning that near miss into a hit). +* **Strategy**: High-information token that rewards patient use and matchup awareness. + +##### Jamming Signal (Token Counter) +* **Timing**: Declare in the Pre-roll Window. +* **Effect**: If opponent declared a Pre-roll token this cycle, cancel that token's effect. +* **Strategy**: Counter-tempo tool that punishes predictable token usage. +* **Constraint Note**: Cannot cancel Reaction Window tokens. + +##### Last Stand (Elimination Prevention) +* **Timing**: Declare in Resolution, after outcomes are known, only if your player would be eliminated and the opponent would survive. +* **Effect**: Your player is not eliminated this cycle; lane remains unresolved and proceeds under normal stalemate rules. +* **Strategy**: Comeback tool to preserve board presence in a critical lane. +* **Constraint Note**: Cannot prevent elimination in `both eliminated` outcomes. + +#### Duel Resolution Sequence (Authoritative) +1. **Pre-roll Window**: Each coach may declare `Twist of Fate`. +2. **Initial Check**: Both players roll attack checks simultaneously (`1d6 + ATK`, or advantage if declared). +3. **Reveal**: Both totals are revealed. +4. **Reaction Window**: Each coach that missed may declare `Second Chance` (if available and no token already used by that coach in this duel). +5. **Re-roll Step**: Any declared `Second Chance` re-rolls are made and replace prior totals. +6. **Resolution**: Apply simultaneous hit outcomes (both hit / one hits / neither hits). +7. **Lane Update**: Apply eliminations and lane state. --- @@ -30,7 +83,12 @@ To add depth to roster building, players are assigned **Attack (ATK)** and **Def * **Simultaneous Resolution**: Both players roll at the same time. * **Both Hit**: Both are eliminated. * **One Hits**: The winner survives, the loser is eliminated. - * **Neither Hits**: Stalemate (re-roll or stay in lane). + * **Neither Hits**: Stalemate. + +#### Stalemate Rule (Global) +To keep pacing deterministic and avoid endless loops: +* A duel can run for up to **3 cycles total** (initial cycle + up to 2 stalemate re-cycles). +* If still unresolved after cycle 3, both players remain in lane and the lane state is `Contested` for the round. #### Player Archetypes @@ -40,7 +98,7 @@ To add depth to roster building, players are assigned **Attack (ATK)** and **Def | **Standard** | +0 | 4 | Balanced (50% hit chance). The baseline for all lanes. | | **Striker** | +1 | 3 | Glass Cannon (83% hit chance against strikers), high threat (66%+ hit chance). | -#### Matchup Win Probabilities (Attacker vs. Defender) +#### Single-Check Hit Probabilities (`1d6 + ATK >= DEF`) | Attacker ↓ vs Defender → | Tank (DEF 5) | Standard (DEF 4) | Striker (DEF 3) | | :--- | :---: | :---: | :---: | @@ -48,10 +106,27 @@ To add depth to roster building, players are assigned **Attack (ATK)** and **Def | **Standard** (ATK 0) | 33% | 50% | 66% | | **Striker** (ATK +1) | 50% | 66% | 83% | +These are **hit chances per check**, not duel win rates. + +#### Simultaneous Outcome Probabilities (Single Cycle) +Given `pA` = attacker hit chance and `pD` = defender hit chance: +* Attacker survives: `pA * (1 - pD)` +* Defender survives: `pD * (1 - pA)` +* Both eliminated: `pA * pD` +* No elimination: `(1 - pA) * (1 - pD)` + +Representative archetype pair outcomes (before stalemate re-cycles): + +| Pairing | A survives | B survives | Both eliminated | No elimination | +| :--- | :---: | :---: | :---: | :---: | +| Tank vs Standard (`p=33%` each) | 22% | 22% | 11% | 44% | +| Tank vs Striker (`p=50%` each) | 25% | 25% | 25% | 25% | +| Standard vs Striker (`p=66%` each) | 22% | 22% | 44% | 11% | + --- ### 3. Tactical Implications -* **Tank Stalling**: Place Tanks in a lane you expect to lose to delay the opponent's advance. -* **Striker Rush**: Use Strikers in priority lanes to clear them quickly, potentially paired with a **Twist of Fate** token. -* **Counter-Picking**: Strikers are necessary to efficiently clear opposing Tanks, while Tanks can neutralize Standard players. +* **Tank Stalling**: Place Tanks in low-priority lanes to increase unresolved/contested outcomes. +* **Tempo Push**: Use Strikers with `Twist of Fate` in must-win lanes for early pressure. +* **Risk Management**: Save `Second Chance` for lanes with high strategic value where a miss would swing the round. From 034194589229cf1ea10cab12ca1c84440df48108 Mon Sep 17 00:00:00 2001 From: frag223 Date: Fri, 3 Jul 2026 20:48:40 +1000 Subject: [PATCH 45/72] update --- docs/proposal.md | 207 +++++++++++++++++++++++------------------------ 1 file changed, 100 insertions(+), 107 deletions(-) diff --git a/docs/proposal.md b/docs/proposal.md index 7506259..2cf98bf 100644 --- a/docs/proposal.md +++ b/docs/proposal.md @@ -1,132 +1,125 @@ -### Proposal: Advanced Tactical Mechanics (Rules-Ready Draft) +### Proposal: Token-Only Tactical Playtest (Rules-Ready Draft) -This proposal introduces two strategic layers to **Rush**: tactical tokens and Attack/Defense stats. -This revision defines exact timing, constraints, and probability framing for simultaneous resolution. +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. Tactical Resources (Single-Use Tokens) +### 1. Playtest Scope and Core Rules -Each coach receives exactly two tokens per game: -* `Second Chance` ×1 -* `Twist of Fate` ×1 +#### Scope +* This phase tests only token-driven decision making. +* No `ATK/DEF` stats. +* No `Rush Lane` declarations. -Token constraints: +#### 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 final outcome for the duel. + +#### Global Token Constraints +* Each coach equips exactly **3 tokens** per match. +* Each equipped token is **single-use**. * Max **1 token per coach per duel**. -* Tokens do **not** stack on the same check. * Both coaches may use a token in the same duel. -* If both coaches use tokens, both effects apply using the duel sequence below. - -#### Second Chance (Reactive Re-roll) -* **Timing**: Declare in the Reaction Window, after the first reveal, only if your check missed. -* **Effect**: Re-roll your own attack check once. The second result replaces the first. -* **Strategy**: A "Safety Net" for high-value lanes when the first roll fails. -* **Probability Note**: In a baseline 50% hit check, one re-roll on miss raises hit chance to **75%**. - -#### Twist of Fate (Advantage) -* **Timing**: Declare in the Pre-roll Window. -* **Effect**: Roll `2d6` for your attack check and keep the highest die. -* **Strategy**: A "Power Surge." Best for aggressive tempo play to secure an early elimination and gain momentum. -* **Probability Note**: In a baseline 50% hit check, advantage raises hit chance to **75%**. - -#### Additional Token Mechanics (Expansion Candidates) -These are optional candidates for future playtests and are **not** part of the current baseline token set. -To preserve pacing, test with **2 tokens per coach total** by replacing an existing token, not adding extra total uses. - -##### Power Play (Flat Attack Boost) -* **Timing**: Declare in the Pre-roll Window. -* **Effect**: Gain `+1 ATK` for this cycle only. -* **Strategy**: Reliable pressure tool; lower variance than advantage. -* **Probability Note**: In a baseline 50% hit check, `+1 ATK` raises hit chance to **66%**. - -##### Brace (Temporary Defense Boost) -* **Timing**: Declare in the Pre-roll Window. -* **Effect**: Gain `+1 DEF` for this cycle only. -* **Strategy**: Defensive stabilization in high-value lanes; strongest as a denial token. -* **Probability Note**: Against a baseline 50% enemy hit check, `+1 DEF` lowers enemy hit chance to **33%**. - -##### Precision Strike (Near-Miss Conversion) -* **Timing**: Declare in the Reaction Window, after reveal, only if your total missed by exactly `1`. -* **Effect**: Add `+1` to your revealed total (turning that near miss into a hit). -* **Strategy**: High-information token that rewards patient use and matchup awareness. - -##### Jamming Signal (Token Counter) -* **Timing**: Declare in the Pre-roll Window. -* **Effect**: If opponent declared a Pre-roll token this cycle, cancel that token's effect. -* **Strategy**: Counter-tempo tool that punishes predictable token usage. -* **Constraint Note**: Cannot cancel Reaction Window tokens. - -##### Last Stand (Elimination Prevention) -* **Timing**: Declare in Resolution, after outcomes are known, only if your player would be eliminated and the opponent would survive. -* **Effect**: Your player is not eliminated this cycle; lane remains unresolved and proceeds under normal stalemate rules. -* **Strategy**: Comeback tool to preserve board presence in a critical lane. -* **Constraint Note**: Cannot prevent elimination in `both eliminated` outcomes. - -#### Duel Resolution Sequence (Authoritative) -1. **Pre-roll Window**: Each coach may declare `Twist of Fate`. -2. **Initial Check**: Both players roll attack checks simultaneously (`1d6 + ATK`, or advantage if declared). -3. **Reveal**: Both totals are revealed. -4. **Reaction Window**: Each coach that missed may declare `Second Chance` (if available and no token already used by that coach in this duel). -5. **Re-roll Step**: Any declared `Second Chance` re-rolls are made and replace prior totals. -6. **Resolution**: Apply simultaneous hit outcomes (both hit / one hits / neither hits). -7. **Lane Update**: Apply eliminations and lane state. +* Tokens do not stack for the same coach in a single duel. --- -### 2. Attack / Defense (AC Style) Stats +### 2. Token Library (Expanded for User Testing) -To add depth to roster building, players are assigned **Attack (ATK)** and **Defense (DEF)** ratings. - -#### The Mechanic -* **Attack Check**: Attacker Rolls `1d6 + ATK`. -* **Hit Condition**: If the total is **equal to or greater than** the opponent's **DEF**, the opponent is eliminated. -* **Simultaneous Resolution**: Both players roll at the same time. - * **Both Hit**: Both are eliminated. - * **One Hits**: The winner survives, the loser is eliminated. - * **Neither Hits**: Stalemate. +#### Twist of Fate (Advantage) +* **Timing**: Pre-roll Window. +* **Effect**: Roll `2d6` and keep the highest. +* **Intent**: Front-load pressure in must-win lanes. -#### Stalemate Rule (Global) -To keep pacing deterministic and avoid endless loops: -* A duel can run for up to **3 cycles total** (initial cycle + up to 2 stalemate re-cycles). -* If still unresolved after cycle 3, both players remain in lane and the lane state is `Contested` for the round. +#### 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. -#### Player Archetypes +--- -| Archetype | ATK | DEF | Profile | -| :--- | :---: | :---: | :--- | -| **Tank** | -1 | 5 | Hard to kill (33% hit chance), but low threat (33% hit chance). | -| **Standard** | +0 | 4 | Balanced (50% hit chance). The baseline for all lanes. | -| **Striker** | +1 | 3 | Glass Cannon (83% hit chance against strikers), high threat (66%+ hit chance). | +### 3. Coach Personas (Token Access Limits) -#### Single-Check Hit Probabilities (`1d6 + ATK >= DEF`) +At match start, the player selects one coach persona. +Each persona restricts which tokens can be equipped for that match. -| Attacker ↓ vs Defender → | Tank (DEF 5) | Standard (DEF 4) | Striker (DEF 3) | -| :--- | :---: | :---: | :---: | -| **Tank** (ATK -1) | 16% | 33% | 50% | -| **Standard** (ATK 0) | 33% | 50% | 66% | -| **Striker** (ATK +1) | 50% | 66% | 83% | +#### Coach Loadout Rule +* Equip exactly **3 single-use tokens** from your persona's allowed list. +* No duplicate token names in the same loadout. -These are **hit chances per check**, not duel win rates. +#### 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. -#### Simultaneous Outcome Probabilities (Single Cycle) -Given `pA` = attacker hit chance and `pD` = defender hit chance: -* Attacker survives: `pA * (1 - pD)` -* Defender survives: `pD * (1 - pA)` -* Both eliminated: `pA * pD` -* No elimination: `(1 - pA) * (1 - pD)` +#### 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. -Representative archetype pair outcomes (before stalemate re-cycles): +#### 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. -| Pairing | A survives | B survives | Both eliminated | No elimination | -| :--- | :---: | :---: | :---: | :---: | -| Tank vs Standard (`p=33%` each) | 22% | 22% | 11% | 44% | -| Tank vs Striker (`p=50%` each) | 25% | 25% | 25% | 25% | -| Standard vs Striker (`p=66%` each) | 22% | 22% | 44% | 11% | +#### 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. --- -### 3. Tactical Implications +### 4. Playtest Objectives -* **Tank Stalling**: Place Tanks in low-priority lanes to increase unresolved/contested outcomes. -* **Tempo Push**: Use Strikers with `Twist of Fate` in must-win lanes for early pressure. -* **Risk Management**: Save `Second Chance` for lanes with high strategic value where a miss would swing the round. +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). From 64569ef1fb76bdbca0d7a6f2db9ecb10d655ef01 Mon Sep 17 00:00:00 2001 From: frag223 Date: Fri, 3 Jul 2026 20:55:44 +1000 Subject: [PATCH 46/72] finalising proposal --- docs/proposal.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/proposal.md b/docs/proposal.md index 2cf98bf..07df7ca 100644 --- a/docs/proposal.md +++ b/docs/proposal.md @@ -18,7 +18,13 @@ The playtest focus is strictly on high-impact token decisions and coach identity 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 final outcome for the duel. +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. From 2dbbd152a4cc3347a6314a7d24fec85653baab9f Mon Sep 17 00:00:00 2001 From: frag223 Date: Fri, 3 Jul 2026 21:11:06 +1000 Subject: [PATCH 47/72] boilerplate adding new feature --- internal/database/models.gen.go | 1 + internal/database/queries/team.sql | 2 +- internal/database/schema/team.sql | 1 + internal/database/team.sql.gen.go | 23 ++++++++---- internal/teams/ai_teams.go | 48 ++++++++++++++++--------- internal/teams/ai_teams_test.go | 6 +++- internal/teams/service.go | 5 ++- internal/teams/service_test.go | 10 ++++++ internal/teams/tokens.go | 50 ++++++++++++++++++++++++++ internal/teams/tokens_test.go | 58 ++++++++++++++++++++++++++++++ internal/teams/transforms.go | 1 + internal/teams/types.go | 37 +++++++++++++++---- 12 files changed, 211 insertions(+), 31 deletions(-) create mode 100644 internal/teams/tokens.go create mode 100644 internal/teams/tokens_test.go 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/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/teams/ai_teams.go b/internal/teams/ai_teams.go index f4b9db9..8dac8ee 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 - 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, }) @@ -244,6 +259,7 @@ func generateAITeams() ([]AIGenerationParams, error) { 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/service.go b/internal/teams/service.go index 15a7ce8..bde39df 100644 --- a/internal/teams/service.go +++ b/internal/teams/service.go @@ -1,6 +1,7 @@ package teams import ( + "cmp" "context" "database/sql" "errors" @@ -24,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, diff --git a/internal/teams/service_test.go b/internal/teams/service_test.go index 9b4319a..b86bd47 100644 --- a/internal/teams/service_test.go +++ b/internal/teams/service_test.go @@ -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" diff --git a/internal/teams/tokens.go b/internal/teams/tokens.go new file mode 100644 index 0000000..8a42fa8 --- /dev/null +++ b/internal/teams/tokens.go @@ -0,0 +1,50 @@ +package teams + +import "slices" + +var coachPersonaTokens = map[CoachPersona][]TokenName{ + CoachPersonaVanguard: { + TokenTwistOfFate, + TokenPowerPlay, + TokenMomentumSurge, + TokenPrecisionStrike, + TokenIceInVeins, + }, + CoachPersonaBastion: { + TokenSecondChance, + TokenBrace, + TokenLastStand, + TokenJammingSignal, + TokenIceInVeins, + }, + CoachPersonaTrickster: { + TokenJammingSignal, + TokenSmokeScreen, + TokenPrecisionStrike, + TokenSecondChance, + TokenPowerPlay, + }, + CoachPersonaWildcard: { + TokenTwistOfFate, + TokenSecondChance, + TokenPowerPlay, + TokenBrace, + TokenPrecisionStrike, + TokenJammingSignal, + TokenIceInVeins, + TokenSmokeScreen, + }, +} + +func (p CoachPersona) AvailableTokens() []TokenName { + tokens, ok := coachPersonaTokens[p] + if !ok { + return []TokenName{} + } + + return slices.Clone(tokens) +} + +func (c *Coach) AvailableTokens() []TokenName { + return c.Persona.AvailableTokens() +} diff --git a/internal/teams/tokens_test.go b/internal/teams/tokens_test.go new file mode 100644 index 0000000..666e104 --- /dev/null +++ b/internal/teams/tokens_test.go @@ -0,0 +1,58 @@ +package teams + +import ( + "testing" + + "github.com/code-gorilla-au/odize" +) + +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.AvailableTokens() + + expected := []TokenName{ + TokenSecondChance, + TokenBrace, + TokenLastStand, + TokenJammingSignal, + TokenIceInVeins, + } + + odize.AssertEqual(t, expected, tokens) + }). + Test("returns cloned slice to prevent mutation leaks", func(t *testing.T) { + first := CoachPersonaVanguard.AvailableTokens() + second := CoachPersonaVanguard.AvailableTokens() + + first[0] = TokenSmokeScreen + + odize.AssertEqual(t, TokenTwistOfFate, second[0]) + }). + Test("returns nil for unknown persona", func(t *testing.T) { + tokens := CoachPersona("Unknown Coach").AvailableTokens() + odize.AssertTrue(t, tokens == nil) + }). + Run() + + odize.AssertNoError(t, err) +} + +func TestCoach_AvailableTokens(t *testing.T) { + group := odize.NewGroup(t, nil) + + err := group. + Test("returns tokens from coach persona", func(t *testing.T) { + coach := Coach{Persona: CoachPersonaTrickster} + + expected := CoachPersonaTrickster.AvailableTokens() + actual := coach.AvailableTokens() + + odize.AssertEqual(t, expected, actual) + }). + 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..c36069d 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,27 @@ var ( ErrCoachNotFound = errors.New("coach not found") ErrTeamNotFound = errors.New("team not found") ) + +type TokenName string + +const ( + TokenTwistOfFate TokenName = "Twist of Fate" + TokenSecondChance TokenName = "Second Chance" + TokenPowerPlay TokenName = "Power Play" + TokenBrace TokenName = "Brace" + TokenPrecisionStrike TokenName = "Precision Strike" + TokenJammingSignal TokenName = "Jamming Signal" + TokenLastStand TokenName = "Last Stand" + TokenMomentumSurge TokenName = "Momentum Surge" + TokenIceInVeins TokenName = "Ice in Veins" + TokenSmokeScreen TokenName = "Smoke Screen" +) + +type CoachPersona string + +const ( + CoachPersonaVanguard CoachPersona = "Vanguard Coach" + CoachPersonaBastion CoachPersona = "Bastion Coach" + CoachPersonaTrickster CoachPersona = "Trickster Coach" + CoachPersonaWildcard CoachPersona = "Wildcard Coach" +) From 519f1e562cc3c64207515b72ae4975456c710b85 Mon Sep 17 00:00:00 2001 From: frag223 Date: Sat, 4 Jul 2026 16:18:38 +1000 Subject: [PATCH 48/72] renaming --- internal/games/dice_roll.go | 21 +++++++++++++++++++++ internal/games/game.go | 10 +++++----- internal/games/rounds.go | 23 +++++++++-------------- internal/games/service.go | 2 +- internal/games/service_test.go | 8 ++++---- internal/games/types.go | 14 +++++++------- internal/ui/components/game.go | 2 +- 7 files changed, 48 insertions(+), 32 deletions(-) create mode 100644 internal/games/dice_roll.go diff --git a/internal/games/dice_roll.go b/internal/games/dice_roll.go new file mode 100644 index 0000000..3db29f0 --- /dev/null +++ b/internal/games/dice_roll.go @@ -0,0 +1,21 @@ +package games + +import ( + "math/rand/v2" + + "github.com/code-gorilla-au/rush/internal/teams" +) + +type RollEngine struct { + TeamA teams.TokenName + TeamB teams.TokenName +} + +type RollResult struct { + Roll int + Team RoundOutcome +} + +func DiceRoll() int { + return rand.IntN(6) + 1 +} diff --git a/internal/games/game.go b/internal/games/game.go index b848f3c..c2cc86e 100644 --- a/internal/games/game.go +++ b/internal/games/game.go @@ -35,9 +35,9 @@ func generateRounds(teamA TeamConfig, teamB TeamConfig) [10]Round { return rounds } -func (g *Game) ResolveRound(roll RollFn) (Result, error) { +func (g *Game) ResolveRound(roll RollFn) (RoundResult, error) { if g.currentRound < 0 || g.currentRound >= int64(len(g.rounds)) { - return Result{}, ErrNoRounds + return RoundResult{}, ErrNoRounds } round := &g.rounds[int(g.currentRound)] @@ -107,8 +107,8 @@ func (g *Game) TeamBScore() int { return len(teamB) } -func filterResultsByTeam(team ResultOutcome, results []Result) []Result { - var filteredResults []Result +func filterResultsByTeam(team RoundOutcome, results []RoundResult) []RoundResult { + var filteredResults []RoundResult for _, result := range results { if result.Outcome == team { @@ -126,7 +126,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/rounds.go b/internal/games/rounds.go index 72ee136..6eb1d85 100644 --- a/internal/games/rounds.go +++ b/internal/games/rounds.go @@ -2,7 +2,6 @@ package games import ( "errors" - "math/rand/v2" ) func NewRound() Round { @@ -17,8 +16,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 RollFn) RoundResult { + var result []RoundResult for lane := 0; lane < len(r.TeamA.Lanes); lane++ { laneResult := r.ResolveLane(lane, rollFn) @@ -29,7 +28,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 @@ -46,26 +45,26 @@ func (r *Round) calculateWinner(result []Result) Result { } if teamAPlayers > teamBPlayers { - return Result{ + return RoundResult{ Outcome: ResultTeamA, RemainingPlayers: teamAPlayers, } } if teamAPlayers < teamBPlayers { - return Result{ + return RoundResult{ Outcome: ResultTeamB, RemainingPlayers: teamBPlayers, } } - return Result{ + return RoundResult{ Outcome: ResultDraw, RemainingPlayers: 0, } } -func (r *Round) ResolveLane(lane int, rollFn RollFn) Result { +func (r *Round) ResolveLane(lane int, rollFn RollFn) RoundResult { for r.TeamA.LaneHasPlayers(lane) && r.TeamB.LaneHasPlayers(lane) { aRoll := rollFn() bRoll := rollFn() @@ -91,13 +90,13 @@ func (r *Round) ResolveLane(lane int, rollFn RollFn) Result { } if r.TeamA.LaneHasPlayers(lane) { - return Result{ + return RoundResult{ Outcome: ResultTeamA, RemainingPlayers: r.TeamA.LaneCount(lane), } } - return Result{ + return RoundResult{ Outcome: ResultTeamB, RemainingPlayers: r.TeamB.LaneCount(lane), } @@ -136,7 +135,3 @@ func (s *TeamFormation) LaneFill(lane int, players int) { s.Lanes[lane] = append(s.Lanes[lane], i) } } - -func DiceRoll() int { - return rand.IntN(6) + 1 -} diff --git a/internal/games/service.go b/internal/games/service.go index e0b7f81..b1e787c 100644 --- a/internal/games/service.go +++ b/internal/games/service.go @@ -151,7 +151,7 @@ func (s *Service) processGameForStats(stats *TeamStatistics, model database.Game stats.Losses++ } - var results []Result + var results []RoundResult if err := json.Unmarshal(model.ResultsLog, &results); err != nil { return fmt.Errorf("parsing game %d results: %w", model.ID, err) } diff --git a/internal/games/service_test.go b/internal/games/service_test.go index e8e8535..03fa251 100644 --- a/internal/games/service_test.go +++ b/internal/games/service_test.go @@ -57,19 +57,19 @@ func TestService_GetTeamStatistics(t *testing.T) { 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, []Result{ + persistCompletedGame(t, gameSvc, ctx, targetCfg, opponentCfg, targetTeam.ID, []RoundResult{ {Outcome: ResultTeamA}, {Outcome: ResultTeamA}, {Outcome: ResultTeamB}, }) - persistCompletedGame(t, gameSvc, ctx, opponentCfg, targetCfg, opponentTeam.ID, []Result{ + persistCompletedGame(t, gameSvc, ctx, opponentCfg, targetCfg, opponentTeam.ID, []RoundResult{ {Outcome: ResultTeamA}, {Outcome: ResultTeamB}, {Outcome: ResultTeamA}, }) - persistCompletedGame(t, gameSvc, ctx, targetCfg, opponentCfg, 0, []Result{ + persistCompletedGame(t, gameSvc, ctx, targetCfg, opponentCfg, 0, []RoundResult{ {Outcome: ResultTeamA}, {Outcome: ResultTeamB}, }) @@ -115,7 +115,7 @@ func setupStatsTestServices(t *testing.T) (*teams.Service, *Service) { return teamSvc, gameSvc } -func persistCompletedGame(t *testing.T, gameSvc *Service, ctx context.Context, teamA TeamConfig, teamB TeamConfig, winner int64, results []Result) { +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) diff --git a/internal/games/types.go b/internal/games/types.go index f0089bd..4a0edc4 100644 --- a/internal/games/types.go +++ b/internal/games/types.go @@ -19,12 +19,12 @@ const ( StatusComplete GameStatus = "complete" ) -type ResultOutcome string +type RoundOutcome string const ( - ResultDraw ResultOutcome = "draw" - ResultTeamA ResultOutcome = "team_a" - ResultTeamB ResultOutcome = "team_b" + ResultDraw RoundOutcome = "draw" + ResultTeamA RoundOutcome = "team_a" + ResultTeamB RoundOutcome = "team_b" ) type Game struct { @@ -37,7 +37,7 @@ type Game struct { status GameStatus rounds [10]Round currentRound int64 - results []Result + results []RoundResult createdAt time.Time updatedAt time.Time } @@ -78,8 +78,8 @@ type LanesConfig struct { Lane3 int } -type Result struct { - Outcome ResultOutcome +type RoundResult struct { + Outcome RoundOutcome RemainingPlayers int } diff --git a/internal/ui/components/game.go b/internal/ui/components/game.go index 45f9113..b3d2366 100644 --- a/internal/ui/components/game.go +++ b/internal/ui/components/game.go @@ -16,7 +16,7 @@ type Game struct { teamAName string teamBName string resolved bool - result games.Result + result games.RoundResult rollFn games.RollFn roundComp Round } From 3d33a8836cd1d271e53cade8618a818a2a3ed250 Mon Sep 17 00:00:00 2001 From: frag223 Date: Sat, 4 Jul 2026 21:15:06 +1000 Subject: [PATCH 49/72] wip creating library --- internal/augments/augments.go | 102 +++++++++++++++++++++++++++++ internal/augments/augments_test.go | 93 ++++++++++++++++++++++++++ internal/augments/types.go | 51 +++++++++++++++ internal/games/dice_roll.go | 12 ---- internal/teams/tokens.go | 62 ++++++++++-------- internal/teams/tokens_test.go | 35 +++++----- internal/teams/types.go | 23 ++----- 7 files changed, 301 insertions(+), 77 deletions(-) create mode 100644 internal/augments/augments.go create mode 100644 internal/augments/augments_test.go create mode 100644 internal/augments/types.go diff --git a/internal/augments/augments.go b/internal/augments/augments.go new file mode 100644 index 0000000..1aca1eb --- /dev/null +++ b/internal/augments/augments.go @@ -0,0 +1,102 @@ +package augments + +import ( + "maps" + "slices" +) + +var _repository = map[Name]Effect{ + TwistOfFate: { + Name: TwistOfFate, + Category: CategoryOffense, + Effect: "Roll 2d6 and keep the highest.", + Intent: "Front-load pressure in must-win lanes.", + Action: ActionAddDie, + Target: TargetSelf, + Amount: 1, + }, + SecondChance: { + Name: SecondChance, + Category: CategoryDefense, + 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, + }, + Overpower: { + Name: Overpower, + Category: CategoryOffense, + Effect: "Gain +1 to your roll total this duel.", + Intent: "Reliable low-variance push.", + Action: ActionIncrease, + Target: TargetSelf, + }, + Hamstring: { + Name: Hamstring, + Category: CategorySabotage, + Effect: "Opponent gets -1 to their roll total this duel.", + Intent: "Defensive denial and tempo slowdown.", + Action: ActionDecrease, + Target: TargetOpponent, + }, + PrecisionStrike: { + Name: PrecisionStrike, + Category: CategoryOffense, + Effect: "Add +1 to your revealed total.", + Intent: "Skill-expression token for close reads.", + Action: ActionIncrease, + Target: TargetSelf, + }, + JammingSignal: { + Name: JammingSignal, + Category: CategorySabotage, + Effect: "Cancel the opponent's declared Pre-roll token.", + Intent: "Anti-pattern counterplay.", + Action: ActionCancel, + Target: TargetOpponent, + }, + LastStand: { + Name: LastStand, + Category: CategoryDefense, + Effect: "Prevent your elimination this duel; lane remains unresolved.", + Intent: "Comeback insurance for high-value lanes.", + Action: "", + Target: TargetSelf, + }, + MomentumSurge: { + Name: MomentumSurge, + Category: CategoryOffense, + Effect: "If last round was a win, gain +2 this duel.", + Intent: "Snowball option with explicit condition gate.", + Action: ActionIncrease, + Target: TargetSelf, + Amount: 2, + }, + IceInVeins: { + Name: IceInVeins, + Category: CategoryOffense, + Effect: "Convert tie into a win for your side.", + Intent: "Tie-state control and clutch finish potential.", + Action: ActionIncrease, + Target: TargetSelf, + Amount: 1, + }, + SmokeScreen: { + Name: SmokeScreen, + Category: CategorySabotage, + Effect: "Your token declaration remains hidden until after reveal.", + Intent: "Mind-game tool to punish reactive opponents.", + Action: "", + Target: TargetSelf, + }, +} + +func Get(name Name) (Effect, bool) { + item, ok := _repository[name] + return item, ok +} + +func List() []Name { + return slices.Collect(maps.Keys(_repository)) +} diff --git a/internal/augments/augments_test.go b/internal/augments/augments_test.go new file mode 100644 index 0000000..21c2370 --- /dev/null +++ b/internal/augments/augments_test.go @@ -0,0 +1,93 @@ +package augments + +import ( + "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{ + TwistOfFate, + SecondChance, + Overpower, + Hamstring, + PrecisionStrike, + JammingSignal, + LastStand, + MomentumSurge, + IceInVeins, + SmokeScreen, + } + + for _, name := range expectedTokens { + _, ok := _repository[name] + odize.AssertTrue(t, ok) + } + }). + Test("should have correct number of tokens per category", func(t *testing.T) { + counts := make(map[Category]int) + for _, effect := range _repository { + counts[effect.Category]++ + } + + odize.AssertEqual(t, 5, counts[CategoryOffense]) + odize.AssertEqual(t, 2, counts[CategoryDefense]) + odize.AssertEqual(t, 3, counts[CategorySabotage]) + }). + 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 TestList(t *testing.T) { + group := odize.NewGroup(t, nil) + + err := group. + Test("should return all token names in repository", func(t *testing.T) { + names := List() + odize.AssertEqual(t, len(_repository), len(names)) + + for _, name := range names { + _, ok := _repository[name] + odize.AssertTrue(t, ok) + } + }). + Run() + + odize.AssertNoError(t, err) +} diff --git a/internal/augments/types.go b/internal/augments/types.go new file mode 100644 index 0000000..932493c --- /dev/null +++ b/internal/augments/types.go @@ -0,0 +1,51 @@ +package augments + +type Name string + +const ( + TwistOfFate Name = "Twist of Fate" + SecondChance Name = "Second Chance" + Overpower Name = "Power Play" + Hamstring Name = "Brace" + PrecisionStrike Name = "Precision Strike" + JammingSignal Name = "Jamming Signal" + LastStand Name = "Last Stand" + MomentumSurge Name = "Momentum Surge" + IceInVeins Name = "Ice in Veins" + SmokeScreen Name = "Smoke Screen" +) + +type Action string + +const ( + ActionIncrease Action = "increase" + ActionDecrease Action = "decrease" + ActionAddDie Action = "add_die" + ActionReRoll Action = "re_roll" + ActionCancel Action = "cancel" +) + +type Target string + +const ( + TargetSelf Target = "self" + TargetOpponent Target = "opponent" +) + +type Category string + +const ( + CategoryOffense Category = "offense" + CategoryDefense Category = "defense" + CategorySabotage Category = "sabotage" +) + +type Effect struct { + Name Name `json:"name,omitempty"` + Category Category `json:"category,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/games/dice_roll.go b/internal/games/dice_roll.go index 3db29f0..b31af5e 100644 --- a/internal/games/dice_roll.go +++ b/internal/games/dice_roll.go @@ -2,20 +2,8 @@ package games import ( "math/rand/v2" - - "github.com/code-gorilla-au/rush/internal/teams" ) -type RollEngine struct { - TeamA teams.TokenName - TeamB teams.TokenName -} - -type RollResult struct { - Roll int - Team RoundOutcome -} - func DiceRoll() int { return rand.IntN(6) + 1 } diff --git a/internal/teams/tokens.go b/internal/teams/tokens.go index 8a42fa8..11702d7 100644 --- a/internal/teams/tokens.go +++ b/internal/teams/tokens.go @@ -1,50 +1,54 @@ package teams -import "slices" +import ( + "slices" -var coachPersonaTokens = map[CoachPersona][]TokenName{ + "github.com/code-gorilla-au/rush/internal/augments" +) + +var coachPersonaTokens = map[CoachPersona][]augments.Name{ CoachPersonaVanguard: { - TokenTwistOfFate, - TokenPowerPlay, - TokenMomentumSurge, - TokenPrecisionStrike, - TokenIceInVeins, + augments.TwistOfFate, + augments.Overpower, + augments.MomentumSurge, + augments.PrecisionStrike, + augments.IceInVeins, }, CoachPersonaBastion: { - TokenSecondChance, - TokenBrace, - TokenLastStand, - TokenJammingSignal, - TokenIceInVeins, + augments.SecondChance, + augments.Hamstring, + augments.LastStand, + augments.JammingSignal, + augments.IceInVeins, }, CoachPersonaTrickster: { - TokenJammingSignal, - TokenSmokeScreen, - TokenPrecisionStrike, - TokenSecondChance, - TokenPowerPlay, + augments.JammingSignal, + augments.SmokeScreen, + augments.PrecisionStrike, + augments.SecondChance, + augments.Overpower, }, CoachPersonaWildcard: { - TokenTwistOfFate, - TokenSecondChance, - TokenPowerPlay, - TokenBrace, - TokenPrecisionStrike, - TokenJammingSignal, - TokenIceInVeins, - TokenSmokeScreen, + augments.TwistOfFate, + augments.SecondChance, + augments.Overpower, + augments.Hamstring, + augments.PrecisionStrike, + augments.JammingSignal, + augments.IceInVeins, + augments.SmokeScreen, }, } -func (p CoachPersona) AvailableTokens() []TokenName { +func (p CoachPersona) Augments() []augments.Name { tokens, ok := coachPersonaTokens[p] if !ok { - return []TokenName{} + return []augments.Name{} } return slices.Clone(tokens) } -func (c *Coach) AvailableTokens() []TokenName { - return c.Persona.AvailableTokens() +func (c *Coach) AvailableAugments() []augments.Name { + return c.Persona.Augments() } diff --git a/internal/teams/tokens_test.go b/internal/teams/tokens_test.go index 666e104..5e54005 100644 --- a/internal/teams/tokens_test.go +++ b/internal/teams/tokens_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/code-gorilla-au/odize" + "github.com/code-gorilla-au/rush/internal/augments" ) func TestCoachPersona_AvailableTokens(t *testing.T) { @@ -11,29 +12,29 @@ func TestCoachPersona_AvailableTokens(t *testing.T) { err := group. Test("returns expected tokens for known persona", func(t *testing.T) { - tokens := CoachPersonaBastion.AvailableTokens() - - expected := []TokenName{ - TokenSecondChance, - TokenBrace, - TokenLastStand, - TokenJammingSignal, - TokenIceInVeins, + tokens := CoachPersonaBastion.Augments() + + expected := []augments.Name{ + augments.SecondChance, + augments.Hamstring, + augments.LastStand, + augments.JammingSignal, + augments.IceInVeins, } odize.AssertEqual(t, expected, tokens) }). Test("returns cloned slice to prevent mutation leaks", func(t *testing.T) { - first := CoachPersonaVanguard.AvailableTokens() - second := CoachPersonaVanguard.AvailableTokens() + first := CoachPersonaVanguard.Augments() + second := CoachPersonaVanguard.Augments() - first[0] = TokenSmokeScreen + first[0] = augments.SmokeScreen - odize.AssertEqual(t, TokenTwistOfFate, second[0]) + odize.AssertEqual(t, augments.TwistOfFate, second[0]) }). - Test("returns nil for unknown persona", func(t *testing.T) { - tokens := CoachPersona("Unknown Coach").AvailableTokens() - odize.AssertTrue(t, tokens == nil) + Test("returns empty slice for unknown persona", func(t *testing.T) { + tokens := CoachPersona("Unknown Coach").Augments() + odize.AssertTrue(t, len(tokens) == 0) }). Run() @@ -47,8 +48,8 @@ func TestCoach_AvailableTokens(t *testing.T) { Test("returns tokens from coach persona", func(t *testing.T) { coach := Coach{Persona: CoachPersonaTrickster} - expected := CoachPersonaTrickster.AvailableTokens() - actual := coach.AvailableTokens() + expected := CoachPersonaTrickster.Augments() + actual := coach.AvailableAugments() odize.AssertEqual(t, expected, actual) }). diff --git a/internal/teams/types.go b/internal/teams/types.go index c36069d..e24a195 100644 --- a/internal/teams/types.go +++ b/internal/teams/types.go @@ -44,26 +44,11 @@ var ( ErrTeamNotFound = errors.New("team not found") ) -type TokenName string - -const ( - TokenTwistOfFate TokenName = "Twist of Fate" - TokenSecondChance TokenName = "Second Chance" - TokenPowerPlay TokenName = "Power Play" - TokenBrace TokenName = "Brace" - TokenPrecisionStrike TokenName = "Precision Strike" - TokenJammingSignal TokenName = "Jamming Signal" - TokenLastStand TokenName = "Last Stand" - TokenMomentumSurge TokenName = "Momentum Surge" - TokenIceInVeins TokenName = "Ice in Veins" - TokenSmokeScreen TokenName = "Smoke Screen" -) - type CoachPersona string const ( - CoachPersonaVanguard CoachPersona = "Vanguard Coach" - CoachPersonaBastion CoachPersona = "Bastion Coach" - CoachPersonaTrickster CoachPersona = "Trickster Coach" - CoachPersonaWildcard CoachPersona = "Wildcard Coach" + CoachPersonaVanguard CoachPersona = "Vanguard" + CoachPersonaBastion CoachPersona = "Bastion" + CoachPersonaTrickster CoachPersona = "Trickster" + CoachPersonaWildcard CoachPersona = "Wildcard" ) From 7e674bf17ed1e4058263738ea4ea364ea6d0b700 Mon Sep 17 00:00:00 2001 From: frag223 Date: Sat, 4 Jul 2026 21:34:07 +1000 Subject: [PATCH 50/72] wip creating library --- internal/augments/augments.go | 20 ++++++++++++-------- internal/augments/augments_test.go | 3 +-- internal/augments/types.go | 28 ++++++++++++++++++---------- internal/teams/tokens.go | 2 -- internal/teams/tokens_test.go | 2 +- 5 files changed, 32 insertions(+), 23 deletions(-) diff --git a/internal/augments/augments.go b/internal/augments/augments.go index 1aca1eb..e35a715 100644 --- a/internal/augments/augments.go +++ b/internal/augments/augments.go @@ -9,6 +9,7 @@ var _repository = map[Name]Effect{ TwistOfFate: { Name: TwistOfFate, Category: CategoryOffense, + Trigger: TriggerConditionOnRoll, Effect: "Roll 2d6 and keep the highest.", Intent: "Front-load pressure in must-win lanes.", Action: ActionAddDie, @@ -18,6 +19,7 @@ var _repository = map[Name]Effect{ SecondChance: { Name: SecondChance, Category: CategoryDefense, + Trigger: TriggerConditionOnLoss, Effect: "Re-roll your own die once; second result replaces the first.", Intent: "Stabilize critical moments after a miss or tie.", Action: ActionReRoll, @@ -27,18 +29,22 @@ var _repository = map[Name]Effect{ Overpower: { Name: Overpower, Category: CategoryOffense, + Trigger: TriggerConditionOnRoll, Effect: "Gain +1 to your roll total this duel.", Intent: "Reliable low-variance push.", Action: ActionIncrease, Target: TargetSelf, + Amount: 1, }, Hamstring: { Name: Hamstring, Category: CategorySabotage, + Trigger: TriggerConditionOnRoll, Effect: "Opponent gets -1 to their roll total this duel.", Intent: "Defensive denial and tempo slowdown.", Action: ActionDecrease, Target: TargetOpponent, + Amount: 1, }, PrecisionStrike: { Name: PrecisionStrike, @@ -51,22 +57,27 @@ var _repository = map[Name]Effect{ JammingSignal: { Name: JammingSignal, Category: CategorySabotage, + Trigger: TriggerConditionOnRoll, Effect: "Cancel the opponent's declared Pre-roll token.", Intent: "Anti-pattern counterplay.", Action: ActionCancel, Target: TargetOpponent, + Amount: 0, }, LastStand: { Name: LastStand, Category: CategoryDefense, + Trigger: TriggerConditionOnLoss, Effect: "Prevent your elimination this duel; lane remains unresolved.", Intent: "Comeback insurance for high-value lanes.", Action: "", Target: TargetSelf, + Amount: 0, }, MomentumSurge: { Name: MomentumSurge, Category: CategoryOffense, + Trigger: TriggerConditionOnRoll, Effect: "If last round was a win, gain +2 this duel.", Intent: "Snowball option with explicit condition gate.", Action: ActionIncrease, @@ -76,20 +87,13 @@ var _repository = map[Name]Effect{ IceInVeins: { Name: IceInVeins, Category: CategoryOffense, + Trigger: TriggerConditionOnDraw, Effect: "Convert tie into a win for your side.", Intent: "Tie-state control and clutch finish potential.", Action: ActionIncrease, Target: TargetSelf, Amount: 1, }, - SmokeScreen: { - Name: SmokeScreen, - Category: CategorySabotage, - Effect: "Your token declaration remains hidden until after reveal.", - Intent: "Mind-game tool to punish reactive opponents.", - Action: "", - Target: TargetSelf, - }, } func Get(name Name) (Effect, bool) { diff --git a/internal/augments/augments_test.go b/internal/augments/augments_test.go index 21c2370..3470fbc 100644 --- a/internal/augments/augments_test.go +++ b/internal/augments/augments_test.go @@ -21,7 +21,6 @@ func TestRepository(t *testing.T) { LastStand, MomentumSurge, IceInVeins, - SmokeScreen, } for _, name := range expectedTokens { @@ -37,7 +36,7 @@ func TestRepository(t *testing.T) { odize.AssertEqual(t, 5, counts[CategoryOffense]) odize.AssertEqual(t, 2, counts[CategoryDefense]) - odize.AssertEqual(t, 3, counts[CategorySabotage]) + odize.AssertEqual(t, 2, counts[CategorySabotage]) }). Run() diff --git a/internal/augments/types.go b/internal/augments/types.go index 932493c..0b8b3dd 100644 --- a/internal/augments/types.go +++ b/internal/augments/types.go @@ -5,14 +5,13 @@ type Name string const ( TwistOfFate Name = "Twist of Fate" SecondChance Name = "Second Chance" - Overpower Name = "Power Play" - Hamstring Name = "Brace" + Overpower Name = "Overpower" + Hamstring Name = "Hamstring" PrecisionStrike Name = "Precision Strike" JammingSignal Name = "Jamming Signal" LastStand Name = "Last Stand" MomentumSurge Name = "Momentum Surge" IceInVeins Name = "Ice in Veins" - SmokeScreen Name = "Smoke Screen" ) type Action string @@ -40,12 +39,21 @@ const ( CategorySabotage Category = "sabotage" ) +type TriggerCondition string + +const ( + TriggerConditionOnRoll TriggerCondition = "on_roll" + TriggerConditionOnLoss TriggerCondition = "on_loss" + TriggerConditionOnDraw TriggerCondition = "on_draw" +) + type Effect struct { - Name Name `json:"name,omitempty"` - Category Category `json:"category,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"` + Name Name `json:"name,omitempty"` + Category Category `json:"category,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/teams/tokens.go b/internal/teams/tokens.go index 11702d7..099b36e 100644 --- a/internal/teams/tokens.go +++ b/internal/teams/tokens.go @@ -23,7 +23,6 @@ var coachPersonaTokens = map[CoachPersona][]augments.Name{ }, CoachPersonaTrickster: { augments.JammingSignal, - augments.SmokeScreen, augments.PrecisionStrike, augments.SecondChance, augments.Overpower, @@ -36,7 +35,6 @@ var coachPersonaTokens = map[CoachPersona][]augments.Name{ augments.PrecisionStrike, augments.JammingSignal, augments.IceInVeins, - augments.SmokeScreen, }, } diff --git a/internal/teams/tokens_test.go b/internal/teams/tokens_test.go index 5e54005..0371e23 100644 --- a/internal/teams/tokens_test.go +++ b/internal/teams/tokens_test.go @@ -28,7 +28,7 @@ func TestCoachPersona_AvailableTokens(t *testing.T) { first := CoachPersonaVanguard.Augments() second := CoachPersonaVanguard.Augments() - first[0] = augments.SmokeScreen + first[0] = augments.SecondChance odize.AssertEqual(t, augments.TwistOfFate, second[0]) }). From 389fba228a8d096303f589cf543de6030de73d8b Mon Sep 17 00:00:00 2001 From: frag223 Date: Sat, 4 Jul 2026 21:46:25 +1000 Subject: [PATCH 51/72] wip --- internal/augments/augments.go | 2 +- internal/augments/augments_test.go | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/augments/augments.go b/internal/augments/augments.go index e35a715..7a21c7c 100644 --- a/internal/augments/augments.go +++ b/internal/augments/augments.go @@ -86,7 +86,7 @@ var _repository = map[Name]Effect{ }, IceInVeins: { Name: IceInVeins, - Category: CategoryOffense, + Category: CategorySabotage, Trigger: TriggerConditionOnDraw, Effect: "Convert tie into a win for your side.", Intent: "Tie-state control and clutch finish potential.", diff --git a/internal/augments/augments_test.go b/internal/augments/augments_test.go index 3470fbc..239b883 100644 --- a/internal/augments/augments_test.go +++ b/internal/augments/augments_test.go @@ -34,9 +34,9 @@ func TestRepository(t *testing.T) { counts[effect.Category]++ } - odize.AssertEqual(t, 5, counts[CategoryOffense]) + odize.AssertEqual(t, 4, counts[CategoryOffense]) odize.AssertEqual(t, 2, counts[CategoryDefense]) - odize.AssertEqual(t, 2, counts[CategorySabotage]) + odize.AssertEqual(t, 3, counts[CategorySabotage]) }). Run() From 6f67528b421ba09b6d2fc9505f38fdb6fff28ab8 Mon Sep 17 00:00:00 2001 From: frag223 Date: Sat, 4 Jul 2026 21:50:52 +1000 Subject: [PATCH 52/72] wip --- docs/rules.md | 57 +++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 46 insertions(+), 11 deletions(-) diff --git a/docs/rules.md b/docs/rules.md index 5de2fd3..e2d7262 100644 --- a/docs/rules.md +++ b/docs/rules.md @@ -10,8 +10,10 @@ Rush is a five-player lane battle game. Rush takes some elements from risk and b - 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 @@ -25,8 +27,15 @@ Each round is resolved in this order: - 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. + - A duel is one complete lane interaction between two opposing players. + - Each duel follows these windows: + 1. **Pre-roll Window**: Coaches may declare one Pre-roll token. + 2. **Roll**: Both players roll `1d6`. + 3. **Reveal**: Results are revealed. Highest score wins. + 4. **Reaction Window**: Eligible Reaction tokens may be declared. + 5. **Resolution Window**: Eligible Resolution tokens may be declared. + 6. **Lane Update**: Apply the final outcome. + - If dice rolls are equal and no tokens resolve the tie, immediately re-roll. - The losing player is eliminated for the round. - Lane duels continue until no opposing players remain in that lane. @@ -56,15 +65,41 @@ To keep the game simple but add meaningful decisions, playbooks and coaches shou - **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 resources per match - -- Give each coach a small tactical resource pool (for example, `2` tokens per match). -- A token can be spent on effects such as: - - `+1` to a single duel roll (declared before rolling), or - - one re-roll in a chosen lane. -- Limited resources force long-term planning across ten rounds. - -### 4) Lane outcome value beyond score +### 3) Tactical Tokens + +Coaches use tokens to influence the outcome of duels. +- Each token is **single-use** per match. +- Max **one token per coach per duel**. +- Tokens do not stack for the same coach in a single duel. + +#### 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, From 120d58bcc1705b38ae208ebe96a74222cac8ff2b Mon Sep 17 00:00:00 2001 From: frag223 Date: Sat, 4 Jul 2026 21:54:47 +1000 Subject: [PATCH 53/72] wip --- docs/rules.md | 21 ++++++++------------- 1 file changed, 8 insertions(+), 13 deletions(-) diff --git a/docs/rules.md b/docs/rules.md index e2d7262..7e5848d 100644 --- a/docs/rules.md +++ b/docs/rules.md @@ -22,20 +22,15 @@ 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. **Lane assignment** +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** - - A duel is one complete lane interaction between two opposing players. - - Each duel follows these windows: - 1. **Pre-roll Window**: Coaches may declare one Pre-roll token. - 2. **Roll**: Both players roll `1d6`. - 3. **Reveal**: Results are revealed. Highest score wins. - 4. **Reaction Window**: Eligible Reaction tokens may be declared. - 5. **Resolution Window**: Eligible Resolution tokens may be declared. - 6. **Lane Update**: Apply the final outcome. - - If dice rolls are equal and no tokens resolve the tie, immediately re-roll. + - 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. @@ -67,10 +62,10 @@ To keep the game simple but add meaningful decisions, playbooks and coaches shou ### 3) Tactical Tokens -Coaches use tokens to influence the outcome of duels. +Coaches use tokens to influence the outcome of the match. - Each token is **single-use** per match. -- Max **one token per coach per duel**. -- Tokens do not stack for the same coach in a single duel. +- Max **3 augmentations per game**. +- An augmentation is selected **before each round**. #### Token Library From 57be13c0b8d9f72f59a7009e1388ff2d0748ffa3 Mon Sep 17 00:00:00 2001 From: frag223 Date: Sat, 4 Jul 2026 22:18:39 +1000 Subject: [PATCH 54/72] allocating augments to coaches --- internal/augments/augments.go | 182 +++++++++++++++++----------------- internal/teams/tokens.go | 11 +- 2 files changed, 95 insertions(+), 98 deletions(-) diff --git a/internal/augments/augments.go b/internal/augments/augments.go index 7a21c7c..453cc67 100644 --- a/internal/augments/augments.go +++ b/internal/augments/augments.go @@ -5,96 +5,98 @@ import ( "slices" ) -var _repository = map[Name]Effect{ - TwistOfFate: { - Name: TwistOfFate, - Category: CategoryOffense, - Trigger: TriggerConditionOnRoll, - Effect: "Roll 2d6 and keep the highest.", - Intent: "Front-load pressure in must-win lanes.", - Action: ActionAddDie, - Target: TargetSelf, - Amount: 1, - }, - SecondChance: { - Name: SecondChance, - Category: CategoryDefense, - Trigger: TriggerConditionOnLoss, - 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, - }, - Overpower: { - Name: Overpower, - Category: CategoryOffense, - Trigger: TriggerConditionOnRoll, - Effect: "Gain +1 to your roll total this duel.", - Intent: "Reliable low-variance push.", - Action: ActionIncrease, - Target: TargetSelf, - Amount: 1, - }, - Hamstring: { - Name: Hamstring, - Category: CategorySabotage, - Trigger: TriggerConditionOnRoll, - Effect: "Opponent gets -1 to their roll total this duel.", - Intent: "Defensive denial and tempo slowdown.", - Action: ActionDecrease, - Target: TargetOpponent, - Amount: 1, - }, - PrecisionStrike: { - Name: PrecisionStrike, - Category: CategoryOffense, - Effect: "Add +1 to your revealed total.", - Intent: "Skill-expression token for close reads.", - Action: ActionIncrease, - Target: TargetSelf, - }, - JammingSignal: { - Name: JammingSignal, - Category: CategorySabotage, - Trigger: TriggerConditionOnRoll, - Effect: "Cancel the opponent's declared Pre-roll token.", - Intent: "Anti-pattern counterplay.", - Action: ActionCancel, - Target: TargetOpponent, - Amount: 0, - }, - LastStand: { - Name: LastStand, - Category: CategoryDefense, - Trigger: TriggerConditionOnLoss, - Effect: "Prevent your elimination this duel; lane remains unresolved.", - Intent: "Comeback insurance for high-value lanes.", - Action: "", - Target: TargetSelf, - Amount: 0, - }, - MomentumSurge: { - Name: MomentumSurge, - Category: CategoryOffense, - Trigger: TriggerConditionOnRoll, - Effect: "If last round was a win, gain +2 this duel.", - Intent: "Snowball option with explicit condition gate.", - Action: ActionIncrease, - Target: TargetSelf, - Amount: 2, - }, - IceInVeins: { - Name: IceInVeins, - Category: CategorySabotage, - Trigger: TriggerConditionOnDraw, - Effect: "Convert tie into a win for your side.", - Intent: "Tie-state control and clutch finish potential.", - Action: ActionIncrease, - Target: TargetSelf, - Amount: 1, - }, -} +var ( + _repository = map[Name]Effect{ + TwistOfFate: { + Name: TwistOfFate, + Category: CategoryOffense, + Trigger: TriggerConditionOnRoll, + Effect: "Roll 2d6 and keep the highest.", + Intent: "Front-load pressure in must-win lanes.", + Action: ActionAddDie, + Target: TargetSelf, + Amount: 1, + }, + SecondChance: { + Name: SecondChance, + Category: CategoryDefense, + Trigger: TriggerConditionOnLoss, + 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, + }, + Overpower: { + Name: Overpower, + Category: CategoryOffense, + Trigger: TriggerConditionOnRoll, + Effect: "Gain +1 to your roll total this duel.", + Intent: "Reliable low-variance push.", + Action: ActionIncrease, + Target: TargetSelf, + Amount: 1, + }, + Hamstring: { + Name: Hamstring, + Category: CategorySabotage, + Trigger: TriggerConditionOnRoll, + Effect: "Opponent gets -1 to their roll total this duel.", + Intent: "Defensive denial and tempo slowdown.", + Action: ActionDecrease, + Target: TargetOpponent, + Amount: 1, + }, + PrecisionStrike: { + Name: PrecisionStrike, + Category: CategoryOffense, + Effect: "Add +1 to your revealed total.", + Intent: "Skill-expression token for close reads.", + Action: ActionIncrease, + Target: TargetSelf, + }, + JammingSignal: { + Name: JammingSignal, + Category: CategorySabotage, + Trigger: TriggerConditionOnRoll, + Effect: "Cancel the opponent's declared Pre-roll token.", + Intent: "Anti-pattern counterplay.", + Action: ActionCancel, + Target: TargetOpponent, + Amount: 0, + }, + LastStand: { + Name: LastStand, + Category: CategoryDefense, + Trigger: TriggerConditionOnLoss, + Effect: "Prevent your elimination this duel; lane remains unresolved.", + Intent: "Comeback insurance for high-value lanes.", + Action: "", + Target: TargetSelf, + Amount: 0, + }, + MomentumSurge: { + Name: MomentumSurge, + Category: CategoryOffense, + Trigger: TriggerConditionOnRoll, + Effect: "If last round was a win, gain +2 this duel.", + Intent: "Snowball option with explicit condition gate.", + Action: ActionIncrease, + Target: TargetSelf, + Amount: 2, + }, + IceInVeins: { + Name: IceInVeins, + Category: CategorySabotage, + Trigger: TriggerConditionOnDraw, + Effect: "Convert tie into a win for your side.", + Intent: "Tie-state control and clutch finish potential.", + Action: ActionIncrease, + Target: TargetSelf, + Amount: 1, + }, + } +) func Get(name Name) (Effect, bool) { item, ok := _repository[name] diff --git a/internal/teams/tokens.go b/internal/teams/tokens.go index 099b36e..1ce60e1 100644 --- a/internal/teams/tokens.go +++ b/internal/teams/tokens.go @@ -6,40 +6,35 @@ import ( "github.com/code-gorilla-au/rush/internal/augments" ) -var coachPersonaTokens = map[CoachPersona][]augments.Name{ +var _coachPersonaTokens = map[CoachPersona][]augments.Name{ CoachPersonaVanguard: { augments.TwistOfFate, augments.Overpower, augments.MomentumSurge, augments.PrecisionStrike, - augments.IceInVeins, }, CoachPersonaBastion: { augments.SecondChance, augments.Hamstring, augments.LastStand, - augments.JammingSignal, augments.IceInVeins, }, CoachPersonaTrickster: { augments.JammingSignal, - augments.PrecisionStrike, augments.SecondChance, augments.Overpower, + augments.IceInVeins, }, CoachPersonaWildcard: { augments.TwistOfFate, - augments.SecondChance, augments.Overpower, augments.Hamstring, augments.PrecisionStrike, - augments.JammingSignal, - augments.IceInVeins, }, } func (p CoachPersona) Augments() []augments.Name { - tokens, ok := coachPersonaTokens[p] + tokens, ok := _coachPersonaTokens[p] if !ok { return []augments.Name{} } From 3e91870e0e918d7871837e6cbd2e43bfcb454b2e Mon Sep 17 00:00:00 2001 From: frag223 Date: Sat, 4 Jul 2026 23:01:55 +1000 Subject: [PATCH 55/72] clean up + adding duel results for the round --- internal/games/dice_roll.go | 13 +++++++++ internal/games/rounds.go | 20 ++++++++++++-- internal/games/types.go | 52 +++++++++++++++++++---------------- internal/teams/tokens_test.go | 1 - 4 files changed, 60 insertions(+), 26 deletions(-) diff --git a/internal/games/dice_roll.go b/internal/games/dice_roll.go index b31af5e..5d55f10 100644 --- a/internal/games/dice_roll.go +++ b/internal/games/dice_roll.go @@ -2,8 +2,21 @@ package games import ( "math/rand/v2" + + "github.com/code-gorilla-au/rush/internal/augments" ) func DiceRoll() int { return rand.IntN(6) + 1 } + +type TeamDecisionInput struct { + augment augments.Name + roll int +} + +type DecisionInput struct { + lastRound RoundResult + teamAAugment augments.Name + teamBAugment augments.Name +} diff --git a/internal/games/rounds.go b/internal/games/rounds.go index 6eb1d85..3847164 100644 --- a/internal/games/rounds.go +++ b/internal/games/rounds.go @@ -6,8 +6,9 @@ import ( func NewRound() Round { return Round{ - TeamA: TeamFormation{Lanes: [3][]int{}}, - TeamB: TeamFormation{Lanes: [3][]int{}}, + TeamA: TeamFormation{Lanes: [3][]int{}}, + TeamB: TeamFormation{Lanes: [3][]int{}}, + DuelResults: []DuelResults{}, } } @@ -75,18 +76,33 @@ func (r *Round) ResolveLane(lane int, rollFn RollFn) RoundResult { } if aRoll > bRoll { + r.DuelResults = append(r.DuelResults, DuelResults{ + Outcome: ResultTeamA, + Roll: aRoll, + }) + _, err := r.TeamB.LanePop(lane) if errors.Is(err, ErrNoPlayer) { break } } else if bRoll > aRoll { + r.DuelResults = append(r.DuelResults, DuelResults{ + Outcome: ResultTeamB, + Roll: bRoll, + }) + _, err := r.TeamA.LanePop(lane) if errors.Is(err, ErrNoPlayer) { break } } + r.DuelResults = append(r.DuelResults, DuelResults{ + Outcome: ResultDraw, + Roll: 0, + }) + } if r.TeamA.LaneHasPlayers(lane) { diff --git a/internal/games/types.go b/internal/games/types.go index 4a0edc4..b8359b6 100644 --- a/internal/games/types.go +++ b/internal/games/types.go @@ -43,44 +43,50 @@ type Game struct { } type Round struct { - TeamA TeamFormation - TeamB TeamFormation + TeamA TeamFormation `json:"team_a"` + TeamB TeamFormation `json:"team_b"` + DuelResults []DuelResults `json:"duel_results"` +} + +type DuelResults struct { + Outcome RoundOutcome `json:"outcome"` + Roll int `json:"roll"` } type TeamStatistics struct { - GamesPlayed int - Wins int - Draws int - Losses int - WinRate float64 - RoundsWon int - RoundsLost int - RoundDifferential int - AverageRoundsWon float64 - AverageRoundsLost float64 + 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"` + Formations []playbooks.Formation `json:"formations"` } type TeamFormation struct { - TeamID int64 - Lanes [3][]int + TeamID int64 `json:"team_id,omitempty"` + Lanes [3][]int `json:"lanes,omitempty"` } type LanesConfig struct { - TeamID int64 - Lane1 int - Lane2 int - Lane3 int + TeamID int64 `json:"team_id"` + Lane1 int `json:"lane_1"` + Lane2 int `json:"lane_2"` + Lane3 int `json:"lane_3"` } type RoundResult struct { - Outcome RoundOutcome - RemainingPlayers int + Outcome RoundOutcome `json:"outcome"` + RemainingPlayers int `json:"remaining_players"` } type RollFn func() int diff --git a/internal/teams/tokens_test.go b/internal/teams/tokens_test.go index 0371e23..1eb6416 100644 --- a/internal/teams/tokens_test.go +++ b/internal/teams/tokens_test.go @@ -18,7 +18,6 @@ func TestCoachPersona_AvailableTokens(t *testing.T) { augments.SecondChance, augments.Hamstring, augments.LastStand, - augments.JammingSignal, augments.IceInVeins, } From a1de8f955230421a27d9470c1329a25abf7abb12 Mon Sep 17 00:00:00 2001 From: frag223 Date: Sun, 5 Jul 2026 19:39:12 +1000 Subject: [PATCH 56/72] clean up + adding duel results for the round --- internal/augments/augments.go | 127 +++++++++++++++++++---------- internal/augments/augments_test.go | 10 ++- internal/augments/types.go | 13 ++- internal/games/dice_roll.go | 32 +++++++- internal/games/dice_roll_test.go | 127 +++++++++++++++++++++++++++++ internal/games/rounds.go | 23 +++--- internal/games/types.go | 9 +- 7 files changed, 271 insertions(+), 70 deletions(-) create mode 100644 internal/games/dice_roll_test.go diff --git a/internal/augments/augments.go b/internal/augments/augments.go index 453cc67..3f46d69 100644 --- a/internal/augments/augments.go +++ b/internal/augments/augments.go @@ -6,89 +6,104 @@ import ( ) var ( - _repository = map[Name]Effect{ + _offenseRepo = map[Name]Effect{ TwistOfFate: { Name: TwistOfFate, Category: CategoryOffense, - Trigger: TriggerConditionOnRoll, + Type: TypeActive, + Trigger: TriggerConditionBeforeRoll, 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 round was a win, gain +2 this duel.", + Intent: "Snowball option with explicit condition gate.", + Action: ActionIncrease, + Target: TargetSelf, + Amount: 2, + }, + } + + _defenseRepo = map[Name]Effect{ SecondChance: { Name: SecondChance, Category: CategoryDefense, - Trigger: TriggerConditionOnLoss, + 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, }, - Overpower: { - Name: Overpower, - Category: CategoryOffense, - Trigger: TriggerConditionOnRoll, - Effect: "Gain +1 to your roll total this duel.", - Intent: "Reliable low-variance push.", - Action: ActionIncrease, + LastStand: { + Name: LastStand, + Category: CategoryDefense, + Type: TypePassive, + Trigger: TriggerConditionAfterRoll, + Effect: "Prevent your elimination this duel; lane remains unresolved.", + Intent: "Comeback insurance for high-value lanes.", + Action: "", Target: TargetSelf, - Amount: 1, + Amount: 0, }, + } + + _sabotageRepo = map[Name]Effect{ Hamstring: { Name: Hamstring, Category: CategorySabotage, - Trigger: TriggerConditionOnRoll, + 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, }, - PrecisionStrike: { - Name: PrecisionStrike, - Category: CategoryOffense, - Effect: "Add +1 to your revealed total.", - Intent: "Skill-expression token for close reads.", - Action: ActionIncrease, - Target: TargetSelf, - }, JammingSignal: { Name: JammingSignal, Category: CategorySabotage, - Trigger: TriggerConditionOnRoll, + Type: TypeActive, + Trigger: TriggerConditionBeforeRoll, Effect: "Cancel the opponent's declared Pre-roll token.", Intent: "Anti-pattern counterplay.", Action: ActionCancel, Target: TargetOpponent, Amount: 0, }, - LastStand: { - Name: LastStand, - Category: CategoryDefense, - Trigger: TriggerConditionOnLoss, - Effect: "Prevent your elimination this duel; lane remains unresolved.", - Intent: "Comeback insurance for high-value lanes.", - Action: "", - Target: TargetSelf, - Amount: 0, - }, - MomentumSurge: { - Name: MomentumSurge, - Category: CategoryOffense, - Trigger: TriggerConditionOnRoll, - Effect: "If last round was a win, gain +2 this duel.", - Intent: "Snowball option with explicit condition gate.", - Action: ActionIncrease, - Target: TargetSelf, - Amount: 2, - }, IceInVeins: { Name: IceInVeins, Category: CategorySabotage, - Trigger: TriggerConditionOnDraw, + Type: TypeActive, + Trigger: TriggerConditionAfterRoll, Effect: "Convert tie into a win for your side.", Intent: "Tie-state control and clutch finish potential.", Action: ActionIncrease, @@ -96,13 +111,35 @@ var ( Amount: 1, }, } + + _repositories = []map[Name]Effect{ + _offenseRepo, + _defenseRepo, + _sabotageRepo, + } ) func Get(name Name) (Effect, bool) { - item, ok := _repository[name] - return item, ok + for _, repository := range _repositories { + item, ok := repository[name] + if ok { + return item, true + } + } + + return Effect{}, false } func List() []Name { - return slices.Collect(maps.Keys(_repository)) + total := 0 + for _, repository := range _repositories { + total += len(repository) + } + + names := make([]Name, 0, total) + for _, repository := range _repositories { + names = append(names, slices.Collect(maps.Keys(repository))...) + } + + return names } diff --git a/internal/augments/augments_test.go b/internal/augments/augments_test.go index 239b883..b209930 100644 --- a/internal/augments/augments_test.go +++ b/internal/augments/augments_test.go @@ -24,13 +24,15 @@ func TestRepository(t *testing.T) { } for _, name := range expectedTokens { - _, ok := _repository[name] + _, ok := Get(name) odize.AssertTrue(t, ok) } }). Test("should have correct number of tokens per category", func(t *testing.T) { counts := make(map[Category]int) - for _, effect := range _repository { + for _, name := range List() { + effect, ok := Get(name) + odize.AssertTrue(t, ok) counts[effect.Category]++ } @@ -79,10 +81,10 @@ func TestList(t *testing.T) { err := group. Test("should return all token names in repository", func(t *testing.T) { names := List() - odize.AssertEqual(t, len(_repository), len(names)) + odize.AssertEqual(t, 9, len(names)) for _, name := range names { - _, ok := _repository[name] + _, ok := Get(name) odize.AssertTrue(t, ok) } }). diff --git a/internal/augments/types.go b/internal/augments/types.go index 0b8b3dd..c3e820a 100644 --- a/internal/augments/types.go +++ b/internal/augments/types.go @@ -39,17 +39,24 @@ const ( CategorySabotage Category = "sabotage" ) +type Type string + +const ( + TypeActive Type = "active" + TypePassive Type = "passive" +) + type TriggerCondition string const ( - TriggerConditionOnRoll TriggerCondition = "on_roll" - TriggerConditionOnLoss TriggerCondition = "on_loss" - TriggerConditionOnDraw TriggerCondition = "on_draw" + TriggerConditionBeforeRoll TriggerCondition = "before_roll" + TriggerConditionAfterRoll TriggerCondition = "after_roll" ) 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"` diff --git a/internal/games/dice_roll.go b/internal/games/dice_roll.go index 5d55f10..32aca75 100644 --- a/internal/games/dice_roll.go +++ b/internal/games/dice_roll.go @@ -11,12 +11,36 @@ func DiceRoll() int { } type TeamDecisionInput struct { - augment augments.Name + augment augments.Effect roll int } type DecisionInput struct { - lastRound RoundResult - teamAAugment augments.Name - teamBAugment augments.Name + lastRound DuelResult + teamAAugment TeamDecisionInput + teamBAugment 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.teamBAugment.augment.Name == augments.TwistOfFate { + secondRoll := roll() + if input.teamBAugment.roll < secondRoll { + input.teamBAugment.roll = secondRoll + } + } + + if input.teamAAugment.augment.Name == augments.TwistOfFate { + secondRoll := roll() + if input.teamAAugment.roll < secondRoll { + input.teamAAugment.roll = secondRoll + } + } + + return input } diff --git a/internal/games/dice_roll_test.go b/internal/games/dice_roll_test.go new file mode 100644 index 0000000..91ed98c --- /dev/null +++ b/internal/games/dice_roll_test.go @@ -0,0 +1,127 @@ +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{ + teamAAugment: TeamDecisionInput{ + augment: augments.Effect{Name: augments.TwistOfFate}, + roll: 1, + }, + teamBAugment: TeamDecisionInput{ + augment: 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{ + teamAAugment: TeamDecisionInput{ + augment: augments.Effect{Name: augments.TwistOfFate}, + roll: 2, + }, + teamBAugment: TeamDecisionInput{ + roll: 3, + }, + } + + mockRoll := func() int { return 5 } + res := ruleTwistOfFate(input, mockRoll) + + odize.AssertEqual(t, 5, res.teamAAugment.roll) + odize.AssertEqual(t, 3, res.teamBAugment.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{ + teamAAugment: TeamDecisionInput{ + augment: augments.Effect{Name: augments.TwistOfFate}, + roll: 4, + }, + } + + mockRoll := func() int { return 2 } + res := ruleTwistOfFate(input, mockRoll) + + odize.AssertEqual(t, 4, res.teamAAugment.roll) + }) + + group.Test("should increase roll for Team B when TwistOfFate provides a higher roll", func(t *testing.T) { + input := DecisionInput{ + teamBAugment: TeamDecisionInput{ + augment: augments.Effect{Name: augments.TwistOfFate}, + roll: 2, + }, + teamAAugment: TeamDecisionInput{ + roll: 3, + }, + } + + mockRoll := func() int { return 5 } + res := ruleTwistOfFate(input, mockRoll) + + odize.AssertEqual(t, 5, res.teamBAugment.roll) + odize.AssertEqual(t, 3, res.teamAAugment.roll) // Team A unchanged + }) + + group.Test("should not change rolls when TwistOfFate augment is not present", func(t *testing.T) { + input := DecisionInput{ + teamAAugment: TeamDecisionInput{ + augment: augments.Effect{Name: "Some Other Effect"}, + roll: 3, + }, + teamBAugment: TeamDecisionInput{ + roll: 4, + }, + } + + mockRoll := func() int { return 6 } + res := ruleTwistOfFate(input, mockRoll) + + odize.AssertEqual(t, 3, res.teamAAugment.roll) + odize.AssertEqual(t, 4, res.teamBAugment.roll) + }) + + group.Test("should handle both teams having TwistOfFate", func(t *testing.T) { + input := DecisionInput{ + teamAAugment: TeamDecisionInput{ + augment: augments.Effect{Name: augments.TwistOfFate}, + roll: 2, + }, + teamBAugment: TeamDecisionInput{ + augment: 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.teamAAugment.roll) + odize.AssertEqual(t, 5, res.teamBAugment.roll) + }) + + err := group.Run() + odize.AssertNoError(t, err) +} diff --git a/internal/games/rounds.go b/internal/games/rounds.go index 3847164..cc41cc2 100644 --- a/internal/games/rounds.go +++ b/internal/games/rounds.go @@ -8,7 +8,7 @@ func NewRound() Round { return Round{ TeamA: TeamFormation{Lanes: [3][]int{}}, TeamB: TeamFormation{Lanes: [3][]int{}}, - DuelResults: []DuelResults{}, + DuelResults: []DuelResult{}, } } @@ -76,9 +76,10 @@ func (r *Round) ResolveLane(lane int, rollFn RollFn) RoundResult { } if aRoll > bRoll { - r.DuelResults = append(r.DuelResults, DuelResults{ - Outcome: ResultTeamA, - Roll: aRoll, + r.DuelResults = append(r.DuelResults, DuelResult{ + Outcome: ResultTeamA, + Roll: aRoll, + RollDelta: aRoll - bRoll, }) _, err := r.TeamB.LanePop(lane) @@ -87,9 +88,10 @@ func (r *Round) ResolveLane(lane int, rollFn RollFn) RoundResult { } } else if bRoll > aRoll { - r.DuelResults = append(r.DuelResults, DuelResults{ - Outcome: ResultTeamB, - Roll: bRoll, + r.DuelResults = append(r.DuelResults, DuelResult{ + Outcome: ResultTeamB, + Roll: bRoll, + RollDelta: bRoll - aRoll, }) _, err := r.TeamA.LanePop(lane) @@ -98,9 +100,10 @@ func (r *Round) ResolveLane(lane int, rollFn RollFn) RoundResult { } } - r.DuelResults = append(r.DuelResults, DuelResults{ - Outcome: ResultDraw, - Roll: 0, + r.DuelResults = append(r.DuelResults, DuelResult{ + Outcome: ResultDraw, + Roll: 0, + RollDelta: 0, }) } diff --git a/internal/games/types.go b/internal/games/types.go index b8359b6..89fca99 100644 --- a/internal/games/types.go +++ b/internal/games/types.go @@ -45,12 +45,13 @@ type Game struct { type Round struct { TeamA TeamFormation `json:"team_a"` TeamB TeamFormation `json:"team_b"` - DuelResults []DuelResults `json:"duel_results"` + DuelResults []DuelResult `json:"duel_results"` } -type DuelResults struct { - Outcome RoundOutcome `json:"outcome"` - Roll int `json:"roll"` +type DuelResult struct { + Outcome RoundOutcome `json:"outcome"` + Roll int `json:"roll"` + RollDelta int `json:"roll_delta"` } type TeamStatistics struct { From 37a05ab04521992626ddfd23b19c009c31240d43 Mon Sep 17 00:00:00 2001 From: frag223 Date: Sun, 5 Jul 2026 20:33:44 +1000 Subject: [PATCH 57/72] adding --- internal/augments/augments.go | 70 +++++++++++++++++++++++++----- internal/augments/augments_test.go | 2 +- internal/augments/types.go | 22 ++++++---- internal/teams/tokens.go | 2 +- 4 files changed, 76 insertions(+), 20 deletions(-) diff --git a/internal/augments/augments.go b/internal/augments/augments.go index 3f46d69..8ef3853 100644 --- a/internal/augments/augments.go +++ b/internal/augments/augments.go @@ -43,7 +43,7 @@ var ( Category: CategoryOffense, Type: TypePassive, Trigger: TriggerConditionAfterRoll, - Effect: "If last round was a win, gain +2 this duel.", + Effect: "If last duel was a win, gain +2 this duel.", Intent: "Snowball option with explicit condition gate.", Action: ActionIncrease, Target: TargetSelf, @@ -52,6 +52,28 @@ var ( } _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 round 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, @@ -66,13 +88,13 @@ var ( LastStand: { Name: LastStand, Category: CategoryDefense, - Type: TypePassive, + Type: TypeActive, Trigger: TriggerConditionAfterRoll, - Effect: "Prevent your elimination this duel; lane remains unresolved.", - Intent: "Comeback insurance for high-value lanes.", - Action: "", + 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: 0, + Amount: 2, }, } @@ -88,22 +110,33 @@ var ( Target: TargetOpponent, Amount: 1, }, - JammingSignal: { - Name: JammingSignal, + PocketSand: { + Name: PocketSand, Category: CategorySabotage, Type: TypeActive, Trigger: TriggerConditionBeforeRoll, - Effect: "Cancel the opponent's declared Pre-roll token.", + Effect: "Cancel the opponent's declared Pre-roll augment.", Intent: "Anti-pattern counterplay.", Action: ActionCancel, Target: TargetOpponent, Amount: 0, }, + CriticalHit: { + Name: CriticalHit, + 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: TypeActive, - Trigger: TriggerConditionAfterRoll, + Trigger: TriggerConditionAfterAugments, Effect: "Convert tie into a win for your side.", Intent: "Tie-state control and clutch finish potential.", Action: ActionIncrease, @@ -130,6 +163,23 @@ func Get(name Name) (Effect, bool) { 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 List() []Name { total := 0 for _, repository := range _repositories { diff --git a/internal/augments/augments_test.go b/internal/augments/augments_test.go index b209930..07721f4 100644 --- a/internal/augments/augments_test.go +++ b/internal/augments/augments_test.go @@ -17,7 +17,7 @@ func TestRepository(t *testing.T) { Overpower, Hamstring, PrecisionStrike, - JammingSignal, + PocketSand, LastStand, MomentumSurge, IceInVeins, diff --git a/internal/augments/types.go b/internal/augments/types.go index c3e820a..33be910 100644 --- a/internal/augments/types.go +++ b/internal/augments/types.go @@ -8,20 +8,24 @@ const ( Overpower Name = "Overpower" Hamstring Name = "Hamstring" PrecisionStrike Name = "Precision Strike" - JammingSignal Name = "Jamming Signal" + PocketSand Name = "Jamming Signal" LastStand Name = "Last Stand" MomentumSurge Name = "Momentum Surge" IceInVeins Name = "Ice in Veins" + Brace Name = "Brace" + Fortify Name = "Fortify" + CriticalHit Name = "Critical Hit" ) type Action string const ( - ActionIncrease Action = "increase" - ActionDecrease Action = "decrease" - ActionAddDie Action = "add_die" - ActionReRoll Action = "re_roll" - ActionCancel Action = "cancel" + ActionIncrease Action = "increase" + ActionDecrease Action = "decrease" + ActionAddDie Action = "add_die" + ActionReRoll Action = "re_roll" + ActionCancel Action = "cancel" + ActionResultTie Action = "result_tie" ) type Target string @@ -29,6 +33,7 @@ type Target string const ( TargetSelf Target = "self" TargetOpponent Target = "opponent" + TargetBoth Target = "both" ) type Category string @@ -49,8 +54,9 @@ const ( type TriggerCondition string const ( - TriggerConditionBeforeRoll TriggerCondition = "before_roll" - TriggerConditionAfterRoll TriggerCondition = "after_roll" + TriggerConditionBeforeRoll TriggerCondition = "before_roll" + TriggerConditionAfterRoll TriggerCondition = "after_roll" + TriggerConditionAfterAugments TriggerCondition = "after_augments" ) type Effect struct { diff --git a/internal/teams/tokens.go b/internal/teams/tokens.go index 1ce60e1..e65209b 100644 --- a/internal/teams/tokens.go +++ b/internal/teams/tokens.go @@ -20,7 +20,7 @@ var _coachPersonaTokens = map[CoachPersona][]augments.Name{ augments.IceInVeins, }, CoachPersonaTrickster: { - augments.JammingSignal, + augments.PocketSand, augments.SecondChance, augments.Overpower, augments.IceInVeins, From c6cbe24cc26e3b520d4109904d0c3f16e761e276 Mon Sep 17 00:00:00 2001 From: frag223 Date: Sun, 5 Jul 2026 21:25:37 +1000 Subject: [PATCH 58/72] adding --- internal/augments/augments.go | 6 ++-- internal/augments/augments_test.go | 50 ++++++++++++++++++++++++++++-- internal/augments/types.go | 2 +- 3 files changed, 51 insertions(+), 7 deletions(-) diff --git a/internal/augments/augments.go b/internal/augments/augments.go index 8ef3853..1b91442 100644 --- a/internal/augments/augments.go +++ b/internal/augments/augments.go @@ -121,8 +121,8 @@ var ( Target: TargetOpponent, Amount: 0, }, - CriticalHit: { - Name: CriticalHit, + PoisonEdge: { + Name: PoisonEdge, Category: CategorySabotage, Type: TypeActive, Trigger: TriggerConditionAfterRoll, @@ -135,7 +135,7 @@ var ( IceInVeins: { Name: IceInVeins, Category: CategorySabotage, - Type: TypeActive, + Type: TypePassive, Trigger: TriggerConditionAfterAugments, Effect: "Convert tie into a win for your side.", Intent: "Tie-state control and clutch finish potential.", diff --git a/internal/augments/augments_test.go b/internal/augments/augments_test.go index 07721f4..3883cbb 100644 --- a/internal/augments/augments_test.go +++ b/internal/augments/augments_test.go @@ -21,6 +21,9 @@ func TestRepository(t *testing.T) { LastStand, MomentumSurge, IceInVeins, + Brace, + Fortify, + PoisonEdge, } for _, name := range expectedTokens { @@ -37,8 +40,8 @@ func TestRepository(t *testing.T) { } odize.AssertEqual(t, 4, counts[CategoryOffense]) - odize.AssertEqual(t, 2, counts[CategoryDefense]) - odize.AssertEqual(t, 3, counts[CategorySabotage]) + odize.AssertEqual(t, 4, counts[CategoryDefense]) + odize.AssertEqual(t, 4, counts[CategorySabotage]) }). Run() @@ -81,7 +84,7 @@ func TestList(t *testing.T) { err := group. Test("should return all token names in repository", func(t *testing.T) { names := List() - odize.AssertEqual(t, 9, len(names)) + odize.AssertEqual(t, 12, len(names)) for _, name := range names { _, ok := Get(name) @@ -92,3 +95,44 @@ func TestList(t *testing.T) { 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) +} diff --git a/internal/augments/types.go b/internal/augments/types.go index 33be910..15a8273 100644 --- a/internal/augments/types.go +++ b/internal/augments/types.go @@ -14,7 +14,7 @@ const ( IceInVeins Name = "Ice in Veins" Brace Name = "Brace" Fortify Name = "Fortify" - CriticalHit Name = "Critical Hit" + PoisonEdge Name = "Critical Hit" ) type Action string From 7e2d8bfd9eb04a0488fe7373b93ad851a335e7bf Mon Sep 17 00:00:00 2001 From: frag223 Date: Sun, 5 Jul 2026 22:04:02 +1000 Subject: [PATCH 59/72] adding decision engine + roll lifecycle --- internal/augments/augments.go | 17 ++++++- internal/augments/types.go | 3 ++ internal/games/dice_roll.go | 84 ++++++++++++++++++++++++++++---- internal/games/dice_roll_test.go | 40 +++++++-------- internal/games/game.go | 10 ++-- internal/games/game_test.go | 2 +- internal/games/rounds.go | 20 ++++---- internal/games/rounds_test.go | 14 +++--- internal/games/service.go | 4 +- internal/games/service_test.go | 16 +++--- internal/games/types.go | 19 ++++---- internal/ui/components/game.go | 4 +- 12 files changed, 159 insertions(+), 74 deletions(-) diff --git a/internal/augments/augments.go b/internal/augments/augments.go index 1b91442..ae73fcf 100644 --- a/internal/augments/augments.go +++ b/internal/augments/augments.go @@ -11,7 +11,7 @@ var ( Name: TwistOfFate, Category: CategoryOffense, Type: TypeActive, - Trigger: TriggerConditionBeforeRoll, + Trigger: TriggerConditionAfterRoll, Effect: "Roll 2d6 and keep the highest.", Intent: "Front-load pressure in must-win lanes.", Action: ActionAddDie, @@ -145,10 +145,25 @@ var ( }, } + _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, } ) diff --git a/internal/augments/types.go b/internal/augments/types.go index 15a8273..a3ebae3 100644 --- a/internal/augments/types.go +++ b/internal/augments/types.go @@ -3,6 +3,7 @@ package augments type Name string const ( + NoAugment Name = "No Augment" TwistOfFate Name = "Twist of Fate" SecondChance Name = "Second Chance" Overpower Name = "Overpower" @@ -26,6 +27,7 @@ const ( ActionReRoll Action = "re_roll" ActionCancel Action = "cancel" ActionResultTie Action = "result_tie" + ActionNoOp Action = "no_op" ) type Target string @@ -42,6 +44,7 @@ const ( CategoryOffense Category = "offense" CategoryDefense Category = "defense" CategorySabotage Category = "sabotage" + CategoryNoOp Category = "no_op" ) type Type string diff --git a/internal/games/dice_roll.go b/internal/games/dice_roll.go index 32aca75..d97e5cd 100644 --- a/internal/games/dice_roll.go +++ b/internal/games/dice_roll.go @@ -12,13 +12,14 @@ func DiceRoll() int { type TeamDecisionInput struct { augment augments.Effect + player int64 roll int } type DecisionInput struct { - lastRound DuelResult - teamAAugment TeamDecisionInput - teamBAugment TeamDecisionInput + lastRound DuelResult + teamA TeamDecisionInput + teamB TeamDecisionInput } type DecisionEngineFunc func(input DecisionInput) DecisionInput @@ -28,19 +29,84 @@ func RuleTwistOfFate(input DecisionInput) DecisionInput { } func ruleTwistOfFate(input DecisionInput, roll RollFn) DecisionInput { - if input.teamBAugment.augment.Name == augments.TwistOfFate { + if input.teamB.augment.Name == augments.TwistOfFate { secondRoll := roll() - if input.teamBAugment.roll < secondRoll { - input.teamBAugment.roll = secondRoll + if input.teamB.roll < secondRoll { + input.teamB.roll = secondRoll } } - if input.teamAAugment.augment.Name == augments.TwistOfFate { + if input.teamA.augment.Name == augments.TwistOfFate { secondRoll := roll() - if input.teamAAugment.roll < secondRoll { - input.teamAAugment.roll = secondRoll + if input.teamA.roll < secondRoll { + input.teamA.roll = secondRoll } } return input } + +type Engine struct { + beforeRole []DecisionEngineFunc + afterRole []DecisionEngineFunc + afterAugments []DecisionEngineFunc + rollFn RollFn +} + +func NewDecisionEngine() *Engine { + return &Engine{ + beforeRole: []DecisionEngineFunc{}, + afterRole: []DecisionEngineFunc{ + RuleTwistOfFate, + }, + afterAugments: []DecisionEngineFunc{}, + rollFn: DiceRoll, + } +} + +func (e *Engine) Run(input DecisionInput) DuelResult { + for _, ruleFn := range e.beforeRole { + input = ruleFn(input) + } + + input.teamB.roll = e.rollFn() + input.teamA.roll = e.rollFn() + + for _, ruleFn := range e.afterRole { + 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 index 91ed98c..4cddbfe 100644 --- a/internal/games/dice_roll_test.go +++ b/internal/games/dice_roll_test.go @@ -12,11 +12,11 @@ func TestRuleTwistOfFate(t *testing.T) { group.Test("should not modify input struct (immutability)", func(t *testing.T) { input := DecisionInput{ - teamAAugment: TeamDecisionInput{ + teamA: TeamDecisionInput{ augment: augments.Effect{Name: augments.TwistOfFate}, roll: 1, }, - teamBAugment: TeamDecisionInput{ + teamB: TeamDecisionInput{ augment: augments.Effect{Name: augments.TwistOfFate}, roll: 1, }, @@ -30,11 +30,11 @@ func TestRuleTwistOfFate(t *testing.T) { group.Test("should increase roll for Team A when TwistOfFate provides a higher roll", func(t *testing.T) { input := DecisionInput{ - teamAAugment: TeamDecisionInput{ + teamA: TeamDecisionInput{ augment: augments.Effect{Name: augments.TwistOfFate}, roll: 2, }, - teamBAugment: TeamDecisionInput{ + teamB: TeamDecisionInput{ roll: 3, }, } @@ -42,13 +42,13 @@ func TestRuleTwistOfFate(t *testing.T) { mockRoll := func() int { return 5 } res := ruleTwistOfFate(input, mockRoll) - odize.AssertEqual(t, 5, res.teamAAugment.roll) - odize.AssertEqual(t, 3, res.teamBAugment.roll) // Team B unchanged + 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{ - teamAAugment: TeamDecisionInput{ + teamA: TeamDecisionInput{ augment: augments.Effect{Name: augments.TwistOfFate}, roll: 4, }, @@ -57,16 +57,16 @@ func TestRuleTwistOfFate(t *testing.T) { mockRoll := func() int { return 2 } res := ruleTwistOfFate(input, mockRoll) - odize.AssertEqual(t, 4, res.teamAAugment.roll) + 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{ - teamBAugment: TeamDecisionInput{ + teamB: TeamDecisionInput{ augment: augments.Effect{Name: augments.TwistOfFate}, roll: 2, }, - teamAAugment: TeamDecisionInput{ + teamA: TeamDecisionInput{ roll: 3, }, } @@ -74,17 +74,17 @@ func TestRuleTwistOfFate(t *testing.T) { mockRoll := func() int { return 5 } res := ruleTwistOfFate(input, mockRoll) - odize.AssertEqual(t, 5, res.teamBAugment.roll) - odize.AssertEqual(t, 3, res.teamAAugment.roll) // Team A unchanged + 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{ - teamAAugment: TeamDecisionInput{ + teamA: TeamDecisionInput{ augment: augments.Effect{Name: "Some Other Effect"}, roll: 3, }, - teamBAugment: TeamDecisionInput{ + teamB: TeamDecisionInput{ roll: 4, }, } @@ -92,17 +92,17 @@ func TestRuleTwistOfFate(t *testing.T) { mockRoll := func() int { return 6 } res := ruleTwistOfFate(input, mockRoll) - odize.AssertEqual(t, 3, res.teamAAugment.roll) - odize.AssertEqual(t, 4, res.teamBAugment.roll) + 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{ - teamAAugment: TeamDecisionInput{ + teamA: TeamDecisionInput{ augment: augments.Effect{Name: augments.TwistOfFate}, roll: 2, }, - teamBAugment: TeamDecisionInput{ + teamB: TeamDecisionInput{ augment: augments.Effect{Name: augments.TwistOfFate}, roll: 3, }, @@ -118,8 +118,8 @@ func TestRuleTwistOfFate(t *testing.T) { res := ruleTwistOfFate(input, mockRoll) - odize.AssertEqual(t, 6, res.teamAAugment.roll) - odize.AssertEqual(t, 5, res.teamBAugment.roll) + odize.AssertEqual(t, 6, res.teamA.roll) + odize.AssertEqual(t, 5, res.teamB.roll) }) err := group.Run() diff --git a/internal/games/game.go b/internal/games/game.go index c2cc86e..e3d6c3d 100644 --- a/internal/games/game.go +++ b/internal/games/game.go @@ -58,9 +58,9 @@ func (g *Game) CalculateWinner() (int64, error) { teamB := 0 for _, result := range g.results { - if result.Outcome == ResultTeamA { + if result.Outcome == TeamA { teamA++ - } else if result.Outcome == ResultTeamB { + } else if result.Outcome == TeamB { teamB++ } } @@ -98,16 +98,16 @@ func (g *Game) Name() string { } func (g *Game) TeamAScore() int { - teamA := filterResultsByTeam(ResultTeamA, g.results) + teamA := filterResultsByTeam(TeamA, g.results) return len(teamA) } func (g *Game) TeamBScore() int { - teamB := filterResultsByTeam(ResultTeamB, g.results) + teamB := filterResultsByTeam(TeamB, g.results) return len(teamB) } -func filterResultsByTeam(team RoundOutcome, results []RoundResult) []RoundResult { +func filterResultsByTeam(team Outcome, results []RoundResult) []RoundResult { var filteredResults []RoundResult for _, result := range results { diff --git a/internal/games/game_test.go b/internal/games/game_test.go index 87e42a0..cbd0385 100644 --- a/internal/games/game_test.go +++ b/internal/games/game_test.go @@ -58,7 +58,7 @@ func TestGame_ResolveRound(t *testing.T) { res, err := game.ResolveRound(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/rounds.go b/internal/games/rounds.go index cc41cc2..8ba17f9 100644 --- a/internal/games/rounds.go +++ b/internal/games/rounds.go @@ -37,9 +37,9 @@ func (r *Round) calculateWinner(result []RoundResult) RoundResult { for _, laneResult := range result { switch laneResult.Outcome { - case ResultTeamA: + case TeamA: teamAPlayers += laneResult.RemainingPlayers - case ResultTeamB: + case TeamB: teamBPlayers += laneResult.RemainingPlayers } @@ -47,20 +47,20 @@ func (r *Round) calculateWinner(result []RoundResult) RoundResult { if teamAPlayers > teamBPlayers { return RoundResult{ - Outcome: ResultTeamA, + Outcome: TeamA, RemainingPlayers: teamAPlayers, } } if teamAPlayers < teamBPlayers { return RoundResult{ - Outcome: ResultTeamB, + Outcome: TeamB, RemainingPlayers: teamBPlayers, } } return RoundResult{ - Outcome: ResultDraw, + Outcome: Draw, RemainingPlayers: 0, } } @@ -77,7 +77,7 @@ func (r *Round) ResolveLane(lane int, rollFn RollFn) RoundResult { if aRoll > bRoll { r.DuelResults = append(r.DuelResults, DuelResult{ - Outcome: ResultTeamA, + Outcome: TeamA, Roll: aRoll, RollDelta: aRoll - bRoll, }) @@ -89,7 +89,7 @@ func (r *Round) ResolveLane(lane int, rollFn RollFn) RoundResult { } else if bRoll > aRoll { r.DuelResults = append(r.DuelResults, DuelResult{ - Outcome: ResultTeamB, + Outcome: TeamB, Roll: bRoll, RollDelta: bRoll - aRoll, }) @@ -101,7 +101,7 @@ func (r *Round) ResolveLane(lane int, rollFn RollFn) RoundResult { } r.DuelResults = append(r.DuelResults, DuelResult{ - Outcome: ResultDraw, + Outcome: Draw, Roll: 0, RollDelta: 0, }) @@ -110,13 +110,13 @@ func (r *Round) ResolveLane(lane int, rollFn RollFn) RoundResult { if r.TeamA.LaneHasPlayers(lane) { return RoundResult{ - Outcome: ResultTeamA, + Outcome: TeamA, RemainingPlayers: r.TeamA.LaneCount(lane), } } return RoundResult{ - Outcome: ResultTeamB, + Outcome: TeamB, RemainingPlayers: r.TeamB.LaneCount(lane), } diff --git a/internal/games/rounds_test.go b/internal/games/rounds_test.go index 05aad6b..79e1dab 100644 --- a/internal/games/rounds_test.go +++ b/internal/games/rounds_test.go @@ -29,7 +29,7 @@ func TestResolveLane(t *testing.T) { res := r.ResolveLane(lane, rollFn) - odize.AssertEqual(t, ResultTeamA, res.Outcome) + odize.AssertEqual(t, TeamA, res.Outcome) odize.AssertEqual(t, 2, res.RemainingPlayers) odize.AssertEqual(t, 0, r.TeamB.LaneCount(lane)) }) @@ -54,7 +54,7 @@ func TestResolveLane(t *testing.T) { res := r.ResolveLane(lane, rollFn) - odize.AssertEqual(t, ResultTeamB, res.Outcome) + odize.AssertEqual(t, TeamB, res.Outcome) odize.AssertEqual(t, 2, res.RemainingPlayers) odize.AssertEqual(t, 0, r.TeamA.LaneCount(lane)) }) @@ -70,7 +70,7 @@ func TestResolveLane(t *testing.T) { res := r.ResolveLane(lane, func() int { return 1 }) - odize.AssertEqual(t, ResultTeamB, res.Outcome) + odize.AssertEqual(t, TeamB, res.Outcome) odize.AssertEqual(t, 3, res.RemainingPlayers) }) @@ -85,7 +85,7 @@ func TestResolveLane(t *testing.T) { res := r.ResolveLane(lane, func() int { return 1 }) - odize.AssertEqual(t, ResultTeamA, res.Outcome) + odize.AssertEqual(t, TeamA, res.Outcome) odize.AssertEqual(t, 3, res.RemainingPlayers) }) @@ -129,7 +129,7 @@ func TestResolveLanes(t *testing.T) { // 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) }) @@ -164,7 +164,7 @@ func TestResolveLanes(t *testing.T) { // 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) }) @@ -190,7 +190,7 @@ func TestResolveLanes(t *testing.T) { // 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 b1e787c..8662677 100644 --- a/internal/games/service.go +++ b/internal/games/service.go @@ -156,8 +156,8 @@ func (s *Service) processGameForStats(stats *TeamStatistics, model database.Game return fmt.Errorf("parsing game %d results: %w", model.ID, err) } - teamRoundsWon := len(filterResultsByTeam(ResultTeamA, results)) - teamRoundsLost := len(filterResultsByTeam(ResultTeamB, results)) + teamRoundsWon := len(filterResultsByTeam(TeamA, results)) + teamRoundsLost := len(filterResultsByTeam(TeamB, results)) if model.TeamB.Valid && model.TeamB.Int64 == teamID { teamRoundsWon, teamRoundsLost = teamRoundsLost, teamRoundsWon } diff --git a/internal/games/service_test.go b/internal/games/service_test.go index 03fa251..4c17776 100644 --- a/internal/games/service_test.go +++ b/internal/games/service_test.go @@ -58,20 +58,20 @@ func TestService_GetTeamStatistics(t *testing.T) { opponentCfg := TeamConfig{TeamID: opponentTeam.ID, TeamName: opponentTeam.Name, Formations: make([]playbooks.Formation, 10)} persistCompletedGame(t, gameSvc, ctx, targetCfg, opponentCfg, targetTeam.ID, []RoundResult{ - {Outcome: ResultTeamA}, - {Outcome: ResultTeamA}, - {Outcome: ResultTeamB}, + {Outcome: TeamA}, + {Outcome: TeamA}, + {Outcome: TeamB}, }) persistCompletedGame(t, gameSvc, ctx, opponentCfg, targetCfg, opponentTeam.ID, []RoundResult{ - {Outcome: ResultTeamA}, - {Outcome: ResultTeamB}, - {Outcome: ResultTeamA}, + {Outcome: TeamA}, + {Outcome: TeamB}, + {Outcome: TeamA}, }) persistCompletedGame(t, gameSvc, ctx, targetCfg, opponentCfg, 0, []RoundResult{ - {Outcome: ResultTeamA}, - {Outcome: ResultTeamB}, + {Outcome: TeamA}, + {Outcome: TeamB}, }) stats, err := gameSvc.GetTeamStatistics(ctx, targetTeam.ID) diff --git a/internal/games/types.go b/internal/games/types.go index 89fca99..07588a8 100644 --- a/internal/games/types.go +++ b/internal/games/types.go @@ -19,12 +19,12 @@ const ( StatusComplete GameStatus = "complete" ) -type RoundOutcome string +type Outcome string const ( - ResultDraw RoundOutcome = "draw" - ResultTeamA RoundOutcome = "team_a" - ResultTeamB RoundOutcome = "team_b" + Draw Outcome = "draw" + TeamA Outcome = "team_a" + TeamB Outcome = "team_b" ) type Game struct { @@ -49,9 +49,10 @@ type Round struct { } type DuelResult struct { - Outcome RoundOutcome `json:"outcome"` - Roll int `json:"roll"` - RollDelta int `json:"roll_delta"` + Player int64 `json:"player"` + Outcome Outcome `json:"outcome"` + Roll int `json:"roll"` + RollDelta int `json:"roll_delta"` } type TeamStatistics struct { @@ -86,8 +87,8 @@ type LanesConfig struct { } type RoundResult struct { - Outcome RoundOutcome `json:"outcome"` - RemainingPlayers int `json:"remaining_players"` + Outcome Outcome `json:"outcome"` + RemainingPlayers int `json:"remaining_players"` } type RollFn func() int diff --git a/internal/ui/components/game.go b/internal/ui/components/game.go index b3d2366..546acb8 100644 --- a/internal/ui/components/game.go +++ b/internal/ui/components/game.go @@ -106,9 +106,9 @@ func (g *Game) View(theme styles.IceTheme) string { var footer string if g.resolved { winner := g.teamAName - if g.result.Outcome == games.ResultTeamB { + if g.result.Outcome == games.TeamB { winner = g.teamBName - } else if g.result.Outcome == games.ResultDraw { + } else if g.result.Outcome == games.Draw { winner = "Draw" } From 0b7d9888559f6ec0337085fed3b334c62b36965a Mon Sep 17 00:00:00 2001 From: frag223 Date: Sun, 5 Jul 2026 22:40:53 +1000 Subject: [PATCH 60/72] adding tests --- internal/games/dice_roll_test.go | 135 +++++++++++++++++++++++++++++++ 1 file changed, 135 insertions(+) diff --git a/internal/games/dice_roll_test.go b/internal/games/dice_roll_test.go index 4cddbfe..cd2d2fd 100644 --- a/internal/games/dice_roll_test.go +++ b/internal/games/dice_roll_test.go @@ -125,3 +125,138 @@ func TestRuleTwistOfFate(t *testing.T) { 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{ + beforeRole: []DecisionEngineFunc{ + func(input DecisionInput) DecisionInput { + beforeCalled = true + input.teamA.player = 11 + return input + }, + }, + afterRole: []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{ + beforeRole: []DecisionEngineFunc{}, + afterRole: []DecisionEngineFunc{ + func(input DecisionInput) DecisionInput { + return ruleTwistOfFate(input, secondRolls) + }, + }, + afterAugments: []DecisionEngineFunc{}, + rollFn: newSequentialRollFn([]int{5, 2}), + } + + result := engine.Run(DecisionInput{ + teamA: TeamDecisionInput{ + player: 101, + augment: 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{ + beforeRole: []DecisionEngineFunc{}, + afterRole: []DecisionEngineFunc{ + func(input DecisionInput) DecisionInput { + return ruleTwistOfFate(input, secondRolls) + }, + }, + afterAugments: []DecisionEngineFunc{}, + rollFn: newSequentialRollFn([]int{5, 2}), + } + + result := engine.Run(DecisionInput{ + teamA: TeamDecisionInput{ + player: 101, + augment: 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{ + beforeRole: []DecisionEngineFunc{}, + afterRole: []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 + } +} From 5c9fe07a22333d41118ad04ca3e2550cff069b4c Mon Sep 17 00:00:00 2001 From: frag223 Date: Sun, 5 Jul 2026 22:42:09 +1000 Subject: [PATCH 61/72] update test --- internal/augments/augments_test.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/internal/augments/augments_test.go b/internal/augments/augments_test.go index 3883cbb..3366962 100644 --- a/internal/augments/augments_test.go +++ b/internal/augments/augments_test.go @@ -12,6 +12,7 @@ func TestRepository(t *testing.T) { err := group. Test("should contain all tokens from proposal", func(t *testing.T) { expectedTokens := []Name{ + NoAugment, TwistOfFate, SecondChance, Overpower, @@ -42,6 +43,7 @@ func TestRepository(t *testing.T) { odize.AssertEqual(t, 4, counts[CategoryOffense]) odize.AssertEqual(t, 4, counts[CategoryDefense]) odize.AssertEqual(t, 4, counts[CategorySabotage]) + odize.AssertEqual(t, 1, counts[CategoryNoOp]) }). Run() @@ -84,7 +86,7 @@ func TestList(t *testing.T) { err := group. Test("should return all token names in repository", func(t *testing.T) { names := List() - odize.AssertEqual(t, 12, len(names)) + odize.AssertEqual(t, 13, len(names)) for _, name := range names { _, ok := Get(name) From 3c08a53a6c53a168d20915469008a7905c14582e Mon Sep 17 00:00:00 2001 From: frag223 Date: Tue, 7 Jul 2026 22:26:07 +1000 Subject: [PATCH 62/72] adding interface --- internal/games/interfaces.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/internal/games/interfaces.go b/internal/games/interfaces.go index fa0d800..72f59c0 100644 --- a/internal/games/interfaces.go +++ b/internal/games/interfaces.go @@ -12,3 +12,7 @@ type Store interface { 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 +} From 23571593d8eb13c97084204a0e65a9cd6a30cbd9 Mon Sep 17 00:00:00 2001 From: frag223 Date: Wed, 8 Jul 2026 20:07:47 +1000 Subject: [PATCH 63/72] small change --- internal/games/dice_roll.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/games/dice_roll.go b/internal/games/dice_roll.go index d97e5cd..df22f10 100644 --- a/internal/games/dice_roll.go +++ b/internal/games/dice_roll.go @@ -17,7 +17,7 @@ type TeamDecisionInput struct { } type DecisionInput struct { - lastRound DuelResult + lastRound *DuelResult teamA TeamDecisionInput teamB TeamDecisionInput } From 3f7995c45b84a1b0aad6a271a46d4569a4570f4c Mon Sep 17 00:00:00 2001 From: frag223 Date: Wed, 8 Jul 2026 20:54:25 +1000 Subject: [PATCH 64/72] passing player ID now --- internal/games/game.go | 18 ++-- internal/games/game_test.go | 8 +- internal/games/rounds.go | 22 +++-- internal/games/rounds_test.go | 99 ++++++++++++++----- internal/games/types.go | 14 +-- internal/ui/components/game_test.go | 4 +- internal/ui/components/round_test.go | 4 +- internal/ui/iugame/page_game_complete_test.go | 2 + internal/ui/uibattle/page_battle_confirm.go | 14 +++ 9 files changed, 130 insertions(+), 55 deletions(-) diff --git a/internal/games/game.go b/internal/games/game.go index e3d6c3d..d52cc10 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, }, ) diff --git a/internal/games/game_test.go b/internal/games/game_test.go index cbd0385..ac54628 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} diff --git a/internal/games/rounds.go b/internal/games/rounds.go index 8ba17f9..6920a69 100644 --- a/internal/games/rounds.go +++ b/internal/games/rounds.go @@ -6,8 +6,8 @@ import ( 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{}, } } @@ -144,13 +144,21 @@ func (s *TeamFormation) LanePop(lane int) (int, error) { 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 79e1dab..355da3b 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,8 +62,8 @@ 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 @@ -40,8 +87,8 @@ 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 @@ -65,8 +112,8 @@ 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 }) @@ -80,8 +127,8 @@ 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 }) @@ -102,16 +149,16 @@ 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 @@ -139,16 +186,16 @@ 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} @@ -174,16 +221,16 @@ 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 }) diff --git a/internal/games/types.go b/internal/games/types.go index 07588a8..8675d19 100644 --- a/internal/games/types.go +++ b/internal/games/types.go @@ -71,19 +71,21 @@ type TeamStatistics struct { type TeamConfig struct { TeamID int64 `json:"team_id"` TeamName string `json:"team_name"` + Players []int64 `json:"players"` Formations []playbooks.Formation `json:"formations"` } type TeamFormation struct { - TeamID int64 `json:"team_id,omitempty"` - Lanes [3][]int `json:"lanes,omitempty"` + TeamID int64 `json:"team_id,omitempty"` + Lanes [3][]int64 `json:"lanes,omitempty"` } type LanesConfig struct { - TeamID int64 `json:"team_id"` - Lane1 int `json:"lane_1"` - Lane2 int `json:"lane_2"` - Lane3 int `json:"lane_3"` + 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 RoundResult struct { diff --git a/internal/ui/components/game_test.go b/internal/ui/components/game_test.go index c62cb2e..6288f57 100644 --- a/internal/ui/components/game_test.go +++ b/internal/ui/components/game_test.go @@ -33,8 +33,8 @@ 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} diff --git a/internal/ui/components/round_test.go b/internal/ui/components/round_test.go index 7e66818..28255de 100644 --- a/internal/ui/components/round_test.go +++ b/internal/ui/components/round_test.go @@ -15,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}, {}, diff --git a/internal/ui/iugame/page_game_complete_test.go b/internal/ui/iugame/page_game_complete_test.go index 1e50da0..2e709bb 100644 --- a/internal/ui/iugame/page_game_complete_test.go +++ b/internal/ui/iugame/page_game_complete_test.go @@ -69,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, }, }) diff --git a/internal/ui/uibattle/page_battle_confirm.go b/internal/ui/uibattle/page_battle_confirm.go index 162d20f..6606a81 100644 --- a/internal/ui/uibattle/page_battle_confirm.go +++ b/internal/ui/uibattle/page_battle_confirm.go @@ -82,15 +82,20 @@ func (m *PageBattleConfirmModel) createGame() tea.Msg { return fmt.Errorf("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, }, } @@ -226,3 +231,12 @@ func getPlayerNames(players []teams.Player) []string { return names } + +func getPlayerIDs(players []teams.Player) []int64 { + IDs := make([]int64, len(players)) + for i, item := range players { + IDs[i] = item.ID + } + + return IDs +} From 7c1e1a81bb682ac2e910a7f81fbca99b61455ec9 Mon Sep 17 00:00:00 2001 From: frag223 Date: Wed, 8 Jul 2026 21:00:56 +1000 Subject: [PATCH 65/72] padding ID now --- internal/games/rounds.go | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/internal/games/rounds.go b/internal/games/rounds.go index 6920a69..064d0b2 100644 --- a/internal/games/rounds.go +++ b/internal/games/rounds.go @@ -76,7 +76,10 @@ func (r *Round) ResolveLane(lane int, rollFn RollFn) RoundResult { } if aRoll > bRoll { + player := r.TeamA.LanePeak(lane) + r.DuelResults = append(r.DuelResults, DuelResult{ + Player: player, Outcome: TeamA, Roll: aRoll, RollDelta: aRoll - bRoll, @@ -88,7 +91,10 @@ func (r *Round) ResolveLane(lane int, rollFn RollFn) RoundResult { } } else if bRoll > aRoll { + player := r.TeamB.LanePeak(lane) + r.DuelResults = append(r.DuelResults, DuelResult{ + Player: player, Outcome: TeamB, Roll: bRoll, RollDelta: bRoll - aRoll, @@ -130,15 +136,20 @@ 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) { From cbb9883826a500b363c580101cecb9f7e4fc2c6b Mon Sep 17 00:00:00 2001 From: frag223 Date: Wed, 8 Jul 2026 21:13:01 +1000 Subject: [PATCH 66/72] fixing name --- internal/augments/types.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/augments/types.go b/internal/augments/types.go index a3ebae3..0bff246 100644 --- a/internal/augments/types.go +++ b/internal/augments/types.go @@ -9,7 +9,7 @@ const ( Overpower Name = "Overpower" Hamstring Name = "Hamstring" PrecisionStrike Name = "Precision Strike" - PocketSand Name = "Jamming Signal" + PocketSand Name = "Pocket Sand" LastStand Name = "Last Stand" MomentumSurge Name = "Momentum Surge" IceInVeins Name = "Ice in Veins" From 411fcce0d408310a0ccf36941d37e395bdaf7d9d Mon Sep 17 00:00:00 2001 From: frag223 Date: Wed, 8 Jul 2026 21:29:06 +1000 Subject: [PATCH 67/72] adding difference between passive and active --- internal/augments/augments.go | 2 +- internal/games/dice_roll.go | 23 ++++++------- internal/games/dice_roll_test.go | 56 ++++++++++++++++---------------- 3 files changed, 41 insertions(+), 40 deletions(-) diff --git a/internal/augments/augments.go b/internal/augments/augments.go index ae73fcf..7924557 100644 --- a/internal/augments/augments.go +++ b/internal/augments/augments.go @@ -68,7 +68,7 @@ var ( Category: CategoryDefense, Type: TypePassive, Trigger: TriggerConditionAfterRoll, - Effect: "If last round was a tie, gain +1 to roll", + Effect: "If last duel was a tie, gain +1 to roll", Intent: "Turn the tides on opponent momentum", Action: ActionIncrease, Target: TargetSelf, diff --git a/internal/games/dice_roll.go b/internal/games/dice_roll.go index df22f10..1253a31 100644 --- a/internal/games/dice_roll.go +++ b/internal/games/dice_roll.go @@ -11,9 +11,10 @@ func DiceRoll() int { } type TeamDecisionInput struct { - augment augments.Effect - player int64 - roll int + activeAugment augments.Effect + passivesAugments []augments.Effect + player int64 + roll int } type DecisionInput struct { @@ -29,14 +30,14 @@ func RuleTwistOfFate(input DecisionInput) DecisionInput { } func ruleTwistOfFate(input DecisionInput, roll RollFn) DecisionInput { - if input.teamB.augment.Name == augments.TwistOfFate { + if input.teamB.activeAugment.Name == augments.TwistOfFate { secondRoll := roll() if input.teamB.roll < secondRoll { input.teamB.roll = secondRoll } } - if input.teamA.augment.Name == augments.TwistOfFate { + if input.teamA.activeAugment.Name == augments.TwistOfFate { secondRoll := roll() if input.teamA.roll < secondRoll { input.teamA.roll = secondRoll @@ -47,16 +48,16 @@ func ruleTwistOfFate(input DecisionInput, roll RollFn) DecisionInput { } type Engine struct { - beforeRole []DecisionEngineFunc - afterRole []DecisionEngineFunc + beforeRoll []DecisionEngineFunc + afterRoll []DecisionEngineFunc afterAugments []DecisionEngineFunc rollFn RollFn } func NewDecisionEngine() *Engine { return &Engine{ - beforeRole: []DecisionEngineFunc{}, - afterRole: []DecisionEngineFunc{ + beforeRoll: []DecisionEngineFunc{}, + afterRoll: []DecisionEngineFunc{ RuleTwistOfFate, }, afterAugments: []DecisionEngineFunc{}, @@ -65,14 +66,14 @@ func NewDecisionEngine() *Engine { } func (e *Engine) Run(input DecisionInput) DuelResult { - for _, ruleFn := range e.beforeRole { + for _, ruleFn := range e.beforeRoll { input = ruleFn(input) } input.teamB.roll = e.rollFn() input.teamA.roll = e.rollFn() - for _, ruleFn := range e.afterRole { + for _, ruleFn := range e.afterRoll { input = ruleFn(input) } diff --git a/internal/games/dice_roll_test.go b/internal/games/dice_roll_test.go index cd2d2fd..5469702 100644 --- a/internal/games/dice_roll_test.go +++ b/internal/games/dice_roll_test.go @@ -13,12 +13,12 @@ func TestRuleTwistOfFate(t *testing.T) { group.Test("should not modify input struct (immutability)", func(t *testing.T) { input := DecisionInput{ teamA: TeamDecisionInput{ - augment: augments.Effect{Name: augments.TwistOfFate}, - roll: 1, + activeAugment: augments.Effect{Name: augments.TwistOfFate}, + roll: 1, }, teamB: TeamDecisionInput{ - augment: augments.Effect{Name: augments.TwistOfFate}, - roll: 1, + activeAugment: augments.Effect{Name: augments.TwistOfFate}, + roll: 1, }, } @@ -31,8 +31,8 @@ func TestRuleTwistOfFate(t *testing.T) { group.Test("should increase roll for Team A when TwistOfFate provides a higher roll", func(t *testing.T) { input := DecisionInput{ teamA: TeamDecisionInput{ - augment: augments.Effect{Name: augments.TwistOfFate}, - roll: 2, + activeAugment: augments.Effect{Name: augments.TwistOfFate}, + roll: 2, }, teamB: TeamDecisionInput{ roll: 3, @@ -49,8 +49,8 @@ func TestRuleTwistOfFate(t *testing.T) { group.Test("should not change roll for Team A when TwistOfFate provides a lower roll", func(t *testing.T) { input := DecisionInput{ teamA: TeamDecisionInput{ - augment: augments.Effect{Name: augments.TwistOfFate}, - roll: 4, + activeAugment: augments.Effect{Name: augments.TwistOfFate}, + roll: 4, }, } @@ -63,8 +63,8 @@ func TestRuleTwistOfFate(t *testing.T) { group.Test("should increase roll for Team B when TwistOfFate provides a higher roll", func(t *testing.T) { input := DecisionInput{ teamB: TeamDecisionInput{ - augment: augments.Effect{Name: augments.TwistOfFate}, - roll: 2, + activeAugment: augments.Effect{Name: augments.TwistOfFate}, + roll: 2, }, teamA: TeamDecisionInput{ roll: 3, @@ -81,8 +81,8 @@ func TestRuleTwistOfFate(t *testing.T) { group.Test("should not change rolls when TwistOfFate augment is not present", func(t *testing.T) { input := DecisionInput{ teamA: TeamDecisionInput{ - augment: augments.Effect{Name: "Some Other Effect"}, - roll: 3, + activeAugment: augments.Effect{Name: "Some Other Effect"}, + roll: 3, }, teamB: TeamDecisionInput{ roll: 4, @@ -99,12 +99,12 @@ func TestRuleTwistOfFate(t *testing.T) { group.Test("should handle both teams having TwistOfFate", func(t *testing.T) { input := DecisionInput{ teamA: TeamDecisionInput{ - augment: augments.Effect{Name: augments.TwistOfFate}, - roll: 2, + activeAugment: augments.Effect{Name: augments.TwistOfFate}, + roll: 2, }, teamB: TeamDecisionInput{ - augment: augments.Effect{Name: augments.TwistOfFate}, - roll: 3, + activeAugment: augments.Effect{Name: augments.TwistOfFate}, + roll: 3, }, } @@ -136,14 +136,14 @@ func TestEngineRun(t *testing.T) { afterAugmentsCalled := false engine := &Engine{ - beforeRole: []DecisionEngineFunc{ + beforeRoll: []DecisionEngineFunc{ func(input DecisionInput) DecisionInput { beforeCalled = true input.teamA.player = 11 return input }, }, - afterRole: []DecisionEngineFunc{ + afterRoll: []DecisionEngineFunc{ func(input DecisionInput) DecisionInput { afterCalled = true input.teamA.roll++ @@ -174,8 +174,8 @@ func TestEngineRun(t *testing.T) { secondRolls := newSequentialRollFn([]int{6}) engine := &Engine{ - beforeRole: []DecisionEngineFunc{}, - afterRole: []DecisionEngineFunc{ + beforeRoll: []DecisionEngineFunc{}, + afterRoll: []DecisionEngineFunc{ func(input DecisionInput) DecisionInput { return ruleTwistOfFate(input, secondRolls) }, @@ -186,8 +186,8 @@ func TestEngineRun(t *testing.T) { result := engine.Run(DecisionInput{ teamA: TeamDecisionInput{ - player: 101, - augment: augments.Effect{Name: augments.TwistOfFate}, + player: 101, + activeAugment: augments.Effect{Name: augments.TwistOfFate}, }, teamB: TeamDecisionInput{player: 202}, }) @@ -201,8 +201,8 @@ func TestEngineRun(t *testing.T) { secondRolls := newSequentialRollFn([]int{6}) engine := &Engine{ - beforeRole: []DecisionEngineFunc{}, - afterRole: []DecisionEngineFunc{ + beforeRoll: []DecisionEngineFunc{}, + afterRoll: []DecisionEngineFunc{ func(input DecisionInput) DecisionInput { return ruleTwistOfFate(input, secondRolls) }, @@ -213,8 +213,8 @@ func TestEngineRun(t *testing.T) { result := engine.Run(DecisionInput{ teamA: TeamDecisionInput{ - player: 101, - augment: augments.Effect{Name: augments.Brace}, + player: 101, + activeAugment: augments.Effect{Name: augments.Brace}, }, teamB: TeamDecisionInput{player: 202}, }) @@ -226,8 +226,8 @@ func TestEngineRun(t *testing.T) { }). Test("should return draw when both final rolls are equal", func(t *testing.T) { engine := &Engine{ - beforeRole: []DecisionEngineFunc{}, - afterRole: []DecisionEngineFunc{}, + beforeRoll: []DecisionEngineFunc{}, + afterRoll: []DecisionEngineFunc{}, afterAugments: []DecisionEngineFunc{}, rollFn: newSequentialRollFn([]int{4, 4}), } From 0820e9b7e168a1f67e73c4cad2c9133f39bfe3ca Mon Sep 17 00:00:00 2001 From: frag223 Date: Wed, 8 Jul 2026 21:48:28 +1000 Subject: [PATCH 68/72] moving to engine rather than a roll func --- internal/games/game.go | 2 +- internal/games/game_test.go | 4 +- internal/games/rounds.go | 64 +++++++++---------- internal/games/rounds_test.go | 26 ++++---- internal/games/test_engine.go | 11 ++++ internal/ui/components/game.go | 32 +++++----- internal/ui/components/game_test.go | 4 +- internal/ui/iugame/page_game.go | 4 +- internal/ui/iugame/page_game_complete_test.go | 4 +- internal/ui/iugame/page_game_test.go | 2 +- 10 files changed, 80 insertions(+), 73 deletions(-) create mode 100644 internal/games/test_engine.go diff --git a/internal/games/game.go b/internal/games/game.go index d52cc10..d453821 100644 --- a/internal/games/game.go +++ b/internal/games/game.go @@ -37,7 +37,7 @@ func generateRounds(teamA TeamConfig, teamB TeamConfig) [10]Round { return rounds } -func (g *Game) ResolveRound(roll RollFn) (RoundResult, error) { +func (g *Game) ResolveRound(roll RollStrategy) (RoundResult, error) { if g.currentRound < 0 || g.currentRound >= int64(len(g.rounds)) { return RoundResult{}, ErrNoRounds } diff --git a/internal/games/game_test.go b/internal/games/game_test.go index ac54628..beef8bf 100644 --- a/internal/games/game_test.go +++ b/internal/games/game_test.go @@ -48,14 +48,14 @@ 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 == TeamA || res.Outcome == TeamB) diff --git a/internal/games/rounds.go b/internal/games/rounds.go index 064d0b2..9d4cc79 100644 --- a/internal/games/rounds.go +++ b/internal/games/rounds.go @@ -2,6 +2,8 @@ package games import ( "errors" + + "github.com/code-gorilla-au/rush/internal/augments" ) func NewRound() Round { @@ -17,7 +19,7 @@ func (r *Round) FillSquad(a LanesConfig, b LanesConfig) { r.TeamB.FillLanes(b) } -func (r *Round) ResolveLanes(rollFn RollFn) RoundResult { +func (r *Round) ResolveLanes(rollFn RollStrategy) RoundResult { var result []RoundResult for lane := 0; lane < len(r.TeamA.Lanes); lane++ { @@ -65,53 +67,51 @@ func (r *Round) calculateWinner(result []RoundResult) RoundResult { } } -func (r *Round) ResolveLane(lane int, rollFn RollFn) RoundResult { +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]) + } - for aRoll == bRoll { - bRoll = rollFn() - aRoll = rollFn() + 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, + }, } - if aRoll > bRoll { - player := r.TeamA.LanePeak(lane) + re := rollFn.Run(rollInput) - r.DuelResults = append(r.DuelResults, DuelResult{ - Player: player, - Outcome: TeamA, - Roll: aRoll, - RollDelta: aRoll - bRoll, - }) + for re.Outcome == Draw { + re = rollFn.Run(rollInput) + } + 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 { - player := r.TeamB.LanePeak(lane) - - r.DuelResults = append(r.DuelResults, DuelResult{ - Player: player, - Outcome: TeamB, - Roll: bRoll, - RollDelta: bRoll - aRoll, - }) - + } else { _, err := r.TeamA.LanePop(lane) if errors.Is(err, ErrNoPlayer) { break } } - r.DuelResults = append(r.DuelResults, DuelResult{ - Outcome: Draw, - Roll: 0, - RollDelta: 0, - }) - } if r.TeamA.LaneHasPlayers(lane) { diff --git a/internal/games/rounds_test.go b/internal/games/rounds_test.go index 355da3b..78eb175 100644 --- a/internal/games/rounds_test.go +++ b/internal/games/rounds_test.go @@ -66,7 +66,7 @@ func TestResolveLane(t *testing.T) { 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] @@ -74,9 +74,7 @@ func TestResolveLane(t *testing.T) { return val } - res := r.ResolveLane(lane, rollFn) - - odize.AssertEqual(t, TeamA, res.Outcome) + res := r.ResolveLane(lane, &TestEngine{RollFn: rollFn}) odize.AssertEqual(t, 2, res.RemainingPlayers) odize.AssertEqual(t, 0, r.TeamB.LaneCount(lane)) }) @@ -91,7 +89,7 @@ func TestResolveLane(t *testing.T) { 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] @@ -99,9 +97,7 @@ func TestResolveLane(t *testing.T) { return val } - res := r.ResolveLane(lane, rollFn) - - odize.AssertEqual(t, TeamB, res.Outcome) + res := r.ResolveLane(lane, &TestEngine{RollFn: rollFn}) odize.AssertEqual(t, 2, res.RemainingPlayers) odize.AssertEqual(t, 0, r.TeamA.LaneCount(lane)) }) @@ -115,7 +111,7 @@ func TestResolveLane(t *testing.T) { 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, TeamB, res.Outcome) odize.AssertEqual(t, 3, res.RemainingPlayers) @@ -130,7 +126,7 @@ func TestResolveLane(t *testing.T) { 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, TeamA, res.Outcome) odize.AssertEqual(t, 3, res.RemainingPlayers) @@ -163,7 +159,7 @@ func TestResolveLanes(t *testing.T) { // 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] @@ -171,7 +167,7 @@ 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 @@ -198,7 +194,7 @@ func TestResolveLanes(t *testing.T) { 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] @@ -206,7 +202,7 @@ 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 @@ -232,7 +228,7 @@ func TestResolveLanes(t *testing.T) { 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 } 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/ui/components/game.go b/internal/ui/components/game.go index 546acb8..33fddac 100644 --- a/internal/ui/components/game.go +++ b/internal/ui/components/game.go @@ -12,13 +12,13 @@ import ( // 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.RoundResult - 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,9 +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 = games.DiceRoll +func NewGame(game *games.Game, teamAName, teamBName string, rollEngine games.RollStrategy) Game { + if rollEngine == nil { + rollEngine = games.NewDecisionEngine() } currentRoundIdx := game.CurrentRound() @@ -41,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), } } @@ -79,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 diff --git a/internal/ui/components/game_test.go b/internal/ui/components/game_test.go index 6288f57..31c754c 100644 --- a/internal/ui/components/game_test.go +++ b/internal/ui/components/game_test.go @@ -41,7 +41,7 @@ func TestGameComponent(t *testing.T) { 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] @@ -58,7 +58,7 @@ 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) diff --git a/internal/ui/iugame/page_game.go b/internal/ui/iugame/page_game.go index d89bd5f..7cc7726 100644 --- a/internal/ui/iugame/page_game.go +++ b/internal/ui/iugame/page_game.go @@ -67,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, games.DiceRoll) + 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)) @@ -81,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, games.DiceRoll) + m.gameComp = components.NewGame(m.game, teamA, teamB, games.NewDecisionEngine()) cmds = append(cmds, m.gameComp.Init()) } else { cmds = append(cmds, func() tea.Msg { diff --git a/internal/ui/iugame/page_game_complete_test.go b/internal/ui/iugame/page_game_complete_test.go index 2e709bb..db4acdd 100644 --- a/internal/ui/iugame/page_game_complete_test.go +++ b/internal/ui/iugame/page_game_complete_test.go @@ -84,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 @@ -92,7 +92,7 @@ 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) diff --git a/internal/ui/iugame/page_game_test.go b/internal/ui/iugame/page_game_test.go index 0f6fca7..cdd7e03 100644 --- a/internal/ui/iugame/page_game_test.go +++ b/internal/ui/iugame/page_game_test.go @@ -103,7 +103,7 @@ func TestPageGameModel(t *testing.T) { m.Update(MsgGameLoaded{Game: game}) // Simulate round resolved (currentRound incremented) - m.game.ResolveRound(func() int { return 1 }) + m.game.ResolveRound(&games.TestEngine{RollFn: func() int { return 1 }}) _, cmd := m.Update(components.MsgNextRound{}) From 7328610336d78ca69a59c4aab170799ab703c5a8 Mon Sep 17 00:00:00 2001 From: frag223 Date: Thu, 9 Jul 2026 07:18:36 +1000 Subject: [PATCH 69/72] fixing test --- internal/augments/augments.go | 14 ++++++++++ internal/augments/augments_test.go | 42 ++++++++++++++++++++++++++++++ internal/teams/tokens.go | 36 +++++-------------------- internal/teams/tokens_test.go | 37 ++++---------------------- 4 files changed, 68 insertions(+), 61 deletions(-) diff --git a/internal/augments/augments.go b/internal/augments/augments.go index 7924557..68c63d9 100644 --- a/internal/augments/augments.go +++ b/internal/augments/augments.go @@ -195,6 +195,20 @@ func GetByCategory(category Category) ([]Effect, bool) { 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 +} + func List() []Name { total := 0 for _, repository := range _repositories { diff --git a/internal/augments/augments_test.go b/internal/augments/augments_test.go index 3366962..0a01a48 100644 --- a/internal/augments/augments_test.go +++ b/internal/augments/augments_test.go @@ -1,6 +1,7 @@ package augments import ( + "slices" "testing" "github.com/code-gorilla-au/odize" @@ -138,3 +139,44 @@ func TestGetByCategory(t *testing.T) { 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/teams/tokens.go b/internal/teams/tokens.go index e65209b..4f94f11 100644 --- a/internal/teams/tokens.go +++ b/internal/teams/tokens.go @@ -1,45 +1,23 @@ package teams import ( - "slices" - "github.com/code-gorilla-au/rush/internal/augments" ) -var _coachPersonaTokens = map[CoachPersona][]augments.Name{ - CoachPersonaVanguard: { - augments.TwistOfFate, - augments.Overpower, - augments.MomentumSurge, - augments.PrecisionStrike, - }, - CoachPersonaBastion: { - augments.SecondChance, - augments.Hamstring, - augments.LastStand, - augments.IceInVeins, - }, - CoachPersonaTrickster: { - augments.PocketSand, - augments.SecondChance, - augments.Overpower, - augments.IceInVeins, - }, - CoachPersonaWildcard: { - augments.TwistOfFate, - augments.Overpower, - augments.Hamstring, - augments.PrecisionStrike, - }, +var _coachPersonaTokens = map[CoachPersona]augments.Category{ + CoachPersonaVanguard: augments.CategoryOffense, + CoachPersonaBastion: augments.CategoryDefense, + CoachPersonaTrickster: augments.CategorySabotage, + CoachPersonaWildcard: augments.CategoryOffense, } func (p CoachPersona) Augments() []augments.Name { - tokens, ok := _coachPersonaTokens[p] + aug, ok := _coachPersonaTokens[p] if !ok { return []augments.Name{} } - return slices.Clone(tokens) + return augments.NamesFromCategory(aug) } func (c *Coach) AvailableAugments() []augments.Name { diff --git a/internal/teams/tokens_test.go b/internal/teams/tokens_test.go index 1eb6416..52f7999 100644 --- a/internal/teams/tokens_test.go +++ b/internal/teams/tokens_test.go @@ -1,6 +1,7 @@ package teams import ( + "slices" "testing" "github.com/code-gorilla-au/odize" @@ -14,22 +15,11 @@ func TestCoachPersona_AvailableTokens(t *testing.T) { Test("returns expected tokens for known persona", func(t *testing.T) { tokens := CoachPersonaBastion.Augments() - expected := []augments.Name{ - augments.SecondChance, - augments.Hamstring, - augments.LastStand, - augments.IceInVeins, - } + 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)) - odize.AssertEqual(t, expected, tokens) - }). - Test("returns cloned slice to prevent mutation leaks", func(t *testing.T) { - first := CoachPersonaVanguard.Augments() - second := CoachPersonaVanguard.Augments() - - first[0] = augments.SecondChance - - odize.AssertEqual(t, augments.TwistOfFate, second[0]) }). Test("returns empty slice for unknown persona", func(t *testing.T) { tokens := CoachPersona("Unknown Coach").Augments() @@ -39,20 +29,3 @@ func TestCoachPersona_AvailableTokens(t *testing.T) { odize.AssertNoError(t, err) } - -func TestCoach_AvailableTokens(t *testing.T) { - group := odize.NewGroup(t, nil) - - err := group. - Test("returns tokens from coach persona", func(t *testing.T) { - coach := Coach{Persona: CoachPersonaTrickster} - - expected := CoachPersonaTrickster.Augments() - actual := coach.AvailableAugments() - - odize.AssertEqual(t, expected, actual) - }). - Run() - - odize.AssertNoError(t, err) -} From 516e54e6c716340fe5491bc4ce4842e9ef711699 Mon Sep 17 00:00:00 2001 From: frag223 Date: Thu, 9 Jul 2026 07:24:57 +1000 Subject: [PATCH 70/72] fixing test --- internal/augments/augments.go | 14 -------------- internal/augments/augments_test.go | 31 ------------------------------ 2 files changed, 45 deletions(-) diff --git a/internal/augments/augments.go b/internal/augments/augments.go index 68c63d9..da40a24 100644 --- a/internal/augments/augments.go +++ b/internal/augments/augments.go @@ -208,17 +208,3 @@ func NamesFromCategory(category Category) []Name { return result } - -func List() []Name { - total := 0 - for _, repository := range _repositories { - total += len(repository) - } - - names := make([]Name, 0, total) - for _, repository := range _repositories { - names = append(names, slices.Collect(maps.Keys(repository))...) - } - - return names -} diff --git a/internal/augments/augments_test.go b/internal/augments/augments_test.go index 0a01a48..a04c1a9 100644 --- a/internal/augments/augments_test.go +++ b/internal/augments/augments_test.go @@ -33,19 +33,6 @@ func TestRepository(t *testing.T) { odize.AssertTrue(t, ok) } }). - Test("should have correct number of tokens per category", func(t *testing.T) { - counts := make(map[Category]int) - for _, name := range List() { - effect, ok := Get(name) - odize.AssertTrue(t, ok) - counts[effect.Category]++ - } - - odize.AssertEqual(t, 4, counts[CategoryOffense]) - odize.AssertEqual(t, 4, counts[CategoryDefense]) - odize.AssertEqual(t, 4, counts[CategorySabotage]) - odize.AssertEqual(t, 1, counts[CategoryNoOp]) - }). Run() odize.AssertNoError(t, err) @@ -81,24 +68,6 @@ func TestGet(t *testing.T) { odize.AssertNoError(t, err) } -func TestList(t *testing.T) { - group := odize.NewGroup(t, nil) - - err := group. - Test("should return all token names in repository", func(t *testing.T) { - names := List() - odize.AssertEqual(t, 13, len(names)) - - for _, name := range names { - _, ok := Get(name) - odize.AssertTrue(t, ok) - } - }). - Run() - - odize.AssertNoError(t, err) -} - func TestGetByCategory(t *testing.T) { group := odize.NewGroup(t, nil) From b55a34994b52461834c5c7c4d738db2b5b3999cc Mon Sep 17 00:00:00 2001 From: frag223 Date: Thu, 9 Jul 2026 20:15:47 +1000 Subject: [PATCH 71/72] fixing test --- .golangci.yaml | 3 ++- internal/database/migrator.go | 2 +- internal/teams/service.go | 17 ----------------- internal/ui/components/team_tile.go | 3 ++- internal/ui/iugame/page_game_complete_test.go | 2 +- internal/ui/iugame/root_game.go | 1 - internal/ui/uibattle/page_battle_selection.go | 6 ++++-- 7 files changed, 10 insertions(+), 24 deletions(-) diff --git a/.golangci.yaml b/.golangci.yaml index b438533..4b677de 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,6 +38,7 @@ linters: - gosec - forbidigo - exhaustruct + - unused settings: depguard: 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/teams/service.go b/internal/teams/service.go index bde39df..767bad2 100644 --- a/internal/teams/service.go +++ b/internal/teams/service.go @@ -138,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, diff --git a/internal/ui/components/team_tile.go b/internal/ui/components/team_tile.go index 1e7f7c6..b805421 100644 --- a/internal/ui/components/team_tile.go +++ b/internal/ui/components/team_tile.go @@ -34,7 +34,8 @@ func (t TeamTile) View(theme styles.IceTheme, width int) string { renderPlayers(theme, t.PlayerNames), ) - tileStyle := theme.RoundBorder.Copy().Align(lipgloss.Left) + tileStyle := theme.RoundBorder + tileStyle.Align(lipgloss.Left) if width > 0 { tileStyle = tileStyle.Width(width) diff --git a/internal/ui/iugame/page_game_complete_test.go b/internal/ui/iugame/page_game_complete_test.go index db4acdd..031c47d 100644 --- a/internal/ui/iugame/page_game_complete_test.go +++ b/internal/ui/iugame/page_game_complete_test.go @@ -92,7 +92,7 @@ func TestPageGameCompleteModel(t *testing.T) { // Finish all rounds for i := 0; i < 10; i++ { - game.ResolveRound(&games.TestEngine{RollFn: rollFn}) + _, _ = game.ResolveRound(&games.TestEngine{RollFn: rollFn}) } _, err = gameSvc.CompleteGame(ctx, game) diff --git a/internal/ui/iugame/root_game.go b/internal/ui/iugame/root_game.go index 52517fc..a3a39b4 100644 --- a/internal/ui/iugame/root_game.go +++ b/internal/ui/iugame/root_game.go @@ -23,7 +23,6 @@ type MsgSwitchGamePage struct { // GameModel handles all game related pages. type GameModel struct { currentPage SubPageGame - subPageGame tea.Model subPageGameRoot tea.Model subPageGameComplete tea.Model } diff --git a/internal/ui/uibattle/page_battle_selection.go b/internal/ui/uibattle/page_battle_selection.go index c9d0d2d..75c8ee0 100644 --- a/internal/ui/uibattle/page_battle_selection.go +++ b/internal/ui/uibattle/page_battle_selection.go @@ -134,11 +134,13 @@ func (m *PageBattleSelectionModel) 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...) From 6e995e3bfc7ea4b89e7c0819991fc6ecb111540d Mon Sep 17 00:00:00 2001 From: frag223 Date: Thu, 9 Jul 2026 20:33:36 +1000 Subject: [PATCH 72/72] lint cleanup --- .golangci.yaml | 8 ++ internal/database/migrator_test.go | 4 +- internal/games/dice_roll.go | 1 + internal/games/game.go | 5 +- internal/games/service_test.go | 2 +- internal/playbooks/service_test.go | 2 +- internal/playbooks/transforms.go | 8 +- internal/teams/ai_teams.go | 2 +- internal/teams/name_generator.go | 1 + internal/teams/service_test.go | 2 +- internal/ui/app.go | 69 +++++++------ internal/ui/app_test.go | 3 +- internal/ui/components/coach_avatar.go | 4 +- internal/ui/components/coach_complete.go | 9 +- internal/ui/components/footer.go | 2 +- internal/ui/components/game.go | 5 +- internal/ui/components/player_list.go | 47 +++++---- internal/ui/iugame/page_game_complete.go | 7 +- internal/ui/iugame/page_game_complete_test.go | 2 +- internal/ui/iugame/page_game_test.go | 8 +- internal/ui/iugame/root_game_test.go | 2 +- internal/ui/page_title.go | 61 +++++++----- internal/ui/uibattle/page_battle_confirm.go | 5 +- internal/ui/uibattle/page_battle_selection.go | 3 +- .../uilocker/page_locker_playbooks_create.go | 85 ++++++++++------ .../ui/uilocker/page_locker_playbooks_edit.go | 54 +++++----- .../ui/uilocker/page_locker_playbooks_list.go | 99 ++++++++++--------- internal/ui/uilocker/page_locker_players.go | 52 +++++++--- internal/ui/uilocker/root_locker.go | 48 ++++----- 29 files changed, 355 insertions(+), 245 deletions(-) diff --git a/.golangci.yaml b/.golangci.yaml index 4b677de..55bb2f8 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -45,5 +45,13 @@ linters: 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/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/games/dice_roll.go b/internal/games/dice_roll.go index 1253a31..4980961 100644 --- a/internal/games/dice_roll.go +++ b/internal/games/dice_roll.go @@ -7,6 +7,7 @@ import ( ) func DiceRoll() int { + //nolint:gosec return rand.IntN(6) + 1 } diff --git a/internal/games/game.go b/internal/games/game.go index d453821..a7b5181 100644 --- a/internal/games/game.go +++ b/internal/games/game.go @@ -60,9 +60,10 @@ func (g *Game) CalculateWinner() (int64, error) { teamB := 0 for _, result := range g.results { - if result.Outcome == TeamA { + switch result.Outcome { + case TeamA: teamA++ - } else if result.Outcome == TeamB { + case TeamB: teamB++ } } diff --git a/internal/games/service_test.go b/internal/games/service_test.go index 4c17776..585e5f7 100644 --- a/internal/games/service_test.go +++ b/internal/games/service_test.go @@ -122,7 +122,7 @@ func persistCompletedGame(t *testing.T, gameSvc *Service, ctx context.Context, t game.status = StatusComplete game.currentRound = int64(len(game.rounds)) game.results = results - game.winner = new(winner) + game.winner = &winner _, err = gameSvc.UpdateGame(ctx, game) odize.AssertNoError(t, err) 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 8dac8ee..c730a82 100644 --- a/internal/teams/ai_teams.go +++ b/internal/teams/ai_teams.go @@ -227,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 { diff --git a/internal/teams/name_generator.go b/internal/teams/name_generator.go index 812c0f4..06a38cb 100644 --- a/internal/teams/name_generator.go +++ b/internal/teams/name_generator.go @@ -11,6 +11,7 @@ type TeamNameGenerator struct { func NewTeamNameGenerator() *TeamNameGenerator { return &TeamNameGenerator{ + //nolint:gosec rng: rand.New(rand.NewPCG(42, 42)), } } diff --git a/internal/teams/service_test.go b/internal/teams/service_test.go index b86bd47..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() } }) diff --git a/internal/ui/app.go b/internal/ui/app.go index 429bc59..ab8d496 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -77,20 +77,9 @@ func (m *RootModel) Init() tea.Cmd { } func (m *RootModel) 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 - 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) - } - return m, tea.Batch(cmds...) + return m.handleStateUpdated(msg) case tea.KeyMsg: switch msg.String() { case "q", "ctrl+c": @@ -98,26 +87,48 @@ func (m *RootModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } case uistate.MsgSwitchPage: m.currentPage = msg.NewPage - 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.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...) } + 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 uistate.PageTitle: @@ -133,9 +144,7 @@ func (m *RootModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case uistate.PageGame: m.pageGame, cmd = m.pageGame.Update(msg) } - cmds = append(cmds, cmd) - - return m, tea.Batch(cmds...) + return m, cmd } func (m *RootModel) View() tea.View { diff --git a/internal/ui/app_test.go b/internal/ui/app_test.go index 4358016..c7e2654 100644 --- a/internal/ui/app_test.go +++ b/internal/ui/app_test.go @@ -72,7 +72,8 @@ func TestNew(t *testing.T) { 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) }). diff --git a/internal/ui/components/coach_avatar.go b/internal/ui/components/coach_avatar.go index 621444c..bdd79ea 100644 --- a/internal/ui/components/coach_avatar.go +++ b/internal/ui/components/coach_avatar.go @@ -1,8 +1,6 @@ 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" @@ -29,7 +27,7 @@ func (c CoachAvatar) View(theme styles.IceTheme) string { } teamName := theme.CoachTeam.Render(c.Team.Name) - coachName := theme.CoachName.Render(fmt.Sprintf("Coach: %s", c.Coach.Name)) + coachName := theme.CoachName.Render("Coach: " + c.Coach.Name) return lipgloss.JoinVertical(lipgloss.Left, teamName, coachName) } diff --git a/internal/ui/components/coach_complete.go b/internal/ui/components/coach_complete.go index 8046539..833a412 100644 --- a/internal/ui/components/coach_complete.go +++ b/internal/ui/components/coach_complete.go @@ -1,7 +1,6 @@ package components import ( - "fmt" "strings" "charm.land/lipgloss/v2" @@ -30,9 +29,9 @@ func (c CoachWinnerHuman) View(theme styles.IceTheme) string { } winnerHeader := theme.SecondaryHeader. - Render(fmt.Sprintf("Winner: %s", c.Team.Name)) + Render("Winner: " + c.Team.Name) - coachInfo := theme.CoachName.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 { @@ -71,9 +70,9 @@ func (c CoachWinnerAI) View(theme styles.IceTheme) string { } winnerHeader := theme.SecondaryHeader. - Render(fmt.Sprintf("Winner: %s", c.Team.Name)) + Render("Winner: " + c.Team.Name) - coachInfo := theme.CoachName.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/footer.go b/internal/ui/components/footer.go index 56a55bc..d3b9fe1 100644 --- a/internal/ui/components/footer.go +++ b/internal/ui/components/footer.go @@ -29,6 +29,6 @@ func (f *Footer) Update(msg tea.Msg) { } // View renders the footer component. -func (f Footer) View(theme styles.IceTheme) string { +func (f *Footer) View(theme styles.IceTheme) string { return theme.Footer.Render(f.Help.View(f.KeyMap)) } diff --git a/internal/ui/components/game.go b/internal/ui/components/game.go index 33fddac..34f6f7b 100644 --- a/internal/ui/components/game.go +++ b/internal/ui/components/game.go @@ -106,9 +106,10 @@ func (g *Game) View(theme styles.IceTheme) string { var footer string if g.resolved { winner := g.teamAName - if g.result.Outcome == games.TeamB { + switch g.result.Outcome { + case games.TeamB: winner = g.teamBName - } else if g.result.Outcome == games.Draw { + case games.Draw: winner = "Draw" } diff --git a/internal/ui/components/player_list.go b/internal/ui/components/player_list.go index ea0c191..3c84eb4 100644 --- a/internal/ui/components/player_list.go +++ b/internal/ui/components/player_list.go @@ -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 { diff --git a/internal/ui/iugame/page_game_complete.go b/internal/ui/iugame/page_game_complete.go index 64d81ef..dfc91be 100644 --- a/internal/ui/iugame/page_game_complete.go +++ b/internal/ui/iugame/page_game_complete.go @@ -115,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( @@ -132,7 +132,7 @@ func (m *PageGameCompleteModel) View() tea.View { return view } -func (m *PageGameCompleteModel) renderMainContent(content string) string { +func (m *PageGameCompleteModel) renderMainContent() string { winMsg := m.theme.Logo. Padding(1, 0). Render("🏆 GAME COMPLETE 🏆") @@ -151,11 +151,10 @@ func (m *PageGameCompleteModel) renderMainContent(content string) string { 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/iugame/page_game_complete_test.go b/internal/ui/iugame/page_game_complete_test.go index 031c47d..ac300fb 100644 --- a/internal/ui/iugame/page_game_complete_test.go +++ b/internal/ui/iugame/page_game_complete_test.go @@ -24,7 +24,7 @@ func TestPageGameCompleteModel(t *testing.T) { group.BeforeEach(func() { db := uitest.SetupTestDB(t) - t.Cleanup(func() { db.Close() }) + t.Cleanup(func() { _ = db.Close() }) queries = database.New(db) teamsSvc = teams.NewTeamsService(queries, playbooks.NewPlaybooksService(queries)) gameSvc = games.NewService(queries) diff --git a/internal/ui/iugame/page_game_test.go b/internal/ui/iugame/page_game_test.go index cdd7e03..baa1a54 100644 --- a/internal/ui/iugame/page_game_test.go +++ b/internal/ui/iugame/page_game_test.go @@ -21,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, @@ -103,7 +106,8 @@ func TestPageGameModel(t *testing.T) { m.Update(MsgGameLoaded{Game: game}) // Simulate round resolved (currentRound incremented) - m.game.ResolveRound(&games.TestEngine{RollFn: 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_test.go b/internal/ui/iugame/root_game_test.go index 5c89296..96d02c9 100644 --- a/internal/ui/iugame/root_game_test.go +++ b/internal/ui/iugame/root_game_test.go @@ -24,7 +24,7 @@ func TestGameModel(t *testing.T) { group.BeforeEach(func() { db := uitest.SetupTestDB(t) - t.Cleanup(func() { db.Close() }) + t.Cleanup(func() { _ = db.Close() }) queries = database.New(db) teamsSvc = teams.NewTeamsService(queries, playbooks.NewPlaybooksService(queries)) gameSvc = games.NewService(queries) diff --git a/internal/ui/page_title.go b/internal/ui/page_title.go index 40a76c0..0d56b3e 100644 --- a/internal/ui/page_title.go +++ b/internal/ui/page_title.go @@ -58,38 +58,55 @@ func (m *ModelTitle) Init() tea.Cmd { } func (m *ModelTitle) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch vMsg := msg.(type) { + switch msg := msg.(type) { case uistate.MsgStateUpdated: - m.globalState.Coach = vMsg.Coach - m.globalState.Team = vMsg.Team - m.menu.SetHasCoach(m.globalState.Coach != nil) + m.handleStateUpdated(msg) case uistate.MsgSwitchPage: - if vMsg.NewPage == uistate.PageTitle { - m.menu.SetHasCoach(m.globalState.Coach != nil) - } + m.handleSwitchPage(msg) case tea.KeyMsg: - switch { - case key.Matches(vMsg, m.keys.Quit): - return m, tea.Quit - case key.Matches(vMsg, m.keys.Up): - m.menu.MoveUp() - case key.Matches(vMsg, m.keys.Down): - m.menu.MoveDown() - case key.Matches(vMsg, m.keys.Enter): - model, cmd, done := m.handleMenuSelect() - if done { - return model, cmd - } + model, cmd, done := m.handleKey(msg) + if done { + return model, cmd } 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 { diff --git a/internal/ui/uibattle/page_battle_confirm.go b/internal/ui/uibattle/page_battle_confirm.go index 6606a81..2ae0b78 100644 --- a/internal/ui/uibattle/page_battle_confirm.go +++ b/internal/ui/uibattle/page_battle_confirm.go @@ -1,6 +1,7 @@ package uibattle import ( + "errors" "fmt" "github.com/code-gorilla-au/rush/internal/games" @@ -75,11 +76,11 @@ func (m *PageBattleConfirmModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m *PageBattleConfirmModel) createGame() tea.Msg { if m.globalState == nil || m.globalState.Team == nil || m.selectedPlaybook == nil || m.selectedAITeam == nil { - return fmt.Errorf("missing team, playbook, or opponent") + return errors.New("missing team, playbook, or opponent") } if m.gameSvc == nil { - return fmt.Errorf("game service is not configured") + return errors.New("game service is not configured") } teamAPlayers := getPlayerIDs(m.globalState.Team.Players) diff --git a/internal/ui/uibattle/page_battle_selection.go b/internal/ui/uibattle/page_battle_selection.go index 75c8ee0..d03f737 100644 --- a/internal/ui/uibattle/page_battle_selection.go +++ b/internal/ui/uibattle/page_battle_selection.go @@ -1,6 +1,7 @@ package uibattle import ( + "errors" "fmt" "github.com/code-gorilla-au/rush/internal/games" @@ -87,7 +88,7 @@ func (m *PageBattleSelectionModel) Init() tea.Cmd { func (m *PageBattleSelectionModel) loadData() tea.Msg { if m.globalState.Team == nil { - return fmt.Errorf("no team loaded") + return errors.New("no team loaded") } pb, err := m.playbookSvc.GetTeamPlaybooks(m.globalState.Context(), m.globalState.Team.ID) diff --git a/internal/ui/uilocker/page_locker_playbooks_create.go b/internal/ui/uilocker/page_locker_playbooks_create.go index 6b580d5..e0b0020 100644 --- a/internal/ui/uilocker/page_locker_playbooks_create.go +++ b/internal/ui/uilocker/page_locker_playbooks_create.go @@ -64,43 +64,16 @@ func (m *ModelLockerPlaybooksCreate) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case MsgSwitchLockerPage: - if msg.NewPage == SubPageLockerPlaybooksCreate { - if msg.Playbook != nil { - m.load(msg.Playbook) - } else { - m.reset() - } - } + 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 MsgSwitchLockerPage{NewPage: SubPageLockerPlaybooksList} - } - case key.Matches(msg, m.keys.Enter): - 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, - }, - } - } - } + 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 @@ -110,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 diff --git a/internal/ui/uilocker/page_locker_playbooks_edit.go b/internal/ui/uilocker/page_locker_playbooks_edit.go index 205f49b..f922867 100644 --- a/internal/ui/uilocker/page_locker_playbooks_edit.go +++ b/internal/ui/uilocker/page_locker_playbooks_edit.go @@ -157,30 +157,9 @@ func (m *ModelLockerPlaybooksEdit) handleKey(msg tea.KeyMsg) (*ModelLockerPlaybo case key.Matches(msg, m.keys.Quit): return m, tea.Quit, true case key.Matches(msg, m.keys.Back): - 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 + return m.handleBack() case key.Matches(msg, m.keys.Tab): - if m.formationList.IsFiltering() { - return m, nil, false - } - if m.activeList == availableFormationListIndex { - m.setActiveList(selectedFormationListIndex) - } else { - m.setActiveList(availableFormationListIndex) - } - return m, nil, true + return m.handleTab() case key.Matches(msg, m.keys.Enter): if m.formationList.IsFiltering() { return m, nil, false @@ -195,6 +174,35 @@ func (m *ModelLockerPlaybooksEdit) handleKey(msg tea.KeyMsg) (*ModelLockerPlaybo 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) diff --git a/internal/ui/uilocker/page_locker_playbooks_list.go b/internal/ui/uilocker/page_locker_playbooks_list.go index b3a81d6..6929e65 100644 --- a/internal/ui/uilocker/page_locker_playbooks_list.go +++ b/internal/ui/uilocker/page_locker_playbooks_list.go @@ -92,40 +92,12 @@ func (m *ModelLockerPlaybooksList) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 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 MsgSwitchLockerPage{NewPage: SubPageLockerRoom} - } - 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 MsgSwitchLockerPage{NewPage: SubPageLockerPlaybooksCreate} - } - } - 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 { @@ -137,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 @@ -177,26 +183,15 @@ 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(m.theme) - 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, "", @@ -216,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/uilocker/page_locker_players.go b/internal/ui/uilocker/page_locker_players.go index 62b620e..71e7421 100644 --- a/internal/ui/uilocker/page_locker_players.go +++ b/internal/ui/uilocker/page_locker_players.go @@ -63,28 +63,18 @@ func (m *ModelLockerPlayers) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case uistate.MsgStateUpdated: - if m.globalState.Team != nil { - m.playerList = components.NewPlayerList(m.globalState.Team.Players) - } + m.handleStateUpdated() case MsgSwitchLockerPage: - if msg.NewPage == SubPageLockerPlayers && m.globalState.Team != nil { - m.playerList = components.NewPlayerList(m.globalState.Team.Players) - } + 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 MsgSwitchLockerPage{NewPage: SubPageLockerRoom} - } + 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) @@ -93,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) diff --git a/internal/ui/uilocker/root_locker.go b/internal/ui/uilocker/root_locker.go index fa9ef57..0a3f0e9 100644 --- a/internal/ui/uilocker/root_locker.go +++ b/internal/ui/uilocker/root_locker.go @@ -54,8 +54,6 @@ func (m *LockerModel) Init() tea.Cmd { } func (m *LockerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - var cmds []tea.Cmd - switch msg := msg.(type) { case uistate.MsgSwitchPage: if msg.NewPage == uistate.PageLockerRoom { @@ -63,27 +61,33 @@ func (m *LockerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, m.Init() } case MsgSwitchLockerPage: - switch msg.NewPage { - case SubPageLockerRoom, SubPageLockerPlayers, SubPageLockerTeamStatistics, SubPageLockerPlaybooksList, SubPageLockerPlaybooksCreate, SubPageLockerPlaybooksEdit: - m.currentPage = msg.NewPage - } - + m.currentPage = msg.NewPage case tea.WindowSizeMsg: - 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 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: @@ -99,9 +103,7 @@ func (m *LockerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case SubPageLockerPlaybooksEdit: m.subPageLockerPlaybooksEdit, cmd = m.subPageLockerPlaybooksEdit.Update(msg) } - cmds = append(cmds, cmd) - - return m, tea.Batch(cmds...) + return m, cmd } func (m *LockerModel) View() tea.View {