Skip to content

Commit 61c1d2f

Browse files
committed
Merge branch 'main' into smw-main
2 parents 7f4eb70 + 32820ba commit 61c1d2f

File tree

214 files changed

+39449
-19660
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

214 files changed

+39449
-19660
lines changed

.github/workflows/unittests.yml

+1-1
Original file line numberDiff line numberDiff line change
@@ -37,4 +37,4 @@ jobs:
3737
python ModuleUpdate.py --yes --force --append "WebHostLib/requirements.txt"
3838
- name: Unittests
3939
run: |
40-
pytest test
40+
pytest

BaseClasses.py

+151
Original file line numberDiff line numberDiff line change
@@ -1369,6 +1369,157 @@ def parse_data(self):
13691369
self.bosses[str(player)]["Ganons Tower"] = "Agahnim 2"
13701370
self.bosses[str(player)]["Ganon"] = "Ganon"
13711371

1372+
def create_playthrough(self, create_paths: bool = True):
1373+
"""Destructive to the world while it is run, damage gets repaired afterwards."""
1374+
from itertools import chain
1375+
# get locations containing progress items
1376+
multiworld = self.multiworld
1377+
prog_locations = {location for location in multiworld.get_filled_locations() if location.item.advancement}
1378+
state_cache = [None]
1379+
collection_spheres: List[Set[Location]] = []
1380+
state = CollectionState(multiworld)
1381+
sphere_candidates = set(prog_locations)
1382+
logging.debug('Building up collection spheres.')
1383+
while sphere_candidates:
1384+
1385+
# build up spheres of collection radius.
1386+
# Everything in each sphere is independent from each other in dependencies and only depends on lower spheres
1387+
1388+
sphere = {location for location in sphere_candidates if state.can_reach(location)}
1389+
1390+
for location in sphere:
1391+
state.collect(location.item, True, location)
1392+
1393+
sphere_candidates -= sphere
1394+
collection_spheres.append(sphere)
1395+
state_cache.append(state.copy())
1396+
1397+
logging.debug('Calculated sphere %i, containing %i of %i progress items.', len(collection_spheres),
1398+
len(sphere),
1399+
len(prog_locations))
1400+
if not sphere:
1401+
logging.debug('The following items could not be reached: %s', ['%s (Player %d) at %s (Player %d)' % (
1402+
location.item.name, location.item.player, location.name, location.player) for location in
1403+
sphere_candidates])
1404+
if any([multiworld.accessibility[location.item.player] != 'minimal' for location in sphere_candidates]):
1405+
raise RuntimeError(f'Not all progression items reachable ({sphere_candidates}). '
1406+
f'Something went terribly wrong here.')
1407+
else:
1408+
self.unreachables = sphere_candidates
1409+
break
1410+
1411+
# in the second phase, we cull each sphere such that the game is still beatable,
1412+
# reducing each range of influence to the bare minimum required inside it
1413+
restore_later = {}
1414+
for num, sphere in reversed(tuple(enumerate(collection_spheres))):
1415+
to_delete = set()
1416+
for location in sphere:
1417+
# we remove the item at location and check if game is still beatable
1418+
logging.debug('Checking if %s (Player %d) is required to beat the game.', location.item.name,
1419+
location.item.player)
1420+
old_item = location.item
1421+
location.item = None
1422+
if multiworld.can_beat_game(state_cache[num]):
1423+
to_delete.add(location)
1424+
restore_later[location] = old_item
1425+
else:
1426+
# still required, got to keep it around
1427+
location.item = old_item
1428+
1429+
# cull entries in spheres for spoiler walkthrough at end
1430+
sphere -= to_delete
1431+
1432+
# second phase, sphere 0
1433+
removed_precollected = []
1434+
for item in (i for i in chain.from_iterable(multiworld.precollected_items.values()) if i.advancement):
1435+
logging.debug('Checking if %s (Player %d) is required to beat the game.', item.name, item.player)
1436+
multiworld.precollected_items[item.player].remove(item)
1437+
multiworld.state.remove(item)
1438+
if not multiworld.can_beat_game():
1439+
multiworld.push_precollected(item)
1440+
else:
1441+
removed_precollected.append(item)
1442+
1443+
# we are now down to just the required progress items in collection_spheres. Unfortunately
1444+
# the previous pruning stage could potentially have made certain items dependant on others
1445+
# in the same or later sphere (because the location had 2 ways to access but the item originally
1446+
# used to access it was deemed not required.) So we need to do one final sphere collection pass
1447+
# to build up the correct spheres
1448+
1449+
required_locations = {item for sphere in collection_spheres for item in sphere}
1450+
state = CollectionState(multiworld)
1451+
collection_spheres = []
1452+
while required_locations:
1453+
state.sweep_for_events(key_only=True)
1454+
1455+
sphere = set(filter(state.can_reach, required_locations))
1456+
1457+
for location in sphere:
1458+
state.collect(location.item, True, location)
1459+
1460+
required_locations -= sphere
1461+
1462+
collection_spheres.append(sphere)
1463+
1464+
logging.debug('Calculated final sphere %i, containing %i of %i progress items.', len(collection_spheres),
1465+
len(sphere), len(required_locations))
1466+
if not sphere:
1467+
raise RuntimeError(f'Not all required items reachable. Unreachable locations: {required_locations}')
1468+
1469+
# we can finally output our playthrough
1470+
self.playthrough = {"0": sorted([str(item) for item in
1471+
chain.from_iterable(multiworld.precollected_items.values())
1472+
if item.advancement])}
1473+
1474+
for i, sphere in enumerate(collection_spheres):
1475+
self.playthrough[str(i + 1)] = {
1476+
str(location): str(location.item) for location in sorted(sphere)}
1477+
if create_paths:
1478+
self.create_paths(state, collection_spheres)
1479+
1480+
# repair the multiworld again
1481+
for location, item in restore_later.items():
1482+
location.item = item
1483+
1484+
for item in removed_precollected:
1485+
multiworld.push_precollected(item)
1486+
1487+
def create_paths(self, state: CollectionState, collection_spheres: List[Set[Location]]):
1488+
from itertools import zip_longest
1489+
multiworld = self.multiworld
1490+
1491+
def flist_to_iter(node):
1492+
while node:
1493+
value, node = node
1494+
yield value
1495+
1496+
def get_path(state, region):
1497+
reversed_path_as_flist = state.path.get(region, (region, None))
1498+
string_path_flat = reversed(list(map(str, flist_to_iter(reversed_path_as_flist))))
1499+
# Now we combine the flat string list into (region, exit) pairs
1500+
pathsiter = iter(string_path_flat)
1501+
pathpairs = zip_longest(pathsiter, pathsiter)
1502+
return list(pathpairs)
1503+
1504+
self.paths = {}
1505+
topology_worlds = (player for player in multiworld.player_ids if multiworld.worlds[player].topology_present)
1506+
for player in topology_worlds:
1507+
self.paths.update(
1508+
{str(location): get_path(state, location.parent_region)
1509+
for sphere in collection_spheres for location in sphere
1510+
if location.player == player})
1511+
if player in multiworld.get_game_players("A Link to the Past"):
1512+
# If Pyramid Fairy Entrance needs to be reached, also path to Big Bomb Shop
1513+
# Maybe move the big bomb over to the Event system instead?
1514+
if any(exit_path == 'Pyramid Fairy' for path in self.paths.values()
1515+
for (_, exit_path) in path):
1516+
if multiworld.mode[player] != 'inverted':
1517+
self.paths[str(multiworld.get_region('Big Bomb Shop', player))] = \
1518+
get_path(state, multiworld.get_region('Big Bomb Shop', player))
1519+
else:
1520+
self.paths[str(multiworld.get_region('Inverted Big Bomb Shop', player))] = \
1521+
get_path(state, multiworld.get_region('Inverted Big Bomb Shop', player))
1522+
13721523
def to_json(self):
13731524
self.parse_data()
13741525
out = OrderedDict()

