diff --git a/internal/game/battle.go b/internal/game/battle.go index 7693590..739fb6a 100644 --- a/internal/game/battle.go +++ b/internal/game/battle.go @@ -440,6 +440,14 @@ func (g *Game) resolveBattle() { // The exchange loop terminates: every clash kills at least one pet or // is a detected stalemate, and summons/auras are finite. The guard is // just insurance as effects get richer. + // + // clashStreak counts how many times the current pair has clashed without + // either fainting. Some effects (e.g. eat-an-apple-when-hurt) can make two + // pets un-killable, looping forever; if they trade blows deadlockLimit + // times in a row, both drop so the battle can proceed. It resets whenever + // the matchup changes (a fresh pet enters). + const deadlockLimit = 9 + clashStreak := 0 for range 10_000 { // Reveal until every side has a pet in play or runs out. Play // effects queue up and resolve after all reveals (simultaneous). @@ -525,6 +533,15 @@ func (g *Game) resolveBattle() { } } + // A fresh pet on either side is a new matchup, so the deadlock streak + // starts over. + for _, np := range newlyPlayed { + if np { + clashStreak = 0 + break + } + } + // Battle over? A side that couldn't field a pet is out; no further // effects resolve. anyOut := false @@ -657,9 +674,25 @@ func (g *Game) resolveBattle() { if dealtB > 0 && ua.hasKnockout() { ub.Damage = max(ub.Damage, ub.Power()) } + // 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 { + deadlocked = true + ua.Damage = max(ua.Damage, ua.Power()) + ub.Damage = max(ub.Damage, ub.Power()) + } + } else { + clashStreak = 0 + } 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 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) case da && db: clashTxt += " Both faint." case da: diff --git a/internal/game/battle_test.go b/internal/game/battle_test.go index 0179f1b..1bc6281 100644 --- a/internal/game/battle_test.go +++ b/internal/game/battle_test.go @@ -736,3 +736,28 @@ func TestSkunkStripFaintsDebuffedPet(t *testing.T) { t.Fatalf("skunk should win once the stripped pet faints, got %d", res.WinnerSeat) } } + +// Two pets that block every hit could clash forever; the deadlock rule makes +// them both faint after 9 clashes so the battle can proceed. +func TestBattleDeadlockBreaksAfterNineClashes(t *testing.T) { + g, _, _ := testGame(t) + wall := func(name string) Card { + return Card{ + ID: g.newCardID(), Kind: KindPet, Name: name, Tier: 1, Power: 3, Suit: SuitRed, + Effects: []Effect{{Trigger: TriggerPlay, Action: ActionShieldSelf, Count: 20}}, + } + } + res := forceBattle(t, g, []Card{wall("Wall")}, []Card{wall("Fort")}) + + clashes := eventsOfType(res, "clash") + if len(clashes) != 9 { + t.Fatalf("deadlock should resolve after 9 clashes, got %d", len(clashes)) + } + last := clashes[len(clashes)-1] + if !last.Died[0] || !last.Died[1] { + t.Fatalf("the 9th clash should faint both pets: %+v", last) + } + if res.WinnerSeat != -1 { + t.Fatalf("a deadlock is a draw, got winner=%d", res.WinnerSeat) + } +}