405 lines
19 KiB
Python
405 lines
19 KiB
Python
import typing
|
|
|
|
from BaseClasses import Item, Tutorial, ItemClassification, Region, MultiWorld, CollectionState
|
|
from Fill import fill_restrictive, FillError
|
|
from worlds.AutoWorld import WebWorld, World
|
|
from .Items import OSRSItem, starting_area_dict, chunksanity_starting_chunks, QP_Items, ItemRow, \
|
|
chunksanity_special_region_names
|
|
from .Locations import OSRSLocation, LocationRow
|
|
from .Rules import *
|
|
from .Options import OSRSOptions, StartingArea
|
|
from .Names import LocationNames, ItemNames, RegionNames
|
|
|
|
from .LogicCSV.LogicCSVToPython import data_csv_tag
|
|
from .LogicCSV.items_generated import item_rows
|
|
from .LogicCSV.locations_generated import location_rows
|
|
from .LogicCSV.regions_generated import region_rows
|
|
from .LogicCSV.resources_generated import resource_rows
|
|
from .Regions import RegionRow, ResourceRow
|
|
|
|
|
|
class OSRSWeb(WebWorld):
|
|
theme = "stone"
|
|
|
|
setup_en = Tutorial(
|
|
"Multiworld Setup Guide",
|
|
"A guide to setting up the Old School Runescape Randomizer connected to an Archipelago Multiworld",
|
|
"English",
|
|
"docs/setup_en.md",
|
|
"setup/en",
|
|
["digiholic"]
|
|
)
|
|
tutorials = [setup_en]
|
|
|
|
|
|
class OSRSWorld(World):
|
|
"""
|
|
The best retro fantasy MMORPG on the planet. Old School is RuneScape but… older! This is the open world you know and love, but as it was in 2007.
|
|
The Randomizer takes the form of a Chunk-Restricted f2p Ironman that takes a brand new account up through defeating
|
|
the Green Dragon of Crandor and earning a spot in the fabled Champion's Guild!
|
|
"""
|
|
|
|
game = "Old School Runescape"
|
|
options_dataclass = OSRSOptions
|
|
options: OSRSOptions
|
|
topology_present = True
|
|
web = OSRSWeb()
|
|
base_id = 0x070000
|
|
data_version = 1
|
|
explicit_indirect_conditions = False
|
|
|
|
item_name_to_id = {item_rows[i].name: 0x070000 + i for i in range(len(item_rows))}
|
|
location_name_to_id = {location_rows[i].name: 0x070000 + i for i in range(len(location_rows))}
|
|
|
|
region_name_to_data: typing.Dict[str, Region]
|
|
location_name_to_data: typing.Dict[str, OSRSLocation]
|
|
|
|
location_rows_by_name: typing.Dict[str, LocationRow]
|
|
region_rows_by_name: typing.Dict[str, RegionRow]
|
|
resource_rows_by_name: typing.Dict[str, ResourceRow]
|
|
item_rows_by_name: typing.Dict[str, ItemRow]
|
|
|
|
starting_area_item: str
|
|
|
|
locations_by_category: typing.Dict[str, typing.List[LocationRow]]
|
|
available_QP_locations: typing.List[str]
|
|
|
|
def __init__(self, multiworld: MultiWorld, player: int):
|
|
super().__init__(multiworld, player)
|
|
self.region_name_to_data = {}
|
|
self.location_name_to_data = {}
|
|
|
|
self.location_rows_by_name = {}
|
|
self.region_rows_by_name = {}
|
|
self.resource_rows_by_name = {}
|
|
self.item_rows_by_name = {}
|
|
|
|
self.starting_area_item = ""
|
|
|
|
self.locations_by_category = {}
|
|
self.available_QP_locations = []
|
|
|
|
def generate_early(self) -> None:
|
|
location_categories = [location_row.category for location_row in location_rows]
|
|
self.locations_by_category = {category:
|
|
[location_row for location_row in location_rows if
|
|
location_row.category == category]
|
|
for category in location_categories}
|
|
|
|
self.location_rows_by_name = {loc_row.name: loc_row for loc_row in location_rows}
|
|
self.region_rows_by_name = {reg_row.name: reg_row for reg_row in region_rows}
|
|
self.resource_rows_by_name = {rec_row.name: rec_row for rec_row in resource_rows}
|
|
self.item_rows_by_name = {it_row.name: it_row for it_row in item_rows}
|
|
|
|
rnd = self.random
|
|
starting_area = self.options.starting_area
|
|
|
|
#UT specific override, if we are in normal gen, resolve starting area, we will get it from slot_data in UT
|
|
if not hasattr(self.multiworld, "generation_is_fake"):
|
|
if starting_area.value == StartingArea.option_any_bank:
|
|
self.starting_area_item = rnd.choice(starting_area_dict)
|
|
elif starting_area.value < StartingArea.option_chunksanity:
|
|
self.starting_area_item = starting_area_dict[starting_area.value]
|
|
else:
|
|
self.starting_area_item = rnd.choice(chunksanity_starting_chunks)
|
|
|
|
# Set Starting Chunk
|
|
self.multiworld.push_precollected(self.create_item(self.starting_area_item))
|
|
|
|
"""
|
|
This function pulls from LogicCSVToPython so that it sends the correct tag of the repository to the client.
|
|
_Make sure to update that value whenever the CSVs change!_
|
|
"""
|
|
|
|
def fill_slot_data(self):
|
|
data = self.options.as_dict("brutal_grinds")
|
|
data["data_csv_tag"] = data_csv_tag
|
|
data["starting_area"] = str(self.starting_area_item) #these aren't actually strings, they just play them on tv
|
|
return data
|
|
|
|
def interpret_slot_data(self, slot_data: typing.Dict[str, typing.Any]) -> None:
|
|
if "starting_area" in slot_data:
|
|
self.starting_area_item = slot_data["starting_area"]
|
|
menu_region = self.multiworld.get_region("Menu",self.player)
|
|
menu_region.exits.clear() #prevent making extra exits if players just reconnect to a differnet slot
|
|
if self.starting_area_item in chunksanity_special_region_names:
|
|
starting_area_region = chunksanity_special_region_names[self.starting_area_item]
|
|
else:
|
|
starting_area_region = self.starting_area_item[6:] # len("Area: ")
|
|
starting_entrance = menu_region.create_exit(f"Start->{starting_area_region}")
|
|
starting_entrance.access_rule = lambda state: state.has(self.starting_area_item, self.player)
|
|
starting_entrance.connect(self.region_name_to_data[starting_area_region])
|
|
|
|
def create_regions(self) -> None:
|
|
"""
|
|
called to place player's regions into the MultiWorld's regions list. If it's hard to separate, this can be done
|
|
during generate_early or basic as well.
|
|
"""
|
|
|
|
# First, create the "Menu" region to start
|
|
menu_region = self.create_region("Menu")
|
|
|
|
for region_row in region_rows:
|
|
self.create_region(region_row.name)
|
|
|
|
for resource_row in resource_rows:
|
|
self.create_region(resource_row.name)
|
|
|
|
# Removes the word "Area: " from the item name to get the region it applies to.
|
|
# I figured tacking "Area: " at the beginning would make it _easier_ to tell apart. Turns out it made it worse
|
|
# if area hasn't been set, then we shouldn't connect it
|
|
if self.starting_area_item != "":
|
|
if self.starting_area_item in chunksanity_special_region_names:
|
|
starting_area_region = chunksanity_special_region_names[self.starting_area_item]
|
|
else:
|
|
starting_area_region = self.starting_area_item[6:] # len("Area: ")
|
|
starting_entrance = menu_region.create_exit(f"Start->{starting_area_region}")
|
|
starting_entrance.access_rule = lambda state: state.has(self.starting_area_item, self.player)
|
|
starting_entrance.connect(self.region_name_to_data[starting_area_region])
|
|
|
|
# Create entrances between regions
|
|
for region_row in region_rows:
|
|
region = self.region_name_to_data[region_row.name]
|
|
|
|
for outbound_region_name in region_row.connections:
|
|
parsed_outbound = outbound_region_name.replace('*', '')
|
|
entrance = region.create_exit(f"{region_row.name}->{parsed_outbound}")
|
|
entrance.connect(self.region_name_to_data[parsed_outbound])
|
|
|
|
item_name = self.region_rows_by_name[parsed_outbound].itemReq
|
|
entrance.access_rule = lambda state, item_name=item_name.replace("*",""): state.has(item_name, self.player)
|
|
generate_special_rules_for(entrance, region_row, outbound_region_name, self.player, self.options)
|
|
|
|
for resource_region in region_row.resources:
|
|
if not resource_region:
|
|
continue
|
|
|
|
entrance = region.create_exit(f"{region_row.name}->{resource_region.replace('*', '')}")
|
|
if "*" not in resource_region:
|
|
entrance.connect(self.region_name_to_data[resource_region])
|
|
else:
|
|
entrance.connect(self.region_name_to_data[resource_region.replace('*', '')])
|
|
generate_special_rules_for(entrance, region_row, resource_region, self.player, self.options)
|
|
|
|
self.roll_locations()
|
|
|
|
def task_within_skill_levels(self, skills_required):
|
|
# Loop through each required skill. If any of its requirements are out of the defined limit, return false
|
|
for skill in skills_required:
|
|
max_level_for_skill = getattr(self.options, f"max_{skill.skill.lower()}_level")
|
|
if skill.level > max_level_for_skill:
|
|
return False
|
|
return True
|
|
|
|
def roll_locations(self):
|
|
generation_is_fake = hasattr(self.multiworld, "generation_is_fake") # UT specific override
|
|
locations_required = 0
|
|
for item_row in item_rows:
|
|
locations_required += item_row.amount
|
|
|
|
locations_added = 1 # At this point we've already added the starting area, so we start at 1 instead of 0
|
|
|
|
# Quests are always added first, before anything else is rolled
|
|
for i, location_row in enumerate(location_rows):
|
|
if location_row.category in {"quest", "points", "goal"}:
|
|
if self.task_within_skill_levels(location_row.skills):
|
|
self.create_and_add_location(i)
|
|
if location_row.category == "quest":
|
|
locations_added += 1
|
|
|
|
# Build up the weighted Task Pool
|
|
rnd = self.random
|
|
|
|
# Start with the minimum general tasks
|
|
general_tasks = [task for task in self.locations_by_category["general"]]
|
|
if not self.options.progressive_tasks:
|
|
rnd.shuffle(general_tasks)
|
|
else:
|
|
general_tasks.reverse()
|
|
for i in range(self.options.minimum_general_tasks):
|
|
task = general_tasks.pop()
|
|
self.add_location(task)
|
|
locations_added += 1
|
|
|
|
general_weight = self.options.general_task_weight if len(general_tasks) > 0 else 0
|
|
|
|
tasks_per_task_type: typing.Dict[str, typing.List[LocationRow]] = {}
|
|
weights_per_task_type: typing.Dict[str, int] = {}
|
|
|
|
task_types = ["prayer", "magic", "runecraft", "mining", "crafting",
|
|
"smithing", "fishing", "cooking", "firemaking", "woodcutting", "combat"]
|
|
for task_type in task_types:
|
|
max_amount_for_task_type = getattr(self.options, f"max_{task_type}_tasks")
|
|
tasks_for_this_type = [task for task in self.locations_by_category[task_type]
|
|
if self.task_within_skill_levels(task.skills)]
|
|
if not self.options.progressive_tasks:
|
|
rnd.shuffle(tasks_for_this_type)
|
|
else:
|
|
tasks_for_this_type.reverse()
|
|
|
|
tasks_for_this_type = tasks_for_this_type[:max_amount_for_task_type]
|
|
weight_for_this_type = getattr(self.options,
|
|
f"{task_type}_task_weight")
|
|
if weight_for_this_type > 0 and tasks_for_this_type:
|
|
tasks_per_task_type[task_type] = tasks_for_this_type
|
|
weights_per_task_type[task_type] = weight_for_this_type
|
|
|
|
# Build a list of collections and weights in a matching order for rnd.choices later
|
|
all_tasks = []
|
|
all_weights = []
|
|
for task_type in task_types:
|
|
if task_type in tasks_per_task_type:
|
|
all_tasks.append(tasks_per_task_type[task_type])
|
|
all_weights.append(weights_per_task_type[task_type])
|
|
|
|
# Even after the initial forced generals, they can still be rolled randomly
|
|
if general_weight > 0:
|
|
all_tasks.append(general_tasks)
|
|
all_weights.append(general_weight)
|
|
|
|
while locations_added < locations_required or (generation_is_fake and len(all_tasks) > 0):
|
|
if all_tasks:
|
|
chosen_task = rnd.choices(all_tasks, all_weights)[0]
|
|
if chosen_task:
|
|
task = chosen_task.pop()
|
|
self.add_location(task)
|
|
locations_added += 1
|
|
|
|
# This isn't an else because chosen_task can become empty in the process of resolving the above block
|
|
# We still want to clear this list out while we're doing that
|
|
if not chosen_task:
|
|
index = all_tasks.index(chosen_task)
|
|
del all_tasks[index]
|
|
del all_weights[index]
|
|
|
|
else:
|
|
if len(general_tasks) == 0:
|
|
raise Exception(f"There are not enough available tasks to fill the remaining pool for OSRS " +
|
|
f"Please adjust {self.player_name}'s settings to be less restrictive of tasks.")
|
|
task = general_tasks.pop()
|
|
self.add_location(task)
|
|
locations_added += 1
|
|
|
|
|
|
def add_location(self, location):
|
|
index = [i for i in range(len(location_rows)) if location_rows[i].name == location.name][0]
|
|
self.create_and_add_location(index)
|
|
|
|
def create_items(self) -> None:
|
|
for item_row in item_rows:
|
|
if item_row.name != self.starting_area_item:
|
|
for c in range(item_row.amount):
|
|
item = self.create_item(item_row.name)
|
|
self.multiworld.itempool.append(item)
|
|
|
|
def get_filler_item_name(self) -> str:
|
|
return self.random.choice(
|
|
[ItemNames.Progressive_Armor, ItemNames.Progressive_Weapons, ItemNames.Progressive_Magic,
|
|
ItemNames.Progressive_Tools, ItemNames.Progressive_Range_Armor, ItemNames.Progressive_Range_Weapon])
|
|
|
|
def create_and_add_location(self, row_index) -> None:
|
|
location_row = location_rows[row_index]
|
|
|
|
# Quest Points are handled differently now, but in case this gets fed an older version of the data sheet,
|
|
# the points might still be listed in a different row
|
|
if location_row.category == "points":
|
|
return
|
|
|
|
# Create Location
|
|
location_id = self.base_id + row_index
|
|
if location_row.category == "goal":
|
|
location_id = None
|
|
location = OSRSLocation(self.player, location_row.name, location_id)
|
|
self.location_name_to_data[location_row.name] = location
|
|
|
|
# Add the location to its first region, or if it doesn't belong to one, to Menu
|
|
region = self.region_name_to_data["Menu"]
|
|
if location_row.regions:
|
|
region = self.region_name_to_data[location_row.regions[0]]
|
|
location.parent_region = region
|
|
region.locations.append(location)
|
|
|
|
# If it's a quest, generate a "Points" location we'll add an event to
|
|
if location_row.category == "quest":
|
|
points_name = location_row.name.replace("Quest:", "Points:")
|
|
points_location = OSRSLocation(self.player, points_name)
|
|
self.location_name_to_data[points_name] = points_location
|
|
points_location.parent_region = region
|
|
region.locations.append(points_location)
|
|
|
|
def set_rules(self) -> None:
|
|
"""
|
|
called to set access and item rules on locations and entrances.
|
|
"""
|
|
quest_attr_names = ["Cooks_Assistant", "Demon_Slayer", "Restless_Ghost", "Romeo_Juliet",
|
|
"Sheep_Shearer", "Shield_of_Arrav", "Ernest_the_Chicken", "Vampyre_Slayer",
|
|
"Imp_Catcher", "Prince_Ali_Rescue", "Dorics_Quest", "Black_Knights_Fortress",
|
|
"Witchs_Potion", "Knights_Sword", "Goblin_Diplomacy", "Pirates_Treasure",
|
|
"Rune_Mysteries", "Misthalin_Mystery", "Corsair_Curse", "X_Marks_the_Spot",
|
|
"Below_Ice_Mountain"]
|
|
|
|
for quest_attr_name in quest_attr_names:
|
|
qp_loc_name = getattr(LocationNames, f"QP_{quest_attr_name}")
|
|
qp_loc = self.location_name_to_data.get(qp_loc_name)
|
|
|
|
q_loc_name = getattr(LocationNames, f"Q_{quest_attr_name}")
|
|
q_loc = self.location_name_to_data.get(q_loc_name)
|
|
|
|
# Checks to make sure the task is actually in the list before trying to create its rules
|
|
if qp_loc and q_loc:
|
|
# Create the QP Event Item
|
|
item_name = getattr(ItemNames, f"QP_{quest_attr_name}")
|
|
qp_loc.place_locked_item(self.create_event(item_name))
|
|
|
|
# If a quest is excluded, don't actually consider it for quest point progression
|
|
if q_loc_name not in self.options.exclude_locations:
|
|
self.available_QP_locations.append(item_name)
|
|
|
|
# Set the access rule for the QP Location
|
|
add_rule(qp_loc, lambda state, loc=q_loc: (loc.can_reach(state)))
|
|
|
|
# place "Victory" at "Dragon Slayer" and set collection as win condition
|
|
self.multiworld.get_location(LocationNames.Q_Dragon_Slayer, self.player) \
|
|
.place_locked_item(self.create_event("Victory"))
|
|
self.multiworld.completion_condition[self.player] = lambda state: (state.has("Victory", self.player))
|
|
|
|
for location_name, location in self.location_name_to_data.items():
|
|
location_row = self.location_rows_by_name[location_name]
|
|
# Set up requirements for region
|
|
for region_required_name in location_row.regions:
|
|
region_required = self.region_name_to_data[region_required_name]
|
|
add_rule(location,
|
|
lambda state, region_required=region_required: state.can_reach(region_required, "Region",
|
|
self.player))
|
|
for skill_req in location_row.skills:
|
|
add_rule(location, get_skill_rule(skill_req.skill, skill_req.level, self.player, self.options))
|
|
for item_req in location_row.items:
|
|
add_rule(location, lambda state, item_req=item_req: state.has(item_req, self.player))
|
|
if location_row.qp:
|
|
add_rule(location, lambda state, location_row=location_row: self.quest_points(state) > location_row.qp)
|
|
|
|
def create_region(self, name: str) -> "Region":
|
|
region = Region(name, self.player, self.multiworld)
|
|
self.region_name_to_data[name] = region
|
|
self.multiworld.regions.append(region)
|
|
return region
|
|
|
|
def create_item(self, item_name: str) -> "Item":
|
|
items = [item for item in item_rows if item.name == item_name]
|
|
assert len(items) > 0, f"No matching item found for name {item_name} for player {self.player_name}"
|
|
item = items[0]
|
|
index = item_rows.index(item)
|
|
return OSRSItem(item.name, item.progression, self.base_id + index, self.player)
|
|
|
|
def create_event(self, event: str):
|
|
# while we are at it, we can also add a helper to create events
|
|
return OSRSItem(event, ItemClassification.progression, None, self.player)
|
|
|
|
def quest_points(self, state):
|
|
qp = 0
|
|
for qp_event in self.available_QP_locations:
|
|
if state.has(qp_event, self.player):
|
|
qp += int(qp_event[0])
|
|
return qp
|
|
|