From 7f4ba6774db54e8bb427024dca05c7c16efa7039 Mon Sep 17 00:00:00 2001 From: Florian Fischer Date: Tue, 22 Jul 2025 11:09:44 -0400 Subject: implement house tile effect --- go/game/action.go | 2 +- go/game/action_test.go | 44 ++++++++++++++++++++++++++++++++++++++++++++ go/game/artifact.go | 2 +- go/game/permanent.go | 19 ++++++++++++++++++- go/game/state.go | 14 ++++++++++++++ go/game/targets.go | 20 ++++++++++++++------ go/game/unit.go | 5 ----- 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 } -- cgit v1.2.3