Main.py

+1-142
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import collections
2-
from itertools import zip_longest, chain
32
import logging
43
import os
54
import time
@@ -417,7 +416,7 @@ def precollect_hint(location):
417416

418417
if args.spoiler > 1:
419418
logger.info('Calculating playthrough.')
420-
create_playthrough(world)
419+
world.spoiler.create_playthrough(create_paths=args.spoiler > 2)
421420

422421
if args.spoiler:
423422
world.spoiler.to_file(os.path.join(temp_dir, '%s_Spoiler.txt' % outfilebase))
@@ -431,143 +430,3 @@ def precollect_hint(location):
431430

432431
logger.info('Done. Enjoy. Total Time: %s', time.perf_counter() - start)
433432
return world
434-
435-
436-
def create_playthrough(world):
437-
"""Destructive to the world while it is run, damage gets repaired afterwards."""
438-
# get locations containing progress items
439-
prog_locations = {location for location in world.get_filled_locations() if location.item.advancement}
440-
state_cache = [None]
441-
collection_spheres = []
442-
state = CollectionState(world)
443-
sphere_candidates = set(prog_locations)
444-
logging.debug('Building up collection spheres.')
445-
while sphere_candidates:
446-
447-
# build up spheres of collection radius.
448-
# Everything in each sphere is independent from each other in dependencies and only depends on lower spheres
449-
450-
sphere = {location for location in sphere_candidates if state.can_reach(location)}
451-
452-
for location in sphere:
453-
state.collect(location.item, True, location)
454-
455-
sphere_candidates -= sphere
456-
collection_spheres.append(sphere)
457-
state_cache.append(state.copy())
458-
459-
logging.debug('Calculated sphere %i, containing %i of %i progress items.', len(collection_spheres), len(sphere),
460-
len(prog_locations))
461-
if not sphere:
462-
logging.debug('The following items could not be reached: %s', ['%s (Player %d) at %s (Player %d)' % (
463-
location.item.name, location.item.player, location.name, location.player) for location in
464-
sphere_candidates])
465-
if any([world.accessibility[location.item.player] != 'minimal' for location in sphere_candidates]):
466-
raise RuntimeError(f'Not all progression items reachable ({sphere_candidates}). '
467-
f'Something went terribly wrong here.')
468-
else:
469-
world.spoiler.unreachables = sphere_candidates
470-
break
471-
472-
# in the second phase, we cull each sphere such that the game is still beatable,
473-
# reducing each range of influence to the bare minimum required inside it
474-
restore_later = {}
475-
for num, sphere in reversed(tuple(enumerate(collection_spheres))):
476-
to_delete = set()
477-
for location in sphere:
478-
# we remove the item at location and check if game is still beatable
479-
logging.debug('Checking if %s (Player %d) is required to beat the game.', location.item.name,
480-
location.item.player)
481-
old_item = location.item
482-
location.item = None
483-
if world.can_beat_game(state_cache[num]):
484-
to_delete.add(location)
485-
restore_later[location] = old_item
486-
else:
487-
# still required, got to keep it around
488-
location.item = old_item
489-
490-
# cull entries in spheres for spoiler walkthrough at end
491-
sphere -= to_delete
492-
493-
# second phase, sphere 0
494-
removed_precollected = []
495-
for item in (i for i in chain.from_iterable(world.precollected_items.values()) if i.advancement):
496-
logging.debug('Checking if %s (Player %d) is required to beat the game.', item.name, item.player)
497-
world.precollected_items[item.player].remove(item)
498-
world.state.remove(item)
499-
if not world.can_beat_game():
500-
world.push_precollected(item)
501-
else:
502-
removed_precollected.append(item)
503-
504-
# we are now down to just the required progress items in collection_spheres. Unfortunately
505-
# the previous pruning stage could potentially have made certain items dependant on others
506-
# in the same or later sphere (because the location had 2 ways to access but the item originally
507-
# used to access it was deemed not required.) So we need to do one final sphere collection pass
508-
# to build up the correct spheres
509-
510-
required_locations = {item for sphere in collection_spheres for item in sphere}
511-
state = CollectionState(world)
512-
collection_spheres = []
513-
while required_locations:
514-
state.sweep_for_events(key_only=True)
515-
516-
sphere = set(filter(state.can_reach, required_locations))
517-
518-
for location in sphere:
519-
state.collect(location.item, True, location)
520-
521-
required_locations -= sphere
522-
523-
collection_spheres.append(sphere)
524-
525-
logging.debug('Calculated final sphere %i, containing %i of %i progress items.', len(collection_spheres),
526-
len(sphere), len(required_locations))
527-
if not sphere:
528-
raise RuntimeError(f'Not all required items reachable. Unreachable locations: {required_locations}')
529-
530-
def flist_to_iter(node):
531-
while node:
532-
value, node = node
533-
yield value
534-
535-
def get_path(state, region):
536-
reversed_path_as_flist = state.path.get(region, (region, None))
537-
string_path_flat = reversed(list(map(str, flist_to_iter(reversed_path_as_flist))))
538-
# Now we combine the flat string list into (region, exit) pairs
539-
pathsiter = iter(string_path_flat)
540-
pathpairs = zip_longest(pathsiter, pathsiter)
541-
return list(pathpairs)
542-
543-
world.spoiler.paths = {}
544-
topology_worlds = (player for player in world.player_ids if world.worlds[player].topology_present)
545-
for player in topology_worlds:
546-
world.spoiler.paths.update(
547-
{str(location): get_path(state, location.parent_region) for sphere in collection_spheres for location in
548-
sphere if location.player == player})
549-
if player in world.get_game_players("A Link to the Past"):
550-
# If Pyramid Fairy Entrance needs to be reached, also path to Big Bomb Shop
551-
# Maybe move the big bomb over to the Event system instead?
552-
if any(exit_path == 'Pyramid Fairy' for path in world.spoiler.paths.values() for (_, exit_path) in path):
553-
if world.mode[player] != 'inverted':
554-
world.spoiler.paths[str(world.get_region('Big Bomb Shop', player))] = \
555-
get_path(state, world.get_region('Big Bomb Shop', player))
556-
else:
557-
world.spoiler.paths[str(world.get_region('Inverted Big Bomb Shop', player))] = \
558-
get_path(state, world.get_region('Inverted Big Bomb Shop', player))
559-
560-
# we can finally output our playthrough
561-
world.spoiler.playthrough = {"0": sorted([str(item) for item in
562-
chain.from_iterable(world.precollected_items.values())
563-
if item.advancement])}
564-
565-
for i, sphere in enumerate(collection_spheres):
566-
world.spoiler.playthrough[str(i + 1)] = {str(location): str(location.item) for location in sorted(sphere)}
567-
568-
# repair the world again
569-
for location, item in restore_later.items():
570-
location.item = item
571-
572-
for item in removed_precollected:
573-
world.push_precollected(item)

