diff --git a/internal/augments/augments.go b/internal/augments/augments.go index da40a24..f1b6a64 100644 --- a/internal/augments/augments.go +++ b/internal/augments/augments.go @@ -10,21 +10,21 @@ var ( TwistOfFate: { Name: TwistOfFate, Category: CategoryOffense, - Type: TypeActive, + Type: TypePassive, Trigger: TriggerConditionAfterRoll, - Effect: "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, Category: CategoryOffense, - Type: TypeActive, - Trigger: TriggerConditionBeforeRoll, + 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,22 +32,24 @@ 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 +1 to your total.", + Intent: "High-variance reward for strong rolls.", Action: ActionIncrease, Target: TargetSelf, + Amount: 1, }, MomentumSurge: { Name: MomentumSurge, Category: CategoryOffense, Type: TypePassive, Trigger: TriggerConditionAfterRoll, - Effect: "If last duel was a win, gain +2 this duel.", - Intent: "Snowball option with explicit condition gate.", + 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, }, } @@ -55,9 +57,9 @@ var ( Brace: { Name: Brace, Category: CategoryDefense, - Type: TypeActive, - Trigger: TriggerConditionAfterRoll, - Effect: "Losing roll by 1, convert result to a tie.", + Type: TypePassive, + Trigger: TriggerConditionAfterAugments, + Effect: "Losing roll by 2 or less, convert result to a tie.", Intent: "Stabilize critical moments after a close loss", Action: ActionResultTie, Target: TargetBoth, @@ -68,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, @@ -83,14 +85,14 @@ var ( Intent: "Stabilize critical moments after a miss or tie.", Action: ActionReRoll, Target: TargetSelf, - Amount: 1, + Amount: 0, }, LastStand: { Name: LastStand, Category: CategoryDefense, - Type: TypeActive, + Type: TypePassive, 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, @@ -102,18 +104,18 @@ var ( Hamstring: { Name: Hamstring, Category: CategorySabotage, - Type: TypeActive, + Type: TypePassive, 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, Category: CategorySabotage, - Type: TypeActive, + Type: TypePassive, Trigger: TriggerConditionBeforeRoll, Effect: "Cancel the opponent's declared Pre-roll augment.", Intent: "Anti-pattern counterplay.", @@ -124,9 +126,9 @@ var ( PoisonEdge: { Name: PoisonEdge, Category: CategorySabotage, - Type: TypeActive, + Type: TypePassive, 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, @@ -157,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/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/augments/types.go b/internal/augments/types.go index 0bff246..4215371 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" @@ -15,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.go b/internal/games/dice_roll.go index 4980961..6f49092 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 @@ -26,28 +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.teamB.activeAugment.Name == augments.TwistOfFate { - secondRoll := roll() - if input.teamB.roll < secondRoll { - input.teamB.roll = secondRoll - } - } - - if input.teamA.activeAugment.Name == augments.TwistOfFate { - secondRoll := roll() - if input.teamA.roll < secondRoll { - input.teamA.roll = secondRoll - } - } - - return input -} - type Engine struct { beforeRoll []DecisionEngineFunc afterRoll []DecisionEngineFunc @@ -57,12 +36,25 @@ type Engine struct { func NewDecisionEngine() *Engine { return &Engine{ - beforeRoll: []DecisionEngineFunc{}, + beforeRoll: []DecisionEngineFunc{ + RulePocketSand, + }, afterRoll: []DecisionEngineFunc{ RuleTwistOfFate, + RuleMomentumSurge, + RulePrecisionStrike, + RuleOverPower, + RuleFortify, + RuleSecondChance, + RuleLastStand, + RuleHamstring, + RulePoisonEdge, + }, + afterAugments: []DecisionEngineFunc{ + RuleBrace, + RuleIceInVeins, }, - afterAugments: []DecisionEngineFunc{}, - rollFn: DiceRoll, + rollFn: DiceRoll, } } @@ -86,29 +78,60 @@ 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, + Player: 0, + Outcome: Draw, + Roll: 0, + RollDelta: 0, + TriggeredAugment: augments.NoAugment, + TeamA: teamA, + TeamB: teamB, } } 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, + TeamA: teamA, + TeamB: teamB, } } 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, + TeamA: teamA, + TeamB: teamB, } } + +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_rules.go b/internal/games/dice_roll_rules.go new file mode 100644 index 0000000..877556a --- /dev/null +++ b/internal/games/dice_roll_rules.go @@ -0,0 +1,313 @@ +package games + +import ( + "math" + + "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 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 { + 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 +} + +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 <= 2 + + 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.Fortify) { + input.teamA.triggeredAugment = augments.Fortify + input.teamA.roll += effect.Amount + } + + if canTriggerAugment(input.teamB.triggeredAugment, input.teamB.passivesAugments, augments.Fortify) { + input.teamB.triggeredAugment = augments.Fortify + input.teamB.roll += effect.Amount + } + } + + 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 { + 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 +} + +func RuleLastStand(input DecisionInput) 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 +} + +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 + } + + 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 +} + +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 new file mode 100644 index 0000000..5009270 --- /dev/null +++ b/internal/games/dice_roll_rules_test.go @@ -0,0 +1,504 @@ +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) +} + +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.Fortify}}, + }, + } + res := RuleFortify(input) + odize.AssertEqual(t, augments.Fortify, res.teamA.triggeredAugment) + + 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) { + input := DecisionInput{ + lastRound: &DuelResult{Outcome: Draw}, + teamB: TeamDecisionInput{ + roll: 3, + 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, 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() + 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 := RuleLastStand(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 := RuleLastStand(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 := RuleHamstring(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 := 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: TeamB}, + 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) +} + +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) +} diff --git a/internal/games/dice_roll_test.go b/internal/games/dice_roll_test.go index 5469702..b91e03c 100644 --- a/internal/games/dice_roll_test.go +++ b/internal/games/dice_roll_test.go @@ -7,125 +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{ - teamA: TeamDecisionInput{ - activeAugment: augments.Effect{Name: augments.TwistOfFate}, - roll: 1, - }, - teamB: TeamDecisionInput{ - activeAugment: augments.Effect{Name: augments.TwistOfFate}, - roll: 1, - }, - } - - inputCopy := input - _ = RuleTwistOfFate(input) - - odize.AssertEqual(t, inputCopy, input) - }) - - group.Test("should increase roll for Team A when TwistOfFate provides a higher roll", func(t *testing.T) { - input := DecisionInput{ - teamA: TeamDecisionInput{ - activeAugment: augments.Effect{Name: augments.TwistOfFate}, - roll: 2, - }, - teamB: TeamDecisionInput{ - roll: 3, - }, - } - - mockRoll := func() int { return 5 } - res := ruleTwistOfFate(input, mockRoll) - - odize.AssertEqual(t, 5, res.teamA.roll) - odize.AssertEqual(t, 3, res.teamB.roll) // Team B unchanged - }) - - group.Test("should not change roll for Team A when TwistOfFate provides a lower roll", func(t *testing.T) { - input := DecisionInput{ - teamA: TeamDecisionInput{ - activeAugment: augments.Effect{Name: augments.TwistOfFate}, - roll: 4, - }, - } - - mockRoll := func() int { return 2 } - res := ruleTwistOfFate(input, mockRoll) - - odize.AssertEqual(t, 4, res.teamA.roll) - }) - - group.Test("should increase roll for Team B when TwistOfFate provides a higher roll", func(t *testing.T) { - input := DecisionInput{ - teamB: TeamDecisionInput{ - activeAugment: augments.Effect{Name: augments.TwistOfFate}, - roll: 2, - }, - teamA: TeamDecisionInput{ - roll: 3, - }, - } - - mockRoll := func() int { return 5 } - res := ruleTwistOfFate(input, mockRoll) - - odize.AssertEqual(t, 5, res.teamB.roll) - odize.AssertEqual(t, 3, res.teamA.roll) // Team A unchanged - }) - - group.Test("should not change rolls when TwistOfFate augment is not present", func(t *testing.T) { - input := DecisionInput{ - teamA: TeamDecisionInput{ - activeAugment: augments.Effect{Name: "Some Other Effect"}, - roll: 3, - }, - teamB: TeamDecisionInput{ - roll: 4, - }, - } - - mockRoll := func() int { return 6 } - res := ruleTwistOfFate(input, mockRoll) - - odize.AssertEqual(t, 3, res.teamA.roll) - odize.AssertEqual(t, 4, res.teamB.roll) - }) - - group.Test("should handle both teams having TwistOfFate", func(t *testing.T) { - input := DecisionInput{ - teamA: TeamDecisionInput{ - activeAugment: augments.Effect{Name: augments.TwistOfFate}, - roll: 2, - }, - teamB: TeamDecisionInput{ - activeAugment: augments.Effect{Name: augments.TwistOfFate}, - roll: 3, - }, - } - - rolls := []int{5, 6} // Team B gets 5, Team A gets 6 - idx := 0 - mockRoll := func() int { - val := rolls[idx] - idx++ - return val - } - - res := ruleTwistOfFate(input, mockRoll) - - odize.AssertEqual(t, 6, res.teamA.roll) - odize.AssertEqual(t, 5, res.teamB.roll) - }) - - err := group.Run() - odize.AssertNoError(t, err) -} - func TestEngineRun(t *testing.T) { group := odize.NewGroup(t, nil) @@ -185,9 +66,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 +94,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}, }) @@ -247,6 +130,115 @@ 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, 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) + + // 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) + odize.AssertEqual(t, 4, result.teamB.roll) + 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, 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) + }). + Run() + + odize.AssertNoError(t, err) +} + func newSequentialRollFn(rolls []int) RollFn { idx := 0 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..6cccd82 100644 --- a/internal/games/rounds.go +++ b/internal/games/rounds.go @@ -68,50 +68,11 @@ 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{ - activeAugment: augments.Effect{}, - passivesAugments: []augments.Effect{}, - player: playerA, - roll: 0, - }, - teamB: TeamDecisionInput{ - activeAugment: augments.Effect{}, - passivesAugments: []augments.Effect{}, - player: playerB, - roll: 0, - }, - } - re := rollFn.Run(rollInput) - - for re.Outcome == Draw { - re = rollFn.Run(rollInput) - } - - r.DuelResults = append(r.DuelResults, re) - if re.Outcome == TeamA { - _, err := r.TeamB.LanePop(lane) - if errors.Is(err, ErrNoPlayer) { - break - } - } else { - _, err := r.TeamA.LanePop(lane) - if errors.Is(err, ErrNoPlayer) { - break - } + for r.TeamA.LaneHasPlayers(lane) && r.TeamB.LaneHasPlayers(lane) { + if err := r.executeDuels(lane, rollFn); err != nil { + break } - } if r.TeamA.LaneHasPlayers(lane) { @@ -128,6 +89,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.TeamB.TriggeredAugment) + r.TeamA.Augments = popAugment(r.TeamA.Augments, re.TeamA.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]) } @@ -154,6 +170,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) @@ -173,3 +190,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 +} diff --git a/internal/games/types.go b/internal/games/types.go index 8675d19..c1cd32a 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" ) @@ -49,10 +50,20 @@ 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"` + 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 { @@ -72,20 +83,23 @@ 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"` } 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 { 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/tokens.go b/internal/teams/persona.go similarity index 59% rename from internal/teams/tokens.go rename to internal/teams/persona.go index 4f94f11..7f8c62f 100644 --- a/internal/teams/tokens.go +++ b/internal/teams/persona.go @@ -11,15 +11,24 @@ var _coachPersonaTokens = map[CoachPersona]augments.Category{ CoachPersonaWildcard: augments.CategoryOffense, } -func (p CoachPersona) Augments() []augments.Name { +func (p CoachPersona) Name() string { + return string(p) +} + +func (p CoachPersona) Augments() []augments.Effect { aug, ok := _coachPersonaTokens[p] if !ok { - return []augments.Name{} + return []augments.Effect{} + } + + list, ok := augments.GetByCategory(aug) + if !ok { + return []augments.Effect{} } - return augments.NamesFromCategory(aug) + 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/persona_test.go similarity index 51% rename from internal/teams/tokens_test.go rename to internal/teams/persona_test.go index 52f7999..072901b 100644 --- a/internal/teams/tokens_test.go +++ b/internal/teams/persona_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/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/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/locker_room_list.go b/internal/ui/components/locker_room_list.go index cf15c63..1f69e24 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 "" } @@ -32,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/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() 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/page_create_coach.go b/internal/ui/page_create_coach.go index f085bfe..107b745 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), } } @@ -104,41 +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 + } + + 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 +} - case key.Matches(msg, m.keys.Up), key.Matches(msg, m.keys.ShiftTab): +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 = 1 + 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 > 1 { + if m.focusIndex > 2 { m.focusIndex = 0 } m.updateFocus() + return true + } + + 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.Enter): - if m.focusIndex == 1 { - return m.submit(), true + if m.focusIndex == 2 { + return m.submit(), true + } + + m.focusIndex++ + m.updateFocus() + + return nil, true +} + +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 } - m.focusIndex++ - m.updateFocus() - return nil, false + return true + } - case key.Matches(msg, m.keys.Back): - return func() tea.Msg { - return uistate.MsgSwitchPage{NewPage: uistate.PageTitle} - }, true + 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) { @@ -164,10 +221,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 +239,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 +259,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 +286,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"}) diff --git a/internal/ui/uibattle/page_battle_confirm.go b/internal/ui/uibattle/page_battle_confirm.go index 2ae0b78..e6f2bf1 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, }, } @@ -206,6 +208,7 @@ func newTeamTile(team *teams.Team, coach *teams.Coach, playbook *playbooks.Playb teamName := "" coachName := "" playbookName := "" + persona := "" var playerNames []string if team != nil { @@ -215,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 { 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..5a75a88 --- /dev/null +++ b/internal/ui/uilocker/page_locker_coach_edit.go @@ -0,0 +1,307 @@ +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 MsgSwitchLockerPage: + if msg.NewPage == SubPageLockerCoachEdit { + m.refresh() + } + + case error: + m.err = msg + } + + return m.updateInputs(msg) +} + +func (m *PageLockerCoachEdit) handleKeyMsg(msg tea.KeyMsg) (tea.Cmd, bool) { + 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 + } + + 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 true + } + + 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 true + } + + return false +} + +func (m *PageLockerCoachEdit) handleEnter(msg tea.KeyMsg) (tea.Cmd, bool) { + if !key.Matches(msg, m.keys.Enter) { + return nil, false + } + + if m.focusIndex == 1 { + return m.submit(), true + } + + 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 + } + + if key.Matches(msg, m.keys.Right) { + m.personaIndex++ + if m.personaIndex >= len(m.personas) { + m.personaIndex = 0 + } + return true + } + + return 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) 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 + + 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")