From 8d43bbf61e9cda4943e61cf515792a02abe1fd4a Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Wed, 22 Jul 2026 23:43:56 -0400 Subject: [PATCH] Add pets for tiers 1-3. --- README.md | 45 ++- internal/game/battle.go | 419 +++++++++++++++++----- internal/game/battle_test.go | 516 ++++++++++++++++++++++++++-- internal/game/cards.go | 360 ++++++++++++++++--- internal/game/game.go | 88 ++++- internal/game/game_test.go | 176 +++++++++- internal/server/ws.go | 6 +- web/src/components/ArrangePhase.tsx | 14 +- web/src/components/BattlePhase.tsx | 310 +++++++++++------ web/src/components/CardView.tsx | 30 +- web/src/components/ShopPhase.tsx | 24 +- web/src/petArt.ts | 32 +- web/src/styles.css | 263 ++++++++++++-- web/src/types.ts | 38 +- 14 files changed, 1915 insertions(+), 406 deletions(-) diff --git a/README.md b/README.md index 75a6d5a..88eed6c 100644 --- a/README.md +++ b/README.md @@ -47,23 +47,36 @@ Set via environment or a `.env` file (see `.env.example`): Six rounds, each with its own shop tier deck. Per round: -1. **Shop** β€” each player has 3 coins and players alternate actions, 1 coin - each: **buy** one of 4 face-up cards; **discard** any number of hand cards - (each becomes an 🍎 apple, +1 power food); or **trade in** 3 same-suit pets - to pick 1 of the top 2 cards of the next tier's deck (the other goes under - that deck). Passing forfeits remaining coins. -2. **Cleanup** β€” anyone holding more than 5 pets must discard down to 5 - (discards become apples). -3. **Arrange** β€” players secretly order their decks. Food cards apply to the - next pet after them; trailing foods are wasted. -4. **Battle** β€” automatic. Front pets simultaneously deal their full power to - each other as damage markers; a pet with markers β‰₯ power dies (attack - power never drops while wounded). Last player with pets standing wins the - round: 1 trophy for rounds 1–5, 2 trophies for round 6. Draws award - nothing. +1. **Shop** β€” each player has 3 gold and players alternate actions, 1 gold + each: **buy** one of 4 face-up cards; **sell** any number of hand cards + (each becomes an 🍎 apple, +1 power food, and Sell effects fire); or + **trade in** 3 same-suit pets (the Triple action) to pick 1 of the top 2 + cards of the next tier's deck β€” Triple effects fire on the traded cards + and the received pet's Buy effect fires. Passing forfeits remaining gold. +2. **Cleanup** β€” anyone holding more than 5 pets must sell down to 5. +3. **Arrange** β€” Battle Prep effects fire first (e.g. Giraffe hands out + apples), then players secretly order their decks. Food cards apply to the + next pet after them; trailing foods are wasted. A pet only benefits from + its last-applied **perk** (e.g. Honey, Garlic). +4. **Battle** β€” automatic stack machine. Cards reveal off the top of each + deck until a pet is in play. Play effects fire on reveal (rocks roll a d6 + with faces 0/0/1/1/2/2 and hit the opposing pet before the clash). The two + pets simultaneously deal their full power to each other as damage markers; + a pet with markers β‰₯ power faints (attack never drops while wounded). + Faint effects can push cards (apples, bees) onto either deck, recycle a + pet's apples (Dodo), or set aside delayed rocks that hit BOTH active pets + (Badger); survivors that took damage fire Hurt effects. Garlic prevents 1 + damage from every attack that hits its pet β€” a clash that damages no one + 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. -Most trophies after round 6 wins. Pet effects are not yet implemented (the -card model carries an `effect` field for when they land). +Apples and bees are **temporary**: they leave your deck after the battle. +Most trophies after round 6 wins. + +Tiers 1–3 use real card data (T1: Ant, Cricket, Duck, Otter, Mosquito, Fish; +T2: Worm, Flamingo, Peacock, Swan, Rat, Spider + Honey; T3: Dog, Dolphin, +Giraffe, Camel, Sheep, Dodo, Badger + Garlic). Tiers 4–6 are effect-less +placeholders awaiting their real definitions. ## Layout diff --git a/internal/game/battle.go b/internal/game/battle.go index f8e9a0d..37b869a 100644 --- a/internal/game/battle.go +++ b/internal/game/battle.go @@ -1,117 +1,376 @@ package game -// BattleUnit is a pet on the battle line with its attached foods applied. -// Power (attack) is unaffected by damage; a unit dies when Damage >= Power. +// BattleUnit is a pet in play with its attached foods applied. Power +// (attack) is unaffected by damage; a unit dies when Damage >= Power. type BattleUnit struct { Card Card `json:"card"` - Foods []Card `json:"foods"` - Bonus int `json:"bonus"` // total power added by foods + Foods []Card `json:"foods,omitempty"` + Bonus int `json:"bonus"` // total power added by foods (and eating) Damage int `json:"damage"` // damage markers accumulated this battle } func (u *BattleUnit) Power() int { return u.Card.Power + u.Bonus } func (u *BattleUnit) Alive() bool { return u.Damage < u.Power() } +// activePerk returns the perk this unit benefits from: only the +// last-applied perk counts when several are attached. +func (u *BattleUnit) activePerk() *Card { + for i := len(u.Foods) - 1; i >= 0; i-- { + if u.Foods[i].Perk { + return &u.Foods[i] + } + } + return nil +} + +// effects returns the unit's live effects: its own plus its active perk's. +func (u *BattleUnit) effects() []Effect { + effs := u.Card.Effects + if perk := u.activePerk(); perk != nil { + effs = append(append([]Effect(nil), effs...), perk.Effects...) + } + return effs +} + +// prevention totals the unit's passive per-attack damage reduction (Garlic). +func (u *BattleUnit) prevention() int { + total := 0 + for _, e := range u.effects() { + if e.Trigger == TriggerPassive && e.Action == ActionPreventDamage { + total += e.count() + } + } + return total +} + +// takeHit applies one attack of the given strength and returns the damage +// actually dealt after prevention. +func (u *BattleUnit) takeHit(amount int) int { + if amount <= 0 { + return 0 + } + dealt := max(0, amount-u.prevention()) + u.Damage += dealt + return dealt +} + +// isBee reports whether a card is a summoned Bee. +func isBee(c Card) bool { return c.IsPet() && c.Name == "Bee" } + // BattleEvent is one step of the battle, in order, for clients to animate. type BattleEvent struct { - Type string `json:"type"` // "clash" - // Indexes into the initial lineups (per seat) of the two front pets. - Units []int `json:"units"` // one entry per seat - // Damage each front pet has accumulated after the clash, per seat. - Damage []int `json:"damage"` - // Whether each front pet died in the clash, per seat. - Died []bool `json:"died"` + // "reveal": Seat flipped Card off their stack (food or pet). + // "summon": an effect put Card on top of Seat's stack. + // "rock": Seat's pet threw rocks at Target's pet in play. + // "clash": the pets in play traded blows. + // "eat": Seat's pet ate apples; Bonus is its new total. + Type string `json:"type"` + Seat int `json:"seat,omitempty"` + Target int `json:"target,omitempty"` + Card *Card `json:"card,omitempty"` + // clash: per-seat damage totals / deaths after the exchange. + Damage []int `json:"damage,omitempty"` + Died []bool `json:"died,omitempty"` + // rock: dice total rolled and the target pet's resulting state. + Roll int `json:"roll"` + DamageAfter int `json:"damageAfter,omitempty"` + TargetDied bool `json:"targetDied,omitempty"` + // eat: the pet's power bonus after eating. + Bonus int `json:"bonus,omitempty"` } -// BattleResult is the full, public record of one round's battle. +// BattleResult is the full, public record of one round's battle. Only what +// was revealed appears in Events; the rest of the winner's stack stays +// hidden. type BattleResult struct { - Round int `json:"round"` - Lineups [][]BattleUnit `json:"lineups"` // initial lineups per seat - WastedFood [][]Card `json:"wastedFoods"` // foods with no pet beneath them, per seat - Events []BattleEvent `json:"events"` - WinnerSeat int `json:"winnerSeat"` // -1 = draw - Trophies int `json:"trophies"` // awarded to the winner + Round int `json:"round"` + StackSizes []int `json:"stackSizes"` // starting deck size per seat + Events []BattleEvent `json:"events"` + WinnerSeat int `json:"winnerSeat"` // -1 = draw + Trophies int `json:"trophies"` // awarded to the winner } -// buildLineup walks a deck top-to-bottom, attaching each run of foods to the -// next pet below it. Foods after the last pet affect nothing and are wasted. -func buildLineup(deck []Card) (units []BattleUnit, wasted []Card) { - var pendingFoods []Card - for _, c := range deck { - if c.IsFood() { - pendingFoods = append(pendingFoods, c) - continue - } - u := BattleUnit{Card: c, Foods: pendingFoods} - for _, f := range pendingFoods { - if f.Food == FoodApple { - u.Bonus++ - } - } - pendingFoods = nil - units = append(units, u) - } - return units, pendingFoods +// battleSide is one seat's live state during the simulation. +type battleSide struct { + stack []Card // remaining face-down cards, top first + pending []Card // foods revealed and waiting for a pet + unit *BattleUnit + beesFainted int // friendly bees fainted so far (Dog) + delayedRocks []int // set-aside Badgers: rocks to throw when the next pet plays +} + +// queuedPlay is a play-time effect waiting to resolve after reveals. +type queuedPlay struct { + seat int + unit *BattleUnit // the pet whose play queued this (must still be in play for eats) + effect Effect + badger bool // delayedRocks payout: hits EVERY active pet, own included } // resolveBattle simulates the battle from the players' arranged decks, // records the event log, awards trophies, and moves to PhaseBattle. // -// Combat: the two front pets deal their full Power to each other -// simultaneously as damage markers. A pet with Damage >= Power dies. Since -// remaining health never exceeds Power, at least one pet dies every clash, -// so the loop always terminates (until effects say otherwise). +// The battle is a stack machine: each side reveals cards off the top of +// their deck until a pet is in play (foods along the way attach to it; only +// the last-applied perk counts). If anyone can no longer field a pet the +// battle ends. Otherwise play effects resolve β€” rocks roll dice (faces +// 0,0,1,1,2,2) and can faint a pet before the clash. Then the two pets deal +// their full Power to each other simultaneously as damage markers; a pet +// with Damage >= Power faints, firing Faint effects. Survivors that took +// damage fire Hurt effects. Garlic prevents 1 damage from every attack that +// hits its pet; a clash that deals no damage to anyone ends the battle as a +// stalemate. func (g *Game) resolveBattle() { - res := &BattleResult{ - Round: g.Round, - Lineups: make([][]BattleUnit, len(g.Players)), - WastedFood: make([][]Card, len(g.Players)), - WinnerSeat: -1, - } - live := make([][]BattleUnit, len(g.Players)) // working copies - front := make([]int, len(g.Players)) // index of each seat's front pet + n := len(g.Players) + res := &BattleResult{Round: g.Round, WinnerSeat: -1, StackSizes: make([]int, n)} + sides := make([]*battleSide, n) for _, p := range g.Players { - units, wasted := buildLineup(p.Deck) - res.Lineups[p.Seat] = units - res.WastedFood[p.Seat] = wasted - live[p.Seat] = append([]BattleUnit(nil), units...) + sides[p.Seat] = &battleSide{stack: append([]Card(nil), p.Deck...)} + res.StackSizes[p.Seat] = len(p.Deck) + } + emit := func(ev BattleEvent) { res.Events = append(res.Events, ev) } + + summon := func(seat int, c Card) { + s := sides[seat] + s.stack = append([]Card{c}, s.stack...) + emit(BattleEvent{Type: "summon", Seat: seat, Card: &c}) } - // Two-player combat. Effects and >2 player battle formats come later; - // the surrounding state (lineups, events) is already per-seat. - a, b := 0, 1 - for front[a] < len(live[a]) && front[b] < len(live[b]) { - ua, ub := &live[a][front[a]], &live[b][front[b]] - ua.Damage += ub.Power() - ub.Damage += ua.Power() - ev := BattleEvent{ + // faint fires the unit's faint effects (its own and its perk's) in + // effect order, and tracks fainted bees. + faint := func(seat int, u *BattleUnit) { + if isBee(u.Card) { + sides[seat].beesFainted++ + } + for _, e := range u.effects() { + if e.Trigger != TriggerFaint { + continue + } + switch e.Action { + case ActionSummonTop: + target := seat + if e.Target == "enemy" { + target = (seat + 1) % n + } + for range e.count() { + if e.Card == "bee" { + summon(target, g.newBee()) + } else { + summon(target, g.newApple()) + } + } + case ActionRecycleApples: + recycled := 0 + for _, f := range u.Foods { + if f.Food == FoodApple && recycled < e.count() { + summon(seat, f) + recycled++ + } + } + case ActionDelayedRocks: + sides[seat].delayedRocks = append(sides[seat].delayedRocks, e.count()) + } + } + } + + // hurt fires Hurt effects on a pet that was damaged and survived. + hurt := func(seat int, u *BattleUnit) { + if !u.Alive() { + return + } + for _, e := range u.effects() { + if e.Trigger != TriggerHurt { + continue + } + switch e.Action { + case ActionEatApple: + for range e.count() { + u.Foods = append(u.Foods, g.newApple()) + u.Bonus++ + } + emit(BattleEvent{Type: "eat", Seat: seat, Bonus: u.Bonus}) + case ActionSummonTop: + for range e.count() { + if e.Card == "bee" { + summon(seat, g.newBee()) + } else { + summon(seat, g.newApple()) + } + } + } + } + } + + // throwRocks rolls `dice` rock dice against one target's pet, handling + // prevention, death (with faint), and hurt. Reports whether it killed. + throwRocks := func(from, target, dice int) (killed bool) { + tu := sides[target].unit + if tu == nil { + return false + } + roll := 0 + for range dice { + roll += g.rollRockDie() + } + dealt := tu.takeHit(roll) + died := !tu.Alive() + emit(BattleEvent{ + Type: "rock", Seat: from, Target: target, Roll: roll, + DamageAfter: tu.Damage, TargetDied: died, + }) + if died { + faint(target, tu) + sides[target].unit = nil + return true + } + if dealt > 0 { + hurt(target, tu) + } + return false + } + + // The exchange loop terminates: every clash kills at least one pet or + // is a detected stalemate, and summons are finite. The guard is just + // insurance as effects get richer. + 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). + var plays []queuedPlay + for seat, s := range sides { + for s.unit == nil && len(s.stack) > 0 { + c := s.stack[0] + s.stack = s.stack[1:] + emit(BattleEvent{Type: "reveal", Seat: seat, Card: &c}) + if c.IsFood() { + s.pending = append(s.pending, c) + continue + } + u := &BattleUnit{Card: c, Foods: s.pending} + for _, f := range s.pending { + if f.Food == FoodApple { + u.Bonus++ + } + } + s.pending = nil + s.unit = u + // Set-aside Badgers pay out when the next pet plays, + // before that pet's own play effects. + for _, dice := range s.delayedRocks { + plays = append(plays, queuedPlay{seat: seat, unit: u, badger: true, + effect: Effect{Action: ActionThrowRock, Count: dice}}) + } + s.delayedRocks = nil + for _, e := range u.effects() { + if e.Trigger == TriggerPlay { + plays = append(plays, queuedPlay{seat: seat, unit: u, effect: e}) + } + } + } + } + + // Battle over? A side that couldn't field a pet is out; no further + // effects resolve. + anyOut := false + for _, s := range sides { + if s.unit == nil { + anyOut = true + } + } + if anyOut { + break + } + + // Resolve play effects. Rocks land before the clash and can faint + // pets outright. + rockDeath := false + for _, q := range plays { + switch q.effect.Action { + case ActionThrowRock: + if q.badger { + // At EACH active pet, the owner's own included. + for seat := range sides { + if throwRocks(q.seat, seat, q.effect.count()) { + rockDeath = true + } + } + } else { + target := -1 + for off := 1; off < n; off++ { + cand := (q.seat + off) % n + if sides[cand].unit != nil { + target = cand + break + } + } + if target >= 0 && throwRocks(q.seat, target, q.effect.count()) { + rockDeath = true + } + } + case ActionEatApple: + // Skip if the eater already died (e.g. to Badger rocks). + if sides[q.seat].unit != q.unit { + continue + } + count := q.effect.count() + if q.effect.Per == PerFaintedBees { + count *= sides[q.seat].beesFainted + } + if count > 0 { + for range count { + q.unit.Foods = append(q.unit.Foods, g.newApple()) + q.unit.Bonus++ + } + emit(BattleEvent{Type: "eat", Seat: q.seat, Bonus: q.unit.Bonus}) + } + } + } + if rockDeath { + continue // refill before any clash + } + + // Clash. Two-player for now; >2-player battle pairings come later + // (the surrounding state is already per-seat). + ua, ub := sides[0].unit, sides[1].unit + dealtA := ua.takeHit(ub.Power()) + dealtB := ub.takeHit(ua.Power()) + emit(BattleEvent{ Type: "clash", - Units: []int{front[a], front[b]}, Damage: []int{ua.Damage, ub.Damage}, Died: []bool{!ua.Alive(), !ub.Alive()}, + }) + if ua.Alive() && ub.Alive() && dealtA == 0 && dealtB == 0 { + break // stalemate: nothing can ever change } - res.Events = append(res.Events, ev) - if !ua.Alive() { - front[a]++ - } - if !ub.Alive() { - front[b]++ + dealt := []int{dealtA, dealtB} + for seat, u := range []*BattleUnit{ua, ub} { + if !u.Alive() { + faint(seat, u) + sides[seat].unit = nil + } else if dealt[seat] > 0 { + hurt(seat, u) + } } } - trophies := 1 - if g.Round == MaxRounds { - trophies = 2 + // A single side still holding a pet in play wins; anything else + // (everyone out, or a stalemate with pets on both sides) is a draw. + winner := -1 + for seat, s := range sides { + if s.unit != nil { + if winner >= 0 { + winner = -1 // stalemate / >2-player safety + break + } + winner = seat + } } - switch { - case front[a] < len(live[a]): - res.WinnerSeat = a - case front[b] < len(live[b]): - res.WinnerSeat = b - } - if res.WinnerSeat >= 0 { - res.Trophies = trophies - g.Players[res.WinnerSeat].Trophies += trophies + res.WinnerSeat = winner + if winner >= 0 { + res.Trophies = 1 + if g.Round == MaxRounds { + res.Trophies = 2 + } + g.Players[winner].Trophies += res.Trophies } g.Battle = res diff --git a/internal/game/battle_test.go b/internal/game/battle_test.go index 01a4539..95856f7 100644 --- a/internal/game/battle_test.go +++ b/internal/game/battle_test.go @@ -1,6 +1,9 @@ package game -import "testing" +import ( + "slices" + "testing" +) // testGame builds a started 2-player game without going through the lobby. func testGame(t *testing.T) (*Game, *Player, *Player) { @@ -20,10 +23,52 @@ func testGame(t *testing.T) (*Game, *Player, *Player) { return g, p1, p2 } +// pet mints a plain effect-less pet for battle scenarios. func (g *Game) pet(name string, power int) Card { - return Card{ID: g.newCardID(), Kind: KindPet, Name: name, Tier: 1, Power: power, Suit: SuitSun} + return Card{ID: g.newCardID(), Kind: KindPet, Name: name, Tier: 1, Power: power, Suit: SuitRed} } +// realPet mints a copy of a real pet (with its effects) by name, searching +// all tiers. +func (g *Game) realPet(t *testing.T, name string) Card { + t.Helper() + for tierIdx, tier := range petTiers { + for _, tmpl := range tier { + if tmpl.Name == name { + return Card{ + ID: g.newCardID(), Kind: KindPet, Name: tmpl.Name, Tier: tierIdx + 1, + Power: tmpl.Power, Suit: tmpl.Suits[0], + Effects: tmpl.Effects, EffectText: tmpl.EffectText, + } + } + } + } + t.Fatalf("no pet named %s", name) + return Card{} +} + +// tier1 is kept as a shorthand for realPet. +func (g *Game) tier1(t *testing.T, name string) Card { return g.realPet(t, name) } + +// realFood mints a shop food card (Honey, Garlic, ...) from its template. +func (g *Game) realFood(t *testing.T, name string) Card { + t.Helper() + for tierIdx, tier := range foodTiers { + for _, f := range tier { + if f.Name == name { + return Card{ + ID: g.newCardID(), Kind: KindFood, Name: f.Name, Tier: tierIdx + 1, + Food: f.Food, Perk: f.Perk, Effects: f.Effects, EffectText: f.EffectText, + } + } + } + } + t.Fatalf("no food named %s", name) + return Card{} +} + +func (g *Game) newHoney(t *testing.T) Card { return g.realFood(t, "Honey") } + // forceBattle sets both decks, arranges them in current order, and resolves. func forceBattle(t *testing.T, g *Game, d1, d2 []Card) *BattleResult { t.Helper() @@ -47,6 +92,16 @@ func forceBattle(t *testing.T, g *Game, d1, d2 []Card) *BattleResult { return g.Battle } +func eventsOfType(res *BattleResult, typ string) []BattleEvent { + var out []BattleEvent + for _, ev := range res.Events { + if ev.Type == typ { + out = append(out, ev) + } + } + return out +} + // The worked example from the rules: a 3-power pet fights a 5-power pet. The // 3 dies, the 5 survives with 3 damage markers (2 health left, 5 attack). // Then a 2-power pet trades with it: both die. @@ -56,17 +111,18 @@ func TestBattleDamageMarkers(t *testing.T) { []Card{g.pet("Three", 3), g.pet("Two", 2)}, []Card{g.pet("Five", 5)}, ) - if len(res.Events) != 2 { - t.Fatalf("expected 2 clashes, got %d", len(res.Events)) + clashes := eventsOfType(res, "clash") + if len(clashes) != 2 { + t.Fatalf("expected 2 clashes, got %d", len(clashes)) } - first := res.Events[0] + first := clashes[0] if !first.Died[0] || first.Died[1] { t.Fatalf("first clash: 3-power should die, 5-power should survive: %+v", first) } if first.Damage[1] != 3 { t.Fatalf("5-power pet should carry 3 damage, has %d", first.Damage[1]) } - second := res.Events[1] + second := clashes[1] if !second.Died[0] || !second.Died[1] { t.Fatalf("second clash: both should die (5 attack kills the 2; 3+2 damage kills the 5): %+v", second) } @@ -84,9 +140,9 @@ func TestBattleEqualPowerBothDie(t *testing.T) { []Card{g.pet("A", 4)}, []Card{g.pet("B", 4)}, ) - ev := res.Events[0] - if !ev.Died[0] || !ev.Died[1] { - t.Fatalf("equal power pets should both die: %+v", ev) + clashes := eventsOfType(res, "clash") + if len(clashes) != 1 || !clashes[0].Died[0] || !clashes[0].Died[1] { + t.Fatalf("equal power pets should both die: %+v", clashes) } if res.WinnerSeat != -1 { t.Fatal("expected a draw") @@ -119,38 +175,448 @@ func TestBattleFinalRoundWorthTwoTrophies(t *testing.T) { } } -// Foods stack onto the next pet beneath them; each apple adds 1 power. -// Trailing foods with no pet under them are wasted. +// Foods attach to the next pet revealed beneath them; each apple adds 1 +// power. 3+2 apples = 5 power draws with a plain 5. func TestBattleApplesBuffNextPet(t *testing.T) { g, _, _ := testGame(t) - apple1, apple2, apple3 := g.newApple(), g.newApple(), g.newApple() res := forceBattle(t, g, - []Card{apple1, apple2, g.pet("Buffed", 3), apple3}, // 3+2=5 power; apple3 wasted + []Card{g.newApple(), g.newApple(), g.pet("Buffed", 3)}, []Card{g.pet("Enemy", 5)}, ) - u := res.Lineups[0][0] - if u.Bonus != 2 || u.Power() != 5 { - t.Fatalf("expected 2 apples for 5 total power, got bonus=%d power=%d", u.Bonus, u.Power()) - } - if len(res.WastedFood[0]) != 1 || res.WastedFood[0][0].ID != apple3.ID { - t.Fatalf("trailing apple should be wasted: %+v", res.WastedFood[0]) + clash := eventsOfType(res, "clash")[0] + if !clash.Died[0] || !clash.Died[1] { + t.Fatalf("5 vs 3+2apples should kill both: %+v", clash) } if res.WinnerSeat != -1 { - t.Fatal("5 vs 5 should draw") + t.Fatal("expected draw") } } -// A player with an empty lineup loses immediately with zero clashes. -func TestBattleEmptyLineupLoses(t *testing.T) { +// A side whose stack holds only foods can't field a pet and loses without a +// single clash. +func TestBattleFoodOnlyLoses(t *testing.T) { g, _, _ := testGame(t) res := forceBattle(t, g, []Card{g.pet("Solo", 1)}, - []Card{g.newApple()}, // food only, no pets + []Card{g.newApple()}, ) - if len(res.Events) != 0 { - t.Fatalf("expected no clashes, got %d", len(res.Events)) + if len(eventsOfType(res, "clash")) != 0 { + t.Fatal("expected no clashes") } if res.WinnerSeat != 0 { t.Fatalf("seat 0 should win by default, got %d", res.WinnerSeat) } } + +// Ant's faint puts an apple on top of its owner's stack, buffing the next +// pet revealed: Ant(1)+Follower(2) beats a plain 2. +func TestAntFaintAddsApple(t *testing.T) { + g, _, _ := testGame(t) + res := forceBattle(t, g, + []Card{g.tier1(t, "Ant"), g.pet("Follower", 2)}, + []Card{g.pet("Enemy", 2)}, + ) + summons := eventsOfType(res, "summon") + if len(summons) != 1 || summons[0].Seat != 0 || summons[0].Card.Food != FoodApple { + t.Fatalf("ant faint should summon an apple for seat 0: %+v", summons) + } + // Follower fights at 3 power vs enemy at 2 power with 1 damage: enemy + // dies, follower survives (2 damage < 3 power). + if res.WinnerSeat != 0 { + t.Fatalf("apple-buffed follower should win, got seat %d", res.WinnerSeat) + } +} + +// Cricket's faint summons a bee, which fights as a real 1-power pet. +func TestCricketFaintSummonsBee(t *testing.T) { + g, _, _ := testGame(t) + res := forceBattle(t, g, + []Card{g.tier1(t, "Cricket")}, + []Card{g.pet("Enemy", 3)}, + ) + summons := eventsOfType(res, "summon") + if len(summons) != 1 || summons[0].Card.Name != "Bee" { + t.Fatalf("cricket faint should summon a bee: %+v", summons) + } + beeRevealed := false + for _, ev := range eventsOfType(res, "reveal") { + if ev.Card.Name == "Bee" { + beeRevealed = true + } + } + if !beeRevealed { + t.Fatal("the summoned bee should be revealed and fight") + } + // Cricket (1) then Bee (1) chip the 3-power enemy for 2 total; enemy + // survives with 2 damage and wins. + if res.WinnerSeat != 1 { + t.Fatalf("enemy should survive with 2 damage, got winner %d", res.WinnerSeat) + } + if len(eventsOfType(res, "clash")) != 2 { + t.Fatal("expected two clashes: cricket then bee") + } +} + +// Mosquito's rock lands before the clash and can kill outright: with a +// rigged roll of 1, a 1-power enemy dies to the rock and the mosquito never +// takes damage. +func TestMosquitoRockKillsBeforeClash(t *testing.T) { + g, _, _ := testGame(t) + g.RollDie = func() int { return 1 } + res := forceBattle(t, g, + []Card{g.tier1(t, "Mosquito")}, + []Card{g.pet("Weakling", 1)}, + ) + rocks := eventsOfType(res, "rock") + if len(rocks) != 1 || rocks[0].Seat != 0 || rocks[0].Target != 1 { + t.Fatalf("expected one rock from seat 0 at seat 1: %+v", rocks) + } + if rocks[0].Roll != 1 || !rocks[0].TargetDied || rocks[0].DamageAfter != 1 { + t.Fatalf("rock should kill the 1-power pet: %+v", rocks[0]) + } + if len(eventsOfType(res, "clash")) != 0 { + t.Fatal("no clash should happen; the rock already won it") + } + if res.WinnerSeat != 0 { + t.Fatalf("mosquito should win, got %d", res.WinnerSeat) + } +} + +// A rock can roll blank faces: 0 damage, no effect, then a normal clash. +func TestMosquitoRockCanMiss(t *testing.T) { + g, _, _ := testGame(t) + g.RollDie = func() int { return 0 } + res := forceBattle(t, g, + []Card{g.tier1(t, "Mosquito")}, + []Card{g.pet("Weakling", 1)}, + ) + rocks := eventsOfType(res, "rock") + if len(rocks) != 1 || rocks[0].Roll != 0 || rocks[0].TargetDied || rocks[0].DamageAfter != 0 { + t.Fatalf("blank roll should do nothing: %+v", rocks) + } + if len(eventsOfType(res, "clash")) != 1 { + t.Fatal("the clash should still happen after a miss") + } + if res.WinnerSeat != 0 { + t.Fatalf("mosquito still wins the clash 2v1, got %d", res.WinnerSeat) + } +} + +// Two mosquitos rock each other simultaneously (roll 1 each); both survive +// (2 power, 1 damage), then clash and both die. +func TestMosquitoMirror(t *testing.T) { + g, _, _ := testGame(t) + g.RollDie = func() int { return 1 } + res := forceBattle(t, g, + []Card{g.tier1(t, "Mosquito")}, + []Card{g.tier1(t, "Mosquito")}, + ) + if len(eventsOfType(res, "rock")) != 2 { + t.Fatal("both mosquitos should throw rocks") + } + clash := eventsOfType(res, "clash")[0] + if !clash.Died[0] || !clash.Died[1] { + t.Fatalf("both wounded mosquitos should die in the clash: %+v", clash) + } + if res.WinnerSeat != -1 { + t.Fatal("expected a draw") + } +} + +// Flamingo's faint stacks two apples: the follower fights at +2. +func TestFlamingoFaintAddsTwoApples(t *testing.T) { + g, _, _ := testGame(t) + res := forceBattle(t, g, + []Card{g.realPet(t, "Flamingo"), g.pet("Follower", 2)}, + []Card{g.pet("Enemy", 3)}, + ) + if len(eventsOfType(res, "summon")) != 2 { + t.Fatalf("flamingo should summon 2 apples: %+v", eventsOfType(res, "summon")) + } + // Follower at 4 power vs enemy at 3 power carrying 1 damage: enemy + // dies, follower survives (3 damage < 4 power). + if res.WinnerSeat != 0 { + t.Fatalf("buffed follower should win, got %d", res.WinnerSeat) + } +} + +// Peacock eats an apple every time it's hurt and survives. +func TestPeacockEatsWhenHurt(t *testing.T) { + g, _, _ := testGame(t) + res := forceBattle(t, g, + []Card{g.realPet(t, "Peacock")}, // 2 power + []Card{g.pet("Chip", 1), g.pet("Chip2", 1)}, + ) + // Clash 1: peacock takes 1 (alive at 2 power), eats β†’ 3 power. + // Clash 2: takes 1 more (2 damage < 3 power), eats β†’ 4 power. + eats := eventsOfType(res, "eat") + if len(eats) != 2 { + t.Fatalf("peacock should eat twice, got %+v", eats) + } + if eats[0].Bonus != 1 || eats[1].Bonus != 2 { + t.Fatalf("bonus should grow 1 then 2: %+v", eats) + } + if res.WinnerSeat != 0 { + t.Fatalf("peacock should survive and win, got %d", res.WinnerSeat) + } +} + +// Rat's faint puts a bee on the ENEMY's stack, where it fights for them. +func TestRatFaintSummonsBeeForEnemy(t *testing.T) { + g, _, _ := testGame(t) + res := forceBattle(t, g, + []Card{g.realPet(t, "Rat"), g.pet("Closer", 9)}, // rat: 4 power + []Card{g.pet("Equal", 4)}, + ) + summons := eventsOfType(res, "summon") + if len(summons) != 1 || summons[0].Seat != 1 || summons[0].Card.Name != "Bee" { + t.Fatalf("rat should summon a bee on the enemy stack: %+v", summons) + } + // Rat and Equal trade (both die). The bee fights for seat 1 next and + // loses to the Closer. + beeRevealedBySeat1 := false + for _, ev := range eventsOfType(res, "reveal") { + if ev.Card.Name == "Bee" && ev.Seat == 1 { + beeRevealedBySeat1 = true + } + } + if !beeRevealedBySeat1 { + t.Fatal("the enemy should reveal and field the rat's bee") + } + if res.WinnerSeat != 0 { + t.Fatalf("seat 0 should win, got %d", res.WinnerSeat) + } +} + +// Spider pushes a bee then an apple: the apple ends up on top, so it buffs +// the bee to 2 power. +func TestSpiderFaintBeeGetsApple(t *testing.T) { + g, _, _ := testGame(t) + res := forceBattle(t, g, + []Card{g.realPet(t, "Spider")}, // 2 power + []Card{g.pet("Enemy", 3)}, + ) + summons := eventsOfType(res, "summon") + if len(summons) != 2 || summons[0].Card.Name != "Bee" || summons[1].Card.Food != FoodApple { + t.Fatalf("spider should summon bee then apple: %+v", summons) + } + // Enemy (3 power) takes 2 from spider, then fights the 2-power bee + // (1+apple): both die. Draw. + if res.WinnerSeat != -1 { + t.Fatalf("expected draw, got %d", res.WinnerSeat) + } +} + +// Honey is a perk: the pet it's attached to summons a bee when it faints, +// and only the last-applied perk counts. +func TestHoneyPerk(t *testing.T) { + g, _, _ := testGame(t) + res := forceBattle(t, g, + []Card{g.newHoney(t), g.newHoney(t), g.pet("Bear", 2)}, + []Card{g.pet("Enemy", 3)}, + ) + summons := eventsOfType(res, "summon") + if len(summons) != 1 || summons[0].Card.Name != "Bee" || summons[0].Seat != 0 { + t.Fatalf("exactly one honey (the last-applied) should trigger: %+v", summons) + } + // Bear dies to the 3; enemy carries 2 damage; the bee finishes it and + // dies too: draw. + if res.WinnerSeat != -1 { + t.Fatalf("expected draw, got %d", res.WinnerSeat) + } +} + +// Garlic prevents 1 damage from every attack that hits its pet. +func TestGarlicPreventsDamage(t *testing.T) { + g, _, _ := testGame(t) + res := forceBattle(t, g, + []Card{g.realFood(t, "Garlic"), g.pet("Tank", 2)}, + []Card{g.pet("Equal", 2)}, + ) + // Tank takes 2-1=1 (survives at 2 power); Equal takes 2 and dies. + clash := eventsOfType(res, "clash")[0] + if clash.Died[0] || !clash.Died[1] { + t.Fatalf("garlic tank should survive the equal-power clash: %+v", clash) + } + if clash.Damage[0] != 1 { + t.Fatalf("garlic should reduce the hit to 1, got %d", clash.Damage[0]) + } + if res.WinnerSeat != 0 { + t.Fatalf("garlic side should win, got %d", res.WinnerSeat) + } +} + +// Two garlic'd 1-power pets can never hurt each other: the battle must end +// as a stalemate draw instead of looping forever. +func TestGarlicStalemateIsDraw(t *testing.T) { + g, _, _ := testGame(t) + res := forceBattle(t, g, + []Card{g.realFood(t, "Garlic"), g.pet("A", 1)}, + []Card{g.realFood(t, "Garlic"), g.pet("B", 1)}, + ) + if res.WinnerSeat != -1 { + t.Fatalf("stalemate should be a draw, got %d", res.WinnerSeat) + } + if len(res.Events) > 10 { + t.Fatalf("stalemate should end immediately, got %d events", len(res.Events)) + } +} + +// Dolphin throws 3 rocks on play (rigged to roll 1 each = 3 damage). +func TestDolphinThrowsThreeRocks(t *testing.T) { + g, _, _ := testGame(t) + g.RollDie = func() int { return 1 } + res := forceBattle(t, g, + []Card{g.realPet(t, "Dolphin")}, + []Card{g.pet("Tank", 3)}, + ) + rocks := eventsOfType(res, "rock") + if len(rocks) != 1 || rocks[0].Roll != 3 || !rocks[0].TargetDied { + t.Fatalf("dolphin should roll 3 dice for 3 damage and kill the tank: %+v", rocks) + } + if res.WinnerSeat != 0 { + t.Fatalf("dolphin should win, got %d", res.WinnerSeat) + } +} + +// Camel pushes an apple onto its own stack whenever it's hurt and survives. +func TestCamelHurtSummonsApple(t *testing.T) { + g, _, _ := testGame(t) + res := forceBattle(t, g, + []Card{g.realPet(t, "Camel"), g.pet("Ally", 2)}, // camel: 3 power + []Card{g.pet("Chip", 1), g.pet("Chip2", 3)}, + ) + // Clash 1: camel takes 1 (survives) β†’ apple onto A's stack. Clash 2 vs + // Chip2: both die. A reveals apple + Ally (3 power) and wins. + summons := eventsOfType(res, "summon") + if len(summons) != 1 || summons[0].Seat != 0 || summons[0].Card.Food != FoodApple { + t.Fatalf("camel should summon one apple onto its own stack: %+v", summons) + } + if res.WinnerSeat != 0 { + t.Fatalf("apple-buffed ally should win, got %d", res.WinnerSeat) + } +} + +// Sheep faints into two bees that fight on. +func TestSheepFaintSummonsTwoBees(t *testing.T) { + g, _, _ := testGame(t) + res := forceBattle(t, g, + []Card{g.realPet(t, "Sheep")}, // 3 power + []Card{g.pet("Big", 5)}, + ) + summons := eventsOfType(res, "summon") + if len(summons) != 2 || summons[0].Card.Name != "Bee" || summons[1].Card.Name != "Bee" { + t.Fatalf("sheep should summon 2 bees: %+v", summons) + } + // Big (5) takes 3 from sheep, then 1 from each bee: 5 total = dead; the + // second bee dies with it. Draw. + if res.WinnerSeat != -1 { + t.Fatalf("expected draw, got %d", res.WinnerSeat) + } +} + +// Dodo recycles up to 3 of its attached apples onto the deck when it faints. +func TestDodoRecyclesApples(t *testing.T) { + g, _, _ := testGame(t) + res := forceBattle(t, g, + []Card{g.newApple(), g.newApple(), g.realPet(t, "Dodo"), g.pet("Heir", 5)}, // dodo: 3+2=5 + []Card{g.pet("Big", 6)}, + ) + // Dodo (5) dies to the 6; Big carries 5 damage. The two apples come + // back on top: Heir fights at 5+2=7, survives Big's 6 attack, kills it. + summons := eventsOfType(res, "summon") + if len(summons) != 2 { + t.Fatalf("dodo should recycle exactly its 2 apples: %+v", summons) + } + for _, s := range summons { + if s.Seat != 0 || s.Card.Food != FoodApple { + t.Fatalf("recycled cards should be seat 0 apples: %+v", s) + } + } + if res.WinnerSeat != 0 { + t.Fatalf("recycled apples should carry the win, got %d", res.WinnerSeat) + } +} + +// Dog eats one apple per friendly bee that has fainted this battle. +func TestDogEatsPerFaintedBee(t *testing.T) { + g, _, _ := testGame(t) + res := forceBattle(t, g, + []Card{g.tier1(t, "Cricket"), g.realPet(t, "Dog")}, // cricket 1, dog 2 + []Card{g.pet("Chip", 1), g.pet("Wall", 2)}, + ) + // Cricket and Chip trade; cricket's bee then dies to Wall (1 friendly + // bee fainted, Wall at 1 damage). Dog plays, eats 1 apple β†’ 3 power, + // and beats the wounded Wall. + eats := eventsOfType(res, "eat") + if len(eats) != 1 || eats[0].Seat != 0 || eats[0].Bonus != 1 { + t.Fatalf("dog should eat exactly 1 apple: %+v", eats) + } + if res.WinnerSeat != 0 { + t.Fatalf("dog should win, got %d", res.WinnerSeat) + } +} + +// Badger sets aside on faint; when the next friendly pet plays it throws 2 +// rocks at EACH active pet β€” enemy and friend alike. +func TestBadgerDelayedRocksHitBothSides(t *testing.T) { + g, _, _ := testGame(t) + g.RollDie = func() int { return 1 } + res := forceBattle(t, g, + []Card{g.realPet(t, "Badger"), g.pet("Next", 3)}, // badger: 3 + []Card{g.pet("Tank", 5)}, + ) + // Clash: badger dies (takes 5), Tank keeps 3 damage (2 health). Next + // plays β†’ badger throws 2 rocks (2 damage) at Next AND at Tank: Next + // drops to 1 health, Tank dies at 5 damage. Seat 0 wins. + rocks := eventsOfType(res, "rock") + if len(rocks) != 2 { + t.Fatalf("badger should produce one rock volley per active pet: %+v", rocks) + } + hitSelf, hitEnemy := false, false + for _, r := range rocks { + if r.Roll != 2 { + t.Fatalf("each volley should roll 2 dice = 2 damage: %+v", r) + } + if r.Target == 0 { + hitSelf = true + if r.TargetDied { + t.Fatalf("Next (3 power) should survive 2 self-damage: %+v", r) + } + } + if r.Target == 1 { + hitEnemy = true + if !r.TargetDied { + t.Fatalf("Tank (5 power, 3 damage) should die to 2 more: %+v", r) + } + } + } + if !hitSelf || !hitEnemy { + t.Fatalf("badger must hit both sides: %+v", rocks) + } + if res.WinnerSeat != 0 { + t.Fatalf("seat 0 should win, got %d", res.WinnerSeat) + } +} + +// Temporary cards (apples, bees) leave the deck once the battle is +// acknowledged; pets stay. +func TestTemporariesExpireAfterBattle(t *testing.T) { + g, p1, p2 := testGame(t) + forceBattle(t, g, + []Card{g.newApple(), g.pet("Keeper", 3)}, + []Card{g.pet("Enemy", 1)}, + ) + if err := g.AcknowledgeBattle(p1.ID); err != nil { + t.Fatal(err) + } + if err := g.AcknowledgeBattle(p2.ID); err != nil { + t.Fatal(err) + } + if slices.ContainsFunc(p1.Deck, func(c Card) bool { return c.Temporary }) { + t.Fatalf("temporary cards should be gone after the battle: %+v", p1.Deck) + } + if len(p1.Deck) != 1 || p1.Deck[0].Name != "Keeper" { + t.Fatalf("pets should survive the round: %+v", p1.Deck) + } +} diff --git a/internal/game/cards.go b/internal/game/cards.go index 36ca272..bb6f869 100644 --- a/internal/game/cards.go +++ b/internal/game/cards.go @@ -2,19 +2,17 @@ package game import "fmt" -// Suit is the trade-in symbol printed on pet cards. Three pets of the same -// suit can be traded for a pick of the next tier's deck. +// Suit is the colored trade-in symbol printed on pet cards. Three pets of +// the same suit can be traded (the "Triple" action) for a pick from the next +// tier's deck. type Suit string const ( - SuitSun Suit = "sun" - SuitMoon Suit = "moon" - SuitStar Suit = "star" - SuitLeaf Suit = "leaf" + SuitRed Suit = "red" + SuitBlue Suit = "blue" + SuitYellow Suit = "yellow" ) -var suits = []Suit{SuitSun, SuitMoon, SuitStar, SuitLeaf} - // CardKind distinguishes pets from foods. type CardKind string @@ -23,63 +21,286 @@ const ( KindFood CardKind = "food" ) -// Food identifiers. Only apples exist for now; more foods come later. -const FoodApple = "apple" +// Food identifiers. +const ( + FoodApple = "apple" + FoodHoney = "honey" + FoodGarlic = "garlic" +) + +// EffectTrigger is when an effect fires. +type EffectTrigger string + +const ( + TriggerFaint EffectTrigger = "faint" // defeated in battle + TriggerSell EffectTrigger = "sell" // sold in the shop (the discardβ†’apple action) + TriggerBuy EffectTrigger = "buy" // purchased (also fires on pets gained via Triple) + TriggerPlay EffectTrigger = "play" // revealed on the stack during battle + TriggerTriple EffectTrigger = "triple" // used as one of the three traded-in cards + TriggerHurt EffectTrigger = "hurt" // attacked and received damage + // TriggerBattlePrep fires right after the shop phase ends, as players + // begin ordering their decks. + TriggerBattlePrep EffectTrigger = "battlePrep" + // TriggerPassive marks always-on effects (e.g. Garlic's damage + // prevention); they're consulted contextually rather than fired. + TriggerPassive EffectTrigger = "passive" +) + +// EffectAction is what the effect does. +type EffectAction string + +const ( + // ActionSummonTop puts Count cards (Effect.Card: "apple" or "bee") on + // top of a deck/stack β€” the owner's, or the enemy's when Target is + // "enemy". Battle-time. + ActionSummonTop EffectAction = "summonTop" + // ActionGainApple adds Count apples to the player's hand. Shop-time. + ActionGainApple EffectAction = "gainApple" + // ActionThrowRock rolls Count rock dice (faces: 0,0,1,1,2,2) and deals + // the total to the opposing pet in play. Battle-time. + ActionThrowRock EffectAction = "throwRock" + // ActionEatApple gives the pet itself +Count power (as if it ate + // apples), if it hasn't fainted. Battle-time. With Per set, Count is + // multiplied by a battle statistic (see Effect.Per). + ActionEatApple EffectAction = "eatApple" + // ActionRefreshGold returns Count spent coins to the player (never + // above the round's allowance). Shop-time. + ActionRefreshGold EffectAction = "refreshGold" + // ActionPreventDamage (passive) reduces every attack that hits this pet + // by Count damage. + ActionPreventDamage EffectAction = "preventDamage" + // ActionRecycleApples (faint) puts up to Count of this pet's attached + // apples back on top of its owner's deck. + ActionRecycleApples EffectAction = "recycleApples" + // ActionDelayedRocks (faint) sets the pet aside: when its owner plays + // their next pet, it throws Count rocks at EACH active pet β€” the + // enemy's and the owner's own. + ActionDelayedRocks EffectAction = "delayedRocks" +) + +// Per multipliers for dynamic effect counts. +const PerFaintedBees = "faintedBees" // Γ— friendly bees fainted this battle + +// Effect is one triggerβ†’action pair printed on a card. +type Effect struct { + Trigger EffectTrigger `json:"trigger"` + Action EffectAction `json:"action"` + Card string `json:"card,omitempty"` // summonTop: "apple" | "bee" + Count int `json:"count,omitempty"` // 0 means 1 + Target string `json:"target,omitempty"` // summonTop: "" (self) | "enemy" + // Per multiplies Count by a battle statistic (e.g. PerFaintedBees). + Per string `json:"per,omitempty"` + // MinRound gates the effect to round >= MinRound (0 = always). + MinRound int `json:"minRound,omitempty"` +} + +// count normalizes the zero value to 1. +func (e Effect) count() int { + if e.Count <= 0 { + return 1 + } + return e.Count +} // Card is a single physical card instance. IDs are unique per game. type Card struct { ID string `json:"id"` Kind CardKind `json:"kind"` Name string `json:"name"` - Tier int `json:"tier"` + Tier int `json:"tier,omitempty"` Power int `json:"power,omitempty"` Suit Suit `json:"suit,omitempty"` - // Effect is display text for the pet's ability. Effects are not yet - // implemented mechanically; the field keeps card data forward-compatible. - Effect string `json:"effect,omitempty"` - Food string `json:"food,omitempty"` + // Effects drive the engine; EffectText is the human-readable rule shown + // on the card face. + Effects []Effect `json:"effects,omitempty"` + EffectText string `json:"effectText,omitempty"` + Food string `json:"food,omitempty"` + // Perk foods (e.g. Honey) attach like any food, but a pet only benefits + // from the last-applied perk. + Perk bool `json:"perk,omitempty"` + // Temporary cards (apples, bees) are removed from the deck after the + // next battle. + Temporary bool `json:"temporary,omitempty"` } func (c Card) IsPet() bool { return c.Kind == KindPet } func (c Card) IsFood() bool { return c.Kind == KindFood } -// petTemplate is the printed definition of a pet; each template appears as -// multiple card copies in its tier's shop deck. +// petTemplate is the printed definition of a pet. Suits lists the suit of +// each physical copy in the tier deck (one card per entry). type petTemplate struct { - Name string - Power int + Name string + Power int + Suits []Suit + Effects []Effect + EffectText string } -const copiesPerPet = 2 +// foodTemplate is the printed definition of a food card that lives in a +// shop deck (e.g. Honey). +type foodTemplate struct { + Name string + Food string + Copies int + Perk bool + Effects []Effect + EffectText string +} -// petTiers defines the shop decks. Index 0 is tier 1 (round 1) through -// index 5 for tier 6 (round 6). Suits are assigned round-robin per tier so -// every tier contains every suit. +// petTiers defines the shop decks' pets. Index 0 is tier 1 (round 1) +// through index 5 for tier 6 (round 6). +// +// Tiers 1-2 are real card data. Tiers 3-6 are placeholders with the same +// structure until their real definitions are provided. var petTiers = [MaxRounds][]petTemplate{ { // Tier 1 - {"Ant", 1}, {"Cricket", 1}, {"Fish", 2}, {"Horse", 1}, - {"Beaver", 2}, {"Otter", 1}, {"Pig", 3}, {"Mosquito", 2}, + { + Name: "Ant", Power: 1, Suits: []Suit{SuitBlue, SuitYellow}, + Effects: []Effect{{Trigger: TriggerFaint, Action: ActionSummonTop, Card: "apple"}}, + EffectText: "Faint: add an Apple on top of your deck", + }, + { + Name: "Cricket", Power: 1, Suits: []Suit{SuitRed, SuitBlue}, + Effects: []Effect{{Trigger: TriggerFaint, Action: ActionSummonTop, Card: "bee"}}, + EffectText: "Faint: add a Bee on top of your deck", + }, + { + Name: "Duck", Power: 2, Suits: []Suit{SuitYellow, SuitBlue}, + Effects: []Effect{{Trigger: TriggerSell, Action: ActionGainApple}}, + EffectText: "Sell: add 1 extra Apple to your hand", + }, + { + Name: "Otter", Power: 1, Suits: []Suit{SuitYellow, SuitRed}, + Effects: []Effect{{Trigger: TriggerBuy, Action: ActionGainApple}}, + EffectText: "Buy: add an Apple to your hand", + }, + { + Name: "Mosquito", Power: 2, Suits: []Suit{SuitRed, SuitBlue}, + Effects: []Effect{{Trigger: TriggerPlay, Action: ActionThrowRock}}, + EffectText: "Play: throw 1 Rock", + }, + { + Name: "Fish", Power: 2, Suits: []Suit{SuitYellow, SuitRed}, + Effects: []Effect{{Trigger: TriggerTriple, Action: ActionGainApple}}, + EffectText: "Triple: add an Apple to your hand", + }, }, { // Tier 2 - {"Crab", 3}, {"Swan", 2}, {"Hedgehog", 3}, {"Peacock", 4}, - {"Flamingo", 3}, {"Rat", 2}, {"Shrimp", 2}, {"Spider", 3}, + { + Name: "Worm", Power: 2, Suits: []Suit{SuitBlue, SuitYellow}, + Effects: []Effect{{Trigger: TriggerBuy, Action: ActionGainApple, Count: 2}}, + EffectText: "Buy: add 2 Apples to your hand", + }, + { + Name: "Flamingo", Power: 1, Suits: []Suit{SuitRed, SuitYellow}, + Effects: []Effect{{Trigger: TriggerFaint, Action: ActionSummonTop, Card: "apple", Count: 2}}, + EffectText: "Faint: add 2 Apples on top of your deck", + }, + { + Name: "Peacock", Power: 2, Suits: []Suit{SuitBlue, SuitRed}, + Effects: []Effect{{Trigger: TriggerHurt, Action: ActionEatApple}}, + EffectText: "Hurt: if this pet hasn't fainted, it eats 1 Apple", + }, + { + Name: "Swan", Power: 1, Suits: []Suit{SuitRed, SuitBlue}, + Effects: []Effect{{Trigger: TriggerTriple, Action: ActionRefreshGold, MinRound: 3}}, + EffectText: "Triple: if it is round 3 or later, refresh a spent Gold", + }, + { + Name: "Rat", Power: 4, Suits: []Suit{SuitRed, SuitYellow}, + Effects: []Effect{{Trigger: TriggerFaint, Action: ActionSummonTop, Card: "bee", Target: "enemy"}}, + EffectText: "Faint: add a Bee on top of the enemy deck", + }, + { + Name: "Spider", Power: 2, Suits: []Suit{SuitYellow, SuitBlue}, + Effects: []Effect{ + {Trigger: TriggerFaint, Action: ActionSummonTop, Card: "bee"}, + {Trigger: TriggerFaint, Action: ActionSummonTop, Card: "apple"}, + }, + EffectText: "Faint: add a Bee, then an Apple, on top of your deck", + }, }, { // Tier 3 - {"Dog", 4}, {"Badger", 4}, {"Camel", 3}, {"Giraffe", 3}, - {"Kangaroo", 4}, {"Ox", 5}, {"Rabbit", 3}, {"Sheep", 4}, + { + Name: "Dog", Power: 2, Suits: []Suit{SuitYellow, SuitBlue}, + Effects: []Effect{{Trigger: TriggerPlay, Action: ActionEatApple, Per: PerFaintedBees}}, + EffectText: "Play: eats 1 Apple for each friendly fainted Bee", + }, + { + Name: "Dolphin", Power: 2, Suits: []Suit{SuitBlue, SuitRed}, + Effects: []Effect{{Trigger: TriggerPlay, Action: ActionThrowRock, Count: 3}}, + EffectText: "Play: throw 3 Rocks", + }, + { + Name: "Giraffe", Power: 2, Suits: []Suit{SuitRed, SuitBlue}, + Effects: []Effect{{Trigger: TriggerBattlePrep, Action: ActionGainApple, Count: 2}}, + EffectText: "Battle Prep: add 2 Apples to your hand", + }, + { + Name: "Camel", Power: 3, Suits: []Suit{SuitYellow, SuitBlue}, + Effects: []Effect{{Trigger: TriggerHurt, Action: ActionSummonTop, Card: "apple"}}, + EffectText: "Hurt: add an Apple on top of your deck", + }, + { + Name: "Sheep", Power: 3, Suits: []Suit{SuitYellow, SuitRed}, + Effects: []Effect{{Trigger: TriggerFaint, Action: ActionSummonTop, Card: "bee", Count: 2}}, + EffectText: "Faint: add 2 Bees on top of your deck", + }, + { + Name: "Dodo", Power: 3, Suits: []Suit{SuitRed, SuitBlue}, + Effects: []Effect{{Trigger: TriggerFaint, Action: ActionRecycleApples, Count: 3}}, + EffectText: "Faint: put up to 3 of this pet's Apples on top of your deck", + }, + { + Name: "Badger", Power: 3, Suits: []Suit{SuitRed, SuitYellow}, + Effects: []Effect{{Trigger: TriggerFaint, Action: ActionDelayedRocks, Count: 2}}, + EffectText: "Faint: set aside β€” when you play your next pet, throw 2 Rocks at each active pet", + }, }, - { // Tier 4 - {"Skunk", 5}, {"Hippo", 6}, {"Bison", 6}, {"Deer", 4}, - {"Squirrel", 4}, {"Whale", 5}, {"Worm", 4}, {"Penguin", 5}, + placeholderTier([6]string{"Skunk", "Hippo", "Bison", "Deer", "Squirrel", "Whale"}, + [6]int{5, 6, 6, 4, 4, 5}), + placeholderTier([6]string{"Scorpion", "Rhino", "Monkey", "Cow", "Seal", "Shark"}, + [6]int{5, 7, 6, 6, 6, 7}), + placeholderTier([6]string{"Leopard", "Boar", "Gorilla", "Mammoth", "Snake", "Tiger"}, + [6]int{8, 9, 9, 10, 8, 9}), +} + +// foodTiers defines the food cards mixed into each tier's shop deck. +var foodTiers = [MaxRounds][]foodTemplate{ + {}, // Tier 1 + { // Tier 2 + { + Name: "Honey", Food: FoodHoney, Copies: 2, Perk: true, + Effects: []Effect{{Trigger: TriggerFaint, Action: ActionSummonTop, Card: "bee"}}, + EffectText: "Faint: add a Bee on top of your deck", + }, }, - { // Tier 5 - {"Scorpion", 5}, {"Rhino", 7}, {"Monkey", 6}, {"Cow", 6}, - {"Seal", 6}, {"Shark", 7}, {"Turkey", 5}, {"Crocodile", 8}, - }, - { // Tier 6 - {"Leopard", 8}, {"Boar", 9}, {"Fly", 7}, {"Gorilla", 9}, - {"Mammoth", 10}, {"Snake", 8}, {"Tiger", 9}, {"Dragon", 10}, + { // Tier 3 + { + Name: "Garlic", Food: FoodGarlic, Copies: 2, Perk: true, + Effects: []Effect{{Trigger: TriggerPassive, Action: ActionPreventDamage}}, + EffectText: "Every attack that hits this pet deals 1 less damage", + }, }, + {}, {}, {}, +} + +// placeholderTier mirrors tier 1's suit distribution (each suit appears on 4 +// of the 12 cards) for pets whose real definitions aren't in yet. +func placeholderTier(names [6]string, powers [6]int) []petTemplate { + suitPairs := [6][]Suit{ + {SuitBlue, SuitYellow}, + {SuitRed, SuitBlue}, + {SuitYellow, SuitBlue}, + {SuitYellow, SuitRed}, + {SuitRed, SuitBlue}, + {SuitYellow, SuitRed}, + } + tier := make([]petTemplate, 6) + for i := range names { + tier[i] = petTemplate{Name: names[i], Power: powers[i], Suits: suitPairs[i]} + } + return tier } // newCardID mints a unique card ID within the game. @@ -91,17 +312,33 @@ func (g *Game) newCardID() string { // buildShopDecks creates all six tier decks (unshuffled). func (g *Game) buildShopDecks() { g.ShopDecks = make([][]Card, MaxRounds) - for tierIdx, templates := range petTiers { - deck := make([]Card, 0, len(templates)*copiesPerPet) - for i, t := range templates { - for range copiesPerPet { + for tierIdx := range petTiers { + var deck []Card + for _, t := range petTiers[tierIdx] { + for _, suit := range t.Suits { deck = append(deck, Card{ - ID: g.newCardID(), - Kind: KindPet, - Name: t.Name, - Tier: tierIdx + 1, - Power: t.Power, - Suit: suits[i%len(suits)], + ID: g.newCardID(), + Kind: KindPet, + Name: t.Name, + Tier: tierIdx + 1, + Power: t.Power, + Suit: suit, + Effects: t.Effects, + EffectText: t.EffectText, + }) + } + } + for _, f := range foodTiers[tierIdx] { + for range f.Copies { + deck = append(deck, Card{ + ID: g.newCardID(), + Kind: KindFood, + Name: f.Name, + Tier: tierIdx + 1, + Food: f.Food, + Perk: f.Perk, + Effects: f.Effects, + EffectText: f.EffectText, }) } } @@ -109,12 +346,27 @@ func (g *Game) buildShopDecks() { } } -// newApple mints an apple food card (from discarding, etc.). +// newApple mints an apple food card. Apples are temporary: they vanish from +// the deck after the next battle. func (g *Game) newApple() Card { return Card{ - ID: g.newCardID(), - Kind: KindFood, - Name: "Apple", - Food: FoodApple, + ID: g.newCardID(), + Kind: KindFood, + Name: "Apple", + Food: FoodApple, + Temporary: true, + } +} + +// newBee mints a bee: a temporary 1-power pet with no effect, summoned by +// other pets' effects. It counts as a pet while it exists (foods can attach +// to it in battle). +func (g *Game) newBee() Card { + return Card{ + ID: g.newCardID(), + Kind: KindPet, + Name: "Bee", + Power: 1, + Temporary: true, } } diff --git a/internal/game/game.go b/internal/game/game.go index 2b69164..c742a43 100644 --- a/internal/game/game.go +++ b/internal/game/game.go @@ -85,6 +85,18 @@ type Game struct { Battle *BattleResult `json:"battle,omitempty"` // most recent battle NextCardID int `json:"nextCardId"` WinnerSeat int `json:"winnerSeat"` // set at gameover; -1 = tie + + // RollDie overrides the rock die (faces 0,0,1,1,2,2) for tests. Nil + // (including after loading from storage) means a fair random roll. + RollDie func() int `json:"-"` +} + +// rollRockDie rolls one rock die: 0, 1, or 2 with equal probability. +func (g *Game) rollRockDie() int { + if g.RollDie != nil { + return g.RollDie() + } + return randInt(3) } var ( @@ -247,23 +259,25 @@ func (g *Game) Buy(playerID string, rowIdx int) error { return fmt.Errorf("%w: no card in that shop slot", ErrInvalidAction) } p.Coins-- - p.Deck = append(p.Deck, g.ShopRow[rowIdx]) + bought := g.ShopRow[rowIdx] + p.Deck = append(p.Deck, bought) g.ShopRow[rowIdx] = g.drawFromTier(g.Round) + g.applyShopTrigger(p, bought, TriggerBuy) g.advanceShopTurn() return nil } -// Discard spends one coin to convert any number (>=1) of the player's cards -// into that many apples. -func (g *Game) Discard(playerID string, cardIDs []string) error { +// Sell spends one coin to sell any number (>=1) of the player's cards: each +// becomes an apple, and Sell effects on the sold cards fire. +func (g *Game) Sell(playerID string, cardIDs []string) error { p, err := g.requireShopTurn(playerID) if err != nil { return err } if len(cardIDs) == 0 { - return fmt.Errorf("%w: choose at least one card to discard", ErrInvalidAction) + return fmt.Errorf("%w: choose at least one card to sell", ErrInvalidAction) } - if err := g.convertToApples(p, cardIDs); err != nil { + if err := g.sellCards(p, cardIDs); err != nil { return err } p.Coins-- @@ -271,9 +285,27 @@ func (g *Game) Discard(playerID string, cardIDs []string) error { return nil } -// convertToApples removes the given cards from p's deck and adds one apple -// per removed card. It validates before mutating. -func (g *Game) convertToApples(p *Player, cardIDs []string) error { +// applyShopTrigger fires a shop-time trigger (buy/sell/triple) on one card. +func (g *Game) applyShopTrigger(p *Player, c Card, trigger EffectTrigger) { + for _, e := range c.Effects { + if e.Trigger != trigger || g.Round < e.MinRound { + continue + } + switch e.Action { + case ActionGainApple: + for range e.count() { + p.Deck = append(p.Deck, g.newApple()) + } + case ActionRefreshGold: + p.Coins = min(p.Coins+e.count(), CoinsPerRound) + } + } +} + +// sellCards removes the given cards from p's deck, adds one apple per +// removed card, and fires the sold cards' Sell effects. It validates before +// mutating. +func (g *Game) sellCards(p *Player, cardIDs []string) error { if hasDuplicates(cardIDs) { return fmt.Errorf("%w: duplicate card", ErrInvalidAction) } @@ -282,11 +314,15 @@ func (g *Game) convertToApples(p *Player, cardIDs []string) error { return fmt.Errorf("%w: card not in your deck", ErrInvalidAction) } } + sold := make([]Card, 0, len(cardIDs)) for _, id := range cardIDs { - p.Deck = slices.Delete(p.Deck, p.cardIndex(id), p.cardIndex(id)+1) + idx := p.cardIndex(id) + sold = append(sold, p.Deck[idx]) + p.Deck = slices.Delete(p.Deck, idx, idx+1) } - for range cardIDs { + for _, c := range sold { p.Deck = append(p.Deck, g.newApple()) + g.applyShopTrigger(p, c, TriggerSell) } return nil } @@ -326,8 +362,11 @@ func (g *Game) TradeStart(playerID string, cardIDs []string) error { return fmt.Errorf("%w: next tier deck is exhausted", ErrInvalidAction) } // Validated; commit. + traded := make([]Card, 0, TradeInCount) for _, id := range cardIDs { - p.Deck = slices.Delete(p.Deck, p.cardIndex(id), p.cardIndex(id)+1) + idx := p.cardIndex(id) + traded = append(traded, p.Deck[idx]) + p.Deck = slices.Delete(p.Deck, idx, idx+1) } p.Coins-- g.Pending = &PendingTrade{ @@ -335,6 +374,10 @@ func (g *Game) TradeStart(playerID string, cardIDs []string) error { Tier: nextTier, Options: [2]Card{g.drawFromTier(nextTier), g.drawFromTier(nextTier)}, } + // Triple effects fire on the traded-in cards themselves (e.g. Fish). + for _, c := range traded { + g.applyShopTrigger(p, c, TriggerTriple) + } return nil } @@ -353,6 +396,8 @@ func (g *Game) TradeChoose(playerID string, pick int) error { tierIdx := g.Pending.Tier - 1 g.ShopDecks[tierIdx] = append(g.ShopDecks[tierIdx], other) g.Pending = nil + // Pets obtained via the Triple action trigger their Buy effects. + g.applyShopTrigger(p, chosen, TriggerBuy) g.advanceShopTurn() return nil } @@ -398,9 +443,9 @@ func (g *Game) endShop() { g.beginArrange() } -// CleanupDiscard performs the forced end-of-shop discard: the player must -// convert exactly their excess pets into apples. -func (g *Game) CleanupDiscard(playerID string, cardIDs []string) error { +// CleanupSell performs the forced end-of-shop sale: the player must sell +// exactly their excess pets (each becomes an apple; Sell effects fire). +func (g *Game) CleanupSell(playerID string, cardIDs []string) error { if g.Phase != PhaseCleanup { return ErrWrongPhase } @@ -413,7 +458,7 @@ func (g *Game) CleanupDiscard(playerID string, cardIDs []string) error { return fmt.Errorf("%w: you are not over the pet limit", ErrInvalidAction) } if len(cardIDs) != excess { - return fmt.Errorf("%w: discard exactly %d pets", ErrInvalidAction, excess) + return fmt.Errorf("%w: sell exactly %d pets", ErrInvalidAction, excess) } for _, id := range cardIDs { idx := p.cardIndex(id) @@ -421,7 +466,7 @@ func (g *Game) CleanupDiscard(playerID string, cardIDs []string) error { return fmt.Errorf("%w: pick pets from your deck", ErrInvalidAction) } } - if err := g.convertToApples(p, cardIDs); err != nil { + if err := g.sellCards(p, cardIDs); err != nil { return err } p.Ready = true @@ -444,6 +489,11 @@ func (g *Game) beginArrange() { g.Phase = PhaseArrange for _, p := range g.Players { p.Ready = false + // Battle Prep effects fire now, before players order their cards + // (e.g. Giraffe hands out apples that can go into the deck order). + for _, c := range slices.Clone(p.Deck) { + g.applyShopTrigger(p, c, TriggerBattlePrep) + } } } @@ -494,6 +544,10 @@ func (g *Game) AcknowledgeBattle(playerID string) error { if !g.allReady() { return nil } + // Temporary cards (apples, bees) expire once their battle has happened. + for _, pl := range g.Players { + pl.Deck = slices.DeleteFunc(pl.Deck, func(c Card) bool { return c.Temporary }) + } if g.Round >= MaxRounds { g.finish() return nil diff --git a/internal/game/game_test.go b/internal/game/game_test.go index eec7159..a8e5e86 100644 --- a/internal/game/game_test.go +++ b/internal/game/game_test.go @@ -84,24 +84,60 @@ func TestTurnValidation(t *testing.T) { } } -func TestDiscardConvertsToApples(t *testing.T) { +func TestSellConvertsToApples(t *testing.T) { g, _, _ := testGame(t) p := current(g) + // Sell a plain pet (no sell effect) for exactly one apple. + plain := g.pet("Plain", 2) + p.Deck = append(p.Deck, plain) + if err := g.Sell(p.ID, []string{plain.ID}); err != nil { + t.Fatal(err) + } + if p.Coins != CoinsPerRound-1 { + t.Fatalf("sell should cost 1 coin, coins=%d", p.Coins) + } + if p.PetCount() != 0 || len(p.Deck) != 1 || p.Deck[0].Food != FoodApple { + t.Fatalf("sold pet should become an apple: %+v", p.Deck) + } +} + +func TestSellDuckAddsExtraApple(t *testing.T) { + g, _, _ := testGame(t) + p := current(g) + duck := g.tier1(t, "Duck") + p.Deck = append(p.Deck, duck) + if err := g.Sell(p.ID, []string{duck.ID}); err != nil { + t.Fatal(err) + } + apples := 0 + for _, c := range p.Deck { + if c.Food == FoodApple { + apples++ + } + } + if apples != 2 { + t.Fatalf("selling a Duck should yield 2 apples (1 base + 1 effect), got %d", apples) + } +} + +func TestBuyOtterAddsApple(t *testing.T) { + g, _, _ := testGame(t) + p := current(g) + g.ShopRow[0] = g.tier1(t, "Otter") if err := g.Buy(p.ID, 0); err != nil { t.Fatal(err) } - // Skip opponent back to p. - if err := g.Buy(current(g).ID, 0); err != nil { - t.Fatal(err) + hasOtter, apples := false, 0 + for _, c := range p.Deck { + if c.Name == "Otter" { + hasOtter = true + } + if c.Food == FoodApple { + apples++ + } } - if err := g.Discard(p.ID, deckIDs(p, Card.IsPet)); err != nil { - t.Fatal(err) - } - if p.Coins != CoinsPerRound-2 { - t.Fatalf("discard should cost 1 coin, coins=%d", p.Coins) - } - if p.PetCount() != 0 || len(p.Deck) != 1 || p.Deck[0].Food != FoodApple { - t.Fatalf("discarded pet should become an apple: %+v", p.Deck) + if !hasOtter || apples != 1 { + t.Fatalf("buying an Otter should also add an apple: %+v", p.Deck) } } @@ -111,7 +147,7 @@ func TestTradeInThreeMatchingSuits(t *testing.T) { // Hand p three same-suit tier-1 pets directly. for range 3 { c := g.pet("Fodder", 1) - c.Suit = SuitMoon + c.Suit = SuitBlue p.Deck = append(p.Deck, c) } nextDeckBefore := len(g.ShopDecks[1]) @@ -150,11 +186,113 @@ func TestTradeInThreeMatchingSuits(t *testing.T) { } } +// Fish fires its Triple effect when traded in, and a pet received from the +// trade fires its Buy effect. +func TestTradeTriggersTripleAndBuyEffects(t *testing.T) { + g, _, _ := testGame(t) + p := current(g) + countApples := func() int { + n := 0 + for _, c := range p.Deck { + if c.Food == FoodApple { + n++ + } + } + return n + } + var ids []string + for range 3 { + f := g.tier1(t, "Fish") + f.Suit = SuitYellow + p.Deck = append(p.Deck, f) + ids = append(ids, f.ID) + } + if err := g.TradeStart(p.ID, ids); err != nil { + t.Fatal(err) + } + if countApples() != 3 { + t.Fatalf("each traded Fish should add an apple, got %d", countApples()) + } + // Rig the revealed options so the chosen card is an Otter (Buy effect). + g.Pending.Options[0] = g.tier1(t, "Otter") + if err := g.TradeChoose(p.ID, 0); err != nil { + t.Fatal(err) + } + if countApples() != 4 { + t.Fatalf("trade-received Otter should fire its Buy effect, got %d apples", countApples()) + } +} + +func TestBuyWormAddsTwoApples(t *testing.T) { + g, _, _ := testGame(t) + p := current(g) + g.ShopRow[0] = g.realPet(t, "Worm") + if err := g.Buy(p.ID, 0); err != nil { + t.Fatal(err) + } + apples := 0 + for _, c := range p.Deck { + if c.Food == FoodApple { + apples++ + } + } + if apples != 2 { + t.Fatalf("buying a Worm should add 2 apples, got %d", apples) + } +} + +// Swan's Triple refreshes a spent gold, but only from round 3 on. +func TestSwanTripleRefreshesGold(t *testing.T) { + for _, tc := range []struct { + round int + wantCoins int + }{ + {round: 1, wantCoins: CoinsPerRound - 1}, // too early: coin stays spent + {round: 3, wantCoins: CoinsPerRound}, // refreshed (capped at 3) + } { + g, _, _ := testGame(t) + g.Round = tc.round + p := current(g) + var ids []string + for range 3 { + s := g.realPet(t, "Swan") + s.Suit = SuitRed + p.Deck = append(p.Deck, s) + ids = append(ids, s.ID) + } + if err := g.TradeStart(p.ID, ids); err != nil { + t.Fatal(err) + } + if p.Coins != tc.wantCoins { + t.Fatalf("round %d: coins after swan trade = %d, want %d", tc.round, p.Coins, tc.wantCoins) + } + } +} + +// Giraffe's Battle Prep hands out apples the moment arranging begins. +func TestGiraffeBattlePrep(t *testing.T) { + g, p1, _ := testGame(t) + p1.Deck = append(p1.Deck, g.realPet(t, "Giraffe")) + spendAllCoins(t, g) + if g.Phase != PhaseArrange { + t.Fatalf("expected arrange, got %s", g.Phase) + } + apples := 0 + for _, c := range p1.Deck { + if c.Food == FoodApple { + apples++ + } + } + if apples != 2 { + t.Fatalf("giraffe should add 2 apples at battle prep, got %d", apples) + } +} + func TestTradeRequiresMatchingSuit(t *testing.T) { g, _, _ := testGame(t) p := current(g) a, b, c := g.pet("A", 1), g.pet("B", 1), g.pet("C", 1) - a.Suit, b.Suit, c.Suit = SuitSun, SuitSun, SuitMoon + a.Suit, b.Suit, c.Suit = SuitRed, SuitRed, SuitBlue p.Deck = append(p.Deck, a, b, c) if err := g.TradeStart(p.ID, []string{a.ID, b.ID, c.ID}); err == nil { t.Fatal("mismatched suits should be rejected") @@ -170,7 +308,7 @@ func TestTradeBlockedOnFinalRound(t *testing.T) { p := current(g) for range 3 { c := g.pet("Fodder", 1) - c.Suit = SuitMoon + c.Suit = SuitBlue p.Deck = append(p.Deck, c) } if err := g.TradeStart(p.ID, deckIDs(p, nil)); err == nil { @@ -206,10 +344,10 @@ func TestForcedDiscardOverPetLimit(t *testing.T) { t.Fatalf("player with %d pets must be forced to discard, got phase %s", MaxPets+2, g.Phase) } // Wrong count rejected. - if err := g.CleanupDiscard(p1.ID, deckIDs(p1, Card.IsPet)[:1]); err == nil { - t.Fatal("must discard exactly the excess") + if err := g.CleanupSell(p1.ID, deckIDs(p1, Card.IsPet)[:1]); err == nil { + t.Fatal("must sell exactly the excess") } - if err := g.CleanupDiscard(p1.ID, deckIDs(p1, Card.IsPet)[:2]); err != nil { + if err := g.CleanupSell(p1.ID, deckIDs(p1, Card.IsPet)[:2]); err != nil { t.Fatal(err) } if p1.PetCount() != MaxPets { @@ -313,7 +451,7 @@ func TestViewHidesSecrets(t *testing.T) { // Pending trade options hidden from the opponent. for range 3 { c := g.pet("Fodder", 1) - c.Suit = SuitLeaf + c.Suit = SuitYellow p1.Deck = append(p1.Deck, c) } g.Turn = p1.Seat diff --git a/internal/server/ws.go b/internal/server/ws.go index 3711d28..fb171a3 100644 --- a/internal/server/ws.go +++ b/internal/server/ws.go @@ -117,11 +117,11 @@ func (s *Server) apply(r *room, c *client, msg clientMessage) { switch msg.Type { case "buy": err = g.Buy(c.playerID, msg.Row) - case "discard": + case "sell": if g.Phase == game.PhaseCleanup { - err = g.CleanupDiscard(c.playerID, msg.Cards) + err = g.CleanupSell(c.playerID, msg.Cards) } else { - err = g.Discard(c.playerID, msg.Cards) + err = g.Sell(c.playerID, msg.Cards) } case "trade": err = g.TradeStart(c.playerID, msg.Cards) diff --git a/web/src/components/ArrangePhase.tsx b/web/src/components/ArrangePhase.tsx index 8da6bef..9bc8859 100644 --- a/web/src/components/ArrangePhase.tsx +++ b/web/src/components/ArrangePhase.tsx @@ -17,10 +17,18 @@ export function ArrangePhase({ view, you, send }: Props) { const dragIndex = useRef(null) const locked = you.ready - // If the server-side deck changes (shouldn't during arrange, but be safe), - // resync. + // Resync only if the deck's actual contents changed β€” every broadcast + // creates a fresh array, and blindly resetting would wipe an in-progress + // ordering whenever the opponent acts. useEffect(() => { - setOrder(you.deck ?? []) + setOrder((prev) => { + const deck = you.deck ?? [] + const ids = new Set(deck.map((c) => c.id)) + if (prev.length === deck.length && prev.every((c) => ids.has(c.id))) { + return prev + } + return deck + }) }, [you.deck]) function move(from: number, to: number) { diff --git a/web/src/components/BattlePhase.tsx b/web/src/components/BattlePhase.tsx index e6a630c..24709cc 100644 --- a/web/src/components/BattlePhase.tsx +++ b/web/src/components/BattlePhase.tsx @@ -1,123 +1,217 @@ import { useEffect, useMemo, useState } from 'react' -import type { BattleEvent, BattleUnit, ClientMessage, GameView } from '../types' +import type { BattleEvent, Card, ClientMessage, GameView } from '../types' import { CardView } from './CardView' +import { artFor } from '../petArt' interface Props { view: GameView send: (msg: ClientMessage) => void } -const STEP_MS = 1400 - -interface UnitState { - unit: BattleUnit - index: number +interface UnitVis { + card: Card + foods: Card[] + bonus: number damage: number - dead: boolean // died in an earlier step (gone) - dying: boolean // died in the step just played (animate out) + dying: boolean } -// applyEvents replays the first `step` events onto a seat's lineup. -function applyEvents(view: GameView, seat: number, step: number): UnitState[] { - const battle = view.battle! - const events = battle.events ?? [] - const states: UnitState[] = (battle.lineups[seat] ?? []).map((u, i) => ({ - unit: u, - index: i, - damage: 0, - dead: false, - dying: false, - })) - const seatPos = seat === 0 ? 0 : 1 - for (let k = 0; k < step && k < events.length; k++) { +interface SideVis { + stack: number + pending: Card[] // revealed foods waiting for a pet + unit: UnitVis | null +} + +// Milliseconds each event type stays on screen during playback. +const EVENT_MS: Record = { + reveal: 800, + summon: 1000, + rock: 1200, + clash: 1400, + eat: 1000, +} + +// replay applies the first `upto` events to fresh stacks and returns each +// seat's visual state. Units that died in the last applied event are still +// present with dying=true so they can animate out. +function replay(events: BattleEvent[], stackSizes: number[], upto: number): SideVis[] { + const sides: SideVis[] = stackSizes.map((n) => ({ stack: n, pending: [], unit: null })) + for (let k = 0; k < upto && k < events.length; k++) { + for (const s of sides) { + if (s.unit?.dying) s.unit = null // clear last step's casualties + } const ev = events[k] - const s = states[ev.units[seatPos]] - if (!s) continue - s.damage = ev.damage[seatPos] - if (ev.died[seatPos]) { - s.dying = k === step - 1 - s.dead = k < step - 1 + switch (ev.type) { + case 'reveal': { + const s = sides[ev.seat!] + s.stack-- + const card = ev.card! + if (card.kind === 'food') { + s.pending.push(card) + } else { + s.unit = { + card, + foods: s.pending, + bonus: s.pending.filter((f) => f.food === 'apple').length, + damage: 0, + dying: false, + } + s.pending = [] + } + break + } + case 'summon': + sides[ev.seat!].stack++ + break + case 'rock': { + const u = sides[ev.target!].unit + if (u) { + u.damage = ev.damageAfter ?? u.damage + if (ev.targetDied) u.dying = true + } + break + } + case 'clash': + sides.forEach((s, seat) => { + if (!s.unit) return + s.unit.damage = ev.damage?.[seat] ?? s.unit.damage + if (ev.died?.[seat]) s.unit.dying = true + }) + break + case 'eat': { + const u = sides[ev.seat!].unit + if (u) u.bonus = ev.bonus ?? u.bonus + break + } } } - return states + return sides } -// BattlePhase plays back the battle log: front pets clash, damage numbers -// fly, the fallen fade out, then the round result lands. +// BattlePhase plays back the battle log: cards flip off each deck, rocks +// fly, pets clash, the fallen fade out, then the round result lands. export function BattlePhase({ view, send }: Props) { const battle = view.battle! const events = battle.events ?? [] const [step, setStep] = useState(0) const [acked, setAcked] = useState(false) const done = step >= events.length + const lastEvent = step > 0 ? events[step - 1] : null useEffect(() => { if (done) return - const t = window.setTimeout(() => setStep((s) => s + 1), STEP_MS) + const delay = EVENT_MS[events[step].type] ?? 1000 + const t = window.setTimeout(() => setStep((s) => s + 1), delay) return () => window.clearTimeout(t) - }, [step, done]) + }, [step, done, events]) + + const sides = useMemo( + () => replay(events, battle.stackSizes, step), + [events, battle.stackSizes, step], + ) const youSeat = view.youSeat const oppSeat = view.players.find((p) => p.seat !== youSeat)?.seat ?? 1 - - const yourLine = useMemo( - () => applyEvents(view, youSeat, step), - [view, youSeat, step], - ) - const oppLine = useMemo( - () => applyEvents(view, oppSeat, step), - [view, oppSeat, step], - ) - - const lastEvent = step > 0 ? events[step - 1] : null - - function renderSide(line: UnitState[], side: 'left' | 'right', seat: number) { - const seatPos = seat === 0 ? 0 : 1 - const frontIdx = line.find((s) => !s.dead && !s.dying)?.index - return ( -
- {line - .filter((s) => !s.dead) - .map((s) => { - const isFront = s.index === frontIdx - const clashing = - !done && lastEvent !== null && lastEvent.units[seatPos] === s.index - return ( -
- - {clashing && ( -
- βˆ’{lastEvent!.damage[seatPos] - - prevDamage(events, step - 1, seatPos, s.index)} -
- )} -
- ) - })} -
- ) - } - const you = view.players[youSeat] const opp = view.players[oppSeat] const won = battle.winnerSeat === youSeat const draw = battle.winnerSeat < 0 + function renderSide(seat: number, dir: 'left' | 'right') { + const s = sides[seat] + const clashing = !done && lastEvent?.type === 'clash' && s.unit && !s.unit.dying + const clashDying = lastEvent?.type === 'clash' && s.unit?.dying + const rockVictim = lastEvent?.type === 'rock' && lastEvent.target === seat + const eating = lastEvent?.type === 'eat' && lastEvent.seat === seat + const summoning = !done && lastEvent?.type === 'summon' && lastEvent.seat === seat + const revealing = !done && lastEvent?.type === 'reveal' && lastEvent.seat === seat + + const stackEl = ( +
+ {s.stack > 0 ? ( +
+ {s.stack} +
+ ) : ( +
+ )} + {summoning && lastEvent?.card && ( +
+ +
+ )} +
+ ) + + const foodsEl = ( +
+ {s.pending.map((f) => ( + + {artFor(f.name)} + + ))} +
+ ) + + const unitEl = ( +
+ {s.unit && ( +
+ + {s.unit.foods.length > 0 && ( +
+ {s.unit.foods.map((f) => ( + + {artFor(f.name)} + + ))} +
+ )} + {(lastEvent?.type === 'clash' || rockVictim) && !done && ( +
+ {lastEvent?.type === 'rock' + ? lastEvent.roll === 0 + ? 'miss!' + : `βˆ’${lastEvent.roll}` + : `βˆ’${clashDamageTaken(events, step - 1, seat)}`} +
+ )} + {eating &&
🍎 +1
} +
+ )} +
+ ) + + return dir === 'left' ? ( +
+ {stackEl} + {foodsEl} + {unitEl} +
+ ) : ( +
+ {unitEl} + {foodsEl} + {stackEl} +
+ ) + } + return (
@@ -136,11 +230,16 @@ export function BattlePhase({ view, send }: Props) {
- {renderSide(yourLine, 'left', youSeat)} + {renderSide(youSeat, 'left')}
- ⚑ + {!done && lastEvent?.type === 'rock' && ( +
+ πŸͺ¨ +
+ )} + ⚑
- {renderSide(oppLine, 'right', oppSeat)} + {renderSide(oppSeat, 'right')}
{done && ( @@ -174,18 +273,25 @@ export function BattlePhase({ view, send }: Props) { ) } -// prevDamage finds the damage a unit had before the given event, so the -// floating number shows just this clash's hit. -function prevDamage( - events: BattleEvent[], - upto: number, - seatPos: number, - unitIndex: number, -): number { - let dmg = 0 - for (let k = 0; k < upto; k++) { - const ev = events[k] - if (ev.units[seatPos] === unitIndex) dmg = ev.damage[seatPos] +// clashDamageTaken computes how much damage a seat's pet took in the clash +// at event index `idx` (its damage total there minus its total beforehand). +function clashDamageTaken(events: BattleEvent[], idx: number, seat: number): number { + const ev = events[idx] + if (ev?.type !== 'clash') return 0 + const after = ev.damage?.[seat] ?? 0 + // Walk back to the pet's damage before this clash. + let before = 0 + for (let k = idx - 1; k >= 0; k--) { + const e = events[k] + if (e.type === 'reveal' && e.seat === seat && e.card?.kind === 'pet') break + if (e.type === 'rock' && e.target === seat) { + before = e.damageAfter ?? 0 + break + } + if (e.type === 'clash') { + before = e.damage?.[seat] ?? 0 + break + } } - return dmg + return after - before } diff --git a/web/src/components/CardView.tsx b/web/src/components/CardView.tsx index 81ebcb0..c6fb638 100644 --- a/web/src/components/CardView.tsx +++ b/web/src/components/CardView.tsx @@ -1,5 +1,5 @@ import type { Card } from '../types' -import { artFor, SUIT_EMOJI } from '../petArt' +import { artFor } from '../petArt' interface Props { card: Card @@ -13,8 +13,9 @@ interface Props { dead?: boolean } -// CardView renders one physical card: pets get a power badge and suit stamp, -// foods a description line. Battle mode layers on buffs and damage markers. +// CardView renders one physical card: pets get a power badge and a colored +// suit dot, foods a description line. Battle mode layers on buffs and damage +// markers. export function CardView({ card, size = 'md', @@ -45,24 +46,25 @@ export function CardView({ role={onClick ? 'button' : undefined} >
- T{card.tier || '–'} - {card.suit && ( - - {SUIT_EMOJI[card.suit]} - - )} + {card.tier ? `T${card.tier}` : 'Β·'} + {card.suit && }
{artFor(card.name)}
{card.name}
{card.kind === 'pet' ? ( -
- 0 ? 'is-buffed' : ''}`}>{power} - {damage > 0 && !dead && βˆ’{damage}} -
+ <> + {card.effectText &&
{card.effectText}
} +
+ 0 ? 'is-buffed' : ''}`}>{power} + {damage > 0 && !dead && βˆ’{damage}} +
+ ) : ( -
+1 power
+
+ {card.effectText ?? (card.food === 'apple' ? '+1 power (this battle)' : '')} +
)} {selected &&
βœ“
}
diff --git a/web/src/components/ShopPhase.tsx b/web/src/components/ShopPhase.tsx index 7648a13..16916fc 100644 --- a/web/src/components/ShopPhase.tsx +++ b/web/src/components/ShopPhase.tsx @@ -1,7 +1,6 @@ import { useEffect, useState } from 'react' import type { Card, ClientMessage, GameView, PlayerView } from '../types' import { CardView } from './CardView' -import { SUIT_EMOJI } from '../petArt' interface Props { view: GameView @@ -55,12 +54,12 @@ export function ShopPhase({ view, you, send }: Props) { {cleanup ? ( excessPets > 0 ? ( - Too many pets! Discard {excessPets} β€” they become + Too many pets! Sell {excessPets} β€” they become apples 🍎 ) : ( - Waiting for {opponent?.name ?? 'opponent'} to discard down to{' '} + Waiting for {opponent?.name ?? 'opponent'} to sell down to{' '} {view.maxPets} pets… ) @@ -135,9 +134,9 @@ export function ShopPhase({ view, you, send }: Props) { ) ) : ( @@ -145,10 +144,10 @@ export function ShopPhase({ view, you, send }: Props) {