Looking for a better way to use `andThen` chains (and other beginner questions)

99 views
Skip to first unread message

Stefan Matthias Aust

unread,
Apr 22, 2017, 5:25:16 PM4/22/17
to Elm Discuss
Hi there!

Just for fun, I started to learn Elm. I wonder how to idiomatically solve the following problems.

I decided to create a little 4X strategy game. Currently, I'm tinkering with the best representation of such a game and how to implement game commands that update the model. 

Here's a simplified run down: The game has stars and players. Each star has planets. Planets have properties like size, population, industry, defense, etc. Planets can be owned by players. Spaceships (organized as fleets (aka taskforces aka tfs) can move between stars. They belong to players. One kind of ship is the transport and it can be used to colonize unoccupied planets.

I refer stars, planets, players and fleets by number (aka `no`) because (this is my current understanding) I have no other way to refer to records and I need a way for the user to enter commands (I intent to eventually create a retro text mode interface). So a command looks like `BuildDefense TeamNo StarNo PlanetNo Amount` or `LandTransports TeamNo TfNo StarNo PlanetNo Amount`. All that types are aliases for `Int`.

This is an excerpt from type definitions:

Game = { stars : List Star, teams : List Team }
Star = { no: StarNo, planets : List Planet }
Planet = { no : PlanetNo, defense : Int }
Command = BuildDefense ... | LandTransports ...

My first question: Is there any way to create a constrained type like "something that has a `no` field?

Right now, I have a lot of nearly identical code like this:

findStar : StarNo -> Game -> Maybe Star
findStar no game =
    List.head (List.filter (\star -> star.no == no) game.stars)

findPlanet : PlanetNo -> Star -> Maybe Planet
findPlanet no star =
    List.head (List.filter (\planet -> planet.no == no) star.planets)

In other languages, I could create some kind of protocol or interface or category and then declare `Star` or `Planet` to be conforming. Then I could implement common operations for conforming types.

I also didn't find some kind of "find first" operation in the standard library (which is frankly surprisingly small). It's easy enough to implement but why do I need to?

find : (a -> Bool) -> List a -> Maybe a
find test list =
    case list of
        head :: tail ->
            if test (head) then
                Just head
            else
                find test tail

        other ->
            Nothing

Because I like terse code, I used the much more compact although less efficient variant.

Also, mainly for esthetic reasons, I'd prefer to extend the `List` module so that my function reads `List.find` because if I have to use `List.map` and `Maybe.map` and `Result.map` and `Random.map` everywhere, I'd stay consistent.

Sidenote, would be using `<|` more idiomatic?

findPlanet no star =
    List.head <| List.filter (\planet -> planet.no == no) star.planets

Second question: Is there an easy way to replace an element in a list?

As I have to recreate the whole game if I change something, I crafted a lot of helper function:

    planet
        |> addDefense amount
        |> subtractResources amount * defenseCost
        |> updatePlanetInStar star
        |> updateStarInGame game
        |> Ok

This ready nicely.

The `updatePlanetInStar` and `updateStarInGame` functions are however very verbose:

updatePlanetInStar : Star -> Planet -> Star
updatePlanetInStar star planet =
    { star
        | planets =
            List.map
                (\p ->
                    if p.no == planet.no then
                        planet
                    else
                        p
                )
                star.planets
    }

Again, I'm unable to abstract it further.

Sidenote: I'd love to use 

{ game | stars[x].planets[y] = planet }

and let the compiler deal with all that stupid boilerplate code. I realize that for this to work, I'd have to use `Array` (a rather unsupported type) instead of `List` but that would be fine. That way, I could alias my `no` to indices. By the way, do I really had to create my own `nth` function for Lists?

Third question. When implementing `BuildDefense`, I need to convert the no's to records and this could fail. Instead of working with `Maybe`, I use a `Result Error a` type and `Error` is a sum type that contains all the errors that could occur. However, I get some really ugly chaining…

