Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

SoE: use new AP API and naming and make APworld #2701

Merged
merged 13 commits into from
Jan 12, 2024
1 change: 0 additions & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,6 @@
"Ocarina of Time",
"Overcooked! 2",
"Raft",
"Secret of Evermore",
"Slay the Spire",
"Sudoku",
"Super Mario 64",
Expand Down
70 changes: 0 additions & 70 deletions worlds/soe/Logic.py

This file was deleted.

166 changes: 73 additions & 93 deletions worlds/soe/__init__.py

Large diffs are not rendered by default.

85 changes: 85 additions & 0 deletions worlds/soe/logic.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import typing
from typing import Callable, Set

from . import pyevermizer
from .options import EnergyCore, OutOfBounds, SequenceBreaks, SoEOptions

if typing.TYPE_CHECKING:
from BaseClasses import CollectionState

# TODO: Options may preset certain progress steps (i.e. P_ROCK_SKIP), set in generate_early?

# TODO: resolve/flatten/expand rules to get rid of recursion below where possible
# Logic.rules are all rules including locations, excluding those with no progress (i.e. locations that only drop items)
rules = [rule for rule in pyevermizer.get_logic() if len(rule.provides) > 0]
# Logic.items are all items and extra items excluding non-progression items and duplicates
item_names: Set[str] = set()
items = [item for item in filter(lambda item: item.progression, pyevermizer.get_items() + pyevermizer.get_extra_items())
if item.name not in item_names and not item_names.add(item.name)] # type: ignore[func-returns-value]


class SoEPlayerLogic:
__slots__ = "player", "out_of_bounds", "sequence_breaks", "has"
player: int
out_of_bounds: bool
sequence_breaks: bool

has: Callable[..., bool]
"""
Returns True if count of one of evermizer's progress steps is reached based on collected items. i.e. 2 * P_DE
"""

def __init__(self, player: int, options: "SoEOptions"):
self.player = player
self.out_of_bounds = options.out_of_bounds == OutOfBounds.option_logic
self.sequence_breaks = options.sequence_breaks == SequenceBreaks.option_logic

if options.energy_core == EnergyCore.option_fragments:
# override logic for energy core fragments
required_fragments = options.required_fragments.value

def fragmented_has(state: "CollectionState", progress: int, count: int = 1) -> bool:
if progress == pyevermizer.P_ENERGY_CORE:
progress = pyevermizer.P_CORE_FRAGMENT
count = required_fragments
return self._has(state, progress, count)

self.has = fragmented_has
else:
# default (energy core) logic
self.has = self._has

def _count(self, state: "CollectionState", progress: int, max_count: int = 0) -> int:
"""
Returns reached count of one of evermizer's progress steps based on collected items.
i.e. returns 0-3 for P_DE based on items providing CHECK_BOSS,DIAMOND_EYE_DROP
"""
n = 0
for item in items:
for pvd in item.provides:
if pvd[1] == progress:
if state.has(item.name, self.player):
n += state.count(item.name, self.player) * pvd[0]
if n >= max_count > 0:
return n
for rule in rules:
for pvd in rule.provides:
if pvd[1] == progress and pvd[0] > 0:
has = True
for req in rule.requires:
if not self.has(state, req[1], req[0]):
has = False
break
if has:
n += pvd[0]
if n >= max_count > 0:
return n
return n

def _has(self, state: "CollectionState", progress: int, count: int = 1) -> bool:
"""Default implementation of has"""
if self.out_of_bounds is True and progress == pyevermizer.P_ALLOW_OOB:
return True
if self.sequence_breaks is True and progress == pyevermizer.P_ALLOW_SEQUENCE_BREAKS:
return True
return self._count(state, progress, count) >= count
97 changes: 58 additions & 39 deletions worlds/soe/Options.py → worlds/soe/options.py
Original file line number Diff line number Diff line change
@@ -1,24 +1,26 @@
import typing
from dataclasses import dataclass, fields
from typing import Any, cast, Dict, Iterator, List, Tuple, Protocol

from Options import Range, Choice, Toggle, DefaultOnToggle, AssembleOptions, DeathLink, ProgressionBalancing
from Options import AssembleOptions, Choice, DeathLink, DefaultOnToggle, PerGameCommonOptions, ProgressionBalancing, \
Range, Toggle


