aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorFlorian Fischer <florian.fischer@muhq.space>2025-07-24 05:25:12 -0400
committerFlorian Fischer <florian.fischer@muhq.space>2025-07-24 12:22:10 -0400
commite78e8fb6ae67e9cd188515c1c806dfa2751327c4 (patch)
tree373b070a267caafce3016f97ab0dd07f7e4cdb74
parented473052c558fbda6bb03d9083b425cb2e7a808f (diff)
downloadmuhqs-game-e78e8fb6ae67e9cd188515c1c806dfa2751327c4.tar.gz
muhqs-game-e78e8fb6ae67e9cd188515c1c806dfa2751327c4.zip
support street actions
-rw-r--r--go/game/action.go42
-rw-r--r--go/game/action_test.go27
-rw-r--r--go/game/ai.go105
-rw-r--r--go/game/ai_test.go2
-rw-r--r--go/game/map_test.go44
-rw-r--r--go/game/targets.go62
-rw-r--r--go/game/unit.go16
7 files changed, 253 insertions, 45 deletions
diff --git a/go/game/action.go b/go/game/action.go
index 5f478de7..077f4026 100644
--- a/go/game/action.go
+++ b/go/game/action.go
@@ -379,6 +379,48 @@ func (a *MoveAction) String() string {
return fmt.Sprintf("%v -> %v", u, t.Position)
}
+type StreetAction struct {
+ ActionBase
+}
+
+var streetActionTargetDesc = TargetDesc{"available connected street tile", "1"}
+
+func NewStreetAction(u *Unit) *StreetAction {
+ a := &StreetAction{
+ ActionBase{
+ source: u,
+ Card: u.Card(),
+ },
+ }
+
+ a.targets = newTargets(newTarget(u.Controller().gameState, streetActionTargetDesc, a))
+
+ a.resolveFunc = func(s *LocalState) {
+ t := targetTile(a.Target())
+ movePermanent(u, t)
+ }
+
+ a.costFunc = func(*LocalState) bool {
+ ok := u.AvailStreetActions > 0 && u.Tile() != nil && u.Tile().Type == TileTypes.Street
+ if ok {
+ u.AvailStreetActions = u.AvailStreetActions - 1
+ }
+ return ok
+ }
+
+ return a
+}
+
+func (a *StreetAction) String() string {
+ u := a.source.(*Unit)
+ if a.targets.RequireSelection() {
+ return fmt.Sprintf("street %s", u.Movement.String())
+ }
+
+ t := targetTile(a.Target())
+ return fmt.Sprintf("%v s-s> %v", u, t.Position)
+}
+
func genBaseActionCost(u *Unit, attack, move int) ActionCostFunc {
return func(*LocalState) bool {
if u.AvailAttackActions < attack || u.AvailMoveActions < move {
diff --git a/go/game/action_test.go b/go/game/action_test.go
index b7a92bae..452a0544 100644
--- a/go/game/action_test.go
+++ b/go/game/action_test.go
@@ -180,3 +180,30 @@ symbols:
t.Fatal("archer took damage in range combat from melee unit")
}
}
+
+func TestStreetAction(t *testing.T) {
+ mapDef := `map: |1-
+ HSH
+ HSH
+ HSS
+ HSH
+
+symbols:
+ H: house
+ S: street
+`
+ s := NewLocalState()
+ r := strings.NewReader(mapDef)
+ m, _ := readMap(r)
+ s.SetMap(m)
+
+ p := s.AddNewPlayer("player", NewDeck())
+
+ c := s.addNewUnit(NewCard("base/cavalry"), Position{1, 0}, p)
+
+ a := NewStreetAction(c)
+ opts := a.Target().Options()
+ if len(opts) != 4 {
+ t.Fatal("Unexpected amount of street targets", len(opts))
+ }
+}
diff --git a/go/game/ai.go b/go/game/ai.go
index 7b20d187..07440899 100644
--- a/go/game/ai.go
+++ b/go/game/ai.go
@@ -122,11 +122,16 @@ func selectRandomTargets(rand *rand.Rand, targets *Targets) error {
var ErrNoPath = errors.New("no path found")
-func (m *Map) generateMapGraphFor(u *Unit) *dijkstra.Graph {
+func generateGraph(tiles []*Tile) *dijkstra.Graph {
graph := dijkstra.NewGraph()
- for _, t := range m.AllTiles() {
+ for _, t := range tiles {
graph.AddMappedVertex(t.Position.String())
}
+ return graph
+}
+
+func (m *Map) generateMapGraphFor(u *Unit) *dijkstra.Graph {
+ graph := generateGraph(m.AllTiles())
for _, t := range m.AllTiles() {
tId := t.Position.String()
@@ -146,35 +151,65 @@ func (m *Map) generateMapGraphFor(u *Unit) *dijkstra.Graph {
return graph
}
-// func (m *Map) generateMovementRangeGraphFor(u *Unit) *dijkstra.Graph {
-// origin := TileOrContainingPermTile(u).Position
-// graph := dijkstra.NewGraph()
-// for _, t := range tilesInRangeFromOrigin(m, origin, u.Movement.Range, true) {
-// graph.AddMappedVertex(t.Position.String())
-// }
-
-// for _, t := range TilesInRange(m, u, u.Movement.Range) {
-// tId := t.Position.String()
-// for _, neighbour := range tilesInRangeFromOrigin(m, t.Position, 1, true) {
-// if !IsPositionInRange(neighbour.Position, origin, u.Movement.Range) {
-// continue
-// }
-
-// if u.IsAvailableTile(neighbour) {
-// cost := 2
-// if t.OnDiagonal(neighbour) {
-// cost = 3
-// }
-// log.Printf("add %v -> %v: %d", tId, neighbour.Position.String(), int64(cost))
-// err := graph.AddMappedArc(tId, neighbour.Position.String(), int64(cost))
-// if err != nil {
-// log.Panicf("Failed to add mapped arc: %s", err)
-// }
-// }
-// }
-// }
-// return graph
-// }
+func (m *Map) generateMovementRangeGraphFor(u *Unit) *dijkstra.Graph {
+ origin := TileOrContainingPermTile(u).Position
+ graph := generateGraph(tilesInRangeFromOrigin(m, origin, u.Movement.Range, true))
+
+ // Add connections for all tiles and their neighbours
+ for _, t := range tilesInRangeFromOrigin(m, origin, u.Movement.Range, true) {
+ tId := t.Position.String()
+ for _, neighbour := range TilesInRangeFromOrigin(m, t.Position, 1) {
+ // Skip neighbours not in movement range
+ if !IsPositionInRange(neighbour.Position, origin, u.Movement.Range) {
+ continue
+ }
+
+ if u.IsAvailableTile(neighbour) {
+ cost := 2
+ if t.OnDiagonal(neighbour) {
+ cost = 3
+ }
+ err := graph.AddMappedArc(tId, neighbour.Position.String(), int64(cost))
+ if err != nil {
+ log.Panicf("Failed to add mapped arc: %s", err)
+ }
+ }
+ }
+ }
+ return graph
+}
+
+func (m *Map) generateConnectedMovementRangeGraphFor(u *Unit, conn TileType) *dijkstra.Graph {
+ origin := TileOrContainingPermTile(u).Position
+ graph := generateGraph(tilesInRangeFromOrigin(m, origin, u.Movement.Range, true))
+
+ // Add connections for all tiles and their neighbours
+ for _, t := range tilesInRangeFromOrigin(m, origin, u.Movement.Range, true) {
+ tId := t.Position.String()
+ // Skip tiles with different types
+ if t.Type != conn {
+ continue
+ }
+ for _, neighbour := range TilesInRangeFromOrigin(m, t.Position, 1) {
+ // Skip neighbours not in movement range or with different type
+ if !IsPositionInRange(neighbour.Position, origin, u.Movement.Range) || neighbour.Type != conn {
+ continue
+ }
+
+ if u.IsAvailableTile(neighbour) {
+ cost := 2
+ if t.OnDiagonal(neighbour) {
+ cost = 3
+ }
+ err := graph.AddMappedArc(tId, neighbour.Position.String(), int64(cost))
+ if err != nil {
+ log.Panicf("Failed to add mapped arc: %s", err)
+ }
+ }
+ }
+ }
+ return graph
+}
func findPathTo(graph *dijkstra.Graph, u *Unit, pos Position) (dijkstra.BestPath, error) {
srcId, err := graph.GetMapping(TileOrContainingPermTile(u).Position.String())
@@ -276,14 +311,17 @@ func moveAwayFromEnemies(ai *UnitAI) Action {
return nil
}
+ // A Movement graph is not sufficient because we have to check the distance to units potentially not in range.
graph := ai.s.Map().generateMapGraphFor(ai.u)
var farest *Tile
var farestEnemyDistance int64
- for _, availableTile := range ai.u.MoveRangeTiles(ai.s.Map()) {
+ // Check all reachable tiles
+ for _, availableTile := range ai.u.MoveRangeTiles() {
var nearestEnemy int64 = -1
for _, enemyUnit := range ai.s.EnemyUnits(ai.u.Controller()) {
+ // Get distance to the enemy unit
p, err := findPathTo(graph, enemyUnit, availableTile.Position)
if err != nil {
continue
@@ -299,6 +337,7 @@ func moveAwayFromEnemies(ai *UnitAI) Action {
}
}
+ // No move possible
if farest == nil {
return nil
}
@@ -331,7 +370,7 @@ func actionFromPaths(ai *UnitAI, graph *dijkstra.Graph, paths []*dijkstra.BestPa
var x, y int
_, _ = fmt.Sscanf(mapping, "(%d, %d)", &x, &y)
nextStep := m.TileAt(Position{X: x, Y: y})
- if slices.Contains(ai.u.MoveRangeTiles(m), nextStep) {
+ if slices.Contains(ai.u.MoveRangeTiles(), nextStep) {
target = nextStep
} else {
break
diff --git a/go/game/ai_test.go b/go/game/ai_test.go
index 854f38dd..60ab1082 100644
--- a/go/game/ai_test.go
+++ b/go/game/ai_test.go
@@ -92,7 +92,7 @@ symbols:
ai := NewUnitAI(s, c)
if ai == nil {
- t.Fatal("No AI available for Bauernfeind")
+ t.Fatal("No AI available for Clownfish")
}
ai.promptAction()
diff --git a/go/game/map_test.go b/go/game/map_test.go
index 15fa05cb..bc17dd55 100644
--- a/go/game/map_test.go
+++ b/go/game/map_test.go
@@ -4,6 +4,8 @@ import (
"reflect"
"strings"
"testing"
+
+ "golang.org/x/exp/slices"
)
func parseMapString(t *testing.T, mapDef string) *Map {
@@ -113,3 +115,45 @@ symbols:
t.Fatal("Tile at (0,0) is not a house")
}
}
+
+func TestMovementRangeTiles(t *testing.T) {
+ mapDef := `map: |1-
+ T H HT
+ FSSSSS2H
+ SHSH HS
+ S SST S
+ H HFH H
+ S TSS S
+ SH HSHS
+ H1SSSSSF
+ TH H T
+
+symbols:
+ T: tower
+ H: house
+ F: farm
+ S: street
+ 1: spawn player 1
+ 2: spawn player 2
+`
+
+ s := NewLocalState()
+ m := parseMapString(t, mapDef)
+ s.SetMap(m)
+ p := s.AddNewPlayer("p", NewDeck())
+ o := Position{2, 2}
+ a := s.addNewUnit(NewCard("base/archer"), o, p)
+
+ tiles := a.MoveRangeTiles()
+ exp := TilesInRange(m, a, a.Movement.Range)
+
+ if len(tiles) != len(exp) {
+ t.Fatal("expected", len(exp), "tiles not", len(tiles))
+ }
+
+ for _, tile := range exp {
+ if !slices.Contains(tiles, tile) {
+ t.Fatal("missing", tile, "in tiles")
+ }
+ }
+}
diff --git a/go/game/targets.go b/go/game/targets.go
index 236b4850..b4456f55 100644
--- a/go/game/targets.go
+++ b/go/game/targets.go
@@ -623,17 +623,22 @@ func availableTileConstraint(action Action, card *Card) TargetConstraintFunc {
}
}
-func moveConstraint(s *LocalState, action Action, u *Unit) TargetConstraintFunc {
- return func(t interface{}) (err error) {
+func moveConstraint(_ *LocalState, action Action, u *Unit) TargetConstraintFunc {
+ return func(t any) (err error) {
// Some actions allow also unit targets as though they were tiles
tile := relaxedTileTarget(action, t)
- if slices.Contains(u.MoveRangeTiles(s.Map()), tile) {
+ if slices.Contains(u.MoveRangeTiles(), tile) {
return nil
}
return fmt.Errorf("tile %v is not in %v's movement range", tile, u)
}
}
+var (
+ ErrTargetWrongTileType = errors.New("wrong tile type")
+ ErrTargetNotConnectedTile = errors.New("not connected")
+)
+
func parseTileTargetConstraint(desc string, s *LocalState, action Action) []TargetConstraintFunc {
constraints := []TargetConstraintFunc{}
@@ -656,6 +661,8 @@ func parseTileTargetConstraint(desc string, s *LocalState, action Action) []Targ
card = a.Card
case *MoveAction:
card = a.Card
+ case *StreetAction:
+ card = a.Card
}
if strings.Contains(desc, "spawn") {
@@ -663,7 +670,7 @@ func parseTileTargetConstraint(desc string, s *LocalState, action Action) []Targ
log.Panicf("Not implemented spawn tile constraint for %T", action)
}
- constraints = append(constraints, func(t interface{}) (err error) {
+ constraints = append(constraints, func(t any) (err error) {
tile := relaxedTileTarget(action, t)
if slices.Contains(s.AvailableSpawnTiles(player, card), tile) {
return nil
@@ -677,7 +684,7 @@ func parseTileTargetConstraint(desc string, s *LocalState, action Action) []Targ
}
if strings.Contains(desc, "water") {
- constraints = append(constraints, func(t interface{}) (err error) {
+ constraints = append(constraints, func(t any) (err error) {
tile := t.(*Tile)
if tile.Water {
return nil
@@ -691,7 +698,7 @@ func parseTileTargetConstraint(desc string, s *LocalState, action Action) []Targ
}
if strings.Contains(desc, "free") {
- constraints = append(constraints, func(t interface{}) (err error) {
+ constraints = append(constraints, func(t any) (err error) {
tile := t.(*Tile)
if tile.IsFree() {
return nil
@@ -700,6 +707,49 @@ func parseTileTargetConstraint(desc string, s *LocalState, action Action) []Targ
})
}
+ for name := range TileNames {
+ if strings.Contains(desc, name) {
+ tileType := TileNames[name]
+ if strings.Contains(desc, "connected") {
+ // Find connected tiles
+ u := action.Source().(*Unit)
+ graph := s.Map().generateConnectedMovementRangeGraphFor(u, tileType)
+ tiles := []*Tile{}
+ for _, pos := range PositionsInRange(u.Tile().Position, u.Movement.Range, false) {
+ tile := s.Map().TileAt(pos)
+ if tile == nil || tile.Type != tileType {
+ continue
+ }
+
+ _, err := findPathTo(graph, u, pos)
+ if err != nil {
+ continue
+ }
+
+ tiles = append(tiles, tile)
+ }
+
+ // Check if tile is connected
+ constraints = append(constraints, func(t any) (err error) {
+ tile := t.(*Tile)
+ if !slices.Contains(tiles, tile) {
+ err = ErrTargetNotConnectedTile
+ }
+ return
+ })
+ }
+
+ // Tile type constraint
+ constraints = append(constraints, func(t any) error {
+ tile := t.(*Tile)
+ if tile.Type == tileType {
+ return nil
+ }
+ return ErrTargetWrongTileType
+ })
+ }
+ }
+
return constraints
}
diff --git a/go/game/unit.go b/go/game/unit.go
index 1bf3afa1..d1192dc0 100644
--- a/go/game/unit.go
+++ b/go/game/unit.go
@@ -5,6 +5,7 @@ import (
)
const (
+ DEFAULT_AVAIL_STREET_ACTIONS = 1
DEFAULT_AVAIL_MOVE_ACTIONS = 1
DEFAULT_AVAIL_ATTACK_ACTIONS = 1
)
@@ -18,6 +19,7 @@ type Unit struct {
FullActions []*FullAction
FreeActions []*FreeAction
// Effects []Effects
+ AvailStreetActions int
AvailMoveActions int
additionalMoveActions int
AvailAttackActions int
@@ -77,6 +79,7 @@ func (u *Unit) UpkeepCost() int {
}
func (u *Unit) resetBaseActions() {
+ u.AvailStreetActions = DEFAULT_AVAIL_STREET_ACTIONS + u.additionalMoveActions
u.AvailMoveActions = DEFAULT_AVAIL_MOVE_ACTIONS + u.additionalMoveActions
u.AvailAttackActions = DEFAULT_AVAIL_ATTACK_ACTIONS + u.additionalAttackActions
}
@@ -86,11 +89,10 @@ func (u *Unit) onUpkeep() {
u.resetBaseActions()
}
-func (u *Unit) MoveRangeTiles(m *Map) []*Tile {
+func (u *Unit) MoveRangeTiles() []*Tile {
tiles := []*Tile{}
- // TODO: build graph only containing possible tile in movement range
- graph := m.generateMapGraphFor(u)
- // graph := m.generateMovementRangeGraphFor(u)
+ m := u.controller.gameState.Map()
+ graph := m.generateMovementRangeGraphFor(u)
origin := TileOrContainingPermTile(u).Position
for _, pos := range PositionsInRange(origin, u.Movement.Range, false) {
tile := m.TileAt(pos)
@@ -124,7 +126,7 @@ func (u *Unit) AttackableTiles() []*Tile {
return tilesInRange
}
- // House tiles are not attackable from $anges > 1
+ // House tiles are not attackable from ranges > 1
aTiles := []*Tile{}
for _, t := range tilesInRange {
if DistanceBetweenPositions(t.Position, u.tile.Position) > 1 && t.Type == TileTypes.House {
@@ -181,6 +183,10 @@ func (u *Unit) AvailSlowActions() (actions []Action) {
actions = append(actions, NewMoveAction(u))
}
+ if u.AvailStreetActions > 0 && u.Movement != INVALID_MOVEMENT() && u.Tile().Type == TileTypes.Street {
+ actions = append(actions, NewStreetAction(u))
+ }
+
m := u.Controller().gameState.Map()
if u.AvailAttackActions > 0 && !INVALID_ATTACK(u.Attack) &&
len(u.AttackableEnemyPermanents()) > 0 {