aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorFlorian Fischer <florian.fischer@muhq.space>2025-08-02 14:47:01 +0200
committerFlorian Fischer <florian.fischer@muhq.space>2025-08-05 16:24:47 +0200
commit3b0c89358104d9d12a5c7a8ff516b811c7b03057 (patch)
tree3aaade10024523cc1cb592cee95ce258bfe8d770
parent8df81b5b63d18b7656103953131203e2f796b504 (diff)
downloadmuhqs-game-3b0c89358104d9d12a5c7a8ff516b811c7b03057.tar.gz
muhqs-game-3b0c89358104d9d12a5c7a8ff516b811c7b03057.zip
add support for flexible attacks
-rw-r--r--go/game/ai_test.go46
-rw-r--r--go/game/attack.go70
-rw-r--r--go/game/attack_test.go34
-rw-r--r--go/game/cardImplementations.go11
-rw-r--r--go/game/cardImplementations_test.go63
-rw-r--r--go/game/state.go2
-rw-r--r--go/game/targets.go3
-rw-r--r--go/game/targets_test.go34
-rw-r--r--go/game/unit.go29
-rw-r--r--go/game/unit_test.go4
-rw-r--r--go/ui/textBox.go2
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())
}