# typing boilerplate
class FlagsProtocol(typing.Protocol):
class FlagsProtocol(Protocol):
value: int
default: int
flags: typing.List[str]
flags: List[str]


class FlagProtocol(typing.Protocol):
class FlagProtocol(Protocol):
value: int
default: int
flag: str


# meta options
class EvermizerFlags:
flags: typing.List[str]
flags: List[str]

def to_flag(self: FlagsProtocol) -> str:
return self.flags[self.value]
Expand Down Expand Up @@ -200,13 +202,13 @@ class TrapCount(Range):

# more meta options
class ItemChanceMeta(AssembleOptions):
def __new__(mcs, name, bases, attrs):
def __new__(mcs, name: str, bases: Tuple[type], attrs: Dict[Any, Any]) -> "ItemChanceMeta":
if 'item_name' in attrs:
attrs["display_name"] = f"{attrs['item_name']} Chance"
attrs["range_start"] = 0
attrs["range_end"] = 100

return super(ItemChanceMeta, mcs).__new__(mcs, name, bases, attrs)
cls = super(ItemChanceMeta, mcs).__new__(mcs, name, bases, attrs)
return cast(ItemChanceMeta, cls)


class TrapChance(Range, metaclass=ItemChanceMeta):
Expand Down Expand Up @@ -247,33 +249,50 @@ class SoEProgressionBalancing(ProgressionBalancing):
special_range_names = {**ProgressionBalancing.special_range_names, "normal": default}


soe_options: typing.Dict[str, AssembleOptions] = {
"difficulty": Difficulty,
"energy_core": EnergyCore,
"required_fragments": RequiredFragments,
"available_fragments": AvailableFragments,
"money_modifier": MoneyModifier,
"exp_modifier": ExpModifier,
"sequence_breaks": SequenceBreaks,
"out_of_bounds": OutOfBounds,
"fix_cheats": FixCheats,
"fix_infinite_ammo": FixInfiniteAmmo,
"fix_atlas_glitch": FixAtlasGlitch,
"fix_wings_glitch": FixWingsGlitch,
"shorter_dialogs": ShorterDialogs,
"short_boss_rush": ShortBossRush,
"ingredienizer": Ingredienizer,
"sniffamizer": Sniffamizer,
"callbeadamizer": Callbeadamizer,
"musicmizer": Musicmizer,
"doggomizer": Doggomizer,
"turdo_mode": TurdoMode,
"death_link": DeathLink,
"trap_count": TrapCount,
"trap_chance_quake": TrapChanceQuake,
"trap_chance_poison": TrapChancePoison,
"trap_chance_confound": TrapChanceConfound,
"trap_chance_hud": TrapChanceHUD,
"trap_chance_ohko": TrapChanceOHKO,
"progression_balancing": SoEProgressionBalancing,
}
# noinspection SpellCheckingInspection
@dataclass
class SoEOptions(PerGameCommonOptions):
difficulty: Difficulty
energy_core: EnergyCore
required_fragments: RequiredFragments
available_fragments: AvailableFragments
money_modifier: MoneyModifier
exp_modifier: ExpModifier
sequence_breaks: SequenceBreaks
out_of_bounds: OutOfBounds
fix_cheats: FixCheats
fix_infinite_ammo: FixInfiniteAmmo
fix_atlas_glitch: FixAtlasGlitch
fix_wings_glitch: FixWingsGlitch
shorter_dialogs: ShorterDialogs
short_boss_rush: ShortBossRush
ingredienizer: Ingredienizer
sniffamizer: Sniffamizer
callbeadamizer: Callbeadamizer
musicmizer: Musicmizer
doggomizer: Doggomizer
turdo_mode: TurdoMode
death_link: DeathLink
trap_count: TrapCount
trap_chance_quake: TrapChanceQuake
trap_chance_poison: TrapChancePoison
trap_chance_confound: TrapChanceConfound
trap_chance_hud: TrapChanceHUD
trap_chance_ohko: TrapChanceOHKO
progression_balancing: SoEProgressionBalancing

@property
def trap_chances(self) -> Iterator[TrapChance]:
for field in fields(self):
option = getattr(self, field.name)
if isinstance(option, TrapChance):
yield option

