diff --git a/README.md b/README.md index 9bb94f7..f67df2a 100644 --- a/README.md +++ b/README.md @@ -116,9 +116,10 @@ Six rounds, each with its own shop tier deck. Per round: (Peacock, Camel, Gorilla). Shields (Turtle/Gorilla/Melon) block whole hits, Garlic shaves 1 per attack, and Scorpion KOs anything its clash attack manages to hurt. Hippo heals when enemies faint; Rhino rocks each - enemy pet as it's played. A clash that changes nothing ends the battle - as a stalemate draw. Last player able to field a pet wins: 1 trophy for - rounds 1–5, 2 for round 6. Draws award nothing. + enemy pet as it's played. A clash that changes nothing is a stalemate: + both pets faint so the ones behind them can settle it. Last player able + to field a pet wins: 1 trophy for rounds 1–5, 2 for round 6. A battle + that runs both sides out is a draw, and draws award nothing. Apples and bees are **temporary**: they leave your deck after the battle. Most trophies after round 6 wins. diff --git a/internal/game/battle.go b/internal/game/battle.go index 3f6c67a..b8dde32 100644 --- a/internal/game/battle.go +++ b/internal/game/battle.go @@ -392,8 +392,8 @@ func effectCount(e Effect, s *battleSide, u *BattleUnit, enemy *battleSide) int // pet with Damage >= Power faints, firing Faint effects. Survivors that // took damage fire Hurt effects. Shields (Turtle, Gorilla, Melon) block // entire hits; Garlic shaves 1 from each; Scorpion KOs whatever its clash -// attack manages to hurt. A clash that changes nothing ends the battle as a -// stalemate. +// attack manages to hurt. A clash that changes nothing is a stalemate: that +// pair both faint so the pets behind them can settle the battle. // // resolveBattles is the orchestrator: it runs each (deterministic) simulation // and publishes the completed results. @@ -1807,12 +1807,18 @@ func (g *Game) runBattle(first, second int) *BattleResult { if dealtB > 0 && ua.hasKnockout() { ub.Damage = max(ub.Damage, ub.Power()) } + // Stalemate: the clash changed nothing at all, so repeating it never + // will either — this pair is stuck no matter how many exchanges it + // gets. Resolve it like a deadlock (below) rather than ending the + // battle: the pets *behind* these two can still settle it. + stuck := ua.Alive() && ub.Alive() && dealtA == 0 && dealtB == 0 && + blockA == nil && blockB == nil && prevA == nil && prevB == nil // Deadlock guard: if the pair keeps surviving, count the exchanges and // force a mutual knockout once they hit the limit. deadlocked := false if ua.Alive() && ub.Alive() { clashStreak++ - if clashStreak >= deadlockLimit { + if stuck || clashStreak >= deadlockLimit { deadlocked = true ua.Damage = max(ua.Damage, ua.Power()) ub.Damage = max(ub.Damage, ub.Power()) @@ -1823,6 +1829,9 @@ func (g *Game) runBattle(first, second int) *BattleResult { clashTxt := fmt.Sprintf("%s's %s and %s's %s trade blows.", pname(0), ua.Card.Name, pname(1), ub.Card.Name) switch da, db := !ua.Alive(), !ub.Alive(); { + case stuck: + clashTxt = fmt.Sprintf("%s's %s and %s's %s can't hurt each other — both faint.", + pname(0), ua.Card.Name, pname(1), ub.Card.Name) case deadlocked: clashTxt = fmt.Sprintf("%s's %s and %s's %s are deadlocked after %d clashes — both faint.", pname(0), ua.Card.Name, pname(1), ub.Card.Name, deadlockLimit) @@ -1851,10 +1860,6 @@ func (g *Game) runBattle(first, second int) *BattleResult { if prevB != nil { emitPrevent(1, ub.Card.Name, prevB) } - if ua.Alive() && ub.Alive() && dealtA == 0 && dealtB == 0 && - blockA == nil && blockB == nil && prevA == nil && prevB == nil { - break // stalemate: nothing can ever change - } dealt := []int{dealtA, dealtB} for seat, u := range []*BattleUnit{ua, ub} { if !u.Alive() { @@ -1874,15 +1879,16 @@ func (g *Game) runBattle(first, second int) *BattleResult { } // A single side that can still field a pet wins; anything else (both out, - // or a stalemate with pets on both sides) is a draw. We test canField, - // not unit, because the loop can break the instant one side runs out while - // the other's current pet has just fainted — that side still has pets left - // in its stack (it simply wasn't refilled) and is the rightful winner. + // or the iteration cap tripping with pets on both sides) is a draw. We test + // canField, not unit, because the loop can break the instant one side runs + // out while the other's current pet has just fainted — that side still has + // pets left in its stack (it simply wasn't refilled) and is the rightful + // winner. winner := -1 for side, s := range sides { if s.canField() { if winner >= 0 { - winner = -1 // both still standing: a stalemate draw + winner = -1 // both still standing: a draw break } winner = side diff --git a/internal/game/regression_test.go b/internal/game/regression_test.go index 347150b..547b766 100644 --- a/internal/game/regression_test.go +++ b/internal/game/regression_test.go @@ -24,3 +24,25 @@ func TestCrocodileLastPetVolleyFiresWhenOwnerOut(t *testing.T) { t.Fatalf("both sides should be out (draw), got winner %d", res.WinnerSeat) } } + +// Regression: a pair that can't hurt each other (two 0-attack pets, here +// Spooked down to nothing) must both faint so the pets behind them settle the +// battle. Previously the stalemate ended the whole battle in a draw, even with +// most of both decks still to come. +func TestStalematedPairFaintsAndBattleContinues(t *testing.T) { + g, p1, _ := unicornGame(t) + p1.Mana = 2 + res := forceBattle(t, g, + // Nightcrawler (1 power) Spooks Barghest twice; Barghest (1 power) + // Spooks it once. Neither can deal damage, but Pengobble is next up. + []Card{g.unicornPet(t, "Nightcrawler"), g.unicornPet(t, "Pengobble")}, + []Card{g.unicornPet(t, "Barghest"), g.pet("Chaff", 1)}, + ) + clashes := eventsOfType(res, "clash") + if len(clashes) != 1 || !clashes[0].Died[0] || !clashes[0].Died[1] { + t.Fatalf("the stuck pair should both faint in one clash: %+v", clashes) + } + if res.WinnerSeat != 0 { + t.Fatalf("Pengobble should go on to win for seat 0, got winner %d", res.WinnerSeat) + } +}