Timespinner: migrate to new options api and correct random (#2485)

* Implemented new options system into Timespinner

* Fixed typo

* Fixed typo

* Fixed slotdata maybe

* Fixes

* more fixes

* Fixed failing unit tests

* Implemented options backwards comnpatibility

* Fixed option fallbacks

* Implemented review results

* Fixed logic bug

* Fixed python 3.8/3.9 compatibility

* Replaced one more multiworld option usage

* Update worlds/timespinner/Options.py

Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>

* Updated logging of options replacement to include player name and also write it to spoiler
Fixed generation bug
Implemented review results

---------

Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
This commit is contained in:
Jarno 2024-07-31 11:50:04 +02:00 committed by GitHub
parent 77e3f9fbef
commit 1d19da0c76
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 417 additions and 233 deletions

View File

@ -1,6 +1,6 @@
from typing import List, Optional, Callable, NamedTuple from typing import List, Optional, Callable, NamedTuple
from BaseClasses import MultiWorld, CollectionState from BaseClasses import CollectionState
from .Options import is_option_enabled from .Options import TimespinnerOptions
from .PreCalculatedWeights import PreCalculatedWeights from .PreCalculatedWeights import PreCalculatedWeights
from .LogicExtensions import TimespinnerLogic from .LogicExtensions import TimespinnerLogic
@ -14,11 +14,10 @@ class LocationData(NamedTuple):
rule: Optional[Callable[[CollectionState], bool]] = None rule: Optional[Callable[[CollectionState], bool]] = None
def get_location_datas(world: Optional[MultiWorld], player: Optional[int], def get_location_datas(player: Optional[int], options: Optional[TimespinnerOptions],
precalculated_weights: PreCalculatedWeights) -> List[LocationData]: precalculated_weights: Optional[PreCalculatedWeights]) -> List[LocationData]:
flooded: Optional[PreCalculatedWeights] = precalculated_weights
flooded: PreCalculatedWeights = precalculated_weights logic = TimespinnerLogic(player, options, precalculated_weights)
logic = TimespinnerLogic(world, player, precalculated_weights)
# 1337000 - 1337155 Generic locations # 1337000 - 1337155 Generic locations
# 1337171 - 1337175 New Pickup checks # 1337171 - 1337175 New Pickup checks
@ -203,7 +202,7 @@ def get_location_datas(world: Optional[MultiWorld], player: Optional[int],
] ]
# 1337156 - 1337170 Downloads # 1337156 - 1337170 Downloads
if not world or is_option_enabled(world, player, "DownloadableItems"): if not options or options.downloadable_items:
location_table += ( location_table += (
LocationData('Library', 'Library: Terminal 2 (Lachiem)', 1337156, lambda state: state.has('Tablet', player)), LocationData('Library', 'Library: Terminal 2 (Lachiem)', 1337156, lambda state: state.has('Tablet', player)),
LocationData('Library', 'Library: Terminal 1 (Windaria)', 1337157, lambda state: state.has('Tablet', player)), LocationData('Library', 'Library: Terminal 1 (Windaria)', 1337157, lambda state: state.has('Tablet', player)),
@ -223,13 +222,13 @@ def get_location_datas(world: Optional[MultiWorld], player: Optional[int],
) )
# 1337176 - 1337176 Cantoran # 1337176 - 1337176 Cantoran
if not world or is_option_enabled(world, player, "Cantoran"): if not options or options.cantoran:
location_table += ( location_table += (
LocationData('Left Side forest Caves', 'Lake Serene: Cantoran', 1337176), LocationData('Left Side forest Caves', 'Lake Serene: Cantoran', 1337176),
) )
# 1337177 - 1337198 Lore Checks # 1337177 - 1337198 Lore Checks
if not world or is_option_enabled(world, player, "LoreChecks"): if not options or options.lore_checks:
location_table += ( location_table += (
LocationData('Lower lake desolation', 'Lake Desolation: Memory - Coyote Jump (Time Messenger)', 1337177), LocationData('Lower lake desolation', 'Lake Desolation: Memory - Coyote Jump (Time Messenger)', 1337177),
LocationData('Library', 'Library: Memory - Waterway (A Message)', 1337178), LocationData('Library', 'Library: Memory - Waterway (A Message)', 1337178),
@ -258,7 +257,7 @@ def get_location_datas(world: Optional[MultiWorld], player: Optional[int],
# 1337199 - 1337236 Reserved for future use # 1337199 - 1337236 Reserved for future use
# 1337237 - 1337245 GyreArchives # 1337237 - 1337245 GyreArchives
if not world or is_option_enabled(world, player, "GyreArchives"): if not options or options.gyre_archives:
location_table += ( location_table += (
LocationData('Ravenlord\'s Lair', 'Ravenlord: Post fight (pedestal)', 1337237), LocationData('Ravenlord\'s Lair', 'Ravenlord: Post fight (pedestal)', 1337237),
LocationData('Ifrit\'s Lair', 'Ifrit: Post fight (pedestal)', 1337238), LocationData('Ifrit\'s Lair', 'Ifrit: Post fight (pedestal)', 1337238),

View File

@ -1,6 +1,6 @@
from typing import Union from typing import Union, Optional
from BaseClasses import MultiWorld, CollectionState from BaseClasses import CollectionState
from .Options import is_option_enabled from .Options import TimespinnerOptions
from .PreCalculatedWeights import PreCalculatedWeights from .PreCalculatedWeights import PreCalculatedWeights
@ -10,17 +10,18 @@ class TimespinnerLogic:
flag_unchained_keys: bool flag_unchained_keys: bool
flag_eye_spy: bool flag_eye_spy: bool
flag_specific_keycards: bool flag_specific_keycards: bool
pyramid_keys_unlock: Union[str, None] pyramid_keys_unlock: Optional[str]
present_keys_unlock: Union[str, None] present_keys_unlock: Optional[str]
past_keys_unlock: Union[str, None] past_keys_unlock: Optional[str]
time_keys_unlock: Union[str, None] time_keys_unlock: Optional[str]
def __init__(self, world: MultiWorld, player: int, precalculated_weights: PreCalculatedWeights): def __init__(self, player: int, options: Optional[TimespinnerOptions],
precalculated_weights: Optional[PreCalculatedWeights]):
self.player = player self.player = player
self.flag_specific_keycards = is_option_enabled(world, player, "SpecificKeycards") self.flag_specific_keycards = bool(options and options.specific_keycards)
self.flag_eye_spy = is_option_enabled(world, player, "EyeSpy") self.flag_eye_spy = bool(options and options.eye_spy)
self.flag_unchained_keys = is_option_enabled(world, player, "UnchainedKeys") self.flag_unchained_keys = bool(options and options.unchained_keys)
if precalculated_weights: if precalculated_weights:
if self.flag_unchained_keys: if self.flag_unchained_keys:

View File

@ -1,59 +1,50 @@
from typing import Dict, Union, List from dataclasses import dataclass
from BaseClasses import MultiWorld from typing import Type, Any
from Options import Toggle, DefaultOnToggle, DeathLink, Choice, Range, Option, OptionDict, OptionList from typing import Dict
from Options import Toggle, DefaultOnToggle, DeathLink, Choice, Range, OptionDict, OptionList, Visibility, Option
from Options import PerGameCommonOptions, DeathLinkMixin, AssembleOptions
from schema import Schema, And, Optional, Or from schema import Schema, And, Optional, Or
class StartWithJewelryBox(Toggle): class StartWithJewelryBox(Toggle):
"Start with Jewelry Box unlocked" "Start with Jewelry Box unlocked"
display_name = "Start with Jewelry Box" display_name = "Start with Jewelry Box"
class DownloadableItems(DefaultOnToggle): class DownloadableItems(DefaultOnToggle):
"With the tablet you will be able to download items at terminals" "With the tablet you will be able to download items at terminals"
display_name = "Downloadable items" display_name = "Downloadable items"
class EyeSpy(Toggle): class EyeSpy(Toggle):
"Requires Oculus Ring in inventory to be able to break hidden walls." "Requires Oculus Ring in inventory to be able to break hidden walls."
display_name = "Eye Spy" display_name = "Eye Spy"
class StartWithMeyef(Toggle): class StartWithMeyef(Toggle):
"Start with Meyef, ideal for when you want to play multiplayer." "Start with Meyef, ideal for when you want to play multiplayer."
display_name = "Start with Meyef" display_name = "Start with Meyef"
class QuickSeed(Toggle): class QuickSeed(Toggle):
"Start with Talaria Attachment, Nyoom!" "Start with Talaria Attachment, Nyoom!"
display_name = "Quick seed" display_name = "Quick seed"
class SpecificKeycards(Toggle): class SpecificKeycards(Toggle):
"Keycards can only open corresponding doors" "Keycards can only open corresponding doors"
display_name = "Specific Keycards" display_name = "Specific Keycards"
class Inverted(Toggle): class Inverted(Toggle):
"Start in the past" "Start in the past"
display_name = "Inverted" display_name = "Inverted"
class GyreArchives(Toggle): class GyreArchives(Toggle):
"Gyre locations are in logic. New warps are gated by Merchant Crow and Kobo" "Gyre locations are in logic. New warps are gated by Merchant Crow and Kobo"
display_name = "Gyre Archives" display_name = "Gyre Archives"
class Cantoran(Toggle): class Cantoran(Toggle):
"Cantoran's fight and check are available upon revisiting his room" "Cantoran's fight and check are available upon revisiting his room"
display_name = "Cantoran" display_name = "Cantoran"
class LoreChecks(Toggle): class LoreChecks(Toggle):
"Memories and journal entries contain items." "Memories and journal entries contain items."
display_name = "Lore Checks" display_name = "Lore Checks"
class BossRando(Choice): class BossRando(Choice):
"Wheter all boss locations are shuffled, and if their damage/hp should be scaled." "Wheter all boss locations are shuffled, and if their damage/hp should be scaled."
display_name = "Boss Randomization" display_name = "Boss Randomization"
@ -62,7 +53,6 @@ class BossRando(Choice):
option_unscaled = 2 option_unscaled = 2
alias_true = 1 alias_true = 1
class EnemyRando(Choice): class EnemyRando(Choice):
"Wheter enemies will be randomized, and if their damage/hp should be scaled." "Wheter enemies will be randomized, and if their damage/hp should be scaled."
display_name = "Enemy Randomization" display_name = "Enemy Randomization"
@ -72,7 +62,6 @@ class EnemyRando(Choice):
option_ryshia = 3 option_ryshia = 3
alias_true = 1 alias_true = 1
class DamageRando(Choice): class DamageRando(Choice):
"Randomly nerfs and buffs some orbs and their associated spells as well as some associated rings." "Randomly nerfs and buffs some orbs and their associated spells as well as some associated rings."
display_name = "Damage Rando" display_name = "Damage Rando"
@ -85,7 +74,6 @@ class DamageRando(Choice):
option_manual = 6 option_manual = 6
alias_true = 2 alias_true = 2
class DamageRandoOverrides(OptionDict): class DamageRandoOverrides(OptionDict):
"""Manual +/-/normal odds for an orb. Put 0 if you don't want a certain nerf or buff to be a possibility. Orbs that """Manual +/-/normal odds for an orb. Put 0 if you don't want a certain nerf or buff to be a possibility. Orbs that
you don't specify will roll with 1/1/1 as odds""" you don't specify will roll with 1/1/1 as odds"""
@ -191,7 +179,6 @@ class DamageRandoOverrides(OptionDict):
"Radiant": { "MinusOdds": 1, "NormalOdds": 1, "PlusOdds": 1 }, "Radiant": { "MinusOdds": 1, "NormalOdds": 1, "PlusOdds": 1 },
} }
class HpCap(Range): class HpCap(Range):
"Sets the number that Lunais's HP maxes out at." "Sets the number that Lunais's HP maxes out at."
display_name = "HP Cap" display_name = "HP Cap"
@ -199,7 +186,6 @@ class HpCap(Range):
range_end = 999 range_end = 999
default = 999 default = 999
class LevelCap(Range): class LevelCap(Range):
"""Sets the max level Lunais can achieve.""" """Sets the max level Lunais can achieve."""
display_name = "Level Cap" display_name = "Level Cap"
@ -207,20 +193,17 @@ class LevelCap(Range):
range_end = 99 range_end = 99
default = 99 default = 99
class ExtraEarringsXP(Range): class ExtraEarringsXP(Range):
"""Adds additional XP granted by Galaxy Earrings.""" """Adds additional XP granted by Galaxy Earrings."""
display_name = "Extra Earrings XP" display_name = "Extra Earrings XP"
range_start = 0 range_start = 0
range_end = 24 range_end = 24
default = 0 default = 0
class BossHealing(DefaultOnToggle): class BossHealing(DefaultOnToggle):
"Enables/disables healing after boss fights. NOTE: Currently only applicable when Boss Rando is enabled." "Enables/disables healing after boss fights. NOTE: Currently only applicable when Boss Rando is enabled."
display_name = "Heal After Bosses" display_name = "Heal After Bosses"
class ShopFill(Choice): class ShopFill(Choice):
"""Sets the items for sale in Merchant Crow's shops. """Sets the items for sale in Merchant Crow's shops.
Default: No sunglasses or trendy jacket, but sand vials for sale. Default: No sunglasses or trendy jacket, but sand vials for sale.
@ -233,12 +216,10 @@ class ShopFill(Choice):
option_vanilla = 2 option_vanilla = 2
option_empty = 3 option_empty = 3
class ShopWarpShards(DefaultOnToggle): class ShopWarpShards(DefaultOnToggle):
"Shops always sell warp shards (when keys possessed), ignoring inventory setting." "Shops always sell warp shards (when keys possessed), ignoring inventory setting."
display_name = "Always Sell Warp Shards" display_name = "Always Sell Warp Shards"
class ShopMultiplier(Range): class ShopMultiplier(Range):
"Multiplier for the cost of items in the shop. Set to 0 for free shops." "Multiplier for the cost of items in the shop. Set to 0 for free shops."
display_name = "Shop Price Multiplier" display_name = "Shop Price Multiplier"
@ -246,7 +227,6 @@ class ShopMultiplier(Range):
range_end = 10 range_end = 10
default = 1 default = 1
class LootPool(Choice): class LootPool(Choice):
"""Sets the items that drop from enemies (does not apply to boss reward checks) """Sets the items that drop from enemies (does not apply to boss reward checks)
Vanilla: Drops are the same as the base game Vanilla: Drops are the same as the base game
@ -257,7 +237,6 @@ class LootPool(Choice):
option_randomized = 1 option_randomized = 1
option_empty = 2 option_empty = 2
class DropRateCategory(Choice): class DropRateCategory(Choice):
"""Sets the drop rate when 'Loot Pool' is set to 'Random' """Sets the drop rate when 'Loot Pool' is set to 'Random'
Tiered: Based on item rarity/value Tiered: Based on item rarity/value
@ -271,7 +250,6 @@ class DropRateCategory(Choice):
option_randomized = 2 option_randomized = 2
option_fixed = 3 option_fixed = 3
class FixedDropRate(Range): class FixedDropRate(Range):
"Base drop rate percentage when 'Drop Rate Category' is set to 'Fixed'" "Base drop rate percentage when 'Drop Rate Category' is set to 'Fixed'"
display_name = "Fixed Drop Rate" display_name = "Fixed Drop Rate"
@ -279,7 +257,6 @@ class FixedDropRate(Range):
range_end = 100 range_end = 100
default = 5 default = 5
class LootTierDistro(Choice): class LootTierDistro(Choice):
"""Sets how often items of each rarity tier are placed when 'Loot Pool' is set to 'Random' """Sets how often items of each rarity tier are placed when 'Loot Pool' is set to 'Random'
Default Weight: Rarer items will be assigned to enemy drop slots less frequently than common items Default Weight: Rarer items will be assigned to enemy drop slots less frequently than common items
@ -291,32 +268,26 @@ class LootTierDistro(Choice):
option_full_random = 1 option_full_random = 1
option_inverted_weight = 2 option_inverted_weight = 2
class ShowBestiary(Toggle): class ShowBestiary(Toggle):
"All entries in the bestiary are visible, without needing to kill one of a given enemy first" "All entries in the bestiary are visible, without needing to kill one of a given enemy first"
display_name = "Show Bestiary Entries" display_name = "Show Bestiary Entries"
class ShowDrops(Toggle): class ShowDrops(Toggle):
"All item drops in the bestiary are visible, without needing an enemy to drop one of a given item first" "All item drops in the bestiary are visible, without needing an enemy to drop one of a given item first"
display_name = "Show Bestiary Item Drops" display_name = "Show Bestiary Item Drops"
class EnterSandman(Toggle): class EnterSandman(Toggle):
"The Ancient Pyramid is unlocked by the Twin Pyramid Keys, but the final boss door opens if you have all 5 Timespinner pieces" "The Ancient Pyramid is unlocked by the Twin Pyramid Keys, but the final boss door opens if you have all 5 Timespinner pieces"
display_name = "Enter Sandman" display_name = "Enter Sandman"
class DadPercent(Toggle): class DadPercent(Toggle):
"""The win condition is beating the boss of Emperor's Tower""" """The win condition is beating the boss of Emperor's Tower"""
display_name = "Dad Percent" display_name = "Dad Percent"
class RisingTides(Toggle): class RisingTides(Toggle):
"""Random areas are flooded or drained, can be further specified with RisingTidesOverrides""" """Random areas are flooded or drained, can be further specified with RisingTidesOverrides"""
display_name = "Rising Tides" display_name = "Rising Tides"
def rising_tide_option(location: str, with_save_point_option: bool = False) -> Dict[Optional, Or]: def rising_tide_option(location: str, with_save_point_option: bool = False) -> Dict[Optional, Or]:
if with_save_point_option: if with_save_point_option:
return { return {
@ -341,7 +312,6 @@ def rising_tide_option(location: str, with_save_point_option: bool = False) -> D
"Flooded") "Flooded")
} }
class RisingTidesOverrides(OptionDict): class RisingTidesOverrides(OptionDict):
"""Odds for specific areas to be flooded or drained, only has effect when RisingTides is on. """Odds for specific areas to be flooded or drained, only has effect when RisingTides is on.
Areas that are not specified will roll with the default 33% chance of getting flooded or drained""" Areas that are not specified will roll with the default 33% chance of getting flooded or drained"""
@ -373,13 +343,11 @@ class RisingTidesOverrides(OptionDict):
"Lab": { "Dry": 67, "Flooded": 33 }, "Lab": { "Dry": 67, "Flooded": 33 },
} }
class UnchainedKeys(Toggle): class UnchainedKeys(Toggle):
"""Start with Twin Pyramid Key, which does not give free warp; """Start with Twin Pyramid Key, which does not give free warp;
warp items for Past, Present, (and ??? with Enter Sandman) can be found.""" warp items for Past, Present, (and ??? with Enter Sandman) can be found."""
display_name = "Unchained Keys" display_name = "Unchained Keys"
class TrapChance(Range): class TrapChance(Range):
"""Chance of traps in the item pool. """Chance of traps in the item pool.
Traps will only replace filler items such as potions, vials and antidotes""" Traps will only replace filler items such as potions, vials and antidotes"""
@ -388,67 +356,256 @@ class TrapChance(Range):
range_end = 100 range_end = 100
default = 10 default = 10
class Traps(OptionList): class Traps(OptionList):
"""List of traps that may be in the item pool to find""" """List of traps that may be in the item pool to find"""
display_name = "Traps Types" display_name = "Traps Types"
valid_keys = { "Meteor Sparrow Trap", "Poison Trap", "Chaos Trap", "Neurotoxin Trap", "Bee Trap" } valid_keys = { "Meteor Sparrow Trap", "Poison Trap", "Chaos Trap", "Neurotoxin Trap", "Bee Trap" }
default = [ "Meteor Sparrow Trap", "Poison Trap", "Chaos Trap", "Neurotoxin Trap", "Bee Trap" ] default = [ "Meteor Sparrow Trap", "Poison Trap", "Chaos Trap", "Neurotoxin Trap", "Bee Trap" ]
class PresentAccessWithWheelAndSpindle(Toggle): class PresentAccessWithWheelAndSpindle(Toggle):
"""When inverted, allows using the refugee camp warp when both the Timespinner Wheel and Spindle is acquired.""" """When inverted, allows using the refugee camp warp when both the Timespinner Wheel and Spindle is acquired."""
display_name = "Past Wheel & Spindle Warp" display_name = "Back to the future"
@dataclass
class TimespinnerOptions(PerGameCommonOptions, DeathLinkMixin):
start_with_jewelry_box: StartWithJewelryBox
downloadable_items: DownloadableItems
eye_spy: EyeSpy
start_with_meyef: StartWithMeyef
quick_seed: QuickSeed
specific_keycards: SpecificKeycards
inverted: Inverted
gyre_archives: GyreArchives
cantoran: Cantoran
lore_checks: LoreChecks
boss_rando: BossRando
damage_rando: DamageRando
damage_rando_overrides: DamageRandoOverrides
hp_cap: HpCap
level_cap: LevelCap
extra_earrings_xp: ExtraEarringsXP
boss_healing: BossHealing
shop_fill: ShopFill
shop_warp_shards: ShopWarpShards
shop_multiplier: ShopMultiplier
loot_pool: LootPool
drop_rate_category: DropRateCategory
fixed_drop_rate: FixedDropRate
loot_tier_distro: LootTierDistro
show_bestiary: ShowBestiary
show_drops: ShowDrops
enter_sandman: EnterSandman
dad_percent: DadPercent
rising_tides: RisingTides
rising_tides_overrides: RisingTidesOverrides
unchained_keys: UnchainedKeys
back_to_the_future: PresentAccessWithWheelAndSpindle
trap_chance: TrapChance
traps: Traps
# Some options that are available in the timespinner randomizer arent currently implemented class HiddenDamageRandoOverrides(DamageRandoOverrides):
timespinner_options: Dict[str, Option] = { """Manual +/-/normal odds for an orb. Put 0 if you don't want a certain nerf or buff to be a possibility. Orbs that
"StartWithJewelryBox": StartWithJewelryBox, you don't specify will roll with 1/1/1 as odds"""
"DownloadableItems": DownloadableItems, visibility = Visibility.none
"EyeSpy": EyeSpy,
"StartWithMeyef": StartWithMeyef,
"QuickSeed": QuickSeed,
"SpecificKeycards": SpecificKeycards,
"Inverted": Inverted,
"GyreArchives": GyreArchives,
"Cantoran": Cantoran,
"LoreChecks": LoreChecks,
"BossRando": BossRando,
"EnemyRando": EnemyRando,
"DamageRando": DamageRando,
"DamageRandoOverrides": DamageRandoOverrides,
"HpCap": HpCap,
"LevelCap": LevelCap,
"ExtraEarringsXP": ExtraEarringsXP,
"BossHealing": BossHealing,
"ShopFill": ShopFill,
"ShopWarpShards": ShopWarpShards,
"ShopMultiplier": ShopMultiplier,
"LootPool": LootPool,
"DropRateCategory": DropRateCategory,
"FixedDropRate": FixedDropRate,
"LootTierDistro": LootTierDistro,
"ShowBestiary": ShowBestiary,
"ShowDrops": ShowDrops,
"EnterSandman": EnterSandman,
"DadPercent": DadPercent,
"RisingTides": RisingTides,
"RisingTidesOverrides": RisingTidesOverrides,
"UnchainedKeys": UnchainedKeys,
"TrapChance": TrapChance,
"Traps": Traps,
"PresentAccessWithWheelAndSpindle": PresentAccessWithWheelAndSpindle,
"DeathLink": DeathLink,
}
class HiddenRisingTidesOverrides(RisingTidesOverrides):
"""Odds for specific areas to be flooded or drained, only has effect when RisingTides is on.
Areas that are not specified will roll with the default 33% chance of getting flooded or drained"""
visibility = Visibility.none
def is_option_enabled(world: MultiWorld, player: int, name: str) -> bool: class HiddenTraps(Traps):
return get_option_value(world, player, name) > 0 """List of traps that may be in the item pool to find"""
visibility = Visibility.none
class OptionsHider:
@classmethod
def hidden(cls, option: Type[Option[Any]]) -> Type[Option]:
new_option = AssembleOptions(f"{option}Hidden", option.__bases__, vars(option).copy())
new_option.visibility = Visibility.none
new_option.__doc__ = option.__doc__
return new_option
class HasReplacedCamelCase(Toggle):
"""For internal use will display a warning message if true"""
visibility = Visibility.none
def get_option_value(world: MultiWorld, player: int, name: str) -> Union[int, Dict, List]: @dataclass
option = getattr(world, name, None) class BackwardsCompatiableTimespinnerOptions(TimespinnerOptions):
if option == None: StartWithJewelryBox: OptionsHider.hidden(StartWithJewelryBox) # type: ignore
return 0 DownloadableItems: OptionsHider.hidden(DownloadableItems) # type: ignore
EyeSpy: OptionsHider.hidden(EyeSpy) # type: ignore
StartWithMeyef: OptionsHider.hidden(StartWithMeyef) # type: ignore
QuickSeed: OptionsHider.hidden(QuickSeed) # type: ignore
SpecificKeycards: OptionsHider.hidden(SpecificKeycards) # type: ignore
Inverted: OptionsHider.hidden(Inverted) # type: ignore
GyreArchives: OptionsHider.hidden(GyreArchives) # type: ignore
Cantoran: OptionsHider.hidden(Cantoran) # type: ignore
LoreChecks: OptionsHider.hidden(LoreChecks) # type: ignore
BossRando: OptionsHider.hidden(BossRando) # type: ignore
DamageRando: OptionsHider.hidden(DamageRando) # type: ignore
DamageRandoOverrides: HiddenDamageRandoOverrides
HpCap: OptionsHider.hidden(HpCap) # type: ignore
LevelCap: OptionsHider.hidden(LevelCap) # type: ignore
ExtraEarringsXP: OptionsHider.hidden(ExtraEarringsXP) # type: ignore
BossHealing: OptionsHider.hidden(BossHealing) # type: ignore
ShopFill: OptionsHider.hidden(ShopFill) # type: ignore
ShopWarpShards: OptionsHider.hidden(ShopWarpShards) # type: ignore
ShopMultiplier: OptionsHider.hidden(ShopMultiplier) # type: ignore
LootPool: OptionsHider.hidden(LootPool) # type: ignore
DropRateCategory: OptionsHider.hidden(DropRateCategory) # type: ignore
FixedDropRate: OptionsHider.hidden(FixedDropRate) # type: ignore
LootTierDistro: OptionsHider.hidden(LootTierDistro) # type: ignore
ShowBestiary: OptionsHider.hidden(ShowBestiary) # type: ignore
ShowDrops: OptionsHider.hidden(ShowDrops) # type: ignore
EnterSandman: OptionsHider.hidden(EnterSandman) # type: ignore
DadPercent: OptionsHider.hidden(DadPercent) # type: ignore
RisingTides: OptionsHider.hidden(RisingTides) # type: ignore
RisingTidesOverrides: HiddenRisingTidesOverrides
UnchainedKeys: OptionsHider.hidden(UnchainedKeys) # type: ignore
PresentAccessWithWheelAndSpindle: OptionsHider.hidden(PresentAccessWithWheelAndSpindle) # type: ignore
TrapChance: OptionsHider.hidden(TrapChance) # type: ignore
Traps: HiddenTraps # type: ignore
DeathLink: OptionsHider.hidden(DeathLink) # type: ignore
has_replaced_options: HasReplacedCamelCase
return option[player].value def handle_backward_compatibility(self) -> None:
if self.StartWithJewelryBox != StartWithJewelryBox.default and \
self.start_with_jewelry_box == StartWithJewelryBox.default:
self.start_with_jewelry_box.value = self.StartWithJewelryBox.value
self.has_replaced_options.value = Toggle.option_true
if self.DownloadableItems != DownloadableItems.default and \
self.downloadable_items == DownloadableItems.default:
self.downloadable_items.value = self.DownloadableItems.value
self.has_replaced_options.value = Toggle.option_true
if self.EyeSpy != EyeSpy.default and \
self.eye_spy == EyeSpy.default:
self.eye_spy.value = self.EyeSpy.value
self.has_replaced_options.value = Toggle.option_true
if self.StartWithMeyef != StartWithMeyef.default and \
self.start_with_meyef == StartWithMeyef.default:
self.start_with_meyef.value = self.StartWithMeyef.value
self.has_replaced_options.value = Toggle.option_true
if self.QuickSeed != QuickSeed.default and \
self.quick_seed == QuickSeed.default:
self.quick_seed.value = self.QuickSeed.value
self.has_replaced_options.value = Toggle.option_true
if self.SpecificKeycards != SpecificKeycards.default and \
self.specific_keycards == SpecificKeycards.default:
self.specific_keycards.value = self.SpecificKeycards.value
self.has_replaced_options.value = Toggle.option_true
if self.Inverted != Inverted.default and \
self.inverted == Inverted.default:
self.inverted.value = self.Inverted.value
self.has_replaced_options.value = Toggle.option_true
if self.GyreArchives != GyreArchives.default and \
self.gyre_archives == GyreArchives.default:
self.gyre_archives.value = self.GyreArchives.value
self.has_replaced_options.value = Toggle.option_true
if self.Cantoran != Cantoran.default and \
self.cantoran == Cantoran.default:
self.cantoran.value = self.Cantoran.value
self.has_replaced_options.value = Toggle.option_true
if self.LoreChecks != LoreChecks.default and \
self.lore_checks == LoreChecks.default:
self.lore_checks.value = self.LoreChecks.value
self.has_replaced_options.value = Toggle.option_true
if self.BossRando != BossRando.default and \
self.boss_rando == BossRando.default:
self.boss_rando.value = self.BossRando.value
self.has_replaced_options.value = Toggle.option_true
if self.DamageRando != DamageRando.default and \
self.damage_rando == DamageRando.default:
self.damage_rando.value = self.DamageRando.value
self.has_replaced_options.value = Toggle.option_true
if self.DamageRandoOverrides != DamageRandoOverrides.default and \
self.damage_rando_overrides == DamageRandoOverrides.default:
self.damage_rando_overrides.value = self.DamageRandoOverrides.value
self.has_replaced_options.value = Toggle.option_true
if self.HpCap != HpCap.default and \
self.hp_cap == HpCap.default:
self.hp_cap.value = self.HpCap.value
self.has_replaced_options.value = Toggle.option_true
if self.LevelCap != LevelCap.default and \
self.level_cap == LevelCap.default:
self.level_cap.value = self.LevelCap.value
self.has_replaced_options.value = Toggle.option_true
if self.ExtraEarringsXP != ExtraEarringsXP.default and \
self.extra_earrings_xp == ExtraEarringsXP.default:
self.extra_earrings_xp.value = self.ExtraEarringsXP.value
self.has_replaced_options.value = Toggle.option_true
if self.BossHealing != BossHealing.default and \
self.boss_healing == BossHealing.default:
self.boss_healing.value = self.BossHealing.value
self.has_replaced_options.value = Toggle.option_true
if self.ShopFill != ShopFill.default and \
self.shop_fill == ShopFill.default:
self.shop_fill.value = self.ShopFill.value
self.has_replaced_options.value = Toggle.option_true
if self.ShopWarpShards != ShopWarpShards.default and \
self.shop_warp_shards == ShopWarpShards.default:
self.shop_warp_shards.value = self.ShopWarpShards.value
self.has_replaced_options.value = Toggle.option_true
if self.ShopMultiplier != ShopMultiplier.default and \
self.shop_multiplier == ShopMultiplier.default:
self.shop_multiplier.value = self.ShopMultiplier.value
self.has_replaced_options.value = Toggle.option_true
if self.LootPool != LootPool.default and \
self.loot_pool == LootPool.default:
self.loot_pool.value = self.LootPool.value
self.has_replaced_options.value = Toggle.option_true
if self.DropRateCategory != DropRateCategory.default and \
self.drop_rate_category == DropRateCategory.default:
self.drop_rate_category.value = self.DropRateCategory.value
self.has_replaced_options.value = Toggle.option_true
if self.FixedDropRate != FixedDropRate.default and \
self.fixed_drop_rate == FixedDropRate.default:
self.fixed_drop_rate.value = self.FixedDropRate.value
self.has_replaced_options.value = Toggle.option_true
if self.LootTierDistro != LootTierDistro.default and \
self.loot_tier_distro == LootTierDistro.default:
self.loot_tier_distro.value = self.LootTierDistro.value
self.has_replaced_options.value = Toggle.option_true
if self.ShowBestiary != ShowBestiary.default and \
self.show_bestiary == ShowBestiary.default:
self.show_bestiary.value = self.ShowBestiary.value
self.has_replaced_options.value = Toggle.option_true
if self.ShowDrops != ShowDrops.default and \
self.show_drops == ShowDrops.default:
self.show_drops.value = self.ShowDrops.value
self.has_replaced_options.value = Toggle.option_true
if self.EnterSandman != EnterSandman.default and \
self.enter_sandman == EnterSandman.default:
self.enter_sandman.value = self.EnterSandman.value
self.has_replaced_options.value = Toggle.option_true
if self.DadPercent != DadPercent.default and \
self.dad_percent == DadPercent.default:
self.dad_percent.value = self.DadPercent.value
self.has_replaced_options.value = Toggle.option_true
if self.RisingTides != RisingTides.default and \
self.rising_tides == RisingTides.default:
self.rising_tides.value = self.RisingTides.value
self.has_replaced_options.value = Toggle.option_true
if self.RisingTidesOverrides != RisingTidesOverrides.default and \
self.rising_tides_overrides == RisingTidesOverrides.default:
self.rising_tides_overrides.value = self.RisingTidesOverrides.value
self.has_replaced_options.value = Toggle.option_true
if self.UnchainedKeys != UnchainedKeys.default and \
self.unchained_keys == UnchainedKeys.default:
self.unchained_keys.value = self.UnchainedKeys.value
self.has_replaced_options.value = Toggle.option_true
if self.PresentAccessWithWheelAndSpindle != PresentAccessWithWheelAndSpindle.default and \
self.back_to_the_future == PresentAccessWithWheelAndSpindle.default:
self.back_to_the_future.value = self.PresentAccessWithWheelAndSpindle.value
self.has_replaced_options.value = Toggle.option_true
if self.TrapChance != TrapChance.default and \
self.trap_chance == TrapChance.default:
self.trap_chance.value = self.TrapChance.value
self.has_replaced_options.value = Toggle.option_true
if self.Traps != Traps.default and \
self.traps == Traps.default:
self.traps.value = self.Traps.value
self.has_replaced_options.value = Toggle.option_true
if self.DeathLink != DeathLink.default and \
self.death_link == DeathLink.default:
self.death_link.value = self.DeathLink.value
self.has_replaced_options.value = Toggle.option_true

View File

@ -1,6 +1,6 @@
from typing import Tuple, Dict, Union, List from typing import Tuple, Dict, Union, List
from BaseClasses import MultiWorld from random import Random
from .Options import timespinner_options, is_option_enabled, get_option_value from .Options import TimespinnerOptions
class PreCalculatedWeights: class PreCalculatedWeights:
pyramid_keys_unlock: str pyramid_keys_unlock: str
@ -21,22 +21,22 @@ class PreCalculatedWeights:
flood_lake_serene_bridge: bool flood_lake_serene_bridge: bool
flood_lab: bool flood_lab: bool
def __init__(self, world: MultiWorld, player: int): def __init__(self, options: TimespinnerOptions, random: Random):
if world and is_option_enabled(world, player, "RisingTides"): if options.rising_tides:
weights_overrrides: Dict[str, Union[str, Dict[str, int]]] = self.get_flood_weights_overrides(world, player) weights_overrrides: Dict[str, Union[str, Dict[str, int]]] = self.get_flood_weights_overrides(options)
self.flood_basement, self.flood_basement_high = \ self.flood_basement, self.flood_basement_high = \
self.roll_flood_setting(world, player, weights_overrrides, "CastleBasement") self.roll_flood_setting(random, weights_overrrides, "CastleBasement")
self.flood_xarion, _ = self.roll_flood_setting(world, player, weights_overrrides, "Xarion") self.flood_xarion, _ = self.roll_flood_setting(random, weights_overrrides, "Xarion")
self.flood_maw, _ = self.roll_flood_setting(world, player, weights_overrrides, "Maw") self.flood_maw, _ = self.roll_flood_setting(random, weights_overrrides, "Maw")
self.flood_pyramid_shaft, _ = self.roll_flood_setting(world, player, weights_overrrides, "AncientPyramidShaft") self.flood_pyramid_shaft, _ = self.roll_flood_setting(random, weights_overrrides, "AncientPyramidShaft")
self.flood_pyramid_back, _ = self.roll_flood_setting(world, player, weights_overrrides, "Sandman") self.flood_pyramid_back, _ = self.roll_flood_setting(random, weights_overrrides, "Sandman")
self.flood_moat, _ = self.roll_flood_setting(world, player, weights_overrrides, "CastleMoat") self.flood_moat, _ = self.roll_flood_setting(random, weights_overrrides, "CastleMoat")
self.flood_courtyard, _ = self.roll_flood_setting(world, player, weights_overrrides, "CastleCourtyard") self.flood_courtyard, _ = self.roll_flood_setting(random, weights_overrrides, "CastleCourtyard")
self.flood_lake_desolation, _ = self.roll_flood_setting(world, player, weights_overrrides, "LakeDesolation") self.flood_lake_desolation, _ = self.roll_flood_setting(random, weights_overrrides, "LakeDesolation")
self.flood_lake_serene, _ = self.roll_flood_setting(world, player, weights_overrrides, "LakeSerene") self.flood_lake_serene, _ = self.roll_flood_setting(random, weights_overrrides, "LakeSerene")
self.flood_lake_serene_bridge, _ = self.roll_flood_setting(world, player, weights_overrrides, "LakeSereneBridge") self.flood_lake_serene_bridge, _ = self.roll_flood_setting(random, weights_overrrides, "LakeSereneBridge")
self.flood_lab, _ = self.roll_flood_setting(world, player, weights_overrrides, "Lab") self.flood_lab, _ = self.roll_flood_setting(random, weights_overrrides, "Lab")
else: else:
self.flood_basement = False self.flood_basement = False
self.flood_basement_high = False self.flood_basement_high = False
@ -52,10 +52,12 @@ class PreCalculatedWeights:
self.flood_lab = False self.flood_lab = False
self.pyramid_keys_unlock, self.present_key_unlock, self.past_key_unlock, self.time_key_unlock = \ self.pyramid_keys_unlock, self.present_key_unlock, self.past_key_unlock, self.time_key_unlock = \
self.get_pyramid_keys_unlocks(world, player, self.flood_maw, self.flood_xarion) self.get_pyramid_keys_unlocks(options, random, self.flood_maw, self.flood_xarion)
@staticmethod @staticmethod
def get_pyramid_keys_unlocks(world: MultiWorld, player: int, is_maw_flooded: bool, is_xarion_flooded: bool) -> Tuple[str, str, str, str]: def get_pyramid_keys_unlocks(options: TimespinnerOptions, random: Random,
is_maw_flooded: bool, is_xarion_flooded: bool) -> Tuple[str, str, str, str]:
present_teleportation_gates: List[str] = [ present_teleportation_gates: List[str] = [
"GateKittyBoss", "GateKittyBoss",
"GateLeftLibrary", "GateLeftLibrary",
@ -80,38 +82,30 @@ class PreCalculatedWeights:
"GateRightPyramid" "GateRightPyramid"
) )
if not world:
return (
present_teleportation_gates[0],
present_teleportation_gates[0],
past_teleportation_gates[0],
ancient_pyramid_teleportation_gates[0]
)
if not is_maw_flooded: if not is_maw_flooded:
past_teleportation_gates.append("GateMaw") past_teleportation_gates.append("GateMaw")
if not is_xarion_flooded: if not is_xarion_flooded:
present_teleportation_gates.append("GateXarion") present_teleportation_gates.append("GateXarion")
if is_option_enabled(world, player, "Inverted"): if options.inverted:
all_gates: Tuple[str, ...] = present_teleportation_gates all_gates: Tuple[str, ...] = present_teleportation_gates
else: else:
all_gates: Tuple[str, ...] = past_teleportation_gates + present_teleportation_gates all_gates: Tuple[str, ...] = past_teleportation_gates + present_teleportation_gates
return ( return (
world.random.choice(all_gates), random.choice(all_gates),
world.random.choice(present_teleportation_gates), random.choice(present_teleportation_gates),
world.random.choice(past_teleportation_gates), random.choice(past_teleportation_gates),
world.random.choice(ancient_pyramid_teleportation_gates) random.choice(ancient_pyramid_teleportation_gates)
) )
@staticmethod @staticmethod
def get_flood_weights_overrides(world: MultiWorld, player: int) -> Dict[str, Union[str, Dict[str, int]]]: def get_flood_weights_overrides(options: TimespinnerOptions) -> Dict[str, Union[str, Dict[str, int]]]:
weights_overrides_option: Union[int, Dict[str, Union[str, Dict[str, int]]]] = \ weights_overrides_option: Union[int, Dict[str, Union[str, Dict[str, int]]]] = \
get_option_value(world, player, "RisingTidesOverrides") options.rising_tides_overrides.value
default_weights: Dict[str, Dict[str, int]] = timespinner_options["RisingTidesOverrides"].default default_weights: Dict[str, Dict[str, int]] = options.rising_tides_overrides.default
if not weights_overrides_option: if not weights_overrides_option:
weights_overrides_option = default_weights weights_overrides_option = default_weights
@ -123,13 +117,13 @@ class PreCalculatedWeights:
return weights_overrides_option return weights_overrides_option
@staticmethod @staticmethod
def roll_flood_setting(world: MultiWorld, player: int, def roll_flood_setting(random: Random, all_weights: Dict[str, Union[Dict[str, int], str]],
all_weights: Dict[str, Union[Dict[str, int], str]], key: str) -> Tuple[bool, bool]: key: str) -> Tuple[bool, bool]:
weights: Union[Dict[str, int], str] = all_weights[key] weights: Union[Dict[str, int], str] = all_weights[key]
if isinstance(weights, dict): if isinstance(weights, dict):
result: str = world.random.choices(list(weights.keys()), weights=list(map(int, weights.values())))[0] result: str = random.choices(list(weights.keys()), weights=list(map(int, weights.values())))[0]
else: else:
result: str = weights result: str = weights

View File

@ -1,14 +1,16 @@
from typing import List, Set, Dict, Optional, Callable from typing import List, Set, Dict, Optional, Callable
from BaseClasses import CollectionState, MultiWorld, Region, Entrance, Location from BaseClasses import CollectionState, MultiWorld, Region, Entrance, Location
from .Options import is_option_enabled from .Options import TimespinnerOptions
from .Locations import LocationData, get_location_datas from .Locations import LocationData, get_location_datas
from .PreCalculatedWeights import PreCalculatedWeights from .PreCalculatedWeights import PreCalculatedWeights
from .LogicExtensions import TimespinnerLogic from .LogicExtensions import TimespinnerLogic
def create_regions_and_locations(world: MultiWorld, player: int, precalculated_weights: PreCalculatedWeights): def create_regions_and_locations(world: MultiWorld, player: int, options: TimespinnerOptions,
precalculated_weights: PreCalculatedWeights):
locations_per_region: Dict[str, List[LocationData]] = split_location_datas_per_region( locations_per_region: Dict[str, List[LocationData]] = split_location_datas_per_region(
get_location_datas(world, player, precalculated_weights)) get_location_datas(player, options, precalculated_weights))
regions = [ regions = [
create_region(world, player, locations_per_region, 'Menu'), create_region(world, player, locations_per_region, 'Menu'),
@ -53,7 +55,7 @@ def create_regions_and_locations(world: MultiWorld, player: int, precalculated_w
create_region(world, player, locations_per_region, 'Space time continuum') create_region(world, player, locations_per_region, 'Space time continuum')
] ]
if is_option_enabled(world, player, "GyreArchives"): if options.gyre_archives:
regions.extend([ regions.extend([
create_region(world, player, locations_per_region, 'Ravenlord\'s Lair'), create_region(world, player, locations_per_region, 'Ravenlord\'s Lair'),
create_region(world, player, locations_per_region, 'Ifrit\'s Lair'), create_region(world, player, locations_per_region, 'Ifrit\'s Lair'),
@ -64,10 +66,10 @@ def create_regions_and_locations(world: MultiWorld, player: int, precalculated_w
world.regions += regions world.regions += regions
connectStartingRegion(world, player) connectStartingRegion(world, player, options)
flooded: PreCalculatedWeights = precalculated_weights flooded: PreCalculatedWeights = precalculated_weights
logic = TimespinnerLogic(world, player, precalculated_weights) logic = TimespinnerLogic(player, options, precalculated_weights)
connect(world, player, 'Lake desolation', 'Lower lake desolation', lambda state: flooded.flood_lake_desolation or logic.has_timestop(state) or state.has('Talaria Attachment', player)) connect(world, player, 'Lake desolation', 'Lower lake desolation', lambda state: flooded.flood_lake_desolation or logic.has_timestop(state) or state.has('Talaria Attachment', player))
connect(world, player, 'Lake desolation', 'Upper lake desolation', lambda state: logic.has_fire(state) and state.can_reach('Upper Lake Serene', 'Region', player), "Upper Lake Serene") connect(world, player, 'Lake desolation', 'Upper lake desolation', lambda state: logic.has_fire(state) and state.can_reach('Upper Lake Serene', 'Region', player), "Upper Lake Serene")
@ -123,7 +125,7 @@ def create_regions_and_locations(world: MultiWorld, player: int, precalculated_w
connect(world, player, 'Sealed Caves (Xarion)', 'Skeleton Shaft') connect(world, player, 'Sealed Caves (Xarion)', 'Skeleton Shaft')
connect(world, player, 'Sealed Caves (Xarion)', 'Space time continuum', logic.has_teleport) connect(world, player, 'Sealed Caves (Xarion)', 'Space time continuum', logic.has_teleport)
connect(world, player, 'Refugee Camp', 'Forest') connect(world, player, 'Refugee Camp', 'Forest')
connect(world, player, 'Refugee Camp', 'Library', lambda state: is_option_enabled(world, player, "Inverted") and is_option_enabled(world, player, "PresentAccessWithWheelAndSpindle") and state.has_all({'Timespinner Wheel', 'Timespinner Spindle'}, player)) connect(world, player, 'Refugee Camp', 'Library', lambda state: options.inverted and options.back_to_the_future and state.has_all({'Timespinner Wheel', 'Timespinner Spindle'}, player))
connect(world, player, 'Refugee Camp', 'Space time continuum', logic.has_teleport) connect(world, player, 'Refugee Camp', 'Space time continuum', logic.has_teleport)
connect(world, player, 'Forest', 'Refugee Camp') connect(world, player, 'Forest', 'Refugee Camp')
connect(world, player, 'Forest', 'Left Side forest Caves', lambda state: flooded.flood_lake_serene_bridge or state.has('Talaria Attachment', player) or logic.has_timestop(state)) connect(world, player, 'Forest', 'Left Side forest Caves', lambda state: flooded.flood_lake_serene_bridge or state.has('Talaria Attachment', player) or logic.has_timestop(state))
@ -178,11 +180,11 @@ def create_regions_and_locations(world: MultiWorld, player: int, precalculated_w
connect(world, player, 'Space time continuum', 'Royal towers (lower)', lambda state: logic.can_teleport_to(state, "Past", "GateRoyalTowers")) connect(world, player, 'Space time continuum', 'Royal towers (lower)', lambda state: logic.can_teleport_to(state, "Past", "GateRoyalTowers"))
connect(world, player, 'Space time continuum', 'Caves of Banishment (Maw)', lambda state: logic.can_teleport_to(state, "Past", "GateMaw")) connect(world, player, 'Space time continuum', 'Caves of Banishment (Maw)', lambda state: logic.can_teleport_to(state, "Past", "GateMaw"))
connect(world, player, 'Space time continuum', 'Caves of Banishment (upper)', lambda state: logic.can_teleport_to(state, "Past", "GateCavesOfBanishment")) connect(world, player, 'Space time continuum', 'Caves of Banishment (upper)', lambda state: logic.can_teleport_to(state, "Past", "GateCavesOfBanishment"))
connect(world, player, 'Space time continuum', 'Ancient Pyramid (entrance)', lambda state: logic.can_teleport_to(state, "Time", "GateGyre") or (not is_option_enabled(world, player, "UnchainedKeys") and is_option_enabled(world, player, "EnterSandman"))) connect(world, player, 'Space time continuum', 'Ancient Pyramid (entrance)', lambda state: logic.can_teleport_to(state, "Time", "GateGyre") or (not options.unchained_keys and options.enter_sandman))
connect(world, player, 'Space time continuum', 'Ancient Pyramid (left)', lambda state: logic.can_teleport_to(state, "Time", "GateLeftPyramid")) connect(world, player, 'Space time continuum', 'Ancient Pyramid (left)', lambda state: logic.can_teleport_to(state, "Time", "GateLeftPyramid"))
connect(world, player, 'Space time continuum', 'Ancient Pyramid (right)', lambda state: logic.can_teleport_to(state, "Time", "GateRightPyramid")) connect(world, player, 'Space time continuum', 'Ancient Pyramid (right)', lambda state: logic.can_teleport_to(state, "Time", "GateRightPyramid"))
if is_option_enabled(world, player, "GyreArchives"): if options.gyre_archives:
connect(world, player, 'The lab (upper)', 'Ravenlord\'s Lair', lambda state: state.has('Merchant Crow', player)) connect(world, player, 'The lab (upper)', 'Ravenlord\'s Lair', lambda state: state.has('Merchant Crow', player))
connect(world, player, 'Ravenlord\'s Lair', 'The lab (upper)') connect(world, player, 'Ravenlord\'s Lair', 'The lab (upper)')
connect(world, player, 'Library top', 'Ifrit\'s Lair', lambda state: state.has('Kobo', player) and state.can_reach('Refugee Camp', 'Region', player), "Refugee Camp") connect(world, player, 'Library top', 'Ifrit\'s Lair', lambda state: state.has('Kobo', player) and state.can_reach('Refugee Camp', 'Region', player), "Refugee Camp")
@ -220,12 +222,12 @@ def create_region(world: MultiWorld, player: int, locations_per_region: Dict[str
return region return region
def connectStartingRegion(world: MultiWorld, player: int): def connectStartingRegion(world: MultiWorld, player: int, options: TimespinnerOptions):
menu = world.get_region('Menu', player) menu = world.get_region('Menu', player)
tutorial = world.get_region('Tutorial', player) tutorial = world.get_region('Tutorial', player)
space_time_continuum = world.get_region('Space time continuum', player) space_time_continuum = world.get_region('Space time continuum', player)
if is_option_enabled(world, player, "Inverted"): if options.inverted:
starting_region = world.get_region('Refugee Camp', player) starting_region = world.get_region('Refugee Camp', player)
else: else:
starting_region = world.get_region('Lake desolation', player) starting_region = world.get_region('Lake desolation', player)

View File

@ -1,12 +1,13 @@
from typing import Dict, List, Set, Tuple, TextIO, Union from typing import Dict, List, Set, Tuple, TextIO
from BaseClasses import Item, MultiWorld, Tutorial, ItemClassification from BaseClasses import Item, Tutorial, ItemClassification
from .Items import get_item_names_per_category from .Items import get_item_names_per_category
from .Items import item_table, starter_melee_weapons, starter_spells, filler_items, starter_progression_items from .Items import item_table, starter_melee_weapons, starter_spells, filler_items, starter_progression_items
from .Locations import get_location_datas, EventId from .Locations import get_location_datas, EventId
from .Options import is_option_enabled, get_option_value, timespinner_options from .Options import BackwardsCompatiableTimespinnerOptions, Toggle
from .PreCalculatedWeights import PreCalculatedWeights from .PreCalculatedWeights import PreCalculatedWeights
from .Regions import create_regions_and_locations from .Regions import create_regions_and_locations
from worlds.AutoWorld import World, WebWorld from worlds.AutoWorld import World, WebWorld
import logging
class TimespinnerWebWorld(WebWorld): class TimespinnerWebWorld(WebWorld):
theme = "ice" theme = "ice"
@ -35,32 +36,34 @@ class TimespinnerWorld(World):
Timespinner is a beautiful metroidvania inspired by classic 90s action-platformers. Timespinner is a beautiful metroidvania inspired by classic 90s action-platformers.
Travel back in time to change fate itself. Join timekeeper Lunais on her quest for revenge against the empire that killed her family. Travel back in time to change fate itself. Join timekeeper Lunais on her quest for revenge against the empire that killed her family.
""" """
options_dataclass = BackwardsCompatiableTimespinnerOptions
option_definitions = timespinner_options options: BackwardsCompatiableTimespinnerOptions
game = "Timespinner" game = "Timespinner"
topology_present = True topology_present = True
web = TimespinnerWebWorld() web = TimespinnerWebWorld()
required_client_version = (0, 4, 2) required_client_version = (0, 4, 2)
item_name_to_id = {name: data.code for name, data in item_table.items()} item_name_to_id = {name: data.code for name, data in item_table.items()}
location_name_to_id = {location.name: location.code for location in get_location_datas(None, None, None)} location_name_to_id = {location.name: location.code for location in get_location_datas(-1, None, None)}
item_name_groups = get_item_names_per_category() item_name_groups = get_item_names_per_category()
precalculated_weights: PreCalculatedWeights precalculated_weights: PreCalculatedWeights
def generate_early(self) -> None: def generate_early(self) -> None:
self.precalculated_weights = PreCalculatedWeights(self.multiworld, self.player) self.options.handle_backward_compatibility()
self.precalculated_weights = PreCalculatedWeights(self.options, self.random)
# in generate_early the start_inventory isnt copied over to precollected_items yet, so we can still modify the options directly # in generate_early the start_inventory isnt copied over to precollected_items yet, so we can still modify the options directly
if self.multiworld.start_inventory[self.player].value.pop('Meyef', 0) > 0: if self.options.start_inventory.value.pop('Meyef', 0) > 0:
self.multiworld.StartWithMeyef[self.player].value = self.multiworld.StartWithMeyef[self.player].option_true self.options.start_with_meyef.value = Toggle.option_true
if self.multiworld.start_inventory[self.player].value.pop('Talaria Attachment', 0) > 0: if self.options.start_inventory.value.pop('Talaria Attachment', 0) > 0:
self.multiworld.QuickSeed[self.player].value = self.multiworld.QuickSeed[self.player].option_true self.options.quick_seed.value = Toggle.option_true
if self.multiworld.start_inventory[self.player].value.pop('Jewelry Box', 0) > 0: if self.options.start_inventory.value.pop('Jewelry Box', 0) > 0:
self.multiworld.StartWithJewelryBox[self.player].value = self.multiworld.StartWithJewelryBox[self.player].option_true self.options.start_with_jewelry_box.value = Toggle.option_true
def create_regions(self) -> None: def create_regions(self) -> None:
create_regions_and_locations(self.multiworld, self.player, self.precalculated_weights) create_regions_and_locations(self.multiworld, self.player, self.options, self.precalculated_weights)
def create_items(self) -> None: def create_items(self) -> None:
self.create_and_assign_event_items() self.create_and_assign_event_items()
@ -74,7 +77,7 @@ class TimespinnerWorld(World):
def set_rules(self) -> None: def set_rules(self) -> None:
final_boss: str final_boss: str
if self.is_option_enabled("DadPercent"): if self.options.dad_percent:
final_boss = "Killed Emperor" final_boss = "Killed Emperor"
else: else:
final_boss = "Killed Nightmare" final_boss = "Killed Nightmare"
@ -82,48 +85,74 @@ class TimespinnerWorld(World):
self.multiworld.completion_condition[self.player] = lambda state: state.has(final_boss, self.player) self.multiworld.completion_condition[self.player] = lambda state: state.has(final_boss, self.player)
def fill_slot_data(self) -> Dict[str, object]: def fill_slot_data(self) -> Dict[str, object]:
slot_data: Dict[str, object] = {} return {
# options
ap_specific_settings: Set[str] = {"RisingTidesOverrides", "TrapChance"} "StartWithJewelryBox": self.options.start_with_jewelry_box.value,
"DownloadableItems": self.options.downloadable_items.value,
for option_name in timespinner_options: "EyeSpy": self.options.eye_spy.value,
if (option_name not in ap_specific_settings): "StartWithMeyef": self.options.start_with_meyef.value,
slot_data[option_name] = self.get_option_value(option_name) "QuickSeed": self.options.quick_seed.value,
"SpecificKeycards": self.options.specific_keycards.value,
slot_data["StinkyMaw"] = True "Inverted": self.options.inverted.value,
slot_data["ProgressiveVerticalMovement"] = False "GyreArchives": self.options.gyre_archives.value,
slot_data["ProgressiveKeycards"] = False "Cantoran": self.options.cantoran.value,
slot_data["PersonalItems"] = self.get_personal_items() "LoreChecks": self.options.lore_checks.value,
slot_data["PyramidKeysGate"] = self.precalculated_weights.pyramid_keys_unlock "BossRando": self.options.boss_rando.value,
slot_data["PresentGate"] = self.precalculated_weights.present_key_unlock "DamageRando": self.options.damage_rando.value,
slot_data["PastGate"] = self.precalculated_weights.past_key_unlock "DamageRandoOverrides": self.options.damage_rando_overrides.value,
slot_data["TimeGate"] = self.precalculated_weights.time_key_unlock "HpCap": self.options.hp_cap.value,
slot_data["Basement"] = int(self.precalculated_weights.flood_basement) + \ "LevelCap": self.options.level_cap.value,
int(self.precalculated_weights.flood_basement_high) "ExtraEarringsXP": self.options.extra_earrings_xp.value,
slot_data["Xarion"] = self.precalculated_weights.flood_xarion "BossHealing": self.options.boss_healing.value,
slot_data["Maw"] = self.precalculated_weights.flood_maw "ShopFill": self.options.shop_fill.value,
slot_data["PyramidShaft"] = self.precalculated_weights.flood_pyramid_shaft "ShopWarpShards": self.options.shop_warp_shards.value,
slot_data["BackPyramid"] = self.precalculated_weights.flood_pyramid_back "ShopMultiplier": self.options.shop_multiplier.value,
slot_data["CastleMoat"] = self.precalculated_weights.flood_moat "LootPool": self.options.loot_pool.value,
slot_data["CastleCourtyard"] = self.precalculated_weights.flood_courtyard "DropRateCategory": self.options.drop_rate_category.value,
slot_data["LakeDesolation"] = self.precalculated_weights.flood_lake_desolation "FixedDropRate": self.options.fixed_drop_rate.value,
slot_data["DryLakeSerene"] = not self.precalculated_weights.flood_lake_serene "LootTierDistro": self.options.loot_tier_distro.value,
slot_data["LakeSereneBridge"] = self.precalculated_weights.flood_lake_serene_bridge "ShowBestiary": self.options.show_bestiary.value,
slot_data["Lab"] = self.precalculated_weights.flood_lab "ShowDrops": self.options.show_drops.value,
"EnterSandman": self.options.enter_sandman.value,
return slot_data "DadPercent": self.options.dad_percent.value,
"RisingTides": self.options.rising_tides.value,
"UnchainedKeys": self.options.unchained_keys.value,
"PresentAccessWithWheelAndSpindle": self.options.back_to_the_future.value,
"Traps": self.options.traps.value,
"DeathLink": self.options.death_link.value,
"StinkyMaw": True,
# data
"PersonalItems": self.get_personal_items(),
"PyramidKeysGate": self.precalculated_weights.pyramid_keys_unlock,
"PresentGate": self.precalculated_weights.present_key_unlock,
"PastGate": self.precalculated_weights.past_key_unlock,
"TimeGate": self.precalculated_weights.time_key_unlock,
# rising tides
"Basement": int(self.precalculated_weights.flood_basement) + \
int(self.precalculated_weights.flood_basement_high),
"Xarion": self.precalculated_weights.flood_xarion,
"Maw": self.precalculated_weights.flood_maw,
"PyramidShaft": self.precalculated_weights.flood_pyramid_shaft,
"BackPyramid": self.precalculated_weights.flood_pyramid_back,
"CastleMoat": self.precalculated_weights.flood_moat,
"CastleCourtyard": self.precalculated_weights.flood_courtyard,
"LakeDesolation": self.precalculated_weights.flood_lake_desolation,
"DryLakeSerene": not self.precalculated_weights.flood_lake_serene,
"LakeSereneBridge": self.precalculated_weights.flood_lake_serene_bridge,
"Lab": self.precalculated_weights.flood_lab
}
def write_spoiler_header(self, spoiler_handle: TextIO) -> None: def write_spoiler_header(self, spoiler_handle: TextIO) -> None:
if self.is_option_enabled("UnchainedKeys"): if self.options.unchained_keys:
spoiler_handle.write(f'Modern Warp Beacon unlock: {self.precalculated_weights.present_key_unlock}\n') spoiler_handle.write(f'Modern Warp Beacon unlock: {self.precalculated_weights.present_key_unlock}\n')
spoiler_handle.write(f'Timeworn Warp Beacon unlock: {self.precalculated_weights.past_key_unlock}\n') spoiler_handle.write(f'Timeworn Warp Beacon unlock: {self.precalculated_weights.past_key_unlock}\n')
if self.is_option_enabled("EnterSandman"): if self.options.enter_sandman:
spoiler_handle.write(f'Mysterious Warp Beacon unlock: {self.precalculated_weights.time_key_unlock}\n') spoiler_handle.write(f'Mysterious Warp Beacon unlock: {self.precalculated_weights.time_key_unlock}\n')
else: else:
spoiler_handle.write(f'Twin Pyramid Keys unlock: {self.precalculated_weights.pyramid_keys_unlock}\n') spoiler_handle.write(f'Twin Pyramid Keys unlock: {self.precalculated_weights.pyramid_keys_unlock}\n')
if self.is_option_enabled("RisingTides"): if self.options.rising_tides:
flooded_areas: List[str] = [] flooded_areas: List[str] = []
if self.precalculated_weights.flood_basement: if self.precalculated_weights.flood_basement:
@ -159,6 +188,15 @@ class TimespinnerWorld(World):
spoiler_handle.write(f'Flooded Areas: {flooded_areas_string}\n') spoiler_handle.write(f'Flooded Areas: {flooded_areas_string}\n')
if self.options.has_replaced_options:
warning = \
f"NOTICE: Timespinner options for player '{self.player_name}' where renamed from PasCalCase to snake_case, " \
"please update your yaml"
spoiler_handle.write("\n")
spoiler_handle.write(warning)
logging.warning(warning)
def create_item(self, name: str) -> Item: def create_item(self, name: str) -> Item:
data = item_table[name] data = item_table[name]
@ -176,41 +214,41 @@ class TimespinnerWorld(World):
if not item.advancement: if not item.advancement:
return item return item
if (name == 'Tablet' or name == 'Library Keycard V') and not self.is_option_enabled("DownloadableItems"): if (name == 'Tablet' or name == 'Library Keycard V') and not self.options.downloadable_items:
item.classification = ItemClassification.filler item.classification = ItemClassification.filler
elif name == 'Oculus Ring' and not self.is_option_enabled("EyeSpy"): elif name == 'Oculus Ring' and not self.options.eye_spy:
item.classification = ItemClassification.filler item.classification = ItemClassification.filler
elif (name == 'Kobo' or name == 'Merchant Crow') and not self.is_option_enabled("GyreArchives"): elif (name == 'Kobo' or name == 'Merchant Crow') and not self.options.gyre_archives:
item.classification = ItemClassification.filler item.classification = ItemClassification.filler
elif name in {"Timeworn Warp Beacon", "Modern Warp Beacon", "Mysterious Warp Beacon"} \ elif name in {"Timeworn Warp Beacon", "Modern Warp Beacon", "Mysterious Warp Beacon"} \
and not self.is_option_enabled("UnchainedKeys"): and not self.options.unchained_keys:
item.classification = ItemClassification.filler item.classification = ItemClassification.filler
return item return item
def get_filler_item_name(self) -> str: def get_filler_item_name(self) -> str:
trap_chance: int = self.get_option_value("TrapChance") trap_chance: int = self.options.trap_chance.value
enabled_traps: List[str] = self.get_option_value("Traps") enabled_traps: List[str] = self.options.traps.value
if self.multiworld.random.random() < (trap_chance / 100) and enabled_traps: if self.random.random() < (trap_chance / 100) and enabled_traps:
return self.multiworld.random.choice(enabled_traps) return self.random.choice(enabled_traps)
else: else:
return self.multiworld.random.choice(filler_items) return self.random.choice(filler_items)
def get_excluded_items(self) -> Set[str]: def get_excluded_items(self) -> Set[str]:
excluded_items: Set[str] = set() excluded_items: Set[str] = set()
if self.is_option_enabled("StartWithJewelryBox"): if self.options.start_with_jewelry_box:
excluded_items.add('Jewelry Box') excluded_items.add('Jewelry Box')
if self.is_option_enabled("StartWithMeyef"): if self.options.start_with_meyef:
excluded_items.add('Meyef') excluded_items.add('Meyef')
if self.is_option_enabled("QuickSeed"): if self.options.quick_seed:
excluded_items.add('Talaria Attachment') excluded_items.add('Talaria Attachment')
if self.is_option_enabled("UnchainedKeys"): if self.options.unchained_keys:
excluded_items.add('Twin Pyramid Key') excluded_items.add('Twin Pyramid Key')
if not self.is_option_enabled("EnterSandman"): if not self.options.enter_sandman:
excluded_items.add('Mysterious Warp Beacon') excluded_items.add('Mysterious Warp Beacon')
else: else:
excluded_items.add('Timeworn Warp Beacon') excluded_items.add('Timeworn Warp Beacon')
@ -224,8 +262,8 @@ class TimespinnerWorld(World):
return excluded_items return excluded_items
def assign_starter_items(self, excluded_items: Set[str]) -> None: def assign_starter_items(self, excluded_items: Set[str]) -> None:
non_local_items: Set[str] = self.multiworld.non_local_items[self.player].value non_local_items: Set[str] = self.options.non_local_items.value
local_items: Set[str] = self.multiworld.local_items[self.player].value local_items: Set[str] = self.options.local_items.value
local_starter_melee_weapons = tuple(item for item in starter_melee_weapons if local_starter_melee_weapons = tuple(item for item in starter_melee_weapons if
item in local_items or not item in non_local_items) item in local_items or not item in non_local_items)
@ -247,27 +285,26 @@ class TimespinnerWorld(World):
self.assign_starter_item(excluded_items, 'Tutorial: Yo Momma 2', local_starter_spells) self.assign_starter_item(excluded_items, 'Tutorial: Yo Momma 2', local_starter_spells)
def assign_starter_item(self, excluded_items: Set[str], location: str, item_list: Tuple[str, ...]) -> None: def assign_starter_item(self, excluded_items: Set[str], location: str, item_list: Tuple[str, ...]) -> None:
item_name = self.multiworld.random.choice(item_list) item_name = self.random.choice(item_list)
self.place_locked_item(excluded_items, location, item_name) self.place_locked_item(excluded_items, location, item_name)
def place_first_progression_item(self, excluded_items: Set[str]) -> None: def place_first_progression_item(self, excluded_items: Set[str]) -> None:
if self.is_option_enabled("QuickSeed") or self.is_option_enabled("Inverted") \ if self.options.quick_seed or self.options.inverted or self.precalculated_weights.flood_lake_desolation:
or self.precalculated_weights.flood_lake_desolation:
return return
for item in self.multiworld.precollected_items[self.player]: for item_name in self.options.start_inventory.value.keys():
if item.name in starter_progression_items and not item.name in excluded_items: if item_name in starter_progression_items:
return return
local_starter_progression_items = tuple( local_starter_progression_items = tuple(
item for item in starter_progression_items item for item in starter_progression_items
if item not in excluded_items and item not in self.multiworld.non_local_items[self.player].value) if item not in excluded_items and item not in self.options.non_local_items.value)
if not local_starter_progression_items: if not local_starter_progression_items:
return return
progression_item = self.multiworld.random.choice(local_starter_progression_items) progression_item = self.random.choice(local_starter_progression_items)
self.multiworld.local_early_items[self.player][progression_item] = 1 self.multiworld.local_early_items[self.player][progression_item] = 1
@ -307,9 +344,3 @@ class TimespinnerWorld(World):
personal_items[location.address] = location.item.code personal_items[location.address] = location.item.code
return personal_items return personal_items
def is_option_enabled(self, option: str) -> bool:
return is_option_enabled(self.multiworld, self.player, option)
def get_option_value(self, option: str) -> Union[int, Dict, List]:
return get_option_value(self.multiworld, self.player, option)