Return to Blog

Building a command line tool to design a farm layout in Stardew Valley

By John Lekberg on February 26, 2020.


Hacker News discussion


This week's post will cover a command line tool that helps you play the video game Stardew Valley.

Stardew Valley is a farming game (like Harvest Moon). You can manually water your crops, or you can use sprinklers to automate the process.

I wrote a Python script, sprinkler-layout, that designs a layout of sprinklers for me, for a given number of sprinklers (e.g. 10 sprinklers). The goals of the layout are:

Script source code

sprinkler-layout

#!/usr/bin/env python3

import itertools


class Layout:
    """A layout of sprinklers on a grid."""

    def __init__(self):
        self._sprinklers = set()
        self._watered_squares = set()

    @classmethod
    def generate(cls, *, num_sprinklers, coordinates):
        """Generate a layout, given:

        - how many sprinklers to place.
        - which positions to attempt to place them at.

        """
        layout = cls()

        while layout.count_sprinklers() < num_sprinklers:
            position = next(coordinates)
            if layout.is_open(position):
                layout.add_sprinkler(position)

        return layout

    def count_sprinklers(self):
        """The current number of placed sprinklers."""
        return len(self._sprinklers)

    def _watering_positions(self, sprinkler_position):
        """Generate positions watered by a sprinkler at a
        given position."""
        x, y = sprinkler_position
        yield x - 1, y
        yield x + 1, y
        yield x, y - 1
        yield x, y + 1

    def is_open(self, position):
        """Check if a position is open for placing a
        sprinkler.

        A position is open if

        - the set of the position and its watered squares

        does not intersect with

        - the set of already placed sprinklers and their
          watered squares.

        """
        new_positions = {position, *self._watering_positions(position)}
        return not (
            new_positions & (self._sprinklers | self._watered_squares)
        )

    def add_sprinkler(self, position):
        """Add a sprinkler at a position."""
        self._sprinklers.add(position)
        self._watered_squares.update(
            self._watering_positions(position)
        )

    def print_report(self):
        """Print out a report of the current layout.

        The report includes:

        - The dimensions of the layout.
        - The materials cost of the layout.
        - A visualization of the layout.

        """
        squares = self._sprinklers | self._watered_squares
        X = [x for x, _ in squares]
        Y = [y for _, y in squares]
        span = lambda Z: range(min(Z), max(Z) + 1)
        grid = [
            [
                "#" if (x, y) in self._sprinklers else "."
                for x in span(X)
            ]
            for y in span(Y)
        ]
        width = len(span(X)) + 2
        height = len(span(Y)) + 2

        print(len(self._sprinklers), "sprinklers")
        print(len(self._watered_squares), "watered squares")
        print(width, "x", height, "squares, including perimeter wall")
        print(2 * (width + height), "square perimeter")

        block = 3
        print(f"map of sprinklers ({block} by {block} blocks)")
        for i, row in enumerate(grid):
            if i % block == 0:
                print()
            for j, square in enumerate(row):
                if j % block == 0:
                    print(end=" ")
                print(square, end="")
            print()
        print()


def spiral_coordinates():
    """Generate positions along a spiral.
    
    The first nine steps of the spiral look like this

        7 6 5  |  v < <
        8 1 4  |  v v ^
        9 2 3  |  v > ^

    """
    yield 0, 0
    for radius in itertools.count(start=1):
        x, y = 1 - radius, radius
        while x < radius:
            yield x, y
            x += 1
        while y > -radius:
            yield x, y
            y -= 1
        while x > -radius:
            yield x, y
            x -= 1
        while y < radius:
            yield x, y
            y += 1

        yield x, y


if __name__ == "__main__":
    import argparse

    parser = argparse.ArgumentParser(
        description=
            "generate a layout of sprinklers for Stardew Valley."
    )
    parser.add_argument(
        "--sprinklers",
        type=int,
        required=True,
        metavar="N",
        help="the number of sprinklers to place",
    )
    args = parser.parse_args()

    layout = Layout.generate(
        num_sprinklers=args.sprinklers,
        coordinates=spiral_coordinates()
    )
    layout.print_report()
