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!