mirror of
https://github.com/ArchipelagoMW/Archipelago.git
synced 2026-03-12 02:23:47 -07:00
Compare commits
29 Commits
NewSoupVi-
...
multiserve
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bede173277 | ||
|
|
2a0d0b4224 | ||
|
|
02fd75c018 | ||
|
|
a87fec0cbd | ||
|
|
11842d396a | ||
|
|
72854cde44 | ||
|
|
b71c8005e7 | ||
|
|
0994afa25b | ||
|
|
7d5693e0fb | ||
|
|
feaed7ea00 | ||
|
|
8340371f9c | ||
|
|
824caaffd0 | ||
|
|
c0b3fa9ff7 | ||
|
|
e809b9328b | ||
|
|
53defd3108 | ||
|
|
a166dc77bc | ||
|
|
68ed208613 | ||
|
|
8f71dac417 | ||
|
|
5f24da7e18 | ||
|
|
4e61f1f23c | ||
|
|
cbfcaeba8b | ||
|
|
9a8abeac28 | ||
|
|
b0f42466f0 | ||
|
|
4a7232c6f3 | ||
|
|
3ad7f55d6b | ||
|
|
342093c510 | ||
|
|
609cb22c91 | ||
|
|
0607051718 | ||
|
|
61fd11b351 |
@@ -1,3 +1,4 @@
|
||||
import sys
|
||||
from worlds.ahit.Client import launch
|
||||
import Utils
|
||||
import ModuleUpdate
|
||||
@@ -5,4 +6,4 @@ ModuleUpdate.update()
|
||||
|
||||
if __name__ == "__main__":
|
||||
Utils.init_logging("AHITClient", exception_logger="Client")
|
||||
launch()
|
||||
launch(*sys.argv[1:])
|
||||
|
||||
@@ -9,8 +9,9 @@ from argparse import Namespace
|
||||
from collections import Counter, deque
|
||||
from collections.abc import Collection, MutableSequence
|
||||
from enum import IntEnum, IntFlag
|
||||
from typing import (AbstractSet, Any, Callable, ClassVar, Dict, Iterable, Iterator, List, Mapping, NamedTuple,
|
||||
from typing import (AbstractSet, Any, Callable, ClassVar, Dict, Iterable, Iterator, List, Literal, Mapping, NamedTuple,
|
||||
Optional, Protocol, Set, Tuple, Union, TYPE_CHECKING)
|
||||
import dataclasses
|
||||
|
||||
from typing_extensions import NotRequired, TypedDict
|
||||
|
||||
@@ -54,12 +55,21 @@ class HasNameAndPlayer(Protocol):
|
||||
player: int
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class PlandoItemBlock:
|
||||
player: int
|
||||
from_pool: bool
|
||||
force: bool | Literal["silent"]
|
||||
worlds: set[int] = dataclasses.field(default_factory=set)
|
||||
items: list[str] = dataclasses.field(default_factory=list)
|
||||
locations: list[str] = dataclasses.field(default_factory=list)
|
||||
resolved_locations: list[Location] = dataclasses.field(default_factory=list)
|
||||
count: dict[str, int] = dataclasses.field(default_factory=dict)
|
||||
|
||||
|
||||
class MultiWorld():
|
||||
debug_types = False
|
||||
player_name: Dict[int, str]
|
||||
plando_texts: List[Dict[str, str]]
|
||||
plando_items: List[List[Dict[str, Any]]]
|
||||
plando_connections: List
|
||||
worlds: Dict[int, "AutoWorld.World"]
|
||||
groups: Dict[int, Group]
|
||||
regions: RegionManager
|
||||
@@ -83,6 +93,8 @@ class MultiWorld():
|
||||
start_location_hints: Dict[int, Options.StartLocationHints]
|
||||
item_links: Dict[int, Options.ItemLinks]
|
||||
|
||||
plando_item_blocks: Dict[int, List[PlandoItemBlock]]
|
||||
|
||||
game: Dict[int, str]
|
||||
|
||||
random: random.Random
|
||||
@@ -160,13 +172,12 @@ class MultiWorld():
|
||||
self.local_early_items = {player: {} for player in self.player_ids}
|
||||
self.indirect_connections = {}
|
||||
self.start_inventory_from_pool: Dict[int, Options.StartInventoryPool] = {}
|
||||
self.plando_item_blocks = {}
|
||||
|
||||
for player in range(1, players + 1):
|
||||
def set_player_attr(attr: str, val) -> None:
|
||||
self.__dict__.setdefault(attr, {})[player] = val
|
||||
set_player_attr('plando_items', [])
|
||||
set_player_attr('plando_texts', {})
|
||||
set_player_attr('plando_connections', [])
|
||||
set_player_attr('plando_item_blocks', [])
|
||||
set_player_attr('game', "Archipelago")
|
||||
set_player_attr('completion_condition', lambda state: True)
|
||||
self.worlds = {}
|
||||
@@ -427,7 +438,8 @@ class MultiWorld():
|
||||
def get_location(self, location_name: str, player: int) -> Location:
|
||||
return self.regions.location_cache[player][location_name]
|
||||
|
||||
def get_all_state(self, use_cache: bool, allow_partial_entrances: bool = False) -> CollectionState:
|
||||
def get_all_state(self, use_cache: bool, allow_partial_entrances: bool = False,
|
||||
collect_pre_fill_items: bool = True) -> CollectionState:
|
||||
cached = getattr(self, "_all_state", None)
|
||||
if use_cache and cached:
|
||||
return cached.copy()
|
||||
@@ -436,10 +448,11 @@ class MultiWorld():
|
||||
|
||||
for item in self.itempool:
|
||||
self.worlds[item.player].collect(ret, item)
|
||||
for player in self.player_ids:
|
||||
subworld = self.worlds[player]
|
||||
for item in subworld.get_pre_fill_items():
|
||||
subworld.collect(ret, item)
|
||||
if collect_pre_fill_items:
|
||||
for player in self.player_ids:
|
||||
subworld = self.worlds[player]
|
||||
for item in subworld.get_pre_fill_items():
|
||||
subworld.collect(ret, item)
|
||||
ret.sweep_for_advancements()
|
||||
|
||||
if use_cache:
|
||||
|
||||
349
Fill.py
349
Fill.py
@@ -4,7 +4,7 @@ import logging
|
||||
import typing
|
||||
from collections import Counter, deque
|
||||
|
||||
from BaseClasses import CollectionState, Item, Location, LocationProgressType, MultiWorld
|
||||
from BaseClasses import CollectionState, Item, Location, LocationProgressType, MultiWorld, PlandoItemBlock
|
||||
from Options import Accessibility
|
||||
|
||||
from worlds.AutoWorld import call_all
|
||||
@@ -100,7 +100,7 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati
|
||||
# if minimal accessibility, only check whether location is reachable if game not beatable
|
||||
if multiworld.worlds[item_to_place.player].options.accessibility == Accessibility.option_minimal:
|
||||
perform_access_check = not multiworld.has_beaten_game(maximum_exploration_state,
|
||||
item_to_place.player) \
|
||||
item_to_place.player) \
|
||||
if single_player_placement else not has_beaten_game
|
||||
else:
|
||||
perform_access_check = True
|
||||
@@ -242,7 +242,7 @@ def remaining_fill(multiworld: MultiWorld,
|
||||
unplaced_items: typing.List[Item] = []
|
||||
placements: typing.List[Location] = []
|
||||
swapped_items: typing.Counter[typing.Tuple[int, str]] = Counter()
|
||||
total = min(len(itempool), len(locations))
|
||||
total = min(len(itempool), len(locations))
|
||||
placed = 0
|
||||
|
||||
# Optimisation: Decide whether to do full location.can_fill check (respect excluded), or only check the item rule
|
||||
@@ -343,8 +343,10 @@ def fast_fill(multiworld: MultiWorld,
|
||||
|
||||
def accessibility_corrections(multiworld: MultiWorld, state: CollectionState, locations, pool=[]):
|
||||
maximum_exploration_state = sweep_from_pool(state, pool)
|
||||
minimal_players = {player for player in multiworld.player_ids if multiworld.worlds[player].options.accessibility == "minimal"}
|
||||
unreachable_locations = [location for location in multiworld.get_locations() if location.player in minimal_players and
|
||||
minimal_players = {player for player in multiworld.player_ids if
|
||||
multiworld.worlds[player].options.accessibility == "minimal"}
|
||||
unreachable_locations = [location for location in multiworld.get_locations() if
|
||||
location.player in minimal_players and
|
||||
not location.can_reach(maximum_exploration_state)]
|
||||
for location in unreachable_locations:
|
||||
if (location.item is not None and location.item.advancement and location.address is not None and not
|
||||
@@ -365,7 +367,7 @@ def inaccessible_location_rules(multiworld: MultiWorld, state: CollectionState,
|
||||
unreachable_locations = [location for location in locations if not location.can_reach(maximum_exploration_state)]
|
||||
if unreachable_locations:
|
||||
def forbid_important_item_rule(item: Item):
|
||||
return not ((item.classification & 0b0011) and multiworld.worlds[item.player].options.accessibility != 'minimal')
|
||||
return not ((item.classification & 0b0011) and multiworld.worlds[item.player].options.accessibility != "minimal")
|
||||
|
||||
for location in unreachable_locations:
|
||||
add_item_rule(location, forbid_important_item_rule)
|
||||
@@ -677,9 +679,9 @@ def balance_multiworld_progression(multiworld: MultiWorld) -> None:
|
||||
if multiworld.worlds[player].options.progression_balancing > 0
|
||||
}
|
||||
if not balanceable_players:
|
||||
logging.info('Skipping multiworld progression balancing.')
|
||||
logging.info("Skipping multiworld progression balancing.")
|
||||
else:
|
||||
logging.info(f'Balancing multiworld progression for {len(balanceable_players)} Players.')
|
||||
logging.info(f"Balancing multiworld progression for {len(balanceable_players)} Players.")
|
||||
logging.debug(balanceable_players)
|
||||
state: CollectionState = CollectionState(multiworld)
|
||||
checked_locations: typing.Set[Location] = set()
|
||||
@@ -777,7 +779,7 @@ def balance_multiworld_progression(multiworld: MultiWorld) -> None:
|
||||
if player in threshold_percentages):
|
||||
break
|
||||
elif not balancing_sphere:
|
||||
raise RuntimeError('Not all required items reachable. Something went terribly wrong here.')
|
||||
raise RuntimeError("Not all required items reachable. Something went terribly wrong here.")
|
||||
# Gather a set of locations which we can swap items into
|
||||
unlocked_locations: typing.Dict[int, typing.Set[Location]] = collections.defaultdict(set)
|
||||
for l in unchecked_locations:
|
||||
@@ -793,8 +795,8 @@ def balance_multiworld_progression(multiworld: MultiWorld) -> None:
|
||||
testing = items_to_test.pop()
|
||||
reducing_state = state.copy()
|
||||
for location in itertools.chain((
|
||||
l for l in items_to_replace
|
||||
if l.item.player == player
|
||||
l for l in items_to_replace
|
||||
if l.item.player == player
|
||||
), items_to_test):
|
||||
reducing_state.collect(location.item, True, location)
|
||||
|
||||
@@ -867,52 +869,30 @@ def swap_location_item(location_1: Location, location_2: Location, check_locked:
|
||||
location_2.item.location = location_2
|
||||
|
||||
|
||||
def distribute_planned(multiworld: MultiWorld) -> None:
|
||||
def warn(warning: str, force: typing.Union[bool, str]) -> None:
|
||||
if force in [True, 'fail', 'failure', 'none', False, 'warn', 'warning']:
|
||||
logging.warning(f'{warning}')
|
||||
def parse_planned_blocks(multiworld: MultiWorld) -> dict[int, list[PlandoItemBlock]]:
|
||||
def warn(warning: str, force: bool | str) -> None:
|
||||
if isinstance(force, bool):
|
||||
logging.warning(f"{warning}")
|
||||
else:
|
||||
logging.debug(f'{warning}')
|
||||
logging.debug(f"{warning}")
|
||||
|
||||
def failed(warning: str, force: typing.Union[bool, str]) -> None:
|
||||
if force in [True, 'fail', 'failure']:
|
||||
def failed(warning: str, force: bool | str) -> None:
|
||||
if force is True:
|
||||
raise Exception(warning)
|
||||
else:
|
||||
warn(warning, force)
|
||||
|
||||
swept_state = multiworld.state.copy()
|
||||
swept_state.sweep_for_advancements()
|
||||
reachable = frozenset(multiworld.get_reachable_locations(swept_state))
|
||||
early_locations: typing.Dict[int, typing.List[str]] = collections.defaultdict(list)
|
||||
non_early_locations: typing.Dict[int, typing.List[str]] = collections.defaultdict(list)
|
||||
for loc in multiworld.get_unfilled_locations():
|
||||
if loc in reachable:
|
||||
early_locations[loc.player].append(loc.name)
|
||||
else: # not reachable with swept state
|
||||
non_early_locations[loc.player].append(loc.name)
|
||||
|
||||
world_name_lookup = multiworld.world_name_lookup
|
||||
|
||||
block_value = typing.Union[typing.List[str], typing.Dict[str, typing.Any], str]
|
||||
plando_blocks: typing.List[typing.Dict[str, typing.Any]] = []
|
||||
player_ids = set(multiworld.player_ids)
|
||||
plando_blocks: dict[int, list[PlandoItemBlock]] = dict()
|
||||
player_ids: set[int] = set(multiworld.player_ids)
|
||||
for player in player_ids:
|
||||
for block in multiworld.plando_items[player]:
|
||||
block['player'] = player
|
||||
if 'force' not in block:
|
||||
block['force'] = 'silent'
|
||||
if 'from_pool' not in block:
|
||||
block['from_pool'] = True
|
||||
elif not isinstance(block['from_pool'], bool):
|
||||
from_pool_type = type(block['from_pool'])
|
||||
raise Exception(f'Plando "from_pool" has to be boolean, not {from_pool_type} for player {player}.')
|
||||
if 'world' not in block:
|
||||
target_world = False
|
||||
else:
|
||||
target_world = block['world']
|
||||
|
||||
plando_blocks[player] = []
|
||||
for block in multiworld.worlds[player].options.plando_items:
|
||||
new_block: PlandoItemBlock = PlandoItemBlock(player, block.from_pool, block.force)
|
||||
target_world = block.world
|
||||
if target_world is False or multiworld.players == 1: # target own world
|
||||
worlds: typing.Set[int] = {player}
|
||||
worlds: set[int] = {player}
|
||||
elif target_world is True: # target any worlds besides own
|
||||
worlds = set(multiworld.player_ids) - {player}
|
||||
elif target_world is None: # target all worlds
|
||||
@@ -922,172 +902,197 @@ def distribute_planned(multiworld: MultiWorld) -> None:
|
||||
for listed_world in target_world:
|
||||
if listed_world not in world_name_lookup:
|
||||
failed(f"Cannot place item to {target_world}'s world as that world does not exist.",
|
||||
block['force'])
|
||||
block.force)
|
||||
continue
|
||||
worlds.add(world_name_lookup[listed_world])
|
||||
elif type(target_world) == int: # target world by slot number
|
||||
if target_world not in range(1, multiworld.players + 1):
|
||||
failed(
|
||||
f"Cannot place item in world {target_world} as it is not in range of (1, {multiworld.players})",
|
||||
block['force'])
|
||||
block.force)
|
||||
continue
|
||||
worlds = {target_world}
|
||||
else: # target world by slot name
|
||||
if target_world not in world_name_lookup:
|
||||
failed(f"Cannot place item to {target_world}'s world as that world does not exist.",
|
||||
block['force'])
|
||||
block.force)
|
||||
continue
|
||||
worlds = {world_name_lookup[target_world]}
|
||||
block['world'] = worlds
|
||||
new_block.worlds = worlds
|
||||
|
||||
items: block_value = []
|
||||
if "items" in block:
|
||||
items = block["items"]
|
||||
if 'count' not in block:
|
||||
block['count'] = False
|
||||
elif "item" in block:
|
||||
items = block["item"]
|
||||
if 'count' not in block:
|
||||
block['count'] = 1
|
||||
else:
|
||||
failed("You must specify at least one item to place items with plando.", block['force'])
|
||||
continue
|
||||
items: list[str] | dict[str, typing.Any] = block.items
|
||||
if isinstance(items, dict):
|
||||
item_list: typing.List[str] = []
|
||||
item_list: list[str] = []
|
||||
for key, value in items.items():
|
||||
if value is True:
|
||||
value = multiworld.itempool.count(multiworld.worlds[player].create_item(key))
|
||||
item_list += [key] * value
|
||||
items = item_list
|
||||
if isinstance(items, str):
|
||||
items = [items]
|
||||
block['items'] = items
|
||||
new_block.items = items
|
||||
|
||||
locations: block_value = []
|
||||
if 'location' in block:
|
||||
locations = block['location'] # just allow 'location' to keep old yamls compatible
|
||||
elif 'locations' in block:
|
||||
locations = block['locations']
|
||||
locations: list[str] = block.locations
|
||||
if isinstance(locations, str):
|
||||
locations = [locations]
|
||||
|
||||
if isinstance(locations, dict):
|
||||
location_list = []
|
||||
for key, value in locations.items():
|
||||
location_list += [key] * value
|
||||
locations = location_list
|
||||
locations_from_groups: list[str] = []
|
||||
resolved_locations: list[Location] = []
|
||||
for target_player in worlds:
|
||||
world_locations = multiworld.get_unfilled_locations(target_player)
|
||||
for group in multiworld.worlds[target_player].location_name_groups:
|
||||
if group in locations:
|
||||
locations_from_groups.extend(multiworld.worlds[target_player].location_name_groups[group])
|
||||
resolved_locations.extend(location for location in world_locations
|
||||
if location.name in [*locations, *locations_from_groups])
|
||||
new_block.locations = sorted(dict.fromkeys(locations))
|
||||
new_block.resolved_locations = sorted(set(resolved_locations))
|
||||
|
||||
count = block.count
|
||||
if not count:
|
||||
count = len(new_block.items)
|
||||
if isinstance(count, int):
|
||||
count = {"min": count, "max": count}
|
||||
if "min" not in count:
|
||||
count["min"] = 0
|
||||
if "max" not in count:
|
||||
count["max"] = len(new_block.items)
|
||||
|
||||
new_block.count = count
|
||||
plando_blocks[player].append(new_block)
|
||||
|
||||
return plando_blocks
|
||||
|
||||
|
||||
def resolve_early_locations_for_planned(multiworld: MultiWorld):
|
||||
def warn(warning: str, force: bool | str) -> None:
|
||||
if isinstance(force, bool):
|
||||
logging.warning(f"{warning}")
|
||||
else:
|
||||
logging.debug(f"{warning}")
|
||||
|
||||
def failed(warning: str, force: bool | str) -> None:
|
||||
if force is True:
|
||||
raise Exception(warning)
|
||||
else:
|
||||
warn(warning, force)
|
||||
|
||||
swept_state = multiworld.state.copy()
|
||||
swept_state.sweep_for_advancements()
|
||||
reachable = frozenset(multiworld.get_reachable_locations(swept_state))
|
||||
early_locations: dict[int, list[Location]] = collections.defaultdict(list)
|
||||
non_early_locations: dict[int, list[Location]] = collections.defaultdict(list)
|
||||
for loc in multiworld.get_unfilled_locations():
|
||||
if loc in reachable:
|
||||
early_locations[loc.player].append(loc)
|
||||
else: # not reachable with swept state
|
||||
non_early_locations[loc.player].append(loc)
|
||||
|
||||
for player in multiworld.plando_item_blocks:
|
||||
removed = []
|
||||
for block in multiworld.plando_item_blocks[player]:
|
||||
locations = block.locations
|
||||
resolved_locations = block.resolved_locations
|
||||
worlds = block.worlds
|
||||
if "early_locations" in locations:
|
||||
locations.remove("early_locations")
|
||||
for target_player in worlds:
|
||||
locations += early_locations[target_player]
|
||||
resolved_locations += early_locations[target_player]
|
||||
if "non_early_locations" in locations:
|
||||
locations.remove("non_early_locations")
|
||||
for target_player in worlds:
|
||||
locations += non_early_locations[target_player]
|
||||
resolved_locations += non_early_locations[target_player]
|
||||
|
||||
block['locations'] = list(dict.fromkeys(locations))
|
||||
if block.count["max"] > len(block.items):
|
||||
count = block.count["max"]
|
||||
failed(f"Plando count {count} greater than items specified", block.force)
|
||||
block.count["max"] = len(block.items)
|
||||
if block.count["min"] > len(block.items):
|
||||
block.count["min"] = len(block.items)
|
||||
if block.count["max"] > len(block.resolved_locations) > 0:
|
||||
count = block.count["max"]
|
||||
failed(f"Plando count {count} greater than locations specified", block.force)
|
||||
block.count["max"] = len(block.resolved_locations)
|
||||
if block.count["min"] > len(block.resolved_locations):
|
||||
block.count["min"] = len(block.resolved_locations)
|
||||
block.count["target"] = multiworld.random.randint(block.count["min"],
|
||||
block.count["max"])
|
||||
|
||||
if not block['count']:
|
||||
block['count'] = (min(len(block['items']), len(block['locations'])) if
|
||||
len(block['locations']) > 0 else len(block['items']))
|
||||
if isinstance(block['count'], int):
|
||||
block['count'] = {'min': block['count'], 'max': block['count']}
|
||||
if 'min' not in block['count']:
|
||||
block['count']['min'] = 0
|
||||
if 'max' not in block['count']:
|
||||
block['count']['max'] = (min(len(block['items']), len(block['locations'])) if
|
||||
len(block['locations']) > 0 else len(block['items']))
|
||||
if block['count']['max'] > len(block['items']):
|
||||
count = block['count']
|
||||
failed(f"Plando count {count} greater than items specified", block['force'])
|
||||
block['count'] = len(block['items'])
|
||||
if block['count']['max'] > len(block['locations']) > 0:
|
||||
count = block['count']
|
||||
failed(f"Plando count {count} greater than locations specified", block['force'])
|
||||
block['count'] = len(block['locations'])
|
||||
block['count']['target'] = multiworld.random.randint(block['count']['min'], block['count']['max'])
|
||||
if not block.count["target"]:
|
||||
removed.append(block)
|
||||
|
||||
if block['count']['target'] > 0:
|
||||
plando_blocks.append(block)
|
||||
for block in removed:
|
||||
multiworld.plando_item_blocks[player].remove(block)
|
||||
|
||||
|
||||
def distribute_planned_blocks(multiworld: MultiWorld, plando_blocks: list[PlandoItemBlock]):
|
||||
def warn(warning: str, force: bool | str) -> None:
|
||||
if isinstance(force, bool):
|
||||
logging.warning(f"{warning}")
|
||||
else:
|
||||
logging.debug(f"{warning}")
|
||||
|
||||
def failed(warning: str, force: bool | str) -> None:
|
||||
if force is True:
|
||||
raise Exception(warning)
|
||||
else:
|
||||
warn(warning, force)
|
||||
|
||||
# shuffle, but then sort blocks by number of locations minus number of items,
|
||||
# so less-flexible blocks get priority
|
||||
multiworld.random.shuffle(plando_blocks)
|
||||
plando_blocks.sort(key=lambda block: (len(block['locations']) - block['count']['target']
|
||||
if len(block['locations']) > 0
|
||||
else len(multiworld.get_unfilled_locations(player)) - block['count']['target']))
|
||||
|
||||
plando_blocks.sort(key=lambda block: (len(block.resolved_locations) - block.count["target"]
|
||||
if len(block.resolved_locations) > 0
|
||||
else len(multiworld.get_unfilled_locations(block.player)) -
|
||||
block.count["target"]))
|
||||
for placement in plando_blocks:
|
||||
player = placement['player']
|
||||
player = placement.player
|
||||
try:
|
||||
worlds = placement['world']
|
||||
locations = placement['locations']
|
||||
items = placement['items']
|
||||
maxcount = placement['count']['target']
|
||||
from_pool = placement['from_pool']
|
||||
worlds = placement.worlds
|
||||
locations = placement.resolved_locations
|
||||
items = placement.items
|
||||
maxcount = placement.count["target"]
|
||||
from_pool = placement.from_pool
|
||||
|
||||
candidates = list(multiworld.get_unfilled_locations_for_players(locations, sorted(worlds)))
|
||||
multiworld.random.shuffle(candidates)
|
||||
multiworld.random.shuffle(items)
|
||||
count = 0
|
||||
err: typing.List[str] = []
|
||||
successful_pairs: typing.List[typing.Tuple[int, Item, Location]] = []
|
||||
claimed_indices: typing.Set[typing.Optional[int]] = set()
|
||||
for item_name in items:
|
||||
index_to_delete: typing.Optional[int] = None
|
||||
if from_pool:
|
||||
try:
|
||||
# If from_pool, try to find an existing item with this name & player in the itempool and use it
|
||||
index_to_delete, item = next(
|
||||
(i, item) for i, item in enumerate(multiworld.itempool)
|
||||
if item.player == player and item.name == item_name and i not in claimed_indices
|
||||
)
|
||||
except StopIteration:
|
||||
warn(
|
||||
f"Could not remove {item_name} from pool for {multiworld.player_name[player]} as it's already missing from it.",
|
||||
placement['force'])
|
||||
item = multiworld.worlds[player].create_item(item_name)
|
||||
else:
|
||||
item = multiworld.worlds[player].create_item(item_name)
|
||||
|
||||
for location in reversed(candidates):
|
||||
if (location.address is None) == (item.code is None): # either both None or both not None
|
||||
if not location.item:
|
||||
if location.item_rule(item):
|
||||
if location.can_fill(multiworld.state, item, False):
|
||||
successful_pairs.append((index_to_delete, item, location))
|
||||
claimed_indices.add(index_to_delete)
|
||||
candidates.remove(location)
|
||||
count = count + 1
|
||||
break
|
||||
else:
|
||||
err.append(f"Can't place item at {location} due to fill condition not met.")
|
||||
else:
|
||||
err.append(f"{item_name} not allowed at {location}.")
|
||||
else:
|
||||
err.append(f"Cannot place {item_name} into already filled location {location}.")
|
||||
item_candidates = []
|
||||
if from_pool:
|
||||
instances = [item for item in multiworld.itempool if item.player == player and item.name in items]
|
||||
for item in multiworld.random.sample(items, maxcount):
|
||||
candidate = next((i for i in instances if i.name == item), None)
|
||||
if candidate is None:
|
||||
warn(f"Could not remove {item} from pool for {multiworld.player_name[player]} as "
|
||||
f"it's already missing from it", placement.force)
|
||||
candidate = multiworld.worlds[player].create_item(item)
|
||||
else:
|
||||
err.append(f"Mismatch between {item_name} and {location}, only one is an event.")
|
||||
|
||||
if count == maxcount:
|
||||
break
|
||||
if count < placement['count']['min']:
|
||||
m = placement['count']['min']
|
||||
failed(
|
||||
f"Plando block failed to place {m - count} of {m} item(s) for {multiworld.player_name[player]}, error(s): {' '.join(err)}",
|
||||
placement['force'])
|
||||
|
||||
# Sort indices in reverse so we can remove them one by one
|
||||
successful_pairs = sorted(successful_pairs, key=lambda successful_pair: successful_pair[0] or 0, reverse=True)
|
||||
|
||||
for (index, item, location) in successful_pairs:
|
||||
multiworld.push_item(location, item, collect=False)
|
||||
location.locked = True
|
||||
logging.debug(f"Plando placed {item} at {location}")
|
||||
if index is not None: # If this item is from_pool and was found in the pool, remove it.
|
||||
multiworld.itempool.pop(index)
|
||||
multiworld.itempool.remove(candidate)
|
||||
instances.remove(candidate)
|
||||
item_candidates.append(candidate)
|
||||
else:
|
||||
item_candidates = [multiworld.worlds[player].create_item(item)
|
||||
for item in multiworld.random.sample(items, maxcount)]
|
||||
if any(item.code is None for item in item_candidates) \
|
||||
and not all(item.code is None for item in item_candidates):
|
||||
failed(f"Plando block for player {player} ({multiworld.player_name[player]}) contains both "
|
||||
f"event items and non-event items. "
|
||||
f"Event items: {[item for item in item_candidates if item.code is None]}, "
|
||||
f"Non-event items: {[item for item in item_candidates if item.code is not None]}",
|
||||
placement.force)
|
||||
continue
|
||||
else:
|
||||
is_real = item_candidates[0].code is not None
|
||||
candidates = [candidate for candidate in locations if candidate.item is None
|
||||
and bool(candidate.address) == is_real]
|
||||
multiworld.random.shuffle(candidates)
|
||||
allstate = multiworld.get_all_state(False)
|
||||
mincount = placement.count["min"]
|
||||
allowed_margin = len(item_candidates) - mincount
|
||||
fill_restrictive(multiworld, allstate, candidates, item_candidates, lock=True,
|
||||
allow_partial=True, name="Plando Main Fill")
|
||||
|
||||
if len(item_candidates) > allowed_margin:
|
||||
failed(f"Could not place {len(item_candidates)} "
|
||||
f"of {mincount + allowed_margin} item(s) "
|
||||
f"for {multiworld.player_name[player]}, "
|
||||
f"remaining items: {item_candidates}",
|
||||
placement.force)
|
||||
if from_pool:
|
||||
multiworld.itempool.extend([item for item in item_candidates if item.code is not None])
|
||||
except Exception as e:
|
||||
raise Exception(
|
||||
f"Error running plando for player {player} ({multiworld.player_name[player]})") from e
|
||||
|
||||
28
Generate.py
28
Generate.py
@@ -10,8 +10,8 @@ import sys
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from collections import Counter
|
||||
from typing import Any, Dict, Tuple, Union
|
||||
from itertools import chain
|
||||
from typing import Any
|
||||
|
||||
import ModuleUpdate
|
||||
|
||||
@@ -77,7 +77,7 @@ def get_seed_name(random_source) -> str:
|
||||
return f"{random_source.randint(0, pow(10, seeddigits) - 1)}".zfill(seeddigits)
|
||||
|
||||
|
||||
def main(args=None) -> Tuple[argparse.Namespace, int]:
|
||||
def main(args=None) -> tuple[argparse.Namespace, int]:
|
||||
# __name__ == "__main__" check so unittests that already imported worlds don't trip this.
|
||||
if __name__ == "__main__" and "worlds" in sys.modules:
|
||||
raise Exception("Worlds system should not be loaded before logging init.")
|
||||
@@ -95,7 +95,7 @@ def main(args=None) -> Tuple[argparse.Namespace, int]:
|
||||
logging.info("Race mode enabled. Using non-deterministic random source.")
|
||||
random.seed() # reset to time-based random source
|
||||
|
||||
weights_cache: Dict[str, Tuple[Any, ...]] = {}
|
||||
weights_cache: dict[str, tuple[Any, ...]] = {}
|
||||
if args.weights_file_path and os.path.exists(args.weights_file_path):
|
||||
try:
|
||||
weights_cache[args.weights_file_path] = read_weights_yamls(args.weights_file_path)
|
||||
@@ -180,7 +180,7 @@ def main(args=None) -> Tuple[argparse.Namespace, int]:
|
||||
erargs.name = {}
|
||||
erargs.csv_output = args.csv_output
|
||||
|
||||
settings_cache: Dict[str, Tuple[argparse.Namespace, ...]] = \
|
||||
settings_cache: dict[str, tuple[argparse.Namespace, ...]] = \
|
||||
{fname: (tuple(roll_settings(yaml, args.plando) for yaml in yamls) if args.sameoptions else None)
|
||||
for fname, yamls in weights_cache.items()}
|
||||
|
||||
@@ -212,7 +212,7 @@ def main(args=None) -> Tuple[argparse.Namespace, int]:
|
||||
path = player_path_cache[player]
|
||||
if path:
|
||||
try:
|
||||
settings: Tuple[argparse.Namespace, ...] = settings_cache[path] if settings_cache[path] else \
|
||||
settings: tuple[argparse.Namespace, ...] = settings_cache[path] if settings_cache[path] else \
|
||||
tuple(roll_settings(yaml, args.plando) for yaml in weights_cache[path])
|
||||
for settingsObject in settings:
|
||||
for k, v in vars(settingsObject).items():
|
||||
@@ -242,7 +242,7 @@ def main(args=None) -> Tuple[argparse.Namespace, int]:
|
||||
return erargs, seed
|
||||
|
||||
|
||||
def read_weights_yamls(path) -> Tuple[Any, ...]:
|
||||
def read_weights_yamls(path) -> tuple[Any, ...]:
|
||||
try:
|
||||
if urllib.parse.urlparse(path).scheme in ('https', 'file'):
|
||||
yaml = str(urllib.request.urlopen(path).read(), "utf-8-sig")
|
||||
@@ -334,12 +334,6 @@ def handle_name(name: str, player: int, name_counter: Counter):
|
||||
return new_name
|
||||
|
||||
|
||||
def roll_percentage(percentage: Union[int, float]) -> bool:
|
||||
"""Roll a percentage chance.
|
||||
percentage is expected to be in range [0, 100]"""
|
||||
return random.random() < (float(percentage) / 100)
|
||||
|
||||
|
||||
def update_weights(weights: dict, new_weights: dict, update_type: str, name: str) -> dict:
|
||||
logging.debug(f'Applying {new_weights}')
|
||||
cleaned_weights = {}
|
||||
@@ -384,7 +378,7 @@ def update_weights(weights: dict, new_weights: dict, update_type: str, name: str
|
||||
return weights
|
||||
|
||||
|
||||
def roll_meta_option(option_key, game: str, category_dict: Dict) -> Any:
|
||||
def roll_meta_option(option_key, game: str, category_dict: dict) -> Any:
|
||||
from worlds import AutoWorldRegister
|
||||
|
||||
if not game:
|
||||
@@ -405,7 +399,7 @@ def roll_linked_options(weights: dict) -> dict:
|
||||
if "name" not in option_set:
|
||||
raise ValueError("One of your linked options does not have a name.")
|
||||
try:
|
||||
if roll_percentage(option_set["percentage"]):
|
||||
if Options.roll_percentage(option_set["percentage"]):
|
||||
logging.debug(f"Linked option {option_set['name']} triggered.")
|
||||
new_options = option_set["options"]
|
||||
for category_name, category_options in new_options.items():
|
||||
@@ -438,7 +432,7 @@ def roll_triggers(weights: dict, triggers: list, valid_keys: set) -> dict:
|
||||
trigger_result = get_choice("option_result", option_set)
|
||||
result = get_choice(key, currently_targeted_weights)
|
||||
currently_targeted_weights[key] = result
|
||||
if result == trigger_result and roll_percentage(get_choice("percentage", option_set, 100)):
|
||||
if result == trigger_result and Options.roll_percentage(get_choice("percentage", option_set, 100)):
|
||||
for category_name, category_options in option_set["options"].items():
|
||||
currently_targeted_weights = weights
|
||||
if category_name:
|
||||
@@ -542,10 +536,6 @@ def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.b
|
||||
handle_option(ret, game_weights, option_key, option, plando_options)
|
||||
valid_keys.add(option_key)
|
||||
|
||||
# TODO remove plando_items after moving it to the options system
|
||||
valid_keys.add("plando_items")
|
||||
if PlandoOptions.items in plando_options:
|
||||
ret.plando_items = copy.deepcopy(game_weights.get("plando_items", []))
|
||||
if ret.game == "A Link to the Past":
|
||||
# TODO there are still more LTTP options not on the options system
|
||||
valid_keys |= {"sprite_pool", "sprite", "random_sprite_on_event"}
|
||||
|
||||
13
Launcher.py
13
Launcher.py
@@ -16,9 +16,10 @@ import subprocess
|
||||
import sys
|
||||
import urllib.parse
|
||||
import webbrowser
|
||||
from collections.abc import Callable, Sequence
|
||||
from os.path import isfile
|
||||
from shutil import which
|
||||
from typing import Callable, Optional, Sequence, Tuple, Union, Any
|
||||
from typing import Any
|
||||
|
||||
if __name__ == "__main__":
|
||||
import ModuleUpdate
|
||||
@@ -114,7 +115,7 @@ components.extend([
|
||||
])
|
||||
|
||||
|
||||
def handle_uri(path: str, launch_args: Tuple[str, ...]) -> None:
|
||||
def handle_uri(path: str, launch_args: tuple[str, ...]) -> None:
|
||||
url = urllib.parse.urlparse(path)
|
||||
queries = urllib.parse.parse_qs(url.query)
|
||||
launch_args = (path, *launch_args)
|
||||
@@ -162,7 +163,7 @@ def handle_uri(path: str, launch_args: Tuple[str, ...]) -> None:
|
||||
).open()
|
||||
|
||||
|
||||
def identify(path: Union[None, str]) -> Tuple[Union[None, str], Union[None, Component]]:
|
||||
def identify(path: None | str) -> tuple[None | str, None | Component]:
|
||||
if path is None:
|
||||
return None, None
|
||||
for component in components:
|
||||
@@ -173,7 +174,7 @@ def identify(path: Union[None, str]) -> Tuple[Union[None, str], Union[None, Comp
|
||||
return None, None
|
||||
|
||||
|
||||
def get_exe(component: Union[str, Component]) -> Optional[Sequence[str]]:
|
||||
def get_exe(component: str | Component) -> Sequence[str] | None:
|
||||
if isinstance(component, str):
|
||||
name = component
|
||||
component = None
|
||||
@@ -226,7 +227,7 @@ def create_shortcut(button: Any, component: Component) -> None:
|
||||
button.menu.dismiss()
|
||||
|
||||
|
||||
refresh_components: Optional[Callable[[], None]] = None
|
||||
refresh_components: Callable[[], None] | None = None
|
||||
|
||||
|
||||
def run_gui(path: str, args: Any) -> None:
|
||||
@@ -451,7 +452,7 @@ def run_component(component: Component, *args):
|
||||
logging.warning(f"Component {component} does not appear to be executable.")
|
||||
|
||||
|
||||
def main(args: Optional[Union[argparse.Namespace, dict]] = None):
|
||||
def main(args: argparse.Namespace | dict | None = None):
|
||||
if isinstance(args, argparse.Namespace):
|
||||
args = {k: v for k, v in args._get_kwargs()}
|
||||
elif not args:
|
||||
|
||||
35
Main.py
35
Main.py
@@ -7,14 +7,13 @@ import tempfile
|
||||
import time
|
||||
import zipfile
|
||||
import zlib
|
||||
from typing import Dict, List, Optional, Set, Tuple, Union
|
||||
|
||||
import worlds
|
||||
from BaseClasses import CollectionState, Item, Location, LocationProgressType, MultiWorld, Region
|
||||
from Fill import FillError, balance_multiworld_progression, distribute_items_restrictive, distribute_planned, \
|
||||
flood_items
|
||||
from BaseClasses import CollectionState, Item, Location, LocationProgressType, MultiWorld
|
||||
from Fill import FillError, balance_multiworld_progression, distribute_items_restrictive, flood_items, \
|
||||
parse_planned_blocks, distribute_planned_blocks, resolve_early_locations_for_planned
|
||||
from Options import StartInventoryPool
|
||||
from Utils import __version__, output_path, version_tuple, get_settings
|
||||
from Utils import __version__, output_path, version_tuple
|
||||
from settings import get_settings
|
||||
from worlds import AutoWorld
|
||||
from worlds.generic.Rules import exclusion_rules, locality_rules
|
||||
@@ -22,7 +21,7 @@ from worlds.generic.Rules import exclusion_rules, locality_rules
|
||||
__all__ = ["main"]
|
||||
|
||||
|
||||
def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = None):
|
||||
def main(args, seed=None, baked_server_options: dict[str, object] | None = None):
|
||||
if not baked_server_options:
|
||||
baked_server_options = get_settings().server_options.as_dict()
|
||||
assert isinstance(baked_server_options, dict)
|
||||
@@ -37,9 +36,6 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
||||
logger = logging.getLogger()
|
||||
multiworld.set_seed(seed, args.race, str(args.outputname) if args.outputname else None)
|
||||
multiworld.plando_options = args.plando_options
|
||||
multiworld.plando_items = args.plando_items.copy()
|
||||
multiworld.plando_texts = args.plando_texts.copy()
|
||||
multiworld.plando_connections = args.plando_connections.copy()
|
||||
multiworld.game = args.game.copy()
|
||||
multiworld.player_name = args.name.copy()
|
||||
multiworld.sprite = args.sprite.copy()
|
||||
@@ -135,13 +131,15 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
||||
multiworld.worlds[1].options.non_local_items.value = set()
|
||||
multiworld.worlds[1].options.local_items.value = set()
|
||||
|
||||
multiworld.plando_item_blocks = parse_planned_blocks(multiworld)
|
||||
|
||||
AutoWorld.call_all(multiworld, "connect_entrances")
|
||||
AutoWorld.call_all(multiworld, "generate_basic")
|
||||
|
||||
# remove starting inventory from pool items.
|
||||
# Because some worlds don't actually create items during create_items this has to be as late as possible.
|
||||
fallback_inventory = StartInventoryPool({})
|
||||
depletion_pool: Dict[int, Dict[str, int]] = {
|
||||
depletion_pool: dict[int, dict[str, int]] = {
|
||||
player: getattr(multiworld.worlds[player].options, "start_inventory_from_pool", fallback_inventory).value.copy()
|
||||
for player in multiworld.player_ids
|
||||
}
|
||||
@@ -150,7 +148,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
||||
}
|
||||
|
||||
if target_per_player:
|
||||
new_itempool: List[Item] = []
|
||||
new_itempool: list[Item] = []
|
||||
|
||||
# Make new itempool with start_inventory_from_pool items removed
|
||||
for item in multiworld.itempool:
|
||||
@@ -179,8 +177,9 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
||||
multiworld._all_state = None
|
||||
|
||||
logger.info("Running Item Plando.")
|
||||
|
||||
distribute_planned(multiworld)
|
||||
resolve_early_locations_for_planned(multiworld)
|
||||
distribute_planned_blocks(multiworld, [x for player in multiworld.plando_item_blocks
|
||||
for x in multiworld.plando_item_blocks[player]])
|
||||
|
||||
logger.info('Running Pre Main Fill.')
|
||||
|
||||
@@ -233,7 +232,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
||||
pool.submit(AutoWorld.call_single, multiworld, "generate_output", player, temp_dir))
|
||||
|
||||
# collect ER hint info
|
||||
er_hint_data: Dict[int, Dict[int, str]] = {}
|
||||
er_hint_data: dict[int, dict[int, str]] = {}
|
||||
AutoWorld.call_all(multiworld, 'extend_hint_information', er_hint_data)
|
||||
|
||||
def write_multidata():
|
||||
@@ -274,7 +273,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
||||
for player in multiworld.groups[location.item.player]["players"]:
|
||||
precollected_hints[player].add(hint)
|
||||
|
||||
locations_data: Dict[int, Dict[int, Tuple[int, int, int]]] = {player: {} for player in multiworld.player_ids}
|
||||
locations_data: dict[int, dict[int, tuple[int, int, int]]] = {player: {} for player in multiworld.player_ids}
|
||||
for location in multiworld.get_filled_locations():
|
||||
if type(location.address) == int:
|
||||
assert location.item.code is not None, "item code None should be event, " \
|
||||
@@ -303,12 +302,12 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
||||
}
|
||||
data_package["Archipelago"] = worlds.network_data_package["games"]["Archipelago"]
|
||||
|
||||
checks_in_area: Dict[int, Dict[str, Union[int, List[int]]]] = {}
|
||||
checks_in_area: dict[int, dict[str, int | list[int]]] = {}
|
||||
|
||||
# get spheres -> filter address==None -> skip empty
|
||||
spheres: List[Dict[int, Set[int]]] = []
|
||||
spheres: list[dict[int, set[int]]] = []
|
||||
for sphere in multiworld.get_sendable_spheres():
|
||||
current_sphere: Dict[int, Set[int]] = collections.defaultdict(set)
|
||||
current_sphere: dict[int, set[int]] = collections.defaultdict(set)
|
||||
for sphere_location in sphere:
|
||||
current_sphere[sphere_location.player].add(sphere_location.address)
|
||||
|
||||
|
||||
@@ -1826,7 +1826,7 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
|
||||
ctx.clients[team][slot].append(client)
|
||||
client.version = args['version']
|
||||
client.tags = args['tags']
|
||||
client.no_locations = "TextOnly" in client.tags or "Tracker" in client.tags
|
||||
client.no_locations = bool(client.tags & _non_game_messages.keys())
|
||||
# set NoText for old PopTracker clients that predate the tag to save traffic
|
||||
client.no_text = "NoText" in client.tags or ("PopTracker" in client.tags and client.version < (0, 5, 1))
|
||||
connected_packet = {
|
||||
@@ -1900,7 +1900,7 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
|
||||
old_tags = client.tags
|
||||
client.tags = args["tags"]
|
||||
if set(old_tags) != set(client.tags):
|
||||
client.no_locations = 'TextOnly' in client.tags or 'Tracker' in client.tags
|
||||
client.no_locations = bool(client.tags & _non_game_messages.keys())
|
||||
client.no_text = "NoText" in client.tags or (
|
||||
"PopTracker" in client.tags and client.version < (0, 5, 1)
|
||||
)
|
||||
@@ -1990,9 +1990,14 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
|
||||
ctx.save()
|
||||
for slot in concerning_slots:
|
||||
ctx.on_changed_hints(client.team, slot)
|
||||
|
||||
|
||||
elif cmd == 'StatusUpdate':
|
||||
update_client_status(ctx, client, args["status"])
|
||||
if client.no_locations and args["status"] == ClientStatus.CLIENT_GOAL:
|
||||
await ctx.send_msgs(client, [{'cmd': 'InvalidPacket', "type": "cmd",
|
||||
"text": "Trackers can't register Goal Complete",
|
||||
"original_cmd": cmd}])
|
||||
else:
|
||||
update_client_status(ctx, client, args["status"])
|
||||
|
||||
elif cmd == 'Say':
|
||||
if "text" not in args or type(args["text"]) is not str or not args["text"].isprintable():
|
||||
@@ -2363,7 +2368,6 @@ class ServerCommandProcessor(CommonCommandProcessor):
|
||||
known_options = (f"{option}: {option_type}" for option, option_type in self.ctx.simple_options.items())
|
||||
self.output(f"Unrecognized option '{option_name}', known: {', '.join(known_options)}")
|
||||
return False
|
||||
|
||||
if value_type == bool:
|
||||
def value_type(input_text: str):
|
||||
return input_text.lower() not in {"off", "0", "false", "none", "null", "no"}
|
||||
@@ -2397,6 +2401,75 @@ class ServerCommandProcessor(CommonCommandProcessor):
|
||||
f"approximately totaling {Utils.format_SI_prefix(total, power=1024)}B")
|
||||
self.output("\n".join(texts))
|
||||
|
||||
def _cmd_discord_webhook(self, webhook_url: str):
|
||||
"""Needs to be supplied with a Discord WebHook url as parameter,
|
||||
which will then relay the server log to a discord channel."""
|
||||
|
||||
import discord_webhook
|
||||
initial_response = discord_webhook.DiscordWebhook(webhook_url, wait=True,
|
||||
content="Beginning Discord Logging").execute()
|
||||
if initial_response.ok:
|
||||
import queue
|
||||
response_queue = queue.SimpleQueue()
|
||||
|
||||
class Emitter(threading.Thread):
|
||||
def run(self):
|
||||
record: typing.Optional[logging.LogRecord] = None
|
||||
while True:
|
||||
time.sleep(1)
|
||||
# check for leftover record from last iteration
|
||||
message = record.msg if record else ""
|
||||
while 1:
|
||||
try:
|
||||
record = response_queue.get_nowait()
|
||||
except queue.Empty:
|
||||
break
|
||||
else:
|
||||
if record is None:
|
||||
return # shutdown
|
||||
if len(record.msg) > 1999:
|
||||
continue # content size limit
|
||||
if len(message) + len(record.msg) > 2000:
|
||||
break # reached content size limit in total
|
||||
else:
|
||||
message += "\n" + record.msg
|
||||
record = None
|
||||
if message:
|
||||
try:
|
||||
response = discord_webhook.DiscordWebhook(
|
||||
webhook_url, rate_limit_retry=True, content=message.strip()).execute()
|
||||
if response.status_code not in (200, 204):
|
||||
shutdown()
|
||||
logging.info(f"Disabled Discord WebHook due to error code {response.status_code}.")
|
||||
return
|
||||
# just in case to prevent an error-loop logging itself
|
||||
except Exception as e:
|
||||
shutdown()
|
||||
logging.error("Disabled Discord WebHook due to error.")
|
||||
logging.exception(e)
|
||||
return
|
||||
|
||||
emitter = Emitter()
|
||||
emitter.daemon = True
|
||||
emitter.start()
|
||||
|
||||
class DiscordLogger(logging.Handler):
|
||||
"""Logs to Discord WebHook"""
|
||||
def emit(self, record: logging.LogRecord):
|
||||
response_queue.put(record)
|
||||
|
||||
handler = DiscordLogger()
|
||||
|
||||
def shutdown():
|
||||
response_queue.put(None)
|
||||
logging.getLogger().removeHandler(handler)
|
||||
|
||||
logging.getLogger().addHandler(handler)
|
||||
self.output("Discord Link established.")
|
||||
|
||||
else:
|
||||
self.output("Discord Link could not be established. Check your webhook url.")
|
||||
|
||||
|
||||
async def console(ctx: Context):
|
||||
import sys
|
||||
|
||||
141
Options.py
141
Options.py
@@ -24,6 +24,12 @@ if typing.TYPE_CHECKING:
|
||||
import pathlib
|
||||
|
||||
|
||||
def roll_percentage(percentage: int | float) -> bool:
|
||||
"""Roll a percentage chance.
|
||||
percentage is expected to be in range [0, 100]"""
|
||||
return random.random() < (float(percentage) / 100)
|
||||
|
||||
|
||||
class OptionError(ValueError):
|
||||
pass
|
||||
|
||||
@@ -1019,7 +1025,7 @@ class PlandoTexts(Option[typing.List[PlandoText]], VerifyKeys):
|
||||
if isinstance(data, typing.Iterable):
|
||||
for text in data:
|
||||
if isinstance(text, typing.Mapping):
|
||||
if random.random() < float(text.get("percentage", 100)/100):
|
||||
if roll_percentage(text.get("percentage", 100)):
|
||||
at = text.get("at", None)
|
||||
if at is not None:
|
||||
if isinstance(at, dict):
|
||||
@@ -1045,7 +1051,7 @@ class PlandoTexts(Option[typing.List[PlandoText]], VerifyKeys):
|
||||
else:
|
||||
raise OptionError("\"at\" must be a valid string or weighted list of strings!")
|
||||
elif isinstance(text, PlandoText):
|
||||
if random.random() < float(text.percentage/100):
|
||||
if roll_percentage(text.percentage):
|
||||
texts.append(text)
|
||||
else:
|
||||
raise Exception(f"Cannot create plando text from non-dictionary type, got {type(text)}")
|
||||
@@ -1169,7 +1175,7 @@ class PlandoConnections(Option[typing.List[PlandoConnection]], metaclass=Connect
|
||||
for connection in data:
|
||||
if isinstance(connection, typing.Mapping):
|
||||
percentage = connection.get("percentage", 100)
|
||||
if random.random() < float(percentage / 100):
|
||||
if roll_percentage(percentage):
|
||||
entrance = connection.get("entrance", None)
|
||||
if is_iterable_except_str(entrance):
|
||||
entrance = random.choice(sorted(entrance))
|
||||
@@ -1187,7 +1193,7 @@ class PlandoConnections(Option[typing.List[PlandoConnection]], metaclass=Connect
|
||||
percentage
|
||||
))
|
||||
elif isinstance(connection, PlandoConnection):
|
||||
if random.random() < float(connection.percentage / 100):
|
||||
if roll_percentage(connection.percentage):
|
||||
value.append(connection)
|
||||
else:
|
||||
raise Exception(f"Cannot create connection from non-Dict type, got {type(connection)}.")
|
||||
@@ -1353,6 +1359,7 @@ class StartInventory(ItemDict):
|
||||
verify_item_name = True
|
||||
display_name = "Start Inventory"
|
||||
rich_text_doc = True
|
||||
max = 10000
|
||||
|
||||
|
||||
class StartInventoryPool(StartInventory):
|
||||
@@ -1468,6 +1475,131 @@ class ItemLinks(OptionList):
|
||||
link["item_pool"] = list(pool)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PlandoItem:
|
||||
items: list[str] | dict[str, typing.Any]
|
||||
locations: list[str]
|
||||
world: int | str | bool | None | typing.Iterable[str] | set[int] = False
|
||||
from_pool: bool = True
|
||||
force: bool | typing.Literal["silent"] = "silent"
|
||||
count: int | bool | dict[str, int] = False
|
||||
percentage: int = 100
|
||||
|
||||
|
||||
class PlandoItems(Option[typing.List[PlandoItem]]):
|
||||
"""Generic items plando."""
|
||||
default = ()
|
||||
supports_weighting = False
|
||||
display_name = "Plando Items"
|
||||
|
||||
def __init__(self, value: typing.Iterable[PlandoItem]) -> None:
|
||||
self.value = list(deepcopy(value))
|
||||
super().__init__()
|
||||
|
||||
@classmethod
|
||||
def from_any(cls, data: typing.Any) -> Option[typing.List[PlandoItem]]:
|
||||
if not isinstance(data, typing.Iterable):
|
||||
raise OptionError(f"Cannot create plando items from non-Iterable type, got {type(data)}")
|
||||
|
||||
value: typing.List[PlandoItem] = []
|
||||
for item in data:
|
||||
if isinstance(item, typing.Mapping):
|
||||
percentage = item.get("percentage", 100)
|
||||
if not isinstance(percentage, int):
|
||||
raise OptionError(f"Plando `percentage` has to be int, not {type(percentage)}.")
|
||||
if not (0 <= percentage <= 100):
|
||||
raise OptionError(f"Plando `percentage` has to be between 0 and 100 (inclusive) not {percentage}.")
|
||||
if roll_percentage(percentage):
|
||||
count = item.get("count", False)
|
||||
items = item.get("items", [])
|
||||
if not items:
|
||||
items = item.get("item", None) # explicitly throw an error here if not present
|
||||
if not items:
|
||||
raise OptionError("You must specify at least one item to place items with plando.")
|
||||
count = 1
|
||||
if isinstance(items, str):
|
||||
items = [items]
|
||||
elif not isinstance(items, (dict, list)):
|
||||
raise OptionError(f"Plando 'items' has to be string, list, or "
|
||||
f"dictionary, not {type(items)}")
|
||||
locations = item.get("locations", [])
|
||||
if not locations:
|
||||
locations = item.get("location", ["Everywhere"])
|
||||
if locations:
|
||||
count = 1
|
||||
if isinstance(locations, str):
|
||||
locations = [locations]
|
||||
if not isinstance(locations, list):
|
||||
raise OptionError(f"Plando `location` has to be string or list, not {type(locations)}")
|
||||
world = item.get("world", False)
|
||||
from_pool = item.get("from_pool", True)
|
||||
force = item.get("force", "silent")
|
||||
if not isinstance(from_pool, bool):
|
||||
raise OptionError(f"Plando 'from_pool' has to be true or false, not {from_pool!r}.")
|
||||
if not (isinstance(force, bool) or force == "silent"):
|
||||
raise OptionError(f"Plando `force` has to be true or false or `silent`, not {force!r}.")
|
||||
value.append(PlandoItem(items, locations, world, from_pool, force, count, percentage))
|
||||
elif isinstance(item, PlandoItem):
|
||||
if roll_percentage(item.percentage):
|
||||
value.append(item)
|
||||
else:
|
||||
raise OptionError(f"Cannot create plando item from non-Dict type, got {type(item)}.")
|
||||
return cls(value)
|
||||
|
||||
def verify(self, world: typing.Type[World], player_name: str, plando_options: "PlandoOptions") -> None:
|
||||
if not self.value:
|
||||
return
|
||||
from BaseClasses import PlandoOptions
|
||||
if not (PlandoOptions.items & plando_options):
|
||||
# plando is disabled but plando options were given so overwrite the options
|
||||
self.value = []
|
||||
logging.warning(f"The plando items module is turned off, "
|
||||
f"so items for {player_name} will be ignored.")
|
||||
else:
|
||||
# filter down item groups
|
||||
for plando in self.value:
|
||||
# confirm a valid count
|
||||
if isinstance(plando.count, dict):
|
||||
if "min" in plando.count and "max" in plando.count:
|
||||
if plando.count["min"] > plando.count["max"]:
|
||||
raise OptionError("Plando cannot have count `min` greater than `max`.")
|
||||
items_copy = plando.items.copy()
|
||||
if isinstance(plando.items, dict):
|
||||
for item in items_copy:
|
||||
if item in world.item_name_groups:
|
||||
value = plando.items.pop(item)
|
||||
group = world.item_name_groups[item]
|
||||
filtered_items = sorted(group.difference(list(plando.items.keys())))
|
||||
if not filtered_items:
|
||||
raise OptionError(f"Plando `items` contains the group \"{item}\" "
|
||||
f"and every item in it. This is not allowed.")
|
||||
if value is True:
|
||||
for key in filtered_items:
|
||||
plando.items[key] = True
|
||||
else:
|
||||
for key in random.choices(filtered_items, k=value):
|
||||
plando.items[key] = plando.items.get(key, 0) + 1
|
||||
else:
|
||||
assert isinstance(plando.items, list) # pycharm can't figure out the hinting without the hint
|
||||
for item in items_copy:
|
||||
if item in world.item_name_groups:
|
||||
plando.items.remove(item)
|
||||
plando.items.extend(sorted(world.item_name_groups[item]))
|
||||
|
||||
@classmethod
|
||||
def get_option_name(cls, value: list[PlandoItem]) -> str:
|
||||
return ", ".join(["(%s: %s)" % (item.items, item.locations) for item in value]) #TODO: see what a better way to display would be
|
||||
|
||||
def __getitem__(self, index: typing.SupportsIndex) -> PlandoItem:
|
||||
return self.value.__getitem__(index)
|
||||
|
||||
def __iter__(self) -> typing.Iterator[PlandoItem]:
|
||||
yield from self.value
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self.value)
|
||||
|
||||
|
||||
class Removed(FreeText):
|
||||
"""This Option has been Removed."""
|
||||
rich_text_doc = True
|
||||
@@ -1490,6 +1622,7 @@ class PerGameCommonOptions(CommonOptions):
|
||||
exclude_locations: ExcludeLocations
|
||||
priority_locations: PriorityLocations
|
||||
item_links: ItemLinks
|
||||
plando_items: PlandoItems
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
5
Utils.py
5
Utils.py
@@ -139,8 +139,11 @@ def local_path(*path: str) -> str:
|
||||
local_path.cached_path = os.path.dirname(os.path.abspath(sys.argv[0]))
|
||||
else:
|
||||
import __main__
|
||||
if hasattr(__main__, "__file__") and os.path.isfile(__main__.__file__):
|
||||
if globals().get("__file__") and os.path.isfile(__file__):
|
||||
# we are running in a normal Python environment
|
||||
local_path.cached_path = os.path.dirname(os.path.abspath(__file__))
|
||||
elif hasattr(__main__, "__file__") and os.path.isfile(__main__.__file__):
|
||||
# we are running in a normal Python environment, but AP was imported weirdly
|
||||
local_path.cached_path = os.path.dirname(os.path.abspath(__main__.__file__))
|
||||
else:
|
||||
# pray
|
||||
|
||||
@@ -17,7 +17,9 @@
|
||||
This page allows you to host a game which was not generated by the website. For example, if you have
|
||||
generated a game on your own computer, you may upload the zip file created by the generator to
|
||||
host the game here. This will also provide a tracker, and the ability for your players to download
|
||||
their patch files.
|
||||
their patch files if the game is core-verified. For Custom Games, you can find the patch files in
|
||||
the output .zip file you are uploading here. You need to manually distribute those patch files to
|
||||
your players.
|
||||
</p>
|
||||
<p>In addition to the zip file created by the generator, you may upload a multidata file here as well.</p>
|
||||
<div id="host-game-form-wrapper">
|
||||
|
||||
@@ -122,3 +122,21 @@ Concrete examples of soft logic include:
|
||||
- Buying expensive shop items might logically require access to a place where you can quickly farm money, or logically require access to enough parts of the game that checking other locations should naturally generate enough money without grinding.
|
||||
|
||||
Remember that all items referenced by logic (however hard or soft) must be `progression`. Since you typically don't want to turn a ton of `filler` items into `progression` just for this, it's common to e.g. write money logic using only the rare "$100" item, so the dozens of "$1" and "$10" items in your world can remain `filler`.
|
||||
|
||||
---
|
||||
|
||||
### What if my game has "missable" or "one-time-only" locations or region connections?
|
||||
|
||||
Archipelago logic assumes that once a region or location becomes reachable, it stays reachable forever, no matter what
|
||||
the player does in-game. Slightly more formally: Receiving an AP item must never cause a region connection or location
|
||||
to "go out of logic" (become unreachable when it was previously reachable), and receiving AP items is the only kind of
|
||||
state change that AP logic acknowledges. No other actions or events can change reachability.
|
||||
|
||||
So when the game itself does not follow this assumption, the options are:
|
||||
- Modify the game to make that location/connection repeatable
|
||||
- If there are both missable and repeatable ways to check the location/traverse the connection, then write logic for
|
||||
only the repeatable ways
|
||||
- Don't generate the missable location/connection at all
|
||||
- For connections, any logical regions will still need to be reachable through other, *repeatable* connections
|
||||
- For locations, this may require game changes to remove the vanilla item if it affects logic
|
||||
- Decide that resetting the save file is part of the game's logic, and warn players about that
|
||||
|
||||
@@ -11,8 +11,13 @@ found in the [general test directory](/test/general).
|
||||
## Defining World Tests
|
||||
|
||||
In order to run tests from your world, you will need to create a `test` package within your world package. This can be
|
||||
done by creating a `test` directory with a file named `__init__.py` inside it inside your world. By convention, a base
|
||||
for your world tests can be created in this file that you can then import into other modules.
|
||||
done by creating a `test` directory inside your world with an (empty) `__init__.py` inside it. By convention, a base
|
||||
for your world tests can be created in `bases.py` or any file that does not start with `test`, that you can then import
|
||||
into other modules. All tests should be defined in files named `test_*.py` (all lower case) and be member functions
|
||||
(named `test_*`) of classes (named `Test*` or `*Test`) that inherit from `unittest.TestCase` or a test base.
|
||||
|
||||
Defining anything inside `test/__init__.py` is deprecated. Defining TestBase there was previously the norm; however,
|
||||
it complicates test discovery because some worlds also put actual tests into `__init__.py`.
|
||||
|
||||
### WorldTestBase
|
||||
|
||||
@@ -21,7 +26,7 @@ interactions in the world interact as expected, you will want to use the [WorldT
|
||||
comes with the basics for test setup as well as a few preloaded tests that most worlds might want to check on varying
|
||||
options combinations.
|
||||
|
||||
Example `/worlds/<my_game>/test/__init__.py`:
|
||||
Example `/worlds/<my_game>/test/bases.py`:
|
||||
|
||||
```python
|
||||
from test.bases import WorldTestBase
|
||||
@@ -49,7 +54,7 @@ with `test_`.
|
||||
Example `/worlds/<my_game>/test/test_chest_access.py`:
|
||||
|
||||
```python
|
||||
from . import MyGameTestBase
|
||||
from .bases import MyGameTestBase
|
||||
|
||||
|
||||
class TestChestAccess(MyGameTestBase):
|
||||
@@ -119,8 +124,12 @@ variable to keep all the benefits of the test framework while not running the ma
|
||||
#### Using Pycharm
|
||||
|
||||
In PyCharm, running all tests can be done by right-clicking the root test directory and selecting Run 'Archipelago Unittests'.
|
||||
Unless you configured PyCharm to use pytest as a test runner, you may get import failures. To solve this, edit the run configuration,
|
||||
and set the working directory to the Archipelago directory which contains all the project files.
|
||||
If you have never previously run ModuleUpdate.py, then you will need to do this once before the tests will run.
|
||||
You can run ModuleUpdate.py by right-clicking ModuleUpdate.py and selecting `Run 'ModuleUpdate'`.
|
||||
After running ModuleUpdate.py you may still get a `ModuleNotFoundError: No module named 'flask'` for the webhost tests.
|
||||
If this happens, run WebHost.py by right-clicking it and selecting `Run 'WebHost'`. Make sure to press enter when prompted.
|
||||
Unless you configured PyCharm to use pytest as a test runner, you may get import failures. To solve this,
|
||||
edit the run configuration, and set the working directory to the Archipelago directory which contains all the project files.
|
||||
|
||||
If you only want to run your world's defined tests, repeat the steps for the test directory within your world.
|
||||
Your working directory should be the directory of your world in the worlds directory and the script should be the
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
[pytest]
|
||||
python_files = test_*.py Test*.py # TODO: remove Test* once all worlds have been ported
|
||||
python_files = test_*.py Test*.py __init__.py # TODO: remove Test* once all worlds have been ported
|
||||
python_classes = Test
|
||||
python_functions = test
|
||||
testpaths =
|
||||
|
||||
@@ -11,6 +11,7 @@ certifi>=2025.4.26
|
||||
cython>=3.0.12
|
||||
cymem>=2.0.11
|
||||
orjson>=3.10.15
|
||||
discord-webhook>=1.3.0
|
||||
typing_extensions>=4.12.2
|
||||
pyshortcuts>=1.9.1
|
||||
kivymd @ git+https://github.com/kivymd/KivyMD@5ff9d0d
|
||||
|
||||
59
settings.py
59
settings.py
@@ -10,9 +10,10 @@ import sys
|
||||
import types
|
||||
import typing
|
||||
import warnings
|
||||
from collections.abc import Iterator, Sequence
|
||||
from enum import IntEnum
|
||||
from threading import Lock
|
||||
from typing import cast, Any, BinaryIO, ClassVar, Dict, Iterator, List, Optional, TextIO, Tuple, Union, TypeVar
|
||||
from typing import cast, Any, BinaryIO, ClassVar, TextIO, TypeVar, Union
|
||||
|
||||
__all__ = [
|
||||
"get_settings", "fmt_doc", "no_gui",
|
||||
@@ -23,7 +24,7 @@ __all__ = [
|
||||
|
||||
no_gui = False
|
||||
skip_autosave = False
|
||||
_world_settings_name_cache: Dict[str, str] = {} # TODO: cache on disk and update when worlds change
|
||||
_world_settings_name_cache: dict[str, str] = {} # TODO: cache on disk and update when worlds change
|
||||
_world_settings_name_cache_updated = False
|
||||
_lock = Lock()
|
||||
|
||||
@@ -53,7 +54,7 @@ def fmt_doc(cls: type, level: int) -> str:
|
||||
|
||||
|
||||
class Group:
|
||||
_type_cache: ClassVar[Optional[Dict[str, Any]]] = None
|
||||
_type_cache: ClassVar[dict[str, Any] | None] = None
|
||||
_dumping: bool = False
|
||||
_has_attr: bool = False
|
||||
_changed: bool = False
|
||||
@@ -106,7 +107,7 @@ class Group:
|
||||
self.__dict__.values()))
|
||||
|
||||
@classmethod
|
||||
def get_type_hints(cls) -> Dict[str, Any]:
|
||||
def get_type_hints(cls) -> dict[str, Any]:
|
||||
"""Returns resolved type hints for the class"""
|
||||
if cls._type_cache is None:
|
||||
if not cls.__annotations__ or not isinstance(next(iter(cls.__annotations__.values())), str):
|
||||
@@ -124,10 +125,10 @@ class Group:
|
||||
return self[key]
|
||||
return default
|
||||
|
||||
def items(self) -> List[Tuple[str, Any]]:
|
||||
def items(self) -> list[tuple[str, Any]]:
|
||||
return [(key, getattr(self, key)) for key in self]
|
||||
|
||||
def update(self, dct: Dict[str, Any]) -> None:
|
||||
def update(self, dct: dict[str, Any]) -> None:
|
||||
assert isinstance(dct, dict), f"{self.__class__.__name__}.update called with " \
|
||||
f"{dct.__class__.__name__} instead of dict."
|
||||
|
||||
@@ -196,7 +197,7 @@ class Group:
|
||||
warnings.warn(f"{self.__class__.__name__}.{k} "
|
||||
f"assigned from incompatible type {type(v).__name__}")
|
||||
|
||||
def as_dict(self, *args: str, downcast: bool = True) -> Dict[str, Any]:
|
||||
def as_dict(self, *args: str, downcast: bool = True) -> dict[str, Any]:
|
||||
return {
|
||||
name: _to_builtin(cast(object, getattr(self, name))) if downcast else getattr(self, name)
|
||||
for name in self if not args or name in args
|
||||
@@ -211,7 +212,7 @@ class Group:
|
||||
f.write(f"{indent}{yaml_line}")
|
||||
|
||||
@classmethod
|
||||
def _dump_item(cls, name: Optional[str], attr: object, f: TextIO, level: int) -> None:
|
||||
def _dump_item(cls, name: str | None, attr: object, f: TextIO, level: int) -> None:
|
||||
"""Write a group, dict or sequence item to f, where attr can be a scalar or a collection"""
|
||||
|
||||
# lazy construction of yaml Dumper to avoid loading Utils early
|
||||
@@ -223,7 +224,7 @@ class Group:
|
||||
def represent_mapping(self, tag: str, mapping: Any, flow_style: Any = None) -> MappingNode:
|
||||
from yaml import ScalarNode
|
||||
res: MappingNode = super().represent_mapping(tag, mapping, flow_style)
|
||||
pairs = cast(List[Tuple[ScalarNode, Any]], res.value)
|
||||
pairs = cast(list[tuple[ScalarNode, Any]], res.value)
|
||||
for k, v in pairs:
|
||||
k.style = None # remove quotes from keys
|
||||
return res
|
||||
@@ -329,9 +330,9 @@ class Path(str):
|
||||
"""Marks the file as required and opens a file browser when missing"""
|
||||
is_exe: bool = False
|
||||
"""Special cross-platform handling for executables"""
|
||||
description: Optional[str] = None
|
||||
description: str | None = None
|
||||
"""Title to display when browsing for the file"""
|
||||
copy_to: Optional[str] = None
|
||||
copy_to: str | None = None
|
||||
"""If not None, copy to AP folder instead of linking it"""
|
||||
|
||||
@classmethod
|
||||
@@ -339,7 +340,7 @@ class Path(str):
|
||||
"""Overload and raise to validate input files from browse"""
|
||||
pass
|
||||
|
||||
def browse(self: T, **kwargs: Any) -> Optional[T]:
|
||||
def browse(self: T, **kwargs: Any) -> T | None:
|
||||
"""Opens a file browser to search for the file"""
|
||||
raise NotImplementedError(f"Please use a subclass of Path for {self.__class__.__name__}")
|
||||
|
||||
@@ -369,12 +370,12 @@ class _LocalPath(str):
|
||||
class FilePath(Path):
|
||||
# path to a file
|
||||
|
||||
md5s: ClassVar[List[Union[str, bytes]]] = []
|
||||
md5s: ClassVar[list[str | bytes]] = []
|
||||
"""MD5 hashes for default validator."""
|
||||
|
||||
def browse(self: T,
|
||||
filetypes: Optional[typing.Sequence[typing.Tuple[str, typing.Sequence[str]]]] = None, **kwargs: Any)\
|
||||
-> Optional[T]:
|
||||
filetypes: Sequence[tuple[str, Sequence[str]]] | None = None, **kwargs: Any)\
|
||||
-> T | None:
|
||||
from Utils import open_filename, is_windows
|
||||
if not filetypes:
|
||||
if self.is_exe:
|
||||
@@ -439,7 +440,7 @@ class FilePath(Path):
|
||||
class FolderPath(Path):
|
||||
# path to a folder
|
||||
|
||||
def browse(self: T, **kwargs: Any) -> Optional[T]:
|
||||
def browse(self: T, **kwargs: Any) -> T | None:
|
||||
from Utils import open_directory
|
||||
res = open_directory(f"Select {self.description or self.__class__.__name__}", self)
|
||||
if res:
|
||||
@@ -597,16 +598,16 @@ class ServerOptions(Group):
|
||||
OFF = 0
|
||||
ON = 1
|
||||
|
||||
host: Optional[str] = None
|
||||
host: str | None = None
|
||||
port: int = 38281
|
||||
password: Optional[str] = None
|
||||
multidata: Optional[str] = None
|
||||
savefile: Optional[str] = None
|
||||
password: str | None = None
|
||||
multidata: str | None = None
|
||||
savefile: str | None = None
|
||||
disable_save: bool = False
|
||||
loglevel: str = "info"
|
||||
logtime: bool = False
|
||||
server_password: Optional[ServerPassword] = None
|
||||
disable_item_cheat: Union[DisableItemCheat, bool] = False
|
||||
server_password: ServerPassword | None = None
|
||||
disable_item_cheat: DisableItemCheat | bool = False
|
||||
location_check_points: LocationCheckPoints = LocationCheckPoints(1)
|
||||
hint_cost: HintCost = HintCost(10)
|
||||
release_mode: ReleaseMode = ReleaseMode("auto")
|
||||
@@ -702,7 +703,7 @@ does nothing if not found
|
||||
"""
|
||||
|
||||
sni_path: SNIPath = SNIPath("SNI")
|
||||
snes_rom_start: Union[SnesRomStart, bool] = True
|
||||
snes_rom_start: SnesRomStart | bool = True
|
||||
|
||||
|
||||
class BizHawkClientOptions(Group):
|
||||
@@ -721,7 +722,7 @@ class BizHawkClientOptions(Group):
|
||||
"""
|
||||
|
||||
emuhawk_path: EmuHawkPath = EmuHawkPath(None)
|
||||
rom_start: Union[RomStart, bool] = True
|
||||
rom_start: RomStart | bool = True
|
||||
|
||||
|
||||
# Top-level group with lazy loading of worlds
|
||||
@@ -733,7 +734,7 @@ class Settings(Group):
|
||||
sni_options: SNIOptions = SNIOptions()
|
||||
bizhawkclient_options: BizHawkClientOptions = BizHawkClientOptions()
|
||||
|
||||
_filename: Optional[str] = None
|
||||
_filename: str | None = None
|
||||
|
||||
def __getattribute__(self, key: str) -> Any:
|
||||
if key.startswith("_") or key in self.__class__.__dict__:
|
||||
@@ -787,7 +788,7 @@ class Settings(Group):
|
||||
|
||||
return super().__getattribute__(key)
|
||||
|
||||
def __init__(self, location: Optional[str]): # change to PathLike[str] once we drop 3.8?
|
||||
def __init__(self, location: str | None): # change to PathLike[str] once we drop 3.8?
|
||||
super().__init__()
|
||||
if location:
|
||||
from Utils import parse_yaml
|
||||
@@ -821,7 +822,7 @@ class Settings(Group):
|
||||
import atexit
|
||||
atexit.register(autosave)
|
||||
|
||||
def save(self, location: Optional[str] = None) -> None: # as above
|
||||
def save(self, location: str | None = None) -> None: # as above
|
||||
from Utils import parse_yaml
|
||||
location = location or self._filename
|
||||
assert location, "No file specified"
|
||||
@@ -854,7 +855,7 @@ class Settings(Group):
|
||||
super().dump(f, level)
|
||||
|
||||
@property
|
||||
def filename(self) -> Optional[str]:
|
||||
def filename(self) -> str | None:
|
||||
return self._filename
|
||||
|
||||
|
||||
@@ -867,7 +868,7 @@ def get_settings() -> Settings:
|
||||
if not res:
|
||||
from Utils import user_path, local_path
|
||||
filenames = ("options.yaml", "host.yaml")
|
||||
locations: List[str] = []
|
||||
locations: list[str] = []
|
||||
if os.path.join(os.getcwd()) != local_path():
|
||||
locations += filenames # use files from cwd only if it's not the local_path
|
||||
locations += [user_path(filename) for filename in filenames]
|
||||
|
||||
46
setup.py
46
setup.py
@@ -1,22 +1,20 @@
|
||||
import base64
|
||||
import datetime
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import sysconfig
|
||||
import threading
|
||||
import urllib.request
|
||||
import warnings
|
||||
import zipfile
|
||||
import urllib.request
|
||||
import io
|
||||
import json
|
||||
import threading
|
||||
import subprocess
|
||||
|
||||
from collections.abc import Iterable, Sequence
|
||||
from hashlib import sha3_512
|
||||
from pathlib import Path
|
||||
from typing import Dict, Iterable, List, Optional, Sequence, Set, Tuple, Union
|
||||
|
||||
|
||||
# This is a bit jank. We need cx-Freeze to be able to run anything from this script, so install it
|
||||
requirement = 'cx-Freeze==8.0.0'
|
||||
@@ -60,7 +58,7 @@ from Cython.Build import cythonize
|
||||
|
||||
|
||||
# On Python < 3.10 LogicMixin is not currently supported.
|
||||
non_apworlds: Set[str] = {
|
||||
non_apworlds: set[str] = {
|
||||
"A Link to the Past",
|
||||
"Adventure",
|
||||
"ArchipIDLE",
|
||||
@@ -147,7 +145,7 @@ def download_SNI() -> None:
|
||||
print(f"No SNI found for system spec {platform_name} {machine_name}")
|
||||
|
||||
|
||||
signtool: Optional[str]
|
||||
signtool: str | None
|
||||
if os.path.exists("X:/pw.txt"):
|
||||
print("Using signtool")
|
||||
with open("X:/pw.txt", encoding="utf-8-sig") as f:
|
||||
@@ -205,7 +203,7 @@ def remove_sprites_from_folder(folder: Path) -> None:
|
||||
os.remove(folder / file)
|
||||
|
||||
|
||||
def _threaded_hash(filepath: Union[str, Path]) -> str:
|
||||
def _threaded_hash(filepath: str | Path) -> str:
|
||||
hasher = sha3_512()
|
||||
hasher.update(open(filepath, "rb").read())
|
||||
return base64.b85encode(hasher.digest()).decode()
|
||||
@@ -255,7 +253,7 @@ class BuildExeCommand(cx_Freeze.command.build_exe.build_exe):
|
||||
self.libfolder = Path(self.buildfolder, "lib")
|
||||
self.library = Path(self.libfolder, "library.zip")
|
||||
|
||||
def installfile(self, path: Path, subpath: Optional[Union[str, Path]] = None, keep_content: bool = False) -> None:
|
||||
def installfile(self, path: Path, subpath: str | Path | None = None, keep_content: bool = False) -> None:
|
||||
folder = self.buildfolder
|
||||
if subpath:
|
||||
folder /= subpath
|
||||
@@ -374,7 +372,7 @@ class BuildExeCommand(cx_Freeze.command.build_exe.build_exe):
|
||||
from worlds.AutoWorld import AutoWorldRegister
|
||||
assert not non_apworlds - set(AutoWorldRegister.world_types), \
|
||||
f"Unknown world {non_apworlds - set(AutoWorldRegister.world_types)} designated for .apworld"
|
||||
folders_to_remove: List[str] = []
|
||||
folders_to_remove: list[str] = []
|
||||
disabled_worlds_folder = "worlds_disabled"
|
||||
for entry in os.listdir(disabled_worlds_folder):
|
||||
if os.path.isdir(os.path.join(disabled_worlds_folder, entry)):
|
||||
@@ -446,12 +444,12 @@ class AppImageCommand(setuptools.Command):
|
||||
("app-exec=", None, "The application to run inside the image."),
|
||||
("yes", "y", 'Answer "yes" to all questions.'),
|
||||
]
|
||||
build_folder: Optional[Path]
|
||||
dist_file: Optional[Path]
|
||||
app_dir: Optional[Path]
|
||||
build_folder: Path | None
|
||||
dist_file: Path | None
|
||||
app_dir: Path | None
|
||||
app_name: str
|
||||
app_exec: Optional[Path]
|
||||
app_icon: Optional[Path] # source file
|
||||
app_exec: Path | None
|
||||
app_icon: Path | None # source file
|
||||
app_id: str # lower case name, used for icon and .desktop
|
||||
yes: bool
|
||||
|
||||
@@ -493,7 +491,7 @@ $APPDIR/$exe "$@"
|
||||
""")
|
||||
launcher_filename.chmod(0o755)
|
||||
|
||||
def install_icon(self, src: Path, name: Optional[str] = None, symlink: Optional[Path] = None) -> None:
|
||||
def install_icon(self, src: Path, name: str | None = None, symlink: Path | None = None) -> None:
|
||||
assert self.app_dir, "Invalid app_dir"
|
||||
try:
|
||||
from PIL import Image
|
||||
@@ -556,7 +554,7 @@ $APPDIR/$exe "$@"
|
||||
subprocess.call(f'ARCH={build_arch} ./appimagetool -n "{self.app_dir}" "{self.dist_file}"', shell=True)
|
||||
|
||||
|
||||
def find_libs(*args: str) -> Sequence[Tuple[str, str]]:
|
||||
def find_libs(*args: str) -> Sequence[tuple[str, str]]:
|
||||
"""Try to find system libraries to be included."""
|
||||
if not args:
|
||||
return []
|
||||
@@ -564,7 +562,7 @@ def find_libs(*args: str) -> Sequence[Tuple[str, str]]:
|
||||
arch = build_arch.replace('_', '-')
|
||||
libc = 'libc6' # we currently don't support musl
|
||||
|
||||
def parse(line: str) -> Tuple[Tuple[str, str, str], str]:
|
||||
def parse(line: str) -> tuple[tuple[str, str, str], str]:
|
||||
lib, path = line.strip().split(' => ')
|
||||
lib, typ = lib.split(' ', 1)
|
||||
for test_arch in ('x86-64', 'i386', 'aarch64'):
|
||||
@@ -589,8 +587,8 @@ def find_libs(*args: str) -> Sequence[Tuple[str, str]]:
|
||||
k: v for k, v in (parse(line) for line in data if "=>" in line)
|
||||
}
|
||||
|
||||
def find_lib(lib: str, arch: str, libc: str) -> Optional[str]:
|
||||
cache: Dict[Tuple[str, str, str], str] = getattr(find_libs, "cache")
|
||||
def find_lib(lib: str, arch: str, libc: str) -> str | None:
|
||||
cache: dict[tuple[str, str, str], str] = getattr(find_libs, "cache")
|
||||
for k, v in cache.items():
|
||||
if k == (lib, arch, libc):
|
||||
return v
|
||||
@@ -599,7 +597,7 @@ def find_libs(*args: str) -> Sequence[Tuple[str, str]]:
|
||||
return v
|
||||
return None
|
||||
|
||||
res: List[Tuple[str, str]] = []
|
||||
res: list[tuple[str, str]] = []
|
||||
for arg in args:
|
||||
# try exact match, empty libc, empty arch, empty arch and libc
|
||||
file = find_lib(arg, arch, libc)
|
||||
|
||||
@@ -53,6 +53,22 @@ class TestImplemented(unittest.TestCase):
|
||||
if failed_world_loads:
|
||||
self.fail(f"The following worlds failed to load: {failed_world_loads}")
|
||||
|
||||
def test_prefill_items(self):
|
||||
"""Test that every world can reach every location from allstate before pre_fill."""
|
||||
for gamename, world_type in AutoWorldRegister.world_types.items():
|
||||
if gamename not in ("Archipelago", "Sudoku", "Final Fantasy", "Test Game"):
|
||||
with self.subTest(gamename):
|
||||
multiworld = setup_solo_multiworld(world_type, ("generate_early", "create_regions", "create_items",
|
||||
"set_rules", "connect_entrances", "generate_basic"))
|
||||
allstate = multiworld.get_all_state(False)
|
||||
locations = multiworld.get_locations()
|
||||
reachable = multiworld.get_reachable_locations(allstate)
|
||||
unreachable = [location for location in locations if location not in reachable]
|
||||
|
||||
self.assertTrue(not unreachable,
|
||||
f"Locations were not reachable with all state before prefill: "
|
||||
f"{unreachable}. Seed: {multiworld.seed}")
|
||||
|
||||
def test_explicit_indirect_conditions_spheres(self):
|
||||
"""Tests that worlds using explicit indirect conditions produce identical spheres as when using implicit
|
||||
indirect conditions"""
|
||||
|
||||
@@ -26,4 +26,4 @@ class TestBase(unittest.TestCase):
|
||||
for step in self.test_steps:
|
||||
with self.subTest("Step", step=step):
|
||||
call_all(multiworld, step)
|
||||
self.assertTrue(multiworld.get_all_state(False, True))
|
||||
self.assertTrue(multiworld.get_all_state(False, allow_partial_entrances=True))
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import re
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import Dict
|
||||
|
||||
|
||||
__all__ = ["copy", "delete"]
|
||||
|
||||
|
||||
_new_worlds: Dict[str, str] = {}
|
||||
_new_worlds: dict[str, str] = {}
|
||||
|
||||
|
||||
def copy(src: str, dst: str) -> None:
|
||||
|
||||
@@ -238,10 +238,10 @@ async def proxy_loop(ctx: AHITContext):
|
||||
logger.info("Aborting AHIT Proxy Client due to errors")
|
||||
|
||||
|
||||
def launch():
|
||||
def launch(*launch_args: str):
|
||||
async def main():
|
||||
parser = get_base_parser()
|
||||
args = parser.parse_args()
|
||||
args = parser.parse_args(launch_args)
|
||||
|
||||
ctx = AHITContext(args.connect, args.password)
|
||||
logger.info("Starting A Hat in Time proxy server")
|
||||
|
||||
@@ -16,9 +16,9 @@ from worlds.LauncherComponents import Component, components, icon_paths, launch
|
||||
from Utils import local_path
|
||||
|
||||
|
||||
def launch_client():
|
||||
def launch_client(*args: str):
|
||||
from .Client import launch
|
||||
launch_component(launch, name="AHITClient")
|
||||
launch_component(launch, name="AHITClient", args=args)
|
||||
|
||||
|
||||
components.append(Component("A Hat in Time Client", "AHITClient", func=launch_client,
|
||||
|
||||
@@ -54,16 +54,13 @@ def parse_arguments(argv, no_defaults=False):
|
||||
ret = parser.parse_args(argv)
|
||||
|
||||
# cannot be set through CLI currently
|
||||
ret.plando_items = []
|
||||
ret.plando_texts = {}
|
||||
ret.plando_connections = []
|
||||
|
||||
if multiargs.multi:
|
||||
defaults = copy.deepcopy(ret)
|
||||
for player in range(1, multiargs.multi + 1):
|
||||
playerargs = parse_arguments(shlex.split(getattr(ret, f"p{player}")), True)
|
||||
|
||||
for name in ["plando_items", "plando_texts", "plando_connections", "game", "sprite", "sprite_pool"]:
|
||||
for name in ["game", "sprite", "sprite_pool"]:
|
||||
value = getattr(defaults, name) if getattr(playerargs, name) is None else getattr(playerargs, name)
|
||||
if player == 1:
|
||||
setattr(ret, name, {1: value})
|
||||
|
||||
@@ -505,20 +505,20 @@ class ALTTPWorld(World):
|
||||
def pre_fill(self):
|
||||
from Fill import fill_restrictive, FillError
|
||||
attempts = 5
|
||||
world = self.multiworld
|
||||
player = self.player
|
||||
all_state = world.get_all_state(use_cache=True)
|
||||
all_state = self.multiworld.get_all_state(use_cache=False)
|
||||
crystals = [self.create_item(name) for name in ['Red Pendant', 'Blue Pendant', 'Green Pendant', 'Crystal 1', 'Crystal 2', 'Crystal 3', 'Crystal 4', 'Crystal 7', 'Crystal 5', 'Crystal 6']]
|
||||
crystal_locations = [world.get_location('Turtle Rock - Prize', player),
|
||||
world.get_location('Eastern Palace - Prize', player),
|
||||
world.get_location('Desert Palace - Prize', player),
|
||||
world.get_location('Tower of Hera - Prize', player),
|
||||
world.get_location('Palace of Darkness - Prize', player),
|
||||
world.get_location('Thieves\' Town - Prize', player),
|
||||
world.get_location('Skull Woods - Prize', player),
|
||||
world.get_location('Swamp Palace - Prize', player),
|
||||
world.get_location('Ice Palace - Prize', player),
|
||||
world.get_location('Misery Mire - Prize', player)]
|
||||
for crystal in crystals:
|
||||
all_state.remove(crystal)
|
||||
crystal_locations = [self.get_location('Turtle Rock - Prize'),
|
||||
self.get_location('Eastern Palace - Prize'),
|
||||
self.get_location('Desert Palace - Prize'),
|
||||
self.get_location('Tower of Hera - Prize'),
|
||||
self.get_location('Palace of Darkness - Prize'),
|
||||
self.get_location('Thieves\' Town - Prize'),
|
||||
self.get_location('Skull Woods - Prize'),
|
||||
self.get_location('Swamp Palace - Prize'),
|
||||
self.get_location('Ice Palace - Prize'),
|
||||
self.get_location('Misery Mire - Prize')]
|
||||
placed_prizes = {loc.item.name for loc in crystal_locations if loc.item}
|
||||
unplaced_prizes = [crystal for crystal in crystals if crystal.name not in placed_prizes]
|
||||
empty_crystal_locations = [loc for loc in crystal_locations if not loc.item]
|
||||
@@ -526,8 +526,8 @@ class ALTTPWorld(World):
|
||||
try:
|
||||
prizepool = unplaced_prizes.copy()
|
||||
prize_locs = empty_crystal_locations.copy()
|
||||
world.random.shuffle(prize_locs)
|
||||
fill_restrictive(world, all_state, prize_locs, prizepool, True, lock=True,
|
||||
self.multiworld.random.shuffle(prize_locs)
|
||||
fill_restrictive(self.multiworld, all_state, prize_locs, prizepool, True, lock=True,
|
||||
name="LttP Dungeon Prizes")
|
||||
except FillError as e:
|
||||
lttp_logger.exception("Failed to place dungeon prizes (%s). Will retry %s more times", e,
|
||||
@@ -541,7 +541,7 @@ class ALTTPWorld(World):
|
||||
if self.options.mode == 'standard' and self.options.small_key_shuffle \
|
||||
and self.options.small_key_shuffle != small_key_shuffle.option_universal and \
|
||||
self.options.small_key_shuffle != small_key_shuffle.option_own_dungeons:
|
||||
world.local_early_items[player]["Small Key (Hyrule Castle)"] = 1
|
||||
self.multiworld.local_early_items[self.player]["Small Key (Hyrule Castle)"] = 1
|
||||
|
||||
@classmethod
|
||||
def stage_pre_fill(cls, world):
|
||||
@@ -811,12 +811,15 @@ class ALTTPWorld(World):
|
||||
return GetBeemizerItem(self.multiworld, self.player, item)
|
||||
|
||||
def get_pre_fill_items(self):
|
||||
res = []
|
||||
res = [self.create_item(name) for name in ('Red Pendant', 'Blue Pendant', 'Green Pendant', 'Crystal 1',
|
||||
'Crystal 2', 'Crystal 3', 'Crystal 4', 'Crystal 7', 'Crystal 5',
|
||||
'Crystal 6')]
|
||||
if self.dungeon_local_item_names:
|
||||
for dungeon in self.dungeons.values():
|
||||
for item in dungeon.all_items:
|
||||
if item.name in self.dungeon_local_item_names:
|
||||
res.append(item)
|
||||
|
||||
return res
|
||||
|
||||
def fill_slot_data(self):
|
||||
|
||||
@@ -207,7 +207,6 @@ class BlasphemousWorld(World):
|
||||
if not self.options.skill_randomizer:
|
||||
self.place_items_from_dict(skill_dict)
|
||||
|
||||
|
||||
def place_items_from_set(self, location_set: Set[str], name: str):
|
||||
for loc in location_set:
|
||||
self.get_location(loc).place_locked_item(self.create_item(name))
|
||||
|
||||
@@ -511,7 +511,7 @@ _vanilla_items = [
|
||||
DS3ItemData("Elkhorn Round Shield", 0x0133C510, DS3ItemCategory.SHIELD_INFUSIBLE),
|
||||
DS3ItemData("Warrior's Round Shield", 0x0133EC20, DS3ItemCategory.SHIELD_INFUSIBLE),
|
||||
DS3ItemData("Caduceus Round Shield", 0x01341330, DS3ItemCategory.SHIELD_INFUSIBLE),
|
||||
DS3ItemData("Red and White Shield", 0x01343A40, DS3ItemCategory.SHIELD_INFUSIBLE),
|
||||
DS3ItemData("Red and White Round Shield", 0x01343A40, DS3ItemCategory.SHIELD_INFUSIBLE),
|
||||
DS3ItemData("Blessed Red and White Shield+1", 0x01343FB9, DS3ItemCategory.SHIELD),
|
||||
DS3ItemData("Plank Shield", 0x01346150, DS3ItemCategory.SHIELD_INFUSIBLE),
|
||||
DS3ItemData("Leather Shield", 0x01348860, DS3ItemCategory.SHIELD_INFUSIBLE),
|
||||
|
||||
@@ -706,7 +706,7 @@ location_tables: Dict[str, List[DS3LocationData]] = {
|
||||
DS3LocationData("US: Whip - back alley, behind wooden wall", "Whip", hidden=True),
|
||||
DS3LocationData("US: Great Scythe - building by white tree, balcony", "Great Scythe"),
|
||||
DS3LocationData("US: Homeward Bone - foot, drop overlook", "Homeward Bone",
|
||||
static='02,0:53100540::'),
|
||||
static='02,0:53100950::'),
|
||||
DS3LocationData("US: Large Soul of a Deserted Corpse - around corner by Cliff Underside",
|
||||
"Large Soul of a Deserted Corpse", hidden=True), # Hidden corner
|
||||
DS3LocationData("US: Ember - behind burning tree", "Ember"),
|
||||
@@ -732,8 +732,9 @@ location_tables: Dict[str, List[DS3LocationData]] = {
|
||||
missable=True), # requires projectile
|
||||
DS3LocationData("US: Flame Stoneplate Ring - hanging corpse by Mound-Maker transport",
|
||||
"Flame Stoneplate Ring"),
|
||||
DS3LocationData("US: Red and White Shield - chasm, hanging corpse", "Red and White Shield",
|
||||
static="02,0:53100740::", missable=True), # requires projectile
|
||||
DS3LocationData("US: Red and White Round Shield - chasm, hanging corpse",
|
||||
"Red and White Round Shield", static="02,0:53100740::",
|
||||
missable=True), # requires projectile
|
||||
DS3LocationData("US: Small Leather Shield - first building, hanging corpse by entrance",
|
||||
"Small Leather Shield"),
|
||||
DS3LocationData("US: Pale Tongue - tower village, hanging corpse", "Pale Tongue"),
|
||||
|
||||
@@ -2239,7 +2239,7 @@ static _Dark Souls III_ randomizer].
|
||||
<tr><td>US: Pyromancy Flame - Cornyx</td><td>Given by Cornyx in Firelink Shrine or dropped.</td></tr>
|
||||
<tr><td>US: Red Bug Pellet - tower village building, basement</td><td>On the floor of the building after the Fire Demon encounter</td></tr>
|
||||
<tr><td>US: Red Hilted Halberd - chasm crypt</td><td>In the skeleton area accessible from Grave Key or dropping down from near Eygon</td></tr>
|
||||
<tr><td>US: Red and White Shield - chasm, hanging corpse</td><td>On a hanging corpse in the ravine accessible with the Grave Key or dropping down near Eygon, to the entrance of Irina's prison. Must be shot down with an arrow or projective.</td></tr>
|
||||
<tr><td>US: Red and White Round Shield - chasm, hanging corpse</td><td>On a hanging corpse in the ravine accessible with the Grave Key or dropping down near Eygon, to the entrance of Irina's prison. Must be shot down with an arrow or projective.</td></tr>
|
||||
<tr><td>US: Reinforced Club - by white tree</td><td>Near the Birch Tree where giant shoots arrows</td></tr>
|
||||
<tr><td>US: Repair Powder - first building, balcony</td><td>On the balcony of the first Undead Settlement building</td></tr>
|
||||
<tr><td>US: Rusted Coin - awning above Dilapidated Bridge</td><td>On a wooden ledge near the Dilapidated Bridge bonfire. Must be jumped to from near Cathedral Evangelist enemy</td></tr>
|
||||
|
||||
@@ -127,6 +127,10 @@ class Hylics2World(World):
|
||||
tv = tvs.pop()
|
||||
self.get_location(tv).place_locked_item(self.create_item(gesture))
|
||||
|
||||
def get_pre_fill_items(self) -> List["Item"]:
|
||||
if self.options.gesture_shuffle:
|
||||
return [self.create_item(gesture["name"]) for gesture in Items.gesture_item_table.values()]
|
||||
return []
|
||||
|
||||
def fill_slot_data(self) -> Dict[str, Any]:
|
||||
slot_data: Dict[str, Any] = {
|
||||
|
||||
@@ -436,6 +436,10 @@ class KH2World(World):
|
||||
for location in keyblade_locations:
|
||||
location.locked = True
|
||||
|
||||
def get_pre_fill_items(self) -> List["Item"]:
|
||||
return [self.create_item(item) for item in [*DonaldAbility_Table.keys(), *GoofyAbility_Table.keys(),
|
||||
*SupportAbility_Table.keys()]]
|
||||
|
||||
def starting_invo_verify(self):
|
||||
"""
|
||||
Making sure the player doesn't put too many abilities in their starting inventory.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from typing import Optional
|
||||
|
||||
from Fill import distribute_planned
|
||||
from Fill import parse_planned_blocks, distribute_planned_blocks, resolve_early_locations_for_planned
|
||||
from Options import PlandoItems
|
||||
from test.general import setup_solo_multiworld
|
||||
from worlds.AutoWorld import call_all
|
||||
from . import LADXTestBase
|
||||
@@ -19,14 +20,17 @@ class PlandoTest(LADXTestBase):
|
||||
],
|
||||
}],
|
||||
}
|
||||
|
||||
|
||||
def world_setup(self, seed: Optional[int] = None) -> None:
|
||||
self.multiworld = setup_solo_multiworld(
|
||||
LinksAwakeningWorld,
|
||||
("generate_early", "create_regions", "create_items", "set_rules", "generate_basic")
|
||||
)
|
||||
self.multiworld.plando_items[1] = self.options["plando_items"]
|
||||
distribute_planned(self.multiworld)
|
||||
self.multiworld.worlds[1].options.plando_items = PlandoItems.from_any(self.options["plando_items"])
|
||||
self.multiworld.plando_item_blocks = parse_planned_blocks(self.multiworld)
|
||||
resolve_early_locations_for_planned(self.multiworld)
|
||||
distribute_planned_blocks(self.multiworld, [x for player in self.multiworld.plando_item_blocks
|
||||
for x in self.multiworld.plando_item_blocks[player]])
|
||||
call_all(self.multiworld, "pre_fill")
|
||||
|
||||
def test_planned(self):
|
||||
|
||||
@@ -16,8 +16,8 @@ from .portals import PORTALS, add_closed_portal_reqs, disconnect_portals, shuffl
|
||||
from .regions import LEVELS, MEGA_SHARDS, LOCATIONS, REGION_CONNECTIONS
|
||||
from .rules import MessengerHardRules, MessengerOOBRules, MessengerRules
|
||||
from .shop import FIGURINES, PROG_SHOP_ITEMS, SHOP_ITEMS, USEFUL_SHOP_ITEMS, shuffle_shop_prices
|
||||
from .subclasses import MessengerEntrance, MessengerItem, MessengerRegion, MessengerShopLocation
|
||||
from .transitions import shuffle_transitions
|
||||
from .subclasses import MessengerItem, MessengerRegion, MessengerShopLocation
|
||||
from .transitions import disconnect_entrances, shuffle_transitions
|
||||
|
||||
components.append(
|
||||
Component("The Messenger", component_type=Type.CLIENT, func=launch_game, game_name="The Messenger", supports_uri=True)
|
||||
@@ -266,6 +266,8 @@ class MessengerWorld(World):
|
||||
# MessengerOOBRules(self).set_messenger_rules()
|
||||
|
||||
def connect_entrances(self) -> None:
|
||||
if self.options.shuffle_transitions:
|
||||
disconnect_entrances(self)
|
||||
add_closed_portal_reqs(self)
|
||||
# i need portal shuffle to happen after rules exist so i can validate it
|
||||
attempts = 5
|
||||
|
||||
@@ -292,12 +292,10 @@ def disconnect_portals(world: "MessengerWorld") -> None:
|
||||
|
||||
|
||||
def validate_portals(world: "MessengerWorld") -> bool:
|
||||
if world.options.shuffle_transitions:
|
||||
return True
|
||||
new_state = CollectionState(world.multiworld)
|
||||
new_state = CollectionState(world.multiworld, True)
|
||||
new_state.update_reachable_regions(world.player)
|
||||
reachable_locs = 0
|
||||
for loc in world.multiworld.get_locations(world.player):
|
||||
for loc in world.get_locations():
|
||||
reachable_locs += loc.can_reach(new_state)
|
||||
if reachable_locs > 5:
|
||||
return True
|
||||
|
||||
@@ -10,25 +10,8 @@ if TYPE_CHECKING:
|
||||
from . import MessengerWorld
|
||||
|
||||
|
||||
class MessengerEntrance(Entrance):
|
||||
world: "MessengerWorld | None" = None
|
||||
|
||||
def can_connect_to(self, other: Entrance, dead_end: bool, state: "ERPlacementState") -> bool:
|
||||
can_connect = super().can_connect_to(other, dead_end, state)
|
||||
world: MessengerWorld = getattr(self, "world", None)
|
||||
if not world or world.reachable_locs or not can_connect:
|
||||
return can_connect
|
||||
empty_state = CollectionState(world.multiworld, True)
|
||||
self.connected_region = other.connected_region
|
||||
empty_state.update_reachable_regions(world.player)
|
||||
world.reachable_locs = any(loc.can_reach(empty_state) and not loc.is_event for loc in world.get_locations())
|
||||
self.connected_region = None
|
||||
return world.reachable_locs and (not state.coupled or self.name != other.name)
|
||||
|
||||
|
||||
class MessengerRegion(Region):
|
||||
parent: str | None
|
||||
entrance_type = MessengerEntrance
|
||||
|
||||
def __init__(self, name: str, world: "MessengerWorld", parent: str | None = None) -> None:
|
||||
super().__init__(name, world.player, world.multiworld)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from BaseClasses import Region
|
||||
from BaseClasses import Entrance, Region
|
||||
from entrance_rando import EntranceType, randomize_entrances
|
||||
from .connections import RANDOMIZED_CONNECTIONS, TRANSITIONS
|
||||
from .options import ShuffleTransitions, TransitionPlando
|
||||
@@ -9,6 +9,33 @@ if TYPE_CHECKING:
|
||||
from . import MessengerWorld
|
||||
|
||||
|
||||
def disconnect_entrances(world: "MessengerWorld") -> None:
|
||||
def disconnect_entrance() -> None:
|
||||
child = entrance.connected_region.name
|
||||
child_region = entrance.connected_region
|
||||
child_region.entrances.remove(entrance)
|
||||
entrance.connected_region = None
|
||||
|
||||
er_type = EntranceType.ONE_WAY if child == "Glacial Peak - Left" else \
|
||||
EntranceType.TWO_WAY if child in RANDOMIZED_CONNECTIONS else EntranceType.ONE_WAY
|
||||
if er_type == EntranceType.TWO_WAY:
|
||||
mock_entrance = entrance.parent_region.create_er_target(entrance.name)
|
||||
else:
|
||||
mock_entrance = child_region.create_er_target(child)
|
||||
|
||||
entrance.randomization_type = er_type
|
||||
mock_entrance.randomization_type = er_type
|
||||
|
||||
|
||||
for parent, child in RANDOMIZED_CONNECTIONS.items():
|
||||
if child == "Corrupted Future":
|
||||
entrance = world.get_entrance("Artificer's Portal")
|
||||
elif child == "Tower of Time - Left":
|
||||
entrance = world.get_entrance("Artificer's Challenge")
|
||||
else:
|
||||
entrance = world.get_entrance(f"{parent} -> {child}")
|
||||
disconnect_entrance()
|
||||
|
||||
def connect_plando(world: "MessengerWorld", plando_connections: TransitionPlando) -> None:
|
||||
def remove_dangling_exit(region: Region) -> None:
|
||||
# find the disconnected exit and remove references to it
|
||||
@@ -59,32 +86,6 @@ def connect_plando(world: "MessengerWorld", plando_connections: TransitionPlando
|
||||
def shuffle_transitions(world: "MessengerWorld") -> None:
|
||||
coupled = world.options.shuffle_transitions == ShuffleTransitions.option_coupled
|
||||
|
||||
def disconnect_entrance() -> None:
|
||||
child_region.entrances.remove(entrance)
|
||||
entrance.connected_region = None
|
||||
|
||||
er_type = EntranceType.ONE_WAY if child == "Glacial Peak - Left" else \
|
||||
EntranceType.TWO_WAY if child in RANDOMIZED_CONNECTIONS else EntranceType.ONE_WAY
|
||||
if er_type == EntranceType.TWO_WAY:
|
||||
mock_entrance = parent_region.create_er_target(entrance.name)
|
||||
else:
|
||||
mock_entrance = child_region.create_er_target(child)
|
||||
|
||||
entrance.randomization_type = er_type
|
||||
mock_entrance.randomization_type = er_type
|
||||
|
||||
for parent, child in RANDOMIZED_CONNECTIONS.items():
|
||||
if child == "Corrupted Future":
|
||||
entrance = world.get_entrance("Artificer's Portal")
|
||||
elif child == "Tower of Time - Left":
|
||||
entrance = world.get_entrance("Artificer's Challenge")
|
||||
else:
|
||||
entrance = world.get_entrance(f"{parent} -> {child}")
|
||||
parent_region = entrance.parent_region
|
||||
child_region = entrance.connected_region
|
||||
entrance.world = world
|
||||
disconnect_entrance()
|
||||
|
||||
plando = world.options.plando_connections
|
||||
if plando:
|
||||
connect_plando(world, plando)
|
||||
|
||||
@@ -278,6 +278,9 @@ class MMBN3World(World):
|
||||
self.multiworld.get_location(LocationName.Help_with_rehab, self.player).access_rule = \
|
||||
lambda state: \
|
||||
state.can_reach_region(RegionName.Beach_Overworld, self.player)
|
||||
self.multiworld.get_location(LocationName.Help_with_rehab_bonus, self.player).access_rule = \
|
||||
lambda state: \
|
||||
state.can_reach_region(RegionName.Beach_Overworld, self.player)
|
||||
self.multiworld.get_location(LocationName.Old_Master, self.player).access_rule = \
|
||||
lambda state: \
|
||||
state.can_reach_region(RegionName.ACDC_Overworld, self.player) and \
|
||||
|
||||
@@ -36,6 +36,8 @@ class MuseDashCollections:
|
||||
"Yume Ou Mono Yo Secret",
|
||||
"Echo over you... Secret",
|
||||
"Tsukuyomi Ni Naru Replaced",
|
||||
"Heart Message feat. Aoi Tokimori Secret",
|
||||
"Meow Rock feat. Chun Ge, Yuan Shen",
|
||||
]
|
||||
|
||||
song_items = SONG_DATA
|
||||
|
||||
@@ -627,10 +627,18 @@ SONG_DATA: Dict[str, SongData] = {
|
||||
"Sharp Bubbles": SongData(2900751, "83-3", "Cosmic Radio 2024", True, 7, 9, 11),
|
||||
"Replay": SongData(2900752, "83-4", "Cosmic Radio 2024", True, 5, 7, 9),
|
||||
"Cosmic Dusty Girl": SongData(2900753, "83-5", "Cosmic Radio 2024", True, 5, 7, 9),
|
||||
"Meow Rock feat. Chun Ge, Yuan Shen": SongData(2900754, "84-0", "Muse Dash Legend", True, None, None, None),
|
||||
"Even if you make an old radio song with AI": SongData(2900755, "84-1", "Muse Dash Legend", False, 3, 6, 8),
|
||||
"Unusual Sketchbook": SongData(2900756, "84-2", "Muse Dash Legend", True, 6, 8, 11),
|
||||
"TransientTears": SongData(2900757, "84-3", "Muse Dash Legend", True, 6, 8, 11),
|
||||
"SHOOTING*STAR": SongData(2900758, "84-4", "Muse Dash Legend", False, 5, 7, 9),
|
||||
"But the Blue Bird is Already Dead": SongData(2900759, "84-5", "Muse Dash Legend", False, 6, 8, 10),
|
||||
"Meow Rock feat. Chun Ge, Yuan Shen": SongData(2900754, "84-0", "Muse Dash・Legend", True, None, None, None),
|
||||
"Even if you make an old radio song with AI": SongData(2900755, "84-1", "Muse Dash・Legend", False, 3, 6, 8),
|
||||
"Unusual Sketchbook": SongData(2900756, "84-2", "Muse Dash・Legend", True, 6, 8, 11),
|
||||
"TransientTears": SongData(2900757, "84-3", "Muse Dash・Legend", True, 6, 8, 11),
|
||||
"SHOOTING*STAR": SongData(2900758, "84-4", "Muse Dash・Legend", False, 5, 7, 9),
|
||||
"But the Blue Bird is Already Dead": SongData(2900759, "84-5", "Muse Dash・Legend", False, 6, 8, 10),
|
||||
"Heart Message feat. Aoi Tokimori Secret": SongData(2900760, "0-57", "Default Music", True, None, 7, 10),
|
||||
"Heart Message feat. Aoi Tokimori": SongData(2900761, "0-58", "Default Music", True, 1, 3, 6),
|
||||
"Aventyr": SongData(2900762, "85-0", "Happy Otaku Pack Vol.20", True, 4, 7, 10),
|
||||
"Raintain": SongData(2900763, "85-1", "Happy Otaku Pack Vol.20", False, 6, 8, 10),
|
||||
"Piercing the Clouds and Waves": SongData(2900764, "85-2", "Happy Otaku Pack Vol.20", True, 3, 6, 8),
|
||||
"Save Yourself": SongData(2900765, "85-3", "Happy Otaku Pack Vol.20", True, 5, 7, 10),
|
||||
"Menace": SongData(2900766, "85-4", "Happy Otaku Pack Vol.20", True, 7, 9, 11),
|
||||
"Dangling": SongData(2900767, "85-5", "Happy Otaku Pack Vol.20", True, 6, 8, 10),
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ class NoitaWorld(World):
|
||||
web = NoitaWeb()
|
||||
|
||||
def generate_early(self) -> None:
|
||||
if not self.multiworld.get_player_name(self.player).isascii():
|
||||
if not self.player_name.isascii():
|
||||
raise Exception("Noita yaml's slot name has invalid character(s).")
|
||||
|
||||
# Returned items will be sent over to the client
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from typing import Dict, TYPE_CHECKING
|
||||
from typing import TYPE_CHECKING
|
||||
from BaseClasses import Item, ItemClassification, Location, Region
|
||||
from . import items, locations
|
||||
|
||||
@@ -6,7 +6,7 @@ if TYPE_CHECKING:
|
||||
from . import NoitaWorld
|
||||
|
||||
|
||||
def create_event(player: int, name: str) -> Item:
|
||||
def create_event_item(player: int, name: str) -> Item:
|
||||
return items.NoitaItem(name, ItemClassification.progression, None, player)
|
||||
|
||||
|
||||
@@ -16,13 +16,13 @@ def create_location(player: int, name: str, region: Region) -> Location:
|
||||
|
||||
def create_locked_location_event(player: int, region: Region, item: str) -> Location:
|
||||
new_location = create_location(player, item, region)
|
||||
new_location.place_locked_item(create_event(player, item))
|
||||
new_location.place_locked_item(create_event_item(player, item))
|
||||
|
||||
region.locations.append(new_location)
|
||||
return new_location
|
||||
|
||||
|
||||
def create_all_events(world: "NoitaWorld", created_regions: Dict[str, Region]) -> None:
|
||||
def create_all_events(world: "NoitaWorld", created_regions: dict[str, Region]) -> None:
|
||||
for region_name, event in event_locks.items():
|
||||
region = created_regions[region_name]
|
||||
create_locked_location_event(world.player, region, event)
|
||||
@@ -31,7 +31,7 @@ def create_all_events(world: "NoitaWorld", created_regions: Dict[str, Region]) -
|
||||
|
||||
|
||||
# Maps region names to event names
|
||||
event_locks: Dict[str, str] = {
|
||||
event_locks: dict[str, str] = {
|
||||
"The Work": "Victory",
|
||||
"Mines": "Portal to Holy Mountain 1",
|
||||
"Coal Pits": "Portal to Holy Mountain 2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import itertools
|
||||
from collections import Counter
|
||||
from typing import Dict, List, NamedTuple, Set, TYPE_CHECKING
|
||||
from typing import NamedTuple, TYPE_CHECKING
|
||||
|
||||
from BaseClasses import Item, ItemClassification
|
||||
from .options import BossesAsChecks, VictoryCondition, ExtraOrbs
|
||||
@@ -27,12 +27,12 @@ def create_item(player: int, name: str) -> Item:
|
||||
return NoitaItem(name, item_data.classification, item_data.code, player)
|
||||
|
||||
|
||||
def create_fixed_item_pool() -> List[str]:
|
||||
required_items: Dict[str, int] = {name: data.required_num for name, data in item_table.items()}
|
||||
def create_fixed_item_pool() -> list[str]:
|
||||
required_items: dict[str, int] = {name: data.required_num for name, data in item_table.items()}
|
||||
return list(Counter(required_items).elements())
|
||||
|
||||
|
||||
def create_orb_items(victory_condition: VictoryCondition, extra_orbs: ExtraOrbs) -> List[str]:
|
||||
def create_orb_items(victory_condition: VictoryCondition, extra_orbs: ExtraOrbs) -> list[str]:
|
||||
orb_count = extra_orbs.value
|
||||
if victory_condition == VictoryCondition.option_pure_ending:
|
||||
orb_count = orb_count + 11
|
||||
@@ -41,15 +41,15 @@ def create_orb_items(victory_condition: VictoryCondition, extra_orbs: ExtraOrbs)
|
||||
return ["Orb" for _ in range(orb_count)]
|
||||
|
||||
|
||||
def create_spatial_awareness_item(bosses_as_checks: BossesAsChecks) -> List[str]:
|
||||
def create_spatial_awareness_item(bosses_as_checks: BossesAsChecks) -> list[str]:
|
||||
return ["Spatial Awareness Perk"] if bosses_as_checks.value >= BossesAsChecks.option_all_bosses else []
|
||||
|
||||
|
||||
def create_kantele(victory_condition: VictoryCondition) -> List[str]:
|
||||
def create_kantele(victory_condition: VictoryCondition) -> list[str]:
|
||||
return ["Kantele"] if victory_condition.value >= VictoryCondition.option_pure_ending else []
|
||||
|
||||
|
||||
def create_random_items(world: NoitaWorld, weights: Dict[str, int], count: int) -> List[str]:
|
||||
def create_random_items(world: NoitaWorld, weights: dict[str, int], count: int) -> list[str]:
|
||||
filler_pool = weights.copy()
|
||||
if not world.options.bad_effects:
|
||||
filler_pool["Trap"] = 0
|
||||
@@ -87,7 +87,7 @@ def create_all_items(world: NoitaWorld) -> None:
|
||||
|
||||
|
||||
# 110000 - 110032
|
||||
item_table: Dict[str, ItemData] = {
|
||||
item_table: dict[str, ItemData] = {
|
||||
"Trap": ItemData(110000, "Traps", ItemClassification.trap),
|
||||
"Extra Max HP": ItemData(110001, "Pickups", ItemClassification.useful),
|
||||
"Spell Refresher": ItemData(110002, "Pickups", ItemClassification.filler),
|
||||
@@ -122,7 +122,7 @@ item_table: Dict[str, ItemData] = {
|
||||
"Broken Wand": ItemData(110031, "Items", ItemClassification.filler),
|
||||
}
|
||||
|
||||
shop_only_filler_weights: Dict[str, int] = {
|
||||
shop_only_filler_weights: dict[str, int] = {
|
||||
"Trap": 15,
|
||||
"Extra Max HP": 25,
|
||||
"Spell Refresher": 20,
|
||||
@@ -135,7 +135,7 @@ shop_only_filler_weights: Dict[str, int] = {
|
||||
"Extra Life Perk": 10,
|
||||
}
|
||||
|
||||
filler_weights: Dict[str, int] = {
|
||||
filler_weights: dict[str, int] = {
|
||||
**shop_only_filler_weights,
|
||||
"Gold (200)": 15,
|
||||
"Gold (1000)": 6,
|
||||
@@ -152,22 +152,10 @@ filler_weights: Dict[str, int] = {
|
||||
}
|
||||
|
||||
|
||||
# These helper functions make the comprehensions below more readable
|
||||
def get_item_group(item_name: str) -> str:
|
||||
return item_table[item_name].group
|
||||
filler_items: list[str] = list(filter(lambda item: item_table[item].classification == ItemClassification.filler,
|
||||
item_table.keys()))
|
||||
item_name_to_id: dict[str, int] = {name: data.code for name, data in item_table.items()}
|
||||
|
||||
|
||||
def item_is_filler(item_name: str) -> bool:
|
||||
return item_table[item_name].classification == ItemClassification.filler
|
||||
|
||||
|
||||
def item_is_perk(item_name: str) -> bool:
|
||||
return item_table[item_name].group == "Perks"
|
||||
|
||||
|
||||
filler_items: List[str] = list(filter(item_is_filler, item_table.keys()))
|
||||
item_name_to_id: Dict[str, int] = {name: data.code for name, data in item_table.items()}
|
||||
|
||||
item_name_groups: Dict[str, Set[str]] = {
|
||||
group: set(item_names) for group, item_names in itertools.groupby(item_table, get_item_group)
|
||||
item_name_groups: dict[str, set[str]] = {
|
||||
group: set(item_names) for group, item_names in itertools.groupby(item_table, lambda item: item_table[item].group)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Locations are specific points that you would obtain an item at.
|
||||
from enum import IntEnum
|
||||
from typing import Dict, NamedTuple, Optional, Set
|
||||
from typing import NamedTuple
|
||||
|
||||
from BaseClasses import Location
|
||||
|
||||
@@ -27,7 +27,7 @@ class LocationFlag(IntEnum):
|
||||
# Only the first Hidden Chest and Pedestal are mapped here, the others are created in Regions.
|
||||
# ltype key: "Chest" = Hidden Chests, "Pedestal" = Pedestals, "Boss" = Boss, "Orb" = Orb.
|
||||
# 110000-110671
|
||||
location_region_mapping: Dict[str, Dict[str, LocationData]] = {
|
||||
location_region_mapping: dict[str, dict[str, LocationData]] = {
|
||||
"Coal Pits Holy Mountain": {
|
||||
"Coal Pits Holy Mountain Shop Item 1": LocationData(110000),
|
||||
"Coal Pits Holy Mountain Shop Item 2": LocationData(110001),
|
||||
@@ -207,15 +207,15 @@ location_region_mapping: Dict[str, Dict[str, LocationData]] = {
|
||||
}
|
||||
|
||||
|
||||
def make_location_range(location_name: str, base_id: int, amt: int) -> Dict[str, int]:
|
||||
def make_location_range(location_name: str, base_id: int, amt: int) -> dict[str, int]:
|
||||
if amt == 1:
|
||||
return {location_name: base_id}
|
||||
return {f"{location_name} {i+1}": base_id + i for i in range(amt)}
|
||||
|
||||
|
||||
location_name_groups: Dict[str, Set[str]] = {"Shop": set(), "Orb": set(), "Boss": set(), "Chest": set(),
|
||||
location_name_groups: dict[str, set[str]] = {"Shop": set(), "Orb": set(), "Boss": set(), "Chest": set(),
|
||||
"Pedestal": set()}
|
||||
location_name_to_id: Dict[str, int] = {}
|
||||
location_name_to_id: dict[str, int] = {}
|
||||
|
||||
|
||||
for region_name, location_group in location_region_mapping.items():
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Regions are areas in your game that you travel to.
|
||||
from typing import Dict, List, TYPE_CHECKING
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from BaseClasses import Entrance, Region
|
||||
from . import locations
|
||||
@@ -36,28 +36,21 @@ def create_region(world: "NoitaWorld", region_name: str) -> Region:
|
||||
return new_region
|
||||
|
||||
|
||||
def create_regions(world: "NoitaWorld") -> Dict[str, Region]:
|
||||
def create_regions(world: "NoitaWorld") -> dict[str, Region]:
|
||||
return {name: create_region(world, name) for name in noita_regions}
|
||||
|
||||
|
||||
# An "Entrance" is really just a connection between two regions
|
||||
def create_entrance(player: int, source: str, destination: str, regions: Dict[str, Region]) -> Entrance:
|
||||
entrance = Entrance(player, f"From {source} To {destination}", regions[source])
|
||||
entrance.connect(regions[destination])
|
||||
return entrance
|
||||
|
||||
|
||||
# Creates connections based on our access mapping in `noita_connections`.
|
||||
def create_connections(player: int, regions: Dict[str, Region]) -> None:
|
||||
def create_connections(regions: dict[str, Region]) -> None:
|
||||
for source, destinations in noita_connections.items():
|
||||
new_entrances = [create_entrance(player, source, destination, regions) for destination in destinations]
|
||||
regions[source].exits = new_entrances
|
||||
for destination in destinations:
|
||||
regions[source].connect(regions[destination])
|
||||
|
||||
|
||||
# Creates all regions and connections. Called from NoitaWorld.
|
||||
def create_all_regions_and_connections(world: "NoitaWorld") -> None:
|
||||
created_regions = create_regions(world)
|
||||
create_connections(world.player, created_regions)
|
||||
create_connections(created_regions)
|
||||
create_all_events(world, created_regions)
|
||||
|
||||
world.multiworld.regions += created_regions.values()
|
||||
@@ -75,7 +68,7 @@ def create_all_regions_and_connections(world: "NoitaWorld") -> None:
|
||||
# - Lake is connected to The Laboratory, since the bosses are hard without specific set-ups (which means late game)
|
||||
# - Snowy Depths connects to Lava Lake orb since you need digging for it, so fairly early is acceptable
|
||||
# - Ancient Laboratory is connected to the Coal Pits, so that Ylialkemisti isn't sphere 1
|
||||
noita_connections: Dict[str, List[str]] = {
|
||||
noita_connections: dict[str, list[str]] = {
|
||||
"Menu": ["Forest"],
|
||||
"Forest": ["Mines", "Floating Island", "Desert", "Snowy Wasteland"],
|
||||
"Frozen Vault": ["The Vault"],
|
||||
@@ -117,4 +110,4 @@ noita_connections: Dict[str, List[str]] = {
|
||||
###
|
||||
}
|
||||
|
||||
noita_regions: List[str] = sorted(set(noita_connections.keys()).union(*noita_connections.values()))
|
||||
noita_regions: list[str] = sorted(set(noita_connections.keys()).union(*noita_connections.values()))
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
from typing import List, NamedTuple, Set, TYPE_CHECKING
|
||||
from typing import NamedTuple, TYPE_CHECKING
|
||||
|
||||
from BaseClasses import CollectionState
|
||||
from . import items, locations
|
||||
from .options import BossesAsChecks, VictoryCondition
|
||||
from worlds.generic import Rules as GenericRules
|
||||
@@ -16,7 +15,7 @@ class EntranceLock(NamedTuple):
|
||||
items_needed: int
|
||||
|
||||
|
||||
entrance_locks: List[EntranceLock] = [
|
||||
entrance_locks: list[EntranceLock] = [
|
||||
EntranceLock("Mines", "Coal Pits Holy Mountain", "Portal to Holy Mountain 1", 1),
|
||||
EntranceLock("Coal Pits", "Snowy Depths Holy Mountain", "Portal to Holy Mountain 2", 2),
|
||||
EntranceLock("Snowy Depths", "Hiisi Base Holy Mountain", "Portal to Holy Mountain 3", 3),
|
||||
@@ -27,7 +26,7 @@ entrance_locks: List[EntranceLock] = [
|
||||
]
|
||||
|
||||
|
||||
holy_mountain_regions: List[str] = [
|
||||
holy_mountain_regions: list[str] = [
|
||||
"Coal Pits Holy Mountain",
|
||||
"Snowy Depths Holy Mountain",
|
||||
"Hiisi Base Holy Mountain",
|
||||
@@ -38,7 +37,7 @@ holy_mountain_regions: List[str] = [
|
||||
]
|
||||
|
||||
|
||||
wand_tiers: List[str] = [
|
||||
wand_tiers: list[str] = [
|
||||
"Wand (Tier 1)", # Coal Pits
|
||||
"Wand (Tier 2)", # Snowy Depths
|
||||
"Wand (Tier 3)", # Hiisi Base
|
||||
@@ -48,29 +47,21 @@ wand_tiers: List[str] = [
|
||||
]
|
||||
|
||||
|
||||
items_hidden_from_shops: Set[str] = {"Gold (200)", "Gold (1000)", "Potion", "Random Potion", "Secret Potion",
|
||||
items_hidden_from_shops: set[str] = {"Gold (200)", "Gold (1000)", "Potion", "Random Potion", "Secret Potion",
|
||||
"Chaos Die", "Greed Die", "Kammi", "Refreshing Gourd", "Sädekivi", "Broken Wand",
|
||||
"Powder Pouch"}
|
||||
|
||||
perk_list: List[str] = list(filter(items.item_is_perk, items.item_table.keys()))
|
||||
perk_list: list[str] = list(filter(lambda item: items.item_table[item].group == "Perks", items.item_table.keys()))
|
||||
|
||||
|
||||
# ----------------
|
||||
# Helper Functions
|
||||
# Helper Function
|
||||
# ----------------
|
||||
|
||||
|
||||
def has_perk_count(state: CollectionState, player: int, amount: int) -> bool:
|
||||
return sum(state.count(perk, player) for perk in perk_list) >= amount
|
||||
|
||||
|
||||
def has_orb_count(state: CollectionState, player: int, amount: int) -> bool:
|
||||
return state.count("Orb", player) >= amount
|
||||
|
||||
|
||||
def forbid_items_at_locations(world: "NoitaWorld", shop_locations: Set[str], forbidden_items: Set[str]) -> None:
|
||||
def forbid_items_at_locations(world: "NoitaWorld", shop_locations: set[str], forbidden_items: set[str]) -> None:
|
||||
for shop_location in shop_locations:
|
||||
location = world.multiworld.get_location(shop_location, world.player)
|
||||
location = world.get_location(shop_location)
|
||||
GenericRules.forbid_items_for_player(location, forbidden_items, world.player)
|
||||
|
||||
|
||||
@@ -104,38 +95,38 @@ def ban_early_high_tier_wands(world: "NoitaWorld") -> None:
|
||||
|
||||
def lock_holy_mountains_into_spheres(world: "NoitaWorld") -> None:
|
||||
for lock in entrance_locks:
|
||||
location = world.multiworld.get_entrance(f"From {lock.source} To {lock.destination}", world.player)
|
||||
location = world.get_entrance(f"{lock.source} -> {lock.destination}")
|
||||
GenericRules.set_rule(location, lambda state, evt=lock.event: state.has(evt, world.player))
|
||||
|
||||
|
||||
def holy_mountain_unlock_conditions(world: "NoitaWorld") -> None:
|
||||
victory_condition = world.options.victory_condition.value
|
||||
for lock in entrance_locks:
|
||||
location = world.multiworld.get_location(lock.event, world.player)
|
||||
location = world.get_location(lock.event)
|
||||
|
||||
if victory_condition == VictoryCondition.option_greed_ending:
|
||||
location.access_rule = lambda state, items_needed=lock.items_needed: (
|
||||
has_perk_count(state, world.player, items_needed//2)
|
||||
state.has_group_unique("Perks", world.player, items_needed // 2)
|
||||
)
|
||||
elif victory_condition == VictoryCondition.option_pure_ending:
|
||||
location.access_rule = lambda state, items_needed=lock.items_needed: (
|
||||
has_perk_count(state, world.player, items_needed//2) and
|
||||
has_orb_count(state, world.player, items_needed)
|
||||
state.has_group_unique("Perks", world.player, items_needed // 2) and
|
||||
state.has("Orb", world.player, items_needed)
|
||||
)
|
||||
elif victory_condition == VictoryCondition.option_peaceful_ending:
|
||||
location.access_rule = lambda state, items_needed=lock.items_needed: (
|
||||
has_perk_count(state, world.player, items_needed//2) and
|
||||
has_orb_count(state, world.player, items_needed * 3)
|
||||
state.has_group_unique("Perks", world.player, items_needed // 2) and
|
||||
state.has("Orb", world.player, items_needed * 3)
|
||||
)
|
||||
|
||||
|
||||
def biome_unlock_conditions(world: "NoitaWorld") -> None:
|
||||
lukki_entrances = world.multiworld.get_region("Lukki Lair", world.player).entrances
|
||||
magical_entrances = world.multiworld.get_region("Magical Temple", world.player).entrances
|
||||
wizard_entrances = world.multiworld.get_region("Wizards' Den", world.player).entrances
|
||||
lukki_entrances = world.get_region("Lukki Lair").entrances
|
||||
magical_entrances = world.get_region("Magical Temple").entrances
|
||||
wizard_entrances = world.get_region("Wizards' Den").entrances
|
||||
for entrance in lukki_entrances:
|
||||
entrance.access_rule = lambda state: state.has("Melee Immunity Perk", world.player) and\
|
||||
state.has("All-Seeing Eye Perk", world.player)
|
||||
entrance.access_rule = lambda state: (
|
||||
state.has_all(("Melee Immunity Perk", "All-Seeing Eye Perk"), world.player))
|
||||
for entrance in magical_entrances:
|
||||
entrance.access_rule = lambda state: state.has("All-Seeing Eye Perk", world.player)
|
||||
for entrance in wizard_entrances:
|
||||
@@ -144,12 +135,12 @@ def biome_unlock_conditions(world: "NoitaWorld") -> None:
|
||||
|
||||
def victory_unlock_conditions(world: "NoitaWorld") -> None:
|
||||
victory_condition = world.options.victory_condition.value
|
||||
victory_location = world.multiworld.get_location("Victory", world.player)
|
||||
victory_location = world.get_location("Victory")
|
||||
|
||||
if victory_condition == VictoryCondition.option_pure_ending:
|
||||
victory_location.access_rule = lambda state: has_orb_count(state, world.player, 11)
|
||||
victory_location.access_rule = lambda state: state.has("Orb", world.player, 11)
|
||||
elif victory_condition == VictoryCondition.option_peaceful_ending:
|
||||
victory_location.access_rule = lambda state: has_orb_count(state, world.player, 33)
|
||||
victory_location.access_rule = lambda state: state.has("Orb", world.player, 33)
|
||||
|
||||
|
||||
# ----------------
|
||||
@@ -168,5 +159,5 @@ def create_all_rules(world: "NoitaWorld") -> None:
|
||||
|
||||
# Prevent the Map perk (used to find Toveri) from being on Toveri (boss)
|
||||
if world.options.bosses_as_checks.value >= BossesAsChecks.option_all_bosses:
|
||||
toveri = world.multiworld.get_location("Toveri", world.player)
|
||||
toveri = world.get_location("Toveri")
|
||||
GenericRules.forbid_items_for_player(toveri, {"Spatial Awareness Perk"}, world.player)
|
||||
|
||||
@@ -32,7 +32,7 @@ from .Cosmetics import patch_cosmetics
|
||||
|
||||
from settings import get_settings
|
||||
from BaseClasses import MultiWorld, CollectionState, Tutorial, LocationProgressType
|
||||
from Options import Range, Toggle, VerifyKeys, Accessibility, PlandoConnections
|
||||
from Options import Range, Toggle, VerifyKeys, Accessibility, PlandoConnections, PlandoItems
|
||||
from Fill import fill_restrictive, fast_fill, FillError
|
||||
from worlds.generic.Rules import exclusion_rules, add_item_rule
|
||||
from worlds.AutoWorld import World, AutoLogicRegister, WebWorld
|
||||
@@ -220,6 +220,8 @@ class OOTWorld(World):
|
||||
option_value = result.value
|
||||
elif isinstance(result, PlandoConnections):
|
||||
option_value = result.value
|
||||
elif isinstance(result, PlandoItems):
|
||||
option_value = result.value
|
||||
else:
|
||||
option_value = result.current_key
|
||||
setattr(self, option_name, option_value)
|
||||
|
||||
@@ -321,7 +321,7 @@ class PokemonRedBlueWorld(World):
|
||||
"Fuchsia Gym - Koga Prize", "Saffron Gym - Sabrina Prize",
|
||||
"Cinnabar Gym - Blaine Prize", "Viridian Gym - Giovanni Prize"
|
||||
] if self.multiworld.get_location(loc, self.player).item is None]
|
||||
state = self.multiworld.get_all_state(False)
|
||||
state = self.multiworld.get_all_state(False, True, False)
|
||||
# Give it two tries to place badges with wild Pokemon and learnsets as-is.
|
||||
# If it can't, then try with all Pokemon collected, and we'll try to fix HM move availability after.
|
||||
if attempt > 1:
|
||||
@@ -395,7 +395,7 @@ class PokemonRedBlueWorld(World):
|
||||
|
||||
# Delete evolution events for Pokémon that are not in logic in an all_state so that accessibility check does not
|
||||
# fail. Re-use test_state from previous final loop.
|
||||
all_state = self.multiworld.get_all_state(False)
|
||||
all_state = self.multiworld.get_all_state(False, True, False)
|
||||
evolutions_region = self.multiworld.get_region("Evolution", self.player)
|
||||
for location in evolutions_region.locations.copy():
|
||||
if not all_state.can_reach(location, player=self.player):
|
||||
@@ -448,7 +448,7 @@ class PokemonRedBlueWorld(World):
|
||||
|
||||
self.local_locs = locs
|
||||
|
||||
all_state = self.multiworld.get_all_state(False)
|
||||
all_state = self.multiworld.get_all_state(False, True, False)
|
||||
|
||||
reachable_mons = set()
|
||||
for mon in poke_data.pokemon_data:
|
||||
@@ -516,6 +516,11 @@ class PokemonRedBlueWorld(World):
|
||||
loc.item = None
|
||||
loc.place_locked_item(self.pc_item)
|
||||
|
||||
def get_pre_fill_items(self) -> typing.List["Item"]:
|
||||
pool = [self.create_item(mon) for mon in poke_data.pokemon_data]
|
||||
pool.append(self.pc_item)
|
||||
return pool
|
||||
|
||||
@classmethod
|
||||
def stage_post_fill(cls, multiworld):
|
||||
# Convert all but one of each instance of a wild Pokemon to useful classification.
|
||||
|
||||
@@ -400,7 +400,7 @@ def verify_hm_moves(multiworld, world, player):
|
||||
last_intervene = None
|
||||
while True:
|
||||
intervene_move = None
|
||||
test_state = multiworld.get_all_state(False)
|
||||
test_state = multiworld.get_all_state(False, True, False)
|
||||
if not logic.can_learn_hm(test_state, world, "Surf", player):
|
||||
intervene_move = "Surf"
|
||||
elif not logic.can_learn_hm(test_state, world, "Strength", player):
|
||||
|
||||
@@ -66,11 +66,8 @@ def get_plando_locations(world: World) -> List[str]:
|
||||
if world is None:
|
||||
return []
|
||||
plando_locations = []
|
||||
for plando_setting in world.multiworld.plando_items[world.player]:
|
||||
plando_locations += plando_setting.get("locations", [])
|
||||
plando_setting_location = plando_setting.get("location", None)
|
||||
if plando_setting_location is not None:
|
||||
plando_locations.append(plando_setting_location)
|
||||
for plando_setting in world.options.plando_items:
|
||||
plando_locations += plando_setting.locations
|
||||
|
||||
return plando_locations
|
||||
|
||||
|
||||
@@ -245,7 +245,7 @@ class ShiversWorld(World):
|
||||
|
||||
storage_items += [self.create_item("Empty") for _ in range(3)]
|
||||
|
||||
state = self.multiworld.get_all_state(False)
|
||||
state = self.multiworld.get_all_state(False, True, False)
|
||||
|
||||
self.random.shuffle(storage_locs)
|
||||
self.random.shuffle(storage_items)
|
||||
@@ -255,6 +255,27 @@ class ShiversWorld(World):
|
||||
self.storage_placements = {location.name.replace("Storage: ", ""): location.item.name.replace(" DUPE", "") for
|
||||
location in storage_locs}
|
||||
|
||||
def get_pre_fill_items(self) -> List[Item]:
|
||||
if self.options.full_pots == "pieces":
|
||||
return [self.create_item(name) for name, data in item_table.items() if
|
||||
data.type == ItemType.POT_DUPLICATE]
|
||||
elif self.options.full_pots == "complete":
|
||||
return [self.create_item(name) for name, data in item_table.items() if
|
||||
data.type == ItemType.POT_COMPELTE_DUPLICATE]
|
||||
else:
|
||||
pool = []
|
||||
pieces = [self.create_item(name) for name, data in item_table.items() if
|
||||
data.type == ItemType.POT_DUPLICATE]
|
||||
complete = [self.create_item(name) for name, data in item_table.items() if
|
||||
data.type == ItemType.POT_COMPELTE_DUPLICATE]
|
||||
for i in range(10):
|
||||
if self.pot_completed_list[i] == 0:
|
||||
pool.append(pieces[i])
|
||||
pool.append(pieces[i + 10])
|
||||
else:
|
||||
pool.append(complete[i])
|
||||
return pool
|
||||
|
||||
def fill_slot_data(self) -> dict:
|
||||
return {
|
||||
"StoragePlacements": self.storage_placements,
|
||||
|
||||
Binary file not shown.
@@ -10,11 +10,13 @@ from .bundles.bundle_room import BundleRoom
|
||||
from .bundles.bundles import get_all_bundles
|
||||
from .content import StardewContent, create_content
|
||||
from .early_items import setup_early_items
|
||||
from .items import item_table, create_items, ItemData, Group, items_by_group, generate_filler_choice_pool
|
||||
from .items import item_table, ItemData, Group, items_by_group
|
||||
from .items.item_creation import create_items, get_all_filler_items, remove_limited_amount_packs, \
|
||||
generate_filler_choice_pool
|
||||
from .locations import location_table, create_locations, LocationData, locations_by_tag
|
||||
from .logic.logic import StardewLogic
|
||||
from .options import StardewValleyOptions, SeasonRandomization, Goal, BundleRandomization, EnabledFillerBuffs, NumberOfMovementBuffs, \
|
||||
BuildingProgression, EntranceRandomization, FarmType
|
||||
from .options import StardewValleyOptions, SeasonRandomization, Goal, BundleRandomization, EnabledFillerBuffs, \
|
||||
NumberOfMovementBuffs, BuildingProgression, EntranceRandomization, FarmType
|
||||
from .options.forced_options import force_change_options_if_incompatible
|
||||
from .options.option_groups import sv_option_groups
|
||||
from .options.presets import sv_options_presets
|
||||
|
||||
1
worlds/stardew_valley/items/__init__.py
Normal file
1
worlds/stardew_valley/items/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from .item_data import item_table, ItemData, Group, items_by_group, load_item_csv
|
||||
@@ -1,165 +1,26 @@
|
||||
import csv
|
||||
import enum
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from functools import reduce
|
||||
from pathlib import Path
|
||||
from random import Random
|
||||
from typing import Dict, List, Protocol, Union, Set, Optional
|
||||
from typing import List, Set
|
||||
|
||||
from BaseClasses import Item, ItemClassification
|
||||
from . import data
|
||||
from .content.feature import friendsanity
|
||||
from .content.game_content import StardewContent
|
||||
from .data.game_item import ItemTag
|
||||
from .logic.logic_event import all_events
|
||||
from .mods.mod_data import ModNames
|
||||
from .options import StardewValleyOptions, TrapItems, FestivalLocations, ExcludeGingerIsland, SpecialOrderLocations, SeasonRandomization, Museumsanity, \
|
||||
from .item_data import StardewItemFactory, items_by_group, Group, item_table, ItemData
|
||||
from ..content.feature import friendsanity
|
||||
from ..content.game_content import StardewContent
|
||||
from ..data.game_item import ItemTag
|
||||
from ..mods.mod_data import ModNames
|
||||
from ..options import StardewValleyOptions, FestivalLocations, ExcludeGingerIsland, SpecialOrderLocations, SeasonRandomization, Museumsanity, \
|
||||
ElevatorProgression, BackpackProgression, ArcadeMachineLocations, Monstersanity, Goal, \
|
||||
Chefsanity, Craftsanity, BundleRandomization, EntranceRandomization, Shipsanity, Walnutsanity, EnabledFillerBuffs
|
||||
from .strings.ap_names.ap_option_names import BuffOptionName, WalnutsanityOptionName
|
||||
from .strings.ap_names.ap_weapon_names import APWeapon
|
||||
from .strings.ap_names.buff_names import Buff
|
||||
from .strings.ap_names.community_upgrade_names import CommunityUpgrade
|
||||
from .strings.ap_names.mods.mod_items import SVEQuestItem
|
||||
from .strings.currency_names import Currency
|
||||
from .strings.tool_names import Tool
|
||||
from .strings.wallet_item_names import Wallet
|
||||
|
||||
ITEM_CODE_OFFSET = 717000
|
||||
Chefsanity, Craftsanity, BundleRandomization, EntranceRandomization, Shipsanity, Walnutsanity, EnabledFillerBuffs, TrapDifficulty
|
||||
from ..strings.ap_names.ap_option_names import BuffOptionName, WalnutsanityOptionName
|
||||
from ..strings.ap_names.ap_weapon_names import APWeapon
|
||||
from ..strings.ap_names.buff_names import Buff
|
||||
from ..strings.ap_names.community_upgrade_names import CommunityUpgrade
|
||||
from ..strings.ap_names.mods.mod_items import SVEQuestItem
|
||||
from ..strings.currency_names import Currency
|
||||
from ..strings.tool_names import Tool
|
||||
from ..strings.wallet_item_names import Wallet
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
world_folder = Path(__file__).parent
|
||||
|
||||
|
||||
class Group(enum.Enum):
|
||||
RESOURCE_PACK = enum.auto()
|
||||
FRIENDSHIP_PACK = enum.auto()
|
||||
COMMUNITY_REWARD = enum.auto()
|
||||
TRASH = enum.auto()
|
||||
FOOTWEAR = enum.auto()
|
||||
HATS = enum.auto()
|
||||
RING = enum.auto()
|
||||
WEAPON = enum.auto()
|
||||
WEAPON_GENERIC = enum.auto()
|
||||
WEAPON_SWORD = enum.auto()
|
||||
WEAPON_CLUB = enum.auto()
|
||||
WEAPON_DAGGER = enum.auto()
|
||||
WEAPON_SLINGSHOT = enum.auto()
|
||||
PROGRESSIVE_TOOLS = enum.auto()
|
||||
SKILL_LEVEL_UP = enum.auto()
|
||||
SKILL_MASTERY = enum.auto()
|
||||
BUILDING = enum.auto()
|
||||
WIZARD_BUILDING = enum.auto()
|
||||
ARCADE_MACHINE_BUFFS = enum.auto()
|
||||
BASE_RESOURCE = enum.auto()
|
||||
WARP_TOTEM = enum.auto()
|
||||
GEODE = enum.auto()
|
||||
ORE = enum.auto()
|
||||
FERTILIZER = enum.auto()
|
||||
SEED = enum.auto()
|
||||
CROPSANITY = enum.auto()
|
||||
FISHING_RESOURCE = enum.auto()
|
||||
SEASON = enum.auto()
|
||||
TRAVELING_MERCHANT_DAY = enum.auto()
|
||||
MUSEUM = enum.auto()
|
||||
FRIENDSANITY = enum.auto()
|
||||
FESTIVAL = enum.auto()
|
||||
RARECROW = enum.auto()
|
||||
TRAP = enum.auto()
|
||||
BONUS = enum.auto()
|
||||
MAXIMUM_ONE = enum.auto()
|
||||
AT_LEAST_TWO = enum.auto()
|
||||
DEPRECATED = enum.auto()
|
||||
RESOURCE_PACK_USEFUL = enum.auto()
|
||||
SPECIAL_ORDER_BOARD = enum.auto()
|
||||
SPECIAL_ORDER_QI = enum.auto()
|
||||
BABY = enum.auto()
|
||||
GINGER_ISLAND = enum.auto()
|
||||
WALNUT_PURCHASE = enum.auto()
|
||||
TV_CHANNEL = enum.auto()
|
||||
QI_CRAFTING_RECIPE = enum.auto()
|
||||
CHEFSANITY = enum.auto()
|
||||
CHEFSANITY_STARTER = enum.auto()
|
||||
CHEFSANITY_QOS = enum.auto()
|
||||
CHEFSANITY_PURCHASE = enum.auto()
|
||||
CHEFSANITY_FRIENDSHIP = enum.auto()
|
||||
CHEFSANITY_SKILL = enum.auto()
|
||||
CRAFTSANITY = enum.auto()
|
||||
BOOK_POWER = enum.auto()
|
||||
LOST_BOOK = enum.auto()
|
||||
PLAYER_BUFF = enum.auto()
|
||||
# Mods
|
||||
MAGIC_SPELL = enum.auto()
|
||||
MOD_WARP = enum.auto()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ItemData:
|
||||
code_without_offset: Optional[int]
|
||||
name: str
|
||||
classification: ItemClassification
|
||||
mod_name: Optional[str] = None
|
||||
groups: Set[Group] = field(default_factory=frozenset)
|
||||
|
||||
def __post_init__(self):
|
||||
if not isinstance(self.groups, frozenset):
|
||||
super().__setattr__("groups", frozenset(self.groups))
|
||||
|
||||
@property
|
||||
def code(self):
|
||||
return ITEM_CODE_OFFSET + self.code_without_offset if self.code_without_offset is not None else None
|
||||
|
||||
def has_any_group(self, *group: Group) -> bool:
|
||||
groups = set(group)
|
||||
return bool(groups.intersection(self.groups))
|
||||
|
||||
|
||||
class StardewItemFactory(Protocol):
|
||||
def __call__(self, name: Union[str, ItemData], override_classification: ItemClassification = None) -> Item:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
def load_item_csv():
|
||||
from importlib.resources import files
|
||||
|
||||
items = []
|
||||
with files(data).joinpath("items.csv").open() as file:
|
||||
item_reader = csv.DictReader(file)
|
||||
for item in item_reader:
|
||||
id = int(item["id"]) if item["id"] else None
|
||||
classification = reduce((lambda a, b: a | b), {ItemClassification[str_classification] for str_classification in item["classification"].split(",")})
|
||||
groups = {Group[group] for group in item["groups"].split(",") if group}
|
||||
mod_name = str(item["mod_name"]) if item["mod_name"] else None
|
||||
items.append(ItemData(id, item["name"], classification, mod_name, groups))
|
||||
return items
|
||||
|
||||
|
||||
events = [
|
||||
ItemData(None, e, ItemClassification.progression)
|
||||
for e in sorted(all_events)
|
||||
]
|
||||
|
||||
all_items: List[ItemData] = load_item_csv() + events
|
||||
item_table: Dict[str, ItemData] = {}
|
||||
items_by_group: Dict[Group, List[ItemData]] = {}
|
||||
|
||||
|
||||
def initialize_groups():
|
||||
for item in all_items:
|
||||
for group in item.groups:
|
||||
item_group = items_by_group.get(group, list())
|
||||
item_group.append(item)
|
||||
items_by_group[group] = item_group
|
||||
|
||||
|
||||
def initialize_item_table():
|
||||
item_table.update({item.name: item for item in all_items})
|
||||
|
||||
|
||||
initialize_item_table()
|
||||
initialize_groups()
|
||||
|
||||
|
||||
def get_too_many_items_error_message(locations_count: int, items_count: int) -> str:
|
||||
return f"There should be at least as many locations [{locations_count}] as there are mandatory items [{items_count}]"
|
||||
@@ -712,13 +573,15 @@ def weapons_count(options: StardewValleyOptions):
|
||||
def fill_with_resource_packs_and_traps(item_factory: StardewItemFactory, options: StardewValleyOptions, random: Random,
|
||||
items_already_added: List[Item],
|
||||
available_item_slots: int) -> List[Item]:
|
||||
include_traps = options.trap_items != TrapItems.option_no_traps
|
||||
include_traps = options.trap_difficulty != TrapDifficulty.option_no_traps
|
||||
items_already_added_names = [item.name for item in items_already_added]
|
||||
useful_resource_packs = [pack for pack in items_by_group[Group.RESOURCE_PACK_USEFUL]
|
||||
if pack.name not in items_already_added_names]
|
||||
trap_items = [trap for trap in items_by_group[Group.TRAP]
|
||||
if trap.name not in items_already_added_names and
|
||||
(trap.mod_name is None or trap.mod_name in options.mods)]
|
||||
Group.DEPRECATED not in trap.groups and
|
||||
(trap.mod_name is None or trap.mod_name in options.mods) and
|
||||
options.trap_distribution[trap.name] > 0]
|
||||
player_buffs = get_allowed_player_buffs(options.enabled_filler_buffs)
|
||||
|
||||
priority_filler_items = []
|
||||
@@ -750,11 +613,13 @@ def fill_with_resource_packs_and_traps(item_factory: StardewItemFactory, options
|
||||
(filler_pack.name not in [priority_item.name for priority_item in
|
||||
priority_filler_items] and filler_pack.name not in items_already_added_names)]
|
||||
|
||||
filler_weights = get_filler_weights(options, all_filler_packs)
|
||||
|
||||
while available_item_slots > 0:
|
||||
resource_pack = random.choice(all_filler_packs)
|
||||
resource_pack = random.choices(all_filler_packs, weights=filler_weights, k=1)[0]
|
||||
exactly_2 = Group.AT_LEAST_TWO in resource_pack.groups
|
||||
while exactly_2 and available_item_slots == 1:
|
||||
resource_pack = random.choice(all_filler_packs)
|
||||
resource_pack = random.choices(all_filler_packs, weights=filler_weights, k=1)[0]
|
||||
exactly_2 = Group.AT_LEAST_TWO in resource_pack.groups
|
||||
classification = ItemClassification.useful if resource_pack.classification == ItemClassification.progression else resource_pack.classification
|
||||
items.append(item_factory(resource_pack, classification))
|
||||
@@ -763,11 +628,24 @@ def fill_with_resource_packs_and_traps(item_factory: StardewItemFactory, options
|
||||
items.append(item_factory(resource_pack, classification))
|
||||
available_item_slots -= 1
|
||||
if exactly_2 or Group.MAXIMUM_ONE in resource_pack.groups:
|
||||
all_filler_packs.remove(resource_pack)
|
||||
index = all_filler_packs.index(resource_pack)
|
||||
all_filler_packs.pop(index)
|
||||
filler_weights.pop(index)
|
||||
|
||||
return items
|
||||
|
||||
|
||||
def get_filler_weights(options: StardewValleyOptions, all_filler_packs: List[ItemData]):
|
||||
weights = []
|
||||
for filler in all_filler_packs:
|
||||
if filler.name in options.trap_distribution:
|
||||
num = options.trap_distribution[filler.name]
|
||||
else:
|
||||
num = options.trap_distribution.default_weight
|
||||
weights.append(num)
|
||||
return weights
|
||||
|
||||
|
||||
def filter_deprecated_items(items: List[ItemData]) -> List[ItemData]:
|
||||
return [item for item in items if Group.DEPRECATED not in item.groups]
|
||||
|
||||
@@ -792,7 +670,7 @@ def remove_excluded_items_island_mods(items, exclude_ginger_island: bool, mods:
|
||||
|
||||
|
||||
def generate_filler_choice_pool(options: StardewValleyOptions) -> list[str]:
|
||||
include_traps = options.trap_items != TrapItems.option_no_traps
|
||||
include_traps = options.trap_difficulty != TrapDifficulty.option_no_traps
|
||||
exclude_island = options.exclude_ginger_island == ExcludeGingerIsland.option_true
|
||||
|
||||
available_filler = get_all_filler_items(include_traps, exclude_island)
|
||||
143
worlds/stardew_valley/items/item_data.py
Normal file
143
worlds/stardew_valley/items/item_data.py
Normal file
@@ -0,0 +1,143 @@
|
||||
import csv
|
||||
import enum
|
||||
from dataclasses import dataclass, field
|
||||
from functools import reduce
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Protocol, Union, Set, Optional
|
||||
|
||||
from BaseClasses import Item, ItemClassification
|
||||
from .. import data
|
||||
from ..logic.logic_event import all_events
|
||||
|
||||
ITEM_CODE_OFFSET = 717000
|
||||
|
||||
world_folder = Path(__file__).parent
|
||||
|
||||
|
||||
class Group(enum.Enum):
|
||||
RESOURCE_PACK = enum.auto()
|
||||
FRIENDSHIP_PACK = enum.auto()
|
||||
COMMUNITY_REWARD = enum.auto()
|
||||
TRASH = enum.auto()
|
||||
FOOTWEAR = enum.auto()
|
||||
HATS = enum.auto()
|
||||
RING = enum.auto()
|
||||
WEAPON = enum.auto()
|
||||
WEAPON_GENERIC = enum.auto()
|
||||
WEAPON_SWORD = enum.auto()
|
||||
WEAPON_CLUB = enum.auto()
|
||||
WEAPON_DAGGER = enum.auto()
|
||||
WEAPON_SLINGSHOT = enum.auto()
|
||||
PROGRESSIVE_TOOLS = enum.auto()
|
||||
SKILL_LEVEL_UP = enum.auto()
|
||||
SKILL_MASTERY = enum.auto()
|
||||
BUILDING = enum.auto()
|
||||
WIZARD_BUILDING = enum.auto()
|
||||
ARCADE_MACHINE_BUFFS = enum.auto()
|
||||
BASE_RESOURCE = enum.auto()
|
||||
WARP_TOTEM = enum.auto()
|
||||
GEODE = enum.auto()
|
||||
ORE = enum.auto()
|
||||
FERTILIZER = enum.auto()
|
||||
SEED = enum.auto()
|
||||
CROPSANITY = enum.auto()
|
||||
FISHING_RESOURCE = enum.auto()
|
||||
SEASON = enum.auto()
|
||||
TRAVELING_MERCHANT_DAY = enum.auto()
|
||||
MUSEUM = enum.auto()
|
||||
FRIENDSANITY = enum.auto()
|
||||
FESTIVAL = enum.auto()
|
||||
RARECROW = enum.auto()
|
||||
TRAP = enum.auto()
|
||||
BONUS = enum.auto()
|
||||
MAXIMUM_ONE = enum.auto()
|
||||
AT_LEAST_TWO = enum.auto()
|
||||
DEPRECATED = enum.auto()
|
||||
RESOURCE_PACK_USEFUL = enum.auto()
|
||||
SPECIAL_ORDER_BOARD = enum.auto()
|
||||
SPECIAL_ORDER_QI = enum.auto()
|
||||
BABY = enum.auto()
|
||||
GINGER_ISLAND = enum.auto()
|
||||
WALNUT_PURCHASE = enum.auto()
|
||||
TV_CHANNEL = enum.auto()
|
||||
QI_CRAFTING_RECIPE = enum.auto()
|
||||
CHEFSANITY = enum.auto()
|
||||
CHEFSANITY_STARTER = enum.auto()
|
||||
CHEFSANITY_QOS = enum.auto()
|
||||
CHEFSANITY_PURCHASE = enum.auto()
|
||||
CHEFSANITY_FRIENDSHIP = enum.auto()
|
||||
CHEFSANITY_SKILL = enum.auto()
|
||||
CRAFTSANITY = enum.auto()
|
||||
BOOK_POWER = enum.auto()
|
||||
LOST_BOOK = enum.auto()
|
||||
PLAYER_BUFF = enum.auto()
|
||||
# Mods
|
||||
MAGIC_SPELL = enum.auto()
|
||||
MOD_WARP = enum.auto()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ItemData:
|
||||
code_without_offset: Optional[int]
|
||||
name: str
|
||||
classification: ItemClassification
|
||||
mod_name: Optional[str] = None
|
||||
groups: Set[Group] = field(default_factory=frozenset)
|
||||
|
||||
def __post_init__(self):
|
||||
if not isinstance(self.groups, frozenset):
|
||||
super().__setattr__("groups", frozenset(self.groups))
|
||||
|
||||
@property
|
||||
def code(self):
|
||||
return ITEM_CODE_OFFSET + self.code_without_offset if self.code_without_offset is not None else None
|
||||
|
||||
def has_any_group(self, *group: Group) -> bool:
|
||||
groups = set(group)
|
||||
return bool(groups.intersection(self.groups))
|
||||
|
||||
|
||||
class StardewItemFactory(Protocol):
|
||||
def __call__(self, name: Union[str, ItemData], override_classification: ItemClassification = None) -> Item:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
def load_item_csv():
|
||||
from importlib.resources import files
|
||||
|
||||
items = []
|
||||
with files(data).joinpath("items.csv").open() as file:
|
||||
item_reader = csv.DictReader(file)
|
||||
for item in item_reader:
|
||||
id = int(item["id"]) if item["id"] else None
|
||||
classification = reduce((lambda a, b: a | b), {ItemClassification[str_classification] for str_classification in item["classification"].split(",")})
|
||||
groups = {Group[group] for group in item["groups"].split(",") if group}
|
||||
mod_name = str(item["mod_name"]) if item["mod_name"] else None
|
||||
items.append(ItemData(id, item["name"], classification, mod_name, groups))
|
||||
return items
|
||||
|
||||
|
||||
events = [
|
||||
ItemData(None, e, ItemClassification.progression)
|
||||
for e in sorted(all_events)
|
||||
]
|
||||
|
||||
all_items: List[ItemData] = load_item_csv() + events
|
||||
item_table: Dict[str, ItemData] = {}
|
||||
items_by_group: Dict[Group, List[ItemData]] = {}
|
||||
|
||||
|
||||
def initialize_groups():
|
||||
for item in all_items:
|
||||
for group in item.groups:
|
||||
item_group = items_by_group.get(group, list())
|
||||
item_group.append(item)
|
||||
items_by_group[group] = item_group
|
||||
|
||||
|
||||
def initialize_item_table():
|
||||
item_table.update({item.name: item for item in all_items})
|
||||
|
||||
|
||||
initialize_item_table()
|
||||
initialize_groups()
|
||||
@@ -1,6 +1,6 @@
|
||||
from .options import StardewValleyOption, Goal, FarmType, StartingMoney, ProfitMargin, BundleRandomization, BundlePrice, EntranceRandomization, \
|
||||
SeasonRandomization, Cropsanity, BackpackProgression, ToolProgression, ElevatorProgression, SkillProgression, BuildingProgression, FestivalLocations, \
|
||||
ArcadeMachineLocations, SpecialOrderLocations, QuestLocations, Fishsanity, Museumsanity, Monstersanity, Shipsanity, Cooksanity, Chefsanity, Craftsanity, \
|
||||
Friendsanity, FriendsanityHeartSize, Booksanity, Walnutsanity, NumberOfMovementBuffs, EnabledFillerBuffs, ExcludeGingerIsland, TrapItems, \
|
||||
Friendsanity, FriendsanityHeartSize, Booksanity, Walnutsanity, NumberOfMovementBuffs, EnabledFillerBuffs, ExcludeGingerIsland, TrapDifficulty, \
|
||||
MultipleDaySleepEnabled, MultipleDaySleepCost, ExperienceMultiplier, FriendshipMultiplier, DebrisMultiplier, QuickStart, Gifting, Mods, BundlePlando, \
|
||||
StardewValleyOptions, enabled_mods, disabled_mods, all_mods
|
||||
StardewValleyOptions, enabled_mods, disabled_mods, all_mods, TrapDistribution, TrapItems, StardewValleyOptions
|
||||
|
||||
@@ -52,7 +52,8 @@ else:
|
||||
options.DebrisMultiplier,
|
||||
options.NumberOfMovementBuffs,
|
||||
options.EnabledFillerBuffs,
|
||||
options.TrapItems,
|
||||
options.TrapDifficulty,
|
||||
options.TrapDistribution,
|
||||
options.MultipleDaySleepEnabled,
|
||||
options.MultipleDaySleepCost,
|
||||
options.QuickStart,
|
||||
|
||||
@@ -3,7 +3,8 @@ import typing
|
||||
from dataclasses import dataclass
|
||||
from typing import Protocol, ClassVar
|
||||
|
||||
from Options import Range, NamedRange, Toggle, Choice, OptionSet, PerGameCommonOptions, DeathLink, OptionList, Visibility
|
||||
from Options import Range, NamedRange, Toggle, Choice, OptionSet, PerGameCommonOptions, DeathLink, OptionList, Visibility, Removed, OptionCounter
|
||||
from ..items import items_by_group, Group
|
||||
from ..mods.mod_data import ModNames
|
||||
from ..strings.ap_names.ap_option_names import BuffOptionName, WalnutsanityOptionName
|
||||
from ..strings.bundle_names import all_cc_bundle_names
|
||||
@@ -658,13 +659,29 @@ class ExcludeGingerIsland(Toggle):
|
||||
default = 0
|
||||
|
||||
|
||||
class TrapItems(Choice):
|
||||
"""When rolling filler items, including resource packs, the game can also roll trap items.
|
||||
Trap items are negative items that cause problems or annoyances for the player
|
||||
This setting is for choosing if traps will be in the item pool, and if so, how punishing they will be.
|
||||
class TrapItems(Removed):
|
||||
"""Deprecated setting, replaced by TrapDifficulty
|
||||
"""
|
||||
internal_name = "trap_items"
|
||||
display_name = "Trap Items"
|
||||
default = ""
|
||||
visibility = Visibility.none
|
||||
|
||||
def __init__(self, value: str):
|
||||
if value:
|
||||
raise Exception("Option trap_items was replaced by trap_difficulty, please update your options file")
|
||||
super().__init__(value)
|
||||
|
||||
|
||||
class TrapDifficulty(Choice):
|
||||
"""When rolling filler items, including resource packs, the game can also roll trap items.
|
||||
Trap items are negative items that cause problems or annoyances for the player.
|
||||
This setting is for choosing how punishing traps will be.
|
||||
Lower difficulties will be on the funny annoyance side, higher difficulty will be on the extreme problems side.
|
||||
Only play Nightmare at your own risk.
|
||||
"""
|
||||
internal_name = "trap_difficulty"
|
||||
display_name = "Trap Difficulty"
|
||||
default = 2
|
||||
option_no_traps = 0
|
||||
option_easy = 1
|
||||
@@ -674,6 +691,34 @@ class TrapItems(Choice):
|
||||
option_nightmare = 5
|
||||
|
||||
|
||||
trap_default_weight = 100
|
||||
|
||||
|
||||
class TrapDistribution(OptionCounter):
|
||||
"""
|
||||
Specify the weighted chance of rolling individual traps when rolling random filler items.
|
||||
The average filler item should be considered to be "100", as in 100%.
|
||||
So a trap on "200" will be twice as likely to roll as any filler item. A trap on "10" will be 10% as likely.
|
||||
You can use weight "0" to disable this trap entirely. The maximum weight is 1000, for x10 chance
|
||||
"""
|
||||
internal_name = "trap_distribution"
|
||||
display_name = "Trap Distribution"
|
||||
default_weight = trap_default_weight
|
||||
visibility = Visibility.all ^ Visibility.simple_ui
|
||||
min = 0
|
||||
max = 1000
|
||||
valid_keys = frozenset({
|
||||
trap_data.name
|
||||
for trap_data in items_by_group[Group.TRAP]
|
||||
if Group.DEPRECATED not in trap_data.groups
|
||||
})
|
||||
default = {
|
||||
trap_data.name: trap_default_weight
|
||||
for trap_data in items_by_group[Group.TRAP]
|
||||
if Group.DEPRECATED not in trap_data.groups
|
||||
}
|
||||
|
||||
|
||||
class MultipleDaySleepEnabled(Toggle):
|
||||
"""Enable the ability to sleep automatically for multiple days straight?"""
|
||||
internal_name = "multiple_day_sleep_enabled"
|
||||
@@ -851,10 +896,14 @@ class StardewValleyOptions(PerGameCommonOptions):
|
||||
debris_multiplier: DebrisMultiplier
|
||||
movement_buff_number: NumberOfMovementBuffs
|
||||
enabled_filler_buffs: EnabledFillerBuffs
|
||||
trap_items: TrapItems
|
||||
trap_difficulty: TrapDifficulty
|
||||
trap_distribution: TrapDistribution
|
||||
multiple_day_sleep_enabled: MultipleDaySleepEnabled
|
||||
multiple_day_sleep_cost: MultipleDaySleepCost
|
||||
gifting: Gifting
|
||||
mods: Mods
|
||||
bundle_plando: BundlePlando
|
||||
death_link: DeathLink
|
||||
|
||||
# removed:
|
||||
trap_items: TrapItems
|
||||
@@ -38,7 +38,7 @@ all_random_settings = {
|
||||
options.Booksanity.internal_name: "random",
|
||||
options.NumberOfMovementBuffs.internal_name: "random",
|
||||
options.ExcludeGingerIsland.internal_name: "random",
|
||||
options.TrapItems.internal_name: "random",
|
||||
options.TrapDifficulty.internal_name: "random",
|
||||
options.MultipleDaySleepEnabled.internal_name: "random",
|
||||
options.MultipleDaySleepCost.internal_name: "random",
|
||||
options.ExperienceMultiplier.internal_name: "random",
|
||||
@@ -82,7 +82,7 @@ easy_settings = {
|
||||
options.NumberOfMovementBuffs.internal_name: 8,
|
||||
options.EnabledFillerBuffs.internal_name: options.EnabledFillerBuffs.preset_all,
|
||||
options.ExcludeGingerIsland.internal_name: options.ExcludeGingerIsland.option_true,
|
||||
options.TrapItems.internal_name: options.TrapItems.option_easy,
|
||||
options.TrapDifficulty.internal_name: options.TrapDifficulty.option_easy,
|
||||
options.MultipleDaySleepEnabled.internal_name: options.MultipleDaySleepEnabled.option_true,
|
||||
options.MultipleDaySleepCost.internal_name: "free",
|
||||
options.ExperienceMultiplier.internal_name: "triple",
|
||||
@@ -126,7 +126,7 @@ medium_settings = {
|
||||
options.NumberOfMovementBuffs.internal_name: 6,
|
||||
options.EnabledFillerBuffs.internal_name: options.EnabledFillerBuffs.preset_all,
|
||||
options.ExcludeGingerIsland.internal_name: options.ExcludeGingerIsland.option_true,
|
||||
options.TrapItems.internal_name: options.TrapItems.option_medium,
|
||||
options.TrapDifficulty.internal_name: options.TrapDifficulty.option_medium,
|
||||
options.MultipleDaySleepEnabled.internal_name: options.MultipleDaySleepEnabled.option_true,
|
||||
options.MultipleDaySleepCost.internal_name: "free",
|
||||
options.ExperienceMultiplier.internal_name: "double",
|
||||
@@ -170,7 +170,7 @@ hard_settings = {
|
||||
options.NumberOfMovementBuffs.internal_name: 4,
|
||||
options.EnabledFillerBuffs.internal_name: options.EnabledFillerBuffs.default,
|
||||
options.ExcludeGingerIsland.internal_name: options.ExcludeGingerIsland.option_false,
|
||||
options.TrapItems.internal_name: options.TrapItems.option_hard,
|
||||
options.TrapDifficulty.internal_name: options.TrapDifficulty.option_hard,
|
||||
options.MultipleDaySleepEnabled.internal_name: options.MultipleDaySleepEnabled.option_true,
|
||||
options.MultipleDaySleepCost.internal_name: "cheap",
|
||||
options.ExperienceMultiplier.internal_name: "vanilla",
|
||||
@@ -214,7 +214,7 @@ nightmare_settings = {
|
||||
options.NumberOfMovementBuffs.internal_name: 2,
|
||||
options.EnabledFillerBuffs.internal_name: options.EnabledFillerBuffs.preset_none,
|
||||
options.ExcludeGingerIsland.internal_name: options.ExcludeGingerIsland.option_false,
|
||||
options.TrapItems.internal_name: options.TrapItems.option_hell,
|
||||
options.TrapDifficulty.internal_name: options.TrapDifficulty.option_hell,
|
||||
options.MultipleDaySleepEnabled.internal_name: options.MultipleDaySleepEnabled.option_true,
|
||||
options.MultipleDaySleepCost.internal_name: "expensive",
|
||||
options.ExperienceMultiplier.internal_name: "half",
|
||||
@@ -258,7 +258,7 @@ short_settings = {
|
||||
options.NumberOfMovementBuffs.internal_name: 10,
|
||||
options.EnabledFillerBuffs.internal_name: options.EnabledFillerBuffs.preset_all,
|
||||
options.ExcludeGingerIsland.internal_name: options.ExcludeGingerIsland.option_true,
|
||||
options.TrapItems.internal_name: options.TrapItems.option_easy,
|
||||
options.TrapDifficulty.internal_name: options.TrapDifficulty.option_easy,
|
||||
options.MultipleDaySleepEnabled.internal_name: options.MultipleDaySleepEnabled.option_true,
|
||||
options.MultipleDaySleepCost.internal_name: "free",
|
||||
options.ExperienceMultiplier.internal_name: "quadruple",
|
||||
@@ -302,7 +302,7 @@ minsanity_settings = {
|
||||
options.NumberOfMovementBuffs.internal_name: options.NumberOfMovementBuffs.default,
|
||||
options.EnabledFillerBuffs.internal_name: options.EnabledFillerBuffs.default,
|
||||
options.ExcludeGingerIsland.internal_name: options.ExcludeGingerIsland.option_true,
|
||||
options.TrapItems.internal_name: options.TrapItems.default,
|
||||
options.TrapDifficulty.internal_name: options.TrapDifficulty.default,
|
||||
options.MultipleDaySleepEnabled.internal_name: options.MultipleDaySleepEnabled.default,
|
||||
options.MultipleDaySleepCost.internal_name: options.MultipleDaySleepCost.default,
|
||||
options.ExperienceMultiplier.internal_name: options.ExperienceMultiplier.default,
|
||||
@@ -346,7 +346,7 @@ allsanity_settings = {
|
||||
options.NumberOfMovementBuffs.internal_name: 12,
|
||||
options.EnabledFillerBuffs.internal_name: options.EnabledFillerBuffs.preset_all,
|
||||
options.ExcludeGingerIsland.internal_name: options.ExcludeGingerIsland.option_false,
|
||||
options.TrapItems.internal_name: options.TrapItems.default,
|
||||
options.TrapDifficulty.internal_name: options.TrapDifficulty.default,
|
||||
options.MultipleDaySleepEnabled.internal_name: options.MultipleDaySleepEnabled.default,
|
||||
options.MultipleDaySleepCost.internal_name: options.MultipleDaySleepCost.default,
|
||||
options.ExperienceMultiplier.internal_name: options.ExperienceMultiplier.default,
|
||||
|
||||
@@ -864,7 +864,7 @@ def set_friendsanity_rules(logic: StardewLogic, multiworld: MultiWorld, player:
|
||||
if not content.features.friendsanity.is_enabled:
|
||||
return
|
||||
set_rule(multiworld.get_location("Spouse Stardrop", player),
|
||||
logic.relationship.has_hearts_with_any_bachelor(13))
|
||||
logic.relationship.has_hearts_with_any_bachelor(13) & logic.relationship.can_get_married())
|
||||
set_rule(multiworld.get_location("Have a Baby", player),
|
||||
logic.relationship.can_reproduce(1))
|
||||
set_rule(multiworld.get_location("Have Another Baby", player),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from . import SVTestBase
|
||||
from .bases import SVTestBase
|
||||
from .. import options
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from . import SVTestBase
|
||||
from .bases import SVTestBase
|
||||
from ..options import ExcludeGingerIsland, Booksanity, Shipsanity
|
||||
from ..strings.book_names import Book, LostBook
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import unittest
|
||||
|
||||
from . import SVTestBase
|
||||
from .bases import SVTestBase
|
||||
from .. import BundleRandomization
|
||||
from ..data.bundle_data import all_bundle_items_except_money, quality_crops_items_thematic, quality_foraging_items, quality_fish_items
|
||||
from ..options import BundlePlando
|
||||
@@ -87,4 +87,3 @@ class TestRemixedAnywhereBundles(SVTestBase):
|
||||
for bundle_name in self.fish_bundle_names:
|
||||
with self.subTest(f"{bundle_name}"):
|
||||
self.assertIn(bundle_name, location_names)
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from . import SVTestBase
|
||||
from .bases import SVTestBase
|
||||
from .. import options
|
||||
from ..strings.ap_names.transport_names import Transportation
|
||||
from ..strings.building_names import Building
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from typing import List, Tuple
|
||||
|
||||
from . import SVTestBase
|
||||
from .assertion import WorldAssertMixin
|
||||
from .bases import SVTestBase
|
||||
from .. import options, StardewItem
|
||||
from ..strings.ap_names.ap_weapon_names import APWeapon
|
||||
from ..strings.ap_names.transport_names import Transportation
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from collections import Counter
|
||||
|
||||
from . import SVTestBase
|
||||
from .assertion import WorldAssertMixin
|
||||
from .bases import SVTestBase
|
||||
from .. import options
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ class TestStartInventoryStandardFarm(WorldAssertMixin, SVTestBase):
|
||||
def test_start_inventory_progressive_coops(self):
|
||||
start_items = Counter((i.name for i in self.multiworld.precollected_items[self.player]))
|
||||
items = Counter((i.name for i in self.multiworld.itempool))
|
||||
|
||||
|
||||
self.assertIn("Progressive Coop", items)
|
||||
self.assertEqual(items["Progressive Coop"], 3)
|
||||
self.assertNotIn("Progressive Coop", start_items)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from . import SVTestBase
|
||||
from .assertion import WorldAssertMixin
|
||||
from .bases import SVTestBase
|
||||
from .options.presets import minimal_locations_maximal_items
|
||||
from .. import options
|
||||
from ..mods.mod_data import ModNames
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import unittest
|
||||
from typing import ClassVar, Set
|
||||
|
||||
from . import SVTestBase
|
||||
from .assertion import WorldAssertMixin
|
||||
from .bases import SVTestBase
|
||||
from ..content.feature import fishsanity
|
||||
from ..mods.mod_data import ModNames
|
||||
from ..options import Fishsanity, ExcludeGingerIsland, Mods, SpecialOrderLocations, Goal, QuestLocations
|
||||
|
||||
@@ -2,7 +2,7 @@ import unittest
|
||||
from collections import Counter
|
||||
from typing import ClassVar, Set
|
||||
|
||||
from . import SVTestBase
|
||||
from .bases import SVTestBase
|
||||
from ..content.feature import friendsanity
|
||||
from ..options import Friendsanity, FriendsanityHeartSize
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
from typing import List
|
||||
|
||||
from BaseClasses import ItemClassification, Item
|
||||
from . import SVTestBase
|
||||
from .. import items, location_table, options
|
||||
from ..items import Group, ItemData
|
||||
from .bases import SVTestBase
|
||||
from .. import location_table, options, items
|
||||
from ..items import Group, ItemData, item_data
|
||||
from ..locations import LocationTags
|
||||
from ..options import Friendsanity, SpecialOrderLocations, Shipsanity, Chefsanity, SeasonRandomization, Craftsanity, ExcludeGingerIsland, SkillProgression, \
|
||||
Booksanity, Walnutsanity
|
||||
@@ -15,10 +15,10 @@ def get_all_permanent_progression_items() -> List[ItemData]:
|
||||
"""
|
||||
return [
|
||||
item
|
||||
for item in items.all_items
|
||||
for item in item_data.all_items
|
||||
if ItemClassification.progression in item.classification
|
||||
if item.mod_name is None
|
||||
if item.name not in {event.name for event in items.events}
|
||||
if item.name not in {event.name for event in item_data.events}
|
||||
if item.name not in {deprecated.name for deprecated in items.items_by_group[Group.DEPRECATED]}
|
||||
if item.name not in {season.name for season in items.items_by_group[Group.SEASON]}
|
||||
if item.name not in {weapon.name for weapon in items.items_by_group[Group.WEAPON]}
|
||||
@@ -54,19 +54,19 @@ class TestBaseItemGeneration(SVTestBase):
|
||||
|
||||
def test_does_not_create_deprecated_items(self):
|
||||
all_created_items = set(self.get_all_created_items())
|
||||
for deprecated_item in items.items_by_group[items.Group.DEPRECATED]:
|
||||
for deprecated_item in item_data.items_by_group[item_data.Group.DEPRECATED]:
|
||||
with self.subTest(f"{deprecated_item.name}"):
|
||||
self.assertNotIn(deprecated_item.name, all_created_items)
|
||||
|
||||
def test_does_not_create_more_than_one_maximum_one_items(self):
|
||||
all_created_items = self.get_all_created_items()
|
||||
for maximum_one_item in items.items_by_group[items.Group.MAXIMUM_ONE]:
|
||||
for maximum_one_item in item_data.items_by_group[item_data.Group.MAXIMUM_ONE]:
|
||||
with self.subTest(f"{maximum_one_item.name}"):
|
||||
self.assertLessEqual(all_created_items.count(maximum_one_item.name), 1)
|
||||
|
||||
def test_does_not_create_or_create_two_of_exactly_two_items(self):
|
||||
all_created_items = self.get_all_created_items()
|
||||
for exactly_two_item in items.items_by_group[items.Group.AT_LEAST_TWO]:
|
||||
for exactly_two_item in item_data.items_by_group[item_data.Group.AT_LEAST_TWO]:
|
||||
with self.subTest(f"{exactly_two_item.name}"):
|
||||
count = all_created_items.count(exactly_two_item.name)
|
||||
self.assertTrue(count == 0 or count == 2)
|
||||
@@ -102,19 +102,19 @@ class TestNoGingerIslandItemGeneration(SVTestBase):
|
||||
|
||||
def test_does_not_create_deprecated_items(self):
|
||||
all_created_items = self.get_all_created_items()
|
||||
for deprecated_item in items.items_by_group[items.Group.DEPRECATED]:
|
||||
for deprecated_item in item_data.items_by_group[item_data.Group.DEPRECATED]:
|
||||
with self.subTest(f"Deprecated item: {deprecated_item.name}"):
|
||||
self.assertNotIn(deprecated_item.name, all_created_items)
|
||||
|
||||
def test_does_not_create_more_than_one_maximum_one_items(self):
|
||||
all_created_items = self.get_all_created_items()
|
||||
for maximum_one_item in items.items_by_group[items.Group.MAXIMUM_ONE]:
|
||||
for maximum_one_item in item_data.items_by_group[item_data.Group.MAXIMUM_ONE]:
|
||||
with self.subTest(f"{maximum_one_item.name}"):
|
||||
self.assertLessEqual(all_created_items.count(maximum_one_item.name), 1)
|
||||
|
||||
def test_does_not_create_exactly_two_items(self):
|
||||
all_created_items = self.get_all_created_items()
|
||||
for exactly_two_item in items.items_by_group[items.Group.AT_LEAST_TWO]:
|
||||
for exactly_two_item in item_data.items_by_group[item_data.Group.AT_LEAST_TWO]:
|
||||
with self.subTest(f"{exactly_two_item.name}"):
|
||||
count = all_created_items.count(exactly_two_item.name)
|
||||
self.assertTrue(count == 0 or count == 2)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from . import SVTestBase
|
||||
from .bases import SVTestBase
|
||||
from .. import options, item_table, Group
|
||||
|
||||
max_iterations = 2000
|
||||
@@ -6,7 +6,7 @@ max_iterations = 2000
|
||||
|
||||
class TestItemLinksEverythingIncluded(SVTestBase):
|
||||
options = {options.ExcludeGingerIsland.internal_name: options.ExcludeGingerIsland.option_false,
|
||||
options.TrapItems.internal_name: options.TrapItems.option_medium}
|
||||
options.TrapDifficulty.internal_name: options.TrapDifficulty.option_medium}
|
||||
|
||||
def test_filler_of_all_types_generated(self):
|
||||
max_number_filler = 114
|
||||
@@ -33,7 +33,7 @@ class TestItemLinksEverythingIncluded(SVTestBase):
|
||||
|
||||
class TestItemLinksNoIsland(SVTestBase):
|
||||
options = {options.ExcludeGingerIsland.internal_name: options.ExcludeGingerIsland.option_true,
|
||||
options.TrapItems.internal_name: options.TrapItems.option_medium}
|
||||
options.TrapDifficulty.internal_name: options.TrapDifficulty.option_medium}
|
||||
|
||||
def test_filler_has_no_island_but_has_traps(self):
|
||||
max_number_filler = 109
|
||||
@@ -57,7 +57,7 @@ class TestItemLinksNoIsland(SVTestBase):
|
||||
|
||||
class TestItemLinksNoTraps(SVTestBase):
|
||||
options = {options.ExcludeGingerIsland.internal_name: options.ExcludeGingerIsland.option_false,
|
||||
options.TrapItems.internal_name: options.TrapItems.option_no_traps}
|
||||
options.TrapDifficulty.internal_name: options.TrapDifficulty.option_no_traps}
|
||||
|
||||
def test_filler_has_no_traps_but_has_island(self):
|
||||
max_number_filler = 99
|
||||
@@ -81,7 +81,7 @@ class TestItemLinksNoTraps(SVTestBase):
|
||||
|
||||
class TestItemLinksNoTrapsAndIsland(SVTestBase):
|
||||
options = {options.ExcludeGingerIsland.internal_name: options.ExcludeGingerIsland.option_true,
|
||||
options.TrapItems.internal_name: options.TrapItems.option_no_traps}
|
||||
options.TrapDifficulty.internal_name: options.TrapDifficulty.option_no_traps}
|
||||
|
||||
def test_filler_generated_without_island_or_traps(self):
|
||||
max_number_filler = 94
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from BaseClasses import MultiWorld, get_seed, ItemClassification
|
||||
from . import setup_solo_multiworld, SVTestCase, solo_multiworld
|
||||
from .bases import SVTestCase, solo_multiworld, setup_solo_multiworld
|
||||
from .options.presets import allsanity_no_mods_6_x_x, get_minsanity_options
|
||||
from .. import StardewValleyWorld
|
||||
from ..items import Group, item_table
|
||||
|
||||
@@ -3,8 +3,8 @@ import unittest
|
||||
from unittest import TestCase, SkipTest
|
||||
|
||||
from BaseClasses import MultiWorld
|
||||
from . import setup_solo_multiworld
|
||||
from .assertion import RuleAssertMixin
|
||||
from .bases import setup_solo_multiworld
|
||||
from .options.presets import allsanity_mods_6_x_x, minimal_locations_maximal_items
|
||||
from .. import StardewValleyWorld
|
||||
from ..data.bundle_data import all_bundle_items_except_money
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from . import SVTestCase, setup_multiworld
|
||||
from .bases import SVTestCase, setup_multiworld
|
||||
from .. import True_
|
||||
from ..options import FestivalLocations, StartingMoney
|
||||
from ..strings.festival_check_names import FestivalCheck
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from . import SVTestBase
|
||||
from .bases import SVTestBase
|
||||
from .options.presets import default_6_x_x, allsanity_no_mods_6_x_x, allsanity_mods_6_x_x_exclude_disabled, get_minsanity_options, \
|
||||
minimal_locations_maximal_items, minimal_locations_maximal_items_with_island
|
||||
from .. import location_table
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from . import SVTestBase
|
||||
from .bases import SVTestBase
|
||||
from .. import BuildingProgression
|
||||
from ..options import ToolProgression
|
||||
|
||||
|
||||
@@ -3,13 +3,13 @@ from typing import ClassVar
|
||||
|
||||
from BaseClasses import ItemClassification
|
||||
from test.param import classvar_matrix
|
||||
from . import SVTestCase, solo_multiworld, SVTestBase
|
||||
from .assertion import WorldAssertMixin
|
||||
from .bases import SVTestCase, SVTestBase, solo_multiworld
|
||||
from .options.option_names import all_option_choices
|
||||
from .options.presets import allsanity_no_mods_6_x_x, allsanity_mods_6_x_x
|
||||
from .. import items_by_group, Group
|
||||
from ..locations import locations_by_tag, LocationTags, location_table
|
||||
from ..options import ExcludeGingerIsland, ToolProgression, Goal, SeasonRandomization, TrapItems, SpecialOrderLocations, ArcadeMachineLocations
|
||||
from ..options import ExcludeGingerIsland, ToolProgression, Goal, SeasonRandomization, TrapDifficulty, SpecialOrderLocations, ArcadeMachineLocations
|
||||
from ..strings.goal_names import Goal as GoalName
|
||||
from ..strings.season_names import Season
|
||||
from ..strings.special_order_names import SpecialOrder
|
||||
@@ -126,7 +126,7 @@ class TestGenerateAllOptionsWithExcludeGingerIsland(WorldAssertMixin, SVTestCase
|
||||
class TestTraps(SVTestCase):
|
||||
def test_given_no_traps_when_generate_then_no_trap_in_pool(self):
|
||||
world_options = allsanity_no_mods_6_x_x().copy()
|
||||
world_options[TrapItems.internal_name] = TrapItems.option_no_traps
|
||||
world_options[TrapDifficulty.internal_name] = TrapDifficulty.option_no_traps
|
||||
with solo_multiworld(world_options) as (multi_world, _):
|
||||
trap_items = [item_data.name for item_data in items_by_group[Group.TRAP]]
|
||||
multiworld_items = [item.name for item in multi_world.get_items()]
|
||||
@@ -136,12 +136,12 @@ class TestTraps(SVTestCase):
|
||||
self.assertNotIn(item, multiworld_items)
|
||||
|
||||
def test_given_traps_when_generate_then_all_traps_in_pool(self):
|
||||
trap_option = TrapItems
|
||||
trap_option = TrapDifficulty
|
||||
for value in trap_option.options:
|
||||
if value == "no_traps":
|
||||
continue
|
||||
world_options = allsanity_mods_6_x_x()
|
||||
world_options.update({TrapItems.internal_name: trap_option.options[value]})
|
||||
world_options.update({TrapDifficulty.internal_name: trap_option.options[value]})
|
||||
with solo_multiworld(world_options) as (multi_world, _):
|
||||
trap_items = [item_data.name for item_data in items_by_group[Group.TRAP] if
|
||||
Group.DEPRECATED not in item_data.groups and item_data.mod_name is None]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from . import SVTestBase
|
||||
from .assertion import WorldAssertMixin
|
||||
from .bases import SVTestBase
|
||||
from .. import options
|
||||
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ from typing import ClassVar
|
||||
|
||||
from BaseClasses import MultiWorld, get_seed
|
||||
from test.param import classvar_matrix
|
||||
from . import SVTestCase, skip_long_tests, solo_multiworld
|
||||
from .assertion import GoalAssertMixin, OptionAssertMixin, WorldAssertMixin
|
||||
from .bases import skip_long_tests, SVTestCase, solo_multiworld
|
||||
from .options.option_names import generate_random_world_options
|
||||
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import unittest
|
||||
from typing import Set
|
||||
|
||||
from BaseClasses import get_seed
|
||||
from . import SVTestCase
|
||||
from .bases import SVTestCase
|
||||
from .options.utils import fill_dataclass_with_default
|
||||
from .. import create_content
|
||||
from ..options import EntranceRandomization, ExcludeGingerIsland, SkillProgression
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from . import SVTestBase
|
||||
from .assertion import WorldAssertMixin
|
||||
from .bases import SVTestBase
|
||||
from .. import options
|
||||
|
||||
|
||||
|
||||
121
worlds/stardew_valley/test/TestTraps.py
Normal file
121
worlds/stardew_valley/test/TestTraps.py
Normal file
@@ -0,0 +1,121 @@
|
||||
import unittest
|
||||
|
||||
from .assertion import WorldAssertMixin
|
||||
from .bases import SVTestBase
|
||||
from .. import options, items_by_group, Group
|
||||
from ..options import TrapDistribution
|
||||
|
||||
default_distribution = {trap.name: TrapDistribution.default_weight for trap in items_by_group[Group.TRAP] if Group.DEPRECATED not in trap.groups}
|
||||
threshold_difference = 2
|
||||
threshold_ballpark = 3
|
||||
|
||||
|
||||
class TestTrapDifficultyCanRemoveAllTraps(WorldAssertMixin, SVTestBase):
|
||||
options = {
|
||||
options.QuestLocations.internal_name: 56,
|
||||
options.Fishsanity.internal_name: options.Fishsanity.option_all,
|
||||
options.Museumsanity.internal_name: options.Museumsanity.option_all,
|
||||
options.SpecialOrderLocations.internal_name: options.SpecialOrderLocations.option_board_qi,
|
||||
options.Shipsanity.internal_name: options.Shipsanity.option_everything,
|
||||
options.Cooksanity.internal_name: options.Cooksanity.option_all,
|
||||
options.Craftsanity.internal_name: options.Craftsanity.option_all,
|
||||
options.Mods.internal_name: frozenset(options.Mods.valid_keys),
|
||||
options.TrapDifficulty.internal_name: options.TrapDifficulty.option_no_traps,
|
||||
}
|
||||
|
||||
def test_no_traps_in_item_pool(self):
|
||||
items = self.multiworld.get_items()
|
||||
item_names = set(item.name for item in items)
|
||||
for trap in items_by_group[Group.TRAP]:
|
||||
if Group.DEPRECATED in trap.groups:
|
||||
continue
|
||||
self.assertNotIn(trap.name, item_names)
|
||||
|
||||
|
||||
class TestDefaultDistributionHasAllTraps(WorldAssertMixin, SVTestBase):
|
||||
options = {
|
||||
options.QuestLocations.internal_name: 56,
|
||||
options.Fishsanity.internal_name: options.Fishsanity.option_all,
|
||||
options.Museumsanity.internal_name: options.Museumsanity.option_all,
|
||||
options.SpecialOrderLocations.internal_name: options.SpecialOrderLocations.option_board_qi,
|
||||
options.Shipsanity.internal_name: options.Shipsanity.option_everything,
|
||||
options.Cooksanity.internal_name: options.Cooksanity.option_all,
|
||||
options.Craftsanity.internal_name: options.Craftsanity.option_all,
|
||||
options.Mods.internal_name: frozenset(options.Mods.valid_keys),
|
||||
options.TrapDifficulty.internal_name: options.TrapDifficulty.option_medium,
|
||||
}
|
||||
|
||||
def test_all_traps_in_item_pool(self):
|
||||
items = self.multiworld.get_items()
|
||||
item_names = set(item.name for item in items)
|
||||
for trap in items_by_group[Group.TRAP]:
|
||||
if Group.DEPRECATED in trap.groups:
|
||||
continue
|
||||
self.assertIn(trap.name, item_names)
|
||||
|
||||
|
||||
class TestDistributionIsRespectedAllTraps(WorldAssertMixin, SVTestBase):
|
||||
options = {
|
||||
options.QuestLocations.internal_name: 56,
|
||||
options.Fishsanity.internal_name: options.Fishsanity.option_all,
|
||||
options.Museumsanity.internal_name: options.Museumsanity.option_all,
|
||||
options.SpecialOrderLocations.internal_name: options.SpecialOrderLocations.option_board_qi,
|
||||
options.Shipsanity.internal_name: options.Shipsanity.option_everything,
|
||||
options.Cooksanity.internal_name: options.Cooksanity.option_all,
|
||||
options.Craftsanity.internal_name: options.Craftsanity.option_all,
|
||||
options.Mods.internal_name: frozenset(options.Mods.valid_keys),
|
||||
options.TrapDifficulty.internal_name: options.TrapDifficulty.option_medium,
|
||||
options.TrapDistribution.internal_name: default_distribution | {"Nudge Trap": 100, "Bark Trap": 1, "Meow Trap": 1000, "Shuffle Trap": 0}
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
super().setUpClass()
|
||||
if cls.skip_long_tests:
|
||||
raise unittest.SkipTest("Unstable tests disabled to not annoy anyone else when it rarely fails")
|
||||
|
||||
def test_about_as_many_nudges_as_other_filler(self):
|
||||
items = self.multiworld.get_items()
|
||||
item_names = [item.name for item in items]
|
||||
num_nudge = len([item for item in item_names if item == "Nudge Trap"])
|
||||
other_fillers = ["Resource Pack: 4 Frozen Geode", "Resource Pack: 50 Wood", "Resource Pack: 5 Warp Totem: Farm",
|
||||
"Resource Pack: 500 Money", "Resource Pack: 75 Copper Ore", "Resource Pack: 30 Speed-Gro"]
|
||||
at_least_one_in_ballpark = False
|
||||
for filler_item in other_fillers:
|
||||
num_filler = len([item for item in item_names if item == filler_item])
|
||||
diff_num = abs(num_filler - num_nudge)
|
||||
is_in_ballpark = diff_num <= threshold_ballpark
|
||||
at_least_one_in_ballpark = at_least_one_in_ballpark or is_in_ballpark
|
||||
self.assertTrue(at_least_one_in_ballpark)
|
||||
|
||||
def test_fewer_barks_than_nudges_in_item_pool(self):
|
||||
items = self.multiworld.get_items()
|
||||
item_names = [item.name for item in items]
|
||||
num_nudge = len([item for item in item_names if item == "Nudge Trap"])
|
||||
num_bark = len([item for item in item_names if item == "Bark Trap"])
|
||||
self.assertLess(num_bark, num_nudge - threshold_difference)
|
||||
|
||||
def test_more_meows_than_nudges_in_item_pool(self):
|
||||
items = self.multiworld.get_items()
|
||||
item_names = [item.name for item in items]
|
||||
num_nudge = len([item for item in item_names if item == "Nudge Trap"])
|
||||
num_meow = len([item for item in item_names if item == "Meow Trap"])
|
||||
self.assertGreater(num_meow, num_nudge + threshold_difference)
|
||||
|
||||
def test_no_shuffles_in_item_pool(self):
|
||||
items = self.multiworld.get_items()
|
||||
item_names = [item.name for item in items]
|
||||
num_shuffle = len([item for item in item_names if item == "Shuffle Trap"])
|
||||
self.assertEqual(0, num_shuffle)
|
||||
|
||||
def test_omitted_item_same_as_nudge_in_item_pool(self):
|
||||
items = self.multiworld.get_items()
|
||||
item_names = [item.name for item in items]
|
||||
num_time_flies = len([item for item in item_names if item == "Time Flies Trap"])
|
||||
num_debris = len([item for item in item_names if item == "Debris Trap"])
|
||||
num_bark = len([item for item in item_names if item == "Bark Trap"])
|
||||
num_meow = len([item for item in item_names if item == "Meow Trap"])
|
||||
self.assertLess(num_bark, num_time_flies - threshold_difference)
|
||||
self.assertLess(num_bark, num_debris - threshold_difference)
|
||||
self.assertGreater(num_meow, num_time_flies + threshold_difference)
|
||||
self.assertGreater(num_meow, num_debris + threshold_difference)
|
||||
@@ -1,4 +1,4 @@
|
||||
from . import SVTestBase
|
||||
from .bases import SVTestBase
|
||||
from ..options import ExcludeGingerIsland, Walnutsanity, ToolProgression, SkillProgression
|
||||
from ..strings.ap_names.ap_option_names import WalnutsanityOptionName
|
||||
|
||||
|
||||
@@ -1,287 +0,0 @@
|
||||
import itertools
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import unittest
|
||||
from contextlib import contextmanager
|
||||
from typing import Dict, ClassVar, Iterable, Tuple, Optional, List, Union, Any
|
||||
|
||||
from BaseClasses import MultiWorld, CollectionState, get_seed, Location, Item
|
||||
from test.bases import WorldTestBase
|
||||
from test.general import gen_steps, setup_solo_multiworld as setup_base_solo_multiworld
|
||||
from worlds.AutoWorld import call_all
|
||||
from .assertion import RuleAssertMixin
|
||||
from .options.utils import fill_namespace_with_default, parse_class_option_keys, fill_dataclass_with_default
|
||||
from .. import StardewValleyWorld, StardewItem, StardewRule
|
||||
from ..logic.time_logic import MONTH_COEFFICIENT
|
||||
from ..options import StardewValleyOption
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_TEST_SEED = get_seed()
|
||||
logger.info(f"Default Test Seed: {DEFAULT_TEST_SEED}")
|
||||
|
||||
|
||||
def skip_default_tests() -> bool:
|
||||
return not bool(os.environ.get("base", False))
|
||||
|
||||
|
||||
def skip_long_tests() -> bool:
|
||||
return not bool(os.environ.get("long", False))
|
||||
|
||||
|
||||
class SVTestCase(unittest.TestCase):
|
||||
skip_default_tests: bool = skip_default_tests()
|
||||
"""Set False to not skip the base fill tests"""
|
||||
skip_long_tests: bool = skip_long_tests()
|
||||
"""Set False to run tests that take long"""
|
||||
|
||||
@contextmanager
|
||||
def solo_world_sub_test(self, msg: Optional[str] = None,
|
||||
/,
|
||||
world_options: Optional[Dict[Union[str, StardewValleyOption], Any]] = None,
|
||||
*,
|
||||
seed=DEFAULT_TEST_SEED,
|
||||
world_caching=True,
|
||||
**kwargs) -> Tuple[MultiWorld, StardewValleyWorld]:
|
||||
if msg is not None:
|
||||
msg += " "
|
||||
else:
|
||||
msg = ""
|
||||
msg += f"[Seed = {seed}]"
|
||||
|
||||
with self.subTest(msg, **kwargs):
|
||||
with solo_multiworld(world_options, seed=seed, world_caching=world_caching) as (multiworld, world):
|
||||
yield multiworld, world
|
||||
|
||||
|
||||
class SVTestBase(RuleAssertMixin, WorldTestBase, SVTestCase):
|
||||
game = "Stardew Valley"
|
||||
world: StardewValleyWorld
|
||||
player: ClassVar[int] = 1
|
||||
|
||||
seed = DEFAULT_TEST_SEED
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
if cls is SVTestBase:
|
||||
raise unittest.SkipTest("No running tests on SVTestBase import.")
|
||||
|
||||
super().setUpClass()
|
||||
|
||||
def world_setup(self, *args, **kwargs):
|
||||
self.options = parse_class_option_keys(self.options)
|
||||
|
||||
self.multiworld = setup_solo_multiworld(self.options, seed=self.seed)
|
||||
self.multiworld.lock.acquire()
|
||||
world = self.multiworld.worlds[self.player]
|
||||
|
||||
self.original_state = self.multiworld.state.copy()
|
||||
self.original_itempool = self.multiworld.itempool.copy()
|
||||
self.unfilled_locations = self.multiworld.get_unfilled_locations(1)
|
||||
if self.constructed:
|
||||
self.world = world # noqa
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.multiworld.state = self.original_state
|
||||
self.multiworld.itempool = self.original_itempool
|
||||
for location in self.unfilled_locations:
|
||||
location.item = None
|
||||
|
||||
self.multiworld.lock.release()
|
||||
|
||||
@property
|
||||
def run_default_tests(self) -> bool:
|
||||
if self.skip_default_tests:
|
||||
return False
|
||||
return super().run_default_tests
|
||||
|
||||
def collect_months(self, months: int) -> None:
|
||||
real_total_prog_items = self.world.total_progression_items
|
||||
percent = months * MONTH_COEFFICIENT
|
||||
self.collect("Stardrop", real_total_prog_items * 100 // percent)
|
||||
self.world.total_progression_items = real_total_prog_items
|
||||
|
||||
def collect_lots_of_money(self, percent: float = 0.25):
|
||||
self.collect("Shipping Bin")
|
||||
real_total_prog_items = self.world.total_progression_items
|
||||
required_prog_items = int(round(real_total_prog_items * percent))
|
||||
self.collect("Stardrop", required_prog_items)
|
||||
|
||||
def collect_all_the_money(self):
|
||||
self.collect_lots_of_money(0.95)
|
||||
|
||||
def collect_everything(self):
|
||||
non_event_items = [item for item in self.multiworld.get_items() if item.code]
|
||||
for item in non_event_items:
|
||||
self.multiworld.state.collect(item)
|
||||
|
||||
def collect_all_except(self, item_to_not_collect: str):
|
||||
non_event_items = [item for item in self.multiworld.get_items() if item.code]
|
||||
for item in non_event_items:
|
||||
if item.name != item_to_not_collect:
|
||||
self.multiworld.state.collect(item)
|
||||
|
||||
def get_real_locations(self) -> List[Location]:
|
||||
return [location for location in self.multiworld.get_locations(self.player) if location.address is not None]
|
||||
|
||||
def get_real_location_names(self) -> List[str]:
|
||||
return [location.name for location in self.get_real_locations()]
|
||||
|
||||
def collect(self, item: Union[str, Item, Iterable[Item]], count: int = 1) -> Union[None, Item, List[Item]]:
|
||||
assert count > 0
|
||||
|
||||
if not isinstance(item, str):
|
||||
super().collect(item)
|
||||
return
|
||||
|
||||
if count == 1:
|
||||
item = self.create_item(item)
|
||||
self.multiworld.state.collect(item)
|
||||
return item
|
||||
|
||||
items = []
|
||||
for i in range(count):
|
||||
item = self.create_item(item)
|
||||
self.multiworld.state.collect(item)
|
||||
items.append(item)
|
||||
|
||||
return items
|
||||
|
||||
def create_item(self, item: str) -> StardewItem:
|
||||
return self.world.create_item(item)
|
||||
|
||||
def get_all_created_items(self) -> list[str]:
|
||||
return [item.name for item in itertools.chain(self.multiworld.get_items(), self.multiworld.precollected_items[self.player])]
|
||||
|
||||
def remove_one_by_name(self, item: str) -> None:
|
||||
self.remove(self.create_item(item))
|
||||
|
||||
def reset_collection_state(self) -> None:
|
||||
self.multiworld.state = self.original_state.copy()
|
||||
|
||||
def assert_rule_true(self, rule: StardewRule, state: CollectionState | None = None) -> None:
|
||||
if state is None:
|
||||
state = self.multiworld.state
|
||||
super().assert_rule_true(rule, state)
|
||||
|
||||
def assert_rule_false(self, rule: StardewRule, state: CollectionState | None = None) -> None:
|
||||
if state is None:
|
||||
state = self.multiworld.state
|
||||
super().assert_rule_false(rule, state)
|
||||
|
||||
def assert_can_reach_location(self, location: Location | str, state: CollectionState | None = None) -> None:
|
||||
if state is None:
|
||||
state = self.multiworld.state
|
||||
super().assert_can_reach_location(location, state)
|
||||
|
||||
def assert_cannot_reach_location(self, location: Location | str, state: CollectionState | None = None) -> None:
|
||||
if state is None:
|
||||
state = self.multiworld.state
|
||||
super().assert_cannot_reach_location(location, state)
|
||||
|
||||
|
||||
pre_generated_worlds = {}
|
||||
|
||||
|
||||
@contextmanager
|
||||
def solo_multiworld(world_options: Optional[Dict[Union[str, StardewValleyOption], Any]] = None,
|
||||
*,
|
||||
seed=DEFAULT_TEST_SEED,
|
||||
world_caching=True) -> Tuple[MultiWorld, StardewValleyWorld]:
|
||||
if not world_caching:
|
||||
multiworld = setup_solo_multiworld(world_options, seed, _cache={})
|
||||
yield multiworld, multiworld.worlds[1]
|
||||
else:
|
||||
multiworld = setup_solo_multiworld(world_options, seed)
|
||||
try:
|
||||
multiworld.lock.acquire()
|
||||
world = multiworld.worlds[1]
|
||||
|
||||
original_state = multiworld.state.copy()
|
||||
original_itempool = multiworld.itempool.copy()
|
||||
unfilled_locations = multiworld.get_unfilled_locations(1)
|
||||
|
||||
yield multiworld, world
|
||||
|
||||
multiworld.state = original_state
|
||||
multiworld.itempool = original_itempool
|
||||
for location in unfilled_locations:
|
||||
location.item = None
|
||||
finally:
|
||||
multiworld.lock.release()
|
||||
|
||||
|
||||
# Mostly a copy of test.general.setup_solo_multiworld, I just don't want to change the core.
|
||||
def setup_solo_multiworld(test_options: Optional[Dict[Union[str, StardewValleyOption], str]] = None,
|
||||
seed=DEFAULT_TEST_SEED,
|
||||
_cache: Dict[frozenset, MultiWorld] = {}, # noqa
|
||||
_steps=gen_steps) -> MultiWorld:
|
||||
test_options = parse_class_option_keys(test_options)
|
||||
|
||||
# Yes I reuse the worlds generated between tests, its speeds the execution by a couple seconds
|
||||
# If the simple dict caching ends up taking too much memory, we could replace it with some kind of lru cache.
|
||||
should_cache = "start_inventory" not in test_options
|
||||
if should_cache:
|
||||
frozen_options = frozenset(test_options.items()).union({("seed", seed)})
|
||||
cached_multi_world = search_world_cache(_cache, frozen_options)
|
||||
if cached_multi_world:
|
||||
print(f"Using cached solo multi world [Seed = {cached_multi_world.seed}] [Cache size = {len(_cache)}]")
|
||||
return cached_multi_world
|
||||
|
||||
multiworld = setup_base_solo_multiworld(StardewValleyWorld, (), seed=seed)
|
||||
# print(f"Seed: {multiworld.seed}") # Uncomment to print the seed for every test
|
||||
|
||||
args = fill_namespace_with_default(test_options)
|
||||
multiworld.set_options(args)
|
||||
|
||||
if "start_inventory" in test_options:
|
||||
for item, amount in test_options["start_inventory"].items():
|
||||
for _ in range(amount):
|
||||
multiworld.push_precollected(multiworld.create_item(item, 1))
|
||||
|
||||
for step in _steps:
|
||||
call_all(multiworld, step)
|
||||
|
||||
if should_cache:
|
||||
add_to_world_cache(_cache, frozen_options, multiworld) # noqa
|
||||
|
||||
# Lock is needed for multi-threading tests
|
||||
setattr(multiworld, "lock", threading.Lock())
|
||||
|
||||
return multiworld
|
||||
|
||||
|
||||
def search_world_cache(cache: Dict[frozenset, MultiWorld], frozen_options: frozenset) -> Optional[MultiWorld]:
|
||||
try:
|
||||
return cache[frozen_options]
|
||||
except KeyError:
|
||||
for cached_options, multi_world in cache.items():
|
||||
if frozen_options.issubset(cached_options):
|
||||
return multi_world
|
||||
return None
|
||||
|
||||
|
||||
def add_to_world_cache(cache: Dict[frozenset, MultiWorld], frozen_options: frozenset, multi_world: MultiWorld) -> None:
|
||||
# We could complete the key with all the default options, but that does not seem to improve performances.
|
||||
cache[frozen_options] = multi_world
|
||||
|
||||
|
||||
def setup_multiworld(test_options: Iterable[Dict[str, int]] = None, seed=None) -> MultiWorld: # noqa
|
||||
if test_options is None:
|
||||
test_options = []
|
||||
|
||||
multiworld = MultiWorld(len(test_options))
|
||||
multiworld.player_name = {}
|
||||
multiworld.set_seed(seed)
|
||||
multiworld.state = CollectionState(multiworld)
|
||||
for i in range(1, len(test_options) + 1):
|
||||
multiworld.game[i] = StardewValleyWorld.game
|
||||
multiworld.player_name.update({i: f"Tester{i}"})
|
||||
args = fill_namespace_with_default(test_options)
|
||||
multiworld.set_options(args)
|
||||
|
||||
for step in gen_steps:
|
||||
call_all(multiworld, step)
|
||||
|
||||
return multiworld
|
||||
|
||||
306
worlds/stardew_valley/test/bases.py
Normal file
306
worlds/stardew_valley/test/bases.py
Normal file
@@ -0,0 +1,306 @@
|
||||
import itertools
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import typing
|
||||
import unittest
|
||||
from contextlib import contextmanager
|
||||
from typing import Optional, Dict, Union, Any, List, Iterable
|
||||
|
||||
from BaseClasses import get_seed, MultiWorld, Location, Item, CollectionState
|
||||
from test.bases import WorldTestBase
|
||||
from test.general import gen_steps, setup_solo_multiworld as setup_base_solo_multiworld
|
||||
from worlds.AutoWorld import call_all
|
||||
from .assertion import RuleAssertMixin
|
||||
from .options.utils import parse_class_option_keys, fill_namespace_with_default
|
||||
from .. import StardewValleyWorld, StardewItem, StardewRule
|
||||
from ..logic.time_logic import MONTH_COEFFICIENT
|
||||
from ..options import StardewValleyOption, options
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
DEFAULT_TEST_SEED = get_seed()
|
||||
logger.info(f"Default Test Seed: {DEFAULT_TEST_SEED}")
|
||||
|
||||
|
||||
def skip_default_tests() -> bool:
|
||||
return not bool(os.environ.get("base", False))
|
||||
|
||||
|
||||
def skip_long_tests() -> bool:
|
||||
return not bool(os.environ.get("long", False))
|
||||
|
||||
|
||||
class SVTestCase(unittest.TestCase):
|
||||
skip_default_tests: bool = skip_default_tests()
|
||||
"""Set False to not skip the base fill tests"""
|
||||
skip_long_tests: bool = skip_long_tests()
|
||||
"""Set False to run tests that take long"""
|
||||
|
||||
@contextmanager
|
||||
def solo_world_sub_test(self, msg: str | None = None,
|
||||
/,
|
||||
world_options: dict[str | type[StardewValleyOption], Any] | None = None,
|
||||
*,
|
||||
seed=DEFAULT_TEST_SEED,
|
||||
world_caching=True,
|
||||
**kwargs) -> Iterable[tuple[MultiWorld, StardewValleyWorld]]:
|
||||
if msg is not None:
|
||||
msg += " "
|
||||
else:
|
||||
msg = ""
|
||||
msg += f"[Seed = {seed}]"
|
||||
|
||||
with self.subTest(msg, **kwargs):
|
||||
with solo_multiworld(world_options, seed=seed, world_caching=world_caching) as (multiworld, world):
|
||||
yield multiworld, world
|
||||
|
||||
|
||||
class SVTestBase(RuleAssertMixin, WorldTestBase, SVTestCase):
|
||||
game = "Stardew Valley"
|
||||
world: StardewValleyWorld
|
||||
|
||||
seed = DEFAULT_TEST_SEED
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
if cls is SVTestBase:
|
||||
raise unittest.SkipTest("No running tests on SVTestBase import.")
|
||||
|
||||
super().setUpClass()
|
||||
|
||||
def world_setup(self, *args, **kwargs):
|
||||
self.options = parse_class_option_keys(self.options)
|
||||
|
||||
self.multiworld = setup_solo_multiworld(self.options, seed=self.seed)
|
||||
self.multiworld.lock.acquire()
|
||||
world = self.multiworld.worlds[self.player]
|
||||
|
||||
self.original_state = self.multiworld.state.copy()
|
||||
self.original_itempool = self.multiworld.itempool.copy()
|
||||
self.unfilled_locations = self.multiworld.get_unfilled_locations(1)
|
||||
if self.constructed:
|
||||
self.world = world # noqa
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.multiworld.state = self.original_state
|
||||
self.multiworld.itempool = self.original_itempool
|
||||
for location in self.unfilled_locations:
|
||||
location.item = None
|
||||
|
||||
self.multiworld.lock.release()
|
||||
|
||||
@property
|
||||
def run_default_tests(self) -> bool:
|
||||
if self.skip_default_tests:
|
||||
return False
|
||||
return super().run_default_tests
|
||||
|
||||
def collect_months(self, months: int) -> None:
|
||||
real_total_prog_items = self.world.total_progression_items
|
||||
percent = months * MONTH_COEFFICIENT
|
||||
self.collect("Stardrop", real_total_prog_items * 100 // percent)
|
||||
self.world.total_progression_items = real_total_prog_items
|
||||
|
||||
def collect_lots_of_money(self, percent: float = 0.25):
|
||||
self.collect("Shipping Bin")
|
||||
real_total_prog_items = self.world.total_progression_items
|
||||
required_prog_items = int(round(real_total_prog_items * percent))
|
||||
self.collect("Stardrop", required_prog_items)
|
||||
|
||||
def collect_all_the_money(self):
|
||||
self.collect_lots_of_money(0.95)
|
||||
|
||||
def collect_everything(self):
|
||||
non_event_items = [item for item in self.multiworld.get_items() if item.code]
|
||||
for item in non_event_items:
|
||||
self.multiworld.state.collect(item)
|
||||
|
||||
def collect_all_except(self, item_to_not_collect: str):
|
||||
non_event_items = [item for item in self.multiworld.get_items() if item.code]
|
||||
for item in non_event_items:
|
||||
if item.name != item_to_not_collect:
|
||||
self.multiworld.state.collect(item)
|
||||
|
||||
def get_real_locations(self) -> List[Location]:
|
||||
return [location for location in self.multiworld.get_locations(self.player) if location.address is not None]
|
||||
|
||||
def get_real_location_names(self) -> List[str]:
|
||||
return [location.name for location in self.get_real_locations()]
|
||||
|
||||
def collect(self, item: Union[str, Item, Iterable[Item]], count: int = 1) -> Union[None, Item, List[Item]]:
|
||||
assert count > 0
|
||||
|
||||
if not isinstance(item, str):
|
||||
super().collect(item)
|
||||
return
|
||||
|
||||
if count == 1:
|
||||
item = self.create_item(item)
|
||||
self.multiworld.state.collect(item)
|
||||
return item
|
||||
|
||||
items = []
|
||||
for i in range(count):
|
||||
item = self.create_item(item)
|
||||
self.multiworld.state.collect(item)
|
||||
items.append(item)
|
||||
|
||||
return items
|
||||
|
||||
def create_item(self, item: str) -> StardewItem:
|
||||
return self.world.create_item(item)
|
||||
|
||||
def get_all_created_items(self) -> list[str]:
|
||||
return [item.name for item in itertools.chain(self.multiworld.get_items(), self.multiworld.precollected_items[self.player])]
|
||||
|
||||
def remove_one_by_name(self, item: str) -> None:
|
||||
self.remove(self.create_item(item))
|
||||
|
||||
def reset_collection_state(self) -> None:
|
||||
self.multiworld.state = self.original_state.copy()
|
||||
|
||||
def assert_rule_true(self, rule: StardewRule, state: CollectionState | None = None) -> None:
|
||||
if state is None:
|
||||
state = self.multiworld.state
|
||||
super().assert_rule_true(rule, state)
|
||||
|
||||
def assert_rule_false(self, rule: StardewRule, state: CollectionState | None = None) -> None:
|
||||
if state is None:
|
||||
state = self.multiworld.state
|
||||
super().assert_rule_false(rule, state)
|
||||
|
||||
def assert_can_reach_location(self, location: Location | str, state: CollectionState | None = None) -> None:
|
||||
if state is None:
|
||||
state = self.multiworld.state
|
||||
super().assert_can_reach_location(location, state)
|
||||
|
||||
def assert_cannot_reach_location(self, location: Location | str, state: CollectionState | None = None) -> None:
|
||||
if state is None:
|
||||
state = self.multiworld.state
|
||||
super().assert_cannot_reach_location(location, state)
|
||||
|
||||
|
||||
pre_generated_worlds = {}
|
||||
|
||||
|
||||
@contextmanager
|
||||
def solo_multiworld(world_options: dict[str | type[StardewValleyOption], Any] | None = None,
|
||||
*,
|
||||
seed=DEFAULT_TEST_SEED,
|
||||
world_caching=True) -> Iterable[tuple[MultiWorld, StardewValleyWorld]]:
|
||||
if not world_caching:
|
||||
multiworld = setup_solo_multiworld(world_options, seed, _cache={})
|
||||
yield multiworld, typing.cast(StardewValleyWorld, multiworld.worlds[1])
|
||||
else:
|
||||
multiworld = setup_solo_multiworld(world_options, seed)
|
||||
try:
|
||||
multiworld.lock.acquire()
|
||||
world = multiworld.worlds[1]
|
||||
|
||||
original_state = multiworld.state.copy()
|
||||
original_itempool = multiworld.itempool.copy()
|
||||
unfilled_locations = multiworld.get_unfilled_locations(1)
|
||||
|
||||
yield multiworld, typing.cast(StardewValleyWorld, world)
|
||||
|
||||
multiworld.state = original_state
|
||||
multiworld.itempool = original_itempool
|
||||
for location in unfilled_locations:
|
||||
location.item = None
|
||||
finally:
|
||||
multiworld.lock.release()
|
||||
|
||||
|
||||
# Mostly a copy of test.general.setup_solo_multiworld, I just don't want to change the core.
|
||||
def setup_solo_multiworld(test_options: Optional[Dict[Union[str, StardewValleyOption], str]] = None,
|
||||
seed=DEFAULT_TEST_SEED,
|
||||
_cache: Dict[frozenset, MultiWorld] = {}, # noqa
|
||||
_steps=gen_steps) -> MultiWorld:
|
||||
test_options = parse_class_option_keys(test_options)
|
||||
|
||||
# Yes I reuse the worlds generated between tests, its speeds the execution by a couple seconds
|
||||
# If the simple dict caching ends up taking too much memory, we could replace it with some kind of lru cache.
|
||||
should_cache = should_cache_world(test_options)
|
||||
if should_cache:
|
||||
frozen_options = make_hashable(test_options, seed)
|
||||
cached_multi_world = search_world_cache(_cache, frozen_options)
|
||||
if cached_multi_world:
|
||||
print(f"Using cached solo multi world [Seed = {cached_multi_world.seed}] [Cache size = {len(_cache)}]")
|
||||
return cached_multi_world
|
||||
|
||||
multiworld = setup_base_solo_multiworld(StardewValleyWorld, (), seed=seed)
|
||||
# print(f"Seed: {multiworld.seed}") # Uncomment to print the seed for every test
|
||||
|
||||
args = fill_namespace_with_default(test_options)
|
||||
multiworld.set_options(args)
|
||||
|
||||
if "start_inventory" in test_options:
|
||||
for item, amount in test_options["start_inventory"].items():
|
||||
for _ in range(amount):
|
||||
multiworld.push_precollected(multiworld.create_item(item, 1))
|
||||
|
||||
for step in _steps:
|
||||
call_all(multiworld, step)
|
||||
|
||||
if should_cache:
|
||||
add_to_world_cache(_cache, frozen_options, multiworld) # noqa
|
||||
|
||||
# Lock is needed for multi-threading tests
|
||||
setattr(multiworld, "lock", threading.Lock())
|
||||
|
||||
return multiworld
|
||||
|
||||
|
||||
def should_cache_world(test_options):
|
||||
if "start_inventory" in test_options:
|
||||
return False
|
||||
|
||||
trap_distribution_key = "trap_distribution"
|
||||
if trap_distribution_key not in test_options:
|
||||
return True
|
||||
|
||||
trap_distribution = test_options[trap_distribution_key]
|
||||
for key in trap_distribution:
|
||||
if trap_distribution[key] != options.TrapDistribution.default_weight:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def make_hashable(test_options, seed):
|
||||
return frozenset(test_options.items()).union({("seed", seed)})
|
||||
|
||||
|
||||
def search_world_cache(cache: Dict[frozenset, MultiWorld], frozen_options: frozenset) -> Optional[MultiWorld]:
|
||||
try:
|
||||
return cache[frozen_options]
|
||||
except KeyError:
|
||||
for cached_options, multi_world in cache.items():
|
||||
if frozen_options.issubset(cached_options):
|
||||
return multi_world
|
||||
return None
|
||||
|
||||
|
||||
def add_to_world_cache(cache: Dict[frozenset, MultiWorld], frozen_options: frozenset, multi_world: MultiWorld) -> None:
|
||||
# We could complete the key with all the default options, but that does not seem to improve performances.
|
||||
cache[frozen_options] = multi_world
|
||||
|
||||
|
||||
def setup_multiworld(test_options: Iterable[Dict[str, int]] = None, seed=None) -> MultiWorld: # noqa
|
||||
if test_options is None:
|
||||
test_options = []
|
||||
|
||||
multiworld = MultiWorld(len(test_options))
|
||||
multiworld.player_name = {}
|
||||
multiworld.set_seed(seed)
|
||||
multiworld.state = CollectionState(multiworld)
|
||||
for i in range(1, len(test_options) + 1):
|
||||
multiworld.game[i] = StardewValleyWorld.game
|
||||
multiworld.player_name.update({i: f"Tester{i}"})
|
||||
args = fill_namespace_with_default(test_options)
|
||||
multiworld.set_options(args)
|
||||
|
||||
for step in gen_steps:
|
||||
call_all(multiworld, step)
|
||||
|
||||
return multiworld
|
||||
@@ -1,5 +1,5 @@
|
||||
from . import SVContentPackTestBase
|
||||
from .. import SVTestBase
|
||||
from ..bases import SVTestBase
|
||||
from ... import options
|
||||
from ...content import content_packs
|
||||
from ...data.artisan import MachineSource
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from .. import SVContentPackTestBase
|
||||
from ... import SVTestBase
|
||||
from ...bases import SVTestBase
|
||||
from .... import options
|
||||
from ....content import content_packs
|
||||
from ....mods.mod_data import ModNames
|
||||
|
||||
@@ -4,8 +4,8 @@ from typing import ClassVar
|
||||
|
||||
from BaseClasses import get_seed
|
||||
from test.param import classvar_matrix
|
||||
from .. import SVTestCase, solo_multiworld, skip_long_tests
|
||||
from ..assertion import WorldAssertMixin, ModAssertMixin
|
||||
from ..bases import skip_long_tests, SVTestCase, solo_multiworld
|
||||
from ..options.option_names import all_option_choices
|
||||
from ... import options
|
||||
from ...mods.mod_data import ModNames
|
||||
|
||||
@@ -4,8 +4,8 @@ from typing import ClassVar
|
||||
|
||||
from BaseClasses import get_seed
|
||||
from test.param import classvar_matrix
|
||||
from .. import SVTestCase, solo_multiworld, skip_long_tests
|
||||
from ..assertion.world_assert import WorldAssertMixin
|
||||
from ..bases import skip_long_tests, SVTestCase, solo_multiworld
|
||||
from ..options.option_names import all_option_choices
|
||||
from ... import options
|
||||
|
||||
|
||||
@@ -3,8 +3,8 @@ from typing import ClassVar
|
||||
|
||||
from BaseClasses import get_seed
|
||||
from test.param import classvar_matrix
|
||||
from .. import SVTestCase, solo_multiworld, skip_long_tests
|
||||
from ..assertion import WorldAssertMixin
|
||||
from ..bases import skip_long_tests, SVTestCase, solo_multiworld
|
||||
from ... import options
|
||||
|
||||
if skip_long_tests():
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from .. import SVTestBase
|
||||
from ..bases import SVTestBase
|
||||
from ...mods.mod_data import ModNames
|
||||
from ...options import Mods, BackpackProgression
|
||||
|
||||
|
||||
@@ -3,9 +3,9 @@ from typing import ClassVar
|
||||
|
||||
from BaseClasses import get_seed
|
||||
from test.param import classvar_matrix
|
||||
from .. import SVTestBase, SVTestCase, solo_multiworld
|
||||
from ..TestGeneration import get_all_permanent_progression_items
|
||||
from ..assertion import ModAssertMixin, WorldAssertMixin
|
||||
from ..bases import SVTestCase, SVTestBase, solo_multiworld
|
||||
from ..options.presets import allsanity_mods_6_x_x
|
||||
from ..options.utils import fill_dataclass_with_default
|
||||
from ... import options, Group, create_content
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from .. import SVTestBase
|
||||
from ..bases import SVTestBase
|
||||
from ... import options
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from .. import SVTestBase
|
||||
from ..bases import SVTestBase
|
||||
from ... import options
|
||||
from ...mods.mod_data import ModNames
|
||||
from ...strings.ap_names.mods.mod_items import SVEQuestItem
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
from Options import PerGameCommonOptions, OptionSet
|
||||
from .. import SVTestCase
|
||||
from ...options import StardewValleyOptions
|
||||
from Options import PerGameCommonOptions, OptionSet, OptionDict
|
||||
from ..bases import SVTestCase
|
||||
from ...options import StardewValleyOptions, TrapItems
|
||||
from ...options.presets import sv_options_presets
|
||||
|
||||
|
||||
class TestPresets(SVTestCase):
|
||||
def test_all_presets_explicitly_set_all_options(self):
|
||||
all_option_names = {option_key for option_key in StardewValleyOptions.type_hints}
|
||||
omitted_option_names = {option_key for option_key in PerGameCommonOptions.type_hints}
|
||||
omitted_option_names = {option_key for option_key in PerGameCommonOptions.type_hints} | {TrapItems.internal_name}
|
||||
mandatory_option_names = {option_key for option_key in all_option_names
|
||||
if option_key not in omitted_option_names and
|
||||
not issubclass(StardewValleyOptions.type_hints[option_key], OptionSet)}
|
||||
not issubclass(StardewValleyOptions.type_hints[option_key], OptionSet | OptionDict)}
|
||||
|
||||
for preset_name in sv_options_presets:
|
||||
with self.subTest(f"{preset_name}"):
|
||||
|
||||
@@ -70,7 +70,7 @@ def allsanity_no_mods_6_x_x():
|
||||
options.SkillProgression.internal_name: options.SkillProgression.option_progressive_with_masteries,
|
||||
options.SpecialOrderLocations.internal_name: options.SpecialOrderLocations.option_board_qi,
|
||||
options.ToolProgression.internal_name: options.ToolProgression.option_progressive,
|
||||
options.TrapItems.internal_name: options.TrapItems.option_nightmare,
|
||||
options.TrapDifficulty.internal_name: options.TrapDifficulty.option_nightmare,
|
||||
options.Walnutsanity.internal_name: options.Walnutsanity.preset_all
|
||||
}
|
||||
|
||||
@@ -119,7 +119,7 @@ def get_minsanity_options():
|
||||
options.SkillProgression.internal_name: options.SkillProgression.option_vanilla,
|
||||
options.SpecialOrderLocations.internal_name: options.SpecialOrderLocations.option_vanilla,
|
||||
options.ToolProgression.internal_name: options.ToolProgression.option_vanilla,
|
||||
options.TrapItems.internal_name: options.TrapItems.option_no_traps,
|
||||
options.TrapDifficulty.internal_name: options.TrapDifficulty.option_no_traps,
|
||||
options.Walnutsanity.internal_name: options.Walnutsanity.preset_none
|
||||
}
|
||||
|
||||
@@ -156,7 +156,7 @@ def minimal_locations_maximal_items():
|
||||
options.SkillProgression.internal_name: options.SkillProgression.option_vanilla,
|
||||
options.SpecialOrderLocations.internal_name: options.SpecialOrderLocations.option_vanilla,
|
||||
options.ToolProgression.internal_name: options.ToolProgression.option_vanilla,
|
||||
options.TrapItems.internal_name: options.TrapItems.option_nightmare,
|
||||
options.TrapDifficulty.internal_name: options.TrapDifficulty.option_nightmare,
|
||||
options.Walnutsanity.internal_name: options.Walnutsanity.preset_none
|
||||
}
|
||||
return min_max_options
|
||||
|
||||
@@ -8,7 +8,7 @@ from typing import List
|
||||
from BaseClasses import get_seed
|
||||
from Fill import distribute_items_restrictive, balance_multiworld_progression
|
||||
from worlds import AutoWorld
|
||||
from .. import SVTestCase, setup_multiworld
|
||||
from ..bases import SVTestCase, setup_multiworld
|
||||
from ..options.presets import default_6_x_x, allsanity_no_mods_6_x_x, allsanity_mods_6_x_x, minimal_locations_maximal_items
|
||||
|
||||
assert default_6_x_x
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from ..bases import SVTestBase
|
||||
from ... import options
|
||||
from ...test import SVTestBase
|
||||
|
||||
|
||||
class TestArcadeMachinesLogic(SVTestBase):
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from ..bases import SVTestBase
|
||||
from ... import options
|
||||
from ...test import SVTestBase
|
||||
|
||||
|
||||
class TestBooksLogic(SVTestBase):
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from ..bases import SVTestBase
|
||||
from ...options import BuildingProgression, FarmType
|
||||
from ...test import SVTestBase
|
||||
|
||||
|
||||
class TestBuildingLogic(SVTestBase):
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from ..bases import SVTestBase
|
||||
from ... import options
|
||||
from ...options import BundleRandomization
|
||||
from ...strings.bundle_names import BundleName
|
||||
from ...test import SVTestBase
|
||||
|
||||
|
||||
class TestBundlesLogic(SVTestBase):
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from ..bases import SVTestBase
|
||||
from ... import options
|
||||
from ...options import BuildingProgression, ExcludeGingerIsland, Chefsanity
|
||||
from ...test import SVTestBase
|
||||
|
||||
|
||||
class TestRecipeLearnLogic(SVTestBase):
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user