diff options
| author | Florian Fischer <florian.fischer@muhq.space> | 2022-12-27 14:29:39 +0100 |
|---|---|---|
| committer | Florian Fischer <florian.fischer@muhq.space> | 2025-08-20 15:46:38 +0200 |
| commit | d6b5eeaa5e639c5ab89b5e1c93075a1b765794dc (patch) | |
| tree | f87422bb91c2197f88aa8d29092592858764049d /go/game | |
| parent | 3c2ee4ee48480a28dc606878503f314ec8862d45 (diff) | |
| download | muhqs-game-d6b5eeaa5e639c5ab89b5e1c93075a1b765794dc.tar.gz muhqs-game-d6b5eeaa5e639c5ab89b5e1c93075a1b765794dc.zip | |
intermediate commit
Diffstat (limited to 'go/game')
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 +} |
