2023-11-25 14:22:30 +00:00
|
|
|
import functools
|
|
|
|
import logging
|
|
|
|
from typing import Any, Dict, List, Set
|
|
|
|
|
2024-04-18 16:53:09 +00:00
|
|
|
from BaseClasses import Entrance, CollectionState, Item, Location, MultiWorld, Region, Tutorial
|
2023-11-25 14:22:30 +00:00
|
|
|
from worlds.AutoWorld import WebWorld, World
|
2024-04-18 16:53:09 +00:00
|
|
|
from . import Items, Locations, Maps, Regions, Rules
|
|
|
|
from .Options import HereticOptions
|
2023-11-25 14:22:30 +00:00
|
|
|
|
|
|
|
logger = logging.getLogger("Heretic")
|
|
|
|
|
|
|
|
HERETIC_TYPE_LEVEL_COMPLETE = -2
|
|
|
|
HERETIC_TYPE_MAP_SCROLL = 35
|
|
|
|
|
|
|
|
|
|
|
|
class HereticLocation(Location):
|
|
|
|
game: str = "Heretic"
|
|
|
|
|
|
|
|
|
|
|
|
class HereticItem(Item):
|
|
|
|
game: str = "Heretic"
|
|
|
|
|
|
|
|
|
|
|
|
class HereticWeb(WebWorld):
|
|
|
|
tutorials = [Tutorial(
|
|
|
|
"Multiworld Setup Guide",
|
|
|
|
"A guide to setting up the Heretic randomizer connected to an Archipelago Multiworld",
|
|
|
|
"English",
|
|
|
|
"setup_en.md",
|
|
|
|
"setup/en",
|
|
|
|
["Daivuk"]
|
|
|
|
)]
|
|
|
|
theme = "dirt"
|
|
|
|
|
|
|
|
|
|
|
|
class HereticWorld(World):
|
|
|
|
"""
|
|
|
|
Heretic is a dark fantasy first-person shooter video game released in December 1994. It was developed by Raven Software.
|
|
|
|
"""
|
2024-04-18 16:53:09 +00:00
|
|
|
options_dataclass = HereticOptions
|
|
|
|
options: HereticOptions
|
2023-11-25 14:22:30 +00:00
|
|
|
game = "Heretic"
|
|
|
|
web = HereticWeb()
|
|
|
|
required_client_version = (0, 3, 9)
|
|
|
|
|
|
|
|
item_name_to_id = {data["name"]: item_id for item_id, data in Items.item_table.items()}
|
|
|
|
item_name_groups = Items.item_name_groups
|
|
|
|
|
|
|
|
location_name_to_id = {data["name"]: loc_id for loc_id, data in Locations.location_table.items()}
|
|
|
|
location_name_groups = Locations.location_name_groups
|
|
|
|
|
|
|
|
starting_level_for_episode: List[str] = [
|
|
|
|
"The Docks (E1M1)",
|
|
|
|
"The Crater (E2M1)",
|
|
|
|
"The Storehouse (E3M1)",
|
|
|
|
"Catafalque (E4M1)",
|
|
|
|
"Ochre Cliffs (E5M1)"
|
|
|
|
]
|
|
|
|
|
2024-04-18 16:53:09 +00:00
|
|
|
boss_level_for_episode: List[str] = [
|
2023-11-25 14:22:30 +00:00
|
|
|
"Hell's Maw (E1M8)",
|
|
|
|
"The Portals of Chaos (E2M8)",
|
|
|
|
"D'Sparil'S Keep (E3M8)",
|
|
|
|
"Shattered Bridge (E4M8)",
|
|
|
|
"Field of Judgement (E5M8)"
|
|
|
|
]
|
|
|
|
|
|
|
|
# Item ratio that scales depending on episode count. These are the ratio for 1 episode.
|
|
|
|
items_ratio: Dict[str, float] = {
|
|
|
|
"Timebomb of the Ancients": 16,
|
|
|
|
"Tome of Power": 16,
|
|
|
|
"Silver Shield": 10,
|
|
|
|
"Enchanted Shield": 5,
|
2024-06-19 10:59:10 +00:00
|
|
|
"Torch": 5,
|
2023-11-25 14:22:30 +00:00
|
|
|
"Morph Ovum": 3,
|
|
|
|
"Mystic Urn": 2,
|
|
|
|
"Chaos Device": 1,
|
|
|
|
"Ring of Invincibility": 1,
|
|
|
|
"Shadowsphere": 1
|
|
|
|
}
|
|
|
|
|
2024-04-18 16:53:09 +00:00
|
|
|
def __init__(self, multiworld: MultiWorld, player: int):
|
2023-11-25 14:22:30 +00:00
|
|
|
self.included_episodes = [1, 1, 1, 0, 0]
|
|
|
|
self.location_count = 0
|
|
|
|
|
2024-04-18 16:53:09 +00:00
|
|
|
super().__init__(multiworld, player)
|
2023-11-25 14:22:30 +00:00
|
|
|
|
|
|
|
def get_episode_count(self):
|
|
|
|
return functools.reduce(lambda count, episode: count + episode, self.included_episodes)
|
|
|
|
|
|
|
|
def generate_early(self):
|
|
|
|
# Cache which episodes are included
|
2024-04-18 16:53:09 +00:00
|
|
|
self.included_episodes[0] = self.options.episode1.value
|
|
|
|
self.included_episodes[1] = self.options.episode2.value
|
|
|
|
self.included_episodes[2] = self.options.episode3.value
|
|
|
|
self.included_episodes[3] = self.options.episode4.value
|
|
|
|
self.included_episodes[4] = self.options.episode5.value
|
2023-11-25 14:22:30 +00:00
|
|
|
|
|
|
|
# If no episodes selected, select Episode 1
|
|
|
|
if self.get_episode_count() == 0:
|
|
|
|
self.included_episodes[0] = 1
|
|
|
|
|
|
|
|
def create_regions(self):
|
2024-04-18 16:53:09 +00:00
|
|
|
pro = self.options.pro.value
|
|
|
|
check_sanity = self.options.check_sanity.value
|
2023-11-25 14:22:30 +00:00
|
|
|
|
|
|
|
# Main regions
|
|
|
|
menu_region = Region("Menu", self.player, self.multiworld)
|
|
|
|
hub_region = Region("Hub", self.player, self.multiworld)
|
|
|
|
self.multiworld.regions += [menu_region, hub_region]
|
|
|
|
menu_region.add_exits(["Hub"])
|
|
|
|
|
|
|
|
# Create regions and locations
|
|
|
|
main_regions = []
|
|
|
|
connections = []
|
|
|
|
for region_dict in Regions.regions:
|
|
|
|
if not self.included_episodes[region_dict["episode"] - 1]:
|
|
|
|
continue
|
|
|
|
|
|
|
|
region_name = region_dict["name"]
|
|
|
|
if region_dict["connects_to_hub"]:
|
|
|
|
main_regions.append(region_name)
|
|
|
|
|
|
|
|
region = Region(region_name, self.player, self.multiworld)
|
|
|
|
region.add_locations({
|
|
|
|
loc["name"]: loc_id
|
|
|
|
for loc_id, loc in Locations.location_table.items()
|
|
|
|
if loc["region"] == region_name and (not loc["check_sanity"] or check_sanity)
|
|
|
|
}, HereticLocation)
|
|
|
|
|
|
|
|
self.multiworld.regions.append(region)
|
|
|
|
|
|
|
|
for connection_dict in region_dict["connections"]:
|
|
|
|
# Check if it's a pro-only connection
|
|
|
|
if connection_dict["pro"] and not pro:
|
|
|
|
continue
|
|
|
|
connections.append((region, connection_dict["target"]))
|
|
|
|
|
|
|
|
# Connect main regions to Hub
|
|
|
|
hub_region.add_exits(main_regions)
|
|
|
|
|
|
|
|
# Do the other connections between regions (They are not all both ways)
|
|
|
|
for connection in connections:
|
|
|
|
source = connection[0]
|
|
|
|
target = self.multiworld.get_region(connection[1], self.player)
|
|
|
|
|
|
|
|
entrance = Entrance(self.player, f"{source.name} -> {target.name}", source)
|
|
|
|
source.exits.append(entrance)
|
|
|
|
entrance.connect(target)
|
|
|
|
|
|
|
|
# Sum locations for items creation
|
|
|
|
self.location_count = len(self.multiworld.get_locations(self.player))
|
|
|
|
|
|
|
|
def completion_rule(self, state: CollectionState):
|
|
|
|
goal_levels = Maps.map_names
|
2024-04-18 16:53:09 +00:00
|
|
|
if self.options.goal.value:
|
|
|
|
goal_levels = self.boss_level_for_episode
|
2023-11-25 14:22:30 +00:00
|
|
|
|
|
|
|
for map_name in goal_levels:
|
|
|
|
if map_name + " - Exit" not in self.location_name_to_id:
|
|
|
|
continue
|
|
|
|
|
|
|
|
# Exit location names are in form: The Docks (E1M1) - Exit
|
|
|
|
loc = Locations.location_table[self.location_name_to_id[map_name + " - Exit"]]
|
|
|
|
if not self.included_episodes[loc["episode"] - 1]:
|
|
|
|
continue
|
|
|
|
|
|
|
|
# Map complete item names are in form: The Docks (E1M1) - Complete
|
|
|
|
if not state.has(map_name + " - Complete", self.player, 1):
|
|
|
|
return False
|
|
|
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
def set_rules(self):
|
2024-04-18 16:53:09 +00:00
|
|
|
pro = self.options.pro.value
|
|
|
|
allow_death_logic = self.options.allow_death_logic.value
|
2023-11-25 14:22:30 +00:00
|
|
|
|
|
|
|
Rules.set_rules(self, self.included_episodes, pro)
|
|
|
|
self.multiworld.completion_condition[self.player] = lambda state: self.completion_rule(state)
|
|
|
|
|
|
|
|
# Forbid progression items to locations that can be missed and can't be picked up. (e.g. One-time timed
|
|
|
|
# platform) Unless the user allows for it.
|
|
|
|
if not allow_death_logic:
|
|
|
|
for death_logic_location in Locations.death_logic_locations:
|
2024-05-10 14:29:07 +00:00
|
|
|
self.options.exclude_locations.value.add(death_logic_location)
|
2023-11-25 14:22:30 +00:00
|
|
|
|
|
|
|
def create_item(self, name: str) -> HereticItem:
|
|
|
|
item_id: int = self.item_name_to_id[name]
|
|
|
|
return HereticItem(name, Items.item_table[item_id]["classification"], item_id, self.player)
|
|
|
|
|
|
|
|
def create_items(self):
|
|
|
|
itempool: List[HereticItem] = []
|
2024-04-18 16:53:09 +00:00
|
|
|
start_with_map_scrolls: bool = self.options.start_with_map_scrolls.value
|
2023-11-25 14:22:30 +00:00
|
|
|
|
|
|
|
# Items
|
|
|
|
for item_id, item in Items.item_table.items():
|
|
|
|
if item["doom_type"] == HERETIC_TYPE_LEVEL_COMPLETE:
|
|
|
|
continue # We'll fill it manually later
|
|
|
|
|
|
|
|
if item["doom_type"] == HERETIC_TYPE_MAP_SCROLL and start_with_map_scrolls:
|
|
|
|
continue # We'll fill it manually, and we will put fillers in place
|
|
|
|
|
|
|
|
if item["episode"] != -1 and not self.included_episodes[item["episode"] - 1]:
|
|
|
|
continue
|
|
|
|
|
|
|
|
count = item["count"] if item["name"] not in self.starting_level_for_episode else item["count"] - 1
|
|
|
|
itempool += [self.create_item(item["name"]) for _ in range(count)]
|
|
|
|
|
|
|
|
# Place end level items in locked locations
|
|
|
|
for map_name in Maps.map_names:
|
|
|
|
loc_name = map_name + " - Exit"
|
|
|
|
item_name = map_name + " - Complete"
|
|
|
|
|
|
|
|
if loc_name not in self.location_name_to_id:
|
|
|
|
continue
|
|
|
|
|
|
|
|
if item_name not in self.item_name_to_id:
|
|
|
|
continue
|
|
|
|
|
|
|
|
loc = Locations.location_table[self.location_name_to_id[loc_name]]
|
|
|
|
if not self.included_episodes[loc["episode"] - 1]:
|
|
|
|
continue
|
|
|
|
|
|
|
|
self.multiworld.get_location(loc_name, self.player).place_locked_item(self.create_item(item_name))
|
|
|
|
self.location_count -= 1
|
|
|
|
|
|
|
|
# Give starting levels right away
|
|
|
|
for i in range(len(self.included_episodes)):
|
|
|
|
if self.included_episodes[i]:
|
|
|
|
self.multiworld.push_precollected(self.create_item(self.starting_level_for_episode[i]))
|
|
|
|
|
|
|
|
# Give Computer area maps if option selected
|
2024-04-18 16:53:09 +00:00
|
|
|
if self.options.start_with_map_scrolls.value:
|
2023-11-25 14:22:30 +00:00
|
|
|
for item_id, item_dict in Items.item_table.items():
|
|
|
|
item_episode = item_dict["episode"]
|
|
|
|
if item_episode > 0:
|
|
|
|
if item_dict["doom_type"] == HERETIC_TYPE_MAP_SCROLL and self.included_episodes[item_episode - 1]:
|
|
|
|
self.multiworld.push_precollected(self.create_item(item_dict["name"]))
|
|
|
|
|
|
|
|
# Fill the rest starting with powerups, then fillers
|
|
|
|
self.create_ratioed_items("Chaos Device", itempool)
|
|
|
|
self.create_ratioed_items("Morph Ovum", itempool)
|
|
|
|
self.create_ratioed_items("Mystic Urn", itempool)
|
|
|
|
self.create_ratioed_items("Ring of Invincibility", itempool)
|
|
|
|
self.create_ratioed_items("Shadowsphere", itempool)
|
2024-06-19 10:59:10 +00:00
|
|
|
self.create_ratioed_items("Torch", itempool)
|
2023-11-25 14:22:30 +00:00
|
|
|
self.create_ratioed_items("Timebomb of the Ancients", itempool)
|
|
|
|
self.create_ratioed_items("Tome of Power", itempool)
|
|
|
|
self.create_ratioed_items("Silver Shield", itempool)
|
|
|
|
self.create_ratioed_items("Enchanted Shield", itempool)
|
|
|
|
|
|
|
|
while len(itempool) < self.location_count:
|
|
|
|
itempool.append(self.create_item(self.get_filler_item_name()))
|
|
|
|
|
|
|
|
# add itempool to multiworld
|
|
|
|
self.multiworld.itempool += itempool
|
|
|
|
|
|
|
|
def get_filler_item_name(self):
|
|
|
|
return self.multiworld.random.choice([
|
|
|
|
"Quartz Flask",
|
|
|
|
"Crystal Geode",
|
|
|
|
"Energy Orb",
|
|
|
|
"Greater Runes",
|
|
|
|
"Inferno Orb",
|
|
|
|
"Pile of Mace Spheres",
|
|
|
|
"Quiver of Ethereal Arrows"
|
|
|
|
])
|
|
|
|
|
|
|
|
def create_ratioed_items(self, item_name: str, itempool: List[HereticItem]):
|
|
|
|
remaining_loc = self.location_count - len(itempool)
|
|
|
|
if remaining_loc <= 0:
|
|
|
|
return
|
|
|
|
|
|
|
|
episode_count = self.get_episode_count()
|
|
|
|
count = min(remaining_loc, max(1, self.items_ratio[item_name] * episode_count))
|
|
|
|
if count == 0:
|
|
|
|
logger.warning("Warning, no " + item_name + " will be placed.")
|
|
|
|
return
|
|
|
|
|
|
|
|
for i in range(count):
|
|
|
|
itempool.append(self.create_item(item_name))
|
|
|
|
|
|
|
|
def fill_slot_data(self) -> Dict[str, Any]:
|
2024-04-18 16:53:09 +00:00
|
|
|
slot_data = self.options.as_dict("goal", "difficulty", "random_monsters", "random_pickups", "random_music", "allow_death_logic", "pro", "death_link", "reset_level_on_death", "check_sanity")
|
2023-11-25 14:22:30 +00:00
|
|
|
|
|
|
|
# Make sure we send proper episode settings
|
|
|
|
slot_data["episode1"] = self.included_episodes[0]
|
|
|
|
slot_data["episode2"] = self.included_episodes[1]
|
|
|
|
slot_data["episode3"] = self.included_episodes[2]
|
|
|
|
slot_data["episode4"] = self.included_episodes[3]
|
|
|
|
slot_data["episode5"] = self.included_episodes[4]
|
|
|
|
|
|
|
|
return slot_data
|