Files
Grinch-AP/worlds/sm/variaRandomizer/graph/graph.py
lordlou 941dcb60e5 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.
2023-01-23 00:36:18 +01:00

414 lines
18 KiB
Python

import copy, logging
from operator import attrgetter
import utils.log
from logic.smbool import SMBool, smboolFalse
from utils.parameters import infinity
from logic.helpers import Bosses
class Path(object):
__slots__ = ( 'path', 'pdiff', 'distance' )
def __init__(self, path, pdiff, distance):
self.path = path
self.pdiff = pdiff
self.distance = distance
class AccessPoint(object):
# name : AccessPoint name
# graphArea : graph area the node is located in
# transitions : intra-area transitions
# traverse: traverse function, will be wand to the added transitions
# exitInfo : dict carrying vanilla door information : 'DoorPtr': door address, 'direction', 'cap', 'screen', 'bitFlag', 'distanceToSpawn', 'doorAsmPtr' : door properties
# entryInfo : dict carrying forced samus X/Y position with keys 'SamusX' and 'SamusY'.
# (to be updated after reading vanillaTransitions and gather entry info from matching exit door)
# roomInfo : dict with 'RoomPtr' : room address, 'area'
# shortName : short name for the credits
# internal : if true, shall not be used for connecting areas
def __init__(self, name, graphArea, transitions,
traverse=lambda sm: SMBool(True),
exitInfo=None, entryInfo=None, roomInfo=None,
internal=False, boss=False, escape=False,
start=None,
dotOrientation='w'):
self.Name = name
self.GraphArea = graphArea
self.ExitInfo = exitInfo
self.EntryInfo = entryInfo
self.RoomInfo = roomInfo
self.Internal = internal
self.Boss = boss
self.Escape = escape
self.Start = start
self.DotOrientation = dotOrientation
self.intraTransitions = self.sortTransitions(transitions)
self.transitions = copy.copy(self.intraTransitions)
self.traverse = traverse
self.distance = 0
# inter-area connection
self.ConnectedTo = None
def __copy__(self):
exitInfo = copy.deepcopy(self.ExitInfo) if self.ExitInfo is not None else None
entryInfo = copy.deepcopy(self.EntryInfo) if self.EntryInfo is not None else None
roomInfo = copy.deepcopy(self.RoomInfo) if self.RoomInfo is not None else None
start = copy.deepcopy(self.Start) if self.Start is not None else None
# in any case, do not copy connections
return AccessPoint(self.Name, self.GraphArea, self.intraTransitions, self.traverse,
exitInfo, entryInfo, roomInfo,
self.Internal, self.Boss, self.Escape,
start, self.DotOrientation)
def __str__(self):
return "[" + self.GraphArea + "] " + self.Name
def __repr__(self):
return self.Name
def sortTransitions(self, transitions=None):
# sort transitions before the loop in getNewAvailNodes.
# as of python3.7 insertion order is guaranteed in dictionaires.
if transitions is None:
transitions = self.transitions
return { key: transitions[key] for key in sorted(transitions.keys()) }
# connect to inter-area access point
def connect(self, destName):
self.disconnect()
if self.Internal is False:
self.transitions[destName] = self.traverse
self.ConnectedTo = destName
else:
raise RuntimeError("Cannot add an internal access point as inter-are transition")
self.transitions = self.sortTransitions()
def disconnect(self):
if self.ConnectedTo is not None:
if self.ConnectedTo not in self.intraTransitions:
del self.transitions[self.ConnectedTo]
else:
self.transitions[self.ConnectedTo] = self.intraTransitions[self.ConnectedTo]
self.ConnectedTo = None
# tells if this node is to connect areas together
def isArea(self):
return not self.Internal and not self.Boss and not self.Escape
# used by the solver to get area and boss APs
def isInternal(self):
return self.Internal or self.Escape
def isLoop(self):
return self.ConnectedTo == self.Name
class AccessGraph(object):
__slots__ = ( 'log', 'accessPoints', 'InterAreaTransitions',
'EscapeAttributes', 'apCache', '_useCache',
'availAccessPoints' )
def __init__(self, accessPointList, transitions, dotFile=None):
self.log = utils.log.get('Graph')
self.accessPoints = {}
self.InterAreaTransitions = []
self.EscapeAttributes = {
'Timer': None,
'Animals': None
}
for ap in accessPointList:
self.addAccessPoint(ap)
for srcName, dstName in transitions:
self.addTransition(srcName, dstName)
if dotFile is not None:
self.toDot(dotFile)
self.apCache = {}
self._useCache = False
# store the avail access points to display in vcr
self.availAccessPoints = {}
def useCache(self, use):
self._useCache = use
if self._useCache:
self.resetCache()
def resetCache(self):
self.apCache = {}
def printGraph(self):
if self.log.getEffectiveLevel() == logging.DEBUG:
self.log.debug("Area graph:")
for s, d in self.InterAreaTransitions:
self.log.debug("{} -> {}".format(s.Name, d.Name))
def addAccessPoint(self, ap):
ap.distance = 0
self.accessPoints[ap.Name] = ap
def toDot(self, dotFile):
colors = ['red', 'blue', 'green', 'yellow', 'skyblue', 'violet', 'orange',
'lawngreen', 'crimson', 'chocolate', 'turquoise', 'tomato',
'navyblue', 'darkturquoise', 'green', 'blue', 'maroon', 'magenta',
'bisque', 'coral', 'chartreuse', 'chocolate', 'cyan']
with open(dotFile, "w") as f:
f.write("digraph {\n")
f.write('size="30,30!";\n')
f.write('rankdir=LR;\n')
f.write('ranksep=2.2;\n')
f.write('overlap=scale;\n')
f.write('edge [dir="both",arrowhead="box",arrowtail="box",arrowsize=0.5,fontsize=7,style=dotted];\n')
f.write('node [shape="box",fontsize=10];\n')
for area in set([ap.GraphArea for ap in self.accessPoints.values()]):
f.write(area + ";\n") # TODO area long name and color
drawn = []
i = 0
for src, dst in self.InterAreaTransitions:
if src.Name in drawn:
continue
f.write('%s:%s -> %s:%s [taillabel="%s",headlabel="%s",color=%s];\n' % (src.GraphArea, src.DotOrientation, dst.GraphArea, dst.DotOrientation, src.Name, dst.Name, colors[i]))
drawn += [src.Name,dst.Name]
i += 1
f.write("}\n")
def addTransition(self, srcName, dstName, both=True):
src = self.accessPoints[srcName]
dst = self.accessPoints[dstName]
src.connect(dstName)
self.InterAreaTransitions.append((src, dst))
if both is True:
self.addTransition(dstName, srcName, False)
# availNodes: all already available nodes
# nodesToCheck: nodes we have to check transitions for
# smbm: smbm to test logic on. if None, discard logic check, assume we can reach everything
# maxDiff: difficulty limit
# return newly opened access points
def getNewAvailNodes(self, availNodes, nodesToCheck, smbm, maxDiff, item=None):
newAvailNodes = {}
# with python >= 3.6 the insertion order in a dict is keeps when looping on the keys,
# so we no longer have to sort them.
for src in nodesToCheck:
for dstName in src.transitions:
dst = self.accessPoints[dstName]
if dst in availNodes or dst in newAvailNodes:
continue
if smbm is not None:
if self._useCache == True and (src, dst, item) in self.apCache:
diff = self.apCache[(src, dst, item)]
else:
tFunc = src.transitions[dstName]
diff = tFunc(smbm)
if self._useCache == True:
self.apCache[(src, dst, item)] = diff
else:
diff = SMBool(True)
if diff.bool and diff.difficulty <= maxDiff:
if src.GraphArea == dst.GraphArea:
dst.distance = src.distance + 0.01
else:
dst.distance = src.distance + 1
newAvailNodes[dst] = { 'difficulty': diff, 'from': src }
#self.log.debug("{} -> {}: {}".format(src.Name, dstName, diff))
return newAvailNodes
# rootNode: starting AccessPoint instance
# smbm: smbm to test logic on. if None, discard logic check, assume we can reach everything
# maxDiff: difficulty limit.
# smbm: if None, discard logic check, assume we can reach everything
# return available AccessPoint list
def getAvailableAccessPoints(self, rootNode, smbm, maxDiff, item=None):
availNodes = { rootNode : { 'difficulty' : SMBool(True, 0), 'from' : None } }
newAvailNodes = availNodes
rootNode.distance = 0
while len(newAvailNodes) > 0:
newAvailNodes = self.getNewAvailNodes(availNodes, newAvailNodes, smbm, maxDiff, item)
availNodes.update(newAvailNodes)
return availNodes
# gets path from the root AP used to compute availAps
def getPath(self, dstAp, availAps):
path = []
root = dstAp
while root != None:
path = [root] + path
root = availAps[root]['from']
return path
def getAvailAPPaths(self, availAccessPoints, locsAPs):
paths = {}
for ap in availAccessPoints:
if ap.Name in locsAPs:
path = self.getPath(ap, availAccessPoints)
pdiff = SMBool.wandmax(*(availAccessPoints[ap]['difficulty'] for ap in path))
paths[ap.Name] = Path(path, pdiff, len(path))
return paths
def getSortedAPs(self, paths, locAccessFrom):
ret = []
for apName in locAccessFrom:
path = paths.get(apName, None)
if path is None:
continue
difficulty = paths[apName].pdiff.difficulty
ret.append((difficulty if difficulty != -1 else infinity, path.distance, apName))
ret.sort()
return [apName for diff, dist, apName in ret]
# locations: locations to check
# items: collected items
# maxDiff: difficulty limit
# rootNode: starting AccessPoint
# return available locations list, also stores difficulty in locations
def getAvailableLocations(self, locations, smbm, maxDiff, rootNode='Landing Site'):
rootAp = self.accessPoints[rootNode]
self.availAccessPoints = self.getAvailableAccessPoints(rootAp, smbm, maxDiff)
availAreas = set([ap.GraphArea for ap in self.availAccessPoints.keys()])
availLocs = []
# get all the current locations APs first to only compute these paths
locsAPs = set()
for loc in locations:
for ap in loc.AccessFrom:
locsAPs.add(ap)
# sort availAccessPoints based on difficulty to take easier paths first
availAPPaths = self.getAvailAPPaths(self.availAccessPoints, locsAPs)
for loc in locations:
if loc.GraphArea not in availAreas:
loc.distance = 30000
loc.difficulty = smboolFalse
#if loc.Name == "Kraid":
# print("loc: {} locDiff is area nok".format(loc.Name))
continue
locAPs = self.getSortedAPs(availAPPaths, loc.AccessFrom)
if len(locAPs) == 0:
loc.distance = 40000
loc.difficulty = smboolFalse
#if loc.Name == "Kraid":
# print("loc: {} no aps".format(loc.Name))
continue
for apName in locAPs:
if apName == None:
loc.distance = 20000
loc.difficulty = smboolFalse
#if loc.Name == "Kraid":
# print("loc: {} ap is none".format(loc.Name))
break
tFunc = loc.AccessFrom[apName]
ap = self.accessPoints[apName]
tdiff = tFunc(smbm)
#if loc.Name == "Kraid":
# print("{} root: {} ap: {}".format(loc.Name, rootNode, apName))
if tdiff.bool == True and tdiff.difficulty <= maxDiff:
diff = loc.Available(smbm)
if diff.bool == True:
path = availAPPaths[apName].path
#if loc.Name == "Kraid":
# print("{} path: {}".format(loc.Name, [a.Name for a in path]))
pdiff = availAPPaths[apName].pdiff
(allDiff, locDiff) = self.computeLocDiff(tdiff, diff, pdiff)
if allDiff.bool == True and allDiff.difficulty <= maxDiff:
loc.distance = ap.distance + 1
loc.accessPoint = apName
loc.difficulty = allDiff
loc.path = path
# used only by solver
loc.pathDifficulty = pdiff
loc.locDifficulty = locDiff
availLocs.append(loc)
#if loc.Name == "Kraid":
# print("{} diff: {} tdiff: {} pdiff: {}".format(loc.Name, diff, tdiff, pdiff))
break
else:
loc.distance = 1000 + tdiff.difficulty
loc.difficulty = smboolFalse
#if loc.Name == "Kraid":
# print("loc: {} allDiff is false".format(loc.Name))
else:
loc.distance = 1000 + tdiff.difficulty
loc.difficulty = smboolFalse
#if loc.Name == "Kraid":
# print("loc: {} allDiff is false".format(loc.Name))
else:
loc.distance = 10000 + tdiff.difficulty
loc.difficulty = smboolFalse
#if loc.Name == "Kraid":
# print("loc: {} tdiff is false".format(loc.Name))
if loc.difficulty is None:
#if loc.Name == "Kraid":
# print("loc: {} no difficulty in loc".format(loc.Name))
loc.distance = 100000
loc.difficulty = smboolFalse
#if loc.Name == "Kraid":
# print("loc: {}: {}".format(loc.Name, loc))
#print("availableLocs: {}".format([loc.Name for loc in availLocs]))
return availLocs
# test access from an access point to another, given an optional item
def canAccess(self, smbm, srcAccessPointName, destAccessPointName, maxDiff, item=None):
if item is not None:
smbm.addItem(item)
#print("canAccess: item: {}, src: {}, dest: {}".format(item, srcAccessPointName, destAccessPointName))
destAccessPoint = self.accessPoints[destAccessPointName]
srcAccessPoint = self.accessPoints[srcAccessPointName]
availAccessPoints = self.getAvailableAccessPoints(srcAccessPoint, smbm, maxDiff, item)
can = destAccessPoint in availAccessPoints
# 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
# (not including source ap)
# or None if no possible path
def accessPath(self, smbm, srcAccessPointName, destAccessPointName, maxDiff):
destAccessPoint = self.accessPoints[destAccessPointName]
srcAccessPoint = self.accessPoints[srcAccessPointName]
availAccessPoints = self.getAvailableAccessPoints(srcAccessPoint, smbm, maxDiff)
if destAccessPoint not in availAccessPoints:
return None
return self.getPath(destAccessPoint, availAccessPoints)
# gives theoretically accessible APs in the graph (no logic check)
def getAccessibleAccessPoints(self, rootNode='Landing Site'):
rootAp = self.accessPoints[rootNode]
inBossChk = lambda ap: ap.Boss and ap.Name.endswith("In")
allAreas = {dst.GraphArea for (src, dst) in self.InterAreaTransitions if not inBossChk(dst) and not dst.isLoop()}
self.log.debug("allAreas="+str(allAreas))
nonBossAPs = [ap for ap in self.getAvailableAccessPoints(rootAp, None, 0) if ap.GraphArea in allAreas]
bossesAPs = [self.accessPoints[boss+'RoomIn'] for boss in Bosses.Golden4()] + [self.accessPoints['Draygon Room Bottom']]
return nonBossAPs + bossesAPs
# gives theoretically accessible locations within a base list
# returns locations with accessible GraphArea in this graph (no logic considered)
def getAccessibleLocations(self, locations, rootNode='Landing Site'):
availAccessPoints = self.getAccessibleAccessPoints(rootNode)
self.log.debug("availAccessPoints="+str([ap.Name for ap in availAccessPoints]))
return [loc for loc in locations if any(ap.Name in loc.AccessFrom for ap in availAccessPoints)]
class AccessGraphSolver(AccessGraph):
def computeLocDiff(self, tdiff, diff, pdiff):
# tdiff: difficulty from the location's access point to the location's room
# diff: difficulty to reach the item in the location's room
# pdiff: difficulty of the path from the current access point to the location's access point
# in output we need the global difficulty but we also need to separate pdiff and (tdiff + diff)
locDiff = SMBool.wandmax(tdiff, diff)
allDiff = SMBool.wandmax(locDiff, pdiff)
return (allDiff, locDiff)
class AccessGraphRando(AccessGraph):
def computeLocDiff(self, tdiff, diff, pdiff):
allDiff = SMBool.wandmax(tdiff, diff, pdiff)
return (allDiff, None)