@property
def flags(self) -> str:
flags = ''
for field in fields(self):
option = getattr(self, field.name)
if isinstance(option, (EvermizerFlag, EvermizerFlags)):
flags += getattr(self, field.name).to_flag()
return flags
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't notice before merge,
but wouldn't this be?
flags += option.to_flag()
so you don't have to getattr again?

Copy link
Member Author

@black-sliver black-sliver Jan 14, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You are kind of right, I saw that after git add, but it needs a type ignore, or two separate instance() calls, or yet another type abstraction, so I decided to not bother at the moment.

Maybe if it isn't obvious why: Flag and Flags use a typing.Protocol for self and the two protocols are incompatible, so currently we'd need to specialize on each rather than use a tuple in isinstance.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I didn't notice that those were mixins.

This is how I would do it:

        for field in fields(self):
            option = getattr(self, field.name)
            if isinstance(option, (EvermizerFlag, EvermizerFlags)):
                assert isinstance(option, Option)
                flags += option.to_flag()
        return flags

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interesting. That seems like a super easy fix, but I don't understand it (and neither does pycharm 2023.2.3)

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm guessing pycharm doesn't understand intersection types?

mypy and pyright see the intersection types:
option: <subclass of EvermizerFlag and Option> | <subclass of EvermizerFlags and Option>

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, I see. the assert creates "typings" that fulfil the individual protocol. Took me a while to see why this works.
And yeah, pycharm seems to think those are Option now, rather than a combined pseudo type.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in #2724

6 changes: 3 additions & 3 deletions worlds/soe/Patch.py → worlds/soe/patch.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import os
from typing import Optional
from typing import BinaryIO, Optional

import Utils
from worlds.Files import APDeltaPatch
Expand Down Expand Up @@ -30,7 +30,7 @@ def get_base_rom_path(file_name: Optional[str] = None) -> str:
return file_name


def read_rom(stream, strip_header=True) -> bytes:
def read_rom(stream: BinaryIO, strip_header: bool=True) -> bytes:
"""Reads rom into bytearray and optionally strips off any smc header"""
data = stream.read()
if strip_header and len(data) % 0x400 == 0x200:
Expand All @@ -40,5 +40,5 @@ def read_rom(stream, strip_header=True) -> bytes:

if __name__ == '__main__':
import sys
print('Please use ../../Patch.py', file=sys.stderr)
print('Please use ../../patch.py', file=sys.stderr)
sys.exit(1)
13 changes: 12 additions & 1 deletion worlds/soe/test/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from test.TestBase import WorldTestBase
from test.bases import WorldTestBase
from typing import Iterable


Expand All @@ -18,3 +18,14 @@ def assertLocationReachability(self, reachable: Iterable[str] = (), unreachable:
for location in unreachable:
self.assertFalse(self.can_reach_location(location),
f"{location} is reachable but shouldn't be")

def testRocketPartsExist(self):
"""Tests that rocket parts exist and are unique"""
self.assertEqual(len(self.get_items_by_name("Gauge")), 1)
self.assertEqual(len(self.get_items_by_name("Wheel")), 1)
diamond_eyes = self.get_items_by_name("Diamond Eye")
self.assertEqual(len(diamond_eyes), 3)
# verify diamond eyes are individual items
self.assertFalse(diamond_eyes[0] is diamond_eyes[1])
self.assertFalse(diamond_eyes[0] is diamond_eyes[2])
self.assertFalse(diamond_eyes[1] is diamond_eyes[2])
4 changes: 2 additions & 2 deletions worlds/soe/test/test_access.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ class AccessTest(SoETestBase):
def _resolveGourds(gourds: typing.Dict[str, typing.Iterable[int]]):
return [f"{name} #{number}" for name, numbers in gourds.items() for number in numbers]

def testBronzeAxe(self):
def test_bronze_axe(self):
gourds = {
"Pyramid bottom": (118, 121, 122, 123, 124, 125),
"Pyramid top": (140,)
Expand All @@ -16,7 +16,7 @@ def testBronzeAxe(self):
items = [["Bronze Axe"]]
self.assertAccessDependency(locations, items)

def testBronzeSpearPlus(self):
def test_bronze_spear_plus(self):
locations = ["Megataur"]
items = [["Bronze Spear"], ["Lance (Weapon)"], ["Laser Lance"]]
self.assertAccessDependency(locations, items)
Loading
Loading