From 5e0a9a9ca67bddbe2789f389c07b02c07420e494 Mon Sep 17 00:00:00 2001 From: frag223 Date: Thu, 9 Jul 2026 21:06:54 +1000 Subject: [PATCH 01/25] fixing tests --- internal/games/types.go | 2 ++ internal/teams/tokens.go | 13 +++++++++---- internal/teams/tokens_test.go | 16 ++++++++++++---- internal/ui/uibattle/page_battle_confirm.go | 2 ++ 4 files changed, 25 insertions(+), 8 deletions(-) diff --git a/internal/games/types.go b/internal/games/types.go index 8675d19..930fd69 100644 --- a/internal/games/types.go +++ b/internal/games/types.go @@ -4,6 +4,7 @@ import ( "errors" "time" + "github.com/code-gorilla-au/rush/internal/augments" "github.com/code-gorilla-au/rush/internal/playbooks" ) @@ -72,6 +73,7 @@ type TeamConfig struct { TeamID int64 `json:"team_id"` TeamName string `json:"team_name"` Players []int64 `json:"players"` + Augments []augments.Effect `json:"augments"` Formations []playbooks.Formation `json:"formations"` } diff --git a/internal/teams/tokens.go b/internal/teams/tokens.go index 4f94f11..185e19d 100644 --- a/internal/teams/tokens.go +++ b/internal/teams/tokens.go @@ -11,15 +11,20 @@ var _coachPersonaTokens = map[CoachPersona]augments.Category{ CoachPersonaWildcard: augments.CategoryOffense, } -func (p CoachPersona) Augments() []augments.Name { +func (p CoachPersona) Augments() []augments.Effect { aug, ok := _coachPersonaTokens[p] if !ok { - return []augments.Name{} + return []augments.Effect{} } - return augments.NamesFromCategory(aug) + list, ok := augments.GetByCategory(aug) + if !ok { + return []augments.Effect{} + } + + return list } -func (c *Coach) AvailableAugments() []augments.Name { +func (c *Coach) AvailableAugments() []augments.Effect { return c.Persona.Augments() } diff --git a/internal/teams/tokens_test.go b/internal/teams/tokens_test.go index 52f7999..072901b 100644 --- a/internal/teams/tokens_test.go +++ b/internal/teams/tokens_test.go @@ -15,10 +15,18 @@ func TestCoachPersona_AvailableTokens(t *testing.T) { Test("returns expected tokens for known persona", func(t *testing.T) { tokens := CoachPersonaBastion.Augments() - odize.AssertTrue(t, slices.Contains(tokens, augments.SecondChance)) - odize.AssertTrue(t, slices.Contains(tokens, augments.LastStand)) - odize.AssertTrue(t, slices.Contains(tokens, augments.Fortify)) - odize.AssertTrue(t, slices.Contains(tokens, augments.Brace)) + odize.AssertTrue(t, slices.ContainsFunc(tokens, func(effect augments.Effect) bool { + return effect.Name == augments.SecondChance + })) + odize.AssertTrue(t, slices.ContainsFunc(tokens, func(effect augments.Effect) bool { + return effect.Name == augments.LastStand + })) + odize.AssertTrue(t, slices.ContainsFunc(tokens, func(effect augments.Effect) bool { + return effect.Name == augments.Fortify + })) + odize.AssertTrue(t, slices.ContainsFunc(tokens, func(effect augments.Effect) bool { + return effect.Name == augments.Brace + })) }). Test("returns empty slice for unknown persona", func(t *testing.T) { diff --git a/internal/ui/uibattle/page_battle_confirm.go b/internal/ui/uibattle/page_battle_confirm.go index 2ae0b78..b6f1cf6 100644 --- a/internal/ui/uibattle/page_battle_confirm.go +++ b/internal/ui/uibattle/page_battle_confirm.go @@ -91,12 +91,14 @@ func (m *PageBattleConfirmModel) createGame() tea.Msg { TeamID: m.globalState.Team.ID, TeamName: m.globalState.Team.Name, Players: teamAPlayers, + Augments: m.globalState.Coach.AvailableAugments(), Formations: m.selectedPlaybook.Formations, }, TeamB: games.TeamConfig{ TeamID: m.selectedAITeam.Team.ID, TeamName: m.selectedAITeam.Team.Name, Players: teamBPlayers, + Augments: m.selectedAITeam.Coach.AvailableAugments(), Formations: m.selectedAITeam.Playbook.Formations, }, } From d3c741e36bc337185c04fbdd34a56b75dd765b29 Mon Sep 17 00:00:00 2001 From: frag223 Date: Thu, 9 Jul 2026 21:59:59 +1000 Subject: [PATCH 02/25] update augment, changing twist of fate to be passive --- internal/augments/augments.go | 4 +- internal/games/dice_roll.go | 66 +++++++++++++++++++++----------- internal/games/dice_roll_test.go | 56 ++++++++++++++------------- internal/games/game.go | 22 ++++++----- internal/games/rounds.go | 9 +++-- internal/games/types.go | 25 ++++++------ 6 files changed, 107 insertions(+), 75 deletions(-) diff --git a/internal/augments/augments.go b/internal/augments/augments.go index da40a24..73230a0 100644 --- a/internal/augments/augments.go +++ b/internal/augments/augments.go @@ -10,9 +10,9 @@ var ( TwistOfFate: { Name: TwistOfFate, Category: CategoryOffense, - Type: TypeActive, + Type: TypePassive, Trigger: TriggerConditionAfterRoll, - Effect: "Roll 2d6 and keep the highest.", + Effect: "If last round was a loss, roll 2d6 and keep the highest.", Intent: "Front-load pressure in must-win lanes.", Action: ActionAddDie, Target: TargetSelf, diff --git a/internal/games/dice_roll.go b/internal/games/dice_roll.go index 4980961..4fb9edc 100644 --- a/internal/games/dice_roll.go +++ b/internal/games/dice_roll.go @@ -2,6 +2,7 @@ package games import ( "math/rand/v2" + "slices" "github.com/code-gorilla-au/rush/internal/augments" ) @@ -12,7 +13,7 @@ func DiceRoll() int { } type TeamDecisionInput struct { - activeAugment augments.Effect + triggeredAugment augments.Name passivesAugments []augments.Effect player int64 roll int @@ -31,17 +32,29 @@ func RuleTwistOfFate(input DecisionInput) DecisionInput { } func ruleTwistOfFate(input DecisionInput, roll RollFn) DecisionInput { - if input.teamB.activeAugment.Name == augments.TwistOfFate { - secondRoll := roll() - if input.teamB.roll < secondRoll { - input.teamB.roll = secondRoll - } + if input.lastRound == nil { + return input } - if input.teamA.activeAugment.Name == augments.TwistOfFate { - secondRoll := roll() - if input.teamA.roll < secondRoll { - input.teamA.roll = secondRoll + switch input.lastRound.Outcome { + case TeamB: + if hasPassiveAugment(input.teamA.passivesAugments, augments.TwistOfFate) { + input.teamA.triggeredAugment = augments.TwistOfFate + + secondRoll := roll() + if input.teamA.roll < secondRoll { + input.teamA.roll = secondRoll + } + } + + case TeamA: + if hasPassiveAugment(input.teamB.passivesAugments, augments.TwistOfFate) { + input.teamB.triggeredAugment = augments.TwistOfFate + + secondRoll := roll() + if input.teamB.roll < secondRoll { + input.teamB.roll = secondRoll + } } } @@ -88,27 +101,36 @@ func (e *Engine) Run(input DecisionInput) DuelResult { func makeDecision(input DecisionInput) DuelResult { if input.teamA.roll == input.teamB.roll { return DuelResult{ - Player: 0, - Outcome: Draw, - Roll: 0, - RollDelta: 0, + Player: 0, + Outcome: Draw, + Roll: 0, + RollDelta: 0, + TriggeredAugment: augments.NoAugment, } } 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, + Player: input.teamA.player, + Outcome: TeamA, + Roll: input.teamA.roll, + RollDelta: input.teamA.roll - input.teamB.roll, + TriggeredAugment: input.teamA.triggeredAugment, } } return DuelResult{ - Player: input.teamB.player, - Outcome: TeamB, - Roll: input.teamB.roll, - RollDelta: input.teamB.roll - input.teamA.roll, + Player: input.teamB.player, + Outcome: TeamB, + Roll: input.teamB.roll, + RollDelta: input.teamB.roll - input.teamA.roll, + TriggeredAugment: input.teamB.triggeredAugment, } } + +func hasPassiveAugment(list []augments.Effect, name augments.Name) bool { + return slices.ContainsFunc(list, func(e augments.Effect) bool { + return e.Name == name + }) +} diff --git a/internal/games/dice_roll_test.go b/internal/games/dice_roll_test.go index 5469702..16fb59e 100644 --- a/internal/games/dice_roll_test.go +++ b/internal/games/dice_roll_test.go @@ -12,13 +12,14 @@ func TestRuleTwistOfFate(t *testing.T) { group.Test("should not modify input struct (immutability)", func(t *testing.T) { input := DecisionInput{ + lastRound: &DuelResult{Outcome: Draw}, teamA: TeamDecisionInput{ - activeAugment: augments.Effect{Name: augments.TwistOfFate}, - roll: 1, + triggeredAugment: augments.TwistOfFate, + roll: 1, }, teamB: TeamDecisionInput{ - activeAugment: augments.Effect{Name: augments.TwistOfFate}, - roll: 1, + triggeredAugment: augments.TwistOfFate, + roll: 1, }, } @@ -30,9 +31,10 @@ 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{ + lastRound: &DuelResult{Outcome: TeamB}, teamA: TeamDecisionInput{ - activeAugment: augments.Effect{Name: augments.TwistOfFate}, - roll: 2, + passivesAugments: []augments.Effect{{Name: augments.TwistOfFate}}, + roll: 2, }, teamB: TeamDecisionInput{ roll: 3, @@ -48,9 +50,10 @@ 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{ + lastRound: &DuelResult{Outcome: TeamB}, teamA: TeamDecisionInput{ - activeAugment: augments.Effect{Name: augments.TwistOfFate}, - roll: 4, + passivesAugments: []augments.Effect{{Name: augments.TwistOfFate}}, + roll: 4, }, } @@ -62,9 +65,10 @@ 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{ + lastRound: &DuelResult{Outcome: TeamA}, teamB: TeamDecisionInput{ - activeAugment: augments.Effect{Name: augments.TwistOfFate}, - roll: 2, + passivesAugments: []augments.Effect{{Name: augments.TwistOfFate}}, + roll: 2, }, teamA: TeamDecisionInput{ roll: 3, @@ -80,9 +84,10 @@ func TestRuleTwistOfFate(t *testing.T) { group.Test("should not change rolls when TwistOfFate augment is not present", func(t *testing.T) { input := DecisionInput{ + lastRound: &DuelResult{Outcome: TeamB}, teamA: TeamDecisionInput{ - activeAugment: augments.Effect{Name: "Some Other Effect"}, - roll: 3, + triggeredAugment: "Some Other Effect", + roll: 3, }, teamB: TeamDecisionInput{ roll: 4, @@ -98,28 +103,25 @@ func TestRuleTwistOfFate(t *testing.T) { group.Test("should handle both teams having TwistOfFate", func(t *testing.T) { input := DecisionInput{ + lastRound: &DuelResult{Outcome: TeamB}, teamA: TeamDecisionInput{ - activeAugment: augments.Effect{Name: augments.TwistOfFate}, - roll: 2, + passivesAugments: []augments.Effect{{Name: augments.TwistOfFate}}, + roll: 2, }, teamB: TeamDecisionInput{ - activeAugment: augments.Effect{Name: augments.TwistOfFate}, - roll: 3, + passivesAugments: []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 + return 6 } res := ruleTwistOfFate(input, mockRoll) odize.AssertEqual(t, 6, res.teamA.roll) - odize.AssertEqual(t, 5, res.teamB.roll) + odize.AssertEqual(t, 3, res.teamB.roll) }) err := group.Run() @@ -185,9 +187,10 @@ func TestEngineRun(t *testing.T) { } result := engine.Run(DecisionInput{ + lastRound: &DuelResult{Outcome: TeamB}, teamA: TeamDecisionInput{ - player: 101, - activeAugment: augments.Effect{Name: augments.TwistOfFate}, + player: 101, + passivesAugments: []augments.Effect{{Name: augments.TwistOfFate}}, }, teamB: TeamDecisionInput{player: 202}, }) @@ -212,9 +215,10 @@ func TestEngineRun(t *testing.T) { } result := engine.Run(DecisionInput{ + lastRound: &DuelResult{Outcome: TeamB}, teamA: TeamDecisionInput{ - player: 101, - activeAugment: augments.Effect{Name: augments.Brace}, + player: 101, + triggeredAugment: augments.Brace, }, teamB: TeamDecisionInput{player: 202}, }) diff --git a/internal/games/game.go b/internal/games/game.go index a7b5181..9147739 100644 --- a/internal/games/game.go +++ b/internal/games/game.go @@ -16,18 +16,20 @@ func generateRounds(teamA TeamConfig, teamB TeamConfig) [10]Round { r.FillSquad( LanesConfig{ - TeamID: teamA.TeamID, - Players: teamA.Players, - Lane1: teamA.Formations[i].Lane1, - Lane2: teamA.Formations[i].Lane2, - Lane3: teamA.Formations[i].Lane3, + TeamID: teamA.TeamID, + Players: teamA.Players, + Augments: teamA.Augments, + Lane1: teamA.Formations[i].Lane1, + Lane2: teamA.Formations[i].Lane2, + Lane3: teamA.Formations[i].Lane3, }, LanesConfig{ - TeamID: teamB.TeamID, - Players: teamB.Players, - Lane1: teamB.Formations[i].Lane1, - Lane2: teamB.Formations[i].Lane2, - Lane3: teamB.Formations[i].Lane3, + TeamID: teamB.TeamID, + Players: teamB.Players, + Augments: teamB.Augments, + Lane1: teamB.Formations[i].Lane1, + Lane2: teamB.Formations[i].Lane2, + Lane3: teamB.Formations[i].Lane3, }, ) diff --git a/internal/games/rounds.go b/internal/games/rounds.go index 9d4cc79..4b6f049 100644 --- a/internal/games/rounds.go +++ b/internal/games/rounds.go @@ -80,14 +80,14 @@ func (r *Round) ResolveLane(lane int, rollFn RollStrategy) RoundResult { rollInput := DecisionInput{ lastRound: lastRound, teamA: TeamDecisionInput{ - activeAugment: augments.Effect{}, - passivesAugments: []augments.Effect{}, + triggeredAugment: augments.NoAugment, + passivesAugments: r.TeamA.Augments, player: playerA, roll: 0, }, teamB: TeamDecisionInput{ - activeAugment: augments.Effect{}, - passivesAugments: []augments.Effect{}, + triggeredAugment: augments.NoAugment, + passivesAugments: r.TeamB.Augments, player: playerB, roll: 0, }, @@ -154,6 +154,7 @@ func (s *TeamFormation) LanePop(lane int) (int64, error) { func (s *TeamFormation) FillLanes(f LanesConfig) { s.TeamID = f.TeamID + s.Augments = f.Augments remainder := s.LaneFill(0, f.Lane1, f.Players) remainder = s.LaneFill(1, f.Lane2, remainder) diff --git a/internal/games/types.go b/internal/games/types.go index 930fd69..d25ce7a 100644 --- a/internal/games/types.go +++ b/internal/games/types.go @@ -50,10 +50,11 @@ type Round struct { } type DuelResult struct { - Player int64 `json:"player"` - Outcome Outcome `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"` + TriggeredAugment augments.Name `json:"triggered_augment"` } type TeamStatistics struct { @@ -78,16 +79,18 @@ type TeamConfig struct { } type TeamFormation struct { - TeamID int64 `json:"team_id,omitempty"` - Lanes [3][]int64 `json:"lanes,omitempty"` + TeamID int64 `json:"team_id,omitempty"` + Augments []augments.Effect `json:"augments"` + Lanes [3][]int64 `json:"lanes,omitempty"` } type LanesConfig struct { - TeamID int64 `json:"team_id"` - Players []int64 `json:"players"` - Lane1 int `json:"lane_1"` - Lane2 int `json:"lane_2"` - Lane3 int `json:"lane_3"` + TeamID int64 `json:"team_id"` + Players []int64 `json:"players"` + Augments []augments.Effect `json:"augments"` + Lane1 int `json:"lane_1"` + Lane2 int `json:"lane_2"` + Lane3 int `json:"lane_3"` } type RoundResult struct { From bd60ae0197a3dd78037f61efb90cab03135fb7fc Mon Sep 17 00:00:00 2001 From: frag223 Date: Thu, 9 Jul 2026 22:19:33 +1000 Subject: [PATCH 03/25] adding augment pop --- internal/games/rounds.go | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/internal/games/rounds.go b/internal/games/rounds.go index 4b6f049..29eda4f 100644 --- a/internal/games/rounds.go +++ b/internal/games/rounds.go @@ -68,6 +68,7 @@ func (r *Round) calculateWinner(result []RoundResult) RoundResult { } func (r *Round) ResolveLane(lane int, rollFn RollStrategy) RoundResult { + for r.TeamA.LaneHasPlayers(lane) && r.TeamB.LaneHasPlayers(lane) { playerA := r.TeamA.LanePeak(lane) playerB := r.TeamB.LanePeak(lane) @@ -94,6 +95,8 @@ func (r *Round) ResolveLane(lane int, rollFn RollStrategy) RoundResult { } re := rollFn.Run(rollInput) + r.TeamB.Augments = popAugment(r.TeamB.Augments, re.TriggeredAugment) + r.TeamA.Augments = popAugment(r.TeamA.Augments, re.TriggeredAugment) for re.Outcome == Draw { re = rollFn.Run(rollInput) @@ -174,3 +177,17 @@ func (s *TeamFormation) LaneFill(lane int, players int, teamPlayers []int64) []i return remainder } + +func popAugment(list []augments.Effect, name augments.Name) []augments.Effect { + + newList := list + + for i, e := range newList { + if e.Name == name { + newList = append(newList[:i], newList[i+1:]...) + return newList + } + } + + return newList +} From f5e5f2c63d21e00f21d012b22b4221419c7fcca8 Mon Sep 17 00:00:00 2001 From: frag223 Date: Thu, 9 Jul 2026 22:31:33 +1000 Subject: [PATCH 04/25] augments are being applied --- internal/games/rounds.go | 6 ++++++ internal/games/types.go | 1 + 2 files changed, 7 insertions(+) diff --git a/internal/games/rounds.go b/internal/games/rounds.go index 29eda4f..f986223 100644 --- a/internal/games/rounds.go +++ b/internal/games/rounds.go @@ -95,11 +95,17 @@ func (r *Round) ResolveLane(lane int, rollFn RollStrategy) RoundResult { } re := rollFn.Run(rollInput) + + re.Lane = lane r.TeamB.Augments = popAugment(r.TeamB.Augments, re.TriggeredAugment) r.TeamA.Augments = popAugment(r.TeamA.Augments, re.TriggeredAugment) for re.Outcome == Draw { re = rollFn.Run(rollInput) + + re.Lane = lane + r.TeamB.Augments = popAugment(r.TeamB.Augments, re.TriggeredAugment) + r.TeamA.Augments = popAugment(r.TeamA.Augments, re.TriggeredAugment) } r.DuelResults = append(r.DuelResults, re) diff --git a/internal/games/types.go b/internal/games/types.go index d25ce7a..e39d1fd 100644 --- a/internal/games/types.go +++ b/internal/games/types.go @@ -55,6 +55,7 @@ type DuelResult struct { Roll int `json:"roll"` RollDelta int `json:"roll_delta"` TriggeredAugment augments.Name `json:"triggered_augment"` + Lane int `json:"lane"` } type TeamStatistics struct { From fe6cccc258e635c673064845c8e14a6d94b860a5 Mon Sep 17 00:00:00 2001 From: frag223 Date: Fri, 10 Jul 2026 07:08:40 +1000 Subject: [PATCH 05/25] extracting method --- internal/games/rounds.go | 106 +++++++++++++++++++++------------------ 1 file changed, 57 insertions(+), 49 deletions(-) diff --git a/internal/games/rounds.go b/internal/games/rounds.go index f986223..b3fc3d7 100644 --- a/internal/games/rounds.go +++ b/internal/games/rounds.go @@ -70,55 +70,8 @@ func (r *Round) calculateWinner(result []RoundResult) RoundResult { func (r *Round) ResolveLane(lane int, rollFn RollStrategy) RoundResult { for r.TeamA.LaneHasPlayers(lane) && r.TeamB.LaneHasPlayers(lane) { - playerA := r.TeamA.LanePeak(lane) - playerB := r.TeamB.LanePeak(lane) - - var lastRound *DuelResult - if len(r.DuelResults) > 0 { - lastRound = new(r.DuelResults[len(r.DuelResults)-1]) - } - - rollInput := DecisionInput{ - lastRound: lastRound, - teamA: TeamDecisionInput{ - triggeredAugment: augments.NoAugment, - passivesAugments: r.TeamA.Augments, - player: playerA, - roll: 0, - }, - teamB: TeamDecisionInput{ - triggeredAugment: augments.NoAugment, - passivesAugments: r.TeamB.Augments, - player: playerB, - roll: 0, - }, - } - - re := rollFn.Run(rollInput) - - re.Lane = lane - r.TeamB.Augments = popAugment(r.TeamB.Augments, re.TriggeredAugment) - r.TeamA.Augments = popAugment(r.TeamA.Augments, re.TriggeredAugment) - - for re.Outcome == Draw { - re = rollFn.Run(rollInput) - - re.Lane = lane - r.TeamB.Augments = popAugment(r.TeamB.Augments, re.TriggeredAugment) - r.TeamA.Augments = popAugment(r.TeamA.Augments, re.TriggeredAugment) - } - - r.DuelResults = append(r.DuelResults, re) - if re.Outcome == TeamA { - _, err := r.TeamB.LanePop(lane) - if errors.Is(err, ErrNoPlayer) { - break - } - } else { - _, err := r.TeamA.LanePop(lane) - if errors.Is(err, ErrNoPlayer) { - break - } + if err := r.executeDuels(lane, rollFn); err != nil { + break } } @@ -137,6 +90,61 @@ func (r *Round) ResolveLane(lane int, rollFn RollStrategy) RoundResult { } +func (r *Round) executeDuels(lane int, rollFn RollStrategy) error { + playerA := r.TeamA.LanePeak(lane) + playerB := r.TeamB.LanePeak(lane) + + var lastRound *DuelResult + if len(r.DuelResults) > 0 { + lastRound = new(r.DuelResults[len(r.DuelResults)-1]) + } + + rollInput := DecisionInput{ + lastRound: lastRound, + teamA: TeamDecisionInput{ + triggeredAugment: augments.NoAugment, + passivesAugments: r.TeamA.Augments, + player: playerA, + roll: 0, + }, + teamB: TeamDecisionInput{ + triggeredAugment: augments.NoAugment, + passivesAugments: r.TeamB.Augments, + player: playerB, + roll: 0, + }, + } + + re := rollFn.Run(rollInput) + + re.Lane = lane + r.TeamB.Augments = popAugment(r.TeamB.Augments, re.TriggeredAugment) + r.TeamA.Augments = popAugment(r.TeamA.Augments, re.TriggeredAugment) + + for re.Outcome == Draw { + re = rollFn.Run(rollInput) + + re.Lane = lane + r.TeamB.Augments = popAugment(r.TeamB.Augments, re.TriggeredAugment) + r.TeamA.Augments = popAugment(r.TeamA.Augments, re.TriggeredAugment) + } + + r.DuelResults = append(r.DuelResults, re) + if re.Outcome == TeamA { + _, err := r.TeamB.LanePop(lane) + if errors.Is(err, ErrNoPlayer) { + return ErrNoPlayer + } + } else { + _, err := r.TeamA.LanePop(lane) + if errors.Is(err, ErrNoPlayer) { + return ErrNoPlayer + } + } + + return nil +} + func (s *TeamFormation) LaneCount(lane int) int { return len(s.Lanes[lane]) } From 33f0e94aaf012f29b3d1b46b3571153c80843748 Mon Sep 17 00:00:00 2001 From: frag223 Date: Fri, 10 Jul 2026 07:10:01 +1000 Subject: [PATCH 06/25] cleanup --- internal/games/rounds.go | 1 - 1 file changed, 1 deletion(-) diff --git a/internal/games/rounds.go b/internal/games/rounds.go index b3fc3d7..716e05b 100644 --- a/internal/games/rounds.go +++ b/internal/games/rounds.go @@ -73,7 +73,6 @@ func (r *Round) ResolveLane(lane int, rollFn RollStrategy) RoundResult { if err := r.executeDuels(lane, rollFn); err != nil { break } - } if r.TeamA.LaneHasPlayers(lane) { From 6bd3be148c4782beafee2d6d17b45781217a8c50 Mon Sep 17 00:00:00 2001 From: frag223 Date: Fri, 10 Jul 2026 07:24:00 +1000 Subject: [PATCH 07/25] adding check --- internal/augments/augments.go | 2 +- internal/games/dice_roll.go | 12 ++++++++---- internal/games/dice_roll_test.go | 1 + 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/internal/augments/augments.go b/internal/augments/augments.go index 73230a0..0bfa734 100644 --- a/internal/augments/augments.go +++ b/internal/augments/augments.go @@ -21,7 +21,7 @@ var ( Overpower: { Name: Overpower, Category: CategoryOffense, - Type: TypeActive, + Type: TypePassive, Trigger: TriggerConditionBeforeRoll, Effect: "Gain +1 to your roll total this duel.", Intent: "Reliable low-variance push.", diff --git a/internal/games/dice_roll.go b/internal/games/dice_roll.go index 4fb9edc..d73dda4 100644 --- a/internal/games/dice_roll.go +++ b/internal/games/dice_roll.go @@ -38,7 +38,7 @@ func ruleTwistOfFate(input DecisionInput, roll RollFn) DecisionInput { switch input.lastRound.Outcome { case TeamB: - if hasPassiveAugment(input.teamA.passivesAugments, augments.TwistOfFate) { + if canTriggerAugment(input.teamA.triggeredAugment, input.teamA.passivesAugments, augments.TwistOfFate) { input.teamA.triggeredAugment = augments.TwistOfFate secondRoll := roll() @@ -48,7 +48,7 @@ func ruleTwistOfFate(input DecisionInput, roll RollFn) DecisionInput { } case TeamA: - if hasPassiveAugment(input.teamB.passivesAugments, augments.TwistOfFate) { + if canTriggerAugment(input.teamB.triggeredAugment, input.teamB.passivesAugments, augments.TwistOfFate) { input.teamB.triggeredAugment = augments.TwistOfFate secondRoll := roll() @@ -129,8 +129,12 @@ func makeDecision(input DecisionInput) DuelResult { } -func hasPassiveAugment(list []augments.Effect, name augments.Name) bool { - return slices.ContainsFunc(list, func(e augments.Effect) bool { +func canTriggerAugment(activeAugment augments.Name, list []augments.Effect, name augments.Name) bool { + noActiveAugment := activeAugment == augments.NoAugment || activeAugment == "" + + hasAugmentInList := slices.ContainsFunc(list, func(e augments.Effect) bool { return e.Name == name }) + + return noActiveAugment && hasAugmentInList } diff --git a/internal/games/dice_roll_test.go b/internal/games/dice_roll_test.go index 16fb59e..792c8d6 100644 --- a/internal/games/dice_roll_test.go +++ b/internal/games/dice_roll_test.go @@ -33,6 +33,7 @@ func TestRuleTwistOfFate(t *testing.T) { input := DecisionInput{ lastRound: &DuelResult{Outcome: TeamB}, teamA: TeamDecisionInput{ + triggeredAugment: augments.NoAugment, passivesAugments: []augments.Effect{{Name: augments.TwistOfFate}}, roll: 2, }, From ad9165d9688074958e15ca40cadf20e5292db7cd Mon Sep 17 00:00:00 2001 From: frag223 Date: Fri, 10 Jul 2026 07:50:36 +1000 Subject: [PATCH 08/25] adding more augments for offense --- internal/augments/augments.go | 2 +- internal/games/dice_roll.go | 36 +--- internal/games/dice_roll_rules.go | 83 +++++++++ internal/games/dice_roll_test.go | 214 ++++++++++-------------- internal/games/dice_rolls_rules_test.go | 130 ++++++++++++++ 5 files changed, 308 insertions(+), 157 deletions(-) create mode 100644 internal/games/dice_roll_rules.go create mode 100644 internal/games/dice_rolls_rules_test.go diff --git a/internal/augments/augments.go b/internal/augments/augments.go index 0bfa734..aa1edc1 100644 --- a/internal/augments/augments.go +++ b/internal/augments/augments.go @@ -22,7 +22,7 @@ var ( Name: Overpower, Category: CategoryOffense, Type: TypePassive, - Trigger: TriggerConditionBeforeRoll, + Trigger: TriggerConditionAfterRoll, Effect: "Gain +1 to your roll total this duel.", Intent: "Reliable low-variance push.", Action: ActionIncrease, diff --git a/internal/games/dice_roll.go b/internal/games/dice_roll.go index d73dda4..ffe8254 100644 --- a/internal/games/dice_roll.go +++ b/internal/games/dice_roll.go @@ -27,40 +27,6 @@ type DecisionInput struct { type DecisionEngineFunc func(input DecisionInput) DecisionInput -func RuleTwistOfFate(input DecisionInput) DecisionInput { - return ruleTwistOfFate(input, DiceRoll) -} - -func ruleTwistOfFate(input DecisionInput, roll RollFn) DecisionInput { - if input.lastRound == nil { - return input - } - - switch input.lastRound.Outcome { - case TeamB: - if canTriggerAugment(input.teamA.triggeredAugment, input.teamA.passivesAugments, augments.TwistOfFate) { - input.teamA.triggeredAugment = augments.TwistOfFate - - secondRoll := roll() - if input.teamA.roll < secondRoll { - input.teamA.roll = secondRoll - } - } - - case TeamA: - if canTriggerAugment(input.teamB.triggeredAugment, input.teamB.passivesAugments, augments.TwistOfFate) { - input.teamB.triggeredAugment = augments.TwistOfFate - - secondRoll := roll() - if input.teamB.roll < secondRoll { - input.teamB.roll = secondRoll - } - } - } - - return input -} - type Engine struct { beforeRoll []DecisionEngineFunc afterRoll []DecisionEngineFunc @@ -73,6 +39,8 @@ func NewDecisionEngine() *Engine { beforeRoll: []DecisionEngineFunc{}, afterRoll: []DecisionEngineFunc{ RuleTwistOfFate, + RuleOverPower, + RuleMomentumSurge, }, afterAugments: []DecisionEngineFunc{}, rollFn: DiceRoll, diff --git a/internal/games/dice_roll_rules.go b/internal/games/dice_roll_rules.go new file mode 100644 index 0000000..c6e8237 --- /dev/null +++ b/internal/games/dice_roll_rules.go @@ -0,0 +1,83 @@ +package games + +import "github.com/code-gorilla-au/rush/internal/augments" + +func RuleTwistOfFate(input DecisionInput) DecisionInput { + return ruleTwistOfFate(input, DiceRoll) +} + +func ruleTwistOfFate(input DecisionInput, roll RollFn) DecisionInput { + if input.lastRound == nil { + return input + } + + switch input.lastRound.Outcome { + case TeamB: + if canTriggerAugment(input.teamA.triggeredAugment, input.teamA.passivesAugments, augments.TwistOfFate) { + input.teamA.triggeredAugment = augments.TwistOfFate + + secondRoll := roll() + if input.teamA.roll < secondRoll { + input.teamA.roll = secondRoll + } + } + + case TeamA: + if canTriggerAugment(input.teamB.triggeredAugment, input.teamB.passivesAugments, augments.TwistOfFate) { + input.teamB.triggeredAugment = augments.TwistOfFate + + secondRoll := roll() + if input.teamB.roll < secondRoll { + input.teamB.roll = secondRoll + } + } + } + + return input +} + +func RuleOverPower(input DecisionInput) DecisionInput { + effect, ok := augments.Get(augments.Overpower) + if !ok { + return input + } + + if canTriggerAugment(input.teamA.triggeredAugment, input.teamA.passivesAugments, augments.Overpower) { + input.teamA.triggeredAugment = augments.Overpower + + input.teamA.roll += effect.Amount + } + + if canTriggerAugment(input.teamB.triggeredAugment, input.teamB.passivesAugments, augments.Overpower) { + input.teamB.triggeredAugment = augments.Overpower + + input.teamB.roll += effect.Amount + } + + return input +} + +func RuleMomentumSurge(input DecisionInput) DecisionInput { + effect, ok := augments.Get(augments.MomentumSurge) + if !ok { + return input + } + + if input.lastRound == nil { + return input + } + + if input.lastRound.Outcome == TeamA && canTriggerAugment(input.teamA.triggeredAugment, input.teamA.passivesAugments, augments.MomentumSurge) { + input.teamA.triggeredAugment = augments.MomentumSurge + + input.teamA.roll += effect.Amount + } + + if input.lastRound.Outcome == TeamB && canTriggerAugment(input.teamB.triggeredAugment, input.teamB.passivesAugments, augments.MomentumSurge) { + input.teamB.triggeredAugment = augments.MomentumSurge + + input.teamB.roll += effect.Amount + } + + return input +} diff --git a/internal/games/dice_roll_test.go b/internal/games/dice_roll_test.go index 792c8d6..aa92978 100644 --- a/internal/games/dice_roll_test.go +++ b/internal/games/dice_roll_test.go @@ -7,128 +7,6 @@ import ( "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{ - lastRound: &DuelResult{Outcome: Draw}, - teamA: TeamDecisionInput{ - triggeredAugment: augments.TwistOfFate, - roll: 1, - }, - teamB: TeamDecisionInput{ - triggeredAugment: 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{ - lastRound: &DuelResult{Outcome: TeamB}, - teamA: TeamDecisionInput{ - triggeredAugment: augments.NoAugment, - passivesAugments: []augments.Effect{{Name: augments.TwistOfFate}}, - roll: 2, - }, - teamB: TeamDecisionInput{ - roll: 3, - }, - } - - mockRoll := func() int { return 5 } - res := ruleTwistOfFate(input, mockRoll) - - odize.AssertEqual(t, 5, res.teamA.roll) - odize.AssertEqual(t, 3, res.teamB.roll) // Team B unchanged - }) - - group.Test("should not change roll for Team A when TwistOfFate provides a lower roll", func(t *testing.T) { - input := DecisionInput{ - lastRound: &DuelResult{Outcome: TeamB}, - teamA: TeamDecisionInput{ - passivesAugments: []augments.Effect{{Name: augments.TwistOfFate}}, - roll: 4, - }, - } - - mockRoll := func() int { return 2 } - res := ruleTwistOfFate(input, mockRoll) - - odize.AssertEqual(t, 4, res.teamA.roll) - }) - - group.Test("should increase roll for Team B when TwistOfFate provides a higher roll", func(t *testing.T) { - input := DecisionInput{ - lastRound: &DuelResult{Outcome: TeamA}, - teamB: TeamDecisionInput{ - passivesAugments: []augments.Effect{{Name: augments.TwistOfFate}}, - roll: 2, - }, - teamA: TeamDecisionInput{ - roll: 3, - }, - } - - mockRoll := func() int { return 5 } - res := ruleTwistOfFate(input, mockRoll) - - odize.AssertEqual(t, 5, res.teamB.roll) - odize.AssertEqual(t, 3, res.teamA.roll) // Team A unchanged - }) - - group.Test("should not change rolls when TwistOfFate augment is not present", func(t *testing.T) { - input := DecisionInput{ - lastRound: &DuelResult{Outcome: TeamB}, - teamA: TeamDecisionInput{ - triggeredAugment: "Some Other Effect", - roll: 3, - }, - teamB: TeamDecisionInput{ - roll: 4, - }, - } - - mockRoll := func() int { return 6 } - res := ruleTwistOfFate(input, mockRoll) - - odize.AssertEqual(t, 3, res.teamA.roll) - odize.AssertEqual(t, 4, res.teamB.roll) - }) - - group.Test("should handle both teams having TwistOfFate", func(t *testing.T) { - input := DecisionInput{ - lastRound: &DuelResult{Outcome: TeamB}, - teamA: TeamDecisionInput{ - passivesAugments: []augments.Effect{{Name: augments.TwistOfFate}}, - roll: 2, - }, - teamB: TeamDecisionInput{ - passivesAugments: []augments.Effect{{Name: augments.TwistOfFate}}, - roll: 3, - }, - } - - mockRoll := func() int { - return 6 - } - - res := ruleTwistOfFate(input, mockRoll) - - odize.AssertEqual(t, 6, res.teamA.roll) - odize.AssertEqual(t, 3, res.teamB.roll) - }) - - err := group.Run() - odize.AssertNoError(t, err) -} - func TestEngineRun(t *testing.T) { group := odize.NewGroup(t, nil) @@ -252,6 +130,98 @@ func TestEngineRun(t *testing.T) { odize.AssertNoError(t, err) } +func TestRules(t *testing.T) { + group := odize.NewGroup(t, nil) + + err := group. + Test("RuleTwistOfFate should do nothing if lastRound is nil", func(t *testing.T) { + input := DecisionInput{ + teamA: TeamDecisionInput{ + roll: 3, + passivesAugments: []augments.Effect{{Name: augments.TwistOfFate}}, + }, + } + result := ruleTwistOfFate(input, func() int { return 6 }) + odize.AssertEqual(t, 3, result.teamA.roll) + odize.AssertEqual(t, augments.Name(""), result.teamA.triggeredAugment) + }). + Test("RuleTwistOfFate should trigger for Team B if Team A won last round", func(t *testing.T) { + input := DecisionInput{ + lastRound: &DuelResult{Outcome: TeamA}, + teamB: TeamDecisionInput{ + roll: 2, + passivesAugments: []augments.Effect{{Name: augments.TwistOfFate}}, + }, + } + result := ruleTwistOfFate(input, func() int { return 5 }) + odize.AssertEqual(t, 5, result.teamB.roll) + odize.AssertEqual(t, augments.TwistOfFate, result.teamB.triggeredAugment) + }). + Test("RuleTwistOfFate should not update roll if second roll is lower", func(t *testing.T) { + input := DecisionInput{ + lastRound: &DuelResult{Outcome: TeamB}, + teamA: TeamDecisionInput{ + roll: 4, + passivesAugments: []augments.Effect{{Name: augments.TwistOfFate}}, + }, + } + result := ruleTwistOfFate(input, func() int { return 2 }) + odize.AssertEqual(t, 4, result.teamA.roll) + odize.AssertEqual(t, augments.TwistOfFate, result.teamA.triggeredAugment) + }). + Test("RuleOverPower should increase roll by 1", func(t *testing.T) { + input := DecisionInput{ + teamA: TeamDecisionInput{ + roll: 3, + passivesAugments: []augments.Effect{{Name: augments.Overpower}}, + }, + teamB: TeamDecisionInput{ + roll: 3, + passivesAugments: []augments.Effect{{Name: augments.Overpower}}, + }, + } + result := RuleOverPower(input) + odize.AssertEqual(t, 4, result.teamA.roll) + odize.AssertEqual(t, 4, result.teamB.roll) + odize.AssertEqual(t, augments.Overpower, result.teamA.triggeredAugment) + odize.AssertEqual(t, augments.Overpower, result.teamB.triggeredAugment) + }). + Test("RuleMomentumSurge should trigger only if won last round", func(t *testing.T) { + // Team A won last round + input := DecisionInput{ + lastRound: &DuelResult{Outcome: TeamA}, + teamA: TeamDecisionInput{ + roll: 3, + passivesAugments: []augments.Effect{{Name: augments.MomentumSurge}}, + }, + teamB: TeamDecisionInput{ + roll: 3, + passivesAugments: []augments.Effect{{Name: augments.MomentumSurge}}, + }, + } + result := RuleMomentumSurge(input) + odize.AssertEqual(t, 5, result.teamA.roll) // 3 + 2 + odize.AssertEqual(t, 3, result.teamB.roll) // No change for Team B + odize.AssertEqual(t, augments.MomentumSurge, result.teamA.triggeredAugment) + odize.AssertEqual(t, augments.Name(""), result.teamB.triggeredAugment) + + // Team B won last round + input.lastRound.Outcome = TeamB + input.teamA.triggeredAugment = "" + input.teamA.roll = 3 + input.teamB.triggeredAugment = "" + input.teamB.roll = 3 + result = RuleMomentumSurge(input) + odize.AssertEqual(t, 3, result.teamA.roll) // No change for Team A + odize.AssertEqual(t, 5, result.teamB.roll) // 3 + 2 + odize.AssertEqual(t, augments.Name(""), result.teamA.triggeredAugment) + odize.AssertEqual(t, augments.MomentumSurge, result.teamB.triggeredAugment) + }). + Run() + + odize.AssertNoError(t, err) +} + func newSequentialRollFn(rolls []int) RollFn { idx := 0 diff --git a/internal/games/dice_rolls_rules_test.go b/internal/games/dice_rolls_rules_test.go new file mode 100644 index 0000000..7cb4770 --- /dev/null +++ b/internal/games/dice_rolls_rules_test.go @@ -0,0 +1,130 @@ +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{ + lastRound: &DuelResult{Outcome: Draw}, + teamA: TeamDecisionInput{ + triggeredAugment: augments.TwistOfFate, + roll: 1, + }, + teamB: TeamDecisionInput{ + triggeredAugment: 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{ + lastRound: &DuelResult{Outcome: TeamB}, + teamA: TeamDecisionInput{ + triggeredAugment: augments.NoAugment, + passivesAugments: []augments.Effect{{Name: augments.TwistOfFate}}, + roll: 2, + }, + teamB: TeamDecisionInput{ + roll: 3, + }, + } + + mockRoll := func() int { return 5 } + res := ruleTwistOfFate(input, mockRoll) + + odize.AssertEqual(t, 5, res.teamA.roll) + odize.AssertEqual(t, 3, res.teamB.roll) // Team B unchanged + }) + + group.Test("should not change roll for Team A when TwistOfFate provides a lower roll", func(t *testing.T) { + input := DecisionInput{ + lastRound: &DuelResult{Outcome: TeamB}, + teamA: TeamDecisionInput{ + passivesAugments: []augments.Effect{{Name: augments.TwistOfFate}}, + roll: 4, + }, + } + + mockRoll := func() int { return 2 } + res := ruleTwistOfFate(input, mockRoll) + + odize.AssertEqual(t, 4, res.teamA.roll) + }) + + group.Test("should increase roll for Team B when TwistOfFate provides a higher roll", func(t *testing.T) { + input := DecisionInput{ + lastRound: &DuelResult{Outcome: TeamA}, + teamB: TeamDecisionInput{ + passivesAugments: []augments.Effect{{Name: augments.TwistOfFate}}, + roll: 2, + }, + teamA: TeamDecisionInput{ + roll: 3, + }, + } + + mockRoll := func() int { return 5 } + res := ruleTwistOfFate(input, mockRoll) + + odize.AssertEqual(t, 5, res.teamB.roll) + odize.AssertEqual(t, 3, res.teamA.roll) // Team A unchanged + }) + + group.Test("should not change rolls when TwistOfFate augment is not present", func(t *testing.T) { + input := DecisionInput{ + lastRound: &DuelResult{Outcome: TeamB}, + teamA: TeamDecisionInput{ + triggeredAugment: "Some Other Effect", + roll: 3, + }, + teamB: TeamDecisionInput{ + roll: 4, + }, + } + + mockRoll := func() int { return 6 } + res := ruleTwistOfFate(input, mockRoll) + + odize.AssertEqual(t, 3, res.teamA.roll) + odize.AssertEqual(t, 4, res.teamB.roll) + }) + + group.Test("should handle both teams having TwistOfFate", func(t *testing.T) { + input := DecisionInput{ + lastRound: &DuelResult{Outcome: TeamB}, + teamA: TeamDecisionInput{ + passivesAugments: []augments.Effect{{Name: augments.TwistOfFate}}, + roll: 2, + }, + teamB: TeamDecisionInput{ + passivesAugments: []augments.Effect{{Name: augments.TwistOfFate}}, + roll: 3, + }, + } + + mockRoll := func() int { + return 6 + } + + res := ruleTwistOfFate(input, mockRoll) + + odize.AssertEqual(t, 6, res.teamA.roll) + odize.AssertEqual(t, 3, res.teamB.roll) + }) + + err := group.Run() + odize.AssertNoError(t, err) +} From 97e1a52ab1fadff05f8a88611afbb03b9abe1fda Mon Sep 17 00:00:00 2001 From: frag223 Date: Fri, 10 Jul 2026 20:05:07 +1000 Subject: [PATCH 09/25] adding more augments for offense --- internal/augments/augments.go | 18 ++++++++++-------- internal/augments/augments_test.go | 2 +- internal/games/dice_roll.go | 3 ++- internal/games/dice_roll_rules.go | 21 +++++++++++++++++++++ internal/games/dice_roll_test.go | 17 +++++++++++++++++ internal/games/dice_rolls_rules_test.go | 8 ++++---- 6 files changed, 55 insertions(+), 14 deletions(-) diff --git a/internal/augments/augments.go b/internal/augments/augments.go index aa1edc1..f545ac8 100644 --- a/internal/augments/augments.go +++ b/internal/augments/augments.go @@ -12,11 +12,11 @@ var ( Category: CategoryOffense, Type: TypePassive, Trigger: TriggerConditionAfterRoll, - Effect: "If last round was a loss, roll 2d6 and keep the highest.", - Intent: "Front-load pressure in must-win lanes.", + Effect: "If last round was a loss, roll 2d6 keep highest.", + Intent: "Strong catch-up pressure in must-win rounds.", Action: ActionAddDie, Target: TargetSelf, - Amount: 1, + Amount: 0, }, Overpower: { Name: Overpower, @@ -24,7 +24,7 @@ var ( Type: TypePassive, Trigger: TriggerConditionAfterRoll, Effect: "Gain +1 to your roll total this duel.", - Intent: "Reliable low-variance push.", + Intent: "Reliable low-variance baseline.", Action: ActionIncrease, Target: TargetSelf, Amount: 1, @@ -32,11 +32,13 @@ var ( PrecisionStrike: { Name: PrecisionStrike, Category: CategoryOffense, - Type: TypeActive, - Effect: "Add +1 to your revealed total.", - Intent: "Skill-expression token for close reads.", + Type: TypePassive, + Trigger: TriggerConditionAfterRoll, + Effect: "If you roll a 4 or higher, gain +2 to your total.", + Intent: "High-variance reward for strong rolls.", Action: ActionIncrease, Target: TargetSelf, + Amount: 2, }, MomentumSurge: { Name: MomentumSurge, @@ -44,7 +46,7 @@ var ( Type: TypePassive, Trigger: TriggerConditionAfterRoll, Effect: "If last duel was a win, gain +2 this duel.", - Intent: "Snowball option with explicit condition gate.", + Intent: "Snowball option to maintain tempo.", Action: ActionIncrease, Target: TargetSelf, Amount: 2, diff --git a/internal/augments/augments_test.go b/internal/augments/augments_test.go index a04c1a9..61a7d8b 100644 --- a/internal/augments/augments_test.go +++ b/internal/augments/augments_test.go @@ -47,7 +47,7 @@ func TestGet(t *testing.T) { odize.AssertTrue(t, ok) odize.AssertEqual(t, TwistOfFate, effect.Name) odize.AssertEqual(t, ActionAddDie, effect.Action) - odize.AssertEqual(t, 1, effect.Amount) + odize.AssertEqual(t, 0, effect.Amount) }). Test("should return empty effect and false for non-existent token", func(t *testing.T) { effect, ok := Get(Name("NonExistent")) diff --git a/internal/games/dice_roll.go b/internal/games/dice_roll.go index ffe8254..9d5783f 100644 --- a/internal/games/dice_roll.go +++ b/internal/games/dice_roll.go @@ -39,8 +39,9 @@ func NewDecisionEngine() *Engine { beforeRoll: []DecisionEngineFunc{}, afterRoll: []DecisionEngineFunc{ RuleTwistOfFate, - RuleOverPower, RuleMomentumSurge, + RulePrecisionStrike, + RuleOverPower, }, afterAugments: []DecisionEngineFunc{}, rollFn: DiceRoll, diff --git a/internal/games/dice_roll_rules.go b/internal/games/dice_roll_rules.go index c6e8237..5dfc7ec 100644 --- a/internal/games/dice_roll_rules.go +++ b/internal/games/dice_roll_rules.go @@ -36,6 +36,27 @@ func ruleTwistOfFate(input DecisionInput, roll RollFn) DecisionInput { return input } +func RulePrecisionStrike(input DecisionInput) DecisionInput { + effect, ok := augments.Get(augments.PrecisionStrike) + if !ok { + return input + } + + if input.teamA.roll >= 4 && canTriggerAugment(input.teamA.triggeredAugment, input.teamA.passivesAugments, augments.PrecisionStrike) { + input.teamA.triggeredAugment = augments.PrecisionStrike + + input.teamA.roll += effect.Amount + } + + if input.teamB.roll >= 4 && canTriggerAugment(input.teamB.triggeredAugment, input.teamB.passivesAugments, augments.PrecisionStrike) { + input.teamB.triggeredAugment = augments.PrecisionStrike + + input.teamB.roll += effect.Amount + } + + return input +} + func RuleOverPower(input DecisionInput) DecisionInput { effect, ok := augments.Get(augments.Overpower) if !ok { diff --git a/internal/games/dice_roll_test.go b/internal/games/dice_roll_test.go index aa92978..5408b2a 100644 --- a/internal/games/dice_roll_test.go +++ b/internal/games/dice_roll_test.go @@ -217,6 +217,23 @@ func TestRules(t *testing.T) { odize.AssertEqual(t, augments.Name(""), result.teamA.triggeredAugment) odize.AssertEqual(t, augments.MomentumSurge, result.teamB.triggeredAugment) }). + Test("RulePrecisionStrike should increase roll by 2 if roll >= 4", func(t *testing.T) { + input := DecisionInput{ + teamA: TeamDecisionInput{ + roll: 4, + passivesAugments: []augments.Effect{{Name: augments.PrecisionStrike}}, + }, + teamB: TeamDecisionInput{ + roll: 3, + passivesAugments: []augments.Effect{{Name: augments.PrecisionStrike}}, + }, + } + result := RulePrecisionStrike(input) + odize.AssertEqual(t, 6, result.teamA.roll) // 4 + 2 + odize.AssertEqual(t, 3, result.teamB.roll) // No change because 3 < 4 + odize.AssertEqual(t, augments.PrecisionStrike, result.teamA.triggeredAugment) + odize.AssertEqual(t, augments.Name(""), result.teamB.triggeredAugment) + }). Run() odize.AssertNoError(t, err) diff --git a/internal/games/dice_rolls_rules_test.go b/internal/games/dice_rolls_rules_test.go index 7cb4770..f76402d 100644 --- a/internal/games/dice_rolls_rules_test.go +++ b/internal/games/dice_rolls_rules_test.go @@ -45,7 +45,7 @@ func TestRuleTwistOfFate(t *testing.T) { mockRoll := func() int { return 5 } res := ruleTwistOfFate(input, mockRoll) - odize.AssertEqual(t, 5, res.teamA.roll) + odize.AssertEqual(t, 5, res.teamA.roll) // max(2, 5) + 1 odize.AssertEqual(t, 3, res.teamB.roll) // Team B unchanged }) @@ -61,7 +61,7 @@ func TestRuleTwistOfFate(t *testing.T) { mockRoll := func() int { return 2 } res := ruleTwistOfFate(input, mockRoll) - odize.AssertEqual(t, 4, res.teamA.roll) + odize.AssertEqual(t, 4, res.teamA.roll) // max(4, 2) + 1 }) group.Test("should increase roll for Team B when TwistOfFate provides a higher roll", func(t *testing.T) { @@ -79,7 +79,7 @@ func TestRuleTwistOfFate(t *testing.T) { mockRoll := func() int { return 5 } res := ruleTwistOfFate(input, mockRoll) - odize.AssertEqual(t, 5, res.teamB.roll) + odize.AssertEqual(t, 5, res.teamB.roll) // max(2, 5) + 1 odize.AssertEqual(t, 3, res.teamA.roll) // Team A unchanged }) @@ -121,7 +121,7 @@ func TestRuleTwistOfFate(t *testing.T) { res := ruleTwistOfFate(input, mockRoll) - odize.AssertEqual(t, 6, res.teamA.roll) + odize.AssertEqual(t, 6, res.teamA.roll) // max(2, 6) + 1 odize.AssertEqual(t, 3, res.teamB.roll) }) From bd5a2a9db49eb4909fb2d7e5225d0a66ae050748 Mon Sep 17 00:00:00 2001 From: frag223 Date: Fri, 10 Jul 2026 20:05:48 +1000 Subject: [PATCH 10/25] removing useless comments --- internal/games/dice_rolls_rules_test.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/internal/games/dice_rolls_rules_test.go b/internal/games/dice_rolls_rules_test.go index f76402d..7cb4770 100644 --- a/internal/games/dice_rolls_rules_test.go +++ b/internal/games/dice_rolls_rules_test.go @@ -45,7 +45,7 @@ func TestRuleTwistOfFate(t *testing.T) { mockRoll := func() int { return 5 } res := ruleTwistOfFate(input, mockRoll) - odize.AssertEqual(t, 5, res.teamA.roll) // max(2, 5) + 1 + odize.AssertEqual(t, 5, res.teamA.roll) odize.AssertEqual(t, 3, res.teamB.roll) // Team B unchanged }) @@ -61,7 +61,7 @@ func TestRuleTwistOfFate(t *testing.T) { mockRoll := func() int { return 2 } res := ruleTwistOfFate(input, mockRoll) - odize.AssertEqual(t, 4, res.teamA.roll) // max(4, 2) + 1 + 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) { @@ -79,7 +79,7 @@ func TestRuleTwistOfFate(t *testing.T) { mockRoll := func() int { return 5 } res := ruleTwistOfFate(input, mockRoll) - odize.AssertEqual(t, 5, res.teamB.roll) // max(2, 5) + 1 + odize.AssertEqual(t, 5, res.teamB.roll) odize.AssertEqual(t, 3, res.teamA.roll) // Team A unchanged }) @@ -121,7 +121,7 @@ func TestRuleTwistOfFate(t *testing.T) { res := ruleTwistOfFate(input, mockRoll) - odize.AssertEqual(t, 6, res.teamA.roll) // max(2, 6) + 1 + odize.AssertEqual(t, 6, res.teamA.roll) odize.AssertEqual(t, 3, res.teamB.roll) }) From 6cbc88ab615074016fe5b5869e490040faac888e Mon Sep 17 00:00:00 2001 From: frag223 Date: Fri, 10 Jul 2026 20:30:24 +1000 Subject: [PATCH 11/25] adding renderings --- internal/teams/{tokens.go => persona.go} | 4 ++++ internal/teams/{tokens_test.go => persona_test.go} | 0 internal/ui/components/ai_team_list.go | 4 +++- internal/ui/components/team_tile.go | 5 ++++- internal/ui/components/team_tile_test.go | 4 ++-- internal/ui/uibattle/page_battle_confirm.go | 4 +++- 6 files changed, 16 insertions(+), 5 deletions(-) rename internal/teams/{tokens.go => persona.go} (91%) rename internal/teams/{tokens_test.go => persona_test.go} (100%) diff --git a/internal/teams/tokens.go b/internal/teams/persona.go similarity index 91% rename from internal/teams/tokens.go rename to internal/teams/persona.go index 185e19d..7f8c62f 100644 --- a/internal/teams/tokens.go +++ b/internal/teams/persona.go @@ -11,6 +11,10 @@ var _coachPersonaTokens = map[CoachPersona]augments.Category{ CoachPersonaWildcard: augments.CategoryOffense, } +func (p CoachPersona) Name() string { + return string(p) +} + func (p CoachPersona) Augments() []augments.Effect { aug, ok := _coachPersonaTokens[p] if !ok { diff --git a/internal/teams/tokens_test.go b/internal/teams/persona_test.go similarity index 100% rename from internal/teams/tokens_test.go rename to internal/teams/persona_test.go diff --git a/internal/ui/components/ai_team_list.go b/internal/ui/components/ai_team_list.go index 24e965e..268b6f4 100644 --- a/internal/ui/components/ai_team_list.go +++ b/internal/ui/components/ai_team_list.go @@ -1,6 +1,8 @@ package components import ( + "fmt" + tea "charm.land/bubbletea/v2" "github.com/code-gorilla-au/rush/internal/teams" "github.com/code-gorilla-au/rush/internal/ui/styles" @@ -18,7 +20,7 @@ func NewAITeamList(items []teams.AITeam, theme styles.IceTheme) AITeamList { return ListItem[teams.AITeam]{ Data: item, TitleVal: item.Team.Name, - DescVal: item.Coach.Name, + DescVal: fmt.Sprintf("%s (%s)", item.Coach.Name, item.Coach.Persona.Name()), FilterVal: item.Team.Name, } }, diff --git a/internal/ui/components/team_tile.go b/internal/ui/components/team_tile.go index b805421..0e9909c 100644 --- a/internal/ui/components/team_tile.go +++ b/internal/ui/components/team_tile.go @@ -11,14 +11,16 @@ import ( type TeamTile struct { TeamName string CoachName string + Persona string PlaybookName string PlayerNames []string } -func NewTeamTile(teamName, coachName, playbookName string, playerNames []string) TeamTile { +func NewTeamTile(teamName, coachName, persona, playbookName string, playerNames []string) TeamTile { return TeamTile{ TeamName: teamName, CoachName: coachName, + Persona: persona, PlaybookName: playbookName, PlayerNames: slices.Clone(playerNames), } @@ -29,6 +31,7 @@ func (t TeamTile) View(theme styles.IceTheme, width int) string { theme.SecondaryHeader.Render(valueOrDefault(t.TeamName, "Unknown Team")), renderField(theme, "Coach", t.CoachName), renderField(theme, "Playbook", t.PlaybookName), + renderField(theme, "Persona", t.Persona), "", theme.Muted.Render("Players"), renderPlayers(theme, t.PlayerNames), diff --git a/internal/ui/components/team_tile_test.go b/internal/ui/components/team_tile_test.go index d643ccb..36080ad 100644 --- a/internal/ui/components/team_tile_test.go +++ b/internal/ui/components/team_tile_test.go @@ -14,7 +14,7 @@ func TestTeamTile(t *testing.T) { 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"}) + tile := NewTeamTile("Frost Wolves", "", "Coach Ice", "North Formation", []string{"Alice", "Bob"}) rendered := tile.View(styles.NewIceTheme(), 40) @@ -25,7 +25,7 @@ func TestTeamTile(t *testing.T) { odize.AssertTrue(t, strings.Contains(rendered, "Bob")) }). Test("View should show fallback values when data is empty", func(t *testing.T) { - tile := NewTeamTile("", "", "", []string{"", " "}) + tile := NewTeamTile("", "", "", "", []string{"", " "}) rendered := tile.View(styles.NewIceTheme(), 30) diff --git a/internal/ui/uibattle/page_battle_confirm.go b/internal/ui/uibattle/page_battle_confirm.go index b6f1cf6..e6f2bf1 100644 --- a/internal/ui/uibattle/page_battle_confirm.go +++ b/internal/ui/uibattle/page_battle_confirm.go @@ -208,6 +208,7 @@ func newTeamTile(team *teams.Team, coach *teams.Coach, playbook *playbooks.Playb teamName := "" coachName := "" playbookName := "" + persona := "" var playerNames []string if team != nil { @@ -217,13 +218,14 @@ func newTeamTile(team *teams.Team, coach *teams.Coach, playbook *playbooks.Playb if coach != nil { coachName = coach.Name + persona = coach.Persona.Name() } if playbook != nil { playbookName = playbook.Name } - return components.NewTeamTile(teamName, coachName, playbookName, playerNames) + return components.NewTeamTile(teamName, coachName, persona, playbookName, playerNames) } func getPlayerNames(players []teams.Player) []string { From ef40a2a71a11911bb2b9f477a8162301f23fc4bb Mon Sep 17 00:00:00 2001 From: frag223 Date: Fri, 10 Jul 2026 22:32:17 +1000 Subject: [PATCH 12/25] adding augments --- internal/augments/augments.go | 6 +- internal/games/dice_roll.go | 7 +- internal/games/dice_roll_rules.go | 72 +++++++++++++++- internal/games/dice_rolls_rules_test.go | 110 ++++++++++++++++++++++++ 4 files changed, 189 insertions(+), 6 deletions(-) diff --git a/internal/augments/augments.go b/internal/augments/augments.go index f545ac8..92ba3e8 100644 --- a/internal/augments/augments.go +++ b/internal/augments/augments.go @@ -58,7 +58,7 @@ var ( Name: Brace, Category: CategoryDefense, Type: TypeActive, - Trigger: TriggerConditionAfterRoll, + Trigger: TriggerConditionAfterAugments, Effect: "Losing roll by 1, convert result to a tie.", Intent: "Stabilize critical moments after a close loss", Action: ActionResultTie, @@ -70,11 +70,11 @@ var ( Category: CategoryDefense, Type: TypePassive, Trigger: TriggerConditionAfterRoll, - Effect: "If last duel was a tie, gain +1 to roll", + Effect: "If last duel was a tie, gain +2 to roll", Intent: "Turn the tides on opponent momentum", Action: ActionIncrease, Target: TargetSelf, - Amount: 1, + Amount: 2, }, SecondChance: { Name: SecondChance, diff --git a/internal/games/dice_roll.go b/internal/games/dice_roll.go index 9d5783f..0cdfad2 100644 --- a/internal/games/dice_roll.go +++ b/internal/games/dice_roll.go @@ -42,9 +42,12 @@ func NewDecisionEngine() *Engine { RuleMomentumSurge, RulePrecisionStrike, RuleOverPower, + RuleFortify, }, - afterAugments: []DecisionEngineFunc{}, - rollFn: DiceRoll, + afterAugments: []DecisionEngineFunc{ + RuleBrace, + }, + rollFn: DiceRoll, } } diff --git a/internal/games/dice_roll_rules.go b/internal/games/dice_roll_rules.go index 5dfc7ec..a3b95dd 100644 --- a/internal/games/dice_roll_rules.go +++ b/internal/games/dice_roll_rules.go @@ -1,6 +1,10 @@ package games -import "github.com/code-gorilla-au/rush/internal/augments" +import ( + "math" + + "github.com/code-gorilla-au/rush/internal/augments" +) func RuleTwistOfFate(input DecisionInput) DecisionInput { return ruleTwistOfFate(input, DiceRoll) @@ -102,3 +106,69 @@ func RuleMomentumSurge(input DecisionInput) DecisionInput { return input } + +func RuleBrace(input DecisionInput) DecisionInput { + effect, ok := augments.Get(augments.Brace) + if !ok { + return input + } + + rollDelta := math.Abs(float64(input.teamA.roll) - float64(input.teamB.roll)) + lostByOne := rollDelta == 1 + + if !lostByOne { + return input + } + + if canTriggerAugment( + input.teamA.triggeredAugment, + input.teamA.passivesAugments, + augments.MomentumSurge, + ) { + if input.teamA.roll < input.teamB.roll { + input.teamA.triggeredAugment = augments.Brace + input.teamA.roll = effect.Amount + input.teamB.roll = effect.Amount + } + + } + + if canTriggerAugment( + input.teamB.triggeredAugment, + input.teamB.passivesAugments, + augments.MomentumSurge, + ) { + if input.teamB.roll < input.teamA.roll { + input.teamB.triggeredAugment = augments.Brace + input.teamA.roll = effect.Amount + input.teamB.roll = effect.Amount + } + } + + return input +} + +func RuleFortify(input DecisionInput) DecisionInput { + effect, ok := augments.Get(augments.Fortify) + if !ok { + return input + } + + if input.lastRound == nil { + return input + } + + if input.lastRound.Outcome == Draw { + if canTriggerAugment(input.teamA.triggeredAugment, input.teamA.passivesAugments, augments.Brace) { + input.teamA.triggeredAugment = augments.Fortify + input.teamA.roll += effect.Amount + } + + if canTriggerAugment(input.teamB.triggeredAugment, input.teamB.passivesAugments, augments.Brace) { + input.teamB.triggeredAugment = augments.Fortify + input.teamB.roll += effect.Amount + } + } + + return input +} diff --git a/internal/games/dice_rolls_rules_test.go b/internal/games/dice_rolls_rules_test.go index 7cb4770..f9660bd 100644 --- a/internal/games/dice_rolls_rules_test.go +++ b/internal/games/dice_rolls_rules_test.go @@ -128,3 +128,113 @@ func TestRuleTwistOfFate(t *testing.T) { err := group.Run() odize.AssertNoError(t, err) } + +func TestRuleBrace(t *testing.T) { + group := odize.NewGroup(t, nil) + + group.Test("should not trigger if Brace augment is not configured", func(t *testing.T) { + input := DecisionInput{ + teamA: TeamDecisionInput{roll: 2}, + teamB: TeamDecisionInput{roll: 3}, + } + + res := RuleBrace(input) + odize.AssertEqual(t, 2, res.teamA.roll) + odize.AssertEqual(t, 3, res.teamB.roll) + }) + + group.Test("should not trigger if roll delta != 1", func(t *testing.T) { + input := DecisionInput{ + teamA: TeamDecisionInput{roll: 2}, + teamB: TeamDecisionInput{roll: 4}, + } + + res := RuleBrace(input) + odize.AssertEqual(t, 2, res.teamA.roll) + odize.AssertEqual(t, 4, res.teamB.roll) + }) + + group.Test("should trigger if Team A loses by 1 and has MomentumSurge", func(t *testing.T) { + input := DecisionInput{ + teamA: TeamDecisionInput{ + roll: 2, + passivesAugments: []augments.Effect{{Name: augments.MomentumSurge}}, + }, + teamB: TeamDecisionInput{ + roll: 3, + }, + } + + res := RuleBrace(input) + + odize.AssertEqual(t, 0, res.teamA.roll) + odize.AssertEqual(t, 0, res.teamB.roll) + odize.AssertEqual(t, augments.Brace, res.teamA.triggeredAugment) + }) + + group.Test("should trigger if Team B loses by 1 and has MomentumSurge", func(t *testing.T) { + input := DecisionInput{ + teamA: TeamDecisionInput{ + roll: 3, + }, + teamB: TeamDecisionInput{ + roll: 2, + passivesAugments: []augments.Effect{{Name: augments.MomentumSurge}}, + }, + } + + res := RuleBrace(input) + odize.AssertEqual(t, 0, res.teamA.roll) + odize.AssertEqual(t, 0, res.teamB.roll) + odize.AssertEqual(t, augments.Brace, res.teamB.triggeredAugment) + }) + + err := group.Run() + odize.AssertNoError(t, err) +} + +func TestRuleFortify(t *testing.T) { + group := odize.NewGroup(t, nil) + + group.Test("should not trigger if last round is not Draw", func(t *testing.T) { + input := DecisionInput{ + lastRound: &DuelResult{Outcome: TeamA}, + teamA: TeamDecisionInput{ + passivesAugments: []augments.Effect{{Name: augments.Brace}}, + }, + } + res := RuleFortify(input) + odize.AssertEqual(t, augments.Name(""), res.teamA.triggeredAugment) + }) + + group.Test("should trigger if Team A has Brace and last round was Draw", func(t *testing.T) { + input := DecisionInput{ + lastRound: &DuelResult{Outcome: Draw}, + teamA: TeamDecisionInput{ + roll: 3, + passivesAugments: []augments.Effect{{Name: augments.Brace}}, + }, + } + res := RuleFortify(input) + odize.AssertEqual(t, augments.Fortify, res.teamA.triggeredAugment) + // Brace effect Amount is 0, but current implementation adds 1 + odize.AssertEqual(t, 4, res.teamA.roll) + }) + + group.Test("should trigger if Team B has Brace and last round was Draw", func(t *testing.T) { + input := DecisionInput{ + lastRound: &DuelResult{Outcome: Draw}, + teamB: TeamDecisionInput{ + roll: 3, + passivesAugments: []augments.Effect{{Name: augments.Brace}}, + }, + } + res := RuleFortify(input) + odize.AssertEqual(t, augments.Fortify, res.teamB.triggeredAugment) + // Brace effect Amount is 0, but current implementation adds 1 + odize.AssertEqual(t, 4, res.teamB.roll) + }) + + err := group.Run() + odize.AssertNoError(t, err) +} From 5781eab2989eac2f3de430e2e3353d3ad79231ee Mon Sep 17 00:00:00 2001 From: frag223 Date: Sat, 11 Jul 2026 19:45:31 +1000 Subject: [PATCH 13/25] adding brace + small changes --- internal/augments/augments.go | 2 +- internal/games/dice_roll.go | 1 + internal/games/dice_roll_rules.go | 32 ++++++- ..._rules_test.go => dice_roll_rules_test.go} | 83 +++++++++++++++++-- 4 files changed, 110 insertions(+), 8 deletions(-) rename internal/games/{dice_rolls_rules_test.go => dice_roll_rules_test.go} (69%) diff --git a/internal/augments/augments.go b/internal/augments/augments.go index 92ba3e8..c481568 100644 --- a/internal/augments/augments.go +++ b/internal/augments/augments.go @@ -85,7 +85,7 @@ var ( Intent: "Stabilize critical moments after a miss or tie.", Action: ActionReRoll, Target: TargetSelf, - Amount: 1, + Amount: 0, }, LastStand: { Name: LastStand, diff --git a/internal/games/dice_roll.go b/internal/games/dice_roll.go index 0cdfad2..03bcaf8 100644 --- a/internal/games/dice_roll.go +++ b/internal/games/dice_roll.go @@ -43,6 +43,7 @@ func NewDecisionEngine() *Engine { RulePrecisionStrike, RuleOverPower, RuleFortify, + RuleSecondChance, }, afterAugments: []DecisionEngineFunc{ RuleBrace, diff --git a/internal/games/dice_roll_rules.go b/internal/games/dice_roll_rules.go index a3b95dd..d9c19ae 100644 --- a/internal/games/dice_roll_rules.go +++ b/internal/games/dice_roll_rules.go @@ -159,12 +159,12 @@ func RuleFortify(input DecisionInput) DecisionInput { } if input.lastRound.Outcome == Draw { - if canTriggerAugment(input.teamA.triggeredAugment, input.teamA.passivesAugments, augments.Brace) { + if canTriggerAugment(input.teamA.triggeredAugment, input.teamA.passivesAugments, augments.Fortify) { input.teamA.triggeredAugment = augments.Fortify input.teamA.roll += effect.Amount } - if canTriggerAugment(input.teamB.triggeredAugment, input.teamB.passivesAugments, augments.Brace) { + if canTriggerAugment(input.teamB.triggeredAugment, input.teamB.passivesAugments, augments.Fortify) { input.teamB.triggeredAugment = augments.Fortify input.teamB.roll += effect.Amount } @@ -172,3 +172,31 @@ func RuleFortify(input DecisionInput) DecisionInput { return input } + +func RuleSecondChance(input DecisionInput) DecisionInput { + return ruleSecondChance(input, DiceRoll) +} + +func ruleSecondChance(input DecisionInput, rollFn RollFn) DecisionInput { + + aRoll := input.teamA.roll + bRoll := input.teamB.roll + + if aRoll == bRoll { + return input + } + + if aRoll < bRoll { + if canTriggerAugment(input.teamA.triggeredAugment, input.teamA.passivesAugments, augments.SecondChance) { + input.teamA.triggeredAugment = augments.SecondChance + input.teamA.roll = rollFn() + } + } else { + if canTriggerAugment(input.teamB.triggeredAugment, input.teamB.passivesAugments, augments.SecondChance) { + input.teamB.triggeredAugment = augments.SecondChance + input.teamB.roll = rollFn() + } + } + + return input +} diff --git a/internal/games/dice_rolls_rules_test.go b/internal/games/dice_roll_rules_test.go similarity index 69% rename from internal/games/dice_rolls_rules_test.go rename to internal/games/dice_roll_rules_test.go index f9660bd..d703f96 100644 --- a/internal/games/dice_rolls_rules_test.go +++ b/internal/games/dice_roll_rules_test.go @@ -212,13 +212,13 @@ func TestRuleFortify(t *testing.T) { lastRound: &DuelResult{Outcome: Draw}, teamA: TeamDecisionInput{ roll: 3, - passivesAugments: []augments.Effect{{Name: augments.Brace}}, + passivesAugments: []augments.Effect{{Name: augments.Fortify}}, }, } res := RuleFortify(input) odize.AssertEqual(t, augments.Fortify, res.teamA.triggeredAugment) - // Brace effect Amount is 0, but current implementation adds 1 - odize.AssertEqual(t, 4, res.teamA.roll) + + odize.AssertEqual(t, 5, res.teamA.roll) }) group.Test("should trigger if Team B has Brace and last round was Draw", func(t *testing.T) { @@ -226,13 +226,86 @@ func TestRuleFortify(t *testing.T) { lastRound: &DuelResult{Outcome: Draw}, teamB: TeamDecisionInput{ roll: 3, - passivesAugments: []augments.Effect{{Name: augments.Brace}}, + passivesAugments: []augments.Effect{{Name: augments.Fortify}}, }, } res := RuleFortify(input) odize.AssertEqual(t, augments.Fortify, res.teamB.triggeredAugment) // Brace effect Amount is 0, but current implementation adds 1 - odize.AssertEqual(t, 4, res.teamB.roll) + odize.AssertEqual(t, 5, res.teamB.roll) + }) + + err := group.Run() + odize.AssertNoError(t, err) +} + +func TestRuleSecondChance(t *testing.T) { + group := odize.NewGroup(t, nil) + + group.Test("does nothing if rolls are equal", func(t *testing.T) { + input := DecisionInput{ + teamA: TeamDecisionInput{roll: 5}, + teamB: TeamDecisionInput{roll: 5}, + } + result := ruleSecondChance(input, func() int { return 6 }) + odize.AssertEqual(t, 5, result.teamA.roll) + odize.AssertEqual(t, 5, result.teamB.roll) + }) + + group.Test("triggers SecondChance for teamA when teamA loses and has augment", func(t *testing.T) { + input := DecisionInput{ + teamA: TeamDecisionInput{ + roll: 2, + passivesAugments: []augments.Effect{{Name: augments.SecondChance}}, + triggeredAugment: augments.NoAugment, + }, + teamB: TeamDecisionInput{roll: 5}, + } + result := ruleSecondChance(input, func() int { return 6 }) + odize.AssertEqual(t, 6, result.teamA.roll) + odize.AssertEqual(t, augments.SecondChance, result.teamA.triggeredAugment) + }) + + group.Test("does not trigger SecondChance for teamA when teamA loses but does not have augment", func(t *testing.T) { + input := DecisionInput{ + teamA: TeamDecisionInput{ + roll: 2, + passivesAugments: []augments.Effect{}, + triggeredAugment: augments.NoAugment, + }, + teamB: TeamDecisionInput{roll: 5}, + } + result := ruleSecondChance(input, func() int { return 6 }) + odize.AssertEqual(t, 2, result.teamA.roll) + odize.AssertEqual(t, augments.NoAugment, result.teamA.triggeredAugment) + }) + + group.Test("triggers SecondChance for teamB when teamB loses and has augment", func(t *testing.T) { + input := DecisionInput{ + teamA: TeamDecisionInput{roll: 5}, + teamB: TeamDecisionInput{ + roll: 2, + passivesAugments: []augments.Effect{{Name: augments.SecondChance}}, + triggeredAugment: augments.NoAugment, + }, + } + result := ruleSecondChance(input, func() int { return 6 }) + odize.AssertEqual(t, 6, result.teamB.roll) + odize.AssertEqual(t, augments.SecondChance, result.teamB.triggeredAugment) + }) + + group.Test("does not trigger SecondChance for teamB when teamB loses but does not have augment", func(t *testing.T) { + input := DecisionInput{ + teamA: TeamDecisionInput{roll: 5}, + teamB: TeamDecisionInput{ + roll: 2, + passivesAugments: []augments.Effect{}, + triggeredAugment: augments.NoAugment, + }, + } + result := ruleSecondChance(input, func() int { return 6 }) + odize.AssertEqual(t, 2, result.teamB.roll) + odize.AssertEqual(t, augments.NoAugment, result.teamB.triggeredAugment) }) err := group.Run() From 09fe76855c32e4dddd24472cc11d215154c442c4 Mon Sep 17 00:00:00 2001 From: frag223 Date: Sat, 11 Jul 2026 20:04:47 +1000 Subject: [PATCH 14/25] balancing the game --- internal/augments/augments.go | 10 ++++---- internal/games/dice_roll.go | 1 + internal/games/dice_roll_rules.go | 39 ++++++++++++++++++++++++++----- internal/games/dice_roll_test.go | 10 ++++---- 4 files changed, 44 insertions(+), 16 deletions(-) diff --git a/internal/augments/augments.go b/internal/augments/augments.go index c481568..04c004b 100644 --- a/internal/augments/augments.go +++ b/internal/augments/augments.go @@ -34,22 +34,22 @@ var ( Category: CategoryOffense, Type: TypePassive, Trigger: TriggerConditionAfterRoll, - Effect: "If you roll a 4 or higher, gain +2 to your total.", + Effect: "If you roll a 4 or higher, gain +1 to your total.", Intent: "High-variance reward for strong rolls.", Action: ActionIncrease, Target: TargetSelf, - Amount: 2, + Amount: 1, }, MomentumSurge: { Name: MomentumSurge, Category: CategoryOffense, Type: TypePassive, Trigger: TriggerConditionAfterRoll, - Effect: "If last duel was a win, gain +2 this duel.", + Effect: "If last duel was a win, gain +1 this duel.", Intent: "Snowball option to maintain tempo.", Action: ActionIncrease, Target: TargetSelf, - Amount: 2, + Amount: 1, }, } @@ -59,7 +59,7 @@ var ( Category: CategoryDefense, Type: TypeActive, Trigger: TriggerConditionAfterAugments, - Effect: "Losing roll by 1, convert result to a tie.", + Effect: "Losing roll by 2 or less, convert result to a tie.", Intent: "Stabilize critical moments after a close loss", Action: ActionResultTie, Target: TargetBoth, diff --git a/internal/games/dice_roll.go b/internal/games/dice_roll.go index 03bcaf8..485a236 100644 --- a/internal/games/dice_roll.go +++ b/internal/games/dice_roll.go @@ -44,6 +44,7 @@ func NewDecisionEngine() *Engine { RuleOverPower, RuleFortify, RuleSecondChance, + LastStand, }, afterAugments: []DecisionEngineFunc{ RuleBrace, diff --git a/internal/games/dice_roll_rules.go b/internal/games/dice_roll_rules.go index d9c19ae..f1c78a3 100644 --- a/internal/games/dice_roll_rules.go +++ b/internal/games/dice_roll_rules.go @@ -114,7 +114,7 @@ func RuleBrace(input DecisionInput) DecisionInput { } rollDelta := math.Abs(float64(input.teamA.roll) - float64(input.teamB.roll)) - lostByOne := rollDelta == 1 + lostByOne := rollDelta <= 2 if !lostByOne { return input @@ -182,11 +182,7 @@ func ruleSecondChance(input DecisionInput, rollFn RollFn) DecisionInput { aRoll := input.teamA.roll bRoll := input.teamB.roll - if aRoll == bRoll { - return input - } - - if aRoll < bRoll { + if aRoll <= bRoll { if canTriggerAugment(input.teamA.triggeredAugment, input.teamA.passivesAugments, augments.SecondChance) { input.teamA.triggeredAugment = augments.SecondChance input.teamA.roll = rollFn() @@ -200,3 +196,34 @@ func ruleSecondChance(input DecisionInput, rollFn RollFn) DecisionInput { return input } + +func LastStand(input DecisionInput) DecisionInput { + return lastStand(input, DiceRoll) +} + +func lastStand(input DecisionInput, roll RollFn) DecisionInput { + effect, ok := augments.Get(augments.LastStand) + if !ok { + return input + } + + if input.lastRound == nil { + return input + } + + switch input.lastRound.Outcome { + case TeamB: + if canTriggerAugment(input.teamA.triggeredAugment, input.teamA.passivesAugments, augments.LastStand) { + input.teamA.triggeredAugment = augments.LastStand + input.teamA.roll += effect.Amount + } + + case TeamA: + if canTriggerAugment(input.teamB.triggeredAugment, input.teamB.passivesAugments, augments.LastStand) { + input.teamB.triggeredAugment = augments.LastStand + input.teamB.roll += effect.Amount + } + } + + return input +} diff --git a/internal/games/dice_roll_test.go b/internal/games/dice_roll_test.go index 5408b2a..b91e03c 100644 --- a/internal/games/dice_roll_test.go +++ b/internal/games/dice_roll_test.go @@ -200,7 +200,7 @@ func TestRules(t *testing.T) { }, } result := RuleMomentumSurge(input) - odize.AssertEqual(t, 5, result.teamA.roll) // 3 + 2 + odize.AssertEqual(t, 4, result.teamA.roll) odize.AssertEqual(t, 3, result.teamB.roll) // No change for Team B odize.AssertEqual(t, augments.MomentumSurge, result.teamA.triggeredAugment) odize.AssertEqual(t, augments.Name(""), result.teamB.triggeredAugment) @@ -212,8 +212,8 @@ func TestRules(t *testing.T) { input.teamB.triggeredAugment = "" input.teamB.roll = 3 result = RuleMomentumSurge(input) - odize.AssertEqual(t, 3, result.teamA.roll) // No change for Team A - odize.AssertEqual(t, 5, result.teamB.roll) // 3 + 2 + odize.AssertEqual(t, 3, result.teamA.roll) + odize.AssertEqual(t, 4, result.teamB.roll) odize.AssertEqual(t, augments.Name(""), result.teamA.triggeredAugment) odize.AssertEqual(t, augments.MomentumSurge, result.teamB.triggeredAugment) }). @@ -229,8 +229,8 @@ func TestRules(t *testing.T) { }, } result := RulePrecisionStrike(input) - odize.AssertEqual(t, 6, result.teamA.roll) // 4 + 2 - odize.AssertEqual(t, 3, result.teamB.roll) // No change because 3 < 4 + odize.AssertEqual(t, 5, result.teamA.roll) + odize.AssertEqual(t, 3, result.teamB.roll) odize.AssertEqual(t, augments.PrecisionStrike, result.teamA.triggeredAugment) odize.AssertEqual(t, augments.Name(""), result.teamB.triggeredAugment) }). From 03a840bb425cd699bb5996a026a1a9ad1c4f8f3f Mon Sep 17 00:00:00 2001 From: frag223 Date: Sat, 11 Jul 2026 20:07:26 +1000 Subject: [PATCH 15/25] fixing effect description --- internal/augments/augments.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/augments/augments.go b/internal/augments/augments.go index 04c004b..5ff742c 100644 --- a/internal/augments/augments.go +++ b/internal/augments/augments.go @@ -92,7 +92,7 @@ var ( Category: CategoryDefense, Type: TypeActive, Trigger: TriggerConditionAfterRoll, - Effect: "If last duel was a loss, and only 1 player remains in the lane, +2 to roll", + Effect: "If last duel was a loss, add +2 to roll", Intent: "Slow down snowball effects.", Action: ActionIncrease, Target: TargetSelf, From ec5724001638d8871d67eca374fa34c6c6ef9702 Mon Sep 17 00:00:00 2001 From: frag223 Date: Sat, 11 Jul 2026 20:15:50 +1000 Subject: [PATCH 16/25] adding test --- internal/games/dice_roll.go | 1 + internal/games/dice_roll_rules.go | 21 ++++++-- internal/games/dice_roll_rules_test.go | 66 ++++++++++++++++++++++++++ 3 files changed, 84 insertions(+), 4 deletions(-) diff --git a/internal/games/dice_roll.go b/internal/games/dice_roll.go index 485a236..64b9636 100644 --- a/internal/games/dice_roll.go +++ b/internal/games/dice_roll.go @@ -45,6 +45,7 @@ func NewDecisionEngine() *Engine { RuleFortify, RuleSecondChance, LastStand, + Hamstring, }, afterAugments: []DecisionEngineFunc{ RuleBrace, diff --git a/internal/games/dice_roll_rules.go b/internal/games/dice_roll_rules.go index f1c78a3..bd3b670 100644 --- a/internal/games/dice_roll_rules.go +++ b/internal/games/dice_roll_rules.go @@ -198,10 +198,6 @@ func ruleSecondChance(input DecisionInput, rollFn RollFn) DecisionInput { } func LastStand(input DecisionInput) DecisionInput { - return lastStand(input, DiceRoll) -} - -func lastStand(input DecisionInput, roll RollFn) DecisionInput { effect, ok := augments.Get(augments.LastStand) if !ok { return input @@ -227,3 +223,20 @@ func lastStand(input DecisionInput, roll RollFn) DecisionInput { return input } + +func Hamstring(input DecisionInput) DecisionInput { + effect, ok := augments.Get(augments.LastStand) + if !ok { + return input + } + + if canTriggerAugment(input.teamA.triggeredAugment, input.teamA.passivesAugments, augments.Hamstring) { + input.teamB.roll -= effect.Amount + } + + if canTriggerAugment(input.teamB.triggeredAugment, input.teamB.passivesAugments, augments.Hamstring) { + input.teamA.roll -= effect.Amount + } + + return input +} diff --git a/internal/games/dice_roll_rules_test.go b/internal/games/dice_roll_rules_test.go index d703f96..f7a9767 100644 --- a/internal/games/dice_roll_rules_test.go +++ b/internal/games/dice_roll_rules_test.go @@ -311,3 +311,69 @@ func TestRuleSecondChance(t *testing.T) { err := group.Run() odize.AssertNoError(t, err) } + +func TestLastStand(t *testing.T) { + group := odize.NewGroup(t, nil) + + group.Test("should trigger if Team A loses and has LastStand", func(t *testing.T) { + input := DecisionInput{ + lastRound: &DuelResult{Outcome: TeamB}, + teamA: TeamDecisionInput{ + roll: 2, + passivesAugments: []augments.Effect{{Name: augments.LastStand}}, + }, + } + res := LastStand(input) + odize.AssertEqual(t, augments.LastStand, res.teamA.triggeredAugment) + odize.AssertTrue(t, res.teamA.roll > 2) + }) + + group.Test("should trigger if Team B loses and has LastStand", func(t *testing.T) { + input := DecisionInput{ + lastRound: &DuelResult{Outcome: TeamA}, + teamB: TeamDecisionInput{ + roll: 2, + passivesAugments: []augments.Effect{{Name: augments.LastStand}}, + }, + } + res := LastStand(input) + odize.AssertEqual(t, augments.LastStand, res.teamB.triggeredAugment) + odize.AssertTrue(t, res.teamB.roll > 2) + }) + + err := group.Run() + odize.AssertNoError(t, err) +} + +func TestHamstring(t *testing.T) { + group := odize.NewGroup(t, nil) + + group.Test("should trigger if Team A has Hamstring", func(t *testing.T) { + input := DecisionInput{ + teamA: TeamDecisionInput{ + passivesAugments: []augments.Effect{{Name: augments.Hamstring}}, + }, + teamB: TeamDecisionInput{ + roll: 5, + }, + } + res := Hamstring(input) + odize.AssertTrue(t, res.teamB.roll < 5) + }) + + group.Test("should trigger if Team B has Hamstring", func(t *testing.T) { + input := DecisionInput{ + teamA: TeamDecisionInput{ + roll: 5, + }, + teamB: TeamDecisionInput{ + passivesAugments: []augments.Effect{{Name: augments.Hamstring}}, + }, + } + res := Hamstring(input) + odize.AssertTrue(t, res.teamA.roll < 5) + }) + + err := group.Run() + odize.AssertNoError(t, err) +} From 09145e5d5ef8607dac011010ff13d30dad25ea9d Mon Sep 17 00:00:00 2001 From: frag223 Date: Sat, 11 Jul 2026 21:08:29 +1000 Subject: [PATCH 17/25] adding augment --- internal/augments/augments.go | 13 ++++- internal/augments/types.go | 1 + internal/games/dice_roll.go | 9 ++-- internal/games/dice_roll_rules.go | 46 ++++++++++++++-- internal/games/dice_roll_rules_test.go | 72 ++++++++++++++++++++++++-- 5 files changed, 130 insertions(+), 11 deletions(-) diff --git a/internal/augments/augments.go b/internal/augments/augments.go index 5ff742c..103aa1c 100644 --- a/internal/augments/augments.go +++ b/internal/augments/augments.go @@ -128,7 +128,7 @@ var ( Category: CategorySabotage, Type: TypeActive, Trigger: TriggerConditionAfterRoll, - Effect: "If last duel was a loss, and only 1 player remains in the lane, +2 to roll", + Effect: "If last duel was a loss, opponent has -2 to roll", Intent: "Slow down snowball effects.", Action: ActionIncrease, Target: TargetSelf, @@ -159,6 +159,17 @@ var ( Target: TargetSelf, Amount: 0, }, + CanceledAugment: { + Name: CanceledAugment, + Category: CategoryNoOp, + Type: TypePassive, + Trigger: TriggerConditionBeforeRoll, + Effect: "Canceled augment.", + Intent: "Augment was affected by an opponent.", + Action: ActionNoOp, + Target: TargetSelf, + Amount: 0, + }, } _repositories = []map[Name]Effect{ diff --git a/internal/augments/types.go b/internal/augments/types.go index 0bff246..ecbbff4 100644 --- a/internal/augments/types.go +++ b/internal/augments/types.go @@ -4,6 +4,7 @@ type Name string const ( NoAugment Name = "No Augment" + CanceledAugment Name = "Canceled Augment" TwistOfFate Name = "Twist of Fate" SecondChance Name = "Second Chance" Overpower Name = "Overpower" diff --git a/internal/games/dice_roll.go b/internal/games/dice_roll.go index 64b9636..8a8378f 100644 --- a/internal/games/dice_roll.go +++ b/internal/games/dice_roll.go @@ -36,7 +36,9 @@ type Engine struct { func NewDecisionEngine() *Engine { return &Engine{ - beforeRoll: []DecisionEngineFunc{}, + beforeRoll: []DecisionEngineFunc{ + RulePocketSand, + }, afterRoll: []DecisionEngineFunc{ RuleTwistOfFate, RuleMomentumSurge, @@ -44,8 +46,9 @@ func NewDecisionEngine() *Engine { RuleOverPower, RuleFortify, RuleSecondChance, - LastStand, - Hamstring, + RuleLastStand, + RuleHamstring, + RulePoisonEdge, }, afterAugments: []DecisionEngineFunc{ RuleBrace, diff --git a/internal/games/dice_roll_rules.go b/internal/games/dice_roll_rules.go index bd3b670..182577e 100644 --- a/internal/games/dice_roll_rules.go +++ b/internal/games/dice_roll_rules.go @@ -197,7 +197,7 @@ func ruleSecondChance(input DecisionInput, rollFn RollFn) DecisionInput { return input } -func LastStand(input DecisionInput) DecisionInput { +func RuleLastStand(input DecisionInput) DecisionInput { effect, ok := augments.Get(augments.LastStand) if !ok { return input @@ -224,17 +224,57 @@ func LastStand(input DecisionInput) DecisionInput { return input } -func Hamstring(input DecisionInput) DecisionInput { - effect, ok := augments.Get(augments.LastStand) +func RuleHamstring(input DecisionInput) DecisionInput { + effect, ok := augments.Get(augments.Hamstring) if !ok { return input } if canTriggerAugment(input.teamA.triggeredAugment, input.teamA.passivesAugments, augments.Hamstring) { + input.teamA.triggeredAugment = augments.Hamstring input.teamB.roll -= effect.Amount } if canTriggerAugment(input.teamB.triggeredAugment, input.teamB.passivesAugments, augments.Hamstring) { + input.teamB.triggeredAugment = augments.Hamstring + input.teamA.roll -= effect.Amount + } + + return input +} + +func RulePocketSand(input DecisionInput) DecisionInput { + + if canTriggerAugment(input.teamA.triggeredAugment, input.teamA.passivesAugments, augments.PocketSand) { + input.teamA.triggeredAugment = augments.PocketSand + input.teamB.triggeredAugment = augments.CanceledAugment + } + + if canTriggerAugment(input.teamB.triggeredAugment, input.teamB.passivesAugments, augments.PocketSand) { + input.teamB.triggeredAugment = augments.PocketSand + input.teamA.triggeredAugment = augments.CanceledAugment + } + + return input +} + +func RulePoisonEdge(input DecisionInput) DecisionInput { + effect, ok := augments.Get(augments.PoisonEdge) + if !ok { + return input + } + + if input.lastRound == nil || input.lastRound.Outcome != Draw { + return input + } + + if canTriggerAugment(input.teamA.triggeredAugment, input.teamA.passivesAugments, augments.PoisonEdge) { + input.teamA.triggeredAugment = augments.PoisonEdge + input.teamB.roll -= effect.Amount + } + + if canTriggerAugment(input.teamB.triggeredAugment, input.teamB.passivesAugments, augments.PoisonEdge) { + input.teamB.triggeredAugment = augments.PoisonEdge input.teamA.roll -= effect.Amount } diff --git a/internal/games/dice_roll_rules_test.go b/internal/games/dice_roll_rules_test.go index f7a9767..1965448 100644 --- a/internal/games/dice_roll_rules_test.go +++ b/internal/games/dice_roll_rules_test.go @@ -323,7 +323,7 @@ func TestLastStand(t *testing.T) { passivesAugments: []augments.Effect{{Name: augments.LastStand}}, }, } - res := LastStand(input) + res := RuleLastStand(input) odize.AssertEqual(t, augments.LastStand, res.teamA.triggeredAugment) odize.AssertTrue(t, res.teamA.roll > 2) }) @@ -336,7 +336,7 @@ func TestLastStand(t *testing.T) { passivesAugments: []augments.Effect{{Name: augments.LastStand}}, }, } - res := LastStand(input) + res := RuleLastStand(input) odize.AssertEqual(t, augments.LastStand, res.teamB.triggeredAugment) odize.AssertTrue(t, res.teamB.roll > 2) }) @@ -357,7 +357,7 @@ func TestHamstring(t *testing.T) { roll: 5, }, } - res := Hamstring(input) + res := RuleHamstring(input) odize.AssertTrue(t, res.teamB.roll < 5) }) @@ -370,10 +370,74 @@ func TestHamstring(t *testing.T) { passivesAugments: []augments.Effect{{Name: augments.Hamstring}}, }, } - res := Hamstring(input) + res := RuleHamstring(input) odize.AssertTrue(t, res.teamA.roll < 5) }) err := group.Run() odize.AssertNoError(t, err) } + +func TestRulePocketSand(t *testing.T) { + group := odize.NewGroup(t, nil) + + group.Test("should trigger if Team A has PocketSand", func(t *testing.T) { + input := DecisionInput{ + teamA: TeamDecisionInput{ + passivesAugments: []augments.Effect{{Name: augments.PocketSand}}, + }, + } + res := RulePocketSand(input) + odize.AssertEqual(t, augments.PocketSand, res.teamA.triggeredAugment) + odize.AssertEqual(t, augments.CanceledAugment, res.teamB.triggeredAugment) + }) + + group.Test("should trigger if Team B has PocketSand", func(t *testing.T) { + input := DecisionInput{ + teamB: TeamDecisionInput{ + passivesAugments: []augments.Effect{{Name: augments.PocketSand}}, + }, + } + res := RulePocketSand(input) + odize.AssertEqual(t, augments.PocketSand, res.teamB.triggeredAugment) + odize.AssertEqual(t, augments.CanceledAugment, res.teamA.triggeredAugment) + }) + + err := group.Run() + odize.AssertNoError(t, err) +} + +func TestRulePoisonEdge(t *testing.T) { + group := odize.NewGroup(t, nil) + + group.Test("should not trigger if last round is not Draw", func(t *testing.T) { + input := DecisionInput{ + lastRound: &DuelResult{Outcome: TeamA}, + teamA: TeamDecisionInput{ + passivesAugments: []augments.Effect{{Name: augments.PoisonEdge}}, + }, + } + res := RulePoisonEdge(input) + odize.AssertEqual(t, augments.Name(""), res.teamA.triggeredAugment) + }) + + group.Test("should trigger and reduce roll if Draw and TeamA has augment", func(t *testing.T) { + input := DecisionInput{ + lastRound: &DuelResult{Outcome: Draw}, + teamA: TeamDecisionInput{ + roll: 5, + passivesAugments: []augments.Effect{{Name: augments.PoisonEdge}}, + }, + teamB: TeamDecisionInput{ + roll: 5, + }, + } + res := RulePoisonEdge(input) + // Should trigger, TeamA.triggeredAugment = PoisonEdge, TeamB.roll reduced + odize.AssertEqual(t, augments.PoisonEdge, res.teamA.triggeredAugment) + odize.AssertTrue(t, res.teamB.roll < 5) + }) + + err := group.Run() + odize.AssertNoError(t, err) +} From b9b85c65625b7cdb3081c326caa5558b10491177 Mon Sep 17 00:00:00 2001 From: frag223 Date: Sat, 11 Jul 2026 21:17:10 +1000 Subject: [PATCH 18/25] adding augment --- internal/games/dice_roll_rules.go | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/internal/games/dice_roll_rules.go b/internal/games/dice_roll_rules.go index 182577e..021187a 100644 --- a/internal/games/dice_roll_rules.go +++ b/internal/games/dice_roll_rules.go @@ -264,18 +264,21 @@ func RulePoisonEdge(input DecisionInput) DecisionInput { return input } - if input.lastRound == nil || input.lastRound.Outcome != Draw { + if input.lastRound == nil || input.lastRound.Outcome == Draw { return input } - if canTriggerAugment(input.teamA.triggeredAugment, input.teamA.passivesAugments, augments.PoisonEdge) { - input.teamA.triggeredAugment = augments.PoisonEdge - input.teamB.roll -= effect.Amount - } - - if canTriggerAugment(input.teamB.triggeredAugment, input.teamB.passivesAugments, augments.PoisonEdge) { - input.teamB.triggeredAugment = augments.PoisonEdge - input.teamA.roll -= effect.Amount + switch input.lastRound.Outcome { + case TeamA: + if canTriggerAugment(input.teamB.triggeredAugment, input.teamB.passivesAugments, augments.PoisonEdge) { + input.teamB.triggeredAugment = augments.PoisonEdge + input.teamA.roll -= effect.Amount + } + case TeamB: + if canTriggerAugment(input.teamA.triggeredAugment, input.teamA.passivesAugments, augments.PoisonEdge) { + input.teamA.triggeredAugment = augments.PoisonEdge + input.teamB.roll -= effect.Amount + } } return input From dcf397c8d56e6cade280224b11ee974d086dc8ff Mon Sep 17 00:00:00 2001 From: frag223 Date: Sat, 11 Jul 2026 21:32:38 +1000 Subject: [PATCH 19/25] adding augments --- internal/augments/augments.go | 4 +- internal/games/dice_roll.go | 1 + internal/games/dice_roll_rules.go | 28 ++++++++++++ internal/games/dice_roll_rules_test.go | 61 ++++++++++++++++++++++++++ 4 files changed, 92 insertions(+), 2 deletions(-) diff --git a/internal/augments/augments.go b/internal/augments/augments.go index 103aa1c..d26af1c 100644 --- a/internal/augments/augments.go +++ b/internal/augments/augments.go @@ -106,11 +106,11 @@ var ( Category: CategorySabotage, Type: TypeActive, Trigger: TriggerConditionAfterRoll, - Effect: "Opponent gets -1 to their roll total this duel.", + Effect: "Opponent gets -2 to their roll total this duel.", Intent: "Defensive denial and tempo slowdown.", Action: ActionDecrease, Target: TargetOpponent, - Amount: 1, + Amount: 2, }, PocketSand: { Name: PocketSand, diff --git a/internal/games/dice_roll.go b/internal/games/dice_roll.go index 8a8378f..5366833 100644 --- a/internal/games/dice_roll.go +++ b/internal/games/dice_roll.go @@ -52,6 +52,7 @@ func NewDecisionEngine() *Engine { }, afterAugments: []DecisionEngineFunc{ RuleBrace, + RuleIceInVeins, }, rollFn: DiceRoll, } diff --git a/internal/games/dice_roll_rules.go b/internal/games/dice_roll_rules.go index 021187a..877556a 100644 --- a/internal/games/dice_roll_rules.go +++ b/internal/games/dice_roll_rules.go @@ -283,3 +283,31 @@ func RulePoisonEdge(input DecisionInput) DecisionInput { return input } + +func RuleIceInVeins(input DecisionInput) DecisionInput { + effect, ok := augments.Get(augments.IceInVeins) + if !ok { + return input + } + + if input.lastRound == nil || input.lastRound.Outcome != Draw { + return input + } + + if canTriggerAugment(input.teamA.triggeredAugment, input.teamA.passivesAugments, augments.IceInVeins) { + input.teamA.triggeredAugment = augments.IceInVeins + input.teamA.roll += effect.Amount + + input.teamB.roll = 0 + + } + + if canTriggerAugment(input.teamB.triggeredAugment, input.teamB.passivesAugments, augments.IceInVeins) { + input.teamB.triggeredAugment = augments.IceInVeins + input.teamB.roll += effect.Amount + + input.teamA.roll = 0 + } + + return input +} diff --git a/internal/games/dice_roll_rules_test.go b/internal/games/dice_roll_rules_test.go index 1965448..5beaeac 100644 --- a/internal/games/dice_roll_rules_test.go +++ b/internal/games/dice_roll_rules_test.go @@ -441,3 +441,64 @@ func TestRulePoisonEdge(t *testing.T) { err := group.Run() odize.AssertNoError(t, err) } + +func TestRuleIceInVeins(t *testing.T) { + group := odize.NewGroup(t, nil) + + group.Test("should not trigger if last round is nil", func(t *testing.T) { + input := DecisionInput{ + lastRound: nil, + teamA: TeamDecisionInput{ + passivesAugments: []augments.Effect{{Name: augments.IceInVeins}}, + }, + } + res := RuleIceInVeins(input) + odize.AssertEqual(t, augments.Name(""), res.teamA.triggeredAugment) + }) + + group.Test("should not trigger if last round is not Draw", func(t *testing.T) { + input := DecisionInput{ + lastRound: &DuelResult{Outcome: TeamA}, + teamA: TeamDecisionInput{ + passivesAugments: []augments.Effect{{Name: augments.IceInVeins}}, + }, + } + res := RuleIceInVeins(input) + odize.AssertEqual(t, augments.Name(""), res.teamA.triggeredAugment) + }) + + group.Test("should trigger and set opponent roll to 0 if Draw and TeamA has augment", func(t *testing.T) { + input := DecisionInput{ + lastRound: &DuelResult{Outcome: Draw}, + teamA: TeamDecisionInput{ + roll: 5, + passivesAugments: []augments.Effect{{Name: augments.IceInVeins}}, + }, + teamB: TeamDecisionInput{ + roll: 5, + }, + } + res := RuleIceInVeins(input) + odize.AssertEqual(t, augments.IceInVeins, res.teamA.triggeredAugment) + odize.AssertEqual(t, 0, res.teamB.roll) + }) + + group.Test("should trigger and set opponent roll to 0 if Draw and TeamB has augment", func(t *testing.T) { + input := DecisionInput{ + lastRound: &DuelResult{Outcome: Draw}, + teamA: TeamDecisionInput{ + roll: 5, + }, + teamB: TeamDecisionInput{ + roll: 5, + passivesAugments: []augments.Effect{{Name: augments.IceInVeins}}, + }, + } + res := RuleIceInVeins(input) + odize.AssertEqual(t, augments.IceInVeins, res.teamB.triggeredAugment) + odize.AssertEqual(t, 0, res.teamA.roll) + }) + + err := group.Run() + odize.AssertNoError(t, err) +} From 6a2b5aa287bc72f2a8cd0a34660422a5ea5a5714 Mon Sep 17 00:00:00 2001 From: frag223 Date: Sat, 11 Jul 2026 21:33:30 +1000 Subject: [PATCH 20/25] cleanup --- internal/augments/augments.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/internal/augments/augments.go b/internal/augments/augments.go index d26af1c..f1b6a64 100644 --- a/internal/augments/augments.go +++ b/internal/augments/augments.go @@ -57,7 +57,7 @@ var ( Brace: { Name: Brace, Category: CategoryDefense, - Type: TypeActive, + Type: TypePassive, Trigger: TriggerConditionAfterAugments, Effect: "Losing roll by 2 or less, convert result to a tie.", Intent: "Stabilize critical moments after a close loss", @@ -90,7 +90,7 @@ var ( LastStand: { Name: LastStand, Category: CategoryDefense, - Type: TypeActive, + Type: TypePassive, Trigger: TriggerConditionAfterRoll, Effect: "If last duel was a loss, add +2 to roll", Intent: "Slow down snowball effects.", @@ -104,7 +104,7 @@ var ( Hamstring: { Name: Hamstring, Category: CategorySabotage, - Type: TypeActive, + Type: TypePassive, Trigger: TriggerConditionAfterRoll, Effect: "Opponent gets -2 to their roll total this duel.", Intent: "Defensive denial and tempo slowdown.", @@ -115,7 +115,7 @@ var ( PocketSand: { Name: PocketSand, Category: CategorySabotage, - Type: TypeActive, + Type: TypePassive, Trigger: TriggerConditionBeforeRoll, Effect: "Cancel the opponent's declared Pre-roll augment.", Intent: "Anti-pattern counterplay.", @@ -126,7 +126,7 @@ var ( PoisonEdge: { Name: PoisonEdge, Category: CategorySabotage, - Type: TypeActive, + Type: TypePassive, Trigger: TriggerConditionAfterRoll, Effect: "If last duel was a loss, opponent has -2 to roll", Intent: "Slow down snowball effects.", From e8d64d384df1f3220fa571d90bd423f4cbe845ec Mon Sep 17 00:00:00 2001 From: frag223 Date: Sat, 11 Jul 2026 22:07:58 +1000 Subject: [PATCH 21/25] adding field --- internal/ui/page_create_coach.go | 73 ++++++++++++++++++++++----- internal/ui/page_create_coach_test.go | 6 +-- 2 files changed, 62 insertions(+), 17 deletions(-) diff --git a/internal/ui/page_create_coach.go b/internal/ui/page_create_coach.go index f085bfe..152d96a 100644 --- a/internal/ui/page_create_coach.go +++ b/internal/ui/page_create_coach.go @@ -40,12 +40,14 @@ type ModelCreateCoach struct { globalState *uistate.GlobalState teamsSvc *teams.Service - coachInput textinput.Model - teamInput textinput.Model - focusIndex int - err error - keys createCoachKeyMap - footer components.Footer + coachInput textinput.Model + teamInput textinput.Model + personaIndex int + personas []teams.CoachPersona + focusIndex int + err error + keys createCoachKeyMap + footer components.Footer } func NewModelCreateCoach(state *uistate.GlobalState, teamsSvc *teams.Service, theme styles.IceTheme) *ModelCreateCoach { @@ -67,9 +69,15 @@ func NewModelCreateCoach(state *uistate.GlobalState, teamsSvc *teams.Service, th teamsSvc: teamsSvc, coachInput: c, teamInput: t, - theme: theme, - keys: keys, - footer: components.NewFooter(keys), + personas: []teams.CoachPersona{ + teams.CoachPersonaVanguard, + teams.CoachPersonaBastion, + teams.CoachPersonaTrickster, + teams.CoachPersonaWildcard, + }, + theme: theme, + keys: keys, + footer: components.NewFooter(keys), } } @@ -111,21 +119,21 @@ func (m *ModelCreateCoach) handleKeyMsg(msg tea.KeyMsg) (tea.Cmd, bool) { case key.Matches(msg, m.keys.Up), key.Matches(msg, m.keys.ShiftTab): m.focusIndex-- if m.focusIndex < 0 { - m.focusIndex = 1 + m.focusIndex = 2 } m.updateFocus() return nil, false case key.Matches(msg, m.keys.Down), key.Matches(msg, m.keys.Tab): m.focusIndex++ - if m.focusIndex > 1 { + if m.focusIndex > 2 { m.focusIndex = 0 } m.updateFocus() return nil, false case key.Matches(msg, m.keys.Enter): - if m.focusIndex == 1 { + if m.focusIndex == 2 { return m.submit(), true } m.focusIndex++ @@ -136,6 +144,24 @@ func (m *ModelCreateCoach) handleKeyMsg(msg tea.KeyMsg) (tea.Cmd, bool) { return func() tea.Msg { return uistate.MsgSwitchPage{NewPage: uistate.PageTitle} }, true + + case key.Matches(msg, m.keys.Left): + if m.focusIndex == 1 { + m.personaIndex-- + if m.personaIndex < 0 { + m.personaIndex = len(m.personas) - 1 + } + return nil, false + } + + case key.Matches(msg, m.keys.Right): + if m.focusIndex == 1 { + m.personaIndex++ + if m.personaIndex >= len(m.personas) { + m.personaIndex = 0 + } + return nil, false + } } return nil, false @@ -164,10 +190,14 @@ func (m *ModelCreateCoach) updateInputs(msg tea.Msg) (tea.Model, tea.Cmd) { } func (m *ModelCreateCoach) updateFocus() { - if m.focusIndex == 0 { + switch m.focusIndex { + case 0: m.coachInput.Focus() m.teamInput.Blur() - } else { + case 1: + m.coachInput.Blur() + m.teamInput.Blur() + case 2: m.coachInput.Blur() m.teamInput.Focus() } @@ -178,6 +208,7 @@ func (m *ModelCreateCoach) submit() tea.Cmd { ctx := m.globalState.Context() coach, err := m.teamsSvc.CreateCoach(ctx, teams.CreateCoachParams{ Name: m.coachInput.Value(), + Persona: m.personas[m.personaIndex], IsHuman: true, IsDefault: true, }) @@ -197,6 +228,17 @@ func (m *ModelCreateCoach) submit() tea.Cmd { } } +func (m *ModelCreateCoach) formatPersona() string { + persona := m.personas[m.personaIndex] + + style := m.theme.Muted + if m.focusIndex == 1 { + style = m.theme.Highlight + } + + return style.Render("< " + persona.Name() + " >") +} + func (m *ModelCreateCoach) View() tea.View { view := tea.NewView("") view.AltScreen = true @@ -213,6 +255,9 @@ func (m *ModelCreateCoach) View() tea.View { m.theme.SecondaryHeader.Render("Coach Details"), m.coachInput.View(), "", + m.theme.SecondaryHeader.Render("Persona"), + m.formatPersona(), + "", m.theme.SecondaryHeader.Render("Team Details"), m.teamInput.View(), "", diff --git a/internal/ui/page_create_coach_test.go b/internal/ui/page_create_coach_test.go index 70c2f22..173ff13 100644 --- a/internal/ui/page_create_coach_test.go +++ b/internal/ui/page_create_coach_test.go @@ -33,14 +33,14 @@ func TestModelCreateCoach(t *testing.T) { }) err := group. - Test("enter should move focus from coach to team input", func(t *testing.T) { + Test("enter should move focus from coach to persona", 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()) + odize.AssertFalse(t, m.teamInput.Focused()) }). Test("submit error from enter should be stored on model", func(t *testing.T) { db := uitest.SetupTestDB(t) @@ -52,7 +52,7 @@ func TestModelCreateCoach(t *testing.T) { m := NewModelCreateCoach(state, brokenTeamsSvc, theme) m.coachInput.SetValue("Coach") m.teamInput.SetValue("Team") - m.focusIndex = 1 + m.focusIndex = 2 m.updateFocus() _, cmd := m.Update(tea.KeyPressMsg{Text: "enter"}) From cb4da8723b6ba437107b9abd45bea54596621b01 Mon Sep 17 00:00:00 2001 From: frag223 Date: Sat, 11 Jul 2026 22:46:08 +1000 Subject: [PATCH 22/25] adding edit capability --- internal/augments/types.go | 2 +- internal/database/queries/team.sql | 6 + internal/database/team.sql.gen.go | 18 ++ internal/games/dice_roll_rules_test.go | 2 +- internal/teams/interfaces.go | 1 + internal/teams/service.go | 12 + internal/ui/components/locker_room_list.go | 3 + .../ui/uilocker/page_locker_coach_edit.go | 252 ++++++++++++++++++ internal/ui/uilocker/page_locker_room.go | 39 ++- internal/ui/uilocker/root_locker.go | 10 + 10 files changed, 330 insertions(+), 15 deletions(-) create mode 100644 internal/ui/uilocker/page_locker_coach_edit.go diff --git a/internal/augments/types.go b/internal/augments/types.go index ecbbff4..4215371 100644 --- a/internal/augments/types.go +++ b/internal/augments/types.go @@ -16,7 +16,7 @@ const ( IceInVeins Name = "Ice in Veins" Brace Name = "Brace" Fortify Name = "Fortify" - PoisonEdge Name = "Critical Hit" + PoisonEdge Name = "Poison Edge" ) type Action string diff --git a/internal/database/queries/team.sql b/internal/database/queries/team.sql index df0d120..bbe0cee 100644 --- a/internal/database/queries/team.sql +++ b/internal/database/queries/team.sql @@ -19,6 +19,12 @@ SELECT * FROM coaches WHERE is_human = false; -- name: CreateCoach :one INSERT INTO coaches (name, persona, is_human, is_default) VALUES (?, ?, ?, ?) RETURNING *; +-- name: UpdateCoach :exec +UPDATE coaches +SET name = ?, +persona = ? +WHERE id = ?; + -- name: DeleteCoach :exec DELETE FROM coaches WHERE id = ?; diff --git a/internal/database/team.sql.gen.go b/internal/database/team.sql.gen.go index 6e0d165..e99d67f 100644 --- a/internal/database/team.sql.gen.go +++ b/internal/database/team.sql.gen.go @@ -361,6 +361,24 @@ func (q *Queries) SetDefaultTeam(ctx context.Context, id int64) error { return err } +const updateCoach = `-- name: UpdateCoach :exec +UPDATE coaches +SET name = ?, +persona = ? +WHERE id = ? +` + +type UpdateCoachParams struct { + Name string + Persona string + ID int64 +} + +func (q *Queries) UpdateCoach(ctx context.Context, arg UpdateCoachParams) error { + _, err := q.db.ExecContext(ctx, updateCoach, arg.Name, arg.Persona, arg.ID) + return err +} + const updatePlayer = `-- name: UpdatePlayer :exec UPDATE players SET name = ? WHERE id = ? ` diff --git a/internal/games/dice_roll_rules_test.go b/internal/games/dice_roll_rules_test.go index 5beaeac..5009270 100644 --- a/internal/games/dice_roll_rules_test.go +++ b/internal/games/dice_roll_rules_test.go @@ -423,7 +423,7 @@ func TestRulePoisonEdge(t *testing.T) { group.Test("should trigger and reduce roll if Draw and TeamA has augment", func(t *testing.T) { input := DecisionInput{ - lastRound: &DuelResult{Outcome: Draw}, + lastRound: &DuelResult{Outcome: TeamB}, teamA: TeamDecisionInput{ roll: 5, passivesAugments: []augments.Effect{{Name: augments.PoisonEdge}}, diff --git a/internal/teams/interfaces.go b/internal/teams/interfaces.go index 8a54c89..c305e46 100644 --- a/internal/teams/interfaces.go +++ b/internal/teams/interfaces.go @@ -19,6 +19,7 @@ type CoachStore interface { GetDefaultCoach(ctx context.Context) (database.Coach, error) ClearDefaultCoach(ctx context.Context) error CreateCoach(ctx context.Context, arg database.CreateCoachParams) (database.Coach, error) + UpdateCoach(ctx context.Context, arg database.UpdateCoachParams) error GetCoaches(ctx context.Context) ([]database.Coach, error) GetAICoaches(ctx context.Context) ([]database.Coach, error) SetDefaultCoach(ctx context.Context, id int64) error diff --git a/internal/teams/service.go b/internal/teams/service.go index 767bad2..332eee7 100644 --- a/internal/teams/service.go +++ b/internal/teams/service.go @@ -50,6 +50,18 @@ func (s *Service) CreateCoach(ctx context.Context, params CreateCoachParams) (Co return fromCoachModel(model), nil } +func (s *Service) UpdateCoach(ctx context.Context, id int64, name string, persona CoachPersona) error { + err := s.store.UpdateCoach(ctx, database.UpdateCoachParams{ + ID: id, + Name: name, + Persona: string(persona), + }) + if err != nil { + return fmt.Errorf("updating coach: %w", err) + } + return nil +} + func (s *Service) GetDefaultCoach(ctx context.Context) (Coach, error) { model, err := s.store.GetDefaultCoach(ctx) if err != nil { diff --git a/internal/ui/components/locker_room_list.go b/internal/ui/components/locker_room_list.go index cf15c63..c6b60f5 100644 --- a/internal/ui/components/locker_room_list.go +++ b/internal/ui/components/locker_room_list.go @@ -11,6 +11,7 @@ const ( ItemPlayers LockerRoomItem = iota ItemTeamStatistics ItemPlaybooks + ItemCoachEdit ) func (i LockerRoomItem) String() string { @@ -21,6 +22,8 @@ func (i LockerRoomItem) String() string { return "Team Statistics" case ItemPlaybooks: return "Playbooks" + case ItemCoachEdit: + return "Coach Edit" } return "" } diff --git a/internal/ui/uilocker/page_locker_coach_edit.go b/internal/ui/uilocker/page_locker_coach_edit.go new file mode 100644 index 0000000..1f3b127 --- /dev/null +++ b/internal/ui/uilocker/page_locker_coach_edit.go @@ -0,0 +1,252 @@ +package uilocker + +import ( + "charm.land/bubbles/v2/key" + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/code-gorilla-au/rush/internal/teams" + "github.com/code-gorilla-au/rush/internal/ui/components" + "github.com/code-gorilla-au/rush/internal/ui/styles" + "github.com/code-gorilla-au/rush/internal/ui/uistate" +) + +type coachEditKeyMap struct { + uistate.KeyMap +} + +func (k coachEditKeyMap) ShortHelp() []key.Binding { + return []key.Binding{k.Enter, k.Back, k.Quit} +} + +func (k coachEditKeyMap) FullHelp() [][]key.Binding { + return [][]key.Binding{ + {k.Enter, k.Back}, + {k.Up, k.Down, k.Tab, k.ShiftTab}, + {k.Quit}, + } +} + +func newCoachEditKeyMap() coachEditKeyMap { + return coachEditKeyMap{ + KeyMap: uistate.NewKeyMap(), + } +} + +type PageLockerCoachEdit struct { + width int + height int + theme styles.IceTheme + globalState *uistate.GlobalState + teamsSvc *teams.Service + + coachInput textinput.Model + personaIndex int + personas []teams.CoachPersona + focusIndex int + err error + keys coachEditKeyMap + footer components.Footer +} + +func NewPageLockerCoachEdit(state *uistate.GlobalState, teamsSvc *teams.Service, theme styles.IceTheme) *PageLockerCoachEdit { + c := textinput.New() + c.Placeholder = "Coach Name" + c.Focus() + c.CharLimit = 156 + c.SetWidth(20) + if state.Coach != nil { + c.SetValue(state.Coach.Name) + } + + keys := newCoachEditKeyMap() + + personaIndex := 0 + personas := []teams.CoachPersona{ + teams.CoachPersonaVanguard, + teams.CoachPersonaBastion, + teams.CoachPersonaTrickster, + teams.CoachPersonaWildcard, + } + if state.Coach != nil { + for i, p := range personas { + if p == state.Coach.Persona { + personaIndex = i + break + } + } + } + + return &PageLockerCoachEdit{ + globalState: state, + teamsSvc: teamsSvc, + coachInput: c, + personas: personas, + personaIndex: personaIndex, + theme: theme, + keys: keys, + footer: components.NewFooter(keys), + } +} + +func (m *PageLockerCoachEdit) Init() tea.Cmd { + return textinput.Blink +} + +func (m *PageLockerCoachEdit) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + m.footer.Update(msg) + + case tea.KeyMsg: + if cmd, done := m.handleKeyMsg(msg); done { + return m, cmd + } + + case error: + m.err = msg + } + + return m.updateInputs(msg) +} + +func (m *PageLockerCoachEdit) handleKeyMsg(msg tea.KeyMsg) (tea.Cmd, bool) { + switch { + case key.Matches(msg, m.keys.Quit): + return tea.Quit, true + + case key.Matches(msg, m.keys.Up), key.Matches(msg, m.keys.ShiftTab): + m.focusIndex-- + if m.focusIndex < 0 { + m.focusIndex = 1 + } + m.updateFocus() + return nil, false + + case key.Matches(msg, m.keys.Down), key.Matches(msg, m.keys.Tab): + m.focusIndex++ + if m.focusIndex > 1 { + m.focusIndex = 0 + } + m.updateFocus() + return nil, false + + case key.Matches(msg, m.keys.Enter): + if m.focusIndex == 1 { + return m.submit(), true + } + m.focusIndex++ + m.updateFocus() + return nil, false + + case key.Matches(msg, m.keys.Back): + return func() tea.Msg { + return MsgSwitchLockerPage{NewPage: SubPageLockerRoom} + }, true + + case key.Matches(msg, m.keys.Left): + if m.focusIndex == 1 { + m.personaIndex-- + if m.personaIndex < 0 { + m.personaIndex = len(m.personas) - 1 + } + return nil, false + } + + case key.Matches(msg, m.keys.Right): + if m.focusIndex == 1 { + m.personaIndex++ + if m.personaIndex >= len(m.personas) { + m.personaIndex = 0 + } + return nil, false + } + } + + return nil, false +} + +func (m *PageLockerCoachEdit) updateInputs(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + m.coachInput, cmd = m.coachInput.Update(msg) + return m, cmd +} + +func (m *PageLockerCoachEdit) updateFocus() { + switch m.focusIndex { + case 0: + m.coachInput.Focus() + case 1: + m.coachInput.Blur() + } +} + +func (m *PageLockerCoachEdit) submit() tea.Cmd { + return func() tea.Msg { + if m.globalState.Coach == nil { + return nil + } + + ctx := m.globalState.Context() + err := m.teamsSvc.UpdateCoach(ctx, m.globalState.Coach.ID, m.coachInput.Value(), m.personas[m.personaIndex]) + if err != nil { + return err + } + + m.globalState.Coach.Name = m.coachInput.Value() + m.globalState.Coach.Persona = m.personas[m.personaIndex] + + return MsgSwitchLockerPage{NewPage: SubPageLockerRoom} + } +} + +func (m *PageLockerCoachEdit) formatPersona() string { + persona := m.personas[m.personaIndex] + + style := m.theme.Muted + if m.focusIndex == 1 { + style = m.theme.Highlight + } + + return style.Render("< " + persona.Name() + " >") +} + +func (m *PageLockerCoachEdit) 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 - EDIT COACH"), + "", + m.theme.SecondaryHeader.Render("Coach Details"), + m.coachInput.View(), + "", + m.theme.SecondaryHeader.Render("Persona"), + m.formatPersona(), + "", + errorView, + "", + m.footer.View(m.theme), + ) + + centeredContent := lipgloss.Place( + m.width, m.height, + lipgloss.Center, lipgloss.Center, + form, + ) + + view.Content = m.theme.Base. + Width(m.width). + Height(m.height). + Render(centeredContent) + + return view +} diff --git a/internal/ui/uilocker/page_locker_room.go b/internal/ui/uilocker/page_locker_room.go index 8e075fe..4f8a100 100644 --- a/internal/ui/uilocker/page_locker_room.go +++ b/internal/ui/uilocker/page_locker_room.go @@ -71,19 +71,9 @@ func (m *ModelLockerRoom) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return uistate.MsgSwitchPage{NewPage: uistate.PageTitle} } case key.Matches(msg, m.keys.Select): - switch m.list.SelectedItem() { - case components.ItemPlayers: - return m, func() tea.Msg { - return 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} - } + model, cmd, done := m.handleListSelect() + if done { + return model, cmd } } case tea.WindowSizeMsg: @@ -100,6 +90,29 @@ func (m *ModelLockerRoom) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Batch(cmds...) } +func (m *ModelLockerRoom) handleListSelect() (tea.Model, tea.Cmd, bool) { + switch m.list.SelectedItem() { + case components.ItemPlayers: + return m, func() tea.Msg { + return MsgSwitchLockerPage{NewPage: SubPageLockerPlayers} + }, true + case components.ItemTeamStatistics: + return m, func() tea.Msg { + return MsgSwitchLockerPage{NewPage: SubPageLockerTeamStatistics} + }, true + case components.ItemPlaybooks: + return m, func() tea.Msg { + return MsgSwitchLockerPage{NewPage: SubPageLockerPlaybooksList} + }, true + case components.ItemCoachEdit: + return m, func() tea.Msg { + return MsgSwitchLockerPage{NewPage: SubPageLockerCoachEdit} + }, true + } + + return nil, nil, false +} + func (m *ModelLockerRoom) View() tea.View { view := tea.NewView("") view.AltScreen = true diff --git a/internal/ui/uilocker/root_locker.go b/internal/ui/uilocker/root_locker.go index 0a3f0e9..67454cb 100644 --- a/internal/ui/uilocker/root_locker.go +++ b/internal/ui/uilocker/root_locker.go @@ -18,6 +18,7 @@ const ( SubPageLockerPlaybooksList SubPageLockerPlaybooksCreate SubPageLockerPlaybooksEdit + SubPageLockerCoachEdit ) type MsgSwitchLockerPage struct { @@ -35,6 +36,7 @@ type LockerModel struct { subPageLockerPlaybooksList tea.Model subPageLockerPlaybooksCreate tea.Model subPageLockerPlaybooksEdit tea.Model + subPageLockerCoachEdit tea.Model } // NewLockerModel returns a new LockerModel. @@ -46,6 +48,7 @@ func NewLockerModel(state *uistate.GlobalState, teamsSvc *teams.Service, playboo subPageLockerPlaybooksList: NewModelLockerPlaybooksList(state, playbookSvc, theme), subPageLockerPlaybooksCreate: NewModelLockerPlaybooksCreate(state, playbookSvc, theme), subPageLockerPlaybooksEdit: NewModelLockerPlaybooksEdit(state, playbookSvc, theme), + subPageLockerCoachEdit: NewPageLockerCoachEdit(state, teamsSvc, theme), } } @@ -84,6 +87,9 @@ func (m *LockerModel) handleWindowSize(msg tea.WindowSizeMsg) tea.Cmd { cmds = append(cmds, cmd) m.subPageLockerPlaybooksEdit, cmd = m.subPageLockerPlaybooksEdit.Update(msg) cmds = append(cmds, cmd) + m.subPageLockerCoachEdit, cmd = m.subPageLockerCoachEdit.Update(msg) + cmds = append(cmds, cmd) + return tea.Batch(cmds...) } @@ -102,6 +108,8 @@ func (m *LockerModel) updateCurrentPage(msg tea.Msg) (tea.Model, tea.Cmd) { m.subPageLockerPlaybooksCreate, cmd = m.subPageLockerPlaybooksCreate.Update(msg) case SubPageLockerPlaybooksEdit: m.subPageLockerPlaybooksEdit, cmd = m.subPageLockerPlaybooksEdit.Update(msg) + case SubPageLockerCoachEdit: + m.subPageLockerCoachEdit, cmd = m.subPageLockerCoachEdit.Update(msg) } return m, cmd } @@ -120,6 +128,8 @@ func (m *LockerModel) View() tea.View { return m.subPageLockerPlaybooksCreate.View() case SubPageLockerPlaybooksEdit: return m.subPageLockerPlaybooksEdit.View() + case SubPageLockerCoachEdit: + return m.subPageLockerCoachEdit.View() } return tea.NewView("unknown locker page") From 0726a7cbcac05d2fb1ad103fcfd4bc2226dc8a4c Mon Sep 17 00:00:00 2001 From: frag223 Date: Sat, 11 Jul 2026 22:51:53 +1000 Subject: [PATCH 23/25] adding edit capability to locker --- internal/ui/components/locker_room_list.go | 2 +- .../ui/uilocker/page_locker_coach_edit.go | 24 +++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/internal/ui/components/locker_room_list.go b/internal/ui/components/locker_room_list.go index c6b60f5..1f69e24 100644 --- a/internal/ui/components/locker_room_list.go +++ b/internal/ui/components/locker_room_list.go @@ -35,7 +35,7 @@ type LockerRoomList struct { func NewLockerRoomList(theme styles.IceTheme) LockerRoomList { return LockerRoomList{ List: NewList(ListConfig[LockerRoomItem]{ - Items: []LockerRoomItem{ItemPlayers, ItemTeamStatistics, ItemPlaybooks}, + Items: []LockerRoomItem{ItemPlayers, ItemTeamStatistics, ItemPlaybooks, ItemCoachEdit}, ItemMapper: func(i LockerRoomItem) ListItem[LockerRoomItem] { return ListItem[LockerRoomItem]{ Data: i, diff --git a/internal/ui/uilocker/page_locker_coach_edit.go b/internal/ui/uilocker/page_locker_coach_edit.go index 1f3b127..9234936 100644 --- a/internal/ui/uilocker/page_locker_coach_edit.go +++ b/internal/ui/uilocker/page_locker_coach_edit.go @@ -105,6 +105,11 @@ func (m *PageLockerCoachEdit) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, cmd } + case MsgSwitchLockerPage: + if msg.NewPage == SubPageLockerCoachEdit { + m.refresh() + } + case error: m.err = msg } @@ -213,6 +218,25 @@ func (m *PageLockerCoachEdit) formatPersona() string { return style.Render("< " + persona.Name() + " >") } +func (m *PageLockerCoachEdit) refresh() { + if m.globalState.Coach == nil { + return + } + + if m.coachInput.Value() != m.globalState.Coach.Name { + m.coachInput.SetValue(m.globalState.Coach.Name) + } + + for i, p := range m.personas { + if p == m.globalState.Coach.Persona { + if m.personaIndex != i { + m.personaIndex = i + } + break + } + } +} + func (m *PageLockerCoachEdit) View() tea.View { view := tea.NewView("") view.AltScreen = true From b94b3a5fb6391bb5689b0c1b2631f5e100203278 Mon Sep 17 00:00:00 2001 From: frag223 Date: Sun, 12 Jul 2026 08:11:46 +1000 Subject: [PATCH 24/25] extending to record both player's rolls and augments --- internal/games/dice_roll.go | 18 ++++++++++++++++++ internal/games/rounds.go | 4 ++-- internal/games/types.go | 8 ++++++++ .../ui/components/locker_room_list_test.go | 4 ++-- 4 files changed, 30 insertions(+), 4 deletions(-) diff --git a/internal/games/dice_roll.go b/internal/games/dice_roll.go index 5366833..6f49092 100644 --- a/internal/games/dice_roll.go +++ b/internal/games/dice_roll.go @@ -78,13 +78,27 @@ func (e *Engine) Run(input DecisionInput) DuelResult { } func makeDecision(input DecisionInput) DuelResult { + teamA := PlayerRoll{ + Player: input.teamA.player, + Roll: input.teamA.roll, + TriggeredAugment: input.teamA.triggeredAugment, + } + teamB := PlayerRoll{ + Player: input.teamB.player, + Roll: input.teamB.roll, + TriggeredAugment: input.teamB.triggeredAugment, + } + if input.teamA.roll == input.teamB.roll { + return DuelResult{ Player: 0, Outcome: Draw, Roll: 0, RollDelta: 0, TriggeredAugment: augments.NoAugment, + TeamA: teamA, + TeamB: teamB, } } @@ -95,6 +109,8 @@ func makeDecision(input DecisionInput) DuelResult { Roll: input.teamA.roll, RollDelta: input.teamA.roll - input.teamB.roll, TriggeredAugment: input.teamA.triggeredAugment, + TeamA: teamA, + TeamB: teamB, } } @@ -104,6 +120,8 @@ func makeDecision(input DecisionInput) DuelResult { Roll: input.teamB.roll, RollDelta: input.teamB.roll - input.teamA.roll, TriggeredAugment: input.teamB.triggeredAugment, + TeamA: teamA, + TeamB: teamB, } } diff --git a/internal/games/rounds.go b/internal/games/rounds.go index 716e05b..6cccd82 100644 --- a/internal/games/rounds.go +++ b/internal/games/rounds.go @@ -117,8 +117,8 @@ func (r *Round) executeDuels(lane int, rollFn RollStrategy) error { re := rollFn.Run(rollInput) re.Lane = lane - r.TeamB.Augments = popAugment(r.TeamB.Augments, re.TriggeredAugment) - r.TeamA.Augments = popAugment(r.TeamA.Augments, re.TriggeredAugment) + r.TeamB.Augments = popAugment(r.TeamB.Augments, re.TeamB.TriggeredAugment) + r.TeamA.Augments = popAugment(r.TeamA.Augments, re.TeamA.TriggeredAugment) for re.Outcome == Draw { re = rollFn.Run(rollInput) diff --git a/internal/games/types.go b/internal/games/types.go index e39d1fd..c1cd32a 100644 --- a/internal/games/types.go +++ b/internal/games/types.go @@ -56,6 +56,14 @@ type DuelResult struct { RollDelta int `json:"roll_delta"` TriggeredAugment augments.Name `json:"triggered_augment"` Lane int `json:"lane"` + TeamA PlayerRoll `json:"team_a"` + TeamB PlayerRoll `json:"team_b"` +} + +type PlayerRoll struct { + Player int64 `json:"player"` + Roll int `json:"roll"` + TriggeredAugment augments.Name `json:"triggered_augment"` } type TeamStatistics struct { diff --git a/internal/ui/components/locker_room_list_test.go b/internal/ui/components/locker_room_list_test.go index 6b21b86..ed086b2 100644 --- a/internal/ui/components/locker_room_list_test.go +++ b/internal/ui/components/locker_room_list_test.go @@ -14,7 +14,7 @@ func TestLockerRoomList(t *testing.T) { group.Test("NewLockerRoomList should have 3 items", func(t *testing.T) { l := NewLockerRoomList(theme) - odize.AssertEqual(t, 3, len(l.Model.Items())) + odize.AssertEqual(t, 4, len(l.Model.Items())) odize.AssertEqual(t, ItemPlayers, l.SelectedItem()) }) @@ -48,7 +48,7 @@ func TestLockerRoomList(t *testing.T) { l.Model.Select(2) l.Update(tea.KeyPressMsg{Text: "down"}) - odize.AssertEqual(t, 2, l.Model.Index()) + odize.AssertEqual(t, 3, l.Model.Index()) }) err := group.Run() From 7c434d72b1c5fec59d324242c287acd56fa52eed Mon Sep 17 00:00:00 2001 From: frag223 Date: Sun, 12 Jul 2026 20:37:15 +1000 Subject: [PATCH 25/25] lint fixes --- internal/ui/page_create_coach.go | 93 ++++++++++++------- .../ui/uilocker/page_locker_coach_edit.go | 93 ++++++++++++------- 2 files changed, 124 insertions(+), 62 deletions(-) diff --git a/internal/ui/page_create_coach.go b/internal/ui/page_create_coach.go index 152d96a..107b745 100644 --- a/internal/ui/page_create_coach.go +++ b/internal/ui/page_create_coach.go @@ -112,59 +112,90 @@ func (m *ModelCreateCoach) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } func (m *ModelCreateCoach) handleKeyMsg(msg tea.KeyMsg) (tea.Cmd, bool) { - switch { - case key.Matches(msg, m.keys.Quit): + if key.Matches(msg, m.keys.Quit) { return tea.Quit, true + } + + if m.handleFocusNavigation(msg) { + return nil, false + } + + if cmd, handled := m.handleEnter(msg); handled { + return cmd, true + } - case key.Matches(msg, m.keys.Up), key.Matches(msg, m.keys.ShiftTab): + if key.Matches(msg, m.keys.Back) { + return func() tea.Msg { + return uistate.MsgSwitchPage{NewPage: uistate.PageTitle} + }, true + } + + if m.handlePersonaNavigation(msg) { + return nil, false + } + + return nil, false +} + +func (m *ModelCreateCoach) handleFocusNavigation(msg tea.KeyMsg) bool { + if key.Matches(msg, m.keys.Up) || key.Matches(msg, m.keys.ShiftTab) { m.focusIndex-- if m.focusIndex < 0 { m.focusIndex = 2 } m.updateFocus() - return nil, false + return true + } - case key.Matches(msg, m.keys.Down), key.Matches(msg, m.keys.Tab): + if key.Matches(msg, m.keys.Down) || key.Matches(msg, m.keys.Tab) { m.focusIndex++ if m.focusIndex > 2 { m.focusIndex = 0 } m.updateFocus() - return nil, false + return true + } - case key.Matches(msg, m.keys.Enter): - if m.focusIndex == 2 { - return m.submit(), true - } - m.focusIndex++ - m.updateFocus() + return false +} + +func (m *ModelCreateCoach) handleEnter(msg tea.KeyMsg) (tea.Cmd, bool) { + if !key.Matches(msg, m.keys.Enter) { return nil, false + } - case key.Matches(msg, m.keys.Back): - return func() tea.Msg { - return uistate.MsgSwitchPage{NewPage: uistate.PageTitle} - }, true + if m.focusIndex == 2 { + return m.submit(), true + } + + m.focusIndex++ + m.updateFocus() + + return nil, true +} - case key.Matches(msg, m.keys.Left): - if m.focusIndex == 1 { - m.personaIndex-- - if m.personaIndex < 0 { - m.personaIndex = len(m.personas) - 1 - } - return nil, false +func (m *ModelCreateCoach) handlePersonaNavigation(msg tea.KeyMsg) bool { + if m.focusIndex != 1 { + return false + } + + if key.Matches(msg, m.keys.Left) { + m.personaIndex-- + if m.personaIndex < 0 { + m.personaIndex = len(m.personas) - 1 } + return true + } - case key.Matches(msg, m.keys.Right): - if m.focusIndex == 1 { - m.personaIndex++ - if m.personaIndex >= len(m.personas) { - m.personaIndex = 0 - } - return nil, false + if key.Matches(msg, m.keys.Right) { + m.personaIndex++ + if m.personaIndex >= len(m.personas) { + m.personaIndex = 0 } + return true } - return nil, false + return false } func (m *ModelCreateCoach) handleStateUpdated(msg uistate.MsgStateUpdated) (tea.Model, tea.Cmd) { diff --git a/internal/ui/uilocker/page_locker_coach_edit.go b/internal/ui/uilocker/page_locker_coach_edit.go index 9234936..5a75a88 100644 --- a/internal/ui/uilocker/page_locker_coach_edit.go +++ b/internal/ui/uilocker/page_locker_coach_edit.go @@ -118,59 +118,90 @@ func (m *PageLockerCoachEdit) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } func (m *PageLockerCoachEdit) handleKeyMsg(msg tea.KeyMsg) (tea.Cmd, bool) { - switch { - case key.Matches(msg, m.keys.Quit): + if key.Matches(msg, m.keys.Quit) { return tea.Quit, true + } + + if m.handleFocusNavigation(msg) { + return nil, false + } + + if cmd, handled := m.handleEnter(msg); handled { + return cmd, true + } + + if key.Matches(msg, m.keys.Back) { + return func() tea.Msg { + return MsgSwitchLockerPage{NewPage: SubPageLockerRoom} + }, true + } - case key.Matches(msg, m.keys.Up), key.Matches(msg, m.keys.ShiftTab): + if m.handlePersonaNavigation(msg) { + return nil, false + } + + return nil, false +} + +func (m *PageLockerCoachEdit) handleFocusNavigation(msg tea.KeyMsg) bool { + if key.Matches(msg, m.keys.Up) || key.Matches(msg, m.keys.ShiftTab) { m.focusIndex-- if m.focusIndex < 0 { m.focusIndex = 1 } m.updateFocus() - return nil, false + return true + } - case key.Matches(msg, m.keys.Down), key.Matches(msg, m.keys.Tab): + if key.Matches(msg, m.keys.Down) || key.Matches(msg, m.keys.Tab) { m.focusIndex++ if m.focusIndex > 1 { m.focusIndex = 0 } m.updateFocus() - return nil, false + return true + } - case key.Matches(msg, m.keys.Enter): - if m.focusIndex == 1 { - return m.submit(), true - } - m.focusIndex++ - m.updateFocus() + return false +} + +func (m *PageLockerCoachEdit) handleEnter(msg tea.KeyMsg) (tea.Cmd, bool) { + if !key.Matches(msg, m.keys.Enter) { return nil, false + } - case key.Matches(msg, m.keys.Back): - return func() tea.Msg { - return MsgSwitchLockerPage{NewPage: SubPageLockerRoom} - }, true + if m.focusIndex == 1 { + return m.submit(), true + } - case key.Matches(msg, m.keys.Left): - if m.focusIndex == 1 { - m.personaIndex-- - if m.personaIndex < 0 { - m.personaIndex = len(m.personas) - 1 - } - return nil, false + m.focusIndex++ + m.updateFocus() + + return nil, true +} + +func (m *PageLockerCoachEdit) handlePersonaNavigation(msg tea.KeyMsg) bool { + if m.focusIndex != 1 { + return false + } + + if key.Matches(msg, m.keys.Left) { + m.personaIndex-- + if m.personaIndex < 0 { + m.personaIndex = len(m.personas) - 1 } + return true + } - case key.Matches(msg, m.keys.Right): - if m.focusIndex == 1 { - m.personaIndex++ - if m.personaIndex >= len(m.personas) { - m.personaIndex = 0 - } - return nil, false + if key.Matches(msg, m.keys.Right) { + m.personaIndex++ + if m.personaIndex >= len(m.personas) { + m.personaIndex = 0 } + return true } - return nil, false + return false } func (m *PageLockerCoachEdit) updateInputs(msg tea.Msg) (tea.Model, tea.Cmd) {