2023-02-10 19:50:34 +00:00
|
|
|
from emis_funky_funktions import *
|
|
|
|
|
2023-02-12 16:10:07 +00:00
|
|
|
from world import Point, Terrain, World
|
2023-02-11 00:02:14 +00:00
|
|
|
|
|
|
|
from PIL import Image
|
|
|
|
|
2023-02-10 19:50:34 +00:00
|
|
|
from itertools import chain
|
2023-02-11 00:02:14 +00:00
|
|
|
from typing import List, NamedTuple, Tuple
|
2023-02-10 19:50:34 +00:00
|
|
|
|
|
|
|
def meters_to_millimeters(meters: float) -> int:
|
|
|
|
"""
|
|
|
|
Convert meters (floating point) to millimeters (int)
|
|
|
|
|
|
|
|
>>> meters_to_millimeters(1.9333473e+02)
|
|
|
|
193334
|
|
|
|
|
|
|
|
>>> meters_to_millimeters(1.0)
|
|
|
|
1000
|
|
|
|
"""
|
|
|
|
return int(1000 * meters)
|
|
|
|
|
|
|
|
def read_single_elevation(dat: str) -> Result[int, str]:
|
|
|
|
"""
|
|
|
|
Read in a single elevation
|
|
|
|
|
|
|
|
Expects a single, stringified floating point value, in meters, without a unit, e.g.
|
|
|
|
"2.057e+02". If there is a parsing error, returns an error containing the input
|
|
|
|
string. Otherwise, returns the elevation in millimeters.
|
|
|
|
|
|
|
|
>>> read_single_elevation("1.9333473e+02")
|
2023-02-10 21:35:01 +00:00
|
|
|
Ok(193334)
|
2023-02-10 19:50:34 +00:00
|
|
|
|
|
|
|
>>> read_single_elevation("1.0")
|
2023-02-10 21:35:01 +00:00
|
|
|
Ok(1000)
|
2023-02-10 19:50:34 +00:00
|
|
|
|
|
|
|
>>> read_single_elevation("10.0m")
|
2023-02-10 21:35:01 +00:00
|
|
|
Err('10.0m')
|
2023-02-10 19:50:34 +00:00
|
|
|
"""
|
2023-02-12 21:58:59 +00:00
|
|
|
try:
|
|
|
|
# fuck floating points
|
|
|
|
return Ok(meters_to_millimeters(float(dat)))
|
|
|
|
except ValueError:
|
|
|
|
return Err(dat)
|
2023-02-10 19:50:34 +00:00
|
|
|
|
2023-02-12 16:09:53 +00:00
|
|
|
def read_elevation_line(line: str) -> Result[Sequence[int], Tuple[int, str]]:
|
2023-02-10 19:50:34 +00:00
|
|
|
"""
|
|
|
|
Reads a line of elevations
|
|
|
|
|
|
|
|
Each elevation should be in the format specified by `read_single_elevation`. Every
|
|
|
|
element of the line is parsed and combined into an iterator, which is returned. If
|
|
|
|
any parsing errors are encountered, the first error is returned. It takes the form of
|
|
|
|
a tuple containing the column of the error (in terms of whitespace-deliniated words,
|
|
|
|
not characters) and the string which could not be parsed.
|
|
|
|
|
|
|
|
Recall from the definition of `read_single_elevation()` that the return value is an
|
|
|
|
integer number of millimeters.
|
|
|
|
|
|
|
|
As per the problem specification, the last five columns of the line are not read or
|
|
|
|
parsed. Parsing errors in these columns therefor cannot be encountered, and any data
|
|
|
|
in these columns will not be returned.
|
|
|
|
|
|
|
|
>>> map_res(list, read_elevation_line("1.9e2 18e1 181 179.1 ignored 170.1 1.8e2 18i+2 20e1"))
|
2023-02-10 21:35:01 +00:00
|
|
|
Ok([190000, 180000, 181000, 179100])
|
2023-02-10 19:50:34 +00:00
|
|
|
|
|
|
|
>>> map_res(list, read_elevation_line("1.9e2 18e 181 179.1 ignored 170.1 1.8e2 18i+2 20e1"))
|
2023-02-10 21:35:01 +00:00
|
|
|
Err((2, '18e'))
|
2023-02-10 19:50:34 +00:00
|
|
|
"""
|
|
|
|
return sequence([
|
|
|
|
map_err(
|
|
|
|
lambda err_text: (col_no + 1, err_text),
|
|
|
|
read_single_elevation(elem)
|
|
|
|
)
|
|
|
|
# As per spec, we drop the last five entries of the file.
|
|
|
|
for (col_no, elem) in enumerate(line.split()[:-5])
|
|
|
|
])
|
|
|
|
|
2023-02-11 00:02:14 +00:00
|
|
|
class UnparsableElevation(NamedTuple):
|
|
|
|
"An elevation entry failed to be parsed as a floating point"
|
2023-02-10 19:50:34 +00:00
|
|
|
|
|
|
|
line_no: int
|
|
|
|
"The line on which the error occurred"
|
|
|
|
|
|
|
|
col_no: int
|
|
|
|
"The column (1-indexed number of whitespace deliniated words) on which the error occurred"
|
|
|
|
|
|
|
|
invalid_entry: str
|
|
|
|
"The text which failed to parse as a floating point value"
|
|
|
|
|
2023-02-11 00:02:14 +00:00
|
|
|
def read_elevations(lines: Iterable[str]) -> Result[Iterator[int], UnparsableElevation]:
|
2023-02-10 19:50:34 +00:00
|
|
|
"""
|
|
|
|
Read an entire elevation file into a list
|
|
|
|
|
|
|
|
This parses each line in of the file using `read_elevation_line()`. Errors
|
2023-02-11 00:02:14 +00:00
|
|
|
encountered are reported using an `UnparsableElevation`. The resulting list is
|
2023-02-10 19:50:34 +00:00
|
|
|
single-dimensional, but can be indexed arbitrarily, meaning for an input file with a
|
|
|
|
constant number of columns per line, this data can be accessed as if it were a 2d
|
|
|
|
square array.
|
|
|
|
|
|
|
|
However, keep in mind that, as with `read_elevation_line()`, the last five columns of
|
|
|
|
each line are dropped/ignored, and are therefor not present in the returned data.
|
|
|
|
|
|
|
|
>>> map_res(list, read_elevations('''
|
|
|
|
... 1 2 3 4 5 6 7 8 9e 10
|
|
|
|
... 11 2.0 3e1 4 5 6 7 bwa 9 10
|
|
|
|
... 11 2.0 3e0 4 5 ign err 8 9 10
|
2023-02-11 00:02:14 +00:00
|
|
|
... '''.strip().split('\\n')))
|
2023-02-10 21:35:01 +00:00
|
|
|
Ok([1000, 2000, 3000, 4000, 5000, 11000, 2000, 30000, 4000, 5000, 11000, 2000, 3000, 4000, 5000])
|
2023-02-10 19:50:34 +00:00
|
|
|
|
|
|
|
>>> map_res(list, read_elevations('''
|
|
|
|
... 1 2 3 4 5 6 7 8 9e 10
|
|
|
|
... 11 ERR 3e1 4 5 6 7 bwa 9 10
|
|
|
|
... 11 2.0 3e0 4 5 ign err 8 9 10
|
2023-02-11 00:02:14 +00:00
|
|
|
... '''.strip().split('\\n')))
|
|
|
|
Err(UnparsableElevation(line_no=2, col_no=2, invalid_entry='ERR'))
|
2023-02-10 19:50:34 +00:00
|
|
|
"""
|
|
|
|
return map_res(
|
|
|
|
chain.from_iterable,
|
|
|
|
sequence([
|
|
|
|
map_err(
|
2023-02-11 00:02:14 +00:00
|
|
|
lambda err: UnparsableElevation(line_no + 1, *err),
|
2023-02-10 19:50:34 +00:00
|
|
|
read_elevation_line(line)
|
|
|
|
)
|
2023-02-11 00:02:14 +00:00
|
|
|
for (line_no, line) in enumerate(lines)
|
2023-02-10 19:50:34 +00:00
|
|
|
])
|
|
|
|
)
|
|
|
|
|
2023-02-11 00:02:14 +00:00
|
|
|
def gb_to_terrain_type(green_blue: Tuple[int, int]) -> Option[Terrain]:
|
|
|
|
"""
|
|
|
|
Maps the green and blue components of a color to a terrain type.
|
|
|
|
|
|
|
|
If no terrain type exists with that color, then `None` is returned.
|
|
|
|
|
|
|
|
>>> gb_to_terrain_type((192, 0))
|
|
|
|
Some(<Terrain.ROUGH_MEADOW: 700>)
|
|
|
|
|
|
|
|
>>> gb_to_terrain_type((192, 1)) is None
|
|
|
|
True
|
|
|
|
"""
|
|
|
|
match green_blue:
|
|
|
|
case (148, 18):
|
|
|
|
return Some(Terrain.OPEN_LAND)
|
|
|
|
case (192, 0):
|
|
|
|
return Some(Terrain.ROUGH_MEADOW)
|
|
|
|
case (255, 255):
|
|
|
|
return Some(Terrain.EASY_FOREST)
|
|
|
|
case (208, 60):
|
|
|
|
return Some(Terrain.MEDIUM_FOREST)
|
|
|
|
case (136, 40):
|
|
|
|
return Some(Terrain.WALK_FOREST)
|
|
|
|
case (73, 24):
|
|
|
|
return Some(Terrain.BRUSH)
|
|
|
|
case (0, 255):
|
|
|
|
return Some(Terrain.WET)
|
|
|
|
case (51, 3):
|
|
|
|
return Some(Terrain.ROAD)
|
|
|
|
case (0, 0):
|
|
|
|
return Some(Terrain.FOOTPATH)
|
|
|
|
case (0, 101):
|
|
|
|
return Some(Terrain.OOB)
|
|
|
|
case _:
|
|
|
|
return None
|
|
|
|
|
|
|
|
class UnrecognizedColor(NamedTuple):
|
|
|
|
"A color in a provided map was not in the list of recognized colors"
|
|
|
|
|
|
|
|
x: int
|
|
|
|
"The x coordinate of the unidentifided color"
|
|
|
|
|
|
|
|
y: int
|
|
|
|
"The y coordinate of the unidentifided color"
|
|
|
|
|
2023-02-12 16:09:53 +00:00
|
|
|
def load_gb_pixels_as_map(pixels: Iterable[Tuple[int, int]]) -> Result[Sequence[Terrain], UnrecognizedColor]:
|
2023-02-11 00:02:14 +00:00
|
|
|
"""
|
|
|
|
Interpret the green and blue bands of an image as pixels on a map
|
|
|
|
|
|
|
|
This assumes that the map follows the specification laid out at
|
|
|
|
https://cs.rit.edu/~jro/courses/intelSys/labs/orienteering/
|
|
|
|
|
|
|
|
Namely, the map should be exactly 395 pixels wide, and should follow the color
|
|
|
|
conventions laid out in the Terrain Type table.
|
|
|
|
|
|
|
|
This function is only interested in the green and blue channels of the image. Rather
|
|
|
|
than pass a full pixel, it is only necessary to pass the green and blue color values.
|
|
|
|
|
|
|
|
If an error is encountered, the returned `Err` type will contain the x and y
|
|
|
|
coordinates of the pixel which could not be identified.
|
|
|
|
"""
|
|
|
|
return sequence([
|
|
|
|
note(
|
2023-02-12 21:58:59 +00:00
|
|
|
lambda: UnrecognizedColor(pix_no % 395, pix_no // 395),
|
2023-02-11 00:02:14 +00:00
|
|
|
gb_to_terrain_type(gb)
|
|
|
|
)
|
|
|
|
for (pix_no, gb) in enumerate(pixels)
|
|
|
|
])
|
|
|
|
|
2023-02-12 16:09:53 +00:00
|
|
|
def load_image(path: str) -> Result[Sequence[Terrain], UnrecognizedColor]:
|
2023-02-11 00:02:14 +00:00
|
|
|
"Run `load_gb_pixels_as_map` on an image read from a path"
|
|
|
|
with Image.open(path) as im:
|
|
|
|
return load_gb_pixels_as_map(zip(im.getdata(1), im.getdata(2)))
|
|
|
|
|
|
|
|
def load_elevations(path: str) -> Result[Iterator[int], UnparsableElevation]:
|
|
|
|
"Run `read_elevations` on a text file read from a path"
|
|
|
|
with open(path) as file:
|
|
|
|
return read_elevations(file)
|
|
|
|
|
2023-02-11 22:08:26 +00:00
|
|
|
def load_world_from_paths(map_path: str, elevation_path: str) -> Result[World, UnparsableElevation | UnrecognizedColor]:
|
2023-02-11 00:02:14 +00:00
|
|
|
"""
|
|
|
|
Read world information (terrain and elevation) from file paths.
|
|
|
|
|
|
|
|
See `read_elevations` and `load_gb_pixels_as_map` for more information on the format
|
|
|
|
that this data should be in. Note that for this method, you do not need to worry
|
|
|
|
about the channels present in the terrain map, just that it align with the colors and
|
|
|
|
size of the specified format.
|
|
|
|
"""
|
|
|
|
return bind_res(
|
|
|
|
lambda terrain_data:
|
|
|
|
map_res(
|
2023-02-11 22:08:26 +00:00
|
|
|
lambda elevation_data: World((*zip(terrain_data, elevation_data),)),
|
2023-02-11 00:02:14 +00:00
|
|
|
load_elevations(elevation_path)
|
|
|
|
),
|
|
|
|
load_image(map_path)
|
|
|
|
)
|
|
|
|
|
2023-02-12 16:10:07 +00:00
|
|
|
def parse_point(s: str) -> Result[Point, str]:
|
|
|
|
"""
|
|
|
|
Parses a point from a string
|
|
|
|
|
|
|
|
The point is expected to be a string containing two integer values seperated by
|
|
|
|
whitespace.
|
|
|
|
|
|
|
|
Any errors in parsing the string will result in an `Err` being returned, containing
|
|
|
|
the original string.
|
|
|
|
|
|
|
|
>>> parse_point('230 327')
|
|
|
|
Ok((230, 327))
|
|
|
|
|
|
|
|
>>> parse_point('230327')
|
|
|
|
Err('230327')
|
|
|
|
|
|
|
|
>>> parse_point('23O 327')
|
|
|
|
Err('23O 327')
|
|
|
|
"""
|
|
|
|
match s.strip().split():
|
|
|
|
case [p1, p2]:
|
|
|
|
try:
|
|
|
|
return Ok(Point(int(p1), int(p2)))
|
|
|
|
except ValueError:
|
|
|
|
return Err(s)
|
|
|
|
case _:
|
|
|
|
return Err(s)
|
|
|
|
|
|
|
|
def parse_points(lines: Iterable[str]) -> Result[Sequence[Point], str]:
|
|
|
|
"""
|
|
|
|
Parse a series of points
|
|
|
|
|
|
|
|
Each point should be in the format specified by `parse_point()`, and each should be on
|
|
|
|
it's own "line" (aka, item in an iterable). Blank lines are ignored and dropped. If
|
|
|
|
any one of the lines in the input string fail to parse, the text of the first failed
|
|
|
|
line will be returned in an `Err`.
|
|
|
|
|
|
|
|
>>> parse_points('''
|
|
|
|
... 230 327
|
|
|
|
... 276 279
|
|
|
|
...
|
|
|
|
... 303 240
|
|
|
|
... '''.split('\\n'))
|
|
|
|
Ok([(230, 327), (276, 279), (303, 240)])
|
|
|
|
|
|
|
|
>>> parse_points('''
|
|
|
|
... 230 327
|
|
|
|
... 276 2BAD
|
|
|
|
... 3BAD 240
|
|
|
|
... '''.split('\\n'))
|
|
|
|
Err('276 2BAD')
|
|
|
|
"""
|
|
|
|
return sequence([
|
|
|
|
parse_point(line)
|
|
|
|
for line in lines
|
|
|
|
if len(line) and not line.isspace()
|
|
|
|
])
|
|
|
|
|
|
|
|
def load_points(path: str) -> Result[Sequence[Point], str]:
|
|
|
|
"""
|
|
|
|
Load a file full of points by passing the lines to `parse_points()`
|
|
|
|
|
|
|
|
See `parse_points()` for format specifications
|
|
|
|
"""
|
|
|
|
with open(path) as f:
|
|
|
|
return parse_points(f)
|
|
|
|
|
2023-02-10 19:50:34 +00:00
|
|
|
if __name__ == '__main__':
|
|
|
|
import doctest
|
|
|
|
doctest.testmod()
|