forked from mirror/Archipelago
Some checks failed
Analyze modified files / flake8 (push) Failing after 2m28s
Build / build-win (push) Has been cancelled
Build / build-ubuntu2204 (push) Has been cancelled
ctest / Test C++ ubuntu-latest (push) Has been cancelled
ctest / Test C++ windows-latest (push) Has been cancelled
Analyze modified files / mypy (push) Has been cancelled
Build and Publish Docker Images / Push Docker image to Docker Hub (push) Successful in 5m4s
Native Code Static Analysis / scan-build (push) Failing after 5m2s
type check / pyright (push) Successful in 1m7s
unittests / Test Python 3.11.2 ubuntu-latest (push) Failing after 16m23s
unittests / Test Python 3.12 ubuntu-latest (push) Failing after 28m19s
unittests / Test Python 3.13 ubuntu-latest (push) Failing after 14m49s
unittests / Test hosting with 3.13 on ubuntu-latest (push) Successful in 5m0s
unittests / Test Python 3.13 macos-latest (push) Has been cancelled
unittests / Test Python 3.11 windows-latest (push) Has been cancelled
unittests / Test Python 3.13 windows-latest (push) Has been cancelled
802 lines
30 KiB
Python
802 lines
30 KiB
Python
from copy import deepcopy
|
|
from typing import TYPE_CHECKING, Set, Sequence, Callable
|
|
import warnings
|
|
|
|
from BaseClasses import CollectionState, Item
|
|
|
|
from .Items import Channel_Key
|
|
from .Stages import AE3EntranceMeta, ENTRANCES_STAGE_SELECT, ENTRANCES_CHANNELS, LEVELS_BY_ORDER, STAGES_FARMABLE, \
|
|
STAGES_FARMABLE_SNEAKY_BORG
|
|
from .Strings import Itm, Stage, APHelper
|
|
|
|
if TYPE_CHECKING:
|
|
from .. import AE3World
|
|
|
|
### [< --- ACCESS RULES --- >]
|
|
## Check if Player can Catch Monkeys
|
|
def can_catch(state : CollectionState, player : int):
|
|
return can_net(state, player) or can_morph_not_monkey(state, player)
|
|
|
|
def can_catch_long(state : CollectionState, player : int):
|
|
return state.has_group(APHelper.catch_long.value, player)
|
|
|
|
def can_net(state : CollectionState, player : int):
|
|
return state.has(Itm.gadget_net.value, player)
|
|
|
|
## Check if Player can Morph
|
|
def can_morph(state : CollectionState, player : int):
|
|
return state.has_group(APHelper.morphs.value, player)
|
|
|
|
def can_morph_not_monkey(state : CollectionState, player : int):
|
|
return state.has_group(APHelper.morphs_no_monkey.value, player)
|
|
|
|
# Gadget Checks
|
|
def has_radar(state : CollectionState, player : int):
|
|
return state.has(Itm.gadget_radar.value, player)
|
|
|
|
def has_club(state : CollectionState, player : int):
|
|
return state.has(Itm.gadget_club.value, player)
|
|
|
|
def has_hoop(state : CollectionState, player : int):
|
|
return state.has(Itm.gadget_hoop.value, player)
|
|
|
|
def has_flyer(state : CollectionState, player : int):
|
|
return state.has(Itm.gadget_fly.value, player)
|
|
|
|
## Check if Player can use the Slingback Shooter
|
|
def can_sling(state : CollectionState, player : int):
|
|
return state.has(Itm.gadget_sling.value, player)
|
|
|
|
## Check if Player has Water Net
|
|
def can_swim(state : CollectionState, player : int):
|
|
return state.has(Itm.gadget_swim.value, player)
|
|
|
|
## Check if Player can use the RC Car
|
|
def can_rcc(state : CollectionState, player : int):
|
|
return state.has_group(APHelper.rc_cars.value, player)
|
|
|
|
# Morph Checks
|
|
def can_knight(state : CollectionState, player : int):
|
|
return state.has(Itm.morph_knight.value, player)
|
|
|
|
def can_cowboy(state : CollectionState, player : int):
|
|
return state.has(Itm.morph_cowboy.value, player)
|
|
|
|
def can_ninja(state : CollectionState, player : int):
|
|
return state.has(Itm.morph_ninja.value, player)
|
|
|
|
def can_genie(state : CollectionState, player : int):
|
|
return state.has(Itm.morph_magician.value, player)
|
|
|
|
def can_kungfu(state : CollectionState, player : int):
|
|
return state.has(Itm.morph_kungfu.value, player)
|
|
|
|
def can_hero(state : CollectionState, player : int):
|
|
return state.has(Itm.morph_hero.value, player)
|
|
|
|
def can_monkey(state : CollectionState, player : int):
|
|
return state.has(Itm.morph_monkey.value, player)
|
|
|
|
# General Ability Checks
|
|
## Check if player can hit beyond basic hip drops
|
|
def can_attack(state : CollectionState, player : int):
|
|
return state.has_group(APHelper.attack.value, player)
|
|
|
|
def can_hit(state : CollectionState, player : int):
|
|
return state.has_group(APHelper.hit.value, player)
|
|
|
|
## Check if player has the ability to move fast
|
|
def can_dash(state : CollectionState, player : int):
|
|
return state.has_group(APHelper.dash.value, player)
|
|
|
|
## Check if Player can use long-ranged attacks
|
|
def can_shoot(state : CollectionState, player : int):
|
|
return state.has_group(APHelper.shoot.value, player)
|
|
|
|
def can_shoot_boom(state : CollectionState, player : int):
|
|
return can_shoot(state, player) and state.has(Itm.ammo_boom.value, player)
|
|
|
|
## Check if Player can fly (can gain height)
|
|
def can_fly(state : CollectionState, player : int):
|
|
return state.has_group(APHelper.fly.value, player)
|
|
|
|
## Check if the Player can glide
|
|
def can_glide(state : CollectionState, player : int):
|
|
return state.has_group(APHelper.glide.value, player)
|
|
|
|
## Boost Jump
|
|
def can_boost_jump(state : CollectionState, player : int):
|
|
return (state.has_any([Itm.gadget_net.value, Itm.gadget_club.value], player),
|
|
state.has_from_list_unique([Itm.gadget_net.value, Itm.gadget_club.value,
|
|
Itm.gadget_radar.value, Itm.gadget_hoop.value,
|
|
Itm.gadget_sling.value, Itm.gadget_rcc.value,
|
|
Itm.gadget_fly.value], player, 2))
|
|
|
|
## Boost Fly
|
|
def can_boost_fly(state : CollectionState, player : int):
|
|
return (state.has(Itm.gadget_fly.value, player) and
|
|
state.has_any([Itm.gadget_net.value, Itm.gadget_club.value], player))
|
|
|
|
## Quad Jump
|
|
def can_qj(state : CollectionState, player : int):
|
|
return (state.has(Itm.gadget_club.value, player) and
|
|
state.has_any([
|
|
Itm.gadget_net.value,
|
|
Itm.gadget_radar.value,
|
|
Itm.gadget_hoop.value,
|
|
Itm.gadget_rcc.value,
|
|
Itm.gadget_fly.value,
|
|
], player))
|
|
|
|
## Glitch Float (Encompasses both Net Float and HDS)
|
|
def can_glitch_float(state: CollectionState, player : int):
|
|
return state.has_all([Itm.gadget_net.value, Itm.gadget_sling.value], player)
|
|
|
|
## Glitch Float related to Morphs
|
|
def can_glitch_float_morph(state: CollectionState, player : int):
|
|
return state.has_any([
|
|
Itm.morph_ninja.value,
|
|
Itm.morph_monkey.value
|
|
], player)
|
|
|
|
# Access Checks
|
|
def can_reach_region(state : CollectionState, player : int, region : str):
|
|
return state.can_reach_region(region, player)
|
|
|
|
def can_access_region(region : str):
|
|
return lambda state, player : can_reach_region(state, player, region)
|
|
|
|
def can_farm_boxes():
|
|
return lambda state, player : any(can_reach_region(state, player, region) for region in STAGES_FARMABLE)
|
|
|
|
def can_farm_sneaky_borgs():
|
|
return lambda state, player : any(can_reach_region(state, player, region) for region in STAGES_FARMABLE_SNEAKY_BORG)
|
|
|
|
def has_enough_morph_stocks(state : CollectionState, player : int, stocks : int = 1):
|
|
return state.has(Itm.acc_morph_stock.value, player, stocks)
|
|
|
|
def has_morph_stocks(stocks : int = 1):
|
|
return lambda state, player : has_enough_morph_stocks(state, player, stocks)
|
|
|
|
def has_enough_morph_extensions(state : CollectionState, player : int, extensions : int = 10):
|
|
return state.has(Itm.acc_morph_ext.value, player, extensions)
|
|
|
|
def has_morph_extensions(extensions: int = 10):
|
|
return lambda state, player : has_enough_morph_extensions(state, player, extensions)
|
|
|
|
def has_enough_keys(state : CollectionState, player : int, keys : int):
|
|
return state.has(APHelper.channel_key.value, player, keys)
|
|
|
|
def has_keys(keys : int):
|
|
return lambda state, player : has_enough_keys(state, player, keys=keys)
|
|
|
|
def has_enough_shop_stock(state : CollectionState, player : int, stock : int):
|
|
return state.has(APHelper.shop_stock.value, player, stock)
|
|
|
|
def has_shop_stock(stock : int):
|
|
return lambda state, player : has_enough_shop_stock(state, player, stock)
|
|
|
|
# Event Checks
|
|
def is_event_invoked(state : CollectionState, player : int, event_name : str):
|
|
return state.has(event_name, player)
|
|
|
|
def is_event_not_invoked(state : CollectionState, player : int, event_name : str):
|
|
return not state.has(event_name, player)
|
|
|
|
def event_invoked(event_name : str):
|
|
return lambda state, player : is_event_invoked(state, player, event_name)
|
|
|
|
def is_goal_achieved(state : CollectionState, player : int, count : int = 1):
|
|
return state.has(APHelper.victory.value, player, count)
|
|
|
|
def are_goals_achieved(goal_count : int):
|
|
return lambda state, player : is_goal_achieved(state, player, goal_count)
|
|
|
|
### [< --- WRAPPER SHORTHAND --- >]
|
|
class AccessRule:
|
|
"""
|
|
Defines required states for the player to achieve in order for an item to be considered "reachable".
|
|
"""
|
|
|
|
# General
|
|
CATCH = can_catch # Monkey Net unlocked or any Morph that can Catch Monkeys
|
|
CATCH_LONG = can_catch_long # Has any morph with ranged capture
|
|
MORPH = can_morph
|
|
MORPH_NO_MONKEY = can_morph_not_monkey # Unlocked any morph that is not Super Monkey
|
|
ATTACK = can_attack # Can attack reasonably
|
|
HIT = can_hit # Can hit at all
|
|
DASH = can_dash # Unlocked Super Hoop or any fast moving Morph
|
|
SHOOT = can_shoot # Slingback Shooter unlocked or has any morph with long range attacks
|
|
SHOOT_BOOM = can_shoot_boom # Slingback Shooter unlocked or has any morph with long range attacks
|
|
SWIM = can_swim
|
|
FLY = can_fly # Sky Flyer unlocked or has any morph that can fly (gain height)
|
|
GLIDE = can_glide # Sky Flyer unlocked or has any morph that can glide
|
|
|
|
# Gadget
|
|
NET = can_net # Monkey Net Unlocked
|
|
CLUB = has_club # Unlocked Stun Club
|
|
RADAR = has_radar # Unlocked Monkey Radar
|
|
HOOP = has_hoop # Unlocked Dash Hoop
|
|
FLYER = has_flyer # Unlocked Sky Flyer
|
|
SLING = can_sling
|
|
RCC = can_rcc
|
|
|
|
# Morph
|
|
KNIGHT = can_knight
|
|
COWBOY = can_cowboy
|
|
NINJA = can_ninja
|
|
MAGICIAN = can_genie
|
|
KUNGFU = can_kungfu
|
|
HERO = can_hero
|
|
MONKEY = can_monkey
|
|
|
|
# Access
|
|
FARM = can_farm_boxes()
|
|
FARM_DUPE = can_farm_sneaky_borgs()
|
|
|
|
# NULL
|
|
NULL = (lambda state, player : False)
|
|
|
|
# Glitches
|
|
BOOST_JUMP = can_boost_jump # Can Boost Jump
|
|
BOOST_FLY = can_boost_fly # Can Boost Fly
|
|
QJ = can_qj # Can use Quad Jumps (Stun Club and most other gadgets)
|
|
G_FLOAT = can_glitch_float # Can use Net Floats/HDS (Monkey Net and Slingback Shooter)
|
|
G_FLOAT_M = can_glitch_float_morph # Can do Infinite Jumps with Eligible Morphs
|
|
|
|
# Victory
|
|
GOAL = is_goal_achieved
|
|
|
|
### [< --- MANAGING CLASS --- >]
|
|
class Rulesets:
|
|
"""
|
|
Helper for Storing and Managing Access Rules of Locations.
|
|
|
|
Attributes:
|
|
critical : Set of AccessRules that must always be true for a Location to be reachable.
|
|
rules : Normal Sets of AccessRules. In addition to adhering to AccessRules set in Critical,
|
|
at least one set of AccessRules must also be adhered to.
|
|
"""
|
|
critical : Set[Callable] = None
|
|
rules : list[list[Callable]] = None
|
|
|
|
def __init__(self, *rules : Callable | list[Callable] | list[list[Callable]] | None,
|
|
critical : Set[Callable] = None):
|
|
self.critical = set()
|
|
self.rules = []
|
|
|
|
if critical:
|
|
self.critical = critical
|
|
|
|
if rules:
|
|
for rule in rules:
|
|
if isinstance(rule, Callable):
|
|
self.rules.append([rule])
|
|
elif isinstance(rule, list):
|
|
if all(isinstance(_, Callable) for _ in rule):
|
|
self.rules.append(rule)
|
|
elif all(isinstance(x, list) and all(isinstance(_, Callable) for _ in x) for x in rule):
|
|
self.rules.extend(rule)
|
|
|
|
|
|
def __bool__(self):
|
|
return bool(self.critical) or bool(self.rules)
|
|
|
|
def update(self, rulesets : "Rulesets"):
|
|
if not rulesets:
|
|
return
|
|
|
|
if rulesets.critical:
|
|
self.critical.update(rulesets.critical)
|
|
|
|
if rulesets.rules:
|
|
for i, rule in enumerate(rulesets.rules):
|
|
if rule in self.rules:
|
|
rulesets.rules.pop(i)
|
|
|
|
self.rules.extend(rulesets.rules)
|
|
|
|
def check(self, state : CollectionState, player : int) -> bool:
|
|
# Any Critical Rules that return False should immediately mark the item as inaccessible with the current state
|
|
if self.critical:
|
|
for rule in self.critical:
|
|
if not rule(state, player):
|
|
return False
|
|
|
|
# At least one set of normal rules (if any) must return true to mark the item as reachable
|
|
if not self.rules:
|
|
return True
|
|
|
|
reachable: bool = True
|
|
|
|
for rulesets in self.rules:
|
|
reachable = all(rule(state, player) for rule in rulesets)
|
|
|
|
if reachable:
|
|
break
|
|
|
|
return reachable
|
|
|
|
def condense(self, player) -> Callable[[CollectionState], bool]:
|
|
return lambda state : self.check(state, player)
|
|
|
|
|
|
class ProgressionMode:
|
|
name : str = "Generic Progression Mode"
|
|
progression : list[int] = None
|
|
order : list[int] = None
|
|
level_select_entrances : list[AE3EntranceMeta] = None
|
|
|
|
boss_indices : Sequence[int] = [ 3, 8, 12, 17, 21, 24, 26, 27 ]
|
|
small_starting_channels : Sequence[int] = [ 6, 9, 11, 13, 15, 18, 20, 22, 23 ]
|
|
|
|
def __init__(self, world : 'AE3World' = None):
|
|
self.progression = []
|
|
self.order = [ _ for _ in range(28) ]
|
|
self.level_select_entrances : list[AE3EntranceMeta] = [ *ENTRANCES_STAGE_SELECT ]
|
|
|
|
def __str__(self):
|
|
return self.name
|
|
|
|
def shuffle(self, world : 'AE3World'):
|
|
new_order: list[int] = self.generate_new_order(world)
|
|
|
|
# Update with the new orders
|
|
self.order = [*new_order]
|
|
|
|
# Apply Channel Rules
|
|
self.reorder(-1, sorted(world.options.blacklist_channel.value))
|
|
self.reorder(-2, sorted(world.options.post_channel.value))
|
|
self.reorder(-3, sorted(world.options.push_channel.value))
|
|
|
|
self.regenerate_level_select_entrances()
|
|
|
|
def generate_new_order(self, world : 'AE3World') -> list[int]:
|
|
new_order : list[int] = [_ for _ in range(28)]
|
|
world.random.shuffle(new_order)
|
|
|
|
self.small_starting_channels = world.logic_preference.small_starting_channels.copy()
|
|
|
|
# Do not allow Bosses or problematic levels to be in the first few levels
|
|
if (len(set(new_order[:5]).intersection(self.small_starting_channels)) > 0 or
|
|
len(set(new_order[:3]).intersection([*self.boss_indices, *self.small_starting_channels])) > 0):
|
|
blacklists : list[int] = [*self.small_starting_channels, *self.boss_indices]
|
|
swap_indexes : list[int] = [ _ for _ in range(7, 26) if new_order[_] not in blacklists ]
|
|
for idx, level in enumerate(new_order):
|
|
if level not in blacklists:
|
|
continue
|
|
|
|
if idx > 3 and len(blacklists) > len(self.small_starting_channels):
|
|
blacklists = [*self.small_starting_channels]
|
|
|
|
swap : int = -1
|
|
swap_idx : int = -1
|
|
while swap < 0 or swap in blacklists:
|
|
if not swap_indexes:
|
|
break
|
|
|
|
swap_idx = world.random.choice(swap_indexes)
|
|
swap = new_order[swap_idx]
|
|
|
|
new_order[idx], new_order[swap_idx] = new_order[swap_idx], new_order[idx]
|
|
|
|
if swap_idx in swap_indexes:
|
|
swap_indexes.remove(swap_idx)
|
|
|
|
if idx >= 5: break
|
|
# Re-insert Channels specified to be preserved in their vanilla indices
|
|
if world.options.preserve_channel:
|
|
preserve_indices : list[int] = [
|
|
LEVELS_BY_ORDER.index(channel) for channel in sorted(world.options.preserve_channel)
|
|
]
|
|
|
|
if preserve_indices:
|
|
new_order = [_ for _ in new_order if _ not in preserve_indices]
|
|
|
|
for index in preserve_indices:
|
|
new_order.insert(index, index)
|
|
|
|
# Apply the chosen Shuffle Mode
|
|
if world.options.shuffle_channel == 1:
|
|
new_boss_order: list[int] = [_ for _ in new_order if _ in self.boss_indices]
|
|
|
|
new_order = [_ for _ in new_order if _ not in self.boss_indices]
|
|
|
|
for index in range(len(self.boss_indices)):
|
|
new_order.insert(self.boss_indices[index], new_boss_order[index])
|
|
|
|
return new_order
|
|
|
|
def reorder(self, set_interest : int, channels : list[str]):
|
|
temp_progression = deepcopy(self.progression)
|
|
# In the presence of padding sets, remove them first
|
|
# Any ProgressionModes that requires the padding will handle putting it back themselves
|
|
if 0 in self.progression[1:-1]:
|
|
temp_progression = [
|
|
self.progression[0], *[_ for _ in self.progression[1:-1] if _ > 0], self.progression[-1]
|
|
]
|
|
|
|
if set_interest < 0:
|
|
set_interest = len(temp_progression) + set_interest
|
|
|
|
targets : list[int] = [
|
|
LEVELS_BY_ORDER.index(channel) for channel in channels
|
|
if channel in LEVELS_BY_ORDER
|
|
].copy()
|
|
|
|
if not targets:
|
|
return
|
|
|
|
self.progression = deepcopy(temp_progression)
|
|
|
|
additive = APHelper.additive.value in channels
|
|
|
|
# Group the Sets
|
|
group_set : list[list[int]] = []
|
|
count : int = 0
|
|
for i, channel_set in enumerate(self.progression):
|
|
offset : int = 0
|
|
if i == 0:
|
|
offset = 1
|
|
|
|
target : int = count + channel_set + offset
|
|
group_set.append([_ if _ not in targets else -1
|
|
for _ in self.order[count : target]])
|
|
count = target
|
|
|
|
if additive:
|
|
group_set[set_interest].extend(targets)
|
|
else:
|
|
group_set.insert(set_interest + 1, targets)
|
|
|
|
if set_interest <= 1:
|
|
set_interest += 1
|
|
|
|
# Create Temporary Values
|
|
temp_order : list[int] = [channel for sets in group_set[:set_interest]
|
|
for channel in sets if channel != -1]
|
|
temp_progression : list[int] = [len(_) for _ in group_set[:set_interest]]
|
|
temp_set : list[list[int]] = []
|
|
|
|
# Regenerate Group Set with new order for all the interest set and all sets before it
|
|
if temp_order:
|
|
count : int = 0
|
|
for i, channel_set in enumerate(temp_progression):
|
|
target : int = count + channel_set
|
|
temp_set.append([_ for _ in temp_order[count : target]])
|
|
count = target
|
|
|
|
temp_set.extend(group_set[set_interest:])
|
|
group_set = deepcopy(temp_set)
|
|
|
|
# Clean Up
|
|
for i, sets in enumerate(group_set):
|
|
if -1 in sets:
|
|
group_set[i] = [_ for _ in sets if _ != -1]
|
|
|
|
new_order : list[int] = [channel for sets in group_set for channel in sets]
|
|
new_progression : list[int] = []
|
|
for i, sets in enumerate(group_set):
|
|
amount : int = len(sets)
|
|
|
|
if amount == 0 and i < len(group_set) - 1:
|
|
continue
|
|
|
|
if not new_progression:
|
|
amount -= 1
|
|
|
|
new_progression.append(amount)
|
|
|
|
self.order = deepcopy(new_order)
|
|
self.progression = deepcopy(new_progression)
|
|
|
|
@property
|
|
def pgc_entrance_names(self) -> set[str]:
|
|
post_game_set_idx = len(self.progression) - 2
|
|
if post_game_set_idx <= 0 or self.progression[post_game_set_idx] <= 0:
|
|
return set()
|
|
|
|
total_before = sum(self.progression[:post_game_set_idx]) + 1
|
|
return {
|
|
self.level_select_entrances[total_before + ch].name
|
|
for ch in range(self.progression[post_game_set_idx])
|
|
if total_before + ch < len(self.level_select_entrances)
|
|
}
|
|
|
|
def generate_rules(self, world : 'AE3World') -> dict[str, Rulesets]:
|
|
channel_rules : dict[str, Rulesets] = {}
|
|
|
|
for i, channel_set in enumerate(self.progression):
|
|
# The first set of levels should NOT have any access rules
|
|
# Filler sets should also be ignored
|
|
if i == 0 or channel_set == 0:
|
|
continue
|
|
|
|
# Get total channels up until this set's point (not counting levels in current set)
|
|
total_from_current : int = sum(self.progression[:i]) + 1
|
|
required_keys : int = i
|
|
|
|
# Get current channel index to be processed for access rules
|
|
# by adding total from current and the range of the current set
|
|
for channel in range(channel_set):
|
|
if i == len(self.progression) - 1:
|
|
break
|
|
|
|
channel_idx : int = total_from_current + channel
|
|
access_rule : Rulesets = Rulesets(has_keys(required_keys))
|
|
|
|
if i == len(self.progression) - 2:
|
|
access_rule = Rulesets(world.post_game_condition.enact(required_keys - 1,
|
|
world.options.monkeysanity_break_rooms.value))
|
|
|
|
channel_rules.update({self.level_select_entrances[channel_idx].name : access_rule})
|
|
|
|
return channel_rules
|
|
|
|
def generate_keys(self, world : 'AE3World') -> list[Item]:
|
|
# The first set of levels and blacklisted set of levels will not cost keys.
|
|
# Keys required by post game is handled by its corresponding option
|
|
amount: int = len(self.progression) - 3 + world.options.post_game_condition_keys + world.options.extra_keys
|
|
|
|
return Channel_Key.to_items(world.player, amount)
|
|
|
|
def regenerate_level_select_entrances(self):
|
|
base_destination_order: list[str] = [entrance.destination for entrance in ENTRANCES_STAGE_SELECT]
|
|
new_entrances : list[AE3EntranceMeta] = []
|
|
|
|
for slot, channel in enumerate(self.order):
|
|
entrance: AE3EntranceMeta = AE3EntranceMeta(ENTRANCES_CHANNELS[slot], Stage.travel_station_a.value,
|
|
base_destination_order[channel])
|
|
new_entrances.append(entrance)
|
|
|
|
self.level_select_entrances = [*new_entrances]
|
|
|
|
def set_progression(self, progression : list[int] = None):
|
|
if progression is None or not progression:
|
|
return
|
|
|
|
self.progression = progression
|
|
|
|
def set_order(self, order : list[int] = None):
|
|
if order is None or not order:
|
|
return
|
|
|
|
self.order = order
|
|
|
|
def get_progress(self, keys : int, post_game_status : bool = False):
|
|
# Offset key count if Channel Keys are not part of Post Game Condition
|
|
if post_game_status and keys <= len(self.progression) - 2:
|
|
keys += 1
|
|
|
|
# Do not include Blacklist set when checking for progress
|
|
# Only include Post Game set when checking Progress if Post Game Conditions have been met
|
|
limit : int = -2 if not post_game_status else -1
|
|
unlocked : int = sum(self.progression[:limit][:keys + 1])
|
|
|
|
return unlocked
|
|
|
|
|
|
class Singles(ProgressionMode):
|
|
def __init__(self, world : 'AE3World' = None):
|
|
super().__init__(world)
|
|
|
|
self.name = "Singles"
|
|
self.progression = [0, *[1 for _ in range(1, 28)], 0]
|
|
|
|
class Group(ProgressionMode):
|
|
def __init__(self, world : 'AE3World' = None):
|
|
super().__init__(world)
|
|
|
|
self.name = "Group"
|
|
self.progression = [2, 1, 4, 1, 3, 1, 4, 1, 3, 1, 2, 1, 1, 1, 1, 0] # 15 Sets
|
|
|
|
|
|
def shuffle(self, world : 'AE3World'):
|
|
new_order : list[int] = self.generate_new_order(world)
|
|
|
|
## Pre-emptively remove Blacklisted Channels
|
|
blacklist : list[int] = [LEVELS_BY_ORDER.index(channel) for channel in sorted(world.options.blacklist_channel)
|
|
if channel in LEVELS_BY_ORDER]
|
|
new_order : list[int] = [_ for _ in new_order if _ not in blacklist]
|
|
|
|
# Track channel being processed to create the new progression.
|
|
new_progression : list[int] = [-1]
|
|
is_last_index_boss : bool = False
|
|
sets : int = 0
|
|
|
|
for slot, level in enumerate(new_order):
|
|
has_incremented : bool = False
|
|
|
|
# Split the level group before and after boss
|
|
if level in self.boss_indices:
|
|
is_last_index_boss = True
|
|
|
|
# If the current set has no levels counted yet, increment it first before incrementing the set number
|
|
if (not sets and new_progression[sets] < 0) or (sets and new_progression[sets] < 1):
|
|
new_progression[sets] += 1
|
|
has_incremented = True
|
|
|
|
if not new_progression[sets] < 0:
|
|
sets += 1
|
|
|
|
if len(new_progression) - sets <= 0:
|
|
new_progression.insert(sets, 0)
|
|
|
|
elif is_last_index_boss:
|
|
# Do not increment set when coming from the first set that only has a boss level
|
|
|
|
if sets == 1 and new_progression[1] > 0:
|
|
sets += 1
|
|
elif sets > 1 and new_progression[sets] > 0:
|
|
sets += 1
|
|
|
|
is_last_index_boss = False
|
|
|
|
if len(new_progression) - sets <= 0:
|
|
new_progression.insert(sets, 0)
|
|
|
|
if not has_incremented:
|
|
new_progression[sets] += 1
|
|
|
|
# Add Blacklisted Channels at end
|
|
new_progression.append(len(blacklist))
|
|
new_order.extend(blacklist)
|
|
|
|
# Update with the new orders
|
|
self.progression = [*new_progression]
|
|
self.order = [*new_order]
|
|
|
|
# Apply Channel Rules
|
|
self.reorder(-2, sorted(world.options.post_channel.value))
|
|
self.reorder(-3, sorted(world.options.push_channel.value))
|
|
|
|
# Update new Channel Destinations
|
|
self.regenerate_level_select_entrances()
|
|
|
|
|
|
class World(ProgressionMode):
|
|
def __init__(self, world : 'AE3World' = None):
|
|
super().__init__(world)
|
|
|
|
self.name = "World"
|
|
self.progression = [ 3, 5, 4, 5, 4, 3, 1, 1, 1, 0 ]
|
|
|
|
def shuffle(self, world : 'AE3World'):
|
|
new_order : list[int] = self.generate_new_order(world)
|
|
|
|
# Pre-emptively remove Blacklisted Channels
|
|
blacklist : list[int] = [LEVELS_BY_ORDER.index(channel) for channel in sorted(world.options.blacklist_channel)
|
|
if channel in LEVELS_BY_ORDER]
|
|
if blacklist:
|
|
new_order = [_ for _ in new_order if _ not in blacklist]
|
|
|
|
# Track channel being processed to create the new progression.
|
|
new_progression : list[int] = [-1]
|
|
sets : int = 0
|
|
for slot, level in enumerate(new_order):
|
|
new_progression[sets] += 1
|
|
|
|
# Split the level group only after the level boss
|
|
if level in self.boss_indices:
|
|
sets += 1
|
|
|
|
if len(new_progression) - sets <= 0 and level != new_order[-1]:
|
|
new_progression.append(0)
|
|
|
|
# Add Blacklisted Channels at end
|
|
new_progression.append(len(blacklist))
|
|
new_order.extend(blacklist)
|
|
|
|
# Update with the new orders
|
|
self.progression = [*new_progression]
|
|
self.order = [*new_order]
|
|
|
|
# Apply Channel Rules
|
|
self.reorder(-2, sorted(world.options.post_channel.value))
|
|
self.reorder(-3, sorted(world.options.push_channel.value))
|
|
|
|
# Update new Channel Destinations
|
|
self.regenerate_level_select_entrances()
|
|
|
|
class Quadruples(ProgressionMode):
|
|
def __init__(self, world : 'AE3World' = None):
|
|
super().__init__(world)
|
|
|
|
self.name = "Quadruples"
|
|
self.progression = [3, *[4 for _ in range(6)], 0]
|
|
|
|
class Open(ProgressionMode):
|
|
required_keys : int = 0
|
|
|
|
def __init__(self, world : 'AE3World' = None):
|
|
super().__init__(world)
|
|
|
|
self.name = "Open"
|
|
self.progression = [25, 1, 1, 0]
|
|
|
|
# Insert filler slots to simulate r
|
|
if world is not None:
|
|
self.required_keys = world.options.open_progression_keys.value - 1
|
|
required_keys : list[int] = [0 for _ in range(self.required_keys)]
|
|
self.progression[1:1] = required_keys
|
|
|
|
def reorder(self, set_interest : int, channels : list[str]):
|
|
super().reorder(set_interest, channels)
|
|
if len(self.progression) > 1 and self.progression[1] != 0 and self.required_keys:
|
|
self.progression[1:1] = [0 for _ in range(self.required_keys)]
|
|
|
|
class Randomize(ProgressionMode):
|
|
def __init__(self, world : 'AE3World' = None):
|
|
super().__init__(world)
|
|
|
|
self.name = "Randomize"
|
|
self.progression = []
|
|
|
|
if world is None:
|
|
return
|
|
|
|
set_minimum : int = 1
|
|
set_maximum : int = 16
|
|
sets : int = world.options.randomize_progression_set_count.value
|
|
if world.options.randomize_progression_channel_range.value:
|
|
set_minimum = world.options.randomize_progression_channel_range.value[0]
|
|
set_maximum = world.options.randomize_progression_channel_range.value[1]
|
|
|
|
if sets:
|
|
set_maximum = int(28 / sets)
|
|
|
|
minimum : int = set_minimum
|
|
maximum : int = set_maximum
|
|
|
|
attempts : int = 0
|
|
while sum(self.progression) < 27:
|
|
# Enforce Set Count
|
|
if sets and len(self.progression) + 1 == sets:
|
|
self.progression.append(27 - sum(self.progression))
|
|
break
|
|
|
|
sets_amount : int = (world.random.randint(minimum, maximum))
|
|
|
|
# Adjust sets amount if it will lead to progression tracking more channels than exists
|
|
if sum(self.progression) + sets_amount > 27:
|
|
sets_amount = 28 - sum(self.progression)
|
|
|
|
# Subtract by an offset of 1 if this is the first set
|
|
if not self.progression:
|
|
sets_amount -= 1
|
|
|
|
self.progression.append(sets_amount)
|
|
|
|
# Recalibrate Maximum and Minimum as necessary
|
|
if maximum > 27 - sum(self.progression):
|
|
maximum = 27 - sum(self.progression)
|
|
|
|
if minimum > maximum:
|
|
minimum = maximum
|
|
|
|
# Reset and try again when channel count exceeds expected when minimum gets pushed to 0
|
|
if minimum <= 0 and sum(self.progression) != 27:
|
|
minimum = set_minimum
|
|
maximum = set_maximum
|
|
self.progression.clear()
|
|
|
|
attempts += 1
|
|
elif sum(self.progression) == 27:
|
|
break
|
|
|
|
# If there are too many attempts, fall back to Quadruples
|
|
if attempts > 5:
|
|
self.progression = [3, *[4 for _ in range(6)], 0]
|
|
warnings.warn("AE3 > Randomize: Generation failed to generate a valid Channel Set. "
|
|
"Falling back to Quadruples Progression...")
|
|
break
|
|
|
|
if sum(self.progression) != 27:
|
|
raise AssertionError("AE3 > Randomize: Generation failed to generate a valid Channel Set. ")
|
|
|
|
self.progression.append(0)
|
|
|
|
|
|
ProgressionModeOptions : Sequence[Callable] = [
|
|
Singles, Group, World, Quadruples, Open, Randomize
|
|
] |