forked from mirror/Archipelago
Some checks failed
Analyze modified files / flake8 (push) Failing after 2m28s
Build / build-win (push) Has been cancelled
Build / build-ubuntu2204 (push) Has been cancelled
ctest / Test C++ ubuntu-latest (push) Has been cancelled
ctest / Test C++ windows-latest (push) Has been cancelled
Analyze modified files / mypy (push) Has been cancelled
Build and Publish Docker Images / Push Docker image to Docker Hub (push) Successful in 5m4s
Native Code Static Analysis / scan-build (push) Failing after 5m2s
type check / pyright (push) Successful in 1m7s
unittests / Test Python 3.11.2 ubuntu-latest (push) Failing after 16m23s
unittests / Test Python 3.12 ubuntu-latest (push) Failing after 28m19s
unittests / Test Python 3.13 ubuntu-latest (push) Failing after 14m49s
unittests / Test hosting with 3.13 on ubuntu-latest (push) Successful in 5m0s
unittests / Test Python 3.13 macos-latest (push) Has been cancelled
unittests / Test Python 3.11 windows-latest (push) Has been cancelled
unittests / Test Python 3.13 windows-latest (push) Has been cancelled
313 lines
13 KiB
Python
313 lines
13 KiB
Python
import logging
|
|
from dataclasses import asdict
|
|
from typing import Any, ClassVar
|
|
|
|
from BaseClasses import Item, MultiWorld, Region, Tutorial
|
|
from Options import OptionGroup
|
|
from worlds.AutoWorld import WebWorld, World
|
|
|
|
from . import options # So we don't need to import every option class when defining option groups
|
|
from .characters import get_available_and_starting_characters
|
|
from .constants import (
|
|
MAX_SHOP_SLOTS,
|
|
RUN_COMPLETE_LOCATION_TEMPLATE,
|
|
)
|
|
from .item_weights import create_items_from_weights
|
|
from .items import BrotatoItem, ItemName, filler_items, item_name_groups, item_name_to_id, item_table
|
|
from .locations import location_name_groups, location_name_to_id
|
|
from .loot_crates import (
|
|
BrotatoLootCrateGroup,
|
|
build_loot_crate_groups,
|
|
)
|
|
from .options import (
|
|
BrotatoOptions,
|
|
)
|
|
from .regions import create_regions
|
|
from .rules import create_has_run_wins_rule
|
|
from .shop_slots import get_num_shop_slot_and_lock_button_items
|
|
from .waves import get_wave_for_each_item, get_waves_with_checks
|
|
|
|
logger = logging.getLogger("Brotato")
|
|
|
|
|
|
class BrotatoWeb(WebWorld):
|
|
# TODO: Add actual tutorial!
|
|
tutorials: list[Tutorial] = [ # noqa: RUF012
|
|
Tutorial(
|
|
"Multiworld Setup Guide",
|
|
"A guide to setting up the Brotato randomizer connected to an Archipelago Multiworld",
|
|
"English",
|
|
"setup_en.md",
|
|
"setup/en",
|
|
["RampagingHippy"],
|
|
)
|
|
]
|
|
theme = "dirt"
|
|
rich_text_options_doc = True
|
|
|
|
option_groups: ClassVar[list[OptionGroup]] = [
|
|
OptionGroup(
|
|
"Loot Crates",
|
|
[
|
|
options.SpawnNormalLootCrates,
|
|
options.NumberCommonCrateDropLocations,
|
|
options.NumberCommonCrateDropsPerCheck,
|
|
options.NumberCommonCrateDropGroups,
|
|
options.NumberLegendaryCrateDropLocations,
|
|
options.NumberLegendaryCrateDropsPerCheck,
|
|
options.NumberLegendaryCrateDropGroups,
|
|
],
|
|
),
|
|
OptionGroup(
|
|
"Shop Slots",
|
|
[options.StartingShopSlots, options.StartingShopLockButtonsMode, options.NumberStartingShopLockButtons],
|
|
),
|
|
OptionGroup(
|
|
"Item Weights",
|
|
[
|
|
options.CommonItemWeight,
|
|
options.UncommonItemWeight,
|
|
options.RareItemWeight,
|
|
options.LegendaryItemWeight,
|
|
options.CommonUpgradeWeight,
|
|
options.UncommonUpgradeWeight,
|
|
options.RareUpgradeWeight,
|
|
options.LegendaryUpgradeWeight,
|
|
options.GoldWeight,
|
|
options.XpWeight,
|
|
],
|
|
),
|
|
OptionGroup(
|
|
"Abyssal Terrors DLC",
|
|
[options.EnableAbyssalTerrorsDLC, options.IncludeAbyssalTerrorsCharacters],
|
|
),
|
|
]
|
|
|
|
|
|
class BrotatoWorld(World):
|
|
"""
|
|
Brotato is a top-down arena shooter roguelite where you play a potato wielding up to
|
|
6 weapons at a time to fight off hordes of aliens. Choose from a variety of traits
|
|
and items to create unique builds and survive until help arrives.
|
|
"""
|
|
|
|
options_dataclass = BrotatoOptions
|
|
options: BrotatoOptions # type: ignore
|
|
game: ClassVar[str] = "Brotato"
|
|
web = BrotatoWeb()
|
|
data_version = 0
|
|
required_client_version: tuple[int, int, int] = (0, 5, 0)
|
|
|
|
item_name_to_id: ClassVar[dict[str, int]] = item_name_to_id
|
|
item_name_groups: ClassVar[dict[str, set[str]]] = item_name_groups
|
|
|
|
_filler_items: list[str] = filler_items
|
|
_starting_characters: list[str]
|
|
_include_characters: list[str]
|
|
"""The characters whose locations (wave/run complete) may have progression and useful items.
|
|
|
|
This is derived from options.include_characters.
|
|
|
|
This is a distinct list from the options value because:
|
|
|
|
* We want to sanitize the list to make sure typos or other errors don't cause bugs down the road.
|
|
* We want to keep things in character definition order for readability by using a list instead of a set.
|
|
"""
|
|
|
|
location_name_to_id: ClassVar[dict[str, int]] = location_name_to_id
|
|
location_name_groups: ClassVar[dict[str, set[str]]] = location_name_groups
|
|
|
|
num_wins_needed: int
|
|
"""The number of runs won needed to achieve the goal.
|
|
|
|
This is the minimum of the num_victories option value and the total number of characters available.
|
|
"""
|
|
|
|
num_shop_slot_items: int
|
|
num_shop_lock_button_items: int
|
|
|
|
waves_with_checks: list[int]
|
|
"""Which waves will count as locations.
|
|
|
|
Calculated from player options in generate_early.
|
|
"""
|
|
|
|
common_loot_crate_groups: list[BrotatoLootCrateGroup]
|
|
"""Information about each common loot crate group, i.e. how many crates it has and how many wins it needs.
|
|
|
|
Calculated from player options in generate_early().
|
|
"""
|
|
|
|
legendary_loot_crate_groups: list[BrotatoLootCrateGroup]
|
|
"""Information about each legendary loot crate group, i.e. how many crates it has and how many wins it needs.
|
|
|
|
Calculated from player options in generate_early().
|
|
"""
|
|
|
|
nonessential_item_counts: dict[ItemName, int]
|
|
"""The names and counts of the items in the pool that aren't characters, shop slots, or shop locks.
|
|
|
|
This includes the (Brotato) items, upgrades, gold and XP drops, which are populated from the respective weight
|
|
options to fill all locations not taken by the aforementioned items.
|
|
|
|
These are "nonessential" because they aren't strictly necessary for completion, like character items, or incredibly
|
|
useful with a strict cap, like the shop slots and locks. There's probably a better name for this, but I can't think
|
|
of it at the time of writing.
|
|
|
|
These are determined in generate_early() instead of create_items() so we can use the amount of them to determine
|
|
combat logic.
|
|
"""
|
|
|
|
def __init__(self, world: MultiWorld, player: int) -> None:
|
|
super().__init__(world, player)
|
|
|
|
def create_item(self, name: str | ItemName) -> BrotatoItem:
|
|
if isinstance(name, ItemName):
|
|
name = name.value
|
|
return item_table[self.item_name_to_id[name]].to_item(self.player)
|
|
|
|
def generate_early(self) -> None:
|
|
# Determine needed values from the options
|
|
self.waves_with_checks = get_waves_with_checks(self.options.waves_per_drop)
|
|
|
|
self._include_characters, self._starting_characters = get_available_and_starting_characters(
|
|
self.options.include_base_game_characters.value,
|
|
bool(self.options.enable_abyssal_terrors_dlc.value),
|
|
self.options.include_abyssal_terrors_characters.value,
|
|
self.options.starting_characters,
|
|
self.options.num_starting_characters.value,
|
|
self.options.num_characters.value,
|
|
self.random,
|
|
)
|
|
|
|
# Clamp the number of wins needed to goal to the number of included characters, so the game isn't unwinnable.
|
|
self.num_wins_needed = min(self.options.num_victories.value, len(self._include_characters))
|
|
|
|
# Thought: if num victories is clamped, do some of the groups become unreachable?
|
|
self.common_loot_crate_groups = build_loot_crate_groups(
|
|
self.options.num_common_crate_drops.value,
|
|
self.options.num_common_crate_drop_groups.value,
|
|
self.num_wins_needed,
|
|
)
|
|
self.legendary_loot_crate_groups = build_loot_crate_groups(
|
|
self.options.num_legendary_crate_drops.value,
|
|
self.options.num_legendary_crate_drop_groups.value,
|
|
self.num_wins_needed,
|
|
)
|
|
|
|
self.num_shop_slot_items, self.num_shop_lock_button_items = get_num_shop_slot_and_lock_button_items(
|
|
self.options.num_starting_shop_slots,
|
|
self.options.shop_lock_buttons_mode,
|
|
self.options.num_starting_lock_buttons,
|
|
)
|
|
|
|
# The number of locations available, not including the "Run Won" locations, which always have "Run Won" items.
|
|
num_locations = sum(
|
|
[
|
|
len(self._include_characters), # Run Won Locations
|
|
len(self._include_characters) * len(self.waves_with_checks), # Wave Complete Locations
|
|
self.options.num_common_crate_drops.value,
|
|
self.options.num_legendary_crate_drops.value,
|
|
]
|
|
)
|
|
|
|
num_essential_items = sum(
|
|
[
|
|
len(self._include_characters), # Run Won Items
|
|
len(self._include_characters) - len(self._starting_characters), # The character items
|
|
self.num_shop_slot_items,
|
|
self.num_shop_lock_button_items,
|
|
]
|
|
)
|
|
|
|
num_nonessential_items = max(num_locations - num_essential_items, 0)
|
|
self.nonessential_item_counts = create_items_from_weights(
|
|
num_nonessential_items,
|
|
self.random,
|
|
self.options.common_item_weight,
|
|
self.options.uncommon_item_weight,
|
|
self.options.rare_item_weight,
|
|
self.options.legendary_item_weight,
|
|
self.options.common_upgrade_weight,
|
|
self.options.uncommon_upgrade_weight,
|
|
self.options.rare_upgrade_weight,
|
|
self.options.legendary_upgrade_weight,
|
|
self.options.gold_weight,
|
|
self.options.xp_weight,
|
|
)
|
|
|
|
def set_rules(self) -> None:
|
|
self.multiworld.completion_condition[self.player] = create_has_run_wins_rule(self.player, self.num_wins_needed)
|
|
|
|
def create_regions(self) -> None:
|
|
def create_region(region_name: str) -> Region:
|
|
return Region(region_name, self.player, self.multiworld)
|
|
|
|
regions: list[Region] = create_regions(
|
|
create_region,
|
|
self._include_characters,
|
|
self.waves_with_checks,
|
|
self.common_loot_crate_groups,
|
|
self.legendary_loot_crate_groups,
|
|
)
|
|
|
|
self.multiworld.regions.extend(regions)
|
|
|
|
def create_items(self) -> None:
|
|
item_pool: list[BrotatoItem | Item] = []
|
|
|
|
for character in self._include_characters:
|
|
character_item: BrotatoItem = self.create_item(character)
|
|
if character in self._starting_characters:
|
|
self.multiworld.push_precollected(character_item)
|
|
else:
|
|
item_pool.append(character_item)
|
|
|
|
# Create an item for each nonessential item. These are determined in generate_early().
|
|
for item_name, item_count in self.nonessential_item_counts.items():
|
|
item_pool += [self.create_item(item_name) for _ in range(item_count)]
|
|
|
|
item_pool += [self.create_item(ItemName.SHOP_SLOT) for _ in range(self.num_shop_slot_items)]
|
|
item_pool += [self.create_item(ItemName.SHOP_LOCK_BUTTON) for _ in range(self.num_shop_lock_button_items)]
|
|
|
|
self.multiworld.itempool += item_pool
|
|
|
|
# Place "Run Won" items at the Run Won locations. Do this before fill happens so
|
|
# plandos can't place items here.
|
|
for character in self._include_characters:
|
|
item: BrotatoItem = self.create_item(ItemName.RUN_COMPLETE)
|
|
run_won_location = RUN_COMPLETE_LOCATION_TEMPLATE.format(char=character)
|
|
self.multiworld.get_location(run_won_location, self.player).place_locked_item(item)
|
|
|
|
def pre_fill(self) -> None:
|
|
pass
|
|
|
|
def get_filler_item_name(self) -> str:
|
|
return self.random.choice(self._filler_items)
|
|
|
|
def fill_slot_data(self) -> dict[str, Any]:
|
|
# Define outside dict for readability
|
|
spawn_normal_loot_crates: bool = (
|
|
self.options.spawn_normal_loot_crates.value == self.options.spawn_normal_loot_crates.option_true
|
|
)
|
|
wave_per_game_item: dict[int, list[int]] = get_wave_for_each_item(self.nonessential_item_counts)
|
|
return {
|
|
"deathlink": self.options.death_link.value,
|
|
"waves_with_checks": self.waves_with_checks,
|
|
"num_wins_needed": self.num_wins_needed,
|
|
"gold_reward_mode": self.options.gold_reward_mode.value,
|
|
"xp_reward_mode": self.options.xp_reward_mode.value,
|
|
"enable_enemy_xp": self.options.enable_enemy_xp.value == self.options.enable_enemy_xp.option_true,
|
|
"num_starting_shop_slots": self.options.num_starting_shop_slots.value,
|
|
"num_starting_shop_lock_buttons": (MAX_SHOP_SLOTS - self.num_shop_lock_button_items),
|
|
"spawn_normal_loot_crates": spawn_normal_loot_crates,
|
|
"num_common_crate_locations": self.options.num_common_crate_drops.value,
|
|
"num_common_crate_drops_per_check": self.options.num_common_crate_drops_per_check.value,
|
|
"common_crate_drop_groups": [asdict(g) for g in self.common_loot_crate_groups],
|
|
"num_legendary_crate_locations": self.options.num_legendary_crate_drops.value,
|
|
"num_legendary_crate_drops_per_check": self.options.num_legendary_crate_drops_per_check.value,
|
|
"legendary_crate_drop_groups": [asdict(g) for g in self.legendary_loot_crate_groups],
|
|
"wave_per_game_item": wave_per_game_item,
|
|
"enable_abyssal_terrors_dlc": self.options.enable_abyssal_terrors_dlc.value,
|
|
}
|