Fix false draw case.

This commit is contained in:
Greyson Parrelli
2026-08-10 16:24:32 -04:00
parent 6ae4d41976
commit 84900415b2
3 changed files with 44 additions and 15 deletions
+4 -3
View File
@@ -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 15, 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 15, 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.
+18 -12
View File
@@ -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
+22
View File
@@ -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)
}
}