$ sprinkler-layout --help
usage: sprinkler-layout [-h] --sprinklers N

generate a layout of sprinklers for Stardew Valley.

optional arguments:
  -h, --help      show this help message and exit
  --sprinklers N  the number of sprinklers to place

Using the script to design a layout

I'm starting a new farming season in Stardew Valley and I have 25 sprinklers available. I use sprinkler-layout to design a layout:

$ sprinkler-layout --sprinklers 25
25 sprinklers
100 watered squares
16 x 17 squares, including perimeter wall
66 square perimeter
map of sprinklers (3 by 3 blocks)

 ... ... ... ... ..
 ... ... ... ... #.
 ..# ..# ... ... ..

 ... ... .#. ..# ..
 ... #.. ... #.. ..
 .#. ..# ... ... ..

 ... ... ..# ..# ..
 ... #.. #.. ... ..
 .#. ... ... .#. ..

 ... ..# ..# ... ..
 ... #.. ... ... #.
 ... ... #.. #.. ..

 ..# ... ... ..# ..
 ... .#. .#. ... ..
 ... ... ... ... ..

Then:

How the script works

I use a custom class, Layout, to represent a sprinkler layout. Layout manages the internal state of:

Layout has a class method, generate, that attempts to position sprinklers by choosing from given positions. generate uses a greedy strategy to place the sprinklers:

I check if I can place a sprinkler by using sets of coordinates and checking that these sets are disjoint:

I have a generator function, spiral_coordinates, the generates positions in a spiral that looks like this: (starting from the center)

v < < < < < <
v v < < < < ^
v v v < < ^ ^
v v v v ^ ^ ^
v v v > ^ ^ ^
v v > > > ^ ^
v > > > > > ^
> ...

I use this technique because it designs good enough layouts for me. spiral_coordinates is simple to implement and keeps the overall perimeter of the layout small.

The report function, print_report, computes a bounding box that encloses:

Then, I take into account a 1 square thick perimeter wall and report:

The report generates a map of the placed sprinklers and partitions it into chunks:

 ... ... ... ... ..
 ... ... ... ... #.
 ..# ..# ... ... ..

 ... ... .#. ..# ..
 ... #.. ... #.. ..
 .#. ..# ... ... ..

 ... ... ..# ..# ..
 ... #.. #.. ... ..
 .#. ... ... .#. ..

 ... ..# ..# ... ..
 ... #.. ... ... #.
 ... ... #.. #.. ..

 ..# ... ... ..# ..
 ... .#. .#. ... ..
 ... ... ... ... ..

I find the map harder to read without the partitioning:

..............
............#.
..#..#........
.......#...#..
...#.....#....
.#...#........
........#..#..
...#..#.......
.#........#...
.....#..#.....
...#........#.
......#..#....
..#........#..
....#..#......
..............

In conclusion...

This week's post covered a Python script that assists people playing Stardew Valley by designing a layout of sprinklers. You learned about:

My challenge to you:

Create a different way to generate coordinates than spiral_coordinates.

For example, here's what a placement of 8 sprinklers looks like with spiral_coordinates:

Layout.generate(
    num_sprinklers = 8,
    coordinates = spiral_coordinates()
).print_report()
8 sprinklers
32 watered squares
11 x 10 squares, including perimeter wall
42 square perimeter
map of sprinklers (3 by 3 blocks)

 ... ... ...
 .#. ... .#.
 ... #.. ...

 ... ... #..
 .#. .#. ...
 ... ... ...

 ... #.. #..
 ... ... ...

And here's a placement of 8 sprinklers that tries positions only in a horizontal line:

from itertools import count

Layout.generate(
    num_sprinklers = 8,
    coordinates = ((i, 0) for i in count())
).print_report()
8 sprinklers
32 watered squares
26 x 5 squares, including perimeter wall
62 square perimeter
map of sprinklers (3 by 3 blocks)

 ... ... ... ... ... ... ... ...
 .#. .#. .#. .#. .#. .#. .#. .#.
 ... ... ... ... ... ... ... ...

If you enjoyed this week's post, share it with your friends and stay tuned for next week's post. See you then!


(If you spot any errors or typos on this post, contact me via my contact page.)