buildDefense : TeamNo -> StarNo -> PlanetNo -> Int -> Game -> Result Error Game
buildDefense teamNo starNo planetNo amount game =
    findTeamR teamNo game
        |> Result.andThen
            (\team ->
                findStarR starNo game
                    |> Result.andThen
                        (\star ->
                            findPlanetR planetNo star
                                |> Result.andThen
                                    (\planet ->
                                        if amount < 1 then
                                            Err InvalidAmount
                                        else if planet.owner /= team.no then
                                            Err NotYourPlanet
                                        else if planet.resources < amount * defenseCost then
                                            Err NotEnoughResources
                                        else
                                            planet
                                                |> addDefense amount
                                                |> subtractResources (amount * defenseCost)
                                                |> updatePlanet star
                                                |> updateStar game
                                                |> Ok
                                    )
                        )
            )

How am I supposed to write this "the Elm way"? 

In other languages, I'd probably use exceptions. And I don't want to invert the control flow by creating tiny help functions where I'm currently using anonymous functions. I want to read the control from from top to bottom. BTW, `findTeamR` is `findTeam`, wrapped in `Result.fromMaybe InvalidTeam`. And while I could use `Result.map2` to resolve team and star in parallel, I cannot do this for the planet which is dependent on the star and therefore I didn't bother.

Here's my final code snippet:

landTransports : TeamNo -> TfNo -> StarNo -> PlanetNo -> Int -> Game -> Result Error Game
landTransports teamNo tfNo starNo planetNo amount game =
    findTeamAndTfR teamNo tfNo game
        |> Result.andThen
            (\( team, tf ) ->
                findStarAndPlanetR starNo planetNo game
                    |> Result.andThen
                        (\( star, planet ) ->
                            if amount < 1 || amount > tf.ships.transports then
                                Err InvalidAmount
                            else if tf.dest /= star.no || tf.eta /= 0 then
                                Err TfNotAtStar
                            else if (planet.owner /= team.no) && (planet.owner /= noTeam) then
                                Err PlanetNotYours
                            else if planet.population + amount > planet.size then
                                Err TooMuchPopulation
                            else
                                let
                                    game1 =
                                        tf
                                            |> substractShips { noShips | transports = amount }
                                            |> updateTfInTeam team
                                            |> updateTeamInGame game

                                    game2 =
                                        planet
                                            |> addPopulation amount
                                            |> setOwner team.no
                                            |> updatePlanetInStar star
                                            |> updateStarIngame game1
                                in
                                    Ok game2
                        )
            )

I'm really sorry for this long mail :-)

Stefan

Peter Damoc

unread,
Apr 23, 2017, 3:28:29 AM4/23/17
to Elm Discuss
On Sat, Apr 22, 2017 at 12:37 AM, Stefan Matthias Aust <eib...@gmail.com> wrote:

My first question: Is there any way to create a constrained type like "something that has a `no` field?
 
Right now, I have a lot of nearly identical code like this:

findStar : StarNo -> Game -> Maybe Star
findStar no game =
    List.head (List.filter (\star -> star.no == no) game.stars)

findPlanet : PlanetNo -> Star -> Maybe Planet
findPlanet no star =
    List.head (List.filter (\planet -> planet.no == no) star.planets)

You could use record pattern matching to create generic functions but in the above case that won't work because the container name is different. 

Here is how it would have looked if the container name would have been the same (in this case `children`) 

findChild : Int -> { b | no : Int, children : List { a | no : Int } } -> Maybe { a | no : Int }
findChild no parent =
    List.head (List.filter (\child -> child.no == no) parent.children)
 
I used Int for the `no` but you you can leave that also as a parameter (e.g. `c`)


Second question: Is there an easy way to replace an element in a list?

You can create a generic helper like

swapIfSameNo : { a | no : Int } -> { a | no : Int } -> { a | no : Int }
swapIfSameNo a b =
    if a.no == b.no then
        a
    else
        b

and then the updates would be simpler 

updatePlanetInStar : Star -> Planet -> Star
updatePlanetInStar star planet =
    { star | planets = List.map (swapIfSameNo planet) star.planets }


The same comments from above apply. If you have a generic name for the container (children) you can make this a generic updateChild function that would work on both. 


Sidenote: I'd love to use 

