SM: fixed flawed and limited comeback check (#1398)

The issue at hand is fixing impossible seeds generated by a lack of properly checking if the player can come back to its previous region after reaching for a new location, as reported here: https://discord.com/channels/731205301247803413/1050529825212874763/1050529825212874763

The previous attempt at checking for comeback was only done against "Landing Site" and the custom start region which is a partial solution at best. For exemple, generating a single player plando seed with a custom starting location at "red_brinstar_elevator" with a forced red door at "RedTowerElevatorBottomLeft" and 2 Missiles set at "Morphing Ball" and "Energy Tank, Brinstar Ceiling" would generate an impossible seed where the player is expected to go through the green door "LandingSiteRight" with no Supers to go to the only possible next location "Power Bomb (red Brinstar spike room)". This is because the comeback check would pass because it would consider coming back to "Landing Site" enough.

The proposed solution is keeping a record of the last accessed region when collecting items. It would then be used as the source of the comeback check with the destination being the new location. This check had to be moved from can_fill() to can_reach() because the maximum_exploration_state of the AP filler only use can_reach().

Its still not perfect because collect() can be called in batch for many items at a time so the last accessed region will be set as the last collected item and will be used for the next comeback checks.

This was tested a bit with the given exemple above (its now failing generation) and by generating some 8 SM players seed with many door color rando, area rando and boss rando enabled.
This commit is contained in:
lordlou 2023-01-22 18:36:18 -05:00 committed by GitHub
parent 25756831b7
commit 941dcb60e5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 33 additions and 26 deletions

View File

@ -39,7 +39,7 @@ class SMCollectionState(metaclass=AutoLogicRegister):
# for unit tests where MultiWorld is instantiated before worlds # for unit tests where MultiWorld is instantiated before worlds
if hasattr(parent, "state"): if hasattr(parent, "state"):
self.smbm = {player: SMBoolManager(player, parent.state.smbm[player].maxDiff, self.smbm = {player: SMBoolManager(player, parent.state.smbm[player].maxDiff,
parent.state.smbm[player].onlyBossLeft) for player in parent.state.smbm[player].onlyBossLeft, parent.state.smbm[player].lastAP) for player in
parent.get_game_players("Super Metroid")} parent.get_game_players("Super Metroid")}
for player, group in parent.groups.items(): for player, group in parent.groups.items():
if (group["game"] == "Super Metroid"): if (group["game"] == "Super Metroid"):
@ -116,7 +116,7 @@ class SMWorld(World):
Logic.factory('vanilla') Logic.factory('vanilla')
self.variaRando = VariaRandomizer(self.multiworld, get_base_rom_path(), self.player) self.variaRando = VariaRandomizer(self.multiworld, get_base_rom_path(), self.player)
self.multiworld.state.smbm[self.player] = SMBoolManager(self.player, self.variaRando.maxDifficulty) self.multiworld.state.smbm[self.player] = SMBoolManager(self.player, self.variaRando.maxDifficulty, lastAP = self.variaRando.args.startLocation)
# keeps Nothing items local so no player will ever pickup Nothing # keeps Nothing items local so no player will ever pickup Nothing
# doing so reduces contribution of this world to the Multiworld the more Nothing there is though # doing so reduces contribution of this world to the Multiworld the more Nothing there is though
@ -628,6 +628,11 @@ class SMWorld(World):
def collect(self, state: CollectionState, item: Item) -> bool: def collect(self, state: CollectionState, item: Item) -> bool:
state.smbm[self.player].addItem(item.type) state.smbm[self.player].addItem(item.type)
if (item.location != None and item.location.player == self.player):
for entrance in self.multiworld.get_region(item.location.parent_region.name, self.player).entrances:
if (entrance.parent_region.can_reach(state)):
state.smbm[self.player].lastAP = entrance.parent_region.name
break
return super(SMWorld, self).collect(state, item) return super(SMWorld, self).collect(state, item)
def remove(self, state: CollectionState, item: Item) -> bool: def remove(self, state: CollectionState, item: Item) -> bool:
@ -736,15 +741,31 @@ class SMLocation(Location):
super(SMLocation, self).__init__(player, name, address, parent) super(SMLocation, self).__init__(player, name, address, parent)
def can_fill(self, state: CollectionState, item: Item, check_access=True) -> bool: def can_fill(self, state: CollectionState, item: Item, check_access=True) -> bool:
return self.always_allow(state, item) or (self.item_rule(item) and (not check_access or (self.can_reach(state) and self.can_comeback(state, item)))) return self.always_allow(state, item) or (self.item_rule(item) and (not check_access or self.can_reach(state)))
def can_reach(self, state: CollectionState) -> bool:
# self.access_rule computes faster on average, so placing it first for faster abort
assert self.parent_region, "Can't reach location without region"
return self.access_rule(state) and self.parent_region.can_reach(state) and self.can_comeback(state)
def can_comeback(self, state: CollectionState):
# some specific early/late game checks
if self.name == 'Bomb' or self.name == 'Mother Brain':
return True
def can_comeback(self, state: CollectionState, item: Item):
randoExec = state.multiworld.worlds[self.player].variaRando.randoExec randoExec = state.multiworld.worlds[self.player].variaRando.randoExec
n = 2 if GraphUtils.isStandardStart(randoExec.graphSettings.startAP) else 3
# is early game
if (len([loc for loc in state.locations_checked if loc.player == self.player]) <= n):
return True
for key in locationsDict[self.name].AccessFrom.keys(): for key in locationsDict[self.name].AccessFrom.keys():
if (randoExec.areaGraph.canAccessList( state.smbm[self.player], smbm = state.smbm[self.player]
if (randoExec.areaGraph.canAccess( smbm,
smbm.lastAP,
key, key,
[randoExec.graphSettings.startAP, 'Landing Site'] if not GraphUtils.isStandardStart(randoExec.graphSettings.startAP) else ['Landing Site'], smbm.maxDiff,
state.smbm[self.player].maxDiff)): None)):
return True return True
return False return False

View File

@ -367,22 +367,6 @@ class AccessGraph(object):
#print("canAccess: {}".format(can)) #print("canAccess: {}".format(can))
return can return can
# test access from an access point to a list of others, given an optional item
def canAccessList(self, smbm, srcAccessPointName, destAccessPointNameList, maxDiff, item=None):
if item is not None:
smbm.addItem(item)
#print("canAccess: item: {}, src: {}, dest: {}".format(item, srcAccessPointName, destAccessPointName))
destAccessPointList = [self.accessPoints[destAccessPointName] for destAccessPointName in destAccessPointNameList]
srcAccessPoint = self.accessPoints[srcAccessPointName]
availAccessPoints = self.getAvailableAccessPoints(srcAccessPoint, smbm, maxDiff, item)
can = any(ap in availAccessPoints for ap in destAccessPointList)
# if not can:
# self.log.debug("canAccess KO: avail = {}".format([ap.Name for ap in availAccessPoints.keys()]))
if item is not None:
smbm.removeItem(item)
#print("canAccess: {}".format(can))
return can
# returns a list of AccessPoint instances from srcAccessPointName to destAccessPointName # returns a list of AccessPoint instances from srcAccessPointName to destAccessPointName
# (not including source ap) # (not including source ap)
# or None if no possible path # or None if no possible path

View File

@ -13,7 +13,7 @@ class SMBoolManager(object):
items = ['ETank', 'Missile', 'Super', 'PowerBomb', 'Bomb', 'Charge', 'Ice', 'HiJump', 'SpeedBooster', 'Wave', 'Spazer', 'SpringBall', 'Varia', 'Plasma', 'Grapple', 'Morph', 'Reserve', 'Gravity', 'XRayScope', 'SpaceJump', 'ScrewAttack', 'Nothing', 'NoEnergy', 'MotherBrain', 'Hyper'] + Bosses.Golden4() items = ['ETank', 'Missile', 'Super', 'PowerBomb', 'Bomb', 'Charge', 'Ice', 'HiJump', 'SpeedBooster', 'Wave', 'Spazer', 'SpringBall', 'Varia', 'Plasma', 'Grapple', 'Morph', 'Reserve', 'Gravity', 'XRayScope', 'SpaceJump', 'ScrewAttack', 'Nothing', 'NoEnergy', 'MotherBrain', 'Hyper'] + Bosses.Golden4()
countItems = ['Missile', 'Super', 'PowerBomb', 'ETank', 'Reserve'] countItems = ['Missile', 'Super', 'PowerBomb', 'ETank', 'Reserve']
def __init__(self, player=0, maxDiff=sys.maxsize, onlyBossLeft = False): def __init__(self, player=0, maxDiff=sys.maxsize, onlyBossLeft = False, lastAP = 'Landing Site'):
self._items = { } self._items = { }
self._counts = { } self._counts = { }
@ -21,6 +21,8 @@ class SMBoolManager(object):
self.maxDiff = maxDiff self.maxDiff = maxDiff
self.onlyBossLeft = onlyBossLeft self.onlyBossLeft = onlyBossLeft
self.lastAP = lastAP
# cache related # cache related
self.cacheKey = 0 self.cacheKey = 0
self.computeItemsPositions() self.computeItemsPositions()