OoTAdjuster.py

+9-3
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import logging
44
import random
55
import os
6+
import zipfile
67
from itertools import chain
78

89
from BaseClasses import MultiWorld
@@ -217,13 +218,18 @@ def adjust(args):
217218
# Load up the ROM
218219
rom = Rom(file=args.rom, force_use=True)
219220
delete_zootdec = True
220-
elif os.path.splitext(args.rom)[-1] == '.apz5':
221+
elif os.path.splitext(args.rom)[-1] in ['.apz5', '.zpf']:
221222
# Load vanilla ROM
222223
rom = Rom(file=args.vanilla_rom, force_use=True)
224+
apz5_file = args.rom
225+
base_name = os.path.splitext(apz5_file)[0]
223226
# Patch file
224-
apply_patch_file(rom, args.rom)
227+
apply_patch_file(rom, apz5_file,
228+
sub_file=(os.path.basename(base_name) + '.zpf'
229+
if zipfile.is_zipfile(apz5_file)
230+
else None))
225231
else:
226-
raise Exception("Invalid file extension; requires .n64, .z64, .apz5")
232+
raise Exception("Invalid file extension; requires .n64, .z64, .apz5, .zpf")
227233
# Call patch_cosmetics
228234
try:
229235
patch_cosmetics(ootworld, rom)

0 commit comments

Comments
 (0)