{ game | stars[x].planets[y] = planet }

and let the compiler deal with all that stupid boilerplate code. I realize that for this to work, I'd have to use `Array` (a rather unsupported type) instead of `List` but that would be fine. That way, I could alias my `no` to indices. By the way, do I really had to create my own `nth` function for Lists?

What make you think Array is unsupported? 

Regarding indexes in arrays (or lists for that matter), it would be lovely to be able to say things like that but it is unsafe. You might be using an out of bounds index. 
There are no solutions in contexts like these, only tradeoffs. Elm choses to make things more verbose and more explicit in order to guarantee safety. That's the tradeoff that Elm chose. 
What I would try to do different is split buildDefense in two between finding the proper planet and doing something with it

findPlanet : TeamNo -> StarNo -> PlanetNo -> Game -> Result Error ( Planet, Star )
findPlanet teamNo starNo planetNo game =
    findTeamR teamNo game
        |> Result.andThen
            (\team ->
                findStarR starNo game
                    |> Result.andThen
                        (\star ->
                            findPlanetR planetNo star
                                |> Result.andThen
                                    (\planet ->
                                        if planet.owner /= team.no then
                                            Err NotYourPlanet
                                        else
                                            Ok ( planet, star )
                                    )
                        )
            )


buildDefense : TeamNo -> StarNo -> PlanetNo -> Int -> Game -> Result Error Game
buildDefense teamNo starNo planetNo amount game =
    let
        planetAndStar =
            findPlanet teamNo starNo planetNo

        updateGame ( planet, star ) =
            if amount < 1 then
                Err InvalidAmount
            else if planet.resources < amount * defenseCost then
                Err NotEnoughResources
            else
                planet
                    |> addDefense amount
                    |> subtractResources (amount * defenseCost)
                    |> updatePlanet star
                    |> updateStar game
                    |> Ok
    in
        Result.andThen updateGame planetAndStar




--
There is NO FATE, we are the creators.
blog: http://damoc.ro/

Max Goldstein

unread,
Apr 23, 2017, 2:09:47 PM4/23/17
to Elm Discuss
https://www.youtube.com/watch?v=IcgmSRJHu_8Instead of lists with numbered items, have you thought about using a dictionary? Something like this?

import Dict exposing (Dict)

type alias No = Int
type alias Game = { stars : Dict No Star, teams : Dict No Team }
type alias Team = { name : String, color : String, species : String }
type alias Star = { name : String, planets : Dict No Planet }
type alias Planet = { name : String, minerals : Int, colony : Maybe Colony }
type alias Colony = { team : No, defense : Int }

getStarPlanet : No -> No -> Game -> Result String Planet
getStarPlanet starNo planetNo {stars} =
  Dict.get starNo stars
    |> Result.fromMaybe "Star not found"
    |> Result.andThen (\{planets} -> Dict.get planetNo planets |> Result.fromMaybe "Planet not found")
    
It's still a little repetitive, and you need to nest a Result.fromMaybe inside a Result.andThen callback, but it works. (You should maybe consider a union type for these errors instead of strings.) 

I've also tried to anticipate that you'll have uncolonized planets, which will still have things like minerals, size, atmosphere, temperature, things like that. Some of those will also have colonies which will have a team, defense, buildings, colonists, and so on.

Incidentally, if you're okay fixing the number of teams at compile-time, you might be able to avoid the inconsistent state of a colony whose team is not in the team dictionary.

type TeamNo = Red | Blue
type alias Teams = { red : Team, blue : Team }
getTeam : TeamNo -> Teams -> Team
getTeam no teams =
  if no == Red then teams.red else teams.blue

If you're looking for a somewhat broader background on data modeling in Elm, this talk is a must-watch: Making Impossible States Impossible

Max Goldstein

unread,
Apr 23, 2017, 2:21:36 PM4/23/17
to Elm Discuss
If you're looking for a somewhat broader background on data modeling in Elm, this talk is a must-watch: Making Impossible States Impossible

Sorry, looks like the YouTube link got put at the top of the post. 
Reply all
Reply to author
Forward
0 new messages