aboutsummaryrefslogtreecommitdiff
path: root/go/game
diff options
context:
space:
mode:
authorFlorian Fischer <florian.fischer@muhq.space>2022-12-27 14:29:39 +0100
committerFlorian Fischer <florian.fischer@muhq.space>2025-08-20 15:46:38 +0200
commitd6b5eeaa5e639c5ab89b5e1c93075a1b765794dc (patch)
treef87422bb91c2197f88aa8d29092592858764049d /go/game
parent3c2ee4ee48480a28dc606878503f314ec8862d45 (diff)
downloadmuhqs-game-d6b5eeaa5e639c5ab89b5e1c93075a1b765794dc.tar.gz
muhqs-game-d6b5eeaa5e639c5ab89b5e1c93075a1b765794dc.zip
intermediate commit
Diffstat (limited to 'go/game')
-rw-r--r--go/game/action.go351
-rw-r--r--go/game/ai.go343
-rw-r--r--go/game/ai_test.go66
-rw-r--r--go/game/artifact.go51
-rw-r--r--go/game/attack.go50
-rw-r--r--go/game/card.go316
-rw-r--r--go/game/cardImplementations.go56
-rw-r--r--go/game/cardParsing.go179
-rw-r--r--go/game/deck.go77
-rw-r--r--go/game/discardPiles.go10
-rw-r--r--go/game/equipment.go5
-rw-r--r--go/game/hand.go10
-rw-r--r--go/game/kraken.go179
-rw-r--r--go/game/map.go303
-rw-r--r--go/game/map_test.go85
-rw-r--r--go/game/movement.go52
-rw-r--r--go/game/permanent.go131
-rw-r--r--go/game/phases.go42
-rw-r--r--go/game/pileOfCards.go63
-rw-r--r--go/game/player.go140
-rw-r--r--go/game/playerControl.go92
-rw-r--r--go/game/pos.go22
-rw-r--r--go/game/range.go58
-rw-r--r--go/game/range_test.go61
-rw-r--r--go/game/set.go78
-rw-r--r--go/game/stack.go46
-rw-r--r--go/game/state.go453
-rw-r--r--go/game/store.go10
-rw-r--r--go/game/targets.go307
-rw-r--r--go/game/tile.go123
-rw-r--r--go/game/unit.go211
-rw-r--r--go/game/winCondition.go36
32 files changed, 4006 insertions, 0 deletions
diff --git a/go/game/action.go b/go/game/action.go
new file mode 100644
index 00000000..4894d47d
--- /dev/null
+++ b/go/game/action.go
@@ -0,0 +1,351 @@
+package game
+
+import (
+ "fmt"
+)
+
+type (
+ ActionCostFunc func(*State) bool
+ ActionResolveFunc func(*State)
+ ActionFuncPrototype func(Action) ActionResolveFunc
+)
+
+type Action interface {
+ Source() interface{}
+ Targets() *Targets
+
+ CheckTargets(*State) error
+ PayCosts(s *State) bool
+ Resolve(s *State)
+ String() string
+}
+
+type PassPriority struct{ source *Player }
+
+func (a *PassPriority) Source() interface{} { return a.source }
+func (*PassPriority) Targets() *Targets { return nil }
+func (*PassPriority) CheckTargets(*State) error { return nil }
+func (*PassPriority) PayCosts(*State) bool { return true }
+func (*PassPriority) Resolve(*State) {}
+func (*PassPriority) String() string { return "pass" }
+func NewPassPriority(p *Player) Action { return &PassPriority{p} }
+
+type HandCardSelection struct{ cards []*Card }
+
+func (*HandCardSelection) Source() interface{} { return nil }
+func (*HandCardSelection) Targets() *Targets { return nil }
+func (*HandCardSelection) CheckTargets(*State) error { return nil }
+func (*HandCardSelection) PayCosts(*State) bool { return true }
+func (*HandCardSelection) Resolve(*State) {}
+func (*HandCardSelection) String() string { return "hand card selection" }
+func NewHandCardSelection(cards []*Card) Action { return &HandCardSelection{cards} }
+
+type ActionBase struct {
+ source interface{}
+ Card *Card
+ targets *Targets
+ resolveFunc ActionResolveFunc
+ costFunc ActionCostFunc
+}
+
+func (a *ActionBase) Source() interface{} {
+ return a.source
+}
+
+func (a *ActionBase) Resolve(s *State) {
+ a.resolveFunc(s)
+}
+
+func (a *ActionBase) PayCosts(s *State) bool {
+ return a.costFunc(s)
+}
+
+func (a *ActionBase) Targets() *Targets {
+ return a.targets
+}
+
+func (a *ActionBase) CheckTargets(s *State) error {
+ return a.targets.CheckTargets(s)
+}
+
+type PlayAction struct {
+ ActionBase
+}
+
+const permanentPlayActionTarget = "available spawn tile"
+
+func NewPlayAction(p *Player, c *Card, args ...interface{}) *PlayAction {
+ a := &PlayAction{ActionBase{
+ source: p,
+ Card: c,
+ }}
+
+ if len(args) > 0 {
+ if costFunc, ok := args[0].(ActionCostFunc); ok {
+ a.costFunc = costFunc
+ }
+ }
+
+ if a.costFunc == nil {
+ a.costFunc = func(*State) bool {
+ cost := c.PlayCosts()
+
+ if p.Resource < cost {
+ return false
+ }
+ p.Resource -= cost
+ return true
+ }
+ }
+
+ s := p.gameState
+ if c.IsPermanent() {
+ a.targets = newTargets(s, []string{permanentPlayActionTarget}, a)
+ } else {
+ a.targets = newTargets(s, []string{}, a)
+ }
+
+ p.Hand.RemoveCard(c)
+
+ a.resolveFunc = func(s *State) {
+ s.resolvePlay(p, c, a.targets)
+ }
+
+ return a
+}
+
+func (a *PlayAction) String() string {
+ p := a.source.(*Player)
+ if a.targets.RequireTargets() {
+ return fmt.Sprintf(" %s play %s", p.Name, a.Card.Name)
+ }
+
+ return fmt.Sprintf("%s play %s@%v", p.Name, a.Card.Name, a.targets)
+}
+
+type MoveAction struct {
+ ActionBase
+}
+
+const moveActionTargetDesc = "available tile"
+
+func NewMoveAction(u *Unit) *MoveAction {
+ a := &MoveAction{
+ ActionBase{
+ source: u,
+ Card: u.Card(),
+ },
+ }
+
+ a.targets = newTargets(u.Controller().gameState, []string{moveActionTargetDesc}, a)
+
+ a.resolveFunc = func(s *State) {
+ tile := a.targets.sel[0].(*Tile)
+ u.move(s, tile)
+ }
+
+ a.costFunc = func(*State) bool {
+ if u.AvailMoveActions < 1 {
+ return false
+ }
+ u.AvailMoveActions--
+ return true
+ }
+
+ return a
+}
+
+func (a *MoveAction) String() string {
+ u := a.source.(*Unit)
+ if a.targets.RequireTargets() {
+ return fmt.Sprintf("move %s", u.Movement.String())
+ }
+
+ t := a.targets.sel[0].(*Tile)
+ return fmt.Sprintf("%s: %s -> %s", u.Controller().Name, u.Card().Name, t.Position.String())
+}
+
+type AttackAction struct {
+ ActionBase
+}
+
+const attackActionTargetDesc = "attackable permanent"
+
+func NewAttackAction(u *Unit) *AttackAction {
+ a := &AttackAction{ActionBase{
+ source: u,
+ Card: u.Card(),
+ }}
+
+ a.targets = newTargets(u.Controller().gameState, []string{attackActionTargetDesc}, a)
+
+ a.resolveFunc = func(s *State) {
+ s.Fight(u, a.targets.sel[0].(Permanent))
+ }
+
+ a.costFunc = func(*State) bool {
+ if u.AvailAttackActions < 1 {
+ return false
+ }
+ u.AvailAttackActions--
+ return true
+ }
+
+ return a
+}
+
+func (a *AttackAction) String() string {
+ u := a.source.(*Unit)
+ if a.targets.RequireTargets() {
+ return fmt.Sprintf("attack %s", u.Attack.String())
+ }
+
+ target := a.targets.sel[0].(Permanent)
+ return fmt.Sprintf("%s: %s x %s",
+ u.Controller().Name, u.Card().Name, target.Tile().Position.String())
+}
+
+type FullAction struct {
+ ActionBase
+ desc string
+}
+
+func NewFullAction(u *Unit, proto ActionFuncPrototype, desc string) *FullAction {
+ a := &FullAction{
+ ActionBase{
+ source: u,
+ Card: u.Card(),
+ },
+
+ desc,
+ }
+
+ a.resolveFunc = func(s *State) {
+ rf := proto(a)
+ rf(s)
+ }
+
+ a.costFunc = func(*State) bool {
+ if u.AvailAttackActions < 1 && u.AvailMoveActions < 1 {
+ return false
+ }
+ u.AvailAttackActions--
+ u.AvailMoveActions--
+ return true
+ }
+ return a
+}
+
+func (a *FullAction) String() string {
+ u := a.source.(*Unit)
+ if a.targets.RequireTargets() {
+ return fmt.Sprintf("↻ %s", a.desc)
+ }
+
+ return fmt.Sprintf("%s: %s ↻@%s", u.Controller(), u.Card().Name, a.targets)
+}
+
+type FreeAction struct {
+ ActionBase
+ desc string
+}
+
+func NewFreeAction(p Permanent, resolveProto ActionFuncPrototype, costFunc ActionCostFunc,
+ desc string,
+) *FreeAction {
+ a := &FreeAction{
+ ActionBase{
+ source: p,
+ Card: p.Card(),
+ costFunc: costFunc,
+ },
+ desc,
+ }
+
+ a.resolveFunc = func(s *State) {
+ rf := resolveProto(a)
+ rf(s)
+ }
+
+ return a
+}
+
+func (a *FreeAction) String() string {
+ p := a.source.(Permanent)
+ if a.targets.RequireTargets() {
+ return fmt.Sprintf("free_action %s", a.desc)
+ }
+
+ return fmt.Sprintf("%s: %s free_action@%s", p.Controller(), p.Card().Name, a.targets)
+}
+
+type BuyAction struct {
+ player *Player
+ card *Card
+}
+
+func (a *BuyAction) Source() interface{} {
+ return a.player
+}
+
+func (a *BuyAction) Targets() *Targets {
+ return newTargetsWithSel(a.player.gameState, []string{"store cards"}, a, []interface{}{a.card})
+}
+
+func (a *BuyAction) CheckTargets(s *State) error {
+ return s.isValidBuy(a)
+}
+
+func (a *BuyAction) Resolve(s *State) {
+ a.player.Store.MoveCard(a.card, a.player.DiscardPile)
+}
+
+func (a *BuyAction) PayCosts(*State) bool {
+ if a.card.BuyCost < 0 || a.card.BuyCost > a.player.Resource {
+ return false
+ }
+
+ a.player.Resource -= a.card.BuyCost
+ return true
+}
+
+func (a *BuyAction) String() string { return fmt.Sprintf("%s buy %s", a.player.Name, a.card.Name) }
+
+func NewBuyAction(p *Player, c *Card) *BuyAction {
+ return &BuyAction{p, c}
+}
+
+type UpkeepAction struct {
+ player *Player
+ unit *Unit
+ pay bool
+}
+
+func (a *UpkeepAction) Resolve(s *State) {
+ if a.pay {
+ a.player.Resource -= a.unit.Upkeep
+ } else {
+ s.DestroyPermanent(a.unit)
+ }
+}
+
+func (a *UpkeepAction) PayCosts(*State) bool {
+ if a.unit.Upkeep > a.player.Resource {
+ return false
+ }
+
+ a.player.Resource -= a.unit.Upkeep
+ return true
+}
+
+func (a *UpkeepAction) String() string {
+ if a.pay {
+ return fmt.Sprintf("%s keep %s", a.player.Name, a.unit.Tile().Position.String())
+ }
+
+ return fmt.Sprintf("%s disband %s", a.player.Name, a.unit.Tile().Position.String())
+}
+
+func NewUpkeepAction(p *Player, u *Unit, pay bool) *UpkeepAction {
+ return &UpkeepAction{p, u, pay}
+}
diff --git a/go/game/ai.go b/go/game/ai.go
new file mode 100644
index 00000000..0adce722
--- /dev/null
+++ b/go/game/ai.go
@@ -0,0 +1,343 @@
+package game
+
+import (
+ "fmt"
+ "log"
+ "strconv"
+ "strings"
+ "sync"
+
+ "github.com/RyanCarrier/dijkstra"
+)
+
+type UnitAI struct {
+ s *State
+ u *Unit
+ actions chan Action
+ syncGameState *sync.WaitGroup
+}
+
+func (ai *UnitAI) NextAction() Action {
+ return <-ai.actions
+}
+
+func (ai *UnitAI) PostAndContinue(a Action) bool {
+ if a == nil {
+ return true
+ }
+
+ ai.actions <- a
+ return ai.awaitGameStateSync()
+}
+
+func (ai *UnitAI) awaitGameStateSync() bool {
+ ai.syncGameState.Wait()
+ ai.syncGameState.Add(1)
+ return !ai.u.IsDestroyed()
+}
+
+func (ai *UnitAI) promptAction() {
+ ai.syncGameState.Done()
+}
+
+func (ai *UnitAI) Execute(f func(*UnitAI)) {
+ defer close(ai.actions)
+ if ai.awaitGameStateSync() {
+ f(ai)
+ }
+}
+
+func NewUnitAI(s *State, u *Unit) *UnitAI {
+ aiDesc := u.Card().getAI()
+ if aiDesc == "" {
+ return nil
+ }
+
+ c := make(chan Action)
+ wg := new(sync.WaitGroup)
+ wg.Add(1)
+ ai := &UnitAI{s, u, c, wg}
+
+ switch aiDesc {
+ case "aggressive":
+ go ai.Execute(AggressiveAI)
+ case "shy":
+ go ai.Execute(ShyAI)
+ case "wandering":
+ aiDesc := u.Card().getAI()
+ x, err := strconv.Atoi(strings.Split(u.Card().getAI(), " ")[1])
+ if err != nil {
+ log.Fatalf("Invalid wandering AI description %s", aiDesc)
+ }
+ go ai.Execute(func(ai *UnitAI) { WanderingAI(ai, x) })
+ case "target-oriented":
+ go ai.Execute(TargetOrientedAI)
+ }
+
+ return ai
+}
+
+func (m *Map) generateMapGraphFor(u *Unit) *dijkstra.Graph {
+ graph := dijkstra.NewGraph()
+ for _, t := range m.AllTiles() {
+ graph.AddMappedVertex(t.Position.String())
+ }
+
+ for _, t := range m.AllTiles() {
+ tId := t.Position.String()
+ for _, neighbour := range TilesInRangeFromOrigin(m, t.Position, 1) {
+ 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.Fatalf("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(u.Tile().Position.String())
+ if err != nil {
+ log.Fatalf("No mapping %s in Graph: %s", u.Tile().Position.String(), err)
+ }
+
+ destId, err := graph.GetMapping(pos.String())
+ if err != nil {
+ log.Fatalf("No mapping %s in Graph: %s", pos.String(), err)
+ }
+
+ return graph.Shortest(srcId, destId)
+}
+
+func findPathsToUnits(graph *dijkstra.Graph, m *Map, u *Unit, units []*Unit) []*dijkstra.BestPath {
+ paths := []*dijkstra.BestPath{}
+
+ for _, unit := range units {
+ var bestPathToUnit *dijkstra.BestPath = nil
+ for _, adjacentTile := range TilesInRangeFromOrigin(m, unit.Tile().Position, 1) {
+ path, err := findPathTo(graph, u, adjacentTile.Position)
+ if err != nil {
+ continue
+ } else if bestPathToUnit == nil || path.Distance < bestPathToUnit.Distance {
+ bestPathToUnit = &path
+ }
+ }
+ paths = append(paths, bestPathToUnit)
+ }
+ return paths
+}
+
+func moveToRandomTile(ai *UnitAI) Action {
+ if ai.u.AvailMoveActions == 0 {
+ return nil
+ }
+
+ a := NewMoveAction(ai.u)
+ options := a.Targets().NextOptions()
+ if len(options) == 0 {
+ return nil
+ }
+
+ idx := 0
+ if len(options) > 1 {
+ idx = ai.s.Rand.Intn(len(options) - 1)
+ }
+ a.Targets().AddTarget(options[idx])
+
+ return a
+}
+
+func moveTowardsNearestEnemyUnit(ai *UnitAI) Action {
+ if ai.u.AvailMoveActions == 0 {
+ return nil
+ }
+
+ graph := ai.s.Map.generateMapGraphFor(ai.u)
+ enemyUnits := ai.s.EnemyUnits(ai.u.Controller())
+ if len(enemyUnits) == 0 {
+ return nil
+ }
+
+ paths := findPathsToUnits(graph, ai.s.Map, ai.u, enemyUnits)
+ nearest := 0
+ for i, p := range paths {
+ if p == nil {
+ continue
+ }
+
+ if paths[nearest] == nil || p.Distance < paths[nearest].Distance {
+ nearest = i
+ }
+ }
+
+ if paths[nearest] == nil {
+ return nil
+ }
+
+ var target Position
+ for _, id := range paths[nearest].Path[1:] {
+ mapping, _ := graph.GetMapped(id)
+ var x, y int
+ fmt.Sscanf(mapping, "(%d, %d)", &x, &y)
+ nextStep := Position{X: x, Y: y}
+ if IsPositionInRange(ai.u.Tile().Position, nextStep, ai.u.Movement.Range) {
+ target = nextStep
+ } else {
+ break
+ }
+ }
+
+ a := NewMoveAction(ai.u)
+ a.Targets().AddTarget(ai.s.Map.TileAt(target))
+ return a
+}
+
+func attackAttackableEnemyPerm(ai *UnitAI) Action {
+ if ai.u.AvailAttackActions == 0 {
+ return nil
+ }
+
+ a := NewAttackAction(ai.u)
+ options := a.Targets().NextOptions()
+ if len(options) == 0 {
+ return nil
+ }
+
+ idx := 0
+ if len(options) > 1 {
+ idx = ai.s.Rand.Intn(len(options) - 1)
+ }
+ a.Targets().AddTarget(options[idx])
+ return a
+}
+
+// 1. If enemy Unit in attack Range then
+// attack enemy Unit in Range
+// 2. While move action available do
+// 3a. If enemy unit exists
+// move towards nearest enemy unit until it is in attack range
+// if attack action avail then proceed at 1.
+// 3b. Otherwise
+// move to random tile in movement range
+func AggressiveAI(ai *UnitAI) {
+ // 1.
+ a := attackAttackableEnemyPerm(ai)
+ if !ai.PostAndContinue(a) {
+ return
+ }
+
+ // 2.
+ if ai.u.AvailMoveActions > 0 {
+ a = moveTowardsNearestEnemyUnit(ai)
+ if !ai.PostAndContinue(a) {
+ return
+ }
+
+ if a != nil && ai.u.AvailAttackActions > 0 {
+ AggressiveAI(ai)
+ } else { // 3b.
+ a = moveToRandomTile(ai)
+ if a != nil {
+ ai.actions <- a
+ }
+ }
+ }
+}
+
+// 2.2 Shy
+// 1. If not in the attack Range of an enemy Unit then
+// activate full action if available
+// 2. If move action available then
+// move to most distant point from all enemy units
+// 3. If enemy Unit in attack Range then
+// attack enemy Unit in Range
+func ShyAI(ai *UnitAI) {
+ // 1.
+ inEnemyAttackRange := false
+out:
+ for _, enemyUnit := range ai.s.EnemyUnits(ai.u.Controller()) {
+ for _, p := range enemyUnit.AttackablePermanents(ai.s.Map) {
+ if p == ai.u {
+ inEnemyAttackRange = true
+ break out
+ }
+ }
+ }
+ if !inEnemyAttackRange && ai.u.HasFullAction() {
+ fullActions := ai.u.Card().Impl.fullActions(ai.u)
+ idx := 0
+ if len(fullActions) > 1 {
+ idx = ai.s.Rand.Intn(len(fullActions) - 1)
+ }
+
+ a := fullActions[idx]
+ options := a.Targets().NextOptions()
+ if len(options) > 1 {
+ idx = ai.s.Rand.Intn(len(options) - 1)
+ }
+ a.Targets().AddTarget(options[idx])
+ ai.actions <- a
+ }
+
+ // 2.
+ // TODO: implement path finding
+ // if u.AvailMoveActions > 0 {
+ // }
+
+ // 3.
+ if ai.u.AvailAttackActions > 0 {
+ attackAttackableEnemyPerm(ai)
+ }
+}
+
+// 2.3 Wandering X
+// 1. If enemy unit in Range X then
+// execute aggressive AI
+// 2. If move action available then
+// move to random tile in movement Range
+// proceed at 1.
+func WanderingAI(ai *UnitAI, x int) {
+ // 1.
+ for _, enemyUnit := range ai.s.EnemyUnits(ai.u.Controller()) {
+ if IsPositionInRange(ai.u.Tile().Position, enemyUnit.Tile().Position, x) {
+ AggressiveAI(ai)
+ break
+ }
+ }
+ // 2.
+ if ai.u.AvailMoveActions > 0 {
+ moveToRandomTile(ai)
+ WanderingAI(ai, x)
+ }
+}
+
+// 2.4 Target-oriented TARGET
+// 1. Use full action if possible
+// 2a. If a TARGET exists and is reachable
+//
+// Move onto or towards nearest TARGET
+// 3a. If enemy Unit in attack Range
+// attack enemy Unit in Range
+//
+// 2b. Otherwise
+//
+// execute wandering 3 AI
+func TargetOrientedAI(ai *UnitAI) {
+ // 1.
+ if ai.u.HasFullAction() {
+ ai.u.FullAction()
+ }
+
+ // if ai.t != nil {
+ // // TODO: path finding again
+ // attackAttackableEnemyPerm(ai)
+ // } else {
+ WanderingAI(ai, 3)
+ // }
+}
diff --git a/go/game/ai_test.go b/go/game/ai_test.go
new file mode 100644
index 00000000..5b68ae27
--- /dev/null
+++ b/go/game/ai_test.go
@@ -0,0 +1,66 @@
+package game
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestGenerateGraph(t *testing.T) {
+ mapDef := `map: |1-
+ HST
+ HSF
+ TST
+symbols:
+ T: tower
+ H: house
+ F: farm
+ S: street
+`
+ r := strings.NewReader(mapDef)
+ m, _ := readMap(r)
+ a := NewUnit(NewCard("base/archer"), m.TileAt(Position{0, 0}), nil)
+ f := NewUnit(NewCard("misc/farmer"), m.TileAt(Position{2, 2}), nil)
+
+ graph := m.generateMapGraphFor(a)
+ p, err := findPathTo(graph, a, Position{1, 1})
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if p.Distance != 3 {
+ t.Fatalf("Wrong distance %d", p.Distance)
+ }
+
+ paths := findPathsToUnits(graph, m, a, []*Unit{f})
+ if paths[0] == nil {
+ t.Fatal("No path found")
+ }
+
+ if paths[0].Distance != 3 {
+ t.Fatalf("Wrong distance %d", p.Distance)
+ }
+}
+
+func TestAngrySquidDiagonal(t *testing.T) {
+ mapDef := `map: |1-
+ WWWWWWW
+ W W
+ WW WW
+ WWWWWWW
+ WWWWWWW
+ W W
+symbols:
+ W: deep water
+`
+ r := strings.NewReader(mapDef)
+ m, _ := readMap(r)
+ s := NewUnit(NewCard("kraken/angry_squid"), m.TileAt(Position{5, 0}), nil)
+ f := NewUnit(NewCard("misc/farmer"), m.TileAt(Position{6, 5}), nil)
+
+ graph := m.generateMapGraphFor(s)
+
+ paths := findPathsToUnits(graph, m, s, []*Unit{f})
+ if paths[0] == nil {
+ t.Fatal("No path found")
+ }
+}
diff --git a/go/game/artifact.go b/go/game/artifact.go
new file mode 100644
index 00000000..acbaeb74
--- /dev/null
+++ b/go/game/artifact.go
@@ -0,0 +1,51 @@
+package game
+
+import (
+ "log"
+ "strconv"
+ "strings"
+)
+
+type Artifact struct {
+ PermanentBase
+ Solid int
+}
+
+func NewArtifactFromPath(cardPath string, tile *Tile, owner *Player) *Artifact {
+ card := NewCard(cardPath)
+ return NewArtifact(card, tile, owner)
+}
+
+func NewArtifact(card *Card, tile *Tile, owner *Player) *Artifact {
+ a := &Artifact{NewPermanentBase(card, tile, owner), 0}
+ tile.Permanent = a
+
+ effects := card.getEffects()
+ for _, e := range effects {
+ if strings.HasPrefix(e, "Solid") {
+ tokens := strings.Split(e, " ")
+ var err error
+ a.Solid, err = strconv.Atoi(tokens[1])
+ if err != nil {
+ log.Fatalf("Invalid Solid definition %s\n", e)
+ }
+ }
+ }
+
+ return a
+}
+
+func (a *Artifact) Fight(p Permanent) {
+}
+
+func (a *Artifact) IsDestroyed() bool {
+ return a.Damage() >= a.Solid
+}
+
+func (a *Artifact) Attackable() bool {
+ return a.Solid > 0
+}
+
+func (a *Artifact) AvailActions() []Action {
+ return []Action{}
+}
diff --git a/go/game/attack.go b/go/game/attack.go
new file mode 100644
index 00000000..97881d48
--- /dev/null
+++ b/go/game/attack.go
@@ -0,0 +1,50 @@
+package game
+
+import (
+ "fmt"
+ "log"
+ "strconv"
+ "strings"
+)
+
+type Attack struct {
+ Damage int
+ Range int
+}
+
+func INVALID_ATTACK() Attack {
+ return Attack{-1, -1}
+}
+
+func (a Attack) String() string {
+ if a == INVALID_ATTACK() {
+ return "invalid attack"
+ }
+
+ if a.Range == 1 {
+ return fmt.Sprintf("%d", a.Damage)
+ }
+ return fmt.Sprintf("%d range %d", a.Damage, a.Range)
+}
+
+func parseAttack(attack interface{}) Attack {
+ if damage, ok := attack.(int); ok {
+ return Attack{damage, 1}
+ }
+
+ attackStr := attack.(string)
+ tokens := strings.Split(attackStr, " ")
+ a, err := strconv.Atoi(tokens[0])
+ if err != nil {
+ log.Fatalf("Attack %s has no valid Damage value\n", attackStr)
+ }
+
+ r := 1
+ if len(tokens) == 3 {
+ r, err = strconv.Atoi(tokens[2])
+ if err != nil {
+ log.Fatalf("Attack %s has no valid Range value\n", attackStr)
+ }
+ }
+ return Attack{a, r}
+}
diff --git a/go/game/card.go b/go/game/card.go
new file mode 100644
index 00000000..9013a059
--- /dev/null
+++ b/go/game/card.go
@@ -0,0 +1,316 @@
+package game
+
+import (
+ "fmt"
+ "io"
+ "log"
+ "net/http"
+ "path"
+ "strconv"
+ "strings"
+
+ "golang.org/x/exp/slices"
+ "gopkg.in/yaml.v3"
+)
+
+type CardType int
+
+const (
+ undefinedCardType CardType = iota
+ unitType
+ spellType
+ artifactType
+ equipmentType
+ potionType
+ intentionType
+)
+
+func (ct CardType) String() string {
+ switch ct {
+ case undefinedCardType:
+ return "undefined"
+ case unitType:
+ return "unit"
+ case spellType:
+ return "spell"
+ case artifactType:
+ return "artifact"
+ case equipmentType:
+ return "equipment"
+ case potionType:
+ return "potion"
+ case intentionType:
+ return "intention"
+ }
+ log.Panicf("Switch not exhausting %d", ct)
+ return ""
+}
+
+var CardTypes = struct {
+ Unit CardType
+ Spell CardType
+ Artifact CardType
+ Equipment CardType
+ Potion CardType
+ Intention CardType
+}{
+ Unit: unitType,
+ Spell: spellType,
+ Artifact: artifactType,
+ Equipment: equipmentType,
+ Potion: potionType,
+ Intention: intentionType,
+}
+
+var CardTypeNames = map[string]CardType{
+ "unit": unitType,
+ "spell": spellType,
+ "artifact": artifactType,
+ "equipment": equipmentType,
+ "potion": potionType,
+ "intention": intentionType,
+}
+
+type cardImplementation interface {
+ spawnTiles(*State, *Player) []*Tile
+ fullActions(*Unit) []*FullAction
+ freeActions(Permanent) []*FreeAction
+}
+
+// Default implementation of the cardImplementation interface.
+//
+// Card Implementations should embed this struct to "inherit" the
+// default behavior.
+type cardImplementationBase struct{}
+
+func (impl *cardImplementationBase) spawnTiles(*State, *Player) []*Tile {
+ return nil
+}
+
+func (impl *cardImplementationBase) fullActions(*Unit) []*FullAction {
+ return nil
+}
+
+func (impl *cardImplementationBase) freeActions(Permanent) []*FreeAction {
+ return nil
+}
+
+// Map of all card implementations
+var cardImplementations map[string]cardImplementation
+
+func getCardImplementation(c *Card) cardImplementation {
+ if impl, found := cardImplementations[c.Path()]; found {
+ return impl
+ }
+
+ impl := newParsedCardImplementation(c)
+ cardImplementations[c.Path()] = impl
+ return impl
+}
+
+const (
+ CARD_DATA_URL string = "data/cards"
+ NOT_BUYABLE int = -1 // Maybe this needs to be changed some day to allow more design space
+)
+
+type Card struct {
+ Name string
+ Set SetIdentifier
+ Type CardType
+ BuyCost int
+ Values map[string]interface{}
+ Impl cardImplementation
+}
+
+var cardDefinitions = map[string]map[string][]byte{}
+
+func getCardDefinition(set, cardName string) []byte {
+ cardDefinition, found := cardDefinitions[set][cardName]
+ if !found {
+ cardDefinition = retrieveCardDefinition(path.Join(set, cardName))
+ if cardDefinitions[set] == nil {
+ cardDefinitions[set] = map[string][]byte{}
+ }
+ cardDefinitions[set][cardName] = cardDefinition
+ }
+ return cardDefinition
+}
+
+func retrieveCardDefinition(cardPath string) []byte {
+ url := PROTOCOL + path.Join(BASE_URL, CARD_DATA_URL, cardPath+".yml")
+ log.Printf("loading %s\n", url)
+ resp, err := http.Get(url)
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ defer resp.Body.Close()
+ data, err := io.ReadAll(resp.Body)
+ if err != nil {
+ log.Fatal(err)
+ }
+ return data
+}
+
+func (card *Card) parseDefinition(definition []byte) {
+ data := make(map[interface{}]interface{})
+ err := yaml.Unmarshal(definition, &data)
+ if err != nil {
+ log.Fatalf("parsing %s failed with: %v\n", definition, err)
+ }
+
+ name, found := data["name"]
+ if !found {
+ log.Fatalf("no name in card yaml %s\n", definition)
+ }
+ delete(data, "name")
+
+ switch nameYml := name.(type) {
+ case string:
+ card.Name = nameYml
+ case map[string]interface{}:
+ cardName := nameYml["en"]
+ card.Name = cardName.(string)
+ default:
+ log.Fatalf("Unexpected type %T of yml card field 'Name'\n", name)
+ }
+
+ t, found := data["type"]
+ if !found {
+ log.Fatalf("no type in card yaml %s\n", definition)
+ }
+ delete(data, "type")
+
+ card.Type = CardTypeNames[t.(string)]
+
+ bc, found := data["buy"]
+ if found {
+ card.BuyCost = bc.(int)
+ } else {
+ card.BuyCost = NOT_BUYABLE
+ }
+ delete(data, "buy")
+
+ for k, v := range data {
+ card.Values[k.(string)] = v
+ }
+
+ card.Impl = getCardImplementation(card)
+}
+
+func (card *Card) getCanonicalValues(key string) []string {
+ var values []string
+ untypedValues, found := card.Values[key]
+ if !found {
+ return []string{}
+ }
+
+ switch typedValues := untypedValues.(type) {
+ case map[string]interface{}:
+ enValues := typedValues["en"]
+ switch typedEnValues := enValues.(type) {
+ case string:
+ values = []string{typedEnValues}
+ case []string:
+ values = typedEnValues
+ case []interface{}:
+ values = []string{}
+ for _, v := range typedEnValues {
+ values = append(values, v.(string))
+ }
+ }
+ case string:
+ values = []string{typedValues}
+ case int:
+ values = []string{strconv.Itoa(typedValues)}
+ case []string:
+ values = typedValues
+ }
+
+ for i, value := range values {
+ values[i] = strings.ToLower(value)
+ }
+
+ return values
+}
+
+func (card *Card) getEffects() []string {
+ return card.getCanonicalValues("effect")
+}
+
+func (card *Card) hasEffect(effect string) bool {
+ return slices.Contains(card.getEffects(), effect)
+}
+
+func (card *Card) getXEffect(effect string) (int, error) {
+ for _, e := range card.getEffects() {
+ if !strings.Contains(e, effect) {
+ continue
+ }
+
+ tokens := strings.SplitN(e, " ", 2)
+ return strconv.Atoi(tokens[1])
+ }
+ return 0, fmt.Errorf("Crad %s has no effect %s", card.Name, effect)
+}
+
+func (card *Card) hasPlacementConstrain(effect string) bool {
+ if slices.Contains(card.getEffects(), effect) {
+ return true
+ } else if card.Type != CardTypes.Unit {
+ return false
+ }
+
+ movementDesc := card.getCanonicalValues("movement")[0]
+ return strings.Contains(movementDesc, effect)
+}
+
+func (card *Card) getAI() string {
+ aiDesc := card.getCanonicalValues("ai")
+ if len(aiDesc) == 0 {
+ return ""
+ }
+ return strings.Split(aiDesc[0], " ")[0]
+}
+
+func NewCard(cardPath string) *Card {
+ cardName := path.Base(cardPath)
+ set := path.Dir(cardPath)
+
+ cardDefinition := getCardDefinition(set, cardName)
+
+ c := Card{
+ Name: "",
+ Set: SetIdentifierNames[set],
+ Type: undefinedCardType,
+ BuyCost: NOT_BUYABLE,
+ Values: map[string]interface{}{},
+ }
+ c.parseDefinition(cardDefinition)
+ return &c
+}
+
+func (c *Card) Path() string {
+ fileName := strings.ReplaceAll(strings.ToLower(c.Name), " ", "_")
+ return path.Join(c.Set.String(), fileName)
+}
+
+func (c *Card) IsPermanent() bool {
+ return c.Type == CardTypes.Unit ||
+ c.Type != CardTypes.Artifact ||
+ c.Type != CardTypes.Equipment ||
+ c.Type != CardTypes.Potion
+}
+
+func (c *Card) IsBuyable() bool {
+ return c.BuyCost != NOT_BUYABLE
+}
+
+func (c *Card) PlayCosts() int {
+ switch c.Type {
+ case CardTypes.Unit:
+ return c.Values["upkeep"].(int)
+ default:
+ return c.Values["play"].(int)
+ }
+}
diff --git a/go/game/cardImplementations.go b/go/game/cardImplementations.go
new file mode 100644
index 00000000..8024e4fc
--- /dev/null
+++ b/go/game/cardImplementations.go
@@ -0,0 +1,56 @@
+package game
+
+type farmerImplementation struct {
+ cardImplementationBase
+}
+
+type advisorImplementation struct {
+ cardImplementationBase
+}
+
+func (impl *advisorImplementation) fullAction(u *Unit) []*FullAction {
+ resolvePrototype := func(a Action) ActionResolveFunc {
+ u := a.Source().(*Unit)
+ return func(s *State) {
+ controller := u.Controller()
+ controller.DrawN(1)
+ cards := controller.PromptHandCardSelection(0, 1)
+ if cards != nil {
+ controller.Deck.MoveCard(cards[0], s.Exile)
+ }
+ }
+ }
+ return []*FullAction{NewFullAction(u, resolvePrototype, "draw 1. exile 1")}
+}
+
+type missionaryImplementation struct {
+ cardImplementationBase
+}
+
+func (impl *missionaryImplementation) fullAction(u *Unit) []*FullAction {
+ resolvePrototype := func(a Action) ActionResolveFunc {
+ u := a.Source().(*Unit)
+ target := a.Targets().sel[0].(*Unit)
+ return func(s *State) {
+ faith, found := target.States[UnitMarks.Faith]
+ if !found {
+ target.States[UnitMarks.Faith] = 2
+ } else {
+ target.States[UnitMarks.Faith] = faith + 2
+ if faith+2 > target.Card().BuyCost {
+ target.controller = u.Controller()
+ }
+ }
+ }
+ }
+ return []*FullAction{NewFullAction(u, resolvePrototype, "put faith ...")}
+}
+
+func init() {
+ cardImplementations = map[string]cardImplementation{
+ "base/advisor": &advisorImplementation{},
+ "base/missionary": &missionaryImplementation{},
+
+ "misc/farmer": &farmerImplementation{},
+ }
+}
diff --git a/go/game/cardParsing.go b/go/game/cardParsing.go
new file mode 100644
index 00000000..afdefe54
--- /dev/null
+++ b/go/game/cardParsing.go
@@ -0,0 +1,179 @@
+package game
+
+import (
+ "log"
+ "regexp"
+ "strconv"
+ "strings"
+)
+
+type parsedCardImplementation struct {
+ card *Card
+ spawnTilesFunc func(*State, *Player) []*Tile
+ genFullActions func(*Unit) []*FullAction
+ genFreeActions func(Permanent) []*FreeAction
+}
+
+func (impl *parsedCardImplementation) spawnTiles(s *State, p *Player) []*Tile {
+ if impl.spawnTilesFunc != nil {
+ return impl.spawnTilesFunc(s, p)
+ }
+ return nil
+}
+
+func (impl *parsedCardImplementation) fullActions(u *Unit) []*FullAction {
+ if impl.genFullActions != nil {
+ return impl.genFullActions(u)
+ }
+ return nil
+}
+
+func (impl *parsedCardImplementation) freeActions(p Permanent) []*FreeAction {
+ if impl.genFreeActions != nil {
+ return impl.genFreeActions(p)
+ }
+ return nil
+}
+
+func newParsedCardImplementation(c *Card) *parsedCardImplementation {
+ impl := &parsedCardImplementation{card: c}
+ for _, effect := range c.getEffects() {
+ impl.parseEffect(effect)
+ }
+
+ for _, fullAction := range c.getCanonicalValues("full_action") {
+ impl.parseFullAction(fullAction)
+ }
+
+ return impl
+}
+
+func (impl *parsedCardImplementation) parseEffect(effect string) {
+ if strings.Contains(effect, "can be played") {
+ impl.parsePlayModification(effect)
+ }
+}
+
+func (impl *parsedCardImplementation) parseFullAction(fullAction string) {
+ var resolveProtos []ActionFuncPrototype
+ gainRessourceRegex := regexp.MustCompile(`gain [1-9][0-9]* resource`)
+
+ if find := gainRessourceRegex.Find([]byte(fullAction)); find != nil {
+ tokens := strings.SplitN(string(find), " ", 3)
+ gain, err := strconv.Atoi(tokens[1])
+ if err != nil {
+ log.Fatalf("Failed to parse resource gain full action: %s", err)
+ }
+
+ resolveProto := func(a Action) ActionResolveFunc {
+ u := a.Source().(*Unit)
+ return func(*State) {
+ u.controller.gainResource(gain)
+ }
+ }
+ resolveProtos = append(resolveProtos, resolveProto)
+ }
+
+ if resolveProtos != nil {
+ impl.genFullActions = func(u *Unit) []*FullAction {
+ fullActions := make([]*FullAction, 0, len(resolveProtos))
+
+ for _, proto := range resolveProtos {
+ fullActions = append(fullActions, NewFullAction(u, proto, ""))
+ }
+
+ return fullActions
+ }
+ }
+}
+
+func parseUnitConstrain(constrain string) func(ctx interface{}, u *Unit) bool {
+ enemy := strings.Contains(constrain, "enemy")
+ if enemy {
+ return func(ctx interface{}, e *Unit) bool {
+ p := ctx.(*Player)
+ return p.IsEnemy(e.Controller())
+ }
+ } else {
+ log.Println("Only enemy unit constrains are implemented yet")
+ }
+
+ return nil
+}
+
+func parseLocation(location string) func(interface{}, *State) []*Tile {
+ r := -1
+ var what string
+ if strings.Contains(location, "on") {
+ r = 0
+ tokens := strings.SplitN(location, "on ", 2)
+ what = tokens[1]
+
+ } else if strings.Contains(location, "next to") {
+ r = 1
+ tokens := strings.SplitN(location, "next to ", 2)
+ what = tokens[1]
+
+ } else if strings.Contains(location, "in range") {
+ tokens := strings.SplitN(location, "in range ", 2)
+ afterRangePattern := tokens[1]
+
+ var rangeStr string
+ tokens = strings.SplitN(afterRangePattern, " ", 2)
+ rangeStr, what = tokens[0], tokens[1]
+
+ var err error
+ r, err = strconv.Atoi(rangeStr)
+ if err != nil {
+ log.Fatalf("Unsupported range pattern \"%s\" in \"%s\"", "range "+rangeStr, location)
+ }
+ }
+
+ if r == -1 || what == "" {
+ return nil
+ }
+
+ if strings.Contains(what, "unit") {
+ constrainFunc := parseUnitConstrain(what)
+ if constrainFunc != nil {
+ return func(ctx interface{}, s *State) []*Tile {
+ origins := s.Map.FilterTiles(func(t *Tile) bool {
+ if t.Permanent != nil &&
+ t.Permanent.Card().Type == CardTypes.Unit &&
+ constrainFunc(ctx, t.Permanent.(*Unit)) {
+ return true
+ }
+ return false
+ })
+
+ if r == 0 {
+ return origins
+ } else {
+ tiles := []*Tile{}
+ for _, origin := range origins {
+ tiles = append(tiles, TilesInRangeFromOrigin(s.Map, origin.Position, r)...)
+ }
+ return tiles
+ }
+ }
+ }
+ }
+ log.Println("Only unit location constrains are implemented yet")
+ return nil
+}
+
+func (impl *parsedCardImplementation) parsePlayModification(effect string) {
+ tokens := strings.SplitN(effect, " can be played ", 2)
+ log.Println(tokens)
+ what, how := tokens[0], tokens[1]
+ if what == strings.ToLower(impl.card.Name) {
+ if locationFunc := parseLocation(how); locationFunc != nil {
+ impl.spawnTilesFunc = func(p *State, s *Player) []*Tile { return locationFunc(s, p) }
+ } else {
+ log.Println("Play modification other than locations are not implemented ")
+ }
+ } else {
+ log.Println(effect)
+ log.Println("Play modification for other cards are not implemented")
+ }
+}
diff --git a/go/game/deck.go b/go/game/deck.go
new file mode 100644
index 00000000..569a952d
--- /dev/null
+++ b/go/game/deck.go
@@ -0,0 +1,77 @@
+package game
+
+import (
+ "log"
+ "math/rand"
+ "strconv"
+ "strings"
+)
+
+type Deck struct {
+ PileOfCardsBase
+}
+
+func NewDeck() *Deck {
+ d := Deck{PileOfCardsBase{[]*Card{}}}
+ return &d
+}
+
+func (d *Deck) Shuffle(r *rand.Rand) {
+ r.Shuffle(d.Size(), func(i, j int) {
+ d.cards[i], d.cards[j] = d.cards[j], d.cards[i]
+ })
+}
+
+func (d *Deck) Draw(amount int) []*Card {
+ if d.Size() < amount {
+ drawn := d.cards
+ d.cards = []*Card{}
+ return drawn
+ }
+
+ drawn := d.cards[0:amount]
+ d.cards = d.cards[amount:]
+ return drawn
+}
+
+func (d *Deck) DrawOne() *Card {
+ drawn := d.Draw(1)
+ if len(drawn) == 0 {
+ return nil
+ }
+ return drawn[0]
+}
+
+func NewDeckFrom(cards PileOfCards) *Deck {
+ d := NewDeck()
+ cards.MoveInto(d)
+ return d
+}
+
+func NewDeckFromCardPaths(cardPaths []string) *Deck {
+ d := NewDeck()
+ for _, cardPath := range cardPaths {
+ card := NewCard(cardPath)
+ d.AddCard(card)
+ }
+ return d
+}
+
+func NewDeckFromDeckList(deckList string) *Deck {
+ cardPaths := []string{}
+ lines := strings.Split(deckList, "\n")
+ for _, line := range lines {
+ tokens := strings.SplitN(line, " ", 2)
+ cardPath := tokens[1]
+ amount, err := strconv.Atoi(tokens[0])
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ for i := 0; i < amount; i++ {
+ cardPaths = append(cardPaths, cardPath)
+ }
+ }
+
+ return NewDeckFromCardPaths(cardPaths)
+}
diff --git a/go/game/discardPiles.go b/go/game/discardPiles.go
new file mode 100644
index 00000000..c39a9646
--- /dev/null
+++ b/go/game/discardPiles.go
@@ -0,0 +1,10 @@
+package game
+
+type DiscardPile struct {
+ PileOfCardsBase
+}
+
+func NewDiscardPile() *DiscardPile {
+ dp := DiscardPile{PileOfCardsBase{[]*Card{}}}
+ return &dp
+}
diff --git a/go/game/equipment.go b/go/game/equipment.go
new file mode 100644
index 00000000..1b78c8ad
--- /dev/null
+++ b/go/game/equipment.go
@@ -0,0 +1,5 @@
+package game
+
+type Equipment struct {
+ Artifact
+}
diff --git a/go/game/hand.go b/go/game/hand.go
new file mode 100644
index 00000000..2cbbe5b6
--- /dev/null
+++ b/go/game/hand.go
@@ -0,0 +1,10 @@
+package game
+
+type Hand struct {
+ PileOfCardsBase
+}
+
+func NewHand() *Hand {
+ h := Hand{PileOfCardsBase{[]*Card{}}}
+ return &h
+}
diff --git a/go/game/kraken.go b/go/game/kraken.go
new file mode 100644
index 00000000..19adfd3c
--- /dev/null
+++ b/go/game/kraken.go
@@ -0,0 +1,179 @@
+package game
+
+import (
+ "log"
+ "strings"
+ "sync"
+)
+
+const (
+ KRAKEN_NAME = "Kraken"
+)
+
+var KrakenDecklist = `2 kraken/angry_squid
+2 kraken/chachalot
+2 kraken/clownfish
+2 kraken/deja_vu!
+2 kraken/devour_the_poor!
+2 kraken/dolphin
+2 kraken/flying_dutchmen
+2 kraken/frost_pylon
+2 kraken/gigantic_hail
+2 kraken/sailfish
+2 kraken/sold_out!
+2 kraken/suprise!
+2 kraken/tentacle_slap
+2 kraken/tides_change!
+2 kraken/unholy_cannonball`
+
+func NewKraken(id int, s *State) *Player {
+ kraken := NewPlayer(id, KRAKEN_NAME, NewDeck(), s)
+ d := NewDeckFromDeckList(KrakenDecklist)
+ d.Shuffle(s.Rand)
+ kraken.Deck = d
+
+ kraken.DrawPerTurn = 0
+
+ kraken_tile := s.Map.FilterTiles(func(t *Tile) bool { return t.Raw == "the kraken" })
+ if len(kraken_tile) != 1 {
+ log.Fatal("No kraken tile found in Map")
+ }
+
+ s.AddPermanent(NewUnitFromPath("kraken/the_kraken", kraken_tile[0], kraken))
+
+ addKrakenControl(kraken)
+
+ return kraken
+}
+
+func (s *State) AddKraken() {
+ kraken := NewKraken(len(s.Players)+1, s)
+ s.Players = append(s.Players, kraken)
+}
+
+type KrakenControl struct {
+ kraken *Player
+ actions chan Action
+ syncGameState *sync.WaitGroup
+ turn int
+}
+
+func addKrakenControl(kraken *Player) {
+ ctrl := &KrakenControl{kraken, make(chan Action), new(sync.WaitGroup), 0}
+ kraken.Ctrl = ctrl
+
+ ctrl.syncGameState.Add(1)
+ go func() {
+ s := kraken.gameState
+ for len(s.Map.WinCondition(s)) == 0 {
+ ctrl.krakenTurn()
+ }
+ }()
+}
+
+func (ctrl *KrakenControl) awaitGameStateSync() {
+ ctrl.syncGameState.Wait()
+ ctrl.syncGameState.Add(1)
+}
+
+func (ctrl *KrakenControl) krakenTurn() {
+ s := ctrl.kraken.gameState
+ kraken := ctrl.kraken
+
+ ctrl.awaitGameStateSync()
+ ctrl.turn++
+ if ctrl.turn != kraken.Turn {
+ log.Fatalf("Ai turn out of sync %d != %d", ctrl.turn, kraken.Turn)
+ }
+
+turn:
+ for {
+ drawn := kraken.Deck.Draw(1)
+ if len(drawn) == 0 {
+ break
+ }
+ c := drawn[0]
+
+ if c.BuyCost > kraken.Resource {
+ log.Println("Kraken discards", c.Name, "because resource to low", kraken.Resource)
+ kraken.DiscardPile.AddCard(c)
+ break
+ }
+ kraken.Hand.AddCard(c)
+
+ costFunc := func(s *State) bool {
+ kraken.Resource -= c.BuyCost
+ return true
+ }
+
+ a := NewPlayAction(kraken, c, costFunc)
+ for a.Targets().RequireTargets() {
+ options := a.Targets().NextOptions()
+ if len(options) == 0 {
+ log.Println("No target available for", c.Name)
+ kraken.DiscardPile.AddCard(c)
+ continue turn
+ }
+
+ idx := 0
+ if len(options) > 1 {
+ idx = s.Rand.Intn(len(options) - 1)
+ }
+ a.Targets().AddTarget(options[idx])
+ }
+
+ ctrl.actions <- a
+ ctrl.awaitGameStateSync()
+ }
+
+ for _, u := range s.OwnUnits(kraken) {
+ unitAI := NewUnitAI(s, u)
+ if unitAI == nil {
+ continue
+ }
+
+ unitAI.promptAction()
+ for a := range unitAI.actions {
+ ctrl.actions <- a
+ ctrl.awaitGameStateSync()
+ unitAI.promptAction()
+ }
+ }
+
+ ctrl.actions <- NewPassPriority(ctrl.kraken)
+}
+
+func (ctrl *KrakenControl) RecvAction() Action {
+ s := ctrl.kraken.gameState
+ kraken := ctrl.kraken
+ if !s.IsActivePlayer(kraken) ||
+ s.ActivePhase != Phases.ActionPhase || !s.Stack.IsEmpty() {
+
+ return NewPassPriority(ctrl.kraken)
+ }
+
+ ctrl.syncGameState.Done()
+ return <-ctrl.actions
+}
+
+func (ctrl *KrakenControl) SendNotification(n PlayerNotification) {
+}
+
+func (p *Player) IsKraken() bool {
+ return p.Name == KRAKEN_NAME
+}
+
+func (s *State) FindKraken() *Player {
+ for _, p := range s.Players {
+ if p.IsKraken() {
+ return p
+ }
+ }
+ return nil
+}
+
+func (s *State) KrakenSpawnTiles() []*Tile {
+ return s.Map.FilterTiles(func(t *Tile) bool {
+ return strings.Contains(t.Raw, "deep water spawn")
+ })
+}
diff --git a/go/game/map.go b/go/game/map.go
new file mode 100644
index 00000000..cab18f26
--- /dev/null
+++ b/go/game/map.go
@@ -0,0 +1,303 @@
+package game
+
+import (
+ "errors"
+ "fmt"
+ "io"
+ "log"
+ "math/rand"
+ "net/http"
+ "os"
+ "path"
+ "strings"
+
+ "gopkg.in/yaml.v3"
+)
+
+const (
+ PROTOCOL string = "http://"
+ BASE_URL string = "localhost:8000/"
+ MAP_URL_PART string = "html/build/maps"
+
+ DEFAULT_RESOURCE_GAIN int = 5
+ DEFAULT_START_DECK string = "3 misc/farmer"
+)
+
+type MapYml struct {
+ Map string `yaml:"map"`
+ Symbols map[string]string `yaml:"symbols"`
+ StartDeckList string `yaml:"start_deck_list"`
+}
+
+func DummyWinCondition(*State) []*Player {
+ return []*Player{}
+}
+
+type Map struct {
+ Tiles [][]Tile
+ symbols map[string]string
+ ResourceGain int
+ StartDeckList string
+ WinCondition func(*State) []*Player
+ Stores map[Position]*Store
+}
+
+func readMap(r io.Reader) (*Map, error) {
+ data, err := io.ReadAll(r)
+ if err != nil {
+ return nil, err
+ }
+
+ return ParseMap(data)
+}
+
+func ParseMap(data []byte) (*Map, error) {
+ mapYml := MapYml{}
+ err := yaml.Unmarshal(data, &mapYml)
+ if err != nil {
+ return nil, err
+ }
+
+ mapYml.Symbols[" "] = "neutral"
+
+ m := &Map{
+ Tiles: [][]Tile{},
+ symbols: mapYml.Symbols,
+ ResourceGain: DEFAULT_RESOURCE_GAIN,
+ StartDeckList: DEFAULT_START_DECK,
+ WinCondition: DummyWinCondition,
+ Stores: make(map[Position]*Store),
+ }
+
+ if mapYml.StartDeckList != "" {
+ m.StartDeckList = mapYml.StartDeckList
+ }
+
+ rows := strings.Split(mapYml.Map, "\n")
+ for y := 0; y < len(rows); y++ {
+ row := rows[y]
+ m.Tiles = append(m.Tiles, []Tile{})
+ for x := 0; x < len(rows[y]); x++ {
+ pos := Position{x, y}
+ s := string(row[x])
+ tile, err := NewTileFromString(mapYml.Symbols[s], pos)
+ if err != nil {
+ return nil, err
+ }
+
+ if tile.Type == TileTypes.Store {
+ m.Stores[pos] = NewStore()
+ }
+
+ m.Tiles[y] = append(m.Tiles[y], tile)
+ }
+ }
+
+ m.selectStreets()
+
+ return m, nil
+}
+
+func (m *Map) selectStreets() {
+ for y := 0; y < len(m.Tiles); y++ {
+ for x := 0; x < len(m.Tiles[y]); x++ {
+ tile := &m.Tiles[y][x]
+ if tile.Type != TileTypes.Street {
+ continue
+ }
+ connections, left, right, above, below := m.FindStreetConnections(x, y)
+
+ // Decide if curve or straight
+ if connections == 2 && (left || right) && (above || below) {
+ tile.Raw = "street 2"
+ }
+
+ if connections > 2 {
+ tile.Raw = fmt.Sprintf("street %d", connections)
+ }
+ }
+ }
+}
+
+func (m *Map) FindNeighbours(x, y int) (left, right, above, below *Tile) {
+ left, right, above, below = nil, nil, nil, nil
+ if y > 0 {
+ above = &m.Tiles[y-1][x]
+ }
+
+ if y < len(m.Tiles)-1 {
+ below = &m.Tiles[y+1][x]
+ }
+
+ if x > 0 {
+ left = &m.Tiles[y][x-1]
+ }
+
+ if x < len(m.Tiles[y])-1 {
+ right = &m.Tiles[y][x+1]
+ }
+
+ return left, right, above, below
+}
+
+func (m *Map) FindAnyConnections(x, y int) (connections int, left, right, above, below bool) {
+ leftT, rightT, aboveT, belowT := m.FindNeighbours(x, y)
+ connections = 0
+
+ if leftT != nil && leftT.Type != TileTypes.Neutral {
+ left = true
+ connections += 1
+ }
+ if rightT != nil && rightT.Type != TileTypes.Neutral {
+ right = true
+ connections += 1
+ }
+ if aboveT != nil && aboveT.Type != TileTypes.Neutral {
+ above = true
+ connections += 1
+ }
+ if belowT != nil && belowT.Type != TileTypes.Neutral {
+ below = true
+ connections += 1
+ }
+
+ return
+}
+
+func (m *Map) FindStreetConnections(x int, y int) (connections int, left, right, above, below bool) {
+ leftT, rightT, aboveT, belowT := m.FindNeighbours(x, y)
+ connections = 0
+
+ if leftT != nil && leftT.Type == TileTypes.Street {
+ left = true
+ connections += 1
+ }
+ if rightT != nil && rightT.Type == TileTypes.Street {
+ right = true
+ connections += 1
+ }
+ if aboveT != nil && aboveT.Type == TileTypes.Street {
+ above = true
+ connections += 1
+ }
+ if belowT != nil && belowT.Type == TileTypes.Street {
+ below = true
+ connections += 1
+ }
+
+ return
+}
+
+func GetMap(name string) (*Map, error) {
+ url := path.Join(BASE_URL, MAP_URL_PART, name+".yml")
+ log.Printf("loading map from %s\n", url)
+ resp, err := http.Get(PROTOCOL + url)
+ if err != nil {
+ log.Fatal(err)
+ }
+ defer resp.Body.Close()
+ return readMap(resp.Body)
+}
+
+func LoadMap(file string) (*Map, error) {
+ f, err := os.Open(file)
+ if err != nil {
+ log.Fatal("Opening map ", file, " failed: ", err)
+ }
+ defer f.Close()
+
+ return readMap(f)
+}
+
+func (m *Map) TileAt(p Position) *Tile {
+ if p.Y >= len(m.Tiles) || p.X >= len(m.Tiles[p.Y]) {
+ return nil
+ }
+ return &m.Tiles[p.Y][p.X]
+}
+
+func (m *Map) RandomTile(r *rand.Rand, types []TileType) Position {
+ candidates := []Position{}
+ for y := 0; y < len(m.Tiles); y++ {
+ for x := 0; x < len(m.Tiles[y]); x++ {
+ candidate := m.Tiles[y][x]
+ for _, t := range types {
+ if candidate.Type == t {
+ candidates = append(candidates, candidate.Position)
+ break
+ }
+ }
+ }
+ }
+
+ randIdx := r.Intn(len(candidates))
+ return candidates[randIdx]
+}
+
+func (m *Map) RandomTileFromSymbols(r *rand.Rand, symbols []string) (Position, error) {
+ types := make([]TileType, 0, len(symbols))
+ for _, symbol := range symbols {
+ tileString, ok := m.symbols[symbol]
+ if !ok {
+ return INVALID_POSITION(), fmt.Errorf(fmt.Sprintf("symbol %s not in the map symbols %v", symbol, m.symbols))
+ }
+
+ tileType, ok := TileNames[tileString]
+ if !ok {
+ return INVALID_POSITION(), errors.New("tile type unknown")
+ }
+
+ types = append(types, tileType)
+ }
+ return m.RandomTile(r, types), nil
+}
+
+func (m *Map) FilterTiles(filter func(t *Tile) bool) []*Tile {
+ tiles := []*Tile{}
+ for y := 0; y < len(m.Tiles); y++ {
+ for x := 0; x < len(m.Tiles[y]); x++ {
+ tile := &m.Tiles[y][x]
+ if filter(tile) {
+ tiles = append(tiles, tile)
+ }
+ }
+ }
+ return tiles
+}
+
+func (m *Map) AllTiles() []*Tile {
+ return m.FilterTiles(func(*Tile) bool { return true })
+}
+
+func (m *Map) distributeStoreCards(cards PileOfCards, rand *rand.Rand) {
+ nStores := len(m.Stores)
+ stores := make([]*Store, 0, nStores)
+ for _, store := range m.Stores {
+ stores = append(stores, store)
+ }
+
+ d := NewDeckFrom(cards)
+ d.Shuffle(rand)
+ nCards := d.Size()
+
+ for i := 0; i < nCards; i++ {
+ c := d.DrawOne()
+ stores[i%nStores].AddCard(c)
+ }
+}
+
+func (m *Map) redistributeStoreCards(cards PileOfCards, rand *rand.Rand) {
+ poc := NewPileOfCards()
+ for _, store := range m.Stores {
+ store.MoveInto(poc)
+ }
+ m.distributeStoreCards(poc, rand)
+}
+
+func (m *Map) HasStores() bool {
+ return len(m.Stores) > 0
+}
+
+func (m *Map) StoreOn(p Position) *Store {
+ return m.Stores[p]
+}
diff --git a/go/game/map_test.go b/go/game/map_test.go
new file mode 100644
index 00000000..6972f867
--- /dev/null
+++ b/go/game/map_test.go
@@ -0,0 +1,85 @@
+package game
+
+import (
+ "strings"
+ "testing"
+)
+
+func parseMapString(t *testing.T, mapDef string) *Map {
+ r := strings.NewReader(mapDef)
+ m, err := readMap(r)
+ if err != nil {
+ t.Fatalf("Parsing failed with: %v", err)
+ }
+ return m
+}
+
+func TestParseMapEndingStreets(t *testing.T) {
+ mapDef := `map: |1-
+ HST
+ HSF
+ TST
+
+symbols:
+ T: tower
+ H: house
+ F: farm
+ S: street
+`
+
+ parseMapString(t, mapDef)
+}
+
+func TestParseMap(t *testing.T) {
+ s := `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
+
+kings:
+ - (1,9)
+ - (9,1)`
+
+ m := parseMapString(t, s)
+
+ if m.Tiles[0][0].Type != TileTypes.Tower {
+ t.Fatalf("Unexpected tile at 0,0: %v (expected %v)", m.Tiles[0][0], TileTypes.Tower)
+ }
+
+ for y := 0; y < len(m.Tiles); y++ {
+ for x := 0; x < len(m.Tiles[y]); x++ {
+ if m.TileAt(Position{x, y}) != &m.Tiles[y][x] {
+ t.Fatalf("TileAt and Tiles differ")
+ }
+ }
+ }
+
+ if m.ResourceGain != DEFAULT_RESOURCE_GAIN {
+ t.Fatalf("resource gain is not the default one")
+ }
+}
+
+func TestLoadingAllMaps(t *testing.T) {
+ maps := []string{"the-tyrant", "2P-ring-street", "2P-river-king", "3P-ring-street", "4P-islands", "the-kraken"}
+
+ for _, m := range maps {
+ _, err := LoadMap("../../maps/" + m + ".yml")
+ if err != nil {
+ t.Fatalf("Loading map %s failed with %s", m, err)
+ }
+ }
+}
diff --git a/go/game/movement.go b/go/game/movement.go
new file mode 100644
index 00000000..1a3f03c4
--- /dev/null
+++ b/go/game/movement.go
@@ -0,0 +1,52 @@
+package game
+
+import (
+ "fmt"
+ "log"
+ "strconv"
+ "strings"
+)
+
+type Movement struct {
+ Range int
+ Swimming bool
+}
+
+func INVALID_MOVEMENT() Movement {
+ return Movement{-1, false}
+}
+
+func parseMovement(desc interface{}) Movement {
+ var movementStr string
+ switch typedDesc := desc.(type) {
+ case int:
+ return Movement{typedDesc, false}
+ case string:
+ movementStr = typedDesc
+ case map[string]interface{}:
+ movementStr = typedDesc["en"].(string)
+ }
+
+ tokens := strings.Split(movementStr, " ")
+ r, err := strconv.Atoi(tokens[0])
+ if err != nil {
+ log.Fatalf("Invalid movement description %s", movementStr)
+ }
+
+ swimming := false
+ if len(tokens) > 1 && tokens[1] == "swimming" {
+ swimming = true
+ }
+
+ return Movement{r, swimming}
+}
+
+func (m Movement) String() string {
+ if m == INVALID_MOVEMENT() {
+ return "invalid movement"
+ }
+ if m.Swimming {
+ return fmt.Sprintf("%d swimming", m.Range)
+ }
+ return fmt.Sprintf("%d", m.Range)
+}
diff --git a/go/game/permanent.go b/go/game/permanent.go
new file mode 100644
index 00000000..b4090592
--- /dev/null
+++ b/go/game/permanent.go
@@ -0,0 +1,131 @@
+package game
+
+import (
+ "fmt"
+ "log"
+ "strconv"
+ "strings"
+
+ "golang.org/x/exp/slices"
+)
+
+type Permanent interface {
+ Card() *Card
+ Tile() *Tile
+ Damage() int
+ Pile() []Permanent
+ Controller() *Player
+ Owner() *Player
+ Effects() []string
+ HasEffect(string) bool
+ XEffect(string) (int, error)
+
+ String() string
+ Fight(p Permanent)
+ AddDamage(damage int)
+ IsDestroyed() bool
+ Attackable() bool
+ AvailActions() []Action
+ IsAvailableTile(*Tile) bool
+}
+
+type PermanentBase struct {
+ card *Card
+ tile *Tile
+ damage int
+ pile []Permanent
+ controller *Player
+ owner *Player
+ tmpEffects []string
+}
+
+func NewPermanentBase(card *Card, tile *Tile, owner *Player) PermanentBase {
+ if tile.Permanent != nil {
+ log.Fatalf("Tile at %v already occupied by %v", tile.Position, tile.Permanent)
+ }
+
+ return PermanentBase{
+ card: card,
+ tile: tile,
+ controller: owner,
+ owner: owner,
+ }
+}
+
+func (p *PermanentBase) Card() *Card {
+ return p.card
+}
+
+func (p *PermanentBase) Tile() *Tile {
+ return p.tile
+}
+
+func (p *PermanentBase) Damage() int {
+ return p.damage
+}
+
+func (p *PermanentBase) Pile() []Permanent {
+ return p.pile
+}
+
+func (p *PermanentBase) Controller() *Player {
+ return p.controller
+}
+
+func (p *PermanentBase) Owner() *Player {
+ return p.owner
+}
+
+func (p *PermanentBase) String() string {
+ return fmt.Sprintf("%s's %s@%s",
+ p.Controller().Name, p.Card().Name, p.Tile().Position.String())
+}
+
+func DealDamage(src, dest Permanent, damage int) {
+ actualDamage := damage
+ shield, err := dest.XEffect("shield")
+ if err != nil {
+ // TODO: implement piercing
+
+ actualDamage -= shield
+ if actualDamage < 0 {
+ actualDamage = 0
+ }
+ }
+ dest.AddDamage(actualDamage)
+}
+
+func (p *PermanentBase) AddDamage(damage int) {
+ p.damage += damage
+}
+
+func (p *PermanentBase) Effects() []string {
+ return append(p.card.getEffects(), p.tmpEffects...)
+}
+
+func (p *PermanentBase) HasEffect(effect string) bool {
+ return slices.Contains(p.tmpEffects, effect) || p.card.hasEffect(effect)
+}
+
+func (p *PermanentBase) XEffect(effect string) (int, error) {
+ x, err := p.card.getXEffect(effect)
+
+ for _, e := range p.tmpEffects {
+ if !strings.Contains(e, effect) {
+ continue
+ }
+
+ tokens := strings.SplitN(e, " ", 2)
+ tmpX, err := strconv.Atoi(tokens[1])
+ if err != nil {
+ return 0, err
+ }
+ x += tmpX
+ }
+
+ return x, err
+}
+
+func (p *PermanentBase) IsAvailableTile(tile *Tile) bool {
+ return tile.IsAvailableForCard(p.card)
+}
diff --git a/go/game/phases.go b/go/game/phases.go
new file mode 100644
index 00000000..ea239278
--- /dev/null
+++ b/go/game/phases.go
@@ -0,0 +1,42 @@
+package game
+
+type PhaseType int
+
+const (
+ drawPhase = iota
+ upkeepPhase
+ actionPhase
+ buyPhase
+ discardPhase
+)
+
+func (p PhaseType) String() string {
+ switch p {
+ case drawPhase:
+ return "Draw"
+ case upkeepPhase:
+ return "Upkeep"
+ case actionPhase:
+ return "Actions"
+ case buyPhase:
+ return "Buy"
+ case discardPhase:
+ return "Discard"
+ }
+
+ return ""
+}
+
+var Phases = struct {
+ DrawPhase PhaseType
+ UpkeepPhase PhaseType
+ ActionPhase PhaseType
+ BuyPhase PhaseType
+ DiscardPhase PhaseType
+}{
+ DrawPhase: drawPhase,
+ UpkeepPhase: upkeepPhase,
+ ActionPhase: actionPhase,
+ BuyPhase: buyPhase,
+ DiscardPhase: discardPhase,
+}
diff --git a/go/game/pileOfCards.go b/go/game/pileOfCards.go
new file mode 100644
index 00000000..c8b81c87
--- /dev/null
+++ b/go/game/pileOfCards.go
@@ -0,0 +1,63 @@
+package game
+
+type PileOfCards interface {
+ Size() int
+ IsEmpty() bool
+ Cards() []*Card
+ AddCards(cards []*Card)
+ AddCard(card *Card)
+ RemoveCard(card *Card)
+ MoveCard(card *Card, into PileOfCards)
+ MoveInto(into PileOfCards)
+}
+
+type PileOfCardsBase struct {
+ cards []*Card
+}
+
+func NewPileOfCards() *PileOfCardsBase {
+ return &PileOfCardsBase{[]*Card{}}
+}
+
+func (poc *PileOfCardsBase) Size() int {
+ return len(poc.cards)
+}
+
+func (poc *PileOfCardsBase) IsEmpty() bool {
+ return poc.Size() == 0
+}
+
+func (poc *PileOfCardsBase) Cards() []*Card {
+ return poc.cards
+}
+
+func (poc *PileOfCardsBase) AddCards(cards []*Card) {
+ poc.cards = append(poc.cards, cards...)
+}
+
+func (poc *PileOfCardsBase) AddCard(card *Card) {
+ poc.cards = append(poc.cards, card)
+}
+
+func (poc *PileOfCardsBase) RemoveCard(card *Card) {
+ ncards := poc.Size()
+ for i, c := range poc.cards {
+ if c != card {
+ continue
+ }
+
+ poc.cards[i] = poc.cards[ncards-1]
+ poc.cards = poc.cards[:ncards-1]
+ return
+ }
+}
+
+func (poc *PileOfCardsBase) MoveCard(c *Card, into PileOfCards) {
+ poc.RemoveCard(c)
+ into.AddCard(c)
+}
+
+func (poc *PileOfCardsBase) MoveInto(into PileOfCards) {
+ into.AddCards(poc.cards)
+ poc.cards = []*Card{}
+}
diff --git a/go/game/player.go b/go/game/player.go
new file mode 100644
index 00000000..0eb474e9
--- /dev/null
+++ b/go/game/player.go
@@ -0,0 +1,140 @@
+package game
+
+import (
+ "image/color"
+ "log"
+)
+
+const (
+ MAX_DRAW int = 3
+)
+
+type Player struct {
+ Id int
+ Name string
+ Turn int
+ Resource int
+ DrawPerTurn int
+ Hand *Hand
+ DiscardPile *DiscardPile
+ Deck *Deck
+ Store *Store
+ gameState *State
+ Color color.Color
+ Ctrl PlayerControl
+}
+
+func NewPlayer(id int, name string, deck *Deck, gameState *State) *Player {
+ store := NewStore()
+ deck.MoveInto(store)
+
+ p := Player{
+ Id: id,
+ Name: name,
+ Turn: 0,
+ Resource: 0,
+ DrawPerTurn: MAX_DRAW,
+ Hand: NewHand(),
+ DiscardPile: NewDiscardPile(),
+ Deck: NewDeckFromDeckList(gameState.Map.StartDeckList),
+ Store: store,
+ gameState: gameState,
+ Ctrl: nil,
+ }
+ return &p
+}
+
+func (p *Player) resourceGain() int {
+ if p.Name == "The kraken" {
+ return p.gameState.Map.ResourceGain * (len(p.gameState.Players) - 1)
+ } else {
+ return p.gameState.Map.ResourceGain
+ }
+}
+
+func (p *Player) gainResource(gain int) {
+ p.Resource += gain
+}
+
+func (p *Player) Upkeep() {
+ p.gainResource(p.resourceGain())
+
+ // TODO: handle upkeep triggers
+
+ for _, unit := range p.gameState.Units {
+ if unit.controller != p {
+ continue
+ }
+
+ // TODO: let players decide if they want to pay the upkeep
+ p.Resource -= unit.Upkeep
+
+ // TODO: better refresh unit actions
+ unit.AvailAttackActions = 1
+ unit.AvailMoveActions = 1
+ }
+}
+
+func (p *Player) ActionPhase() {
+ for {
+ a := promptAction(p.Ctrl)
+ if _, ok := a.(*PassPriority); ok {
+ return
+ }
+ p.gameState.DeclareAction(a)
+ p.gameState.Stack.Resolve()
+ }
+}
+
+func (p *Player) BuyPhase() {
+ a := promptBuy(p.Ctrl)
+ if _, ok := a.(*PassPriority); ok {
+ return
+ }
+ p.gameState.DeclareAction(a)
+ p.gameState.Stack.Resolve()
+}
+
+func (p *Player) DiscardCard(c *Card) {
+ p.Hand.MoveCard(c, p.DiscardPile)
+}
+
+func (p *Player) DiscardPhase() {
+ cards := p.PromptHandCardSelection(0, p.Hand.Size())
+ for _, c := range cards {
+ p.DiscardCard(c)
+ }
+}
+
+func (p *Player) PromptHandCardSelection(min, max int) []*Card {
+ p.Ctrl.SendNotification(NewHandCardSelectionPrompt(min, max))
+ switch a := p.Ctrl.RecvAction().(type) {
+ case *HandCardSelection:
+ // TODO: validate cards
+ return a.cards
+ case *PassPriority:
+ return nil
+ default:
+ log.Fatalf("Unexpected response type %T for hand card selection", a)
+ }
+ return nil
+}
+
+func (p *Player) Draw() {
+ toDraw := p.DrawPerTurn - p.Hand.Size()
+ if toDraw < 1 {
+ return
+ }
+
+ p.DrawN(toDraw)
+}
+
+func (p *Player) DrawN(n int) {
+ drawnCards := p.Deck.Draw(n)
+ p.Hand.AddCards(drawnCards)
+}
+
+func (p *Player) IsEnemy(other *Player) bool {
+ // TODO: support coop / teams
+ return p != other
+}
diff --git a/go/game/playerControl.go b/go/game/playerControl.go
new file mode 100644
index 00000000..e62d91b3
--- /dev/null
+++ b/go/game/playerControl.go
@@ -0,0 +1,92 @@
+package game
+
+type PlayerNotificationType int
+
+const (
+ DeclaredActionNotification = iota
+ ResolvedActionNotification
+ PriorityNotification
+ UpkeepPrompt
+ BuyPrompt
+ HandCardSelectionPrompt
+)
+
+func (n PlayerNotification) IsPriorityNotification() bool {
+ return n.Notification == PriorityNotification
+}
+
+type PlayerNotification struct {
+ Notification PlayerNotificationType
+ Context interface{}
+ Error error
+}
+
+func NewPriorityNotification() PlayerNotification {
+ return PlayerNotification{PriorityNotification, nil, nil}
+}
+
+func NewDeclaredActionNotification(a Action, err error) PlayerNotification {
+ return PlayerNotification{DeclaredActionNotification, a, err}
+}
+
+func NewResolvedActionNotification(a Action, err error) PlayerNotification {
+ return PlayerNotification{ResolvedActionNotification, a, err}
+}
+
+func NewUpkeepPrompt(u *Unit) PlayerNotification {
+ return PlayerNotification{ResolvedActionNotification, u, nil}
+}
+
+func NewBuyPrompt() PlayerNotification {
+ return PlayerNotification{BuyPrompt, nil, nil}
+}
+
+func NewHandCardSelectionPrompt(min, max int) PlayerNotification {
+ return PlayerNotification{HandCardSelectionPrompt, []int{min, max}, nil}
+}
+
+type PlayerControl interface {
+ RecvAction() Action
+ SendNotification(PlayerNotification)
+}
+
+type DummyPlayerControl struct{ P *Player }
+
+func (c *DummyPlayerControl) RecvAction() Action { return NewPassPriority(c.P) }
+func (*DummyPlayerControl) SendNotification(PlayerNotification) {}
+
+type ChanPlayerControl struct {
+ Actions chan Action
+ Notifications chan PlayerNotification
+}
+
+func (c *ChanPlayerControl) SendAction(a Action) { c.Actions <- a }
+func (c *ChanPlayerControl) RecvAction() Action { return <-c.Actions }
+func (c *ChanPlayerControl) SendNotification(n PlayerNotification) { c.Notifications <- n }
+func (c *ChanPlayerControl) RecvNotification() *PlayerNotification {
+ select {
+ case n := <-c.Notifications:
+ return &n
+ default:
+ return nil
+ }
+}
+
+func NewChanPlayerControl() *ChanPlayerControl {
+ a := make(chan Action)
+ n := make(chan PlayerNotification)
+ return &ChanPlayerControl{a, n}
+}
+
+func prompt(ctrl PlayerControl, notification PlayerNotification) Action {
+ ctrl.SendNotification(NewPriorityNotification())
+ return ctrl.RecvAction()
+}
+
+func promptBuy(ctrl PlayerControl) Action {
+ return prompt(ctrl, NewBuyPrompt())
+}
+
+func promptAction(ctrl PlayerControl) Action {
+ return prompt(ctrl, NewPriorityNotification())
+}
diff --git a/go/game/pos.go b/go/game/pos.go
new file mode 100644
index 00000000..2a646a83
--- /dev/null
+++ b/go/game/pos.go
@@ -0,0 +1,22 @@
+package game
+
+import (
+ "fmt"
+)
+
+type Position struct {
+ X int
+ Y int
+}
+
+func (p *Position) String() string {
+ if *p == INVALID_POSITION() {
+ return "INVALID_POSITION"
+ }
+
+ return fmt.Sprintf("(%d, %d)", p.X, p.Y)
+}
+
+func INVALID_POSITION() Position {
+ return Position{-1, -1}
+}
diff --git a/go/game/range.go b/go/game/range.go
new file mode 100644
index 00000000..ab86c44e
--- /dev/null
+++ b/go/game/range.go
@@ -0,0 +1,58 @@
+package game
+
+import (
+ "math"
+)
+
+func IsPositionInRange(origin Position, pos Position, r int) bool {
+ x, y := float64(pos.X-origin.X), float64(pos.Y-origin.Y)
+ ax, ay := math.Abs(x), math.Abs(y)
+ if ax > float64(r) || ay > float64(r) {
+ return false
+ }
+
+ var abs float64
+ if pos.X > pos.Y {
+ abs = ax + math.Floor(ay/2)
+ } else {
+ abs = ay + math.Floor(ax/2)
+ }
+
+ return abs <= float64(r)
+}
+
+func PositionsInRange(origin Position, r int, includeOrigin bool) []Position {
+ positions := []Position{}
+ for x := -r; x <= r; x++ {
+ for y := -r; y <= r; y++ {
+ pos := Position{origin.X + x, origin.Y + y}
+ if pos.X < 0 || pos.Y < 0 {
+ continue
+ }
+
+ if !includeOrigin && pos == origin {
+ continue
+ }
+
+ if IsPositionInRange(origin, pos, r) {
+ positions = append(positions, pos)
+ }
+ }
+ }
+ return positions
+}
+
+func TilesInRangeFromOrigin(m *Map, origin Position, r int) []*Tile {
+ tiles := []*Tile{}
+ for _, pos := range PositionsInRange(origin, r, false) {
+ tile := m.TileAt(pos)
+ if tile != nil {
+ tiles = append(tiles, tile)
+ }
+ }
+ return tiles
+}
+
+func TilesInRange(m *Map, p Permanent, r int) []*Tile {
+ return TilesInRangeFromOrigin(m, p.Tile().Position, r)
+}
diff --git a/go/game/range_test.go b/go/game/range_test.go
new file mode 100644
index 00000000..4b6e05ad
--- /dev/null
+++ b/go/game/range_test.go
@@ -0,0 +1,61 @@
+package game
+
+import (
+ "testing"
+
+ "golang.org/x/exp/slices"
+)
+
+func TestPositionsInRange(t *testing.T) {
+ origin := Position{0, 0}
+ positionsInRange1 := []Position{{0, 1}, {1, 0}, {1, 1}}
+ calculatedPositions := PositionsInRange(origin, 1, false)
+ if len(positionsInRange1) != len(calculatedPositions) {
+ t.Fatalf("Unexpected number of calculated positions")
+ }
+
+ for _, p := range positionsInRange1 {
+ if !slices.Contains(calculatedPositions, p) {
+ t.Fatalf("Position %v not in calculated positions", p)
+ }
+ }
+
+ origin = Position{2, 2}
+ positionsInRange2 := []Position{
+ {0, 1},
+ {0, 2},
+ {0, 3},
+ {1, 0},
+ {1, 1},
+ {1, 2},
+ {1, 3},
+ {1, 4},
+ {2, 0},
+ {2, 1},
+ {2, 3},
+ {2, 4},
+ {3, 0},
+ {3, 1},
+ {3, 2},
+ {3, 3},
+ {3, 4},
+ {4, 1},
+ {4, 2},
+ {4, 3},
+ }
+ calculatedPositions = PositionsInRange(origin, 2, false)
+
+ for _, p := range positionsInRange2 {
+ if !slices.Contains(calculatedPositions, p) {
+ t.Fatalf("Position %v not in calculated positions", p)
+ }
+ }
+}
+
+func TestPositionNotInRange(t *testing.T) {
+ o := Position{5, 0}
+ p := Position{6, 3}
+ if IsPositionInRange(o, p, 2) {
+ t.Fatalf("Invalid range inclusion %v not in %v", o, PositionsInRange(o, 2, false))
+ }
+}
diff --git a/go/game/set.go b/go/game/set.go
new file mode 100644
index 00000000..b0565eca
--- /dev/null
+++ b/go/game/set.go
@@ -0,0 +1,78 @@
+package game
+
+import (
+ "log"
+)
+
+type SetIdentifier int
+
+const (
+ Base SetIdentifier = iota + 1
+ Magic
+ Equipments
+ Misc
+ Nautics
+ Potions
+ Kraken
+ Tyrant
+ Exp1
+)
+
+func (si SetIdentifier) String() string {
+ switch si {
+ case Base:
+ return "base"
+ case Magic:
+ return "magic"
+ case Equipments:
+ return "equipments"
+ case Misc:
+ return "misc"
+ case Nautics:
+ return "nautics"
+ case Potions:
+ return "potions"
+ case Kraken:
+ return "kraken"
+ case Tyrant:
+ return "tyrant"
+ case Exp1:
+ return "exp1"
+ }
+ log.Panicf("Switch not exhausting %d", si)
+ return ""
+}
+
+var SetIdentifiers = struct {
+ Base SetIdentifier
+ Magic SetIdentifier
+ Equipments SetIdentifier
+ Misc SetIdentifier
+ Nautics SetIdentifier
+ Potions SetIdentifier
+ Kraken SetIdentifier
+ Tyrant SetIdentifier
+ Exp1 SetIdentifier
+}{
+ Base: Base,
+ Magic: Magic,
+ Equipments: Equipments,
+ Misc: Misc,
+ Nautics: Nautics,
+ Potions: Potions,
+ Kraken: Kraken,
+ Tyrant: Tyrant,
+ Exp1: Exp1,
+}
+
+var SetIdentifierNames = map[string]SetIdentifier{
+ "base": Base,
+ "magic": Magic,
+ "equipments": Equipments,
+ "misc": Misc,
+ "nautics": Nautics,
+ "potions": Potions,
+ "kraken": Kraken,
+ "tyrant": Tyrant,
+ "exp1": Exp1,
+}
diff --git a/go/game/stack.go b/go/game/stack.go
new file mode 100644
index 00000000..9132ae13
--- /dev/null
+++ b/go/game/stack.go
@@ -0,0 +1,46 @@
+package game
+
+import (
+ "log"
+)
+
+type Stack struct {
+ gameState *State
+ Actions []Action
+}
+
+func NewStack(s *State) *Stack {
+ return &Stack{s, []Action{}}
+}
+
+func (s *Stack) IsEmpty() bool {
+ return len(s.Actions) == 0
+}
+
+func (s *Stack) Push(a Action) {
+ s.Actions = append(s.Actions, a)
+}
+
+func (s *Stack) Pop() {
+ l := len(s.Actions)
+ if l == 0 {
+ log.Fatalf("Can not pop from empty stack")
+ }
+
+ a := s.Actions[l-1]
+ s.Actions = s.Actions[:l-1]
+
+ err := s.gameState.ValidateAction(a)
+ if err == nil {
+ a.Resolve(s.gameState)
+ }
+ log.Println("Resolved", a, err)
+ s.gameState.broadcastNotification(NewResolvedActionNotification(a, err))
+}
+
+func (s *Stack) Resolve() {
+ for !s.IsEmpty() {
+ s.gameState.AllPassing(false)
+ s.Pop()
+ }
+}
diff --git a/go/game/state.go b/go/game/state.go
new file mode 100644
index 00000000..1d4b644f
--- /dev/null
+++ b/go/game/state.go
@@ -0,0 +1,453 @@
+package game
+
+import (
+ "fmt"
+ "log"
+ "math/rand"
+ "strconv"
+ "strings"
+ "time"
+
+ "golang.org/x/exp/slices"
+)
+
+type State struct {
+ Decks []*Deck
+ Stores []*Store
+ Exile *PileOfCardsBase
+ Map *Map
+ Stack *Stack
+ Hands []*Hand
+ DiscardPiles []*DiscardPile
+ Players []*Player
+ ActivePlayer *Player
+ ActivePhase PhaseType
+ Permanents []Permanent
+ Units []*Unit
+ Artifacts []*Artifact
+ Rand *rand.Rand
+}
+
+func NewState() *State {
+ s := &State{}
+ s.Stack = NewStack(s)
+ s.Exile = NewPileOfCards()
+ s.Rand = rand.New(rand.NewSource(time.Now().UnixNano()))
+ return s
+}
+
+func (s *State) AddNewPlayer(name string, deck *Deck) {
+ p := NewPlayer(len(s.Players)+1, name, deck, s)
+ s.Players = append(s.Players, p)
+}
+
+func (s *State) IsActivePlayer(p *Player) bool {
+ return s.ActivePlayer == p
+}
+
+func (s *State) Loop() []*Player {
+ winners := []*Player{}
+
+ if s.Map.HasStores() {
+ poc := NewPileOfCards()
+ for _, p := range s.Players {
+ if p.IsKraken() {
+ continue
+ }
+
+ p.Store.MoveInto(poc)
+ }
+ s.Map.distributeStoreCards(poc, s.Rand)
+ }
+
+ for len(winners) == 0 {
+ for _, p := range s.Players {
+ s.ActivePlayer = p
+ p.Turn++
+
+ if p.Deck.IsEmpty() {
+ log.Printf("Shuffle %s pile into the deck\n", p.Name)
+ p.DiscardPile.MoveInto(p.Deck)
+ p.Deck.Shuffle(s.Rand)
+ }
+
+ s.ActivePhase = Phases.DrawPhase
+ p.Draw()
+
+ s.ActivePhase = Phases.UpkeepPhase
+ p.Upkeep()
+ s.PhaseChange()
+
+ s.ActivePhase = Phases.ActionPhase
+ p.ActionPhase()
+ s.PhaseChange()
+
+ s.ActivePhase = Phases.BuyPhase
+ p.BuyPhase()
+ s.PhaseChange()
+
+ s.ActivePhase = Phases.DiscardPhase
+ p.DiscardPhase()
+
+ winners = s.Map.WinCondition(s)
+ }
+ }
+
+ return winners
+}
+
+func (s *State) IsValidPlay(a *PlayAction) error {
+ if s.ActivePhase != Phases.ActionPhase {
+ log.Fatalf("Play actions can only be declared during one's actions phase")
+ }
+
+ if a.Card.IsPermanent() {
+ spawn := a.Targets().sel[0].(*Tile)
+ if !slices.Contains(s.AvailableSpawnTiles(a.Source().(*Player), a.Card), spawn) {
+ return fmt.Errorf("Spawn %v is not a valid spawn tile for %s",
+ spawn.Position.String(), a.Card.Name)
+ }
+ }
+
+ return nil
+}
+
+func (s *State) IsValidMove(a *MoveAction) error {
+ if s.ActivePhase != Phases.ActionPhase {
+ return fmt.Errorf("Move actions can only be declared during one's actions phase")
+ }
+
+ u := a.Source().(*Unit)
+ tile := a.Targets().sel[0].(*Tile)
+
+ if !slices.Contains(u.MoveRangeTiles(s.Map), tile) {
+ return fmt.Errorf("Unit %s at %s can not move to %s",
+ u.card.Name, u.tile.Position.String(), tile.Position.String())
+ }
+
+ if tile.Permanent != nil {
+ return fmt.Errorf("Unit %s at %s can not move to occupied %s",
+ u.card.Name, u.tile.Position.String(), tile.Position.String())
+ }
+
+ return nil
+}
+
+func (s *State) IsValidAttack(a *AttackAction) error {
+ if s.ActivePhase != Phases.ActionPhase {
+ return fmt.Errorf("Attack actions can only be declared during one's actions phase")
+ }
+
+ unit := a.Source().(Unit)
+ if unit.Attack == INVALID_ATTACK() {
+ return fmt.Errorf("%s can not attack", unit.card.Name)
+ }
+
+ target := a.Targets().sel[0].(Permanent)
+ if !IsPositionInRange(unit.tile.Position, target.Tile().Position, unit.Attack.Range) {
+ return fmt.Errorf("Attack target on %s is not in attack range %d of %s on %s",
+ target.Tile().Position.String(), unit.Attack.Range, unit.card.Name,
+ unit.tile.Position.String())
+ }
+
+ return nil
+}
+
+func (s *State) isValidBuy(a *BuyAction) error {
+ if s.Map.HasStores() {
+ var storeTiles []*Tile
+ for pos, store := range s.Map.Stores {
+ if slices.Contains(store.cards, a.card) {
+ storeTiles = append(storeTiles, s.Map.TileAt(pos))
+ }
+ }
+
+ if storeTiles == nil {
+ return fmt.Errorf("no store contains %s", a.card.Name)
+ }
+
+ controlsUnitOnStore := false
+ for _, tile := range storeTiles {
+ controlsUnitOnStore = controlsUnitOnStore || (tile.Permanent != nil &&
+ tile.Permanent.Card().Type == CardTypes.Unit &&
+ tile.Permanent.Controller() == a.player)
+ }
+ if !controlsUnitOnStore {
+ return fmt.Errorf("%s' controls no unit on a store containing %s",
+ a.player.Name, a.card.Name)
+ }
+
+ } else if !slices.Contains(a.player.Store.cards, a.card) {
+ return fmt.Errorf("%s's store does not contain %s", a.player.Name, a.card.Name)
+ }
+
+ return nil
+}
+
+func (s *State) ValidateAction(a Action) (err error) {
+ err = a.CheckTargets(s)
+ if err != nil {
+ return
+ }
+
+ switch a := a.(type) {
+ case *PlayAction:
+ err = s.IsValidPlay(a)
+ case *MoveAction:
+ err = s.IsValidMove(a)
+ case *AttackAction:
+ err = s.IsValidAttack(a)
+ case *BuyAction:
+ }
+
+ return
+}
+
+func (s *State) PushAction(a Action) {
+ if !a.PayCosts(s) {
+ log.Fatalf("Cost for %s could not be paid", a)
+ }
+
+ s.Stack.Push(a)
+}
+
+func (s *State) DeclareAction(a Action) {
+ var err error = nil
+
+ err = a.CheckTargets(s)
+
+ if err != nil {
+ switch a := a.(type) {
+ case *BuyAction:
+ if s.ActivePhase != Phases.BuyPhase {
+ err = fmt.Errorf("Cards can only be bought during one's buy phase")
+ }
+ if !a.card.IsBuyable() {
+ err = fmt.Errorf("Card %s is not buyable", a.card.Name)
+ }
+ case *FreeAction:
+ default:
+ if !s.Stack.IsEmpty() {
+ err = fmt.Errorf("Slow action can not be declared while other actions are on the stack")
+ }
+ }
+ }
+
+ if err == nil {
+ s.PushAction(a)
+ }
+
+ s.broadcastNotification(NewDeclaredActionNotification(a, err))
+}
+
+func (s *State) AllPassing(skipFirst bool) bool {
+ nPlayers := len(s.Players)
+ i := 0
+ if skipFirst {
+ i++
+ }
+ for ; i < nPlayers; i++ {
+ p := s.Players[(i+s.ActivePlayer.Id-1)%nPlayers]
+ p.Ctrl.SendNotification(NewPriorityNotification())
+ a := p.Ctrl.RecvAction()
+ switch a.(type) {
+ case *PassPriority:
+ log.Printf("%s passed %d/%d\n", p.Name, i+1, nPlayers)
+ default:
+ s.DeclareAction(a)
+ return false
+ }
+ }
+ return true
+}
+
+func (s *State) PhaseChange() {
+ skipFirst := true
+ for !s.AllPassing(skipFirst) {
+ s.Stack.Resolve()
+ skipFirst = false
+ }
+}
+
+func (s *State) broadcastNotification(n PlayerNotification) {
+ for _, p := range s.Players {
+ p.Ctrl.SendNotification(n)
+ }
+}
+
+func (s *State) AddPermanent(perm Permanent) {
+ s.Permanents = append(s.Permanents, perm)
+ switch p := perm.(type) {
+ case *Unit:
+ s.Units = append(s.Units, p)
+ case *Artifact:
+ s.Artifacts = append(s.Artifacts, p)
+ }
+}
+
+func (s *State) AddNewUnit(card *Card, pos Position, owner *Player) {
+ s.AddPermanent(NewUnit(card, s.Map.TileAt(pos), owner))
+}
+
+func (s *State) AddNewArtifact(card *Card, pos Position, owner *Player) {
+ s.AddPermanent(NewArtifact(card, s.Map.TileAt(pos), owner))
+}
+
+func (s *State) AddNewPermFromPath(cardPath string, pos Position, owner *Player) {
+ card := NewCard(cardPath)
+ switch card.Type {
+ case CardTypes.Unit:
+ s.AddNewUnit(card, pos, owner)
+ case CardTypes.Artifact:
+ s.AddNewArtifact(card, pos, owner)
+ }
+}
+
+func (s *State) removePermanent(p Permanent) {
+ for i, perm := range s.Permanents {
+ if perm == p {
+ s.Permanents[i] = s.Permanents[len(s.Permanents)-1]
+ s.Permanents = s.Permanents[:len(s.Permanents)-1]
+ }
+ }
+}
+
+func removePtr[P *Unit | *Artifact](collection []P, ptr P) []P {
+ for i, p := range collection {
+ if p == ptr {
+ collection[i] = collection[len(collection)-1]
+ return collection[:len(collection)-1]
+ }
+ }
+ return collection
+}
+
+func (s *State) DestroyPermanent(p Permanent) {
+ s.removePermanent(p)
+
+ switch p := p.(type) {
+ case *Unit:
+ s.Units = removePtr(s.Units, p)
+ case *Artifact:
+ s.Artifacts = removePtr(s.Artifacts, p)
+ }
+ p.Owner().DiscardPile.AddCard(p.Card())
+}
+
+func (s *State) Fight(p1, p2 Permanent) {
+ p1.Fight(p2)
+ p2.Fight(p1)
+ if p1.IsDestroyed() {
+ s.DestroyPermanent(p1)
+ }
+ if p2.IsDestroyed() {
+ s.DestroyPermanent(p2)
+ }
+}
+
+func (s *State) MoveUnit(u *Unit, t *Tile) {
+ u.move(s, t)
+}
+
+func (s *State) FilterUnits(filter func(*Unit) bool) []*Unit {
+ units := []*Unit{}
+ for _, u := range s.Units {
+ if filter(u) {
+ units = append(units, u)
+ }
+ }
+ return units
+}
+
+func (s *State) OwnUnits(player *Player) []*Unit {
+ return s.FilterUnits(func(u *Unit) bool {
+ return u.Controller() == player
+ })
+}
+
+func (s *State) EnemyUnits(player *Player) []*Unit {
+ return s.FilterUnits(func(u *Unit) bool {
+ return u.Controller().IsEnemy(player)
+ })
+}
+
+func (s *State) resolvePlay(p *Player, c *Card, targets *Targets) {
+ switch c.Type {
+ case CardTypes.Unit:
+ tile := targets.sel[0].(*Tile)
+ s.AddNewUnit(c, tile.Position, p)
+ case CardTypes.Artifact:
+ tile := targets.sel[0].(*Tile)
+ s.AddNewArtifact(c, tile.Position, p)
+ default:
+ log.Printf("Resolving unhandled card type: %s\n", c.Type.String())
+ }
+
+ if c.IsPermanent() {
+ } else {
+ p.DiscardPile.AddCard(c)
+ }
+}
+
+func (s *State) SpawnTiles(p *Player) []*Tile {
+ spawns := s.Map.FilterTiles(func(t *Tile) bool {
+ if t.Type != TileTypes.Spawn {
+ return false
+ }
+
+ return strings.HasSuffix(t.Raw, strconv.Itoa(p.Id)) || t.Raw == "player spawn"
+ })
+
+ return spawns
+}
+
+func (s *State) WaterSpawnTiles(p *Player) []*Tile {
+ if p.IsKraken() {
+ return s.KrakenSpawnTiles()
+ }
+
+ docks := s.Map.FilterTiles(func(t *Tile) bool {
+ if t.Type != TileTypes.Docks {
+ return false
+ }
+
+ return t.Raw == "docks" || strings.HasSuffix(t.Raw, strconv.Itoa(p.Id))
+ })
+
+ spawns := []*Tile{}
+ for _, dock := range docks {
+ for _, candidate := range TilesInRangeFromOrigin(s.Map, dock.Position, 1) {
+ if candidate.Water {
+ spawns = append(spawns, candidate)
+ }
+ }
+ }
+
+ return spawns
+}
+
+func (s *State) AvailableSpawnTiles(p *Player, c *Card) []*Tile {
+ if !c.IsPermanent() {
+ log.Fatalf("AvailableSpawnTiles called for %s", c.Type.String())
+ }
+
+ candidates := c.Impl.spawnTiles(s, p)
+ if candidates == nil {
+ // Default spawn tiles
+ if c.hasPlacementConstrain("swimming") {
+ candidates = s.WaterSpawnTiles(p)
+ } else {
+ candidates = s.SpawnTiles(p)
+ }
+ }
+
+ tiles := []*Tile{}
+
+ for _, candidate := range candidates {
+ if candidate.IsAvailableForCard(c) {
+ tiles = append(tiles, candidate)
+ }
+ }
+
+ return tiles
+}
diff --git a/go/game/store.go b/go/game/store.go
new file mode 100644
index 00000000..46c136e0
--- /dev/null
+++ b/go/game/store.go
@@ -0,0 +1,10 @@
+package game
+
+type Store struct {
+ PileOfCardsBase
+}
+
+func NewStore() *Store {
+ s := &Store{PileOfCardsBase{[]*Card{}}}
+ return s
+}
diff --git a/go/game/targets.go b/go/game/targets.go
new file mode 100644
index 00000000..1a8c7ab3
--- /dev/null
+++ b/go/game/targets.go
@@ -0,0 +1,307 @@
+package game
+
+import (
+ "fmt"
+ "log"
+ "strconv"
+ "strings"
+
+ "golang.org/x/exp/slices"
+ "muhq.space/muhqs-game/go/utils"
+)
+
+type (
+ TargetConstrainFunc func(interface{}) error
+ TargetConstrain struct {
+ desc string
+ constrain TargetConstrainFunc
+ }
+)
+
+type Targets struct {
+ s *State
+ action Action
+ constrains []*TargetConstrain
+ sel []interface{}
+}
+
+func fmtSel(sel interface{}) string {
+ switch t := sel.(type) {
+ case *Tile:
+ return t.Position.String()
+ default:
+ return fmt.Sprintf("%d", t)
+ }
+}
+
+func (t *Targets) String() string {
+ if len(t.sel) == 0 {
+ return "undecided targets"
+ }
+
+ if len(t.sel) == 1 {
+ return fmt.Sprintf("%s", fmtSel(t.sel[0]))
+ }
+
+ s := "["
+ for _, sel := range t.sel {
+ s += fmt.Sprintf("%s ", fmtSel(sel))
+ }
+ return s[:len(s)-1] + "]"
+}
+
+func newTargetsWithSel(s *State, targets []string, action Action, sel []interface{}) *Targets {
+ t := &Targets{
+ s: s,
+ sel: sel,
+ }
+
+ t.buildConstrains(targets, action)
+ return t
+}
+
+func newTargets(s *State, targets []string, action Action) *Targets {
+ return newTargetsWithSel(s, targets, action, make([]interface{}, 0, len(targets)))
+}
+
+func (t *Targets) buildConstrains(targets []string, action Action) {
+ for _, desc := range targets {
+ c := &TargetConstrain{desc, targetConstrain(desc, t.s, action)}
+ t.constrains = append(t.constrains, c)
+ }
+}
+
+func (t *Targets) RequireTargets() bool {
+ return len(t.constrains) != len(t.sel)
+}
+
+func (t *Targets) Options() [][]interface{} {
+ options := make([][]interface{}, 0, len(t.constrains))
+ for _, c := range t.constrains {
+ options = append(options, c.options(t.s))
+ }
+ return options
+}
+
+func (t *Targets) NextOptions() []interface{} {
+ next := len(t.sel)
+ return t.constrains[next].options(t.s)
+}
+
+func (t *Targets) AddTarget(target interface{}) {
+ t.sel = append(t.sel, target)
+}
+
+func (t *Targets) CheckTargets(s *State) error {
+ if t.RequireTargets() {
+ return fmt.Errorf("Number of selected targets and required ones does not match")
+ }
+
+ for i, c := range t.constrains {
+ sel := t.sel[i]
+ if err := c.constrain(sel); err != nil {
+ return fmt.Errorf("selected target %s does not fit its desciption %s: %s",
+ sel, c.desc, err)
+ }
+ }
+
+ return nil
+}
+
+func targetConstrain(desc string, s *State, action Action) TargetConstrainFunc {
+ constrains := []TargetConstrainFunc{}
+ if strings.Contains(desc, "unit") {
+ constrains = append(constrains, parseUnitTargetConstrain(desc, s, action)...)
+ } else if strings.Contains(desc, "artifact") {
+ constrains = append(constrains, parseArtifactTargetConstrain(desc, s, action)...)
+ } else if strings.Contains(desc, "tile") {
+ constrains = append(constrains, parseTileTargetConstrain(desc, s, action)...)
+ // } else if strings.Contains("action") {
+ // contrains = append(contrains, parseActionTargetConstrain(desc, s, action)...)
+ // } else if strings.Contains("spell") {
+ // contrains = append(contrains, parseSpellTargetConstrain(desc, s, action)...)
+ }
+
+ return func(t interface{}) error {
+ for _, constrain := range constrains {
+ if err := constrain(t); err != nil {
+ return err
+ }
+ }
+
+ return nil
+ }
+}
+
+func enemyPermanentTargetConstrain(action Action) TargetConstrainFunc {
+ var sourceController *Player
+ switch source := action.Source().(type) {
+ case *Player:
+ sourceController = source
+ case Permanent:
+ sourceController = source.Controller()
+ default:
+ log.Fatalf("Unhandled source type %T in enemyPermanentTargetConstrain", source)
+ }
+
+ return func(t interface{}) (err error) {
+ p, _ := t.(Permanent)
+ if !p.Controller().IsEnemy(sourceController) {
+ err = fmt.Errorf("Controller %s of target %t is not an enemy of %s",
+ p.Controller(), t, sourceController)
+ }
+ return
+ }
+}
+
+func rangeTargetConstrain(source interface{}, r int) TargetConstrainFunc {
+ var sourcePos Position
+ switch source := source.(type) {
+ case *Tile:
+ sourcePos = source.Position
+ case Permanent:
+ sourcePos = source.Tile().Position
+ default:
+ log.Fatalf("Unhandled source type %T in rangeTargetConstrain", source)
+ }
+
+ return func(t interface{}) (err error) {
+ p, _ := t.(Permanent)
+ if !IsPositionInRange(sourcePos, p.Tile().Position, r) {
+ err = fmt.Errorf("Position %s of target %s not in range %d of source's position %s",
+ p.Tile().Position, t, r, sourcePos)
+ }
+ return
+ }
+}
+
+var unitTargetConstrain TargetConstrainFunc = func(t interface{}) (err error) {
+ _, ok := t.(*Unit)
+ if !ok {
+ err = fmt.Errorf("target is %T not *Unit", t)
+ }
+ return
+}
+
+func parseUnitTargetConstrain(desc string, s *State, action Action) []TargetConstrainFunc {
+ constrains := []TargetConstrainFunc{unitTargetConstrain}
+
+ if strings.Contains(desc, "enemy unit") {
+ constrains = append(constrains, enemyPermanentTargetConstrain(action))
+ }
+
+ if strings.Contains(desc, " in range ") {
+ tokens := strings.SplitN(desc, " in range ", 2)
+ r, err := strconv.Atoi(tokens[1])
+ if err != nil {
+ log.Fatalf("Invalid range %s in target constrain %s", tokens[1], desc)
+ }
+ constrains = append(constrains, rangeTargetConstrain(action, r))
+ }
+
+ return constrains
+}
+
+var artifactTargetConstrain TargetConstrainFunc = func(t interface{}) (err error) {
+ _, ok := t.(*Artifact)
+ if !ok {
+ err = fmt.Errorf("target is %T not *Artifact", t)
+ }
+ return
+}
+
+func parseArtifactTargetConstrain(constrain string, s *State, action Action) []TargetConstrainFunc {
+ constrains := []TargetConstrainFunc{artifactTargetConstrain}
+ return constrains
+}
+
+var tileTargetConstrain TargetConstrainFunc = func(t interface{}) (err error) {
+ _, ok := t.(*Tile)
+ if !ok {
+ err = fmt.Errorf("target is %T not *Tile", t)
+ }
+ return
+}
+
+func parseTileTargetConstrain(desc string, s *State, action Action) []TargetConstrainFunc {
+ log.Println("parsing", desc)
+ constrains := []TargetConstrainFunc{tileTargetConstrain}
+
+ if moveAction, ok := action.(*MoveAction); ok {
+ u := moveAction.Source().(*Unit)
+ constrains = append(constrains, func(t interface{}) (err error) {
+ tile := t.(*Tile)
+ if slices.Contains(u.MoveRangeTiles(s.Map), tile) {
+ return nil
+ }
+ return fmt.Errorf("tile %s is not in %d movement range", tile)
+ })
+ }
+
+ var player *Player
+ switch a := action.(type) {
+ case *PlayAction:
+ player = a.Source().(*Player)
+ }
+
+ var card *Card
+ switch a := action.(type) {
+ case *PlayAction:
+ card = a.Card
+ case *MoveAction:
+ card = a.Card
+ }
+
+ if strings.Contains(desc, "spawn") {
+ if player == nil {
+ log.Fatalf("Not implemented spawn tile constrain for %T", action)
+ }
+
+ constrains = append(constrains, func(t interface{}) (err error) {
+ tile := t.(*Tile)
+ if slices.Contains(s.AvailableSpawnTiles(player, card), tile) {
+ return nil
+ }
+ return fmt.Errorf("tile %s is no spawn tile", tile)
+ })
+ }
+
+ if strings.Contains(desc, "available") {
+ constrains = append(constrains, func(t interface{}) (err error) {
+ tile := t.(*Tile)
+
+ if card == nil {
+ log.Fatalf("Not implemented availability tile constrain for %T", action)
+ }
+
+ if tile.IsAvailableForCard(card) {
+ return nil
+ }
+ return fmt.Errorf("tile %s is not available for %s", tile, card.Name)
+ })
+ }
+
+ return constrains
+}
+
+func (c *TargetConstrain) options(s *State) (options []interface{}) {
+ candidates := targetCandidates(c.desc, s)
+
+ for _, candidate := range candidates {
+ if err := c.constrain(candidate); err == nil {
+ options = append(options, candidate)
+ }
+ }
+ return
+}
+
+func targetCandidates(desc string, s *State) []interface{} {
+ if strings.Contains(desc, "unit") || strings.Contains(desc, "artifact") {
+ return utils.TypedSliceToInterfaceSlice(s.Permanents)
+ } else if strings.Contains(desc, "tile") {
+ return utils.TypedSliceToInterfaceSlice(s.Map.AllTiles())
+ }
+
+ log.Fatalf("Unimplemented target options for %s", desc)
+ return nil
+}
diff --git a/go/game/tile.go b/go/game/tile.go
new file mode 100644
index 00000000..260747d1
--- /dev/null
+++ b/go/game/tile.go
@@ -0,0 +1,123 @@
+package game
+
+import (
+ "fmt"
+ "strings"
+)
+
+type TileType int
+
+const (
+ neutral TileType = iota + 1
+ tower
+ house
+ farm
+ street
+ spawn
+ docks
+ store
+)
+
+func (t TileType) String() string {
+ switch t {
+ case neutral:
+ return "neutral"
+ case tower:
+ return "tower"
+ case house:
+ return "house"
+ case farm:
+ return "farm"
+ case street:
+ return "street"
+ case spawn:
+ return "spawn"
+ case docks:
+ return "docks"
+ case store:
+ return "store"
+ }
+ return ""
+}
+
+var TileTypes = struct {
+ Neutral TileType
+ Tower TileType
+ House TileType
+ Farm TileType
+ Street TileType
+ Spawn TileType
+ Docks TileType
+ Store TileType
+}{
+ Neutral: neutral,
+ Tower: tower,
+ House: house,
+ Farm: farm,
+ Street: street,
+ Spawn: spawn,
+ Docks: docks,
+ Store: store,
+}
+
+var TileNames = map[string]TileType{
+ "neutral": neutral,
+ "tower": tower,
+ "house": house,
+ "farm": farm,
+ "street": street,
+ "spawn": spawn,
+ "docks": docks,
+ "store": store,
+}
+
+type Tile struct {
+ Position Position
+ Permanent Permanent
+ Type TileType
+ Water bool
+ Raw string
+}
+
+func (t *Tile) String() string {
+ return fmt.Sprintf("%d@%d", t.Type, t.Position.String())
+}
+
+func INVALID_TILE() Tile {
+ return Tile{INVALID_POSITION(), nil, 0, false, ""}
+}
+
+func NewTileFromString(t string, pos Position) (Tile, error) {
+ tile := strings.ToLower(t)
+ tokens := strings.Split(tile, " ")
+ tileType, found := TileNames[tokens[0]]
+ if !found {
+ if strings.Contains(tile, "spawn") {
+ tileType = spawn
+ }
+ }
+ water := strings.Contains(tile, "water")
+ return Tile{pos, nil, tileType, water, t}, nil
+}
+
+func (t *Tile) IsFree() bool {
+ return t.Permanent == nil
+}
+
+func (t *Tile) IsAvailableForCard(card *Card) bool {
+ if t.Water && !card.hasPlacementConstrain("swimming") {
+ return false
+ }
+ if !t.Water && card.hasPlacementConstrain("swimming") {
+ return false
+ }
+ return t.IsFree()
+}
+
+func (t *Tile) OnDiagonal(other *Tile) bool {
+ p1, p2 := t.Position, other.Position
+ return p1.X == p2.X-1 && p1.Y == p2.Y-1 ||
+ p1.X == p2.X+1 && p1.Y == p2.Y-1 ||
+ p1.X == p2.X+1 && p1.Y == p2.Y+1 ||
+ p1.X == p2.X-1 && p1.Y == p2.Y+1
+}
diff --git a/go/game/unit.go b/go/game/unit.go
new file mode 100644
index 00000000..2bc90992
--- /dev/null
+++ b/go/game/unit.go
@@ -0,0 +1,211 @@
+package game
+
+import (
+ "log"
+)
+
+type UnitMark int
+
+const (
+ paralysis UnitMark = iota + 1
+ poison
+ panic_
+ rage
+ ward
+ faith
+)
+
+func (mark UnitMark) String() string {
+ switch mark {
+ case paralysis:
+ return "paralysis"
+ case poison:
+ return "poison"
+ case panic_:
+ return "panic"
+ case rage:
+ return "rage"
+ case ward:
+ return "ward"
+ case faith:
+ return "faith"
+ default:
+ log.Fatalf("Unhandled unit mark %d", mark)
+ return ""
+ }
+}
+
+var UnitMarks = struct {
+ Paralysis UnitMark
+ Poison UnitMark
+ Panic UnitMark
+ Rage UnitMark
+ Ward UnitMark
+ Faith UnitMark
+}{
+ Paralysis: paralysis,
+ Poison: poison,
+ Panic: panic_,
+ Rage: rage,
+ Ward: ward,
+ Faith: faith,
+}
+
+type Unit struct {
+ PermanentBase
+ Health int
+ Movement Movement
+ Attack Attack
+ Upkeep int
+ FullActions []*FullAction
+ FreeActions []*FreeAction
+ States map[UnitMark]int
+ // Effects []Effects
+ AvailMoveActions int
+ AvailAttackActions int
+}
+
+func NewUnit(card *Card, tile *Tile, owner *Player) *Unit {
+ var movement Movement
+ movementDesc, found := card.Values["movement"]
+ if !found {
+ movement = INVALID_MOVEMENT()
+ } else {
+ movement = parseMovement(movementDesc)
+ }
+
+ upkeep, ok := card.Values["upkeep"].(int)
+ if !ok {
+ upkeep = 0
+ }
+
+ var attack Attack
+ attackDesc, found := card.Values["attack"]
+ if !found {
+ attack = INVALID_ATTACK()
+ } else {
+ attack = parseAttack(attackDesc)
+ }
+
+ u := &Unit{
+ PermanentBase: NewPermanentBase(card, tile, owner),
+ Health: card.Values["health"].(int),
+ Movement: movement,
+ Attack: attack,
+ Upkeep: upkeep,
+ States: make(map[UnitMark]int),
+ AvailMoveActions: 1,
+ AvailAttackActions: 1,
+ }
+
+ u.FullActions = card.Impl.fullActions(u)
+ u.FreeActions = card.Impl.freeActions(u)
+
+ tile.Permanent = u
+ return u
+}
+
+func NewUnitFromPath(cardPath string, tile *Tile, owner *Player) *Unit {
+ return NewUnit(NewCard(cardPath), tile, owner)
+}
+
+func (u *Unit) move(s *State, tile *Tile) {
+ u.tile.Permanent = nil
+ tile.Permanent = u
+ u.tile = tile
+}
+
+func (u *Unit) MoveRangeTiles(m *Map) []*Tile {
+ tiles := []*Tile{}
+ // TODO: build graph only containing possible tile in movement range
+ graph := m.generateMapGraphFor(u)
+ for _, pos := range PositionsInRange(u.tile.Position, u.Movement.Range, false) {
+ tile := m.TileAt(pos)
+ if tile == nil || !u.IsAvailableTile(tile) {
+ continue
+ }
+
+ path, err := findPathTo(graph, u, pos)
+ if err != nil {
+ continue
+ }
+
+ if path.Distance/2 > int64(u.Movement.Range) {
+ continue
+ }
+
+ tiles = append(tiles, tile)
+ }
+ return tiles
+}
+
+func (u *Unit) AttackableTiles(m *Map) []*Tile {
+ return TilesInRange(m, u, u.Attack.Range)
+}
+
+func (u *Unit) AttackablePermanents(m *Map) []Permanent {
+ permanents := []Permanent{}
+ for _, t := range u.AttackableTiles(m) {
+ if t.Permanent != nil && t.Permanent.Attackable() {
+ permanents = append(permanents, t.Permanent)
+ }
+ }
+ return permanents
+}
+
+func (u *Unit) AttackableEnemyPermanents(m *Map) []Permanent {
+ controller := u.Controller()
+ permanents := []Permanent{}
+ for _, t := range u.AttackableTiles(m) {
+ if t.Permanent != nil && t.Permanent.Attackable() &&
+ t.Permanent.Controller() != controller {
+
+ permanents = append(permanents, t.Permanent)
+ }
+ }
+ return permanents
+}
+
+func (u *Unit) Fight(p Permanent) {
+ DealDamage(u, p, u.Attack.Damage)
+}
+
+func (u *Unit) IsDestroyed() bool {
+ return u.damage >= u.Health
+}
+
+func (u *Unit) Attackable() bool {
+ return true
+}
+
+func (u *Unit) HasFullAction() bool {
+ return len(u.FullActions) > 0
+}
+
+func (u *Unit) FullAction() {
+}
+
+func (u *Unit) AvailActions() (actions []Action) {
+ if u.AvailMoveActions > 0 && u.Movement != INVALID_MOVEMENT() {
+ actions = append(actions, NewMoveAction(u))
+ }
+
+ m := u.Controller().gameState.Map
+ if u.AvailAttackActions > 0 && u.Attack != INVALID_ATTACK() &&
+ len(u.AttackableEnemyPermanents(m)) > 0 {
+
+ actions = append(actions, NewAttackAction(u))
+ }
+
+ if u.AvailAttackActions > 0 && u.AvailMoveActions > 0 {
+ for _, p := range u.FullActions {
+ actions = append(actions, p)
+ }
+ }
+
+ for _, p := range u.FreeActions {
+ actions = append(actions, p)
+ }
+
+ return actions
+}
diff --git a/go/game/winCondition.go b/go/game/winCondition.go
new file mode 100644
index 00000000..4db36e1d
--- /dev/null
+++ b/go/game/winCondition.go
@@ -0,0 +1,36 @@
+package game
+
+import (
+ "golang.org/x/exp/slices"
+)
+
+func KingGame(s *State) []*Player {
+ foundKings := map[*Player]struct{}{}
+ for _, u := range s.Units {
+ if u.card.Name != "King" {
+ continue
+ }
+
+ foundKings[u.owner] = struct{}{}
+ }
+
+ if len(foundKings) == len(s.Players) {
+ return []*Player{}
+ }
+
+ loosers := make([]*Player, 0, len(s.Players))
+ copy(loosers, s.Players)
+ for p := range foundKings {
+ i := slices.Index(loosers, p)
+ loosers[i] = loosers[len(loosers)-1]
+ loosers = loosers[:len(loosers)-1]
+ }
+
+ winners := []*Player{}
+ for _, p := range s.Players {
+ if !slices.Contains(loosers, p) {
+ winners = append(winners, p)
+ }
+ }
+ return winners
+}