aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorFlorian Fischer <florian.fischer@muhq.space>2025-10-18 12:59:08 +0200
committerFlorian Fischer <florian.fischer@muhq.space>2025-10-18 12:59:08 +0200
commit2c2a2d84a92cda814e0e56d9408e6cbfd68c4fbb (patch)
tree91f5f79807d1aee3993e965d72b6707dbbef77f2
parent6a0013c62f24654529fd589a7de486bf1888a63b (diff)
downloadmuhqs-game-2c2a2d84a92cda814e0e56d9408e6cbfd68c4fbb.tar.gz
muhqs-game-2c2a2d84a92cda814e0e56d9408e6cbfd68c4fbb.zip
support phase change triggers and implement relic
-rw-r--r--go/game/cardImplementations.go54
-rw-r--r--go/game/cardImplementations_test.go47
-rw-r--r--go/game/events.go9
-rw-r--r--go/game/permanent.go5
-rw-r--r--go/game/player.go2
-rw-r--r--go/game/range.go6
-rw-r--r--go/game/state.go47
-rw-r--r--go/game/trigger.go16
8 files changed, 171 insertions, 15 deletions
diff --git a/go/game/cardImplementations.go b/go/game/cardImplementations.go
index 886bf3bb..fa7259a6 100644
--- a/go/game/cardImplementations.go
+++ b/go/game/cardImplementations.go
@@ -247,6 +247,59 @@ func (*poisonDaggerImpl) onPile(p Permanent) {
func (*poisonDaggerImpl) onUnpile(p Permanent) {
}
+type relicImpl struct{ cardImplementationBase }
+
+func (*relicImpl) onETB(s *LocalState, p Permanent) {
+ t := newUpkeepTrigger(
+ p,
+ func(*LocalState, Event) bool { return p.IsDestroyed() },
+ func(*LocalState, Event) bool {
+ controlled := make(map[*Player]int)
+ for _, t := range AdjacentTiles(p) {
+ if t.Permanent == nil {
+ continue
+ }
+ if _, found := controlled[t.Permanent.Controller()]; found {
+ controlled[t.Permanent.Controller()] += 1
+ } else {
+ controlled[t.Permanent.Controller()] = 1
+ }
+ }
+ return len(controlled) > 0
+ },
+ func(*LocalState, Event) ActionResolveFunc {
+ return func(*LocalState) {
+ controlled := make(map[*Player]int)
+ for _, t := range AdjacentTiles(p) {
+ if t.Permanent == nil {
+ continue
+ }
+ if _, found := controlled[t.Permanent.Controller()]; found {
+ controlled[t.Permanent.Controller()] += 1
+ } else {
+ controlled[t.Permanent.Controller()] = 1
+ }
+ }
+ // Find maximum amount of units around relic
+ max := 0
+ for _, c := range controlled {
+ if c > max {
+ max = c
+ }
+ }
+ // Each player controlling the most units gain 1 resource
+ for p, c := range controlled {
+ if c == max {
+ p.gainResource(1)
+ }
+ }
+ }
+ },
+ "relic gain",
+ )
+ s.addTrigger(t)
+}
+
type spearImpl struct{ cardImplementationBase }
func (*spearImpl) onPile(p Permanent) {
@@ -914,6 +967,7 @@ func init() {
"equipments/banner": &bannerImpl{aoe: bannerAoE},
"equipments/mace": &maceImpl{triggers: make(map[Permanent]Trigger)},
"equipments/poison_dagger": &poisonDaggerImpl{},
+ "equipments/relic": &relicImpl{},
"equipments/spear": &spearImpl{},
"magic/appear!": &appearImpl{},
diff --git a/go/game/cardImplementations_test.go b/go/game/cardImplementations_test.go
index 9b356c5b..3a6ea9e2 100644
--- a/go/game/cardImplementations_test.go
+++ b/go/game/cardImplementations_test.go
@@ -515,3 +515,50 @@ func TestNo(t *testing.T) {
t.Fatal("Approach not countered")
}
}
+
+func TestRelic(t *testing.T) {
+ s, _, p, o := newMockState()
+ p.Resource = 0
+ o.Resource = 0
+ s.addNewArtifact(NewCard("equipments/relic"), Position{0, 0}, p)
+
+ s.changePhase(upkeepPhase)
+ if !s.stack.IsEmpty() {
+ t.Fatal("Unexpected relic trigger")
+ }
+
+ s.addNewUnit(NewCard("base/archer"), Position{0, 1}, p)
+
+ s.changePhase(upkeepPhase)
+ if s.stack.IsEmpty() {
+ t.Fatal("Relic not triggered")
+ }
+ s.stack.pop()
+ if p.Resource != 1 {
+ t.Fatal("player did not gain 1 resource")
+ }
+
+ s.addNewUnit(NewCard("base/archer"), Position{1, 1}, o)
+ s.changePhase(upkeepPhase)
+ if s.stack.IsEmpty() {
+ t.Fatal("Relic not triggered")
+ }
+ s.stack.pop()
+ if p.Resource != 2 && o.Resource != 1 {
+ t.Fatal("player and opponent did not gain 1 resource each")
+ }
+
+ s.addNewUnit(NewCard("base/archer"), Position{1, 0}, o)
+ s.changePhase(upkeepPhase)
+ if s.stack.IsEmpty() {
+ t.Fatal("Relic not triggered")
+ }
+ s.stack.pop()
+ if p.Resource != 2 {
+ t.Fatal("player resource changed")
+ }
+
+ if o.Resource != 2 {
+ t.Fatal("opponent did not gain 1 resource")
+ }
+}
diff --git a/go/game/events.go b/go/game/events.go
index 4e2479a8..2e0b0f08 100644
--- a/go/game/events.go
+++ b/go/game/events.go
@@ -21,6 +21,7 @@ const (
declaredAction
resolvedAction
discard
+ phaseChange
)
var EventTypes = struct {
@@ -35,6 +36,7 @@ var EventTypes = struct {
DeclaredAction eventType
ResolvedAction eventType
Discard eventType
+ PhaseChange eventType
}{
Destruction: destruction,
Eot: eot,
@@ -47,6 +49,7 @@ var EventTypes = struct {
DeclaredAction: declaredAction,
ResolvedAction: resolvedAction,
Discard: discard,
+ PhaseChange: phaseChange,
}
func (e eventType) String() string {
@@ -73,6 +76,8 @@ func (e eventType) String() string {
return "resolvedAction"
case discard:
return "discard"
+ case phaseChange:
+ return "phaseChange"
}
log.Panicf("Unknown eventType: %d", e)
@@ -124,6 +129,10 @@ func newResolvedActionEvent(action Action, err error) Event {
}
}
+func newPhaseChangeEvent() Event {
+ return Event{eventType: phaseChange}
+}
+
func (s *LocalState) fireEvent(e Event) {
log.Game(e)
s.events = append(s.events, e)
diff --git a/go/game/permanent.go b/go/game/permanent.go
index 1f78cb87..9cf8754b 100644
--- a/go/game/permanent.go
+++ b/go/game/permanent.go
@@ -314,3 +314,8 @@ func TileOrContainingPermTile(p Permanent) *Tile {
return tile
}
+
+func AdjacentTiles(p Permanent) []*Tile {
+ m := p.Controller().gameState.Map()
+ return TilesInRange(m, p, 1)
+}
diff --git a/go/game/player.go b/go/game/player.go
index 09488a32..47b2c474 100644
--- a/go/game/player.go
+++ b/go/game/player.go
@@ -116,7 +116,7 @@ func (p *Player) upkeep() []*Player {
p.gainResource(p.ResourceGain())
s := p.gameState
- // TODO: handle upkeep triggers
+ s.handleTriggers()
// Skip upkeep prompt if player does not controll any units
controllsUnits := false
diff --git a/go/game/range.go b/go/game/range.go
index 83003dfa..c91ad2a8 100644
--- a/go/game/range.go
+++ b/go/game/range.go
@@ -62,10 +62,16 @@ func tilesInRangeFromOrigin(m *Map, origin Position, r int, includeOrigin bool)
return tiles
}
+// TilesInRangeFromOrigin returns all tiles in range r from a position.
+//
+// The position itself is excluded.
func TilesInRangeFromOrigin(m *Map, origin Position, r int) []*Tile {
return tilesInRangeFromOrigin(m, origin, r, false)
}
+// TilesInRange returns all tiles in range r from a Permanent.
+//
+// The permanent's position itself is excluded.
func TilesInRange(m *Map, p Permanent, r int) []*Tile {
return TilesInRangeFromOrigin(m, TileOrContainingPermTile(p).Position, r)
}
diff --git a/go/game/state.go b/go/game/state.go
index a9c2f638..1ab3c95d 100644
--- a/go/game/state.go
+++ b/go/game/state.go
@@ -153,6 +153,12 @@ func (s *LocalState) IsActivePlayer(p *Player) bool {
return s.activePlayerId == p.Id
}
+func (s *LocalState) changePhase(next PhaseType) []*Player {
+ s.activePhase = next
+ s.fireEvent(newPhaseChangeEvent())
+ return s.stateBasedActions()
+}
+
func (s *LocalState) Loop() []*Player {
// Prepare the map
if s._map.Prepare != nil {
@@ -177,6 +183,7 @@ func (s *LocalState) Loop() []*Player {
s.activePlayerId = p.Id
p.Turn++
+ // Shuffle discard pile into empty deck
if p.Deck.IsEmpty() {
e := newShuffleEvent(p, []PileOfCards{p.DiscardPile})
s.fireEvent(e)
@@ -185,42 +192,50 @@ func (s *LocalState) Loop() []*Player {
p.Deck.Shuffle(s.Rand)
}
- s.activePhase = Phases.DrawStep
+ // Switch to draw step without a priority round
+ if w := s.changePhase(Phases.DrawStep); len(w) > 0 {
+ return w
+ }
p.Draw()
- s.activePhase = Phases.UpkeepPhase
+ // Switch to upkeep without a priority round
+ if w := s.changePhase(Phases.UpkeepPhase); len(w) > 0 {
+ return w
+ }
winners := p.upkeep()
if len(winners) > 0 {
return winners
}
- winners = s.phaseChange()
+ // Full phase change to the actions phase with a priority round
+ winners = s.phaseChange(Phases.ActionPhase)
if len(winners) > 0 {
return winners
}
- s.activePhase = Phases.ActionPhase
winners = p.actionPhase()
if len(winners) > 0 {
return winners
}
- winners = s.phaseChange()
+
+ // Full phase change to the buy phase with a priority round
+ winners = s.phaseChange(Phases.BuyPhase)
if len(winners) > 0 {
return winners
}
- s.activePhase = Phases.BuyPhase
winners = p.buyPhase()
if len(winners) > 0 {
return winners
}
- winners = s.phaseChange()
+
+ // Full phase change to the discard step with a priority round
+ winners = s.phaseChange(Phases.DiscardStep)
if len(winners) > 0 {
return winners
}
-
- s.activePhase = Phases.DiscardStep
p.discardStep()
+ // End the trun after discarding without a priority round
winners = s.endOfTurn()
if len(winners) > 0 {
return winners
@@ -499,12 +514,13 @@ func (s *LocalState) allPassing(skipFirst bool) (bool, []*Player) {
return true, nil
}
-func (s *LocalState) phaseChange() []*Player {
+func (s *LocalState) phaseChange(next PhaseType) (w []*Player) {
skipFirst := true
for {
- passing, w := s.allPassing(skipFirst)
+ var passing bool
+ passing, w = s.allPassing(skipFirst)
if len(w) > 0 {
- return w
+ break
}
if passing {
break
@@ -512,12 +528,15 @@ func (s *LocalState) phaseChange() []*Player {
w = s.stack.resolve()
if len(w) > 0 {
- return w
+ break
}
skipFirst = false
}
- return nil
+
+ s.changePhase(next)
+
+ return
}
func (s *LocalState) broadcastNotification(n PlayerNotification) {
diff --git a/go/game/trigger.go b/go/game/trigger.go
index 6ab42856..6401dee6 100644
--- a/go/game/trigger.go
+++ b/go/game/trigger.go
@@ -45,6 +45,22 @@ func (t *triggerBase) trigger(s *LocalState, e Event) ([]*TriggeredAction, bool)
return []*TriggeredAction{newTriggeredAction(e, t, t.resolveFunc(s, e), t.costFunc)}, remove
}
+func newUpkeepTrigger(
+ src any,
+ remove func(*LocalState, Event) bool,
+ cond func(*LocalState, Event) bool,
+ resolveFunc func(*LocalState, Event) ActionResolveFunc,
+ desc string,
+) Trigger {
+ t := triggerBase{source: src, resolveFunc: resolveFunc, desc: desc}
+ t.condition = func(s *LocalState, event Event) (bool, bool) {
+ triggered := event.eventType == EventTypes.PhaseChange && s.activePhase == Phases.UpkeepPhase && cond(s, event)
+ removed := remove(s, event)
+ return triggered, removed
+ }
+ return &t
+}
+
func newTargetedTrigger(p Permanent, singleshot bool, resolveFunc func(*LocalState, Event) ActionResolveFunc, desc string) Trigger {
t := triggerBase{source: p, resolveFunc: resolveFunc, desc: desc}
t.condition = func(_ *LocalState, event Event) (bool, bool) {