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:

water as much land as possible.

have a reasonably small perimeter.

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:

I construct the required materials (66 squares of walls for the perimeter).

I clear out enough space for the layout (a grid of 16 by 17 squares).

I place sprinklers as shown in the layout ( # ).

). I place the walls around the perimeter.

How the script works

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

Where sprinklers have been placed.

Which positions are watered by the placed sprinklers.

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:

Loop until I have placed enough sprinklers: Get the next position to try. If I can place a sprinkler at this position, do it.



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

the set of the new sprinkler and its watered squares.

the set of already placed sprinklers and their watered squares.

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:

the sprinklers that have been placed.

the squares that are watered by the placed sprinklers.

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

The dimensions of the bounding box.

The perimeter of the bounding box.

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:

Using Python classes to manage internal state.

Using Python sets to check if two sets of positions are disjoint.

Using a simple greedy strategy to make decisions (placing the sprinklers).

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.)