aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorFlorian Fischer <florian.fischer@muhq.space>2025-07-22 11:09:44 -0400
committerFlorian Fischer <florian.fischer@muhq.space>2025-07-22 12:54:28 -0400
commit7f4ba6774db54e8bb427024dca05c7c16efa7039 (patch)
tree81db9aae21b6d410f32ff8947800593afd4fd38c
parenteb2fc0af7db8614024f6d73093ccb0825c8a1cc0 (diff)
downloadmuhqs-game-7f4ba6774db54e8bb427024dca05c7c16efa7039.tar.gz
muhqs-game-7f4ba6774db54e8bb427024dca05c7c16efa7039.zip
implement house tile effect
-rw-r--r--go/game/action.go2
-rw-r--r--go/game/action_test.go44
-rw-r--r--go/game/artifact.go2
-rw-r--r--go/game/permanent.go19
-rw-r--r--go/game/state.go14
-rw-r--r--go/game/targets.go20
-rw-r--r--go/game/unit.go5
7 files changed, 92 insertions, 14 deletions
diff --git a/go/game/action.go b/go/game/action.go
index f29b8b4c..0533a109 100644
--- a/go/game/action.go
+++ b/go/game/action.go
@@ -450,7 +450,7 @@ func NewAttackAction(u *Unit) *AttackAction {
a.targets = newTargets(newTarget(u.Controller().gameState, attackActionTargetDesc, a))
a.resolveFunc = func(s *LocalState) {
- s.fight(u, a.Target().sel[0].(Permanent))
+ s.attack(u, a.Target().sel[0].(Permanent))
}
a.costFunc = genAttackActionCost(u)
diff --git a/go/game/action_test.go b/go/game/action_test.go
index a4cf0752..5fa0b64d 100644
--- a/go/game/action_test.go
+++ b/go/game/action_test.go
@@ -1,6 +1,8 @@
package game
import (
+ "errors"
+ "strings"
"testing"
"muhq.space/muhqs-game/go/utils"
@@ -104,3 +106,45 @@ func TestUnmarshalPickAction(t *testing.T) {
t.Fatalf("expexted archer beeing picked not %s", p.pick.Path())
}
}
+
+func TestAttackAction(t *testing.T) {
+ mapDef := `map: |1-
+ H T
+ SSS
+symbols:
+ T: tower
+ H: house
+ S: street
+`
+ s := NewLocalState()
+ r := strings.NewReader(mapDef)
+ m, _ := readMap(r)
+ s.SetMap(m)
+
+ p := s.AddNewPlayer("player", NewDeck())
+ o := s.AddNewPlayer("opponent", NewDeck())
+
+ t1 := s.addNewUnit(NewCard("base/fighter"), Position{0, 0}, o)
+ t2 := s.addNewUnit(NewCard("base/fighter"), Position{0, 1}, o)
+ a := s.addNewUnit(NewCard("base/archer"), Position{2, 0}, p)
+ ca := s.addNewUnit(NewCard("base/cavalry_archer"), Position{2, 1}, p)
+
+ aa1 := NewAttackAction(a)
+ err := aa1.Target().AddSelection(t1)
+ if !errors.Is(err, ErrTargetRangeProtected) {
+ t.Fatal("protected from range combat not detected")
+ }
+
+ aa2 := NewAttackAction(ca)
+ err = aa2.Target().AddSelection(t2)
+ if err != nil {
+ t.Fatal("unexpected target error:", err)
+ }
+ s.ResolveAction(aa2)
+ if t2.Damage() != 1 {
+ t.Fatal("fighter did not took 1 damage")
+ }
+ if ca.Damage() > 0 {
+ t.Fatal("cavalry archer took damage in range combat from melee unit")
+ }
+}
diff --git a/go/game/artifact.go b/go/game/artifact.go
index 9bf653ef..eb9673ac 100644
--- a/go/game/artifact.go
+++ b/go/game/artifact.go
@@ -45,5 +45,5 @@ func (a *Artifact) IsDestroyed() bool {
}
func (a *Artifact) Attackable() bool {
- return a.Solid > 0
+ return a.permanentBase.Attackable() && a.Solid > 0
}
diff --git a/go/game/permanent.go b/go/game/permanent.go
index 76f2325d..19d65bb6 100644
--- a/go/game/permanent.go
+++ b/go/game/permanent.go
@@ -27,13 +27,17 @@ type Permanent interface {
adjustMarks(PermanentMark, int)
IsDestroyed() bool
- Attackable() bool
String() string
CurrentlyAvailActions() []Action
AvailActions() []Action
IsAvailableTile(*Tile) bool
+ // Attackable reports if a permanent is attackable.
+ Attackable() bool
+ // RangeProtected reports if a permanent is attackable in ranged combat.
+ RangeProtected() bool
+
fight(p Permanent)
adjustDamage(damage int)
addTmpEffect(tmpEffect string)
@@ -193,6 +197,19 @@ func (p *permanentBase) IsAvailableTile(tile *Tile) bool {
return tile.IsAvailableForCard(p.card)
}
+// Attackable reports if a permanent is attackable.
+// Only permanents represented on the map are attackable.
+func (p *permanentBase) Attackable() bool {
+ return p.Tile() != nil
+}
+
+// RangeProtected reports if the permanent is attackable in range combat.
+// If the permanent has no Tile then it is not represented on the map and is not attackable at all.
+// House tiles protect from ranged combat.
+func (p *permanentBase) RangeProtected() bool {
+ return !p.Attackable() || p.Tile().Type == TileTypes.House
+}
+
func (p *permanentBase) setContainingPerm(containing Permanent) { p.containingPerm = containing }
func (p *permanentBase) addPermanentToPile(a Permanent) { p.pile = append(p.pile, a) }
func (p *permanentBase) clearPile() { p.pile = nil }
diff --git a/go/game/state.go b/go/game/state.go
index d5a9bfc6..16670263 100644
--- a/go/game/state.go
+++ b/go/game/state.go
@@ -644,11 +644,25 @@ func (s *LocalState) destroyPermanent(p Permanent) {
s.events = append(s.events, Event{eventType: EventTypes.Destruction, affected: []any{p}})
}
+// fight implements the effect of two permanents fighting.
func (s *LocalState) fight(p1, p2 Permanent) {
p1.fight(p2)
p2.fight(p1)
}
+// attack implements the effects of a permanent p1 attacking another permanent p2.
+func (s *LocalState) attack(p1, p2 Permanent) {
+ if p1 == p2 {
+ log.Panic("A unit can not attack itself")
+ }
+ d := DistanceBetweenPermanents(p1, p2)
+ if d > 1 && p2.RangeProtected() {
+ log.Panic("Range protected unit can only be attacked in melee combat")
+ }
+
+ s.fight(p1, p2)
+}
+
func (s *LocalState) switchPermanents(p1 Permanent, p2 Permanent) {
t1 := TileOrContainingPermTile(p1)
t2 := TileOrContainingPermTile(p2)
diff --git a/go/game/targets.go b/go/game/targets.go
index bcb47d50..9ab3c621 100644
--- a/go/game/targets.go
+++ b/go/game/targets.go
@@ -416,22 +416,30 @@ func controlledPermanentTargetConstraint(action Action) TargetConstraintFunc {
}
}
+var (
+ ErrTargetNotAttackable = errors.New("not attackable")
+ ErrTargetOutOfRange = errors.New("not in range")
+ ErrTargetRangeProtected = errors.New("range protected")
+)
+
func attackableTargetConstraint(action Action) TargetConstraintFunc {
- return func(t interface{}) (err error) {
+ return func(t any) error {
p, _ := t.(Permanent)
if !p.Attackable() {
- err = fmt.Errorf("Permanent %v is not attackable", p)
- return
+ return ErrTargetNotAttackable
}
if attackAction, ok := action.(*AttackAction); ok {
attacker := attackAction.Source().(*Unit)
if !IsPositionInRange(attacker.Tile().Position, p.Tile().Position, attacker.Attack.MaxRange()) {
- err = fmt.Errorf("Position %v of target %v not in attack range %d of source's position %v",
- p.Tile().Position, p, attacker.Attack.MaxRange(), attacker.Tile().Position)
+ return ErrTargetOutOfRange
+ }
+ d := DistanceBetweenPermanents(attacker, p)
+ if d > 1 && p.RangeProtected() {
+ return ErrTargetRangeProtected
}
}
- return
+ return nil
}
}
diff --git a/go/game/unit.go b/go/game/unit.go
index a096c0f2..75015a30 100644
--- a/go/game/unit.go
+++ b/go/game/unit.go
@@ -145,11 +145,6 @@ func (u *Unit) IsDestroyed() bool {
return u.damage >= u.Health
}
-func (u *Unit) Attackable() bool {
- // Only units on the map are attackable
- return u.tile != nil
-}
-
func (u *Unit) HasFullAction() bool {
return len(u.FullActions) > 0
}