diff options
| author | Florian Fischer <florian.fischer@muhq.space> | 2025-08-02 14:47:01 +0200 |
|---|---|---|
| committer | Florian Fischer <florian.fischer@muhq.space> | 2025-08-05 16:24:47 +0200 |
| commit | 3b0c89358104d9d12a5c7a8ff516b811c7b03057 (patch) | |
| tree | 3aaade10024523cc1cb592cee95ce258bfe8d770 | |
| parent | 8df81b5b63d18b7656103953131203e2f796b504 (diff) | |
| download | muhqs-game-3b0c89358104d9d12a5c7a8ff516b811c7b03057.tar.gz muhqs-game-3b0c89358104d9d12a5c7a8ff516b811c7b03057.zip | |
add support for flexible attacks
| -rw-r--r-- | go/game/ai_test.go | 46 | ||||
| -rw-r--r-- | go/game/attack.go | 70 | ||||
| -rw-r--r-- | go/game/attack_test.go | 34 | ||||
| -rw-r--r-- | go/game/cardImplementations.go | 11 | ||||
| -rw-r--r-- | go/game/cardImplementations_test.go | 63 | ||||
| -rw-r--r-- | go/game/state.go | 2 | ||||
| -rw-r--r-- | go/game/targets.go | 3 | ||||
| -rw-r--r-- | go/game/targets_test.go | 34 | ||||
| -rw-r--r-- | go/game/unit.go | 29 | ||||
| -rw-r--r-- | go/game/unit_test.go | 4 | ||||
| -rw-r--r-- | go/ui/textBox.go | 2 |
11 files changed, 277 insertions, 21 deletions
diff --git a/go/game/ai_test.go b/go/game/ai_test.go index c1d378b6..9a9df154 100644 --- a/go/game/ai_test.go +++ b/go/game/ai_test.go @@ -365,3 +365,49 @@ symbols: } s.ResolveAction(a) } + +func TestAggressiveFlexAttackUnitAI(t *testing.T) { + mapDef := `map: |1- + SSSS + +symbols: + S: street +` + s := NewLocalState() + + m, _ := readMap(strings.NewReader(mapDef)) + s.SetMap(m) + + p := s.AddNewPlayer("p", NewDeck()) + o := s.AddNewPlayer("o", NewDeck()) + + pm := s.addNewUnit(NewCard("base/pikeman"), Position{0, 0}, p) + c := s.addNewUnit(NewCard("base/cavalry"), Position{3, 0}, o) + + ai := NewUnitAI(s, pm) + if ai == nil { + t.Fatal("ai is nil") + } + // Move p + ai.promptAction() + a, _ := ai.NextAction() + if a == nil { + t.Fatal("Nil action received from aggressive AI") + } + if _, ok := a.(*MoveAction); !ok { + t.Fatal("Received unexpected action", a) + } + s.ResolveAction(a) + + // Attack c + ai.promptAction() + a, _ = ai.NextAction() + if a == nil { + t.Fatal("Nil action received from aggressive AI") + } + s.ResolveAction(a) + + if c.Damage() != 1 { + t.Fatal("Cavlary not destroyed") + } +} diff --git a/go/game/attack.go b/go/game/attack.go index 6b33e8d9..e7d2e836 100644 --- a/go/game/attack.go +++ b/go/game/attack.go @@ -7,15 +7,30 @@ import ( "strings" ) +type FlexAttack = func(*Unit, *Tile) (int, bool) + // Attack contains all damage values for each attackable range. // The damage value for range 1 is stored at index 0, the value for range 2 at index 1 and so on. type Attack struct { - attacks []int + attacks []int + flexAttack FlexAttack +} + +// Valid reports if there are attackable ranges. +func (a Attack) Valid() bool { + return len(a.attacks) > 0 || a.flexAttack != nil } -// INVALID_ATTACK reports if there are no attackable ranges. -func INVALID_ATTACK(a Attack) bool { - return len(a.attacks) == 0 +// DamageForTile returns the damage inflicted by this attack to a tile +func (a Attack) DamageForTile(u *Unit, t *Tile) (int, bool) { + if a.flexAttack != nil { + return a.flexAttack(u, t) + } + + o := u.Tile().Position + trgt := t.Position + d := DistanceBetweenPositions(o, trgt) + return a.DamageInRange(d), IsPositionInRange(o, trgt, a.MaxRange()) } // MaxRange returns the biggest attackable range. @@ -23,9 +38,18 @@ func (a Attack) MaxRange() int { return len(a.attacks) } +// Copy returns a copy of the attack +func (a Attack) Copy() Attack { + copy := Attack{} + for _, v := range a.attacks { + copy.attacks = append(copy.attacks, v) + } + return copy +} + // DamageInRange returns the damage in a certain range. func (a Attack) DamageInRange(r int) int { - if r > a.MaxRange() { + if r == 0 || r > a.MaxRange() { return 0 } @@ -51,7 +75,7 @@ func (a *Attack) Shorten(amount int) { } func (a Attack) String() string { - if INVALID_ATTACK(a) { + if !a.Valid() { return "invalid attack" } @@ -65,7 +89,7 @@ func (a Attack) String() string { func parseAttack(attack any) Attack { if damage, ok := attack.(int); ok { - return Attack{[]int{damage}} + return Attack{attacks: []int{damage}} } attackStr := attack.(string) @@ -89,5 +113,35 @@ func parseAttack(attack any) Attack { attacks = append(attacks, a) } - return Attack{attacks} + return Attack{attacks: attacks} +} + +func flexibleRangeAttack(constraint func(*Unit, *Tile) bool) FlexAttack { + return func(u *Unit, t *Tile) (int, bool) { + baseAttack := u.Attack + pos := u.Tile().Position + d := DistanceBetweenPositions(pos, t.Position) + if IsPositionInRange(pos, t.Position, baseAttack.MaxRange()) { + return baseAttack.DamageInRange(d), true + } + + if constraint(u, t) { + return baseAttack.DamageInRange(baseAttack.MaxRange()), true + } + + return 0, false + } +} + +func pikeConstraint(u *Unit, t *Tile) bool { + if o, ok := t.Permanent.(*Unit); ok { + d := DistanceBetweenPositions(u.Tile().Position, t.Position) + + return d < 3 && o.Movement.Range > 2 + } + return false +} + +func pikeAttack() FlexAttack { + return flexibleRangeAttack(pikeConstraint) } diff --git a/go/game/attack_test.go b/go/game/attack_test.go index 6327b8eb..a13b4795 100644 --- a/go/game/attack_test.go +++ b/go/game/attack_test.go @@ -50,3 +50,37 @@ func TestAttackString(t *testing.T) { t.Fatal("unexpected attack string:", a.String()) } } + +func TestAttackCopy(t *testing.T) { + aStr := "2 range 3" + a := parseAttack(aStr) + c := a.Copy() + c.Extend(1) + if a.MaxRange() != 3 { + t.Fatal("changed max range") + } + c.attacks[0] = 1 + if a.attacks[0] != 2 { + t.Fatal("changed attack in range 0") + } +} + +func TestPikeAttack(t *testing.T) { + a := parseAttack("1") + a.flexAttack = pikeAttack() + o := &Tile{Position: Position{0, 0}} + u := NewUnit(NewCard("base/cavalry"), o, NewMockPlayer()) + enterTile(u, o) + + tile := &Tile{Position: Position{1, 2}} + enterTile(NewUnitFromPath("base/cavalry", tile, NewMockPlayer()), tile) + + // This test is crooked because the attack of the attacking unit is checked in flexAttack and not a. + d, ok := a.DamageForTile(u, tile) + if !ok { + t.Fatal("cavalry not in range for pike attack") + } + if d != u.Attack.DamageInRange(u.Attack.MaxRange()) { + t.Fatal("unexpected damage: amount", d) + } +} diff --git a/go/game/cardImplementations.go b/go/game/cardImplementations.go index 18235faa..007c2335 100644 --- a/go/game/cardImplementations.go +++ b/go/game/cardImplementations.go @@ -12,7 +12,7 @@ func adjustMelee(p Permanent, damage int) { return } - if INVALID_ATTACK(u.Attack) { + if !u.Attack.Valid() { u.Attack.attacks = append(u.Attack.attacks, 1) } else { if u.Attack.attacks[0]+damage == 0 { @@ -29,7 +29,7 @@ func adjustAttack(p Permanent, damage int) { return } - if INVALID_ATTACK(u.Attack) { + if !u.Attack.Valid() { u.Attack.attacks = append(u.Attack.attacks, damage) } else { // TODO: remove 0 attacks @@ -201,6 +201,12 @@ func (*wormtongueImpl) fullActions(u *Unit) []*FullAction { return []*FullAction{a} } +type pikemanImpl struct{ cardImplementationBase } + +func (*pikemanImpl) onETB(s *LocalState, p Permanent) { + p.(*Unit).Attack.flexAttack = pikeAttack() +} + // ====== Magic Set ====== type attackImpl struct{ cardImplementationBase } @@ -706,6 +712,7 @@ func init() { "base/tax_collector": &taxCollectorImpl{}, "base/tower_shield": &towerShieldImpl{}, "base/wormtongue": &wormtongueImpl{}, + "base/pikeman": &pikemanImpl{}, "magic/attack!": &attackImpl{}, "magic/appear!": &appearImpl{}, diff --git a/go/game/cardImplementations_test.go b/go/game/cardImplementations_test.go index e073a027..fc051f4e 100644 --- a/go/game/cardImplementations_test.go +++ b/go/game/cardImplementations_test.go @@ -63,3 +63,66 @@ symbols: t.Fatal("palisade not destroyed") } } + +func TestPikemanRange(t *testing.T) { + mapDef := `map: |1- + SSS + SSS + SSS +symbols: + S: street +` + s := NewLocalState() + + m, _ := readMap(strings.NewReader(mapDef)) + s.SetMap(m) + + player := s.AddNewPlayer("p", NewDeck()) + opo := s.AddNewPlayer("o", NewDeck()) + + p := s.addNewUnit(NewCard("base/pikeman"), Position{0, 0}, player) + c := s.addNewUnit(NewCard("base/cavalry"), Position{0, 2}, opo) + ca := s.addNewUnit(NewCard("base/cavalry_archer"), Position{1, 2}, opo) + k := s.addNewUnit(NewCard("base/knight"), Position{2, 0}, opo) + + attackable := utils.TypedSliceToInterfaceSlice(p.AttackableEnemyPermanents()) + if !utils.InterfaceSliceContains(attackable, c) { + t.Fatal("cavalry not attackable") + } + if !utils.InterfaceSliceContains(attackable, ca) { + t.Fatal("cavalry archer not attackable") + } + if utils.InterfaceSliceContains(attackable, k) { + t.Fatal("knight is attackable") + } + + a := NewAttackAction(p) + err := a.Target().AddSelection(c) + if err != nil { + t.Fatal("invalid target:", err) + } + + s.ResolveAction(a) + if !c.IsDestroyed() { + t.Fatal("cavalry not destroyed") + } + + a.Target().ClearSelection() + err = a.Target().AddSelection(ca) + if err != nil { + t.Fatal("invalid target:", err) + } + s.ResolveAction(a) + if !c.IsDestroyed() { + t.Fatal("cavalry archer not destroyed") + } + if p.Damage() != 1 { + t.Fatal("pikeman not damaged") + } + + a.Target().ClearSelection() + err = a.Target().AddSelection(k) + if err == nil { + t.Fatal("unexpected valid target") + } +} diff --git a/go/game/state.go b/go/game/state.go index 8901aed7..f6bcbc8e 100644 --- a/go/game/state.go +++ b/go/game/state.go @@ -319,7 +319,7 @@ func (s *LocalState) IsValidAttack(a *AttackAction) error { } unit := a.Source().(*Unit) - if INVALID_ATTACK(unit.Attack) { + if !unit.Attack.Valid() { return fmt.Errorf("%s can not attack", unit.card.Name) } diff --git a/go/game/targets.go b/go/game/targets.go index 75245f58..a7e89740 100644 --- a/go/game/targets.go +++ b/go/game/targets.go @@ -454,7 +454,8 @@ func attackableTargetConstraint(action Action) TargetConstraintFunc { if attackAction, ok := action.(*AttackAction); ok { attacker := attackAction.Source().(*Unit) - if !IsPositionInRange(attacker.Tile().Position, p.Tile().Position, attacker.Attack.MaxRange()) { + ok, _ := attacker.AttackableTile(p.Tile()) + if !ok { return ErrTargetOutOfRange } d := DistanceBetweenPermanents(attacker, p) diff --git a/go/game/targets_test.go b/go/game/targets_test.go index 092e2bb6..9285bb13 100644 --- a/go/game/targets_test.go +++ b/go/game/targets_test.go @@ -74,6 +74,40 @@ symbols: } } +func TestFlexAttackTargets(t *testing.T) { + mapDef := `map: |1- + HSTS + HSFS + TSWS + +symbols: + T: tower + H: house + F: farm + S: street + W: deep water +` + s := NewLocalState() + r := strings.NewReader(mapDef) + m, _ := readMap(r) + s.SetMap(m) + + p := s.AddNewPlayer("player", NewDeck()) + o := s.AddNewPlayer("oponent", NewDeck()) + + u := s.addNewUnit(NewCard("base/pikeman"), Position{1, 1}, p) + s.addNewUnit(NewCard("base/pikeman"), Position{0, 0}, o) + s.addNewUnit(NewCard("base/pikeman"), Position{3, 1}, o) + s.addNewUnit(NewCard("base/cavalry"), Position{3, 2}, o) + + a := NewAttackAction(u) + opts := a.Target().Options() + if len(opts) != 2 { + t.Fatal("expected 2 attackable options") + } + +} + func TestDisjunction(t *testing.T) { mapDef := `map: |1- HST diff --git a/go/game/unit.go b/go/game/unit.go index 463a1ad9..7032d464 100644 --- a/go/game/unit.go +++ b/go/game/unit.go @@ -122,6 +122,16 @@ func (u *Unit) MoveRangeTiles() []*Tile { return tiles } +func (u *Unit) AttackableTile(t *Tile) (bool, Attack) { + if flexAttack := u.Attack.flexAttack; flexAttack != nil { + if _, ok := flexAttack(u, t); ok { + return true, u.Attack + } + } + + return IsPositionInRange(u.Tile().Position, t.Position, u.Attack.MaxRange()), u.Attack +} + func (u *Unit) AttackableTiles() []*Tile { // Units not on the map can not attack if u.tile == nil { @@ -129,9 +139,14 @@ func (u *Unit) AttackableTiles() []*Tile { } m := u.controller.gameState.Map() - tilesInRange := TilesInRange(m, u, u.Attack.MaxRange()) - if u.Attack.MaxRange() == 1 { - return tilesInRange + var tilesInRange []*Tile + if flexAttack := u.Attack.flexAttack; flexAttack != nil { + tilesInRange = m.FilterTiles(func(t *Tile) bool { _, ok := flexAttack(u, t); return ok }) + } else { + tilesInRange = TilesInRange(m, u, u.Attack.MaxRange()) + if u.Attack.MaxRange() == 1 { + return tilesInRange + } } // House tiles are not attackable from ranges > 1 @@ -169,8 +184,10 @@ func (u *Unit) AttackableEnemyPermanents() []Permanent { } func (u *Unit) fight(p Permanent) { - r := DistanceBetweenPermanents(p, u) - DealDamage(u, p, u.Attack.DamageInRange(r)) + d, reachable := u.Attack.DamageForTile(u, p.Tile()) + if reachable { + DealDamage(u, p, d) + } } func (u *Unit) IsDestroyed() bool { @@ -202,7 +219,7 @@ func (u *Unit) AvailSlowActions() (actions []Action) { } m := u.Controller().gameState.Map() - if u.AvailAttackActions > 0 && !INVALID_ATTACK(u.Attack) && + if u.AvailAttackActions > 0 && u.Attack.Valid() && len(u.AttackableEnemyPermanents()) > 0 { actions = append(actions, NewAttackAction(u)) diff --git a/go/game/unit_test.go b/go/game/unit_test.go index 602fa8fb..b93f658d 100644 --- a/go/game/unit_test.go +++ b/go/game/unit_test.go @@ -53,7 +53,7 @@ symbols: } } - if INVALID_ATTACK(f.Attack) { + if !f.Attack.Valid() { t.Fatalf("fisher has an invalid attack") } @@ -70,7 +70,7 @@ symbols: t.Fatalf("fisher's pile is not empty") } - if !INVALID_ATTACK(f.Attack) { + if f.Attack.Valid() { t.Fatalf("fisher has still a valid atack") } diff --git a/go/ui/textBox.go b/go/ui/textBox.go index 7a3772f0..287c2a89 100644 --- a/go/ui/textBox.go +++ b/go/ui/textBox.go @@ -80,7 +80,7 @@ func NewUnitInfo(x, y int, u *game.Unit) *TextBox { info = fmt.Sprintf("%s\nMovement: %s", info, u.Movement.String()) } - if !game.INVALID_ATTACK(u.Attack) { + if u.Attack.Valid() { info = fmt.Sprintf("%s\nAttack: %s", info, u.Attack.String()) } |
