Building a command line tool to design a farm layout in Stardew Valley
By John Lekberg on February 26, 2020.
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.)