diff --git a/docs/Background/companies.md b/docs/Background/companies.md index 40e992f..2aa184e 100644 --- a/docs/Background/companies.md +++ b/docs/Background/companies.md @@ -8,6 +8,17 @@ here. ## Raw Materials and Energy Corporations +### Red Rocket + +*For more details, see the Red Rocket entry in the Fallout RPG Core Rulebook (pg 230) and the [Red Rocket entry from the Fallout wiki](https://fallout.fandom.com/wiki/Red_Rocket).* + +Many remnants of the Red Rocket Corporation in the Gulf Coast Wasteland eventually coalesced and joined the Oil Barons. For more details, see [Oil Barons](../denizens_of_the_wasteland/factions/oilbarons.md). + +### Poseidon Energy + +*For more details, see the Poseidon entry in the Fallout RPG Core Rulebook (pg 231) and the [Poseidon Energy entry from the Fallout wiki](https://fallout.fandom.com/wiki/Poseidon_Energy).* + +Many remnants of the Poseidon Energy Corporation in the Gulf Coast Wasteland eventually coalesced and joined the Oil Barons. For more details, see [Oil Barons](../denizens_of_the_wasteland/factions/oilbarons.md). ## Vault-Tec diff --git a/docs/Background/geography/loot_locations.md b/docs/Background/geography/loot_locations.md index 844a990..e83347e 100644 --- a/docs/Background/geography/loot_locations.md +++ b/docs/Background/geography/loot_locations.md @@ -11,6 +11,17 @@ included in the list solely to contain a geotag to be used on the auto-rendered ## Super Markets -Super Markets are a great place to find food, medicine, and other useful items. +Super Markets are a great place to find food, medicine, and other useful items. Super Markets are generally unmaintained and well-picked over. Often, they will be inhabited by raiders or other scavengers. For more details, consult the *Fallout Core Rulebook* (p. 244). - \ No newline at end of file + + +## Gas Stations + +Gas Stations are a great place to find fuel, and sometimes food and medicine. Unlike in many other regions of the former +United States, gas stations in the Gulf Coast Wasteland (due to the relative abundance of oil and nuclear fuels) are +often still stocked and maintained. Those that are maintained will receive regular shipments of fuel from the Oil +Barons, and will often have a small convenience store attached to them. + +As the game master, you can decide whether a given gas station is maintained or not. + + \ No newline at end of file diff --git a/docs/Background/geography/neighborhoods.md b/docs/Background/geography/neighborhoods.md index 8c21436..6d860c9 100644 --- a/docs/Background/geography/neighborhoods.md +++ b/docs/Background/geography/neighborhoods.md @@ -578,8 +578,8 @@ The PC with the highest Charisma makes a settlement reputation test **(CHA + Rep ## Radiant Shores (formerly Pearland) diff --git a/mkdocs.yml b/mkdocs.yml index 7a0499b..4c11e8f 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -126,4 +126,4 @@ extra: zoom: 9 auto_populate: center: [29.7604, -95.3698] - population_radius: 35000 # Radius around the center to auto-populate locations (in meters) \ No newline at end of file + population_radius: 130000 # Radius around the center to auto-populate locations (in meters) \ No newline at end of file diff --git a/render_map/auto_populate/auto_populate_hooks.py b/render_map/auto_populate/auto_populate_hooks.py index a947481..ae78d71 100644 --- a/render_map/auto_populate/auto_populate_hooks.py +++ b/render_map/auto_populate/auto_populate_hooks.py @@ -4,6 +4,7 @@ from render_map.auto_populate import auto_populate_map + @mkdocs.plugins.event_priority(100) def on_page_markdown( markdown: str, @@ -28,7 +29,8 @@ def on_page_markdown( latitude, longitude = config.extra["auto_populate"]["center"] radius = config.extra["auto_populate"]["population_radius"] - geolinks, markdown = auto_populate_map.find_auto_populate_geotags(markdown, latitude, longitude, radius) + geolinks, markdown = auto_populate_map.find_auto_populate_geotags( + markdown, latitude, longitude, radius + ) return markdown - diff --git a/render_map/auto_populate/auto_populate_map.py b/render_map/auto_populate/auto_populate_map.py index 5570e1d..0dd0447 100644 --- a/render_map/auto_populate/auto_populate_map.py +++ b/render_map/auto_populate/auto_populate_map.py @@ -1,33 +1,58 @@ """Automatically populate the map with supermarkets and other landmarks using the Overpass (Open Street Map) API.""" from __future__ import annotations +from typing import TypeVar, Sequence, Hashable, Callable, TypeAlias + import bs4 +import mkdocs.plugins import overpy import pydantic from render_map import mapping -RADIUS=35000 -CENTRAL_LATITUDE=29.7063997 -CENTRAL_LONGITUDE=-95.553997 +LOGGER = mkdocs.plugins.get_plugin_logger(__name__) + +T = TypeVar("T") +NameZoomIcon: TypeAlias = tuple[ + str | None, mapping.ZoomLevel, mapping.map_icons.MapIcon +] -SUPER_MARKET_QUERY="""[out:json]; +SUPER_MARKET_QUERY = """[out:json]; (node["building"="supermarket"](around:{radius},{lat},{lon}); node["shop"="supermarket"](around:{radius},{lat},{lon}); - /*way["shop"="supermarket"](around:{radius},{lat},{lon});*/ + way["shop"="supermarket"](around:{radius},{lat},{lon}); +); +(._;>;); +out meta; +""" + +GAS_STATION_QUERY = """[out:json]; +(node["amenity"="fuel"](around:{radius},{lat},{lon}); +node["amenity"="charging_station"](around:{radius},{lat},{lon}); +node[name="Buc-ee's"](around:{radius},{lat},{lon}); +node[brand="Buc-ee's"](around:{radius},{lat},{lon}); +way[brand="Buc-ee's"][shop="convenience"](around:{radius},{lat},{lon}); ); (._;>;); out meta; """ +GAS_STATIONS: list[tuple[str, mapping.map_icons.MapIcon]] = [ + ("Red Rocket", mapping.map_icons.MapIcon.ROCKET), + ("Poseidon Energy", mapping.map_icons.MapIcon.POSEIDON), + # ("Petro-Chico", mapping.map_icons.MapIcon.SOMBRERO), + ("Gas Station", mapping.map_icons.MapIcon.GAS_STATION), +] API = overpy.Overpass() + class AutoPopulateConfig(pydantic.BaseModel): """The configuration for the auto-populate plugin.""" supermarket: bool = False + gas_station: bool = False @staticmethod def tag_name() -> str: @@ -35,7 +60,7 @@ def tag_name() -> str: return "populate_geotag" @classmethod - def from_dict(cls, config_dict: dict[str, str|bool]) -> AutoPopulateConfig: + def from_dict(cls, config_dict: dict[str, str | bool]) -> AutoPopulateConfig: """Create a config object from a dictionary. Args: @@ -49,7 +74,32 @@ def from_dict(cls, config_dict: dict[str, str|bool]) -> AutoPopulateConfig: config_dict[key] = True return cls(**config_dict) -def find_auto_populate_geotags(markdown: str, latitude:float, longitude:float, radius:float) -> tuple[list[AutoPopulateConfig], str]: + +def choose_item_from_list(list_: Sequence[T], criterion: Hashable) -> T: + """Choose an item in such a way that it is fully deterministic and reproducible. The items must also be chosen uniformly. + + This is done by effectively using a poor-man's hash function with a co-domain of the length of the list. + + Args: + list_: The list to choose from. + criterion: The criterion to choose the item by. + + Returns: + An item from the list. + + Raises: + ValueError: If the list is empty. + """ + if len(list_) == 0: + raise ValueError("List must not be empty.") + + index = hash(criterion) % len(list_) + return list_[index] + + +def find_auto_populate_geotags( + markdown: str, latitude: float, longitude: float, radius: float +) -> tuple[list[AutoPopulateConfig], str]: """Find all geotags in the markdown and process them into a list of GeoLink objects. Args: @@ -73,19 +123,67 @@ def find_auto_populate_geotags(markdown: str, latitude:float, longitude:float, r populate_geotags_configs.append(geotag_config) # Replace the geotag with a the list of supermarkets - new_tag = soup.new_tag("div") + bulleted_list = soup.new_tag("div") if geotag_config.supermarket: - supermarkets_tags = populate_supermarkets(radius, latitude, longitude) - for tag in supermarkets_tags: - new_tag.append("- ") - new_tag.append(tag) - new_tag.append("\n") - geo_tag.replace_with(new_tag) - new_tag.unwrap() + populate_tags( + SUPER_MARKET_QUERY, + "supermarkets", + choose_supermarket_name_zoom_icon, + bulleted_list, + radius, + latitude, + longitude, + ) + if geotag_config.gas_station: + populate_tags( + GAS_STATION_QUERY, + "gas stations", + choose_gas_station_name_zoom_icon, + bulleted_list, + radius, + latitude, + longitude, + ) + geo_tag.replace_with(bulleted_list) + bulleted_list.unwrap() # If we don't unwrap the page will not treat all the bullet points as siblings under root, and thus the markdown will not be rendered correctly. return populate_geotags_configs, str(soup) -def choose_supermarket_name_zoom(node:overpy.Node) -> tuple[str|None, mapping.ZoomLevel]: +def populate_tags( + query: str, + feature_type_name: str, + choose_name_function: Callable[[overpy.Node | overpy.Way], NameZoomIcon], + parent_tag: bs4.Tag, + radius: float, + latitude: float, + longitude: float, +) -> list[bs4.Tag]: + """Call a populate function and convert the results to bulleted lists. + + Args: + query: The query to search for in the OpenStreetMap API. + feature_type_name: The name of the feature type to search for (used for logging). + choose_name_function: A function that chooses the name and zoom level for a particular feature given an OpenStreetMap node or way. + radius: The radius in meters to search for supermarkets. + latitude: The latitude to search for supermarkets. + longitude: The longitude to search for supermarkets. + parent_tag: The tag to add the bulleted list to. + + Returns: + A string of geotags for the supermarkets. + """ + tags = populate_features( + query, feature_type_name, choose_name_function, radius, latitude, longitude + ) + for tag in tags: + # Convert a geotag to a bulleted list, modifying the `parent_tag` in place. + parent_tag.append("- ") + parent_tag.append(tag) + parent_tag.append("\n") + return tags + + +def choose_supermarket_name_zoom_icon(node: overpy.Node) -> NameZoomIcon: """Choose the game name and map zoom level for a supermarket, based on the properties of the supermarket in the real world. @@ -98,37 +196,119 @@ def choose_supermarket_name_zoom(node:overpy.Node) -> tuple[str|None, mapping.Zo name_from_node = node.tags.get("name", None) # If the supermarket is not named in OpenStreetMap, we'll (unfairly) assume it's not a very important supermarket. if name_from_node is None: - return None, mapping.ZoomLevel.WASTELAND + return ( + None, + mapping.ZoomLevel.WASTELAND, + mapping.map_icons.MapIcon.SUPER_DUPER_MART, + ) # Super-Duper Mart is implied to be a chain of very large supermarkets, likely wholesale. In the video games, there # is only one Super-Duper Mart in its corresponding city metro-area. - if "walmart" in name_from_node.lower() or "sam's" in name_from_node.lower() or "costco" in name_from_node.lower(): - return "Super-Duper Mart", mapping.ZoomLevel.WASTELAND + if ( + "walmart" in name_from_node.lower() + or "sam's" in name_from_node.lower() + or "costco" in name_from_node.lower() + ): + # Only a quarter of the supermarkets should be visible from the large wasteland map. + zoom_level = ( + mapping.ZoomLevel.TOWN if node.id % 4 else mapping.ZoomLevel.WASTELAND + ) + return ( + "Super-Duper Mart", + zoom_level, + mapping.map_icons.MapIcon.SUPER_DUPER_MART, + ) # TODO: Provide more plausible and generic names for super markets. - return "Supermarket", mapping.ZoomLevel.TOWN + return ( + "Supermarket", + mapping.ZoomLevel.TOWN, + mapping.map_icons.MapIcon.SUPER_DUPER_MART, + ) -def populate_supermarkets(radius:float, latitude:float, longitude:float) -> list[bs4.Tag]: - """Generate geotags for supermarkets in the game world, using the locations of supermarkets in the real world (using - the Overpass API). +def choose_gas_station_name_zoom_icon(node: overpy.Node | overpy.Way) -> NameZoomIcon: + """Choose the game name and map zoom level for a supermarket, based on the properties of the supermarket in the + real world. Args: + node: The node in OpenStreetMap representing the supermarket. + + Returns: + Game name and map zoom level for the supermarket. + """ + name_from_node = node.tags.get("name", "") + brand_from_node = node.tags.get("brand", "") + # Womb-ee's is a fictional gas station chain in the Fallout: Houston campaign. + # It is a parody of Buc-ee's, a real gas station chain in Texas. + if "buc-ee" in name_from_node.lower() or "buc-ee" in brand_from_node.lower(): + return ( + "Womb-ee's", + mapping.ZoomLevel.WASTELAND, + mapping.map_icons.MapIcon.BEAVER, + ) + # If the gas station is not named in OpenStreetMap, we'll (unfairly) assume it's not very important. + if name_from_node is None: + return None, mapping.ZoomLevel.WASTELAND, mapping.map_icons.MapIcon.GAS_STATION + + name, icon = choose_item_from_list(GAS_STATIONS, name_from_node) + + # # We want only about a quarter of the gas stations to be visible from the large wasteland map. + # zoom_level = mapping.ZoomLevel.TOWN if hash((name_from_node, node.id)) % 4 else mapping.ZoomLevel.WASTELAND + zoom_level = mapping.ZoomLevel.TOWN + return name, zoom_level, icon + + +def populate_features( + query: str, + feature_type_name: str, + choose_name_function: Callable[[overpy.Node | overpy.Way], NameZoomIcon], + radius: float, + latitude: float, + longitude: float, +) -> list[bs4.Tag]: + """Populate the map with features (matching a query) from OpenStreetMap using icons and names from `choose_name_function`. + + Args: + query: The query to search for in the OpenStreetMap API. + feature_type_name: The name of the feature type to search for (used for logging). + choose_name_function: A function that chooses the name and zoom level for a particular feature given an OpenStreetMap node or way. radius: The radius in meters to search for supermarkets. latitude: The latitude to search for supermarkets. longitude: The longitude to search for supermarkets. Returns: - A string of geotags for the supermarkets. + A list of geotags for the features to place in the web page source. """ - shops = API.query(SUPER_MARKET_QUERY.format(radius=radius, lat=latitude, lon=longitude)) + features = API.query(query.format(radius=radius, lat=latitude, lon=longitude)) geotags: list[mapping.GeoLink] = [] - for node in shops.nodes: - name, zoom = choose_supermarket_name_zoom(node) + + # First iterate over the ways, but eject the nodes from the ways so that we don't add duplicate geotags. + # We only want one geotag per ways, and some nodes will be duplicated in `shops.nodes`. + node_ids_to_ignore: list[int] = [] + for way in features.ways: + node_ids_to_ignore.extend([node.id for node in way.nodes]) + name, zoom, icon = choose_name_function(way) + # Skip over unnamed features (they're likely not important enough to show up on the game map). if name is None: continue + latitude = way.center_lat or way.nodes[0].lat + longitude = way.center_lon or way.nodes[0].lon geotags.append( mapping.GeoLink( - name=name, latitude=node.lat, longitude=node.lon, zoom=zoom, icon=mapping.map_icons.MapIcon.SUPER_DUPER_MART + name=name, latitude=latitude, longitude=longitude, zoom=zoom, icon=icon ) + ) + for node in features.nodes: + if node.id in node_ids_to_ignore: + continue + name, zoom, icon = choose_name_function(node) + # Skip over unnamed features (they're likely not important enough to show up on the game map). + if name is None: + continue + geotags.append( + mapping.GeoLink( + name=name, latitude=node.lat, longitude=node.lon, zoom=zoom, icon=icon + ) ) + LOGGER.info(f"Added {len(geotags)} {feature_type_name}.") return [geotag.get_tag() for geotag in geotags] diff --git a/render_map/map_icons.py b/render_map/map_icons.py index c86ecb2..09ad8d2 100644 --- a/render_map/map_icons.py +++ b/render_map/map_icons.py @@ -11,6 +11,12 @@ class MapIcon(enum.Enum): ) ROCKET = "https://static.wikia.nocookie.net/fallout_gamepedia/images/c/ce/153.svg" + GAS_STATION = ( + "https://game-icons.net/icons/ffffff/000000/1x1/delapouite/gas-pump.png" + ) + POSEIDON = "https://game-icons.net/icons/ffffff/000000/1x1/lorc/trident.png" + SOMBRERO = "https://game-icons.net/icons/ffffff/000000/1x1/delapouite/sombrero.png" + BEAVER = "https://game-icons.net/icons/ffffff/000000/1x1/delapouite/beaver.png" SATELLITE = "https://static.wikia.nocookie.net/fallout_gamepedia/images/e/ea/47.svg" BOAT = "https://static.wikia.nocookie.net/fallout_gamepedia/images/8/86/11.svg/" @@ -60,7 +66,9 @@ class MapIcon(enum.Enum): LIGHT = "https://static.wikia.nocookie.net/fallout_gamepedia/images/e/ed/59.svg" RADIO = "https://static.wikia.nocookie.net/fallout_gamepedia/images/6/64/62.svg" - DOOR_TARGET = "https://static.wikia.nocookie.net/fallout_gamepedia/images/0/03/68.svg" + DOOR_TARGET = ( + "https://static.wikia.nocookie.net/fallout_gamepedia/images/0/03/68.svg" + ) FOOD = SETTLEMENT WATER = "https://static.wikia.nocookie.net/fallout_gamepedia/images/b/bd/35.svg" POWER = RADIATION @@ -68,9 +76,13 @@ class MapIcon(enum.Enum): CARAVAN = WAREHOUSE INN = DOOR_TARGET - SUPER_DUPER_MART = "https://static.wikia.nocookie.net/fallout_gamepedia/images/e/e9/14.svg" + SUPER_DUPER_MART = ( + "https://static.wikia.nocookie.net/fallout_gamepedia/images/e/e9/14.svg" + ) CEMETARY = "https://static.wikia.nocookie.net/fallout_gamepedia/images/d/df/144.svg" - RICE_VILLAGE = "https://static.wikia.nocookie.net/fallout_gamepedia/images/c/c8/147.svg" + RICE_VILLAGE = ( + "https://static.wikia.nocookie.net/fallout_gamepedia/images/c/c8/147.svg" + ) DRIVE_IN = "https://static.wikia.nocookie.net/fallout_gamepedia/images/5/51/169.svg" CAR = "https://static.wikia.nocookie.net/fallout_gamepedia/images/3/31/200.svg" @@ -79,15 +91,21 @@ class MapIcon(enum.Enum): MINE = "https://static.wikia.nocookie.net/fallout_gamepedia/images/0/0f/76.svg" BUNKER = "https://static.wikia.nocookie.net/fallout_gamepedia/images/f/f3/206.svg" - TRAILER_PARK = "https://static.wikia.nocookie.net/fallout_gamepedia/images/b/b4/203.svg" + TRAILER_PARK = ( + "https://static.wikia.nocookie.net/fallout_gamepedia/images/b/b4/203.svg" + ) STATUE = "https://static.wikia.nocookie.net/fallout_gamepedia/images/b/be/50.svg/" POLICE = "https://static.wikia.nocookie.net/fallout_gamepedia/images/3/3d/88.svg" - AMUSEMENT_PARK = "https://static.wikia.nocookie.net/fallout_gamepedia/images/8/84/185.svg" + AMUSEMENT_PARK = ( + "https://static.wikia.nocookie.net/fallout_gamepedia/images/8/84/185.svg" + ) SQUARE = "https://static.wikia.nocookie.net/fallout_gamepedia/images/e/eb/173.svg" OVERPASS = "https://static.wikia.nocookie.net/fallout_gamepedia/images/a/a1/166.svg" - BEACH="https://static.wikia.nocookie.net/fallout_gamepedia/images/f/fa/123.svg" - PLANETARIUM = "https://static.wikia.nocookie.net/fallout_gamepedia/images/1/14/102.svg" \ No newline at end of file + BEACH = "https://static.wikia.nocookie.net/fallout_gamepedia/images/f/fa/123.svg" + PLANETARIUM = ( + "https://static.wikia.nocookie.net/fallout_gamepedia/images/1/14/102.svg" + ) diff --git a/render_map/mapping.py b/render_map/mapping.py index dd9bac4..6e208fe 100644 --- a/render_map/mapping.py +++ b/render_map/mapping.py @@ -35,11 +35,12 @@ ) MAP_TEMPLATE = MAP_TEMPLATE.replace("{{STYLE}}", json.dumps(MAP_STYLE_JSON)) + class ZoomLevel(enum.Enum): """The zoom level of the map.""" WASTELAND = 0 # Always visible - TOWN=13 + TOWN = 13 class GeoLink(pydantic.BaseModel): @@ -54,7 +55,7 @@ class GeoLink(pydantic.BaseModel): zoom: ZoomLevel = pydantic.Field(default=ZoomLevel.WASTELAND, validate_default=True) uuid: str = pydantic.Field(default_factory=lambda: str(uuid.uuid4())) - def get_tag(self, include_uuid:bool=False) -> bs4.Tag: + def get_tag(self, include_uuid: bool = False) -> bs4.Tag: """Get the geotag as a string. Args: @@ -75,9 +76,13 @@ def get_tag(self, include_uuid:bool=False) -> bs4.Tag: return tag + GEO_LINKS: list[GeoLink] = [] -def resolve_enum(result:dict[str,str], enum_type: Type[EnumType], enum_key:str) -> None: + +def resolve_enum( + result: dict[str, str], enum_type: Type[EnumType], enum_key: str +) -> None: """Resolve an enum from a string. If the enum key is in the result, then the enum is resolved. If the value of the enum key is empty, then the enum is removed from the result. @@ -89,10 +94,11 @@ def resolve_enum(result:dict[str,str], enum_type: Type[EnumType], enum_key:str) """ if enum_key in result: if result[enum_key]: - result[enum_key] = getattr(enum_type, result[enum_key]) + result[enum_key] = getattr(enum_type, result[enum_key]) else: result.pop(enum_key) + def find_geo_links(markdown: str) -> tuple[list[GeoLink], str]: """Find all geotags in the markdown and process them into a list of GeoLink objects. @@ -118,7 +124,7 @@ def find_geo_links(markdown: str) -> tuple[list[GeoLink], str]: # Replace the geotag with a span tag with the uuid as the id and the name as the text new_tag = soup.new_tag("span") new_tag.string = geo_link.name - new_tag['id'] = geo_link.uuid + new_tag["id"] = geo_link.uuid geo_tag.replace_with(new_tag) return geo_links, str(soup) @@ -146,5 +152,3 @@ def create_map_template(config: mkdocs.plugins.MkDocsConfig) -> str: map_source = map_source.replace("{{MARKERS}}", json.dumps(markers)) return map_source - - diff --git a/render_map/plugin_hooks.py b/render_map/plugin_hooks.py index 7f3a64b..b55703f 100644 --- a/render_map/plugin_hooks.py +++ b/render_map/plugin_hooks.py @@ -4,6 +4,7 @@ from render_map import mapping + @mkdocs.plugins.event_priority(0) def on_page_markdown( markdown: str,