Add pets for tiers 1-3.
This commit is contained in:
@@ -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:
|
Six rounds, each with its own shop tier deck. Per round:
|
||||||
|
|
||||||
1. **Shop** — each player has 3 coins and players alternate actions, 1 coin
|
1. **Shop** — each player has 3 gold and players alternate actions, 1 gold
|
||||||
each: **buy** one of 4 face-up cards; **discard** any number of hand cards
|
each: **buy** one of 4 face-up cards; **sell** any number of hand cards
|
||||||
(each becomes an 🍎 apple, +1 power food); or **trade in** 3 same-suit pets
|
(each becomes an 🍎 apple, +1 power food, and Sell effects fire); or
|
||||||
to pick 1 of the top 2 cards of the next tier's deck (the other goes under
|
**trade in** 3 same-suit pets (the Triple action) to pick 1 of the top 2
|
||||||
that deck). Passing forfeits remaining coins.
|
cards of the next tier's deck — Triple effects fire on the traded cards
|
||||||
2. **Cleanup** — anyone holding more than 5 pets must discard down to 5
|
and the received pet's Buy effect fires. Passing forfeits remaining gold.
|
||||||
(discards become apples).
|
2. **Cleanup** — anyone holding more than 5 pets must sell down to 5.
|
||||||
3. **Arrange** — players secretly order their decks. Food cards apply to the
|
3. **Arrange** — Battle Prep effects fire first (e.g. Giraffe hands out
|
||||||
next pet after them; trailing foods are wasted.
|
apples), then players secretly order their decks. Food cards apply to the
|
||||||
4. **Battle** — automatic. Front pets simultaneously deal their full power to
|
next pet after them; trailing foods are wasted. A pet only benefits from
|
||||||
each other as damage markers; a pet with markers ≥ power dies (attack
|
its last-applied **perk** (e.g. Honey, Garlic).
|
||||||
power never drops while wounded). Last player with pets standing wins the
|
4. **Battle** — automatic stack machine. Cards reveal off the top of each
|
||||||
round: 1 trophy for rounds 1–5, 2 trophies for round 6. Draws award
|
deck until a pet is in play. Play effects fire on reveal (rocks roll a d6
|
||||||
nothing.
|
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
|
Apples and bees are **temporary**: they leave your deck after the battle.
|
||||||
card model carries an `effect` field for when they land).
|
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
|
## Layout
|
||||||
|
|
||||||
|
|||||||
+335
-76
@@ -1,117 +1,376 @@
|
|||||||
package game
|
package game
|
||||||
|
|
||||||
// BattleUnit is a pet on the battle line with its attached foods applied.
|
// BattleUnit is a pet in play with its attached foods applied. Power
|
||||||
// Power (attack) is unaffected by damage; a unit dies when Damage >= Power.
|
// (attack) is unaffected by damage; a unit dies when Damage >= Power.
|
||||||
type BattleUnit struct {
|
type BattleUnit struct {
|
||||||
Card Card `json:"card"`
|
Card Card `json:"card"`
|
||||||
Foods []Card `json:"foods"`
|
Foods []Card `json:"foods,omitempty"`
|
||||||
Bonus int `json:"bonus"` // total power added by foods
|
Bonus int `json:"bonus"` // total power added by foods (and eating)
|
||||||
Damage int `json:"damage"` // damage markers accumulated this battle
|
Damage int `json:"damage"` // damage markers accumulated this battle
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *BattleUnit) Power() int { return u.Card.Power + u.Bonus }
|
func (u *BattleUnit) Power() int { return u.Card.Power + u.Bonus }
|
||||||
func (u *BattleUnit) Alive() bool { return u.Damage < u.Power() }
|
func (u *BattleUnit) Alive() bool { return u.Damage < u.Power() }
|
||||||
|
|
||||||
// BattleEvent is one step of the battle, in order, for clients to animate.
|
// activePerk returns the perk this unit benefits from: only the
|
||||||
type BattleEvent struct {
|
// last-applied perk counts when several are attached.
|
||||||
Type string `json:"type"` // "clash"
|
func (u *BattleUnit) activePerk() *Card {
|
||||||
// Indexes into the initial lineups (per seat) of the two front pets.
|
for i := len(u.Foods) - 1; i >= 0; i-- {
|
||||||
Units []int `json:"units"` // one entry per seat
|
if u.Foods[i].Perk {
|
||||||
// Damage each front pet has accumulated after the clash, per seat.
|
return &u.Foods[i]
|
||||||
Damage []int `json:"damage"`
|
}
|
||||||
// Whether each front pet died in the clash, per seat.
|
}
|
||||||
Died []bool `json:"died"`
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// BattleResult is the full, public record of one round's battle.
|
// 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 {
|
||||||
|
// "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. Only what
|
||||||
|
// was revealed appears in Events; the rest of the winner's stack stays
|
||||||
|
// hidden.
|
||||||
type BattleResult struct {
|
type BattleResult struct {
|
||||||
Round int `json:"round"`
|
Round int `json:"round"`
|
||||||
Lineups [][]BattleUnit `json:"lineups"` // initial lineups per seat
|
StackSizes []int `json:"stackSizes"` // starting deck size per seat
|
||||||
WastedFood [][]Card `json:"wastedFoods"` // foods with no pet beneath them, per seat
|
|
||||||
Events []BattleEvent `json:"events"`
|
Events []BattleEvent `json:"events"`
|
||||||
WinnerSeat int `json:"winnerSeat"` // -1 = draw
|
WinnerSeat int `json:"winnerSeat"` // -1 = draw
|
||||||
Trophies int `json:"trophies"` // awarded to the winner
|
Trophies int `json:"trophies"` // awarded to the winner
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildLineup walks a deck top-to-bottom, attaching each run of foods to the
|
// battleSide is one seat's live state during the simulation.
|
||||||
// next pet below it. Foods after the last pet affect nothing and are wasted.
|
type battleSide struct {
|
||||||
func buildLineup(deck []Card) (units []BattleUnit, wasted []Card) {
|
stack []Card // remaining face-down cards, top first
|
||||||
var pendingFoods []Card
|
pending []Card // foods revealed and waiting for a pet
|
||||||
for _, c := range deck {
|
unit *BattleUnit
|
||||||
if c.IsFood() {
|
beesFainted int // friendly bees fainted so far (Dog)
|
||||||
pendingFoods = append(pendingFoods, c)
|
delayedRocks []int // set-aside Badgers: rocks to throw when the next pet plays
|
||||||
continue
|
}
|
||||||
}
|
|
||||||
u := BattleUnit{Card: c, Foods: pendingFoods}
|
// queuedPlay is a play-time effect waiting to resolve after reveals.
|
||||||
for _, f := range pendingFoods {
|
type queuedPlay struct {
|
||||||
if f.Food == FoodApple {
|
seat int
|
||||||
u.Bonus++
|
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
|
||||||
pendingFoods = nil
|
|
||||||
units = append(units, u)
|
|
||||||
}
|
|
||||||
return units, pendingFoods
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolveBattle simulates the battle from the players' arranged decks,
|
// resolveBattle simulates the battle from the players' arranged decks,
|
||||||
// records the event log, awards trophies, and moves to PhaseBattle.
|
// records the event log, awards trophies, and moves to PhaseBattle.
|
||||||
//
|
//
|
||||||
// Combat: the two front pets deal their full Power to each other
|
// The battle is a stack machine: each side reveals cards off the top of
|
||||||
// simultaneously as damage markers. A pet with Damage >= Power dies. Since
|
// their deck until a pet is in play (foods along the way attach to it; only
|
||||||
// remaining health never exceeds Power, at least one pet dies every clash,
|
// the last-applied perk counts). If anyone can no longer field a pet the
|
||||||
// so the loop always terminates (until effects say otherwise).
|
// 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() {
|
func (g *Game) resolveBattle() {
|
||||||
res := &BattleResult{
|
n := len(g.Players)
|
||||||
Round: g.Round,
|
res := &BattleResult{Round: g.Round, WinnerSeat: -1, StackSizes: make([]int, n)}
|
||||||
Lineups: make([][]BattleUnit, len(g.Players)),
|
sides := make([]*battleSide, n)
|
||||||
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
|
|
||||||
for _, p := range g.Players {
|
for _, p := range g.Players {
|
||||||
units, wasted := buildLineup(p.Deck)
|
sides[p.Seat] = &battleSide{stack: append([]Card(nil), p.Deck...)}
|
||||||
res.Lineups[p.Seat] = units
|
res.StackSizes[p.Seat] = len(p.Deck)
|
||||||
res.WastedFood[p.Seat] = wasted
|
}
|
||||||
live[p.Seat] = append([]BattleUnit(nil), units...)
|
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;
|
// faint fires the unit's faint effects (its own and its perk's) in
|
||||||
// the surrounding state (lineups, events) is already per-seat.
|
// effect order, and tracks fainted bees.
|
||||||
a, b := 0, 1
|
faint := func(seat int, u *BattleUnit) {
|
||||||
for front[a] < len(live[a]) && front[b] < len(live[b]) {
|
if isBee(u.Card) {
|
||||||
ua, ub := &live[a][front[a]], &live[b][front[b]]
|
sides[seat].beesFainted++
|
||||||
ua.Damage += ub.Power()
|
}
|
||||||
ub.Damage += ua.Power()
|
for _, e := range u.effects() {
|
||||||
ev := BattleEvent{
|
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",
|
Type: "clash",
|
||||||
Units: []int{front[a], front[b]},
|
|
||||||
Damage: []int{ua.Damage, ub.Damage},
|
Damage: []int{ua.Damage, ub.Damage},
|
||||||
Died: []bool{!ua.Alive(), !ub.Alive()},
|
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)
|
dealt := []int{dealtA, dealtB}
|
||||||
if !ua.Alive() {
|
for seat, u := range []*BattleUnit{ua, ub} {
|
||||||
front[a]++
|
if !u.Alive() {
|
||||||
|
faint(seat, u)
|
||||||
|
sides[seat].unit = nil
|
||||||
|
} else if dealt[seat] > 0 {
|
||||||
|
hurt(seat, u)
|
||||||
}
|
}
|
||||||
if !ub.Alive() {
|
|
||||||
front[b]++
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
trophies := 1
|
// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
res.WinnerSeat = winner
|
||||||
|
if winner >= 0 {
|
||||||
|
res.Trophies = 1
|
||||||
if g.Round == MaxRounds {
|
if g.Round == MaxRounds {
|
||||||
trophies = 2
|
res.Trophies = 2
|
||||||
}
|
}
|
||||||
switch {
|
g.Players[winner].Trophies += res.Trophies
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
g.Battle = res
|
g.Battle = res
|
||||||
|
|||||||
+491
-25
@@ -1,6 +1,9 @@
|
|||||||
package game
|
package game
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"slices"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
// testGame builds a started 2-player game without going through the lobby.
|
// testGame builds a started 2-player game without going through the lobby.
|
||||||
func testGame(t *testing.T) (*Game, *Player, *Player) {
|
func testGame(t *testing.T) (*Game, *Player, *Player) {
|
||||||
@@ -20,10 +23,52 @@ func testGame(t *testing.T) (*Game, *Player, *Player) {
|
|||||||
return g, p1, p2
|
return g, p1, p2
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// pet mints a plain effect-less pet for battle scenarios.
|
||||||
func (g *Game) pet(name string, power int) Card {
|
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.
|
// forceBattle sets both decks, arranges them in current order, and resolves.
|
||||||
func forceBattle(t *testing.T, g *Game, d1, d2 []Card) *BattleResult {
|
func forceBattle(t *testing.T, g *Game, d1, d2 []Card) *BattleResult {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
@@ -47,6 +92,16 @@ func forceBattle(t *testing.T, g *Game, d1, d2 []Card) *BattleResult {
|
|||||||
return g.Battle
|
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
|
// 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).
|
// 3 dies, the 5 survives with 3 damage markers (2 health left, 5 attack).
|
||||||
// Then a 2-power pet trades with it: both die.
|
// 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("Three", 3), g.pet("Two", 2)},
|
||||||
[]Card{g.pet("Five", 5)},
|
[]Card{g.pet("Five", 5)},
|
||||||
)
|
)
|
||||||
if len(res.Events) != 2 {
|
clashes := eventsOfType(res, "clash")
|
||||||
t.Fatalf("expected 2 clashes, got %d", len(res.Events))
|
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] {
|
if !first.Died[0] || first.Died[1] {
|
||||||
t.Fatalf("first clash: 3-power should die, 5-power should survive: %+v", first)
|
t.Fatalf("first clash: 3-power should die, 5-power should survive: %+v", first)
|
||||||
}
|
}
|
||||||
if first.Damage[1] != 3 {
|
if first.Damage[1] != 3 {
|
||||||
t.Fatalf("5-power pet should carry 3 damage, has %d", first.Damage[1])
|
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] {
|
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)
|
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("A", 4)},
|
||||||
[]Card{g.pet("B", 4)},
|
[]Card{g.pet("B", 4)},
|
||||||
)
|
)
|
||||||
ev := res.Events[0]
|
clashes := eventsOfType(res, "clash")
|
||||||
if !ev.Died[0] || !ev.Died[1] {
|
if len(clashes) != 1 || !clashes[0].Died[0] || !clashes[0].Died[1] {
|
||||||
t.Fatalf("equal power pets should both die: %+v", ev)
|
t.Fatalf("equal power pets should both die: %+v", clashes)
|
||||||
}
|
}
|
||||||
if res.WinnerSeat != -1 {
|
if res.WinnerSeat != -1 {
|
||||||
t.Fatal("expected a draw")
|
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.
|
// Foods attach to the next pet revealed beneath them; each apple adds 1
|
||||||
// Trailing foods with no pet under them are wasted.
|
// power. 3+2 apples = 5 power draws with a plain 5.
|
||||||
func TestBattleApplesBuffNextPet(t *testing.T) {
|
func TestBattleApplesBuffNextPet(t *testing.T) {
|
||||||
g, _, _ := testGame(t)
|
g, _, _ := testGame(t)
|
||||||
apple1, apple2, apple3 := g.newApple(), g.newApple(), g.newApple()
|
|
||||||
res := forceBattle(t, g,
|
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)},
|
[]Card{g.pet("Enemy", 5)},
|
||||||
)
|
)
|
||||||
u := res.Lineups[0][0]
|
clash := eventsOfType(res, "clash")[0]
|
||||||
if u.Bonus != 2 || u.Power() != 5 {
|
if !clash.Died[0] || !clash.Died[1] {
|
||||||
t.Fatalf("expected 2 apples for 5 total power, got bonus=%d power=%d", u.Bonus, u.Power())
|
t.Fatalf("5 vs 3+2apples should kill both: %+v", clash)
|
||||||
}
|
|
||||||
if len(res.WastedFood[0]) != 1 || res.WastedFood[0][0].ID != apple3.ID {
|
|
||||||
t.Fatalf("trailing apple should be wasted: %+v", res.WastedFood[0])
|
|
||||||
}
|
}
|
||||||
if res.WinnerSeat != -1 {
|
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.
|
// A side whose stack holds only foods can't field a pet and loses without a
|
||||||
func TestBattleEmptyLineupLoses(t *testing.T) {
|
// single clash.
|
||||||
|
func TestBattleFoodOnlyLoses(t *testing.T) {
|
||||||
g, _, _ := testGame(t)
|
g, _, _ := testGame(t)
|
||||||
res := forceBattle(t, g,
|
res := forceBattle(t, g,
|
||||||
[]Card{g.pet("Solo", 1)},
|
[]Card{g.pet("Solo", 1)},
|
||||||
[]Card{g.newApple()}, // food only, no pets
|
[]Card{g.newApple()},
|
||||||
)
|
)
|
||||||
if len(res.Events) != 0 {
|
if len(eventsOfType(res, "clash")) != 0 {
|
||||||
t.Fatalf("expected no clashes, got %d", len(res.Events))
|
t.Fatal("expected no clashes")
|
||||||
}
|
}
|
||||||
if res.WinnerSeat != 0 {
|
if res.WinnerSeat != 0 {
|
||||||
t.Fatalf("seat 0 should win by default, got %d", res.WinnerSeat)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+293
-41
@@ -2,19 +2,17 @@ package game
|
|||||||
|
|
||||||
import "fmt"
|
import "fmt"
|
||||||
|
|
||||||
// Suit is the trade-in symbol printed on pet cards. Three pets of the same
|
// Suit is the colored trade-in symbol printed on pet cards. Three pets of
|
||||||
// suit can be traded for a pick of the next tier's deck.
|
// the same suit can be traded (the "Triple" action) for a pick from the next
|
||||||
|
// tier's deck.
|
||||||
type Suit string
|
type Suit string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
SuitSun Suit = "sun"
|
SuitRed Suit = "red"
|
||||||
SuitMoon Suit = "moon"
|
SuitBlue Suit = "blue"
|
||||||
SuitStar Suit = "star"
|
SuitYellow Suit = "yellow"
|
||||||
SuitLeaf Suit = "leaf"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var suits = []Suit{SuitSun, SuitMoon, SuitStar, SuitLeaf}
|
|
||||||
|
|
||||||
// CardKind distinguishes pets from foods.
|
// CardKind distinguishes pets from foods.
|
||||||
type CardKind string
|
type CardKind string
|
||||||
|
|
||||||
@@ -23,63 +21,286 @@ const (
|
|||||||
KindFood CardKind = "food"
|
KindFood CardKind = "food"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Food identifiers. Only apples exist for now; more foods come later.
|
// Food identifiers.
|
||||||
const FoodApple = "apple"
|
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.
|
// Card is a single physical card instance. IDs are unique per game.
|
||||||
type Card struct {
|
type Card struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Kind CardKind `json:"kind"`
|
Kind CardKind `json:"kind"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Tier int `json:"tier"`
|
Tier int `json:"tier,omitempty"`
|
||||||
Power int `json:"power,omitempty"`
|
Power int `json:"power,omitempty"`
|
||||||
Suit Suit `json:"suit,omitempty"`
|
Suit Suit `json:"suit,omitempty"`
|
||||||
// Effect is display text for the pet's ability. Effects are not yet
|
// Effects drive the engine; EffectText is the human-readable rule shown
|
||||||
// implemented mechanically; the field keeps card data forward-compatible.
|
// on the card face.
|
||||||
Effect string `json:"effect,omitempty"`
|
Effects []Effect `json:"effects,omitempty"`
|
||||||
|
EffectText string `json:"effectText,omitempty"`
|
||||||
Food string `json:"food,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) IsPet() bool { return c.Kind == KindPet }
|
||||||
func (c Card) IsFood() bool { return c.Kind == KindFood }
|
func (c Card) IsFood() bool { return c.Kind == KindFood }
|
||||||
|
|
||||||
// petTemplate is the printed definition of a pet; each template appears as
|
// petTemplate is the printed definition of a pet. Suits lists the suit of
|
||||||
// multiple card copies in its tier's shop deck.
|
// each physical copy in the tier deck (one card per entry).
|
||||||
type petTemplate struct {
|
type petTemplate struct {
|
||||||
Name string
|
Name string
|
||||||
Power int
|
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
|
// petTiers defines the shop decks' pets. Index 0 is tier 1 (round 1)
|
||||||
// index 5 for tier 6 (round 6). Suits are assigned round-robin per tier so
|
// through index 5 for tier 6 (round 6).
|
||||||
// every tier contains every suit.
|
//
|
||||||
|
// 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{
|
var petTiers = [MaxRounds][]petTemplate{
|
||||||
{ // Tier 1
|
{ // 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
|
{ // 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
|
{ // 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",
|
||||||
},
|
},
|
||||||
{ // Tier 4
|
{
|
||||||
{"Skunk", 5}, {"Hippo", 6}, {"Bison", 6}, {"Deer", 4},
|
Name: "Dolphin", Power: 2, Suits: []Suit{SuitBlue, SuitRed},
|
||||||
{"Squirrel", 4}, {"Whale", 5}, {"Worm", 4}, {"Penguin", 5},
|
Effects: []Effect{{Trigger: TriggerPlay, Action: ActionThrowRock, Count: 3}},
|
||||||
|
EffectText: "Play: throw 3 Rocks",
|
||||||
},
|
},
|
||||||
{ // Tier 5
|
{
|
||||||
{"Scorpion", 5}, {"Rhino", 7}, {"Monkey", 6}, {"Cow", 6},
|
Name: "Giraffe", Power: 2, Suits: []Suit{SuitRed, SuitBlue},
|
||||||
{"Seal", 6}, {"Shark", 7}, {"Turkey", 5}, {"Crocodile", 8},
|
Effects: []Effect{{Trigger: TriggerBattlePrep, Action: ActionGainApple, Count: 2}},
|
||||||
|
EffectText: "Battle Prep: add 2 Apples to your hand",
|
||||||
},
|
},
|
||||||
{ // Tier 6
|
{
|
||||||
{"Leopard", 8}, {"Boar", 9}, {"Fly", 7}, {"Gorilla", 9},
|
Name: "Camel", Power: 3, Suits: []Suit{SuitYellow, SuitBlue},
|
||||||
{"Mammoth", 10}, {"Snake", 8}, {"Tiger", 9}, {"Dragon", 10},
|
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",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
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 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.
|
// 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).
|
// buildShopDecks creates all six tier decks (unshuffled).
|
||||||
func (g *Game) buildShopDecks() {
|
func (g *Game) buildShopDecks() {
|
||||||
g.ShopDecks = make([][]Card, MaxRounds)
|
g.ShopDecks = make([][]Card, MaxRounds)
|
||||||
for tierIdx, templates := range petTiers {
|
for tierIdx := range petTiers {
|
||||||
deck := make([]Card, 0, len(templates)*copiesPerPet)
|
var deck []Card
|
||||||
for i, t := range templates {
|
for _, t := range petTiers[tierIdx] {
|
||||||
for range copiesPerPet {
|
for _, suit := range t.Suits {
|
||||||
deck = append(deck, Card{
|
deck = append(deck, Card{
|
||||||
ID: g.newCardID(),
|
ID: g.newCardID(),
|
||||||
Kind: KindPet,
|
Kind: KindPet,
|
||||||
Name: t.Name,
|
Name: t.Name,
|
||||||
Tier: tierIdx + 1,
|
Tier: tierIdx + 1,
|
||||||
Power: t.Power,
|
Power: t.Power,
|
||||||
Suit: suits[i%len(suits)],
|
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 {
|
func (g *Game) newApple() Card {
|
||||||
return Card{
|
return Card{
|
||||||
ID: g.newCardID(),
|
ID: g.newCardID(),
|
||||||
Kind: KindFood,
|
Kind: KindFood,
|
||||||
Name: "Apple",
|
Name: "Apple",
|
||||||
Food: FoodApple,
|
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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+71
-17
@@ -85,6 +85,18 @@ type Game struct {
|
|||||||
Battle *BattleResult `json:"battle,omitempty"` // most recent battle
|
Battle *BattleResult `json:"battle,omitempty"` // most recent battle
|
||||||
NextCardID int `json:"nextCardId"`
|
NextCardID int `json:"nextCardId"`
|
||||||
WinnerSeat int `json:"winnerSeat"` // set at gameover; -1 = tie
|
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 (
|
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)
|
return fmt.Errorf("%w: no card in that shop slot", ErrInvalidAction)
|
||||||
}
|
}
|
||||||
p.Coins--
|
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.ShopRow[rowIdx] = g.drawFromTier(g.Round)
|
||||||
|
g.applyShopTrigger(p, bought, TriggerBuy)
|
||||||
g.advanceShopTurn()
|
g.advanceShopTurn()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Discard spends one coin to convert any number (>=1) of the player's cards
|
// Sell spends one coin to sell any number (>=1) of the player's cards: each
|
||||||
// into that many apples.
|
// becomes an apple, and Sell effects on the sold cards fire.
|
||||||
func (g *Game) Discard(playerID string, cardIDs []string) error {
|
func (g *Game) Sell(playerID string, cardIDs []string) error {
|
||||||
p, err := g.requireShopTurn(playerID)
|
p, err := g.requireShopTurn(playerID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if len(cardIDs) == 0 {
|
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
|
return err
|
||||||
}
|
}
|
||||||
p.Coins--
|
p.Coins--
|
||||||
@@ -271,9 +285,27 @@ func (g *Game) Discard(playerID string, cardIDs []string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// convertToApples removes the given cards from p's deck and adds one apple
|
// applyShopTrigger fires a shop-time trigger (buy/sell/triple) on one card.
|
||||||
// per removed card. It validates before mutating.
|
func (g *Game) applyShopTrigger(p *Player, c Card, trigger EffectTrigger) {
|
||||||
func (g *Game) convertToApples(p *Player, cardIDs []string) error {
|
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) {
|
if hasDuplicates(cardIDs) {
|
||||||
return fmt.Errorf("%w: duplicate card", ErrInvalidAction)
|
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)
|
return fmt.Errorf("%w: card not in your deck", ErrInvalidAction)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
sold := make([]Card, 0, len(cardIDs))
|
||||||
for _, id := range 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())
|
p.Deck = append(p.Deck, g.newApple())
|
||||||
|
g.applyShopTrigger(p, c, TriggerSell)
|
||||||
}
|
}
|
||||||
return nil
|
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)
|
return fmt.Errorf("%w: next tier deck is exhausted", ErrInvalidAction)
|
||||||
}
|
}
|
||||||
// Validated; commit.
|
// Validated; commit.
|
||||||
|
traded := make([]Card, 0, TradeInCount)
|
||||||
for _, id := range cardIDs {
|
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--
|
p.Coins--
|
||||||
g.Pending = &PendingTrade{
|
g.Pending = &PendingTrade{
|
||||||
@@ -335,6 +374,10 @@ func (g *Game) TradeStart(playerID string, cardIDs []string) error {
|
|||||||
Tier: nextTier,
|
Tier: nextTier,
|
||||||
Options: [2]Card{g.drawFromTier(nextTier), g.drawFromTier(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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -353,6 +396,8 @@ func (g *Game) TradeChoose(playerID string, pick int) error {
|
|||||||
tierIdx := g.Pending.Tier - 1
|
tierIdx := g.Pending.Tier - 1
|
||||||
g.ShopDecks[tierIdx] = append(g.ShopDecks[tierIdx], other)
|
g.ShopDecks[tierIdx] = append(g.ShopDecks[tierIdx], other)
|
||||||
g.Pending = nil
|
g.Pending = nil
|
||||||
|
// Pets obtained via the Triple action trigger their Buy effects.
|
||||||
|
g.applyShopTrigger(p, chosen, TriggerBuy)
|
||||||
g.advanceShopTurn()
|
g.advanceShopTurn()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -398,9 +443,9 @@ func (g *Game) endShop() {
|
|||||||
g.beginArrange()
|
g.beginArrange()
|
||||||
}
|
}
|
||||||
|
|
||||||
// CleanupDiscard performs the forced end-of-shop discard: the player must
|
// CleanupSell performs the forced end-of-shop sale: the player must sell
|
||||||
// convert exactly their excess pets into apples.
|
// exactly their excess pets (each becomes an apple; Sell effects fire).
|
||||||
func (g *Game) CleanupDiscard(playerID string, cardIDs []string) error {
|
func (g *Game) CleanupSell(playerID string, cardIDs []string) error {
|
||||||
if g.Phase != PhaseCleanup {
|
if g.Phase != PhaseCleanup {
|
||||||
return ErrWrongPhase
|
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)
|
return fmt.Errorf("%w: you are not over the pet limit", ErrInvalidAction)
|
||||||
}
|
}
|
||||||
if len(cardIDs) != excess {
|
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 {
|
for _, id := range cardIDs {
|
||||||
idx := p.cardIndex(id)
|
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)
|
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
|
return err
|
||||||
}
|
}
|
||||||
p.Ready = true
|
p.Ready = true
|
||||||
@@ -444,6 +489,11 @@ func (g *Game) beginArrange() {
|
|||||||
g.Phase = PhaseArrange
|
g.Phase = PhaseArrange
|
||||||
for _, p := range g.Players {
|
for _, p := range g.Players {
|
||||||
p.Ready = false
|
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() {
|
if !g.allReady() {
|
||||||
return nil
|
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 {
|
if g.Round >= MaxRounds {
|
||||||
g.finish()
|
g.finish()
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
+155
-17
@@ -84,24 +84,60 @@ func TestTurnValidation(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDiscardConvertsToApples(t *testing.T) {
|
func TestSellConvertsToApples(t *testing.T) {
|
||||||
g, _, _ := testGame(t)
|
g, _, _ := testGame(t)
|
||||||
p := current(g)
|
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 {
|
if err := g.Buy(p.ID, 0); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
// Skip opponent back to p.
|
hasOtter, apples := false, 0
|
||||||
if err := g.Buy(current(g).ID, 0); err != nil {
|
for _, c := range p.Deck {
|
||||||
t.Fatal(err)
|
if c.Name == "Otter" {
|
||||||
|
hasOtter = true
|
||||||
}
|
}
|
||||||
if err := g.Discard(p.ID, deckIDs(p, Card.IsPet)); err != nil {
|
if c.Food == FoodApple {
|
||||||
t.Fatal(err)
|
apples++
|
||||||
}
|
}
|
||||||
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 {
|
if !hasOtter || apples != 1 {
|
||||||
t.Fatalf("discarded pet should become an apple: %+v", p.Deck)
|
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.
|
// Hand p three same-suit tier-1 pets directly.
|
||||||
for range 3 {
|
for range 3 {
|
||||||
c := g.pet("Fodder", 1)
|
c := g.pet("Fodder", 1)
|
||||||
c.Suit = SuitMoon
|
c.Suit = SuitBlue
|
||||||
p.Deck = append(p.Deck, c)
|
p.Deck = append(p.Deck, c)
|
||||||
}
|
}
|
||||||
nextDeckBefore := len(g.ShopDecks[1])
|
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) {
|
func TestTradeRequiresMatchingSuit(t *testing.T) {
|
||||||
g, _, _ := testGame(t)
|
g, _, _ := testGame(t)
|
||||||
p := current(g)
|
p := current(g)
|
||||||
a, b, c := g.pet("A", 1), g.pet("B", 1), g.pet("C", 1)
|
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)
|
p.Deck = append(p.Deck, a, b, c)
|
||||||
if err := g.TradeStart(p.ID, []string{a.ID, b.ID, c.ID}); err == nil {
|
if err := g.TradeStart(p.ID, []string{a.ID, b.ID, c.ID}); err == nil {
|
||||||
t.Fatal("mismatched suits should be rejected")
|
t.Fatal("mismatched suits should be rejected")
|
||||||
@@ -170,7 +308,7 @@ func TestTradeBlockedOnFinalRound(t *testing.T) {
|
|||||||
p := current(g)
|
p := current(g)
|
||||||
for range 3 {
|
for range 3 {
|
||||||
c := g.pet("Fodder", 1)
|
c := g.pet("Fodder", 1)
|
||||||
c.Suit = SuitMoon
|
c.Suit = SuitBlue
|
||||||
p.Deck = append(p.Deck, c)
|
p.Deck = append(p.Deck, c)
|
||||||
}
|
}
|
||||||
if err := g.TradeStart(p.ID, deckIDs(p, nil)); err == nil {
|
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)
|
t.Fatalf("player with %d pets must be forced to discard, got phase %s", MaxPets+2, g.Phase)
|
||||||
}
|
}
|
||||||
// Wrong count rejected.
|
// Wrong count rejected.
|
||||||
if err := g.CleanupDiscard(p1.ID, deckIDs(p1, Card.IsPet)[:1]); err == nil {
|
if err := g.CleanupSell(p1.ID, deckIDs(p1, Card.IsPet)[:1]); err == nil {
|
||||||
t.Fatal("must discard exactly the excess")
|
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)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if p1.PetCount() != MaxPets {
|
if p1.PetCount() != MaxPets {
|
||||||
@@ -313,7 +451,7 @@ func TestViewHidesSecrets(t *testing.T) {
|
|||||||
// Pending trade options hidden from the opponent.
|
// Pending trade options hidden from the opponent.
|
||||||
for range 3 {
|
for range 3 {
|
||||||
c := g.pet("Fodder", 1)
|
c := g.pet("Fodder", 1)
|
||||||
c.Suit = SuitLeaf
|
c.Suit = SuitYellow
|
||||||
p1.Deck = append(p1.Deck, c)
|
p1.Deck = append(p1.Deck, c)
|
||||||
}
|
}
|
||||||
g.Turn = p1.Seat
|
g.Turn = p1.Seat
|
||||||
|
|||||||
@@ -117,11 +117,11 @@ func (s *Server) apply(r *room, c *client, msg clientMessage) {
|
|||||||
switch msg.Type {
|
switch msg.Type {
|
||||||
case "buy":
|
case "buy":
|
||||||
err = g.Buy(c.playerID, msg.Row)
|
err = g.Buy(c.playerID, msg.Row)
|
||||||
case "discard":
|
case "sell":
|
||||||
if g.Phase == game.PhaseCleanup {
|
if g.Phase == game.PhaseCleanup {
|
||||||
err = g.CleanupDiscard(c.playerID, msg.Cards)
|
err = g.CleanupSell(c.playerID, msg.Cards)
|
||||||
} else {
|
} else {
|
||||||
err = g.Discard(c.playerID, msg.Cards)
|
err = g.Sell(c.playerID, msg.Cards)
|
||||||
}
|
}
|
||||||
case "trade":
|
case "trade":
|
||||||
err = g.TradeStart(c.playerID, msg.Cards)
|
err = g.TradeStart(c.playerID, msg.Cards)
|
||||||
|
|||||||
@@ -17,10 +17,18 @@ export function ArrangePhase({ view, you, send }: Props) {
|
|||||||
const dragIndex = useRef<number | null>(null)
|
const dragIndex = useRef<number | null>(null)
|
||||||
const locked = you.ready
|
const locked = you.ready
|
||||||
|
|
||||||
// If the server-side deck changes (shouldn't during arrange, but be safe),
|
// Resync only if the deck's actual contents changed — every broadcast
|
||||||
// resync.
|
// creates a fresh array, and blindly resetting would wipe an in-progress
|
||||||
|
// ordering whenever the opponent acts.
|
||||||
useEffect(() => {
|
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])
|
}, [you.deck])
|
||||||
|
|
||||||
function move(from: number, to: number) {
|
function move(from: number, to: number) {
|
||||||
|
|||||||
@@ -1,95 +1,168 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react'
|
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 { CardView } from './CardView'
|
||||||
|
import { artFor } from '../petArt'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
view: GameView
|
view: GameView
|
||||||
send: (msg: ClientMessage) => void
|
send: (msg: ClientMessage) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const STEP_MS = 1400
|
interface UnitVis {
|
||||||
|
card: Card
|
||||||
interface UnitState {
|
foods: Card[]
|
||||||
unit: BattleUnit
|
bonus: number
|
||||||
index: number
|
|
||||||
damage: number
|
damage: number
|
||||||
dead: boolean // died in an earlier step (gone)
|
dying: boolean
|
||||||
dying: boolean // died in the step just played (animate out)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// applyEvents replays the first `step` events onto a seat's lineup.
|
interface SideVis {
|
||||||
function applyEvents(view: GameView, seat: number, step: number): UnitState[] {
|
stack: number
|
||||||
const battle = view.battle!
|
pending: Card[] // revealed foods waiting for a pet
|
||||||
const events = battle.events ?? []
|
unit: UnitVis | null
|
||||||
const states: UnitState[] = (battle.lineups[seat] ?? []).map((u, i) => ({
|
}
|
||||||
unit: u,
|
|
||||||
index: i,
|
// Milliseconds each event type stays on screen during playback.
|
||||||
damage: 0,
|
const EVENT_MS: Record<BattleEvent['type'], number> = {
|
||||||
dead: false,
|
reveal: 800,
|
||||||
dying: false,
|
summon: 1000,
|
||||||
}))
|
rock: 1200,
|
||||||
const seatPos = seat === 0 ? 0 : 1
|
clash: 1400,
|
||||||
for (let k = 0; k < step && k < events.length; k++) {
|
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 ev = events[k]
|
||||||
const s = states[ev.units[seatPos]]
|
switch (ev.type) {
|
||||||
if (!s) continue
|
case 'reveal': {
|
||||||
s.damage = ev.damage[seatPos]
|
const s = sides[ev.seat!]
|
||||||
if (ev.died[seatPos]) {
|
s.stack--
|
||||||
s.dying = k === step - 1
|
const card = ev.card!
|
||||||
s.dead = k < step - 1
|
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
|
// BattlePhase plays back the battle log: cards flip off each deck, rocks
|
||||||
// fly, the fallen fade out, then the round result lands.
|
// fly, pets clash, the fallen fade out, then the round result lands.
|
||||||
export function BattlePhase({ view, send }: Props) {
|
export function BattlePhase({ view, send }: Props) {
|
||||||
const battle = view.battle!
|
const battle = view.battle!
|
||||||
const events = battle.events ?? []
|
const events = battle.events ?? []
|
||||||
const [step, setStep] = useState(0)
|
const [step, setStep] = useState(0)
|
||||||
const [acked, setAcked] = useState(false)
|
const [acked, setAcked] = useState(false)
|
||||||
const done = step >= events.length
|
const done = step >= events.length
|
||||||
|
const lastEvent = step > 0 ? events[step - 1] : null
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (done) return
|
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)
|
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 youSeat = view.youSeat
|
||||||
const oppSeat = view.players.find((p) => p.seat !== youSeat)?.seat ?? 1
|
const oppSeat = view.players.find((p) => p.seat !== youSeat)?.seat ?? 1
|
||||||
|
const you = view.players[youSeat]
|
||||||
|
const opp = view.players[oppSeat]
|
||||||
|
const won = battle.winnerSeat === youSeat
|
||||||
|
const draw = battle.winnerSeat < 0
|
||||||
|
|
||||||
const yourLine = useMemo(
|
function renderSide(seat: number, dir: 'left' | 'right') {
|
||||||
() => applyEvents(view, youSeat, step),
|
const s = sides[seat]
|
||||||
[view, youSeat, step],
|
const clashing = !done && lastEvent?.type === 'clash' && s.unit && !s.unit.dying
|
||||||
)
|
const clashDying = lastEvent?.type === 'clash' && s.unit?.dying
|
||||||
const oppLine = useMemo(
|
const rockVictim = lastEvent?.type === 'rock' && lastEvent.target === seat
|
||||||
() => applyEvents(view, oppSeat, step),
|
const eating = lastEvent?.type === 'eat' && lastEvent.seat === seat
|
||||||
[view, oppSeat, step],
|
const summoning = !done && lastEvent?.type === 'summon' && lastEvent.seat === seat
|
||||||
|
const revealing = !done && lastEvent?.type === 'reveal' && lastEvent.seat === seat
|
||||||
|
|
||||||
|
const stackEl = (
|
||||||
|
<div className="stackpile">
|
||||||
|
{s.stack > 0 ? (
|
||||||
|
<div className="card-back">
|
||||||
|
<span className="card-back-count">{s.stack}</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="card-slot-empty stack-empty" />
|
||||||
|
)}
|
||||||
|
{summoning && lastEvent?.card && (
|
||||||
|
<div className="summon-pop">
|
||||||
|
<CardView card={lastEvent.card} size="sm" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
const lastEvent = step > 0 ? events[step - 1] : null
|
const foodsEl = (
|
||||||
|
<div className="pending-foods">
|
||||||
|
{s.pending.map((f) => (
|
||||||
|
<span key={f.id} className="food-chip" title={f.name}>
|
||||||
|
{artFor(f.name)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
function renderSide(line: UnitState[], side: 'left' | 'right', seat: number) {
|
const unitEl = (
|
||||||
const seatPos = seat === 0 ? 0 : 1
|
<div className="battle-unit-zone">
|
||||||
const frontIdx = line.find((s) => !s.dead && !s.dying)?.index
|
{s.unit && (
|
||||||
return (
|
|
||||||
<div className={`battle-side battle-side-${side}`}>
|
|
||||||
{line
|
|
||||||
.filter((s) => !s.dead)
|
|
||||||
.map((s) => {
|
|
||||||
const isFront = s.index === frontIdx
|
|
||||||
const clashing =
|
|
||||||
!done && lastEvent !== null && lastEvent.units[seatPos] === s.index
|
|
||||||
return (
|
|
||||||
<div
|
<div
|
||||||
key={`${s.unit.card.id}-${clashing ? step : 'idle'}`}
|
key={`${s.unit.card.id}-${clashing || rockVictim ? step : 'idle'}`}
|
||||||
className={[
|
className={[
|
||||||
'battle-unit',
|
'battle-unit',
|
||||||
clashing ? `clash-${side}` : '',
|
clashing || clashDying ? `clash-${dir}` : '',
|
||||||
s.dying ? 'unit-dying' : '',
|
s.unit.dying ? 'unit-dying' : '',
|
||||||
isFront && !s.dying ? 'is-front' : '',
|
revealing ? 'unit-reveal' : '',
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(' ')}
|
.join(' ')}
|
||||||
@@ -97,27 +170,48 @@ export function BattlePhase({ view, send }: Props) {
|
|||||||
<CardView
|
<CardView
|
||||||
card={s.unit.card}
|
card={s.unit.card}
|
||||||
bonus={s.unit.bonus}
|
bonus={s.unit.bonus}
|
||||||
damage={s.damage}
|
damage={s.unit.damage}
|
||||||
dead={s.dying}
|
dead={s.unit.dying}
|
||||||
/>
|
/>
|
||||||
{clashing && (
|
{s.unit.foods.length > 0 && (
|
||||||
|
<div className="unit-foods">
|
||||||
|
{s.unit.foods.map((f) => (
|
||||||
|
<span key={f.id} title={f.name}>
|
||||||
|
{artFor(f.name)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{(lastEvent?.type === 'clash' || rockVictim) && !done && (
|
||||||
<div className="damage-pop">
|
<div className="damage-pop">
|
||||||
−{lastEvent!.damage[seatPos] -
|
{lastEvent?.type === 'rock'
|
||||||
prevDamage(events, step - 1, seatPos, s.index)}
|
? lastEvent.roll === 0
|
||||||
|
? 'miss!'
|
||||||
|
: `−${lastEvent.roll}`
|
||||||
|
: `−${clashDamageTaken(events, step - 1, seat)}`}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{eating && <div className="eat-pop">🍎 +1</div>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
|
||||||
|
return dir === 'left' ? (
|
||||||
|
<div className="battle-side">
|
||||||
|
{stackEl}
|
||||||
|
{foodsEl}
|
||||||
|
{unitEl}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="battle-side">
|
||||||
|
{unitEl}
|
||||||
|
{foodsEl}
|
||||||
|
{stackEl}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const you = view.players[youSeat]
|
|
||||||
const opp = view.players[oppSeat]
|
|
||||||
const won = battle.winnerSeat === youSeat
|
|
||||||
const draw = battle.winnerSeat < 0
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="battle">
|
<div className="battle">
|
||||||
<div className="battle-header">
|
<div className="battle-header">
|
||||||
@@ -136,11 +230,16 @@ export function BattlePhase({ view, send }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="battlefield">
|
<div className="battlefield">
|
||||||
{renderSide(yourLine, 'left', youSeat)}
|
{renderSide(youSeat, 'left')}
|
||||||
<div className="battle-center" aria-hidden>
|
<div className="battle-center" aria-hidden>
|
||||||
⚡
|
{!done && lastEvent?.type === 'rock' && (
|
||||||
|
<div className={`rock-fly ${lastEvent.target === youSeat ? 'rock-fly-left' : 'rock-fly-right'}`}>
|
||||||
|
🪨
|
||||||
</div>
|
</div>
|
||||||
{renderSide(oppLine, 'right', oppSeat)}
|
)}
|
||||||
|
<span className="battle-center-bolt">⚡</span>
|
||||||
|
</div>
|
||||||
|
{renderSide(oppSeat, 'right')}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{done && (
|
{done && (
|
||||||
@@ -174,18 +273,25 @@ export function BattlePhase({ view, send }: Props) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// prevDamage finds the damage a unit had before the given event, so the
|
// clashDamageTaken computes how much damage a seat's pet took in the clash
|
||||||
// floating number shows just this clash's hit.
|
// at event index `idx` (its damage total there minus its total beforehand).
|
||||||
function prevDamage(
|
function clashDamageTaken(events: BattleEvent[], idx: number, seat: number): number {
|
||||||
events: BattleEvent[],
|
const ev = events[idx]
|
||||||
upto: number,
|
if (ev?.type !== 'clash') return 0
|
||||||
seatPos: number,
|
const after = ev.damage?.[seat] ?? 0
|
||||||
unitIndex: number,
|
// Walk back to the pet's damage before this clash.
|
||||||
): number {
|
let before = 0
|
||||||
let dmg = 0
|
for (let k = idx - 1; k >= 0; k--) {
|
||||||
for (let k = 0; k < upto; k++) {
|
const e = events[k]
|
||||||
const ev = events[k]
|
if (e.type === 'reveal' && e.seat === seat && e.card?.kind === 'pet') break
|
||||||
if (ev.units[seatPos] === unitIndex) dmg = ev.damage[seatPos]
|
if (e.type === 'rock' && e.target === seat) {
|
||||||
|
before = e.damageAfter ?? 0
|
||||||
|
break
|
||||||
}
|
}
|
||||||
return dmg
|
if (e.type === 'clash') {
|
||||||
|
before = e.damage?.[seat] ?? 0
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return after - before
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Card } from '../types'
|
import type { Card } from '../types'
|
||||||
import { artFor, SUIT_EMOJI } from '../petArt'
|
import { artFor } from '../petArt'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
card: Card
|
card: Card
|
||||||
@@ -13,8 +13,9 @@ interface Props {
|
|||||||
dead?: boolean
|
dead?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
// CardView renders one physical card: pets get a power badge and suit stamp,
|
// CardView renders one physical card: pets get a power badge and a colored
|
||||||
// foods a description line. Battle mode layers on buffs and damage markers.
|
// suit dot, foods a description line. Battle mode layers on buffs and damage
|
||||||
|
// markers.
|
||||||
export function CardView({
|
export function CardView({
|
||||||
card,
|
card,
|
||||||
size = 'md',
|
size = 'md',
|
||||||
@@ -45,24 +46,25 @@ export function CardView({
|
|||||||
role={onClick ? 'button' : undefined}
|
role={onClick ? 'button' : undefined}
|
||||||
>
|
>
|
||||||
<div className="card-top">
|
<div className="card-top">
|
||||||
<span className="card-tier">T{card.tier || '–'}</span>
|
<span className="card-tier">{card.tier ? `T${card.tier}` : '·'}</span>
|
||||||
{card.suit && (
|
{card.suit && <span className={`suit-dot suit-${card.suit}`} title={`${card.suit} suit`} />}
|
||||||
<span className="card-suit" title={`Suit: ${card.suit}`}>
|
|
||||||
{SUIT_EMOJI[card.suit]}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="card-art" aria-hidden>
|
<div className="card-art" aria-hidden>
|
||||||
{artFor(card.name)}
|
{artFor(card.name)}
|
||||||
</div>
|
</div>
|
||||||
<div className="card-name">{card.name}</div>
|
<div className="card-name">{card.name}</div>
|
||||||
{card.kind === 'pet' ? (
|
{card.kind === 'pet' ? (
|
||||||
|
<>
|
||||||
|
{card.effectText && <div className="card-effect">{card.effectText}</div>}
|
||||||
<div className="card-bottom">
|
<div className="card-bottom">
|
||||||
<span className={`card-power ${bonus > 0 ? 'is-buffed' : ''}`}>{power}</span>
|
<span className={`card-power ${bonus > 0 ? 'is-buffed' : ''}`}>{power}</span>
|
||||||
{damage > 0 && !dead && <span className="card-damage">−{damage}</span>}
|
{damage > 0 && !dead && <span className="card-damage">−{damage}</span>}
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="card-food-text">+1 power</div>
|
<div className="card-effect">
|
||||||
|
{card.effectText ?? (card.food === 'apple' ? '+1 power (this battle)' : '')}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
{selected && <div className="card-check">✓</div>}
|
{selected && <div className="card-check">✓</div>}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import type { Card, ClientMessage, GameView, PlayerView } from '../types'
|
import type { Card, ClientMessage, GameView, PlayerView } from '../types'
|
||||||
import { CardView } from './CardView'
|
import { CardView } from './CardView'
|
||||||
import { SUIT_EMOJI } from '../petArt'
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
view: GameView
|
view: GameView
|
||||||
@@ -55,12 +54,12 @@ export function ShopPhase({ view, you, send }: Props) {
|
|||||||
{cleanup ? (
|
{cleanup ? (
|
||||||
excessPets > 0 ? (
|
excessPets > 0 ? (
|
||||||
<span className="status-hot">
|
<span className="status-hot">
|
||||||
Too many pets! Discard <strong>{excessPets}</strong> — they become
|
Too many pets! Sell <strong>{excessPets}</strong> — they become
|
||||||
apples 🍎
|
apples 🍎
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="muted">
|
<span className="muted">
|
||||||
Waiting for {opponent?.name ?? 'opponent'} to discard down to{' '}
|
Waiting for {opponent?.name ?? 'opponent'} to sell down to{' '}
|
||||||
{view.maxPets} pets…
|
{view.maxPets} pets…
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
@@ -135,9 +134,9 @@ export function ShopPhase({ view, you, send }: Props) {
|
|||||||
<button
|
<button
|
||||||
className="btn btn-primary"
|
className="btn btn-primary"
|
||||||
disabled={!cleanupReady}
|
disabled={!cleanupReady}
|
||||||
onClick={() => act({ type: 'discard', cards: selected })}
|
onClick={() => act({ type: 'sell', cards: selected })}
|
||||||
>
|
>
|
||||||
Discard {excessPets} pet{excessPets > 1 ? 's' : ''} → 🍎
|
Sell {excessPets} pet{excessPets > 1 ? 's' : ''} → 🍎
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
@@ -145,10 +144,10 @@ export function ShopPhase({ view, you, send }: Props) {
|
|||||||
<button
|
<button
|
||||||
className="btn btn-secondary"
|
className="btn btn-secondary"
|
||||||
disabled={!myTurn || selected.length === 0}
|
disabled={!myTurn || selected.length === 0}
|
||||||
onClick={() => act({ type: 'discard', cards: selected })}
|
onClick={() => act({ type: 'sell', cards: selected })}
|
||||||
title="Convert selected cards into apples (+1 power each)"
|
title="Convert selected cards into apples (+1 power each, this battle only)"
|
||||||
>
|
>
|
||||||
Discard {selected.length > 0 ? selected.length : ''} → 🍎 (1 🪙)
|
Sell {selected.length > 0 ? selected.length : ''} → 🍎 (1 🪙)
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="btn btn-secondary"
|
className="btn btn-secondary"
|
||||||
@@ -156,8 +155,13 @@ export function ShopPhase({ view, you, send }: Props) {
|
|||||||
onClick={() => act({ type: 'trade', cards: selected })}
|
onClick={() => act({ type: 'trade', cards: selected })}
|
||||||
title="Trade 3 same-suit pets for a pick from the next tier"
|
title="Trade 3 same-suit pets for a pick from the next tier"
|
||||||
>
|
>
|
||||||
Trade 3 {sameSuit && selectedCards[0].suit ? SUIT_EMOJI[selectedCards[0].suit] : 'matching'} ↑ Tier{' '}
|
Trade 3{' '}
|
||||||
{Math.min(view.round + 1, view.maxRounds)} (1 🪙)
|
{sameSuit && selectedCards[0].suit ? (
|
||||||
|
<span className={`suit-dot suit-${selectedCards[0].suit}`} />
|
||||||
|
) : (
|
||||||
|
'matching'
|
||||||
|
)}{' '}
|
||||||
|
↑ Tier {Math.min(view.round + 1, view.maxRounds)} (1 🪙)
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="btn btn-ghost"
|
className="btn btn-ghost"
|
||||||
|
|||||||
+10
-22
@@ -1,35 +1,23 @@
|
|||||||
import type { Suit } from './types'
|
|
||||||
|
|
||||||
const PET_EMOJI: Record<string, string> = {
|
const PET_EMOJI: Record<string, string> = {
|
||||||
// Tier 1
|
// Tier 1
|
||||||
Ant: '🐜', Cricket: '🦗', Fish: '🐟', Horse: '🐴',
|
Ant: '🐜', Cricket: '🦗', Duck: '🦆', Otter: '🦦', Mosquito: '🦟', Fish: '🐟',
|
||||||
Beaver: '🦫', Otter: '🦦', Pig: '🐷', Mosquito: '🦟',
|
|
||||||
// Tier 2
|
// Tier 2
|
||||||
Crab: '🦀', Swan: '🦢', Hedgehog: '🦔', Peacock: '🦚',
|
Worm: '🪱', Flamingo: '🦩', Peacock: '🦚', Swan: '🦢', Rat: '🐀', Spider: '🕷️',
|
||||||
Flamingo: '🦩', Rat: '🐀', Shrimp: '🦐', Spider: '🕷️',
|
|
||||||
// Tier 3
|
// Tier 3
|
||||||
Dog: '🐶', Badger: '🦡', Camel: '🐫', Giraffe: '🦒',
|
Dog: '🐶', Dolphin: '🐬', Giraffe: '🦒', Camel: '🐫', Sheep: '🐑', Dodo: '🦤', Badger: '🦡',
|
||||||
Kangaroo: '🦘', Ox: '🐂', Rabbit: '🐰', Sheep: '🐑',
|
|
||||||
// Tier 4
|
// Tier 4
|
||||||
Skunk: '🦨', Hippo: '🦛', Bison: '🦬', Deer: '🦌',
|
Skunk: '🦨', Hippo: '🦛', Bison: '🦬', Deer: '🦌', Squirrel: '🐿️', Whale: '🐳',
|
||||||
Squirrel: '🐿️', Whale: '🐳', Worm: '🪱', Penguin: '🐧',
|
|
||||||
// Tier 5
|
// Tier 5
|
||||||
Scorpion: '🦂', Rhino: '🦏', Monkey: '🐒', Cow: '🐄',
|
Scorpion: '🦂', Rhino: '🦏', Monkey: '🐒', Cow: '🐄', Seal: '🦭', Shark: '🦈',
|
||||||
Seal: '🦭', Shark: '🦈', Turkey: '🦃', Crocodile: '🐊',
|
|
||||||
// Tier 6
|
// Tier 6
|
||||||
Leopard: '🐆', Boar: '🐗', Fly: '🪰', Gorilla: '🦍',
|
Leopard: '🐆', Boar: '🐗', Gorilla: '🦍', Mammoth: '🦣', Snake: '🐍', Tiger: '🐯',
|
||||||
Mammoth: '🦣', Snake: '🐍', Tiger: '🐯', Dragon: '🐉',
|
// Summons & foods
|
||||||
// Foods
|
Bee: '🐝',
|
||||||
Apple: '🍎',
|
Apple: '🍎',
|
||||||
|
Honey: '🍯',
|
||||||
|
Garlic: '🧄',
|
||||||
}
|
}
|
||||||
|
|
||||||
export function artFor(name: string): string {
|
export function artFor(name: string): string {
|
||||||
return PET_EMOJI[name] ?? '🐾'
|
return PET_EMOJI[name] ?? '🐾'
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SUIT_EMOJI: Record<Suit, string> = {
|
|
||||||
sun: '☀️',
|
|
||||||
moon: '🌙',
|
|
||||||
star: '⭐',
|
|
||||||
leaf: '🍃',
|
|
||||||
}
|
|
||||||
|
|||||||
+239
-24
@@ -436,30 +436,69 @@ h3 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.card-art {
|
.card-art {
|
||||||
font-size: 3rem;
|
font-size: 2.4rem;
|
||||||
line-height: 1.25;
|
line-height: 1.2;
|
||||||
filter: drop-shadow(0 3px 2px rgba(0, 0, 0, 0.25));
|
filter: drop-shadow(0 3px 2px rgba(0, 0, 0, 0.25));
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-lg .card-art {
|
.card-lg .card-art {
|
||||||
font-size: 3.6rem;
|
font-size: 3rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-sm .card-art {
|
.card-sm .card-art {
|
||||||
font-size: 2.2rem;
|
font-size: 1.8rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-name {
|
.card-name {
|
||||||
font-family: var(--font-display);
|
font-family: var(--font-display);
|
||||||
font-size: 0.85rem;
|
font-size: 0.8rem;
|
||||||
margin-top: auto;
|
margin-top: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-effect {
|
||||||
|
font-size: 0.56rem;
|
||||||
|
line-height: 1.25;
|
||||||
|
font-weight: 700;
|
||||||
|
color: rgba(51, 35, 15, 0.75);
|
||||||
|
text-align: center;
|
||||||
|
padding: 0 2px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-lg .card-effect {
|
||||||
|
font-size: 0.62rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-sm .card-effect {
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-bottom {
|
.card-bottom {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-top: 2px;
|
margin-top: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suit-dot {
|
||||||
|
display: inline-block;
|
||||||
|
width: 13px;
|
||||||
|
height: 13px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid rgba(0, 0, 0, 0.35);
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suit-red {
|
||||||
|
background: #e2483d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suit-blue {
|
||||||
|
background: #3d7de2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suit-yellow {
|
||||||
|
background: #f4c430;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-power {
|
.card-power {
|
||||||
@@ -490,17 +529,14 @@ h3 {
|
|||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-food-text {
|
|
||||||
font-size: 0.72rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #7a2e1e;
|
|
||||||
margin-top: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-food {
|
.card-food {
|
||||||
background: linear-gradient(180deg, #ffe9e0, #ffd4c2);
|
background: linear-gradient(180deg, #ffe9e0, #ffd4c2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.card-food .card-effect {
|
||||||
|
color: #7a2e1e;
|
||||||
|
}
|
||||||
|
|
||||||
.card-check {
|
.card-check {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: -10px;
|
top: -10px;
|
||||||
@@ -708,26 +744,123 @@ h3 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.battle-center {
|
.battle-center {
|
||||||
|
position: relative;
|
||||||
font-size: 1.6rem;
|
font-size: 1.6rem;
|
||||||
opacity: 0.5;
|
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
min-width: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.battle-center-bolt {
|
||||||
|
opacity: 0.45;
|
||||||
}
|
}
|
||||||
|
|
||||||
.battle-side {
|
.battle-side {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 10px;
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Front units meet in the middle: left side is reversed so index 0 sits
|
/* --- deck stacks --- */
|
||||||
next to the center. */
|
|
||||||
.battle-side-left {
|
.stackpile {
|
||||||
flex-direction: row-reverse;
|
position: relative;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.battle-side-right {
|
.card-back {
|
||||||
flex-direction: row;
|
width: 84px;
|
||||||
|
height: 116px;
|
||||||
|
border-radius: var(--card-radius);
|
||||||
|
border: 3px solid var(--cocoa);
|
||||||
|
background:
|
||||||
|
repeating-linear-gradient(
|
||||||
|
45deg,
|
||||||
|
#b3552b 0 10px,
|
||||||
|
#a34a22 10px 20px
|
||||||
|
);
|
||||||
|
box-shadow:
|
||||||
|
2px 2px 0 rgba(0, 0, 0, 0.25),
|
||||||
|
4px 4px 0 rgba(0, 0, 0, 0.15);
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-back-count {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 1.3rem;
|
||||||
|
color: var(--cream);
|
||||||
|
background: rgba(0, 0, 0, 0.35);
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stack-empty {
|
||||||
|
width: 84px;
|
||||||
|
height: 116px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes summon-pop {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(18px) scale(0.6);
|
||||||
|
}
|
||||||
|
30% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(-14px) scale(1.05);
|
||||||
|
}
|
||||||
|
80% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(-10px) scale(1);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(0) scale(0.7);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.summon-pop {
|
||||||
|
position: absolute;
|
||||||
|
top: -30px;
|
||||||
|
left: 50%;
|
||||||
|
margin-left: -42px;
|
||||||
|
z-index: 6;
|
||||||
|
animation: summon-pop 1000ms ease forwards;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- foods waiting for a pet --- */
|
||||||
|
|
||||||
|
.pending-foods {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
font-size: 1.3rem;
|
||||||
|
min-width: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unit-foods {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 2px;
|
||||||
|
font-size: 1rem;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- the pet in play --- */
|
||||||
|
|
||||||
|
.battle-unit-zone {
|
||||||
|
min-width: 100px;
|
||||||
|
min-height: 150px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.battle-unit {
|
.battle-unit {
|
||||||
@@ -740,8 +873,90 @@ h3 {
|
|||||||
height: 132px;
|
height: 132px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.battle-unit.is-front .card {
|
@keyframes unit-reveal {
|
||||||
outline: 3px solid rgba(255, 207, 92, 0.6);
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: rotateY(90deg) scale(0.8);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: rotateY(0) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.battle-unit.unit-reveal {
|
||||||
|
animation: unit-reveal 500ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes rock-fly-right {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-70px) translateY(-10px) rotate(0);
|
||||||
|
}
|
||||||
|
20% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(70px) translateY(4px) rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes rock-fly-left {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(70px) translateY(-10px) rotate(0);
|
||||||
|
}
|
||||||
|
20% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(-70px) translateY(4px) rotate(-360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.rock-fly {
|
||||||
|
position: absolute;
|
||||||
|
font-size: 1.4rem;
|
||||||
|
z-index: 7;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rock-fly-right {
|
||||||
|
animation: rock-fly-right 700ms ease-in forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rock-fly-left {
|
||||||
|
animation: rock-fly-left 700ms ease-in forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes eat-pop {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translate(-50%, 6px) scale(0.6);
|
||||||
|
}
|
||||||
|
30% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translate(-50%, -14px) scale(1.2);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translate(-50%, -34px) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.eat-pop {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 50%;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 1rem;
|
||||||
|
color: #7be495;
|
||||||
|
text-shadow: 0 2px 0 rgba(0, 0, 0, 0.5);
|
||||||
|
animation: eat-pop 1000ms ease forwards;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 5;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes clash-left {
|
@keyframes clash-left {
|
||||||
|
|||||||
+21
-17
@@ -1,6 +1,6 @@
|
|||||||
// Mirrors of the Go view types (internal/game/view.go).
|
// Mirrors of the Go view types (internal/game/view.go).
|
||||||
|
|
||||||
export type Suit = 'sun' | 'moon' | 'star' | 'leaf'
|
export type Suit = 'red' | 'blue' | 'yellow'
|
||||||
export type CardKind = 'pet' | 'food'
|
export type CardKind = 'pet' | 'food'
|
||||||
export type Phase = 'lobby' | 'shop' | 'cleanup' | 'arrange' | 'battle' | 'gameover'
|
export type Phase = 'lobby' | 'shop' | 'cleanup' | 'arrange' | 'battle' | 'gameover'
|
||||||
|
|
||||||
@@ -8,11 +8,14 @@ export interface Card {
|
|||||||
id: string
|
id: string
|
||||||
kind: CardKind
|
kind: CardKind
|
||||||
name: string
|
name: string
|
||||||
tier: number
|
tier?: number
|
||||||
power?: number
|
power?: number
|
||||||
suit?: Suit
|
suit?: Suit
|
||||||
effect?: string
|
effects?: unknown[]
|
||||||
|
effectText?: string
|
||||||
food?: string
|
food?: string
|
||||||
|
perk?: boolean
|
||||||
|
temporary?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PlayerView {
|
export interface PlayerView {
|
||||||
@@ -34,24 +37,25 @@ export interface PendingTrade {
|
|||||||
options: [Card, Card]
|
options: [Card, Card]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BattleUnit {
|
|
||||||
card: Card
|
|
||||||
foods: Card[] | null
|
|
||||||
bonus: number
|
|
||||||
damage: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BattleEvent {
|
export interface BattleEvent {
|
||||||
type: 'clash'
|
type: 'reveal' | 'summon' | 'rock' | 'clash' | 'eat'
|
||||||
units: number[]
|
seat?: number
|
||||||
damage: number[]
|
target?: number
|
||||||
died: boolean[]
|
card?: Card
|
||||||
|
// clash
|
||||||
|
damage?: number[]
|
||||||
|
died?: boolean[]
|
||||||
|
// rock
|
||||||
|
roll?: number
|
||||||
|
damageAfter?: number
|
||||||
|
targetDied?: boolean
|
||||||
|
// eat
|
||||||
|
bonus?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BattleResult {
|
export interface BattleResult {
|
||||||
round: number
|
round: number
|
||||||
lineups: BattleUnit[][]
|
stackSizes: number[]
|
||||||
wastedFoods: (Card[] | null)[]
|
|
||||||
events: BattleEvent[] | null
|
events: BattleEvent[] | null
|
||||||
winnerSeat: number
|
winnerSeat: number
|
||||||
trophies: number
|
trophies: number
|
||||||
@@ -76,7 +80,7 @@ export interface GameView {
|
|||||||
|
|
||||||
export type ClientMessage =
|
export type ClientMessage =
|
||||||
| { type: 'buy'; row: number }
|
| { type: 'buy'; row: number }
|
||||||
| { type: 'discard'; cards: string[] }
|
| { type: 'sell'; cards: string[] }
|
||||||
| { type: 'trade'; cards: string[] }
|
| { type: 'trade'; cards: string[] }
|
||||||
| { type: 'tradeChoose'; pick: number }
|
| { type: 'tradeChoose'; pick: number }
|
||||||
| { type: 'pass' }
|
| { type: 'pass' }
|
||||||
|
|||||||
Reference in New Issue
Block a user