Archipelago/worlds/sm/variaRandomizer/graph/graph_utils.py

576 lines
24 KiB
Python

import copy
import random
from ..logic.logic import Logic
from ..utils.parameters import Knows
from ..graph.location import locationsDict
from ..rom.rom import snes_to_pc
from ..utils import log
# order expected by ROM patches
graphAreas = [
"Ceres",
"Crateria",
"GreenPinkBrinstar",
"RedBrinstar",
"WreckedShip",
"Kraid",
"Norfair",
"Crocomire",
"LowerNorfair",
"WestMaridia",
"EastMaridia",
"Tourian"
]
vanillaTransitions = [
('Lower Mushrooms Left', 'Green Brinstar Elevator'),
('Morph Ball Room Left', 'Green Hill Zone Top Right'),
('Moat Right', 'West Ocean Left'),
('Keyhunter Room Bottom', 'Red Brinstar Elevator'),
('Noob Bridge Right', 'Red Tower Top Left'),
('Crab Maze Left', 'Le Coude Right'),
('Kronic Boost Room Bottom Left', 'Lava Dive Right'),
('Crocomire Speedway Bottom', 'Crocomire Room Top'),
('Three Muskateers Room Left', 'Single Chamber Top Right'),
('Warehouse Entrance Left', 'East Tunnel Right'),
('East Tunnel Top Right', 'Crab Hole Bottom Left'),
('Caterpillar Room Top Right', 'Red Fish Room Left'),
('Glass Tunnel Top', 'Main Street Bottom'),
('Green Pirates Shaft Bottom Right', 'Golden Four'),
('Warehouse Entrance Right', 'Warehouse Zeela Room Left'),
('Crab Shaft Right', 'Aqueduct Top Left')
]
vanillaBossesTransitions = [
('KraidRoomOut', 'KraidRoomIn'),
('PhantoonRoomOut', 'PhantoonRoomIn'),
('DraygonRoomOut', 'DraygonRoomIn'),
('RidleyRoomOut', 'RidleyRoomIn')
]
# vanilla escape transition in first position
vanillaEscapeTransitions = [
('Tourian Escape Room 4 Top Right', 'Climb Bottom Left'),
('Brinstar Pre-Map Room Right', 'Green Brinstar Main Shaft Top Left'),
('Wrecked Ship Map Room', 'Basement Left'),
('Norfair Map Room', 'Business Center Mid Left'),
('Maridia Map Room', 'Crab Hole Bottom Right')
]
vanillaEscapeAnimalsTransitions = [
('Flyway Right 0', 'Bomb Torizo Room Left'),
('Flyway Right 1', 'Bomb Torizo Room Left'),
('Flyway Right 2', 'Bomb Torizo Room Left'),
('Flyway Right 3', 'Bomb Torizo Room Left'),
('Bomb Torizo Room Left Animals', 'Flyway Right')
]
escapeSource = 'Tourian Escape Room 4 Top Right'
escapeTargets = ['Green Brinstar Main Shaft Top Left', 'Basement Left', 'Business Center Mid Left', 'Crab Hole Bottom Right']
locIdsByAreaAddresses = {
"Ceres": snes_to_pc(0xA1F568),
"Crateria": snes_to_pc(0xA1F569),
"GreenPinkBrinstar": snes_to_pc(0xA1F57B),
"RedBrinstar": snes_to_pc(0xA1F58C),
"WreckedShip": snes_to_pc(0xA1F592),
"Kraid": snes_to_pc(0xA1F59E),
"Norfair": snes_to_pc(0xA1F5A2),
"Crocomire": snes_to_pc(0xA1F5B2),
"LowerNorfair": snes_to_pc(0xA1F5B8),
"WestMaridia": snes_to_pc(0xA1F5C3),
"EastMaridia": snes_to_pc(0xA1F5CB),
"Tourian": snes_to_pc(0xA1F5D7)
}
def getAccessPoint(apName, apList=None):
if apList is None:
apList = Logic.accessPoints
return next(ap for ap in apList if ap.Name == apName)
class GraphUtils:
log = log.get('GraphUtils')
def getStartAccessPointNames():
return [ap.Name for ap in Logic.accessPoints if ap.Start is not None]
def getStartAccessPointNamesCategory():
ret = {'regular': [], 'custom': [], 'area': []}
for ap in Logic.accessPoints:
if ap.Start == None:
continue
elif 'areaMode' in ap.Start and ap.Start['areaMode'] == True:
ret['area'].append(ap.Name)
elif GraphUtils.isStandardStart(ap.Name):
ret['regular'].append(ap.Name)
else:
ret['custom'].append(ap.Name)
return ret
def isStandardStart(startApName):
return startApName == 'Ceres' or startApName == 'Landing Site'
def getPossibleStartAPs(areaMode, maxDiff, morphPlacement, player):
ret = []
refused = {}
allStartAPs = GraphUtils.getStartAccessPointNames()
for apName in allStartAPs:
start = getAccessPoint(apName).Start
ok = True
cause = ""
if 'knows' in start:
for k in start['knows']:
if not Knows.knowsDict[player].knows(k, maxDiff):
ok = False
cause += Knows.desc[k]['display']+" is not known. "
break
if 'areaMode' in start and start['areaMode'] != areaMode:
ok = False
cause += "Start location available only with area randomization enabled. "
if 'forcedEarlyMorph' in start and start['forcedEarlyMorph'] == True and morphPlacement == 'late':
ok = False
cause += "Start location unavailable with late morph placement. "
if ok:
ret.append(apName)
else:
refused[apName] = cause
return ret, refused
def updateLocClassesStart(startGraphArea, split, possibleMajLocs, preserveMajLocs, nLocs):
locs = locationsDict
preserveMajLocs = [locs[locName] for locName in preserveMajLocs if locs[locName].isClass(split)]
possLocs = [locs[locName] for locName in possibleMajLocs][:nLocs]
GraphUtils.log.debug("possLocs="+str([loc.Name for loc in possLocs]))
candidates = [loc for loc in locs.values() if loc.GraphArea == startGraphArea and loc.isClass(split) and loc not in preserveMajLocs]
remLocs = [loc for loc in locs.values() if loc not in possLocs and loc not in candidates and loc.isClass(split)]
newLocs = []
while len(newLocs) < nLocs:
if len(candidates) == 0:
candidates = remLocs
loc = possLocs.pop(random.randint(0,len(possLocs)-1))
newLocs.append(loc)
loc.setClass([split])
if not loc in preserveMajLocs:
GraphUtils.log.debug("newMajor="+loc.Name)
loc = candidates.pop(random.randint(0,len(candidates)-1))
loc.setClass(["Minor"])
GraphUtils.log.debug("replaced="+loc.Name)
def getGraphPatches(startApName):
ap = getAccessPoint(startApName)
return ap.Start['patches'] if 'patches' in ap.Start else []
def createBossesTransitions():
transitions = vanillaBossesTransitions
def isVanilla():
for t in vanillaBossesTransitions:
if t not in transitions:
return False
return True
while isVanilla():
transitions = []
srcs = []
dsts = []
for (src,dst) in vanillaBossesTransitions:
srcs.append(src)
dsts.append(dst)
while len(srcs) > 0:
src = srcs.pop(random.randint(0,len(srcs)-1))
dst = dsts.pop(random.randint(0,len(dsts)-1))
transitions.append((src,dst))
return transitions
def createAreaTransitions(lightAreaRando=False):
if lightAreaRando:
return GraphUtils.createLightAreaTransitions()
else:
return GraphUtils.createRegularAreaTransitions()
def createRegularAreaTransitions(apList=None, apPred=None):
if apList is None:
apList = Logic.accessPoints
if apPred is None:
apPred = lambda ap: ap.isArea()
tFrom = []
tTo = []
apNames = [ap.Name for ap in apList if apPred(ap) == True]
transitions = []
def findTo(trFrom):
ap = getAccessPoint(trFrom, apList)
fromArea = ap.GraphArea
targets = [apName for apName in apNames if apName not in tTo and getAccessPoint(apName, apList).GraphArea != fromArea]
if len(targets) == 0: # fallback if no area transition is found
targets = [apName for apName in apNames if apName != ap.Name]
if len(targets) == 0: # extreme fallback: loop on itself
targets = [ap.Name]
return random.choice(targets)
def addTransition(src, dst):
tFrom.append(src)
tTo.append(dst)
while len(apNames) > 0:
sources = [apName for apName in apNames if apName not in tFrom]
src = random.choice(sources)
dst = findTo(src)
transitions.append((src, dst))
addTransition(src, dst)
addTransition(dst, src)
toRemove = [apName for apName in apNames if apName in tFrom and apName in tTo]
for apName in toRemove:
apNames.remove(apName)
return transitions
def getAPs(apPredicate, apList=None):
if apList is None:
apList = Logic.accessPoints
return [ap for ap in apList if apPredicate(ap) == True]
def loopUnusedTransitions(transitions, apList=None):
if apList is None:
apList = Logic.accessPoints
usedAPs = set()
for (src,dst) in transitions:
usedAPs.add(getAccessPoint(src, apList))
usedAPs.add(getAccessPoint(dst, apList))
unusedAPs = [ap for ap in apList if not ap.isInternal() and ap not in usedAPs]
for ap in unusedAPs:
transitions.append((ap.Name, ap.Name))
def createMinimizerTransitions(startApName, locLimit):
if startApName == 'Ceres':
startApName = 'Landing Site'
startAp = getAccessPoint(startApName)
def getNLocs(locsPredicate, locList=None):
if locList is None:
locList = Logic.locations
# leave out bosses and count post boss locs systematically
return len([loc for loc in locList if locsPredicate(loc) == True and not loc.SolveArea.endswith(" Boss") and not loc.isBoss()])
availAreas = list(sorted({ap.GraphArea for ap in Logic.accessPoints if ap.GraphArea != startAp.GraphArea and getNLocs(lambda loc: loc.GraphArea == ap.GraphArea) > 0}))
areas = [startAp.GraphArea]
GraphUtils.log.debug("availAreas: {}".format(availAreas))
GraphUtils.log.debug("areas: {}".format(areas))
inBossCheck = lambda ap: ap.Boss and ap.Name.endswith("In")
nLocs = 0
transitions = []
usedAPs = []
trLimit = 5
locLimit -= 3 # 3 "post boss" locs will always be available, and are filtered out in getNLocs
def openTransitions():
nonlocal areas, inBossCheck, usedAPs
return GraphUtils.getAPs(lambda ap: ap.GraphArea in areas and not ap.isInternal() and not inBossCheck(ap) and not ap in usedAPs)
while nLocs < locLimit or len(openTransitions()) < trLimit:
GraphUtils.log.debug("openTransitions="+str([ap.Name for ap in openTransitions()]))
fromAreas = availAreas
if nLocs >= locLimit:
GraphUtils.log.debug("not enough open transitions")
# we just need transitions, avoid adding a huge area
fromAreas = []
n = trLimit - len(openTransitions())
while len(fromAreas) == 0:
fromAreas = [area for area in availAreas if len(GraphUtils.getAPs(lambda ap: not ap.isInternal())) > n]
n -= 1
minLocs = min([getNLocs(lambda loc: loc.GraphArea == area) for area in fromAreas])
fromAreas = [area for area in fromAreas if getNLocs(lambda loc: loc.GraphArea == area) == minLocs]
elif len(openTransitions()) <= 1: # dont' get stuck by adding dead ends
fromAreas = [area for area in fromAreas if len(GraphUtils.getAPs(lambda ap: ap.GraphArea == area and not ap.isInternal())) > 1]
nextArea = random.choice(fromAreas)
GraphUtils.log.debug("nextArea="+str(nextArea))
apCheck = lambda ap: not ap.isInternal() and not inBossCheck(ap) and ap not in usedAPs
possibleSources = GraphUtils.getAPs(lambda ap: ap.GraphArea in areas and apCheck(ap))
possibleTargets = GraphUtils.getAPs(lambda ap: ap.GraphArea == nextArea and apCheck(ap))
src = random.choice(possibleSources)
dst = random.choice(possibleTargets)
usedAPs += [src,dst]
GraphUtils.log.debug("add transition: (src: {}, dst: {})".format(src.Name, dst.Name))
transitions.append((src.Name,dst.Name))
availAreas.remove(nextArea)
areas.append(nextArea)
GraphUtils.log.debug("areas: {}".format(areas))
nLocs = getNLocs(lambda loc:loc.GraphArea in areas)
GraphUtils.log.debug("nLocs: {}".format(nLocs))
# we picked the areas, add transitions (bosses and tourian first)
sourceAPs = openTransitions()
random.shuffle(sourceAPs)
targetAPs = GraphUtils.getAPs(lambda ap: (inBossCheck(ap) or ap.Name == "Golden Four") and not ap in usedAPs)
random.shuffle(targetAPs)
assert len(sourceAPs) >= len(targetAPs), "Minimizer: less source than target APs"
while len(targetAPs) > 0:
transitions.append((sourceAPs.pop().Name, targetAPs.pop().Name))
transitions += GraphUtils.createRegularAreaTransitions(sourceAPs, lambda ap: not ap.isInternal())
GraphUtils.log.debug("FINAL MINIMIZER transitions: {}".format(transitions))
GraphUtils.loopUnusedTransitions(transitions)
GraphUtils.log.debug("FINAL MINIMIZER nLocs: "+str(nLocs+3))
GraphUtils.log.debug("FINAL MINIMIZER areas: "+str(areas))
return transitions
def createLightAreaTransitions():
# group APs by area
aps = {}
totalCount = 0
for ap in Logic.accessPoints:
if not ap.isArea():
continue
if not ap.GraphArea in aps:
aps[ap.GraphArea] = {'totalCount': 0, 'transCount': {}, 'apNames': []}
aps[ap.GraphArea]['apNames'].append(ap.Name)
# count number of vanilla transitions between each area
for (srcName, destName) in vanillaTransitions:
srcAP = getAccessPoint(srcName)
destAP = getAccessPoint(destName)
aps[srcAP.GraphArea]['transCount'][destAP.GraphArea] = aps[srcAP.GraphArea]['transCount'].get(destAP.GraphArea, 0) + 1
aps[srcAP.GraphArea]['totalCount'] += 1
aps[destAP.GraphArea]['transCount'][srcAP.GraphArea] = aps[destAP.GraphArea]['transCount'].get(srcAP.GraphArea, 0) + 1
aps[destAP.GraphArea]['totalCount'] += 1
totalCount += 1
transitions = []
while totalCount > 0:
# choose transition
srcArea = random.choice(list(aps.keys()))
srcName = random.choice(aps[srcArea]['apNames'])
src = getAccessPoint(srcName)
destArea = random.choice(list(aps[src.GraphArea]['transCount'].keys()))
destName = random.choice(aps[destArea]['apNames'])
transitions.append((srcName, destName))
# update counts
totalCount -= 1
aps[srcArea]['totalCount'] -= 1
aps[destArea]['totalCount'] -= 1
aps[srcArea]['transCount'][destArea] -= 1
if aps[srcArea]['transCount'][destArea] == 0:
del aps[srcArea]['transCount'][destArea]
aps[destArea]['transCount'][srcArea] -= 1
if aps[destArea]['transCount'][srcArea] == 0:
del aps[destArea]['transCount'][srcArea]
aps[srcArea]['apNames'].remove(srcName)
aps[destArea]['apNames'].remove(destName)
if aps[srcArea]['totalCount'] == 0:
del aps[srcArea]
if aps[destArea]['totalCount'] == 0:
del aps[destArea]
return transitions
def getVanillaExit(apName):
allVanillaTransitions = vanillaTransitions + vanillaBossesTransitions + vanillaEscapeTransitions
for (src,dst) in allVanillaTransitions:
if apName == src:
return dst
if apName == dst:
return src
return None
def isEscapeAnimals(apName):
return 'Flyway Right' in apName or 'Bomb Torizo Room Left' in apName
# gets dict like
# (RoomPtr, (vanilla entry screen X, vanilla entry screen Y)): AP
def getRooms():
rooms = {}
for ap in Logic.accessPoints:
if ap.Internal == True:
continue
# special ap for random escape animals surprise
if GraphUtils.isEscapeAnimals(ap.Name):
continue
roomPtr = ap.RoomInfo['RoomPtr']
vanillaExitName = GraphUtils.getVanillaExit(ap.Name)
# special ap for random escape animals surprise
if GraphUtils.isEscapeAnimals(vanillaExitName):
continue
connAP = getAccessPoint(vanillaExitName)
entryInfo = connAP.ExitInfo
rooms[(roomPtr, entryInfo['screen'], entryInfo['direction'])] = ap
rooms[(roomPtr, entryInfo['screen'], (ap.EntryInfo['SamusX'], ap.EntryInfo['SamusY']))] = ap
# for boss rando with incompatible ridley transition, also register this one
if ap.Name == 'RidleyRoomIn':
rooms[(roomPtr, (0x0, 0x1), 0x5)] = ap
rooms[(roomPtr, (0x0, 0x1), (0xbf, 0x198))] = ap
return rooms
def escapeAnimalsTransitions(graph, possibleTargets, firstEscape):
n = len(possibleTargets)
assert (n < 4 and firstEscape is not None) or (n <= 4 and firstEscape is None), "Invalid possibleTargets list: " + str(possibleTargets)
# first get our list of 4 entries for escape patch
if n >= 1:
# get actual animals: pick one of the remaining targets
animalsAccess = possibleTargets.pop()
graph.EscapeAttributes['Animals'] = animalsAccess
# we now have at most 3 targets left, fill up to fill cycling 4 targets for animals suprise
possibleTargets.append('Climb Bottom Left')
if firstEscape is not None:
possibleTargets.append(firstEscape)
poss = possibleTargets[:]
while len(possibleTargets) < 4:
possibleTargets.append(poss.pop(random.randint(0, len(poss)-1)))
else:
# failsafe: if not enough targets left, abort and do vanilla animals
animalsAccess = 'Flyway Right'
possibleTargets = ['Bomb Torizo Room Left'] * 4
GraphUtils.log.debug("escapeAnimalsTransitions. animalsAccess="+animalsAccess)
assert len(possibleTargets) == 4, "Invalid possibleTargets list: " + str(possibleTargets)
# actually add the 4 connections for successive escapes challenge
basePtr = 0xADAC
btDoor = getAccessPoint('Flyway Right')
for i in range(len(possibleTargets)):
ap = copy.copy(btDoor)
ap.Name += " " + str(i)
ap.ExitInfo['DoorPtr'] = basePtr + i*24
graph.addAccessPoint(ap)
target = possibleTargets[i]
graph.addTransition(ap.Name, target)
# add the connection for animals access
bt = getAccessPoint('Bomb Torizo Room Left')
btCpy = copy.copy(bt)
btCpy.Name += " Animals"
btCpy.ExitInfo['DoorPtr'] = 0xAE00
graph.addAccessPoint(btCpy)
graph.addTransition(animalsAccess, btCpy.Name)
def isHorizontal(dir):
# up: 0x3, 0x7
# down: 0x2, 0x6
# left: 0x1, 0x5
# right: 0x0, 0x4
return dir in [0x1, 0x5, 0x0, 0x4]
def removeCap(dir):
if dir < 4:
return dir
return dir - 4
def getDirection(src, dst):
exitDir = src.ExitInfo['direction']
entryDir = dst.EntryInfo['direction']
# compatible transition
if exitDir == entryDir:
return exitDir
# if incompatible but horizontal we keep entry dir (looks more natural)
if GraphUtils.isHorizontal(exitDir) and GraphUtils.isHorizontal(entryDir):
return entryDir
# otherwise keep exit direction and remove cap
return GraphUtils.removeCap(exitDir)
def getBitFlag(srcArea, dstArea, origFlag):
flags = origFlag
if srcArea == dstArea:
flags &= 0xBF
else:
flags |= 0x40
return flags
def getDoorConnections(graph, areas=True, bosses=False,
escape=True, escapeAnimals=True):
transitions = []
if areas:
transitions += vanillaTransitions
if bosses:
transitions += vanillaBossesTransitions
if escape:
transitions += vanillaEscapeTransitions
if escapeAnimals:
transitions += vanillaEscapeAnimalsTransitions
for srcName, dstName in transitions:
src = graph.accessPoints[srcName]
dst = graph.accessPoints[dstName]
dst.EntryInfo.update(src.ExitInfo)
src.EntryInfo.update(dst.ExitInfo)
connections = []
for src, dst in graph.InterAreaTransitions:
if not (escape and src.Escape and dst.Escape):
# area only
if not bosses and src.Boss:
continue
# boss only
if not areas and not src.Boss:
continue
# no random escape
if not escape and src.Escape:
continue
conn = {}
conn['ID'] = str(src) + ' -> ' + str(dst)
# remove duplicates (loop transitions)
if any(c['ID'] == conn['ID'] for c in connections):
continue
# print(conn['ID'])
# where to write
conn['DoorPtr'] = src.ExitInfo['DoorPtr']
# door properties
conn['RoomPtr'] = dst.RoomInfo['RoomPtr']
conn['doorAsmPtr'] = dst.EntryInfo['doorAsmPtr']
if 'exitAsmPtr' in src.ExitInfo:
conn['exitAsmPtr'] = src.ExitInfo['exitAsmPtr']
conn['direction'] = GraphUtils.getDirection(src, dst)
conn['bitFlag'] = GraphUtils.getBitFlag(src.RoomInfo['area'], dst.RoomInfo['area'],
dst.EntryInfo['bitFlag'])
conn['cap'] = dst.EntryInfo['cap']
conn['screen'] = dst.EntryInfo['screen']
if conn['direction'] != src.ExitInfo['direction']: # incompatible transition
conn['distanceToSpawn'] = 0
conn['SamusX'] = dst.EntryInfo['SamusX']
conn['SamusY'] = dst.EntryInfo['SamusY']
if dst.Name == 'RidleyRoomIn': # special case: spawn samus on ridley platform
conn['screen'] = (0x0, 0x1)
else:
conn['distanceToSpawn'] = dst.EntryInfo['distanceToSpawn']
if 'song' in dst.EntryInfo:
conn['song'] = dst.EntryInfo['song']
conn['songs'] = dst.RoomInfo['songs']
connections.append(conn)
return connections
def getDoorsPtrs2Aps():
ret = {}
for ap in Logic.accessPoints:
if ap.Internal == True:
continue
ret[ap.ExitInfo["DoorPtr"]] = ap.Name
return ret
def getAps2DoorsPtrs():
ret = {}
for ap in Logic.accessPoints:
if ap.Internal == True:
continue
ret[ap.Name] = ap.ExitInfo["DoorPtr"]
return ret
def getTransitions(addresses):
# build address -> name dict
doorsPtrs = GraphUtils.getDoorsPtrs2Aps()
transitions = []
# (src.ExitInfo['DoorPtr'], dst.ExitInfo['DoorPtr'])
for (srcDoorPtr, destDoorPtr) in addresses:
transitions.append((doorsPtrs[srcDoorPtr], doorsPtrs[destDoorPtr]))
return transitions
def hasMixedTransitions(areaTransitions, bossTransitions):
vanillaAPs = []
for (src, dest) in vanillaTransitions:
vanillaAPs += [src, dest]
vanillaBossesAPs = []
for (src, dest) in vanillaBossesTransitions:
vanillaBossesAPs += [src, dest]
for (src, dest) in areaTransitions:
if src in vanillaBossesAPs or dest in vanillaBossesAPs:
return True
for (src, dest) in bossTransitions:
if src in vanillaAPs or dest in vanillaAPs:
return True
return False