mirror of
https://github.com/ArchipelagoMW/Archipelago.git
synced 2026-03-24 08:53:27 -07:00
Merge branch 'ArchipelagoMW:main' into main
This commit is contained in:
6
.gitignore
vendored
6
.gitignore
vendored
@@ -13,6 +13,10 @@
|
||||
*.z64
|
||||
*.n64
|
||||
*.nes
|
||||
*.sms
|
||||
*.gb
|
||||
*.gbc
|
||||
*.gba
|
||||
*.wixobj
|
||||
*.lck
|
||||
*.db3
|
||||
@@ -125,7 +129,7 @@ ipython_config.py
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.venv
|
||||
.venv*
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from __future__ import annotations
|
||||
from argparse import Namespace
|
||||
|
||||
import copy
|
||||
from enum import unique, IntEnum, IntFlag
|
||||
@@ -40,6 +41,7 @@ class MultiWorld():
|
||||
plando_connections: List
|
||||
worlds: Dict[int, auto_world]
|
||||
groups: Dict[int, Group]
|
||||
regions: List[Region]
|
||||
itempool: List[Item]
|
||||
is_race: bool = False
|
||||
precollected_items: Dict[int, List[Item]]
|
||||
@@ -50,6 +52,10 @@ class MultiWorld():
|
||||
non_local_items: Dict[int, Options.NonLocalItems]
|
||||
progression_balancing: Dict[int, Options.ProgressionBalancing]
|
||||
completion_condition: Dict[int, Callable[[CollectionState], bool]]
|
||||
indirect_connections: Dict[Region, Set[Entrance]]
|
||||
exclude_locations: Dict[int, Options.ExcludeLocations]
|
||||
|
||||
game: Dict[int, str]
|
||||
|
||||
class AttributeProxy():
|
||||
def __init__(self, rule):
|
||||
@@ -87,6 +93,7 @@ class MultiWorld():
|
||||
self.customitemarray = []
|
||||
self.shuffle_ganon = True
|
||||
self.spoiler = Spoiler(self)
|
||||
self.indirect_connections = {}
|
||||
self.fix_trock_doors = self.AttributeProxy(
|
||||
lambda player: self.shuffle[player] != 'vanilla' or self.mode[player] == 'inverted')
|
||||
self.fix_skullwoods_exit = self.AttributeProxy(
|
||||
@@ -195,7 +202,7 @@ class MultiWorld():
|
||||
self.slot_seeds = {player: random.Random(self.random.getrandbits(64)) for player in
|
||||
range(1, self.players + 1)}
|
||||
|
||||
def set_options(self, args):
|
||||
def set_options(self, args: Namespace) -> None:
|
||||
for option_key in Options.common_options:
|
||||
setattr(self, option_key, getattr(args, option_key, {}))
|
||||
for option_key in Options.per_game_common_options:
|
||||
@@ -295,6 +302,13 @@ class MultiWorld():
|
||||
def get_file_safe_player_name(self, player: int) -> str:
|
||||
return ''.join(c for c in self.get_player_name(player) if c not in '<>:"/\\|?*')
|
||||
|
||||
def get_out_file_name_base(self, player: int) -> str:
|
||||
""" the base name (without file extension) for each player's output file for a seed """
|
||||
return f"AP_{self.seed_name}_P{player}" \
|
||||
+ (f"_{self.get_file_safe_player_name(player).replace(' ', '_')}"
|
||||
if (self.player_name[player] != f"Player{player}")
|
||||
else '')
|
||||
|
||||
def initialize_regions(self, regions=None):
|
||||
for region in regions if regions else self.regions:
|
||||
region.world = self
|
||||
@@ -404,6 +418,11 @@ class MultiWorld():
|
||||
def clear_entrance_cache(self):
|
||||
self._cached_entrances = None
|
||||
|
||||
def register_indirect_condition(self, region: Region, entrance: Entrance):
|
||||
"""Report that access to this Region can result in unlocking this Entrance,
|
||||
state.can_reach(Region) in the Entrance's traversal condition, as opposed to pure transition logic."""
|
||||
self.indirect_connections.setdefault(region, set()).add(entrance)
|
||||
|
||||
def get_locations(self) -> List[Location]:
|
||||
if self._cached_locations is None:
|
||||
self._cached_locations = [location for region in self.regions for location in region.locations]
|
||||
@@ -529,7 +548,7 @@ class MultiWorld():
|
||||
|
||||
beatable_fulfilled = False
|
||||
|
||||
def location_conditition(location: Location):
|
||||
def location_condition(location: Location):
|
||||
"""Determine if this location has to be accessible, location is already filtered by location_relevant"""
|
||||
if location.player in players["minimal"]:
|
||||
return False
|
||||
@@ -546,7 +565,7 @@ class MultiWorld():
|
||||
def all_done():
|
||||
"""Check if all access rules are fulfilled"""
|
||||
if beatable_fulfilled:
|
||||
if any(location_conditition(location) for location in locations):
|
||||
if any(location_condition(location) for location in locations):
|
||||
return False # still locations required to be collected
|
||||
return True
|
||||
|
||||
@@ -608,7 +627,6 @@ class CollectionState():
|
||||
self.collect(item, True)
|
||||
|
||||
def update_reachable_regions(self, player: int):
|
||||
from worlds.alttp.EntranceShuffle import indirect_connections
|
||||
self.stale[player] = False
|
||||
rrp = self.reachable_regions[player]
|
||||
bc = self.blocked_connections[player]
|
||||
@@ -616,7 +634,7 @@ class CollectionState():
|
||||
start = self.world.get_region('Menu', player)
|
||||
|
||||
# init on first call - this can't be done on construction since the regions don't exist yet
|
||||
if not start in rrp:
|
||||
if start not in rrp:
|
||||
rrp.add(start)
|
||||
bc.update(start.exits)
|
||||
queue.extend(start.exits)
|
||||
@@ -636,8 +654,7 @@ class CollectionState():
|
||||
self.path[new_region] = (new_region.name, self.path.get(connection, None))
|
||||
|
||||
# Retry connections if the new region can unblock them
|
||||
if new_region.name in indirect_connections:
|
||||
new_entrance = self.world.get_entrance(indirect_connections[new_region.name], player)
|
||||
for new_entrance in self.world.indirect_connections.get(new_region, set()):
|
||||
if new_entrance in bc and new_entrance not in queue:
|
||||
queue.append(new_entrance)
|
||||
|
||||
@@ -674,14 +691,14 @@ class CollectionState():
|
||||
def sweep_for_events(self, key_only: bool = False, locations: Optional[Iterable[Location]] = None) -> None:
|
||||
if locations is None:
|
||||
locations = self.world.get_filled_locations()
|
||||
new_locations = True
|
||||
reachable_events = True
|
||||
# since the loop has a good chance to run more than once, only filter the events once
|
||||
locations = {location for location in locations if location.event and
|
||||
locations = {location for location in locations if location.event and location not in self.events and
|
||||
not key_only or getattr(location.item, "locked_dungeon_item", False)}
|
||||
while new_locations:
|
||||
while reachable_events:
|
||||
reachable_events = {location for location in locations if location.can_reach(self)}
|
||||
new_locations = reachable_events - self.events
|
||||
for event in new_locations:
|
||||
locations -= reachable_events
|
||||
for event in reachable_events:
|
||||
self.events.add(event)
|
||||
assert isinstance(event.item, Item), "tried to collect Event with no Item"
|
||||
self.collect(event.item, True, event)
|
||||
@@ -993,7 +1010,7 @@ class Entrance:
|
||||
|
||||
return False
|
||||
|
||||
def connect(self, region: Region, addresses=None, target=None):
|
||||
def connect(self, region: Region, addresses: Any = None, target: Any = None) -> None:
|
||||
self.connected_region = region
|
||||
self.target = target
|
||||
self.addresses = addresses
|
||||
@@ -1081,7 +1098,7 @@ class Location:
|
||||
show_in_spoiler: bool = True
|
||||
progress_type: LocationProgressType = LocationProgressType.DEFAULT
|
||||
always_allow = staticmethod(lambda item, state: False)
|
||||
access_rule = staticmethod(lambda state: True)
|
||||
access_rule: Callable[[CollectionState], bool] = staticmethod(lambda state: True)
|
||||
item_rule = staticmethod(lambda item: True)
|
||||
item: Optional[Item] = None
|
||||
|
||||
|
||||
@@ -132,12 +132,12 @@ class CommonContext:
|
||||
# defaults
|
||||
starting_reconnect_delay: int = 5
|
||||
current_reconnect_delay: int = starting_reconnect_delay
|
||||
command_processor: type(CommandProcessor) = ClientCommandProcessor
|
||||
command_processor: typing.Type[CommandProcessor] = ClientCommandProcessor
|
||||
ui = None
|
||||
ui_task: typing.Optional[asyncio.Task] = None
|
||||
input_task: typing.Optional[asyncio.Task] = None
|
||||
keep_alive_task: typing.Optional[asyncio.Task] = None
|
||||
server_task: typing.Optional[asyncio.Task] = None
|
||||
ui_task: typing.Optional["asyncio.Task[None]"] = None
|
||||
input_task: typing.Optional["asyncio.Task[None]"] = None
|
||||
keep_alive_task: typing.Optional["asyncio.Task[None]"] = None
|
||||
server_task: typing.Optional["asyncio.Task[None]"] = None
|
||||
server: typing.Optional[Endpoint] = None
|
||||
server_version: Version = Version(0, 0, 0)
|
||||
current_energy_link_value: int = 0 # to display in UI, gets set by server
|
||||
@@ -146,14 +146,20 @@ class CommonContext:
|
||||
|
||||
# remaining type info
|
||||
slot_info: typing.Dict[int, NetworkSlot]
|
||||
server_address: str
|
||||
server_address: typing.Optional[str]
|
||||
password: typing.Optional[str]
|
||||
hint_cost: typing.Optional[int]
|
||||
player_names: typing.Dict[int, str]
|
||||
|
||||
finished_game: bool
|
||||
ready: bool
|
||||
auth: typing.Optional[str]
|
||||
seed_name: typing.Optional[str]
|
||||
|
||||
# locations
|
||||
locations_checked: typing.Set[int] # local state
|
||||
locations_scouted: typing.Set[int]
|
||||
items_received: typing.List[NetworkItem]
|
||||
missing_locations: typing.Set[int] # server state
|
||||
checked_locations: typing.Set[int] # server state
|
||||
server_locations: typing.Set[int] # all locations the server knows of, missing_location | checked_locations
|
||||
@@ -163,7 +169,7 @@ class CommonContext:
|
||||
# current message box through kvui
|
||||
_messagebox = None
|
||||
|
||||
def __init__(self, server_address, password):
|
||||
def __init__(self, server_address: typing.Optional[str], password: typing.Optional[str]) -> None:
|
||||
# server state
|
||||
self.server_address = server_address
|
||||
self.username = None
|
||||
@@ -243,7 +249,8 @@ class CommonContext:
|
||||
if self.server_task is not None:
|
||||
await self.server_task
|
||||
|
||||
async def send_msgs(self, msgs):
|
||||
async def send_msgs(self, msgs: typing.List[typing.Any]) -> None:
|
||||
""" `msgs` JSON serializable """
|
||||
if not self.server or not self.server.socket.open or self.server.socket.closed:
|
||||
return
|
||||
await self.server.socket.send(encode(msgs))
|
||||
@@ -271,7 +278,7 @@ class CommonContext:
|
||||
logger.info('Enter slot name:')
|
||||
self.auth = await self.console_input()
|
||||
|
||||
async def send_connect(self, **kwargs):
|
||||
async def send_connect(self, **kwargs: typing.Any) -> None:
|
||||
payload = {
|
||||
'cmd': 'Connect',
|
||||
'password': self.password, 'name': self.auth, 'version': Utils.version_tuple,
|
||||
@@ -282,11 +289,11 @@ class CommonContext:
|
||||
payload.update(kwargs)
|
||||
await self.send_msgs([payload])
|
||||
|
||||
async def console_input(self):
|
||||
async def console_input(self) -> str:
|
||||
self.input_requests += 1
|
||||
return await self.input_queue.get()
|
||||
|
||||
async def connect(self, address=None):
|
||||
async def connect(self, address: typing.Optional[str] = None) -> None:
|
||||
await self.disconnect()
|
||||
self.server_task = asyncio.create_task(server_loop(self, address), name="server loop")
|
||||
|
||||
@@ -390,7 +397,7 @@ class CommonContext:
|
||||
|
||||
# DeathLink hooks
|
||||
|
||||
def on_deathlink(self, data: dict):
|
||||
def on_deathlink(self, data: typing.Dict[str, typing.Any]) -> None:
|
||||
"""Gets dispatched when a new DeathLink is triggered by another linked player."""
|
||||
self.last_death_link = max(data["time"], self.last_death_link)
|
||||
text = data.get("cause", "")
|
||||
@@ -477,7 +484,7 @@ async def keep_alive(ctx: CommonContext, seconds_between_checks=100):
|
||||
seconds_elapsed = 0
|
||||
|
||||
|
||||
async def server_loop(ctx: CommonContext, address=None):
|
||||
async def server_loop(ctx: CommonContext, address: typing.Optional[str] = None) -> None:
|
||||
if ctx.server and ctx.server.socket:
|
||||
logger.error('Already connected')
|
||||
return
|
||||
@@ -722,7 +729,7 @@ async def console_loop(ctx: CommonContext):
|
||||
logger.exception(e)
|
||||
|
||||
|
||||
def get_base_parser(description=None):
|
||||
def get_base_parser(description: typing.Optional[str] = None):
|
||||
import argparse
|
||||
parser = argparse.ArgumentParser(description=description)
|
||||
parser.add_argument('--connect', default=None, help='Address of the multiworld host.')
|
||||
|
||||
@@ -211,6 +211,8 @@ async def game_watcher(ctx: FactorioContext):
|
||||
|
||||
|
||||
def stream_factorio_output(pipe, queue, process):
|
||||
pipe.reconfigure(errors="replace")
|
||||
|
||||
def queuer():
|
||||
while process.poll() is None:
|
||||
text = pipe.readline().strip()
|
||||
|
||||
157
Fill.py
157
Fill.py
@@ -4,9 +4,10 @@ import collections
|
||||
import itertools
|
||||
from collections import Counter, deque
|
||||
|
||||
from BaseClasses import CollectionState, Location, LocationProgressType, MultiWorld, Item
|
||||
from BaseClasses import CollectionState, Location, LocationProgressType, MultiWorld, Item, ItemClassification
|
||||
|
||||
from worlds.AutoWorld import call_all
|
||||
from worlds.generic.Rules import add_item_rule
|
||||
|
||||
|
||||
class FillError(RuntimeError):
|
||||
@@ -22,7 +23,8 @@ def sweep_from_pool(base_state: CollectionState, itempool: typing.Sequence[Item]
|
||||
|
||||
|
||||
def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations: typing.List[Location],
|
||||
itempool: typing.List[Item], single_player_placement: bool = False, lock: bool = False) -> None:
|
||||
itempool: typing.List[Item], single_player_placement: bool = False, lock: bool = False,
|
||||
swap: bool = True, on_place: typing.Optional[typing.Callable[[Location], None]] = None) -> None:
|
||||
unplaced_items: typing.List[Item] = []
|
||||
placements: typing.List[Location] = []
|
||||
|
||||
@@ -69,60 +71,66 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations:
|
||||
|
||||
else:
|
||||
# we filled all reachable spots.
|
||||
# try swapping this item with previously placed items
|
||||
for (i, location) in enumerate(placements):
|
||||
placed_item = location.item
|
||||
# Unplaceable items can sometimes be swapped infinitely. Limit the
|
||||
# number of times we will swap an individual item to prevent this
|
||||
swap_count = swapped_items[placed_item.player,
|
||||
placed_item.name]
|
||||
if swap_count > 1:
|
||||
if swap:
|
||||
# try swapping this item with previously placed items
|
||||
for (i, location) in enumerate(placements):
|
||||
placed_item = location.item
|
||||
# Unplaceable items can sometimes be swapped infinitely. Limit the
|
||||
# number of times we will swap an individual item to prevent this
|
||||
swap_count = swapped_items[placed_item.player,
|
||||
placed_item.name]
|
||||
if swap_count > 1:
|
||||
continue
|
||||
|
||||
location.item = None
|
||||
placed_item.location = None
|
||||
swap_state = sweep_from_pool(base_state, [placed_item])
|
||||
# swap_state assumes we can collect placed item before item_to_place
|
||||
if (not single_player_placement or location.player == item_to_place.player) \
|
||||
and location.can_fill(swap_state, item_to_place, perform_access_check):
|
||||
|
||||
# Verify that placing this item won't reduce available locations, which could happen with rules
|
||||
# that want to not have both items. Left in until removal is proven useful.
|
||||
prev_state = swap_state.copy()
|
||||
prev_loc_count = len(
|
||||
world.get_reachable_locations(prev_state))
|
||||
|
||||
swap_state.collect(item_to_place, True)
|
||||
new_loc_count = len(
|
||||
world.get_reachable_locations(swap_state))
|
||||
|
||||
if new_loc_count >= prev_loc_count:
|
||||
# Add this item to the existing placement, and
|
||||
# add the old item to the back of the queue
|
||||
spot_to_fill = placements.pop(i)
|
||||
|
||||
swap_count += 1
|
||||
swapped_items[placed_item.player,
|
||||
placed_item.name] = swap_count
|
||||
|
||||
reachable_items[placed_item.player].appendleft(
|
||||
placed_item)
|
||||
itempool.append(placed_item)
|
||||
|
||||
break
|
||||
|
||||
# Item can't be placed here, restore original item
|
||||
location.item = placed_item
|
||||
placed_item.location = location
|
||||
|
||||
if spot_to_fill is None:
|
||||
# Can't place this item, move on to the next
|
||||
unplaced_items.append(item_to_place)
|
||||
continue
|
||||
|
||||
location.item = None
|
||||
placed_item.location = None
|
||||
swap_state = sweep_from_pool(base_state)
|
||||
if (not single_player_placement or location.player == item_to_place.player) \
|
||||
and location.can_fill(swap_state, item_to_place, perform_access_check):
|
||||
|
||||
# Verify that placing this item won't reduce available locations
|
||||
prev_state = swap_state.copy()
|
||||
prev_state.collect(placed_item)
|
||||
prev_loc_count = len(
|
||||
world.get_reachable_locations(prev_state))
|
||||
|
||||
swap_state.collect(item_to_place, True)
|
||||
new_loc_count = len(
|
||||
world.get_reachable_locations(swap_state))
|
||||
|
||||
if new_loc_count >= prev_loc_count:
|
||||
# Add this item to the existing placement, and
|
||||
# add the old item to the back of the queue
|
||||
spot_to_fill = placements.pop(i)
|
||||
|
||||
swap_count += 1
|
||||
swapped_items[placed_item.player,
|
||||
placed_item.name] = swap_count
|
||||
|
||||
reachable_items[placed_item.player].appendleft(
|
||||
placed_item)
|
||||
itempool.append(placed_item)
|
||||
|
||||
break
|
||||
|
||||
# Item can't be placed here, restore original item
|
||||
location.item = placed_item
|
||||
placed_item.location = location
|
||||
|
||||
if spot_to_fill is None:
|
||||
# Can't place this item, move on to the next
|
||||
else:
|
||||
unplaced_items.append(item_to_place)
|
||||
continue
|
||||
|
||||
world.push_item(spot_to_fill, item_to_place, False)
|
||||
spot_to_fill.locked = lock
|
||||
placements.append(spot_to_fill)
|
||||
spot_to_fill.event = item_to_place.advancement
|
||||
if on_place:
|
||||
on_place(spot_to_fill)
|
||||
|
||||
if len(unplaced_items) > 0 and len(locations) > 0:
|
||||
# There are leftover unplaceable items and locations that won't accept them
|
||||
@@ -209,6 +217,37 @@ def fast_fill(world: MultiWorld,
|
||||
return item_pool[placing:], fill_locations[placing:]
|
||||
|
||||
|
||||
def accessibility_corrections(world: MultiWorld, state: CollectionState, locations, pool=[]):
|
||||
maximum_exploration_state = sweep_from_pool(state, pool)
|
||||
minimal_players = {player for player in world.player_ids if world.accessibility[player] == "minimal"}
|
||||
unreachable_locations = [location for location in world.get_locations() if location.player in minimal_players and
|
||||
not location.can_reach(maximum_exploration_state)]
|
||||
for location in unreachable_locations:
|
||||
if (location.item is not None and location.item.advancement and location.address is not None and not
|
||||
location.locked and location.item.player not in minimal_players):
|
||||
pool.append(location.item)
|
||||
state.remove(location.item)
|
||||
location.item = None
|
||||
location.event = False
|
||||
if location in state.events:
|
||||
state.events.remove(location)
|
||||
locations.append(location)
|
||||
if pool and locations:
|
||||
locations.sort(key=lambda loc: loc.progress_type != LocationProgressType.PRIORITY)
|
||||
fill_restrictive(world, state, locations, pool)
|
||||
|
||||
|
||||
def inaccessible_location_rules(world: MultiWorld, state: CollectionState, locations):
|
||||
maximum_exploration_state = sweep_from_pool(state)
|
||||
unreachable_locations = [location for location in locations if not location.can_reach(maximum_exploration_state)]
|
||||
if unreachable_locations:
|
||||
def forbid_important_item_rule(item: Item):
|
||||
return not ((item.classification & 0b0011) and world.accessibility[item.player] != 'minimal')
|
||||
|
||||
for location in unreachable_locations:
|
||||
add_item_rule(location, forbid_important_item_rule)
|
||||
|
||||
|
||||
def distribute_items_restrictive(world: MultiWorld) -> None:
|
||||
fill_locations = sorted(world.get_unfilled_locations())
|
||||
world.random.shuffle(fill_locations)
|
||||
@@ -239,15 +278,33 @@ def distribute_items_restrictive(world: MultiWorld) -> None:
|
||||
defaultlocations = locations[LocationProgressType.DEFAULT]
|
||||
excludedlocations = locations[LocationProgressType.EXCLUDED]
|
||||
|
||||
fill_restrictive(world, world.state, prioritylocations, progitempool, lock=True)
|
||||
# can't lock due to accessibility corrections touching things, so we remember which ones got placed and lock later
|
||||
lock_later = []
|
||||
|
||||
def mark_for_locking(location: Location):
|
||||
nonlocal lock_later
|
||||
lock_later.append(location)
|
||||
|
||||
if prioritylocations:
|
||||
# "priority fill"
|
||||
fill_restrictive(world, world.state, prioritylocations, progitempool, swap=False, on_place=mark_for_locking)
|
||||
accessibility_corrections(world, world.state, prioritylocations, progitempool)
|
||||
defaultlocations = prioritylocations + defaultlocations
|
||||
|
||||
if progitempool:
|
||||
# "progression fill"
|
||||
fill_restrictive(world, world.state, defaultlocations, progitempool)
|
||||
if progitempool:
|
||||
raise FillError(
|
||||
f'Not enough locations for progress items. There are {len(progitempool)} more items than locations')
|
||||
accessibility_corrections(world, world.state, defaultlocations)
|
||||
|
||||
for location in lock_later:
|
||||
if location.item:
|
||||
location.locked = True
|
||||
del mark_for_locking, lock_later
|
||||
|
||||
inaccessible_location_rules(world, world.state, defaultlocations)
|
||||
|
||||
remaining_fill(world, excludedlocations, filleritempool)
|
||||
if excludedlocations:
|
||||
|
||||
13
Generate.py
13
Generate.py
@@ -154,11 +154,12 @@ def main(args=None, callback=ERmain):
|
||||
# sort dict for consistent results across platforms:
|
||||
weights_cache = {key: value for key, value in sorted(weights_cache.items())}
|
||||
for filename, yaml_data in weights_cache.items():
|
||||
for yaml in yaml_data:
|
||||
print(f"P{player_id} Weights: {filename} >> "
|
||||
f"{get_choice('description', yaml, 'No description specified')}")
|
||||
player_files[player_id] = filename
|
||||
player_id += 1
|
||||
if filename not in {args.meta_file_path, args.weights_file_path}:
|
||||
for yaml in yaml_data:
|
||||
print(f"P{player_id} Weights: {filename} >> "
|
||||
f"{get_choice('description', yaml, 'No description specified')}")
|
||||
player_files[player_id] = filename
|
||||
player_id += 1
|
||||
|
||||
args.multi = max(player_id - 1, args.multi)
|
||||
print(f"Generating for {args.multi} player{'s' if args.multi > 1 else ''}, {seed_name} Seed {seed} with plando: "
|
||||
@@ -377,7 +378,7 @@ def roll_meta_option(option_key, game: str, category_dict: Dict) -> Any:
|
||||
if option_key in options:
|
||||
if options[option_key].supports_weighting:
|
||||
return get_choice(option_key, category_dict)
|
||||
return options[option_key]
|
||||
return category_dict[option_key]
|
||||
if game == "A Link to the Past": # TODO wow i hate this
|
||||
if option_key in {"glitches_required", "dark_room_logic", "entrance_shuffle", "goals", "triforce_pieces_mode",
|
||||
"triforce_pieces_percentage", "triforce_pieces_available", "triforce_pieces_extra",
|
||||
|
||||
@@ -132,7 +132,7 @@ components: Iterable[Component] = (
|
||||
Component('Text Client', 'CommonClient', 'ArchipelagoTextClient'),
|
||||
# SNI
|
||||
Component('SNI Client', 'SNIClient',
|
||||
file_identifier=SuffixIdentifier('.apz3', '.apm3', '.apsoe', '.aplttp', '.apsm', '.apsmz3', '.apdkc3')),
|
||||
file_identifier=SuffixIdentifier('.apz3', '.apm3', '.apsoe', '.aplttp', '.apsm', '.apsmz3', '.apdkc3', '.apsmw')),
|
||||
Component('LttP Adjuster', 'LttPAdjuster'),
|
||||
# Factorio
|
||||
Component('Factorio Client', 'FactorioClient'),
|
||||
@@ -145,10 +145,15 @@ components: Iterable[Component] = (
|
||||
Component('OoT Adjuster', 'OoTAdjuster'),
|
||||
# FF1
|
||||
Component('FF1 Client', 'FF1Client'),
|
||||
# Pokémon
|
||||
Component('Pokemon Client', 'PokemonClient', file_identifier=SuffixIdentifier('.apred', '.apblue')),
|
||||
# ChecksFinder
|
||||
Component('ChecksFinder Client', 'ChecksFinderClient'),
|
||||
# Starcraft 2
|
||||
Component('Starcraft 2 Client', 'Starcraft2Client'),
|
||||
# Zillion
|
||||
Component('Zillion Client', 'ZillionClient',
|
||||
file_identifier=SuffixIdentifier('.apzl')),
|
||||
# Functions
|
||||
Component('Open host.yaml', func=open_host_yaml),
|
||||
Component('Open Patch', func=open_patch),
|
||||
|
||||
@@ -139,7 +139,7 @@ def adjust(args):
|
||||
vanillaRom = args.baserom
|
||||
if not os.path.exists(vanillaRom) and not os.path.isabs(vanillaRom):
|
||||
vanillaRom = local_path(vanillaRom)
|
||||
if os.path.splitext(args.rom)[-1].lower() in {'.apbp', '.aplttp'}:
|
||||
if os.path.splitext(args.rom)[-1].lower() == '.aplttp':
|
||||
import Patch
|
||||
meta, args.rom = Patch.create_rom_file(args.rom)
|
||||
|
||||
@@ -195,7 +195,7 @@ def adjustGUI():
|
||||
romEntry2 = Entry(romDialogFrame, textvariable=romVar2)
|
||||
|
||||
def RomSelect2():
|
||||
rom = filedialog.askopenfilename(filetypes=[("Rom Files", (".sfc", ".smc", ".apbp")), ("All Files", "*")])
|
||||
rom = filedialog.askopenfilename(filetypes=[("Rom Files", (".sfc", ".smc", ".aplttp")), ("All Files", "*")])
|
||||
romVar2.set(rom)
|
||||
|
||||
romSelectButton2 = Button(romDialogFrame, text='Select Rom', command=RomSelect2)
|
||||
@@ -725,7 +725,7 @@ def get_rom_options_frame(parent=None):
|
||||
vars.auto_apply = StringVar(value=adjuster_settings.auto_apply)
|
||||
autoApplyFrame = Frame(romOptionsFrame)
|
||||
autoApplyFrame.grid(row=9, column=0, columnspan=2, sticky=W)
|
||||
filler = Label(autoApplyFrame, text="Automatically apply last used settings on opening .apbp files")
|
||||
filler = Label(autoApplyFrame, text="Automatically apply last used settings on opening .aplttp files")
|
||||
filler.pack(side=TOP, expand=True, fill=X)
|
||||
askRadio = Radiobutton(autoApplyFrame, text='Ask', variable=vars.auto_apply, value='ask')
|
||||
askRadio.pack(side=LEFT, padx=5, pady=5)
|
||||
|
||||
10
Main.py
10
Main.py
@@ -16,7 +16,7 @@ from worlds.alttp.Regions import is_main_entrance
|
||||
from Fill import distribute_items_restrictive, flood_items, balance_multiworld_progression, distribute_planned
|
||||
from worlds.alttp.Shops import SHOP_ID_START, total_shop_slots, FillDisabledShopSlots
|
||||
from Utils import output_path, get_options, __version__, version_tuple
|
||||
from worlds.generic.Rules import locality_rules, exclusion_rules, group_locality_rules
|
||||
from worlds.generic.Rules import locality_rules, exclusion_rules
|
||||
from worlds import AutoWorld
|
||||
|
||||
ordered_areas = (
|
||||
@@ -82,7 +82,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
||||
longest_name = max(len(text) for text in AutoWorld.AutoWorldRegister.world_types)
|
||||
numlength = 8
|
||||
for name, cls in AutoWorld.AutoWorldRegister.world_types.items():
|
||||
if not cls.hidden:
|
||||
if not cls.hidden and len(cls.item_names) > 0:
|
||||
logger.info(f" {name:{longest_name}}: {len(cls.item_names):3} "
|
||||
f"Items (IDs: {min(cls.item_id_to_name):{numlength}} - "
|
||||
f"{max(cls.item_id_to_name):{numlength}}) | "
|
||||
@@ -107,7 +107,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
||||
if world.goal[player] in ["localtriforcehunt", "localganontriforcehunt"]:
|
||||
world.local_items[player].value.add('Triforce Piece')
|
||||
|
||||
# Not possible to place pendants/crystals out side of boss prizes yet.
|
||||
# Not possible to place pendants/crystals outside boss prizes yet.
|
||||
world.non_local_items[player].value -= item_name_groups['Pendants']
|
||||
world.non_local_items[player].value -= item_name_groups['Crystals']
|
||||
|
||||
@@ -122,9 +122,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
||||
|
||||
logger.info('Calculating Access Rules.')
|
||||
if world.players > 1:
|
||||
for player in world.player_ids:
|
||||
locality_rules(world, player)
|
||||
group_locality_rules(world)
|
||||
locality_rules(world)
|
||||
else:
|
||||
world.non_local_items[1].value = set()
|
||||
world.local_items[1].value = set()
|
||||
|
||||
@@ -13,10 +13,12 @@ update_ran = getattr(sys, "frozen", False) # don't run update if environment is
|
||||
|
||||
if not update_ran:
|
||||
for entry in os.scandir(os.path.join(local_dir, "worlds")):
|
||||
if entry.is_dir():
|
||||
req_file = os.path.join(entry.path, "requirements.txt")
|
||||
if os.path.exists(req_file):
|
||||
requirements_files.add(req_file)
|
||||
# skip .* (hidden / disabled) folders
|
||||
if not entry.name.startswith("."):
|
||||
if entry.is_dir():
|
||||
req_file = os.path.join(entry.path, "requirements.txt")
|
||||
if os.path.exists(req_file):
|
||||
requirements_files.add(req_file)
|
||||
|
||||
|
||||
def update_command():
|
||||
@@ -37,11 +39,25 @@ def update(yes=False, force=False):
|
||||
path = os.path.join(os.path.dirname(__file__), req_file)
|
||||
with open(path) as requirementsfile:
|
||||
for line in requirementsfile:
|
||||
if line.startswith('https://'):
|
||||
# extract name and version from url
|
||||
wheel = line.split('/')[-1]
|
||||
name, version, _ = wheel.split('-', 2)
|
||||
line = f'{name}=={version}'
|
||||
if line.startswith(("https://", "git+https://")):
|
||||
# extract name and version for url
|
||||
rest = line.split('/')[-1]
|
||||
line = ""
|
||||
if "#egg=" in rest:
|
||||
# from egg info
|
||||
rest, egg = rest.split("#egg=", 1)
|
||||
egg = egg.split(";", 1)[0]
|
||||
if any(compare in egg for compare in ("==", ">=", ">", "<", "<=", "!=")):
|
||||
line = egg
|
||||
else:
|
||||
egg = ""
|
||||
if "@" in rest and not line:
|
||||
raise ValueError("Can't deduce version from requirement")
|
||||
elif not line:
|
||||
# from filename
|
||||
rest = rest.replace(".zip", "-").replace(".tar.gz", "-")
|
||||
name, version, _ = rest.split("-", 2)
|
||||
line = f'{egg or name}=={version}'
|
||||
requirements = pkg_resources.parse_requirements(line)
|
||||
for requirement in requirements:
|
||||
requirement = str(requirement)
|
||||
|
||||
@@ -100,7 +100,7 @@ _encode = JSONEncoder(
|
||||
).encode
|
||||
|
||||
|
||||
def encode(obj):
|
||||
def encode(obj: typing.Any) -> str:
|
||||
return _encode(_scan_for_TypedTuples(obj))
|
||||
|
||||
|
||||
|
||||
@@ -5,7 +5,8 @@ import multiprocessing
|
||||
import subprocess
|
||||
from asyncio import StreamReader, StreamWriter
|
||||
|
||||
from CommonClient import CommonContext, server_loop, gui_enabled, console_loop, \
|
||||
# CommonClient import first to trigger ModuleUpdater
|
||||
from CommonClient import CommonContext, server_loop, gui_enabled, \
|
||||
ClientCommandProcessor, logger, get_base_parser
|
||||
import Utils
|
||||
from worlds import network_data_package
|
||||
|
||||
127
Options.py
127
Options.py
@@ -78,6 +78,9 @@ class AssembleOptions(abc.ABCMeta):
|
||||
|
||||
return super(AssembleOptions, mcs).__new__(mcs, name, bases, attrs)
|
||||
|
||||
@abc.abstractclassmethod
|
||||
def from_any(cls, value: typing.Any) -> "Option[typing.Any]": ...
|
||||
|
||||
|
||||
T = typing.TypeVar('T')
|
||||
|
||||
@@ -165,6 +168,7 @@ class FreeText(Option):
|
||||
|
||||
|
||||
class NumericOption(Option[int], numbers.Integral):
|
||||
default = 0
|
||||
# note: some of the `typing.Any`` here is a result of unresolved issue in python standards
|
||||
# `int` is not a `numbers.Integral` according to the official typestubs
|
||||
# (even though isinstance(5, numbers.Integral) == True)
|
||||
@@ -426,7 +430,6 @@ class TextChoice(Choice):
|
||||
assert isinstance(value, str) or isinstance(value, int), \
|
||||
f"{value} is not a valid option for {self.__class__.__name__}"
|
||||
self.value = value
|
||||
super(TextChoice, self).__init__()
|
||||
|
||||
@property
|
||||
def current_key(self) -> str:
|
||||
@@ -466,6 +469,124 @@ class TextChoice(Choice):
|
||||
raise TypeError(f"Can't compare {self.__class__.__name__} with {other.__class__.__name__}")
|
||||
|
||||
|
||||
class BossMeta(AssembleOptions):
|
||||
def __new__(mcs, name, bases, attrs):
|
||||
if name != "PlandoBosses":
|
||||
assert "bosses" in attrs, f"Please define valid bosses for {name}"
|
||||
attrs["bosses"] = frozenset((boss.lower() for boss in attrs["bosses"]))
|
||||
assert "locations" in attrs, f"Please define valid locations for {name}"
|
||||
attrs["locations"] = frozenset((location.lower() for location in attrs["locations"]))
|
||||
cls = super().__new__(mcs, name, bases, attrs)
|
||||
assert not cls.duplicate_bosses or "singularity" in cls.options, f"Please define option_singularity for {name}"
|
||||
return cls
|
||||
|
||||
|
||||
class PlandoBosses(TextChoice, metaclass=BossMeta):
|
||||
"""Generic boss shuffle option that supports plando. Format expected is
|
||||
'location1-boss1;location2-boss2;shuffle_mode'.
|
||||
If shuffle_mode is not provided in the string, this will be the default shuffle mode. Must override can_place_boss,
|
||||
which passes a plando boss and location. Check if the placement is valid for your game here."""
|
||||
bosses: typing.ClassVar[typing.Union[typing.Set[str], typing.FrozenSet[str]]]
|
||||
locations: typing.ClassVar[typing.Union[typing.Set[str], typing.FrozenSet[str]]]
|
||||
|
||||
duplicate_bosses: bool = False
|
||||
|
||||
@classmethod
|
||||
def from_text(cls, text: str):
|
||||
# set all of our text to lower case for name checking
|
||||
text = text.lower()
|
||||
if text == "random":
|
||||
return cls(random.choice(list(cls.options.values())))
|
||||
for option_name, value in cls.options.items():
|
||||
if option_name == text:
|
||||
return cls(value)
|
||||
options = text.split(";")
|
||||
|
||||
# since plando exists in the option verify the plando values given are valid
|
||||
cls.validate_plando_bosses(options)
|
||||
return cls.get_shuffle_mode(options)
|
||||
|
||||
@classmethod
|
||||
def get_shuffle_mode(cls, option_list: typing.List[str]):
|
||||
# find out what mode of boss shuffle we should use for placing bosses after plando
|
||||
# and add as a string to look nice in the spoiler
|
||||
if "random" in option_list:
|
||||
shuffle = random.choice(list(cls.options))
|
||||
option_list.remove("random")
|
||||
options = ";".join(option_list) + f";{shuffle}"
|
||||
boss_class = cls(options)
|
||||
else:
|
||||
for option in option_list:
|
||||
if option in cls.options:
|
||||
options = ";".join(option_list)
|
||||
break
|
||||
else:
|
||||
if cls.duplicate_bosses and len(option_list) == 1:
|
||||
if cls.valid_boss_name(option_list[0]):
|
||||
# this doesn't exist in this class but it's a forced option for classes where this is called
|
||||
options = option_list[0] + ";singularity"
|
||||
else:
|
||||
options = option_list[0] + f";{cls.name_lookup[cls.default]}"
|
||||
else:
|
||||
options = ";".join(option_list) + f";{cls.name_lookup[cls.default]}"
|
||||
boss_class = cls(options)
|
||||
return boss_class
|
||||
|
||||
@classmethod
|
||||
def validate_plando_bosses(cls, options: typing.List[str]) -> None:
|
||||
used_locations = []
|
||||
used_bosses = []
|
||||
for option in options:
|
||||
# check if a shuffle mode was provided in the incorrect location
|
||||
if option == "random" or option in cls.options:
|
||||
if option != options[-1]:
|
||||
raise ValueError(f"{option} option must be at the end of the boss_shuffle options!")
|
||||
elif "-" in option:
|
||||
location, boss = option.split("-")
|
||||
if location in used_locations:
|
||||
raise ValueError(f"Duplicate Boss Location {location} not allowed.")
|
||||
if not cls.duplicate_bosses and boss in used_bosses:
|
||||
raise ValueError(f"Duplicate Boss {boss} not allowed.")
|
||||
used_locations.append(location)
|
||||
used_bosses.append(boss)
|
||||
if not cls.valid_boss_name(boss):
|
||||
raise ValueError(f"{boss.title()} is not a valid boss name.")
|
||||
if not cls.valid_location_name(location):
|
||||
raise ValueError(f"{location.title()} is not a valid boss location name.")
|
||||
if not cls.can_place_boss(boss, location):
|
||||
raise ValueError(f"{location.title()} is not a valid location for {boss.title()} to be placed.")
|
||||
else:
|
||||
if cls.duplicate_bosses:
|
||||
if not cls.valid_boss_name(option):
|
||||
raise ValueError(f"{option} is not a valid boss name.")
|
||||
else:
|
||||
raise ValueError(f"{option.title()} is not formatted correctly.")
|
||||
|
||||
@classmethod
|
||||
def can_place_boss(cls, boss: str, location: str) -> bool:
|
||||
raise NotImplementedError
|
||||
|
||||
@classmethod
|
||||
def valid_boss_name(cls, value: str) -> bool:
|
||||
return value in cls.bosses
|
||||
|
||||
@classmethod
|
||||
def valid_location_name(cls, value: str) -> bool:
|
||||
return value in cls.locations
|
||||
|
||||
def verify(self, world, player_name: str, plando_options) -> None:
|
||||
if isinstance(self.value, int):
|
||||
return
|
||||
from Generate import PlandoSettings
|
||||
if not(PlandoSettings.bosses & plando_options):
|
||||
import logging
|
||||
# plando is disabled but plando options were given so pull the option and change it to an int
|
||||
option = self.value.split(";")[-1]
|
||||
self.value = self.options[option]
|
||||
logging.warning(f"The plando bosses module is turned off, so {self.name_lookup[self.value].title()} "
|
||||
f"boss shuffle will be used for player {player_name}.")
|
||||
|
||||
|
||||
class Range(NumericOption):
|
||||
range_start = 0
|
||||
range_end = 1
|
||||
@@ -628,7 +749,7 @@ class VerifyKeys:
|
||||
|
||||
|
||||
class OptionDict(Option[typing.Dict[str, typing.Any]], VerifyKeys):
|
||||
default = {}
|
||||
default: typing.Dict[str, typing.Any] = {}
|
||||
supports_weighting = False
|
||||
|
||||
def __init__(self, value: typing.Dict[str, typing.Any]):
|
||||
@@ -659,7 +780,7 @@ class ItemDict(OptionDict):
|
||||
|
||||
|
||||
class OptionList(Option[typing.List[typing.Any]], VerifyKeys):
|
||||
default = []
|
||||
default: typing.List[typing.Any] = []
|
||||
supports_weighting = False
|
||||
|
||||
def __init__(self, value: typing.List[typing.Any]):
|
||||
|
||||
424
Patch.py
424
Patch.py
@@ -1,266 +1,33 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import json
|
||||
import bsdiff4
|
||||
import yaml
|
||||
import os
|
||||
import lzma
|
||||
import threading
|
||||
import concurrent.futures
|
||||
import zipfile
|
||||
import sys
|
||||
from typing import Tuple, Optional, Dict, Any, Union, BinaryIO
|
||||
from typing import Tuple, Optional, TypedDict
|
||||
|
||||
import ModuleUpdate
|
||||
ModuleUpdate.update()
|
||||
if __name__ == "__main__":
|
||||
import ModuleUpdate
|
||||
ModuleUpdate.update()
|
||||
|
||||
import Utils
|
||||
|
||||
current_patch_version = 5
|
||||
from worlds.Files import AutoPatchRegister, APDeltaPatch
|
||||
|
||||
|
||||
class AutoPatchRegister(type):
|
||||
patch_types: Dict[str, APDeltaPatch] = {}
|
||||
file_endings: Dict[str, APDeltaPatch] = {}
|
||||
|
||||
def __new__(cls, name: str, bases, dct: Dict[str, Any]):
|
||||
# construct class
|
||||
new_class = super().__new__(cls, name, bases, dct)
|
||||
if "game" in dct:
|
||||
AutoPatchRegister.patch_types[dct["game"]] = new_class
|
||||
if not dct["patch_file_ending"]:
|
||||
raise Exception(f"Need an expected file ending for {name}")
|
||||
AutoPatchRegister.file_endings[dct["patch_file_ending"]] = new_class
|
||||
return new_class
|
||||
|
||||
@staticmethod
|
||||
def get_handler(file: str) -> Optional[type(APDeltaPatch)]:
|
||||
for file_ending, handler in AutoPatchRegister.file_endings.items():
|
||||
if file.endswith(file_ending):
|
||||
return handler
|
||||
|
||||
|
||||
class APContainer:
|
||||
"""A zipfile containing at least archipelago.json"""
|
||||
version: int = current_patch_version
|
||||
compression_level: int = 9
|
||||
compression_method: int = zipfile.ZIP_DEFLATED
|
||||
game: Optional[str] = None
|
||||
|
||||
# instance attributes:
|
||||
path: Optional[str]
|
||||
player: Optional[int]
|
||||
player_name: str
|
||||
server: str
|
||||
|
||||
def __init__(self, path: Optional[str] = None, player: Optional[int] = None,
|
||||
player_name: str = "", server: str = ""):
|
||||
self.path = path
|
||||
self.player = player
|
||||
self.player_name = player_name
|
||||
self.server = server
|
||||
|
||||
def write(self, file: Optional[Union[str, BinaryIO]] = None):
|
||||
if not self.path and not file:
|
||||
raise FileNotFoundError(f"Cannot write {self.__class__.__name__} due to no path provided.")
|
||||
with zipfile.ZipFile(file if file else self.path, "w", self.compression_method, True, self.compression_level) \
|
||||
as zf:
|
||||
if file:
|
||||
self.path = zf.filename
|
||||
self.write_contents(zf)
|
||||
|
||||
def write_contents(self, opened_zipfile: zipfile.ZipFile):
|
||||
manifest = self.get_manifest()
|
||||
try:
|
||||
manifest = json.dumps(manifest)
|
||||
except Exception as e:
|
||||
raise Exception(f"Manifest {manifest} did not convert to json.") from e
|
||||
else:
|
||||
opened_zipfile.writestr("archipelago.json", manifest)
|
||||
|
||||
def read(self, file: Optional[Union[str, BinaryIO]] = None):
|
||||
"""Read data into patch object. file can be file-like, such as an outer zip file's stream."""
|
||||
if not self.path and not file:
|
||||
raise FileNotFoundError(f"Cannot read {self.__class__.__name__} due to no path provided.")
|
||||
with zipfile.ZipFile(file if file else self.path, "r") as zf:
|
||||
if file:
|
||||
self.path = zf.filename
|
||||
self.read_contents(zf)
|
||||
|
||||
def read_contents(self, opened_zipfile: zipfile.ZipFile):
|
||||
with opened_zipfile.open("archipelago.json", "r") as f:
|
||||
manifest = json.load(f)
|
||||
if manifest["compatible_version"] > self.version:
|
||||
raise Exception(f"File (version: {manifest['compatible_version']}) too new "
|
||||
f"for this handler (version: {self.version})")
|
||||
self.player = manifest["player"]
|
||||
self.server = manifest["server"]
|
||||
self.player_name = manifest["player_name"]
|
||||
|
||||
def get_manifest(self) -> dict:
|
||||
return {
|
||||
"server": self.server, # allow immediate connection to server in multiworld. Empty string otherwise
|
||||
"player": self.player,
|
||||
"player_name": self.player_name,
|
||||
"game": self.game,
|
||||
# minimum version of patch system expected for patching to be successful
|
||||
"compatible_version": 4,
|
||||
"version": current_patch_version,
|
||||
}
|
||||
|
||||
|
||||
class APDeltaPatch(APContainer, metaclass=AutoPatchRegister):
|
||||
"""An APContainer that additionally has delta.bsdiff4
|
||||
containing a delta patch to get the desired file, often a rom."""
|
||||
|
||||
hash = Optional[str] # base checksum of source file
|
||||
patch_file_ending: str = ""
|
||||
delta: Optional[bytes] = None
|
||||
result_file_ending: str = ".sfc"
|
||||
source_data: bytes
|
||||
|
||||
def __init__(self, *args, patched_path: str = "", **kwargs):
|
||||
self.patched_path = patched_path
|
||||
super(APDeltaPatch, self).__init__(*args, **kwargs)
|
||||
|
||||
def get_manifest(self) -> dict:
|
||||
manifest = super(APDeltaPatch, self).get_manifest()
|
||||
manifest["base_checksum"] = self.hash
|
||||
manifest["result_file_ending"] = self.result_file_ending
|
||||
manifest["patch_file_ending"] = self.patch_file_ending
|
||||
return manifest
|
||||
|
||||
@classmethod
|
||||
def get_source_data(cls) -> bytes:
|
||||
"""Get Base data"""
|
||||
raise NotImplementedError()
|
||||
|
||||
@classmethod
|
||||
def get_source_data_with_cache(cls) -> bytes:
|
||||
if not hasattr(cls, "source_data"):
|
||||
cls.source_data = cls.get_source_data()
|
||||
return cls.source_data
|
||||
|
||||
def write_contents(self, opened_zipfile: zipfile.ZipFile):
|
||||
super(APDeltaPatch, self).write_contents(opened_zipfile)
|
||||
# write Delta
|
||||
opened_zipfile.writestr("delta.bsdiff4",
|
||||
bsdiff4.diff(self.get_source_data_with_cache(), open(self.patched_path, "rb").read()),
|
||||
compress_type=zipfile.ZIP_STORED) # bsdiff4 is a format with integrated compression
|
||||
|
||||
def read_contents(self, opened_zipfile: zipfile.ZipFile):
|
||||
super(APDeltaPatch, self).read_contents(opened_zipfile)
|
||||
self.delta = opened_zipfile.read("delta.bsdiff4")
|
||||
|
||||
def patch(self, target: str):
|
||||
"""Base + Delta -> Patched"""
|
||||
if not self.delta:
|
||||
self.read()
|
||||
result = bsdiff4.patch(self.get_source_data_with_cache(), self.delta)
|
||||
with open(target, "wb") as f:
|
||||
f.write(result)
|
||||
|
||||
|
||||
# legacy patch handling follows:
|
||||
GAME_ALTTP = "A Link to the Past"
|
||||
GAME_SM = "Super Metroid"
|
||||
GAME_SOE = "Secret of Evermore"
|
||||
GAME_SMZ3 = "SMZ3"
|
||||
GAME_DKC3 = "Donkey Kong Country 3"
|
||||
supported_games = {"A Link to the Past", "Super Metroid", "Secret of Evermore", "SMZ3", "Donkey Kong Country 3"}
|
||||
|
||||
preferred_endings = {
|
||||
GAME_ALTTP: "apbp",
|
||||
GAME_SM: "apm3",
|
||||
GAME_SOE: "apsoe",
|
||||
GAME_SMZ3: "apsmz",
|
||||
GAME_DKC3: "apdkc3"
|
||||
}
|
||||
GAME_SMW = "Super Mario World"
|
||||
|
||||
|
||||
def generate_yaml(patch: bytes, metadata: Optional[dict] = None, game: str = GAME_ALTTP) -> bytes:
|
||||
if game == GAME_ALTTP:
|
||||
from worlds.alttp.Rom import LTTPJPN10HASH as HASH
|
||||
elif game == GAME_SM:
|
||||
from worlds.sm.Rom import SMJUHASH as HASH
|
||||
elif game == GAME_SOE:
|
||||
from worlds.soe.Patch import USHASH as HASH
|
||||
elif game == GAME_SMZ3:
|
||||
from worlds.alttp.Rom import LTTPJPN10HASH as ALTTPHASH
|
||||
from worlds.sm.Rom import SMJUHASH as SMHASH
|
||||
HASH = ALTTPHASH + SMHASH
|
||||
elif game == GAME_DKC3:
|
||||
from worlds.dkc3.Rom import USHASH as HASH
|
||||
else:
|
||||
raise RuntimeError(f"Selected game {game} for base rom not found.")
|
||||
|
||||
patch = yaml.dump({"meta": metadata,
|
||||
"patch": patch,
|
||||
"game": game,
|
||||
# minimum version of patch system expected for patching to be successful
|
||||
"compatible_version": 3,
|
||||
"version": current_patch_version,
|
||||
"base_checksum": HASH})
|
||||
return patch.encode(encoding="utf-8-sig")
|
||||
class RomMeta(TypedDict):
|
||||
server: str
|
||||
player: Optional[int]
|
||||
player_name: str
|
||||
|
||||
|
||||
def generate_patch(rom: bytes, metadata: Optional[dict] = None, game: str = GAME_ALTTP) -> bytes:
|
||||
if metadata is None:
|
||||
metadata = {}
|
||||
patch = bsdiff4.diff(get_base_rom_data(game), rom)
|
||||
return generate_yaml(patch, metadata, game)
|
||||
|
||||
|
||||
def create_patch_file(rom_file_to_patch: str, server: str = "", destination: str = None,
|
||||
player: int = 0, player_name: str = "", game: str = GAME_ALTTP) -> str:
|
||||
meta = {"server": server, # allow immediate connection to server in multiworld. Empty string otherwise
|
||||
"player_id": player,
|
||||
"player_name": player_name}
|
||||
bytes = generate_patch(load_bytes(rom_file_to_patch),
|
||||
meta,
|
||||
game)
|
||||
target = destination if destination else os.path.splitext(rom_file_to_patch)[0] + (
|
||||
".apbp" if game == GAME_ALTTP
|
||||
else ".apsmz" if game == GAME_SMZ3
|
||||
else ".apdkc3" if game == GAME_DKC3
|
||||
else ".apm3")
|
||||
write_lzma(bytes, target)
|
||||
return target
|
||||
|
||||
|
||||
def create_rom_bytes(patch_file: str, ignore_version: bool = False) -> Tuple[dict, str, bytearray]:
|
||||
data = Utils.parse_yaml(lzma.decompress(load_bytes(patch_file)).decode("utf-8-sig"))
|
||||
game_name = data["game"]
|
||||
if not ignore_version and data["compatible_version"] > current_patch_version:
|
||||
raise RuntimeError("Patch file is incompatible with this patcher, likely an update is required.")
|
||||
patched_data = bsdiff4.patch(get_base_rom_data(game_name), data["patch"])
|
||||
rom_hash = patched_data[int(0x7FC0):int(0x7FD5)]
|
||||
data["meta"]["hash"] = "".join(chr(x) for x in rom_hash)
|
||||
target = os.path.splitext(patch_file)[0] + ".sfc"
|
||||
return data["meta"], target, patched_data
|
||||
|
||||
|
||||
def get_base_rom_data(game: str):
|
||||
if game == GAME_ALTTP:
|
||||
from worlds.alttp.Rom import get_base_rom_bytes
|
||||
elif game == "alttp": # old version for A Link to the Past
|
||||
from worlds.alttp.Rom import get_base_rom_bytes
|
||||
elif game == GAME_SM:
|
||||
from worlds.sm.Rom import get_base_rom_bytes
|
||||
elif game == GAME_SOE:
|
||||
from worlds.soe.Patch import get_base_rom_path
|
||||
get_base_rom_bytes = lambda: bytes(read_rom(open(get_base_rom_path(), "rb")))
|
||||
elif game == GAME_SMZ3:
|
||||
from worlds.smz3.Rom import get_base_rom_bytes
|
||||
elif game == GAME_DKC3:
|
||||
from worlds.dkc3.Rom import get_base_rom_bytes
|
||||
else:
|
||||
raise RuntimeError("Selected game for base rom not found.")
|
||||
return get_base_rom_bytes()
|
||||
|
||||
|
||||
def create_rom_file(patch_file: str) -> Tuple[dict, str]:
|
||||
def create_rom_file(patch_file: str) -> Tuple[RomMeta, str]:
|
||||
auto_handler = AutoPatchRegister.get_handler(patch_file)
|
||||
if auto_handler:
|
||||
handler: APDeltaPatch = auto_handler(patch_file)
|
||||
@@ -269,171 +36,10 @@ def create_rom_file(patch_file: str) -> Tuple[dict, str]:
|
||||
return {"server": handler.server,
|
||||
"player": handler.player,
|
||||
"player_name": handler.player_name}, target
|
||||
else:
|
||||
data, target, patched_data = create_rom_bytes(patch_file)
|
||||
with open(target, "wb") as f:
|
||||
f.write(patched_data)
|
||||
return data, target
|
||||
|
||||
|
||||
def update_patch_data(patch_data: bytes, server: str = "") -> bytes:
|
||||
data = Utils.parse_yaml(lzma.decompress(patch_data).decode("utf-8-sig"))
|
||||
data["meta"]["server"] = server
|
||||
bytes = generate_yaml(data["patch"], data["meta"], data["game"])
|
||||
return lzma.compress(bytes)
|
||||
|
||||
|
||||
def load_bytes(path: str) -> bytes:
|
||||
with open(path, "rb") as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
def write_lzma(data: bytes, path: str):
|
||||
with lzma.LZMAFile(path, 'wb') as f:
|
||||
f.write(data)
|
||||
|
||||
|
||||
def read_rom(stream, strip_header=True) -> bytearray:
|
||||
"""Reads rom into bytearray and optionally strips off any smc header"""
|
||||
buffer = bytearray(stream.read())
|
||||
if strip_header and len(buffer) % 0x400 == 0x200:
|
||||
return buffer[0x200:]
|
||||
return buffer
|
||||
raise NotImplementedError(f"No Handler for {patch_file} found.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
host = Utils.get_public_ipv4()
|
||||
options = Utils.get_options()['server_options']
|
||||
if options['host']:
|
||||
host = options['host']
|
||||
|
||||
address = f"{host}:{options['port']}"
|
||||
ziplock = threading.Lock()
|
||||
print(f"Host for patches to be created is {address}")
|
||||
with concurrent.futures.ThreadPoolExecutor() as pool:
|
||||
for rom in sys.argv:
|
||||
try:
|
||||
if rom.endswith(".sfc"):
|
||||
print(f"Creating patch for {rom}")
|
||||
result = pool.submit(create_patch_file, rom, address)
|
||||
result.add_done_callback(lambda task: print(f"Created patch {task.result()}"))
|
||||
|
||||
elif rom.endswith(".apbp"):
|
||||
print(f"Applying patch {rom}")
|
||||
data, target = create_rom_file(rom)
|
||||
#romfile, adjusted = Utils.get_adjuster_settings(target)
|
||||
adjuster_settings = Utils.get_adjuster_settings(GAME_ALTTP)
|
||||
adjusted = False
|
||||
if adjuster_settings:
|
||||
import pprint
|
||||
from worlds.alttp.Rom import get_base_rom_path
|
||||
adjuster_settings.rom = target
|
||||
adjuster_settings.baserom = get_base_rom_path()
|
||||
adjuster_settings.world = None
|
||||
whitelist = {"music", "menuspeed", "heartbeep", "heartcolor", "ow_palettes", "quickswap",
|
||||
"uw_palettes", "sprite", "sword_palettes", "shield_palettes", "hud_palettes",
|
||||
"reduceflashing", "deathlink"}
|
||||
printed_options = {name: value for name, value in vars(adjuster_settings).items() if name in whitelist}
|
||||
if hasattr(adjuster_settings, "sprite_pool"):
|
||||
sprite_pool = {}
|
||||
for sprite in getattr(adjuster_settings, "sprite_pool"):
|
||||
if sprite in sprite_pool:
|
||||
sprite_pool[sprite] += 1
|
||||
else:
|
||||
sprite_pool[sprite] = 1
|
||||
if sprite_pool:
|
||||
printed_options["sprite_pool"] = sprite_pool
|
||||
|
||||
adjust_wanted = str('no')
|
||||
if not hasattr(adjuster_settings, 'auto_apply') or 'ask' in adjuster_settings.auto_apply:
|
||||
adjust_wanted = input(f"Last used adjuster settings were found. Would you like to apply these? \n"
|
||||
f"{pprint.pformat(printed_options)}\n"
|
||||
f"Enter yes, no, always or never: ")
|
||||
if adjuster_settings.auto_apply == 'never': # never adjust, per user request
|
||||
adjust_wanted = 'no'
|
||||
elif adjuster_settings.auto_apply == 'always':
|
||||
adjust_wanted = 'yes'
|
||||
|
||||
if adjust_wanted and "never" in adjust_wanted:
|
||||
adjuster_settings.auto_apply = 'never'
|
||||
Utils.persistent_store("adjuster", GAME_ALTTP, adjuster_settings)
|
||||
|
||||
elif adjust_wanted and "always" in adjust_wanted:
|
||||
adjuster_settings.auto_apply = 'always'
|
||||
Utils.persistent_store("adjuster", GAME_ALTTP, adjuster_settings)
|
||||
|
||||
if adjust_wanted and adjust_wanted.startswith("y"):
|
||||
if hasattr(adjuster_settings, "sprite_pool"):
|
||||
from LttPAdjuster import AdjusterWorld
|
||||
adjuster_settings.world = AdjusterWorld(getattr(adjuster_settings, "sprite_pool"))
|
||||
|
||||
adjusted = True
|
||||
import LttPAdjuster
|
||||
_, romfile = LttPAdjuster.adjust(adjuster_settings)
|
||||
|
||||
if hasattr(adjuster_settings, "world"):
|
||||
delattr(adjuster_settings, "world")
|
||||
else:
|
||||
adjusted = False
|
||||
if adjusted:
|
||||
try:
|
||||
shutil.move(romfile, target)
|
||||
romfile = target
|
||||
except Exception as e:
|
||||
print(e)
|
||||
print(f"Created rom {romfile if adjusted else target}.")
|
||||
if 'server' in data:
|
||||
Utils.persistent_store("servers", data['hash'], data['server'])
|
||||
print(f"Host is {data['server']}")
|
||||
elif rom.endswith(".apm3"):
|
||||
print(f"Applying patch {rom}")
|
||||
data, target = create_rom_file(rom)
|
||||
print(f"Created rom {target}.")
|
||||
if 'server' in data:
|
||||
Utils.persistent_store("servers", data['hash'], data['server'])
|
||||
print(f"Host is {data['server']}")
|
||||
elif rom.endswith(".apsmz"):
|
||||
print(f"Applying patch {rom}")
|
||||
data, target = create_rom_file(rom)
|
||||
print(f"Created rom {target}.")
|
||||
if 'server' in data:
|
||||
Utils.persistent_store("servers", data['hash'], data['server'])
|
||||
print(f"Host is {data['server']}")
|
||||
elif rom.endswith(".apdkc3"):
|
||||
print(f"Applying patch {rom}")
|
||||
data, target = create_rom_file(rom)
|
||||
print(f"Created rom {target}.")
|
||||
if 'server' in data:
|
||||
Utils.persistent_store("servers", data['hash'], data['server'])
|
||||
print(f"Host is {data['server']}")
|
||||
|
||||
elif rom.endswith(".zip"):
|
||||
print(f"Updating host in patch files contained in {rom}")
|
||||
|
||||
|
||||
def _handle_zip_file_entry(zfinfo: zipfile.ZipInfo, server: str):
|
||||
data = zfr.read(zfinfo)
|
||||
if zfinfo.filename.endswith(".apbp") or \
|
||||
zfinfo.filename.endswith(".apm3") or \
|
||||
zfinfo.filename.endswith(".apdkc3"):
|
||||
data = update_patch_data(data, server)
|
||||
with ziplock:
|
||||
zfw.writestr(zfinfo, data)
|
||||
return zfinfo.filename
|
||||
|
||||
|
||||
futures = []
|
||||
with zipfile.ZipFile(rom, "r") as zfr:
|
||||
updated_zip = os.path.splitext(rom)[0] + "_updated.zip"
|
||||
with zipfile.ZipFile(updated_zip, "w", compression=zipfile.ZIP_DEFLATED,
|
||||
compresslevel=9) as zfw:
|
||||
for zfname in zfr.namelist():
|
||||
futures.append(pool.submit(_handle_zip_file_entry, zfr.getinfo(zfname), address))
|
||||
for future in futures:
|
||||
print(f"File {future.result()} added to {os.path.split(updated_zip)[1]}")
|
||||
|
||||
except:
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
input("Press enter to close.")
|
||||
for file in sys.argv[1:]:
|
||||
meta_data, result_file = create_rom_file(file)
|
||||
print(f"Patch with meta-data {meta_data} was written to {result_file}")
|
||||
|
||||
319
PokemonClient.py
Normal file
319
PokemonClient.py
Normal file
@@ -0,0 +1,319 @@
|
||||
import asyncio
|
||||
import json
|
||||
import time
|
||||
import os
|
||||
import bsdiff4
|
||||
import subprocess
|
||||
import zipfile
|
||||
import hashlib
|
||||
from asyncio import StreamReader, StreamWriter
|
||||
from typing import List
|
||||
|
||||
|
||||
import Utils
|
||||
from CommonClient import CommonContext, server_loop, gui_enabled, ClientCommandProcessor, logger, \
|
||||
get_base_parser
|
||||
|
||||
from worlds.pokemon_rb.locations import location_data
|
||||
|
||||
location_map = {"Rod": {}, "EventFlag": {}, "Missable": {}, "Hidden": {}, "list": {}}
|
||||
location_bytes_bits = {}
|
||||
for location in location_data:
|
||||
if location.ram_address is not None:
|
||||
if type(location.ram_address) == list:
|
||||
location_map[type(location.ram_address).__name__][(location.ram_address[0].flag, location.ram_address[1].flag)] = location.address
|
||||
location_bytes_bits[location.address] = [{'byte': location.ram_address[0].byte, 'bit': location.ram_address[0].bit},
|
||||
{'byte': location.ram_address[1].byte, 'bit': location.ram_address[1].bit}]
|
||||
else:
|
||||
location_map[type(location.ram_address).__name__][location.ram_address.flag] = location.address
|
||||
location_bytes_bits[location.address] = {'byte': location.ram_address.byte, 'bit': location.ram_address.bit}
|
||||
|
||||
SYSTEM_MESSAGE_ID = 0
|
||||
|
||||
CONNECTION_TIMING_OUT_STATUS = "Connection timing out. Please restart your emulator, then restart pkmn_rb.lua"
|
||||
CONNECTION_REFUSED_STATUS = "Connection Refused. Please start your emulator and make sure pkmn_rb.lua is running"
|
||||
CONNECTION_RESET_STATUS = "Connection was reset. Please restart your emulator, then restart pkmn_rb.lua"
|
||||
CONNECTION_TENTATIVE_STATUS = "Initial Connection Made"
|
||||
CONNECTION_CONNECTED_STATUS = "Connected"
|
||||
CONNECTION_INITIAL_STATUS = "Connection has not been initiated"
|
||||
|
||||
DISPLAY_MSGS = True
|
||||
|
||||
|
||||
class GBCommandProcessor(ClientCommandProcessor):
|
||||
def __init__(self, ctx: CommonContext):
|
||||
super().__init__(ctx)
|
||||
|
||||
def _cmd_gb(self):
|
||||
"""Check Gameboy Connection State"""
|
||||
if isinstance(self.ctx, GBContext):
|
||||
logger.info(f"Gameboy Status: {self.ctx.gb_status}")
|
||||
|
||||
|
||||
class GBContext(CommonContext):
|
||||
command_processor = GBCommandProcessor
|
||||
game = 'Pokemon Red and Blue'
|
||||
items_handling = 0b101
|
||||
|
||||
def __init__(self, server_address, password):
|
||||
super().__init__(server_address, password)
|
||||
self.gb_streams: (StreamReader, StreamWriter) = None
|
||||
self.gb_sync_task = None
|
||||
self.messages = {}
|
||||
self.locations_array = None
|
||||
self.gb_status = CONNECTION_INITIAL_STATUS
|
||||
self.awaiting_rom = False
|
||||
self.display_msgs = True
|
||||
|
||||
async def server_auth(self, password_requested: bool = False):
|
||||
if password_requested and not self.password:
|
||||
await super(GBContext, self).server_auth(password_requested)
|
||||
if not self.auth:
|
||||
self.awaiting_rom = True
|
||||
logger.info('Awaiting connection to Bizhawk to get Player information')
|
||||
return
|
||||
|
||||
await self.send_connect()
|
||||
|
||||
def _set_message(self, msg: str, msg_id: int):
|
||||
if DISPLAY_MSGS:
|
||||
self.messages[(time.time(), msg_id)] = msg
|
||||
|
||||
def on_package(self, cmd: str, args: dict):
|
||||
if cmd == 'Connected':
|
||||
self.locations_array = None
|
||||
elif cmd == "RoomInfo":
|
||||
self.seed_name = args['seed_name']
|
||||
elif cmd == 'Print':
|
||||
msg = args['text']
|
||||
if ': !' not in msg:
|
||||
self._set_message(msg, SYSTEM_MESSAGE_ID)
|
||||
elif cmd == "ReceivedItems":
|
||||
msg = f"Received {', '.join([self.item_names[item.item] for item in args['items']])}"
|
||||
self._set_message(msg, SYSTEM_MESSAGE_ID)
|
||||
|
||||
def run_gui(self):
|
||||
from kvui import GameManager
|
||||
|
||||
class GBManager(GameManager):
|
||||
logging_pairs = [
|
||||
("Client", "Archipelago")
|
||||
]
|
||||
base_title = "Archipelago Pokémon Client"
|
||||
|
||||
self.ui = GBManager(self)
|
||||
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
|
||||
|
||||
|
||||
def get_payload(ctx: GBContext):
|
||||
current_time = time.time()
|
||||
return json.dumps(
|
||||
{
|
||||
"items": [item.item for item in ctx.items_received],
|
||||
"messages": {f'{key[0]}:{key[1]}': value for key, value in ctx.messages.items()
|
||||
if key[0] > current_time - 10}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def parse_locations(data: List, ctx: GBContext):
|
||||
locations = []
|
||||
flags = {"EventFlag": data[:0x140], "Missable": data[0x140:0x140 + 0x20],
|
||||
"Hidden": data[0x140 + 0x20: 0x140 + 0x20 + 0x0E], "Rod": data[0x140 + 0x20 + 0x0E:]}
|
||||
|
||||
# Check for clear problems
|
||||
if len(flags['Rod']) > 1:
|
||||
return
|
||||
if flags["EventFlag"][1] + flags["EventFlag"][8] + flags["EventFlag"][9] + flags["EventFlag"][12] \
|
||||
+ flags["EventFlag"][61] + flags["EventFlag"][62] + flags["EventFlag"][63] + flags["EventFlag"][64] \
|
||||
+ flags["EventFlag"][65] + flags["EventFlag"][66] + flags["EventFlag"][67] + flags["EventFlag"][68] \
|
||||
+ flags["EventFlag"][69] + flags["EventFlag"][70] != 0:
|
||||
return
|
||||
|
||||
for flag_type, loc_map in location_map.items():
|
||||
for flag, loc_id in loc_map.items():
|
||||
if flag_type == "list":
|
||||
if (flags["EventFlag"][location_bytes_bits[loc_id][0]['byte']] & 1 << location_bytes_bits[loc_id][0]['bit']
|
||||
and flags["Missable"][location_bytes_bits[loc_id][1]['byte']] & 1 << location_bytes_bits[loc_id][1]['bit']):
|
||||
locations.append(loc_id)
|
||||
elif flags[flag_type][location_bytes_bits[loc_id]['byte']] & 1 << location_bytes_bits[loc_id]['bit']:
|
||||
locations.append(loc_id)
|
||||
if flags["EventFlag"][280] & 1 and not ctx.finished_game:
|
||||
await ctx.send_msgs([
|
||||
{"cmd": "StatusUpdate",
|
||||
"status": 30}
|
||||
])
|
||||
ctx.finished_game = True
|
||||
if locations == ctx.locations_array:
|
||||
return
|
||||
ctx.locations_array = locations
|
||||
if locations is not None:
|
||||
await ctx.send_msgs([{"cmd": "LocationChecks", "locations": locations}])
|
||||
|
||||
|
||||
async def gb_sync_task(ctx: GBContext):
|
||||
logger.info("Starting GB connector. Use /gb for status information")
|
||||
while not ctx.exit_event.is_set():
|
||||
error_status = None
|
||||
if ctx.gb_streams:
|
||||
(reader, writer) = ctx.gb_streams
|
||||
msg = get_payload(ctx).encode()
|
||||
writer.write(msg)
|
||||
writer.write(b'\n')
|
||||
try:
|
||||
await asyncio.wait_for(writer.drain(), timeout=1.5)
|
||||
try:
|
||||
# Data will return a dict with up to two fields:
|
||||
# 1. A keepalive response of the Players Name (always)
|
||||
# 2. An array representing the memory values of the locations area (if in game)
|
||||
data = await asyncio.wait_for(reader.readline(), timeout=5)
|
||||
data_decoded = json.loads(data.decode())
|
||||
#print(data_decoded)
|
||||
|
||||
if ctx.seed_name and ctx.seed_name != bytes(data_decoded['seedName']).decode():
|
||||
msg = "The server is running a different multiworld than your client is. (invalid seed_name)"
|
||||
logger.info(msg, extra={'compact_gui': True})
|
||||
ctx.gui_error('Error', msg)
|
||||
error_status = CONNECTION_RESET_STATUS
|
||||
ctx.seed_name = bytes(data_decoded['seedName']).decode()
|
||||
if not ctx.auth:
|
||||
ctx.auth = ''.join([chr(i) for i in data_decoded['playerName'] if i != 0])
|
||||
if ctx.auth == '':
|
||||
logger.info("Invalid ROM detected. No player name built into the ROM.")
|
||||
if ctx.awaiting_rom:
|
||||
await ctx.server_auth(False)
|
||||
if 'locations' in data_decoded and ctx.game and ctx.gb_status == CONNECTION_CONNECTED_STATUS \
|
||||
and not error_status and ctx.auth:
|
||||
# Not just a keep alive ping, parse
|
||||
asyncio.create_task(parse_locations(data_decoded['locations'], ctx))
|
||||
except asyncio.TimeoutError:
|
||||
logger.debug("Read Timed Out, Reconnecting")
|
||||
error_status = CONNECTION_TIMING_OUT_STATUS
|
||||
writer.close()
|
||||
ctx.gb_streams = None
|
||||
except ConnectionResetError as e:
|
||||
logger.debug("Read failed due to Connection Lost, Reconnecting")
|
||||
error_status = CONNECTION_RESET_STATUS
|
||||
writer.close()
|
||||
ctx.gb_streams = None
|
||||
except TimeoutError:
|
||||
logger.debug("Connection Timed Out, Reconnecting")
|
||||
error_status = CONNECTION_TIMING_OUT_STATUS
|
||||
writer.close()
|
||||
ctx.gb_streams = None
|
||||
except ConnectionResetError:
|
||||
logger.debug("Connection Lost, Reconnecting")
|
||||
error_status = CONNECTION_RESET_STATUS
|
||||
writer.close()
|
||||
ctx.gb_streams = None
|
||||
if ctx.gb_status == CONNECTION_TENTATIVE_STATUS:
|
||||
if not error_status:
|
||||
logger.info("Successfully Connected to Gameboy")
|
||||
ctx.gb_status = CONNECTION_CONNECTED_STATUS
|
||||
else:
|
||||
ctx.gb_status = f"Was tentatively connected but error occured: {error_status}"
|
||||
elif error_status:
|
||||
ctx.gb_status = error_status
|
||||
logger.info("Lost connection to Gameboy and attempting to reconnect. Use /gb for status updates")
|
||||
else:
|
||||
try:
|
||||
logger.debug("Attempting to connect to Gameboy")
|
||||
ctx.gb_streams = await asyncio.wait_for(asyncio.open_connection("localhost", 17242), timeout=10)
|
||||
ctx.gb_status = CONNECTION_TENTATIVE_STATUS
|
||||
except TimeoutError:
|
||||
logger.debug("Connection Timed Out, Trying Again")
|
||||
ctx.gb_status = CONNECTION_TIMING_OUT_STATUS
|
||||
continue
|
||||
except ConnectionRefusedError:
|
||||
logger.debug("Connection Refused, Trying Again")
|
||||
ctx.gb_status = CONNECTION_REFUSED_STATUS
|
||||
continue
|
||||
|
||||
|
||||
async def run_game(romfile):
|
||||
auto_start = Utils.get_options()["pokemon_rb_options"].get("rom_start", True)
|
||||
if auto_start is True:
|
||||
import webbrowser
|
||||
webbrowser.open(romfile)
|
||||
elif os.path.isfile(auto_start):
|
||||
subprocess.Popen([auto_start, romfile],
|
||||
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
|
||||
|
||||
async def patch_and_run_game(game_version, patch_file, ctx):
|
||||
base_name = os.path.splitext(patch_file)[0]
|
||||
comp_path = base_name + '.gb'
|
||||
with open(Utils.local_path(Utils.get_options()["pokemon_rb_options"][f"{game_version}_rom_file"]), "rb") as stream:
|
||||
base_rom = bytes(stream.read())
|
||||
try:
|
||||
with open(Utils.local_path('lib', 'worlds', 'pokemon_rb', f'basepatch_{game_version}.bsdiff4'), 'rb') as stream:
|
||||
base_patch = bytes(stream.read())
|
||||
except FileNotFoundError:
|
||||
with open(Utils.local_path('worlds', 'pokemon_rb', f'basepatch_{game_version}.bsdiff4'), 'rb') as stream:
|
||||
base_patch = bytes(stream.read())
|
||||
base_patched_rom_data = bsdiff4.patch(base_rom, base_patch)
|
||||
basemd5 = hashlib.md5()
|
||||
basemd5.update(base_patched_rom_data)
|
||||
|
||||
with zipfile.ZipFile(patch_file, 'r') as patch_archive:
|
||||
with patch_archive.open('delta.bsdiff4', 'r') as stream:
|
||||
patch = stream.read()
|
||||
patched_rom_data = bsdiff4.patch(base_patched_rom_data, patch)
|
||||
|
||||
written_hash = patched_rom_data[0xFFCC:0xFFDC]
|
||||
if written_hash == basemd5.digest():
|
||||
with open(comp_path, "wb") as patched_rom_file:
|
||||
patched_rom_file.write(patched_rom_data)
|
||||
|
||||
asyncio.create_task(run_game(comp_path))
|
||||
else:
|
||||
msg = "Patch supplied was not generated with the same base patch version as this client. Patching failed."
|
||||
logger.warning(msg)
|
||||
ctx.gui_error('Error', msg)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
Utils.init_logging("PokemonClient")
|
||||
|
||||
options = Utils.get_options()
|
||||
|
||||
async def main():
|
||||
parser = get_base_parser()
|
||||
parser.add_argument('patch_file', default="", type=str, nargs="?",
|
||||
help='Path to an APRED or APBLUE patch file')
|
||||
args = parser.parse_args()
|
||||
|
||||
ctx = GBContext(args.connect, args.password)
|
||||
ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop")
|
||||
if gui_enabled:
|
||||
ctx.run_gui()
|
||||
ctx.run_cli()
|
||||
ctx.gb_sync_task = asyncio.create_task(gb_sync_task(ctx), name="GB Sync")
|
||||
|
||||
if args.patch_file:
|
||||
ext = args.patch_file.split(".")[len(args.patch_file.split(".")) - 1].lower()
|
||||
if ext == "apred":
|
||||
logger.info("APRED file supplied, beginning patching process...")
|
||||
asyncio.create_task(patch_and_run_game("red", args.patch_file, ctx))
|
||||
elif ext == "apblue":
|
||||
logger.info("APBLUE file supplied, beginning patching process...")
|
||||
asyncio.create_task(patch_and_run_game("blue", args.patch_file, ctx))
|
||||
else:
|
||||
logger.warning(f"Unknown patch file extension {ext}")
|
||||
|
||||
await ctx.exit_event.wait()
|
||||
ctx.server_address = None
|
||||
|
||||
await ctx.shutdown()
|
||||
|
||||
if ctx.gb_sync_task:
|
||||
await ctx.gb_sync_task
|
||||
|
||||
|
||||
import colorama
|
||||
|
||||
colorama.init()
|
||||
|
||||
asyncio.run(main())
|
||||
colorama.deinit()
|
||||
@@ -28,6 +28,11 @@ Currently, the following games are supported:
|
||||
* Starcraft 2: Wings of Liberty
|
||||
* Donkey Kong Country 3
|
||||
* Dark Souls 3
|
||||
* Super Mario World
|
||||
* Pokémon Red and Blue
|
||||
* Hylics 2
|
||||
* Overcooked! 2
|
||||
* Zillion
|
||||
|
||||
For setup and instructions check out our [tutorials page](https://archipelago.gg/tutorial/).
|
||||
Downloads can be found at [Releases](https://github.com/ArchipelagoMW/Archipelago/releases), including compiled
|
||||
|
||||
32
SNIClient.py
32
SNIClient.py
@@ -15,10 +15,13 @@ import typing
|
||||
|
||||
from json import loads, dumps
|
||||
|
||||
from Utils import init_logging, messagebox
|
||||
# CommonClient import first to trigger ModuleUpdater
|
||||
from CommonClient import CommonContext, server_loop, ClientCommandProcessor, gui_enabled, get_base_parser
|
||||
|
||||
import Utils
|
||||
|
||||
if __name__ == "__main__":
|
||||
init_logging("SNIClient", exception_logger="Client")
|
||||
Utils.init_logging("SNIClient", exception_logger="Client")
|
||||
|
||||
import colorama
|
||||
import websockets
|
||||
@@ -28,9 +31,8 @@ from worlds.alttp import Regions, Shops
|
||||
from worlds.alttp.Rom import ROM_PLAYER_LIMIT
|
||||
from worlds.sm.Rom import ROM_PLAYER_LIMIT as SM_ROM_PLAYER_LIMIT
|
||||
from worlds.smz3.Rom import ROM_PLAYER_LIMIT as SMZ3_ROM_PLAYER_LIMIT
|
||||
import Utils
|
||||
from CommonClient import CommonContext, server_loop, ClientCommandProcessor, gui_enabled, get_base_parser
|
||||
from Patch import GAME_ALTTP, GAME_SM, GAME_SMZ3, GAME_DKC3
|
||||
from Patch import GAME_ALTTP, GAME_SM, GAME_SMZ3, GAME_DKC3, GAME_SMW
|
||||
|
||||
|
||||
snes_logger = logging.getLogger("SNES")
|
||||
|
||||
@@ -236,6 +238,10 @@ async def deathlink_kill_player(ctx: Context):
|
||||
snes_buffered_write(ctx, WRAM_START + 0x0A50, bytes([255])) # deal 255 of damage at next opportunity
|
||||
if not ctx.death_link_allow_survive:
|
||||
snes_buffered_write(ctx, WRAM_START + 0x09D6, bytes([0, 0])) # set current reserve to 0
|
||||
elif ctx.game == GAME_SMW:
|
||||
from worlds.smw.Client import deathlink_kill_player as smw_deathlink_kill_player
|
||||
await smw_deathlink_kill_player(ctx)
|
||||
|
||||
await snes_flush_writes(ctx)
|
||||
await asyncio.sleep(1)
|
||||
|
||||
@@ -1041,6 +1047,9 @@ async def game_watcher(ctx: Context):
|
||||
|
||||
from worlds.dkc3.Client import dkc3_rom_init
|
||||
init_handled = await dkc3_rom_init(ctx)
|
||||
if not init_handled:
|
||||
from worlds.smw.Client import smw_rom_init
|
||||
init_handled = await smw_rom_init(ctx)
|
||||
if not init_handled:
|
||||
game_name = await snes_read(ctx, SM_ROMNAME_START, 5)
|
||||
if game_name is None:
|
||||
@@ -1299,6 +1308,9 @@ async def game_watcher(ctx: Context):
|
||||
elif ctx.game == GAME_DKC3:
|
||||
from worlds.dkc3.Client import dkc3_game_watcher
|
||||
await dkc3_game_watcher(ctx)
|
||||
elif ctx.game == GAME_SMW:
|
||||
from worlds.smw.Client import smw_game_watcher
|
||||
await smw_game_watcher(ctx)
|
||||
|
||||
|
||||
async def run_game(romfile):
|
||||
@@ -1326,20 +1338,18 @@ async def main():
|
||||
try:
|
||||
meta, romfile = Patch.create_rom_file(args.diff_file)
|
||||
except Exception as e:
|
||||
messagebox('Error', str(e), True)
|
||||
Utils.messagebox('Error', str(e), True)
|
||||
raise
|
||||
if "server" in meta:
|
||||
args.connect = meta["server"]
|
||||
args.connect = meta["server"]
|
||||
logging.info(f"Wrote rom file to {romfile}")
|
||||
if args.diff_file.endswith(".apsoe"):
|
||||
import webbrowser
|
||||
webbrowser.open("http://www.evermizer.com/apclient/" +
|
||||
(f"#server={meta['server']}" if "server" in meta else ""))
|
||||
webbrowser.open(f"http://www.evermizer.com/apclient/#server={meta['server']}")
|
||||
logging.info("Starting Evermizer Client in your Browser...")
|
||||
import time
|
||||
time.sleep(3)
|
||||
sys.exit()
|
||||
elif args.diff_file.endswith((".apbp", ".apz3", ".aplttp")):
|
||||
elif args.diff_file.endswith(".aplttp"):
|
||||
adjustedromfile, adjusted = get_alttp_settings(romfile)
|
||||
asyncio.create_task(run_game(adjustedromfile if adjusted else romfile))
|
||||
else:
|
||||
|
||||
@@ -12,21 +12,9 @@ import typing
|
||||
import queue
|
||||
from pathlib import Path
|
||||
|
||||
import nest_asyncio
|
||||
import sc2
|
||||
from sc2.bot_ai import BotAI
|
||||
from sc2.data import Race
|
||||
from sc2.main import run_game
|
||||
from sc2.player import Bot
|
||||
|
||||
import NetUtils
|
||||
from MultiServer import mark_raw
|
||||
# CommonClient import first to trigger ModuleUpdater
|
||||
from CommonClient import CommonContext, server_loop, ClientCommandProcessor, gui_enabled, get_base_parser
|
||||
from Utils import init_logging, is_windows
|
||||
from worlds.sc2wol import SC2WoLWorld
|
||||
from worlds.sc2wol.Items import lookup_id_to_name, item_table, ItemData, type_flaggroups
|
||||
from worlds.sc2wol.Locations import SC2WOL_LOC_ID_OFFSET
|
||||
from worlds.sc2wol.MissionTables import lookup_id_to_mission
|
||||
from worlds.sc2wol.Regions import MissionInfo
|
||||
|
||||
if __name__ == "__main__":
|
||||
init_logging("SC2Client", exception_logger="Client")
|
||||
@@ -34,10 +22,21 @@ if __name__ == "__main__":
|
||||
logger = logging.getLogger("Client")
|
||||
sc2_logger = logging.getLogger("Starcraft2")
|
||||
|
||||
import colorama
|
||||
import nest_asyncio
|
||||
import sc2
|
||||
from sc2.bot_ai import BotAI
|
||||
from sc2.data import Race
|
||||
from sc2.main import run_game
|
||||
from sc2.player import Bot
|
||||
from worlds.sc2wol import SC2WoLWorld
|
||||
from worlds.sc2wol.Items import lookup_id_to_name, item_table, ItemData, type_flaggroups
|
||||
from worlds.sc2wol.Locations import SC2WOL_LOC_ID_OFFSET
|
||||
from worlds.sc2wol.MissionTables import lookup_id_to_mission
|
||||
from worlds.sc2wol.Regions import MissionInfo
|
||||
|
||||
from NetUtils import ClientStatus, RawJSONtoTextParser
|
||||
from CommonClient import CommonContext, server_loop, ClientCommandProcessor, gui_enabled, get_base_parser
|
||||
import colorama
|
||||
from NetUtils import ClientStatus, NetworkItem, RawJSONtoTextParser
|
||||
from MultiServer import mark_raw
|
||||
|
||||
nest_asyncio.apply()
|
||||
max_bonus: int = 8
|
||||
@@ -115,12 +114,40 @@ class StarcraftClientProcessor(ClientCommandProcessor):
|
||||
"""Manually set the SC2 install directory (if the automatic detection fails)."""
|
||||
if path:
|
||||
os.environ["SC2PATH"] = path
|
||||
check_mod_install()
|
||||
is_mod_installed_correctly()
|
||||
return True
|
||||
else:
|
||||
sc2_logger.warning("When using set_path, you must type the path to your SC2 install directory.")
|
||||
return False
|
||||
|
||||
def _cmd_download_data(self, force: bool = False) -> bool:
|
||||
"""Download the most recent release of the necessary files for playing SC2 with
|
||||
Archipelago. force should be True or False. force=True will overwrite your files."""
|
||||
if "SC2PATH" not in os.environ:
|
||||
check_game_install_path()
|
||||
|
||||
if os.path.exists(os.environ["SC2PATH"]+"ArchipelagoSC2Version.txt"):
|
||||
with open(os.environ["SC2PATH"]+"ArchipelagoSC2Version.txt", "r") as f:
|
||||
current_ver = f.read()
|
||||
else:
|
||||
current_ver = None
|
||||
|
||||
tempzip, version = download_latest_release_zip('TheCondor07', 'Starcraft2ArchipelagoData', current_version=current_ver, force_download=force)
|
||||
|
||||
if tempzip != '':
|
||||
try:
|
||||
import zipfile
|
||||
zipfile.ZipFile(tempzip).extractall(path=os.environ["SC2PATH"])
|
||||
sc2_logger.info(f"Download complete. Version {version} installed.")
|
||||
with open(os.environ["SC2PATH"]+"ArchipelagoSC2Version.txt", "w") as f:
|
||||
f.write(version)
|
||||
finally:
|
||||
os.remove(tempzip)
|
||||
else:
|
||||
sc2_logger.warning("Download aborted/failed. Read the log for more information.")
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
class SC2Context(CommonContext):
|
||||
command_processor = StarcraftClientProcessor
|
||||
@@ -159,10 +186,13 @@ class SC2Context(CommonContext):
|
||||
|
||||
self.build_location_to_mission_mapping()
|
||||
|
||||
# Look for and set SC2PATH.
|
||||
# check_game_install_path() returns True if and only if it finds + sets SC2PATH.
|
||||
if "SC2PATH" not in os.environ and check_game_install_path():
|
||||
check_mod_install()
|
||||
# Looks for the required maps and mods for SC2. Runs check_game_install_path.
|
||||
is_mod_installed_correctly()
|
||||
if os.path.exists(os.environ["SC2PATH"] + "ArchipelagoSC2Version.txt"):
|
||||
with open(os.environ["SC2PATH"] + "ArchipelagoSC2Version.txt", "r") as f:
|
||||
current_ver = f.read()
|
||||
if is_mod_update_available("TheCondor07", "Starcraft2ArchipelagoData", current_ver):
|
||||
sc2_logger.info("NOTICE: Update for required files found. Run /download_data to install.")
|
||||
|
||||
def on_print_json(self, args: dict):
|
||||
# goes to this world
|
||||
@@ -357,8 +387,9 @@ class SC2Context(CommonContext):
|
||||
|
||||
self.ui = SC2Manager(self)
|
||||
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
|
||||
|
||||
Builder.load_file(Utils.local_path(os.path.dirname(SC2WoLWorld.__file__), "Starcraft2.kv"))
|
||||
import pkgutil
|
||||
data = pkgutil.get_data(SC2WoLWorld.__module__, "Starcraft2.kv").decode()
|
||||
Builder.load_string(data)
|
||||
|
||||
async def shutdown(self):
|
||||
await super(SC2Context, self).shutdown()
|
||||
@@ -440,8 +471,8 @@ wol_default_categories = [
|
||||
]
|
||||
|
||||
|
||||
def calculate_items(items: typing.List[NetUtils.NetworkItem]) -> typing.List[int]:
|
||||
network_item: NetUtils.NetworkItem
|
||||
def calculate_items(items: typing.List[NetworkItem]) -> typing.List[int]:
|
||||
network_item: NetworkItem
|
||||
accumulators: typing.List[int] = [0 for _ in type_flaggroups]
|
||||
|
||||
for network_item in items:
|
||||
@@ -820,18 +851,53 @@ def check_game_install_path() -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def check_mod_install() -> bool:
|
||||
# Pull up the SC2PATH if set. If not, encourage the user to manually run /set_path.
|
||||
try:
|
||||
# Check inside the Mods folder for Archipelago.SC2Mod. If found, tell user. If not, tell user.
|
||||
if os.path.isfile(modfile := (os.environ["SC2PATH"] / Path("Mods") / Path("Archipelago.SC2Mod"))):
|
||||
sc2_logger.info(f"Archipelago mod found at {modfile}.")
|
||||
return True
|
||||
else:
|
||||
sc2_logger.warning(f"Archipelago mod could not be found at {modfile}. Please install the mod file there.")
|
||||
except KeyError:
|
||||
sc2_logger.warning(f"SC2PATH isn't set. Please run /set_path with the path to your SC2 install.")
|
||||
return False
|
||||
def is_mod_installed_correctly() -> bool:
|
||||
"""Searches for all required files."""
|
||||
if "SC2PATH" not in os.environ:
|
||||
check_game_install_path()
|
||||
|
||||
mapdir = os.environ['SC2PATH'] / Path('Maps/ArchipelagoCampaign')
|
||||
modfile = os.environ["SC2PATH"] / Path("Mods/Archipelago.SC2Mod")
|
||||
wol_required_maps = [
|
||||
"ap_thanson01.SC2Map", "ap_thanson02.SC2Map", "ap_thanson03a.SC2Map", "ap_thanson03b.SC2Map",
|
||||
"ap_thorner01.SC2Map", "ap_thorner02.SC2Map", "ap_thorner03.SC2Map", "ap_thorner04.SC2Map", "ap_thorner05s.SC2Map",
|
||||
"ap_traynor01.SC2Map", "ap_traynor02.SC2Map", "ap_traynor03.SC2Map",
|
||||
"ap_ttosh01.SC2Map", "ap_ttosh02.SC2Map", "ap_ttosh03a.SC2Map", "ap_ttosh03b.SC2Map",
|
||||
"ap_ttychus01.SC2Map", "ap_ttychus02.SC2Map", "ap_ttychus03.SC2Map", "ap_ttychus04.SC2Map", "ap_ttychus05.SC2Map",
|
||||
"ap_tvalerian01.SC2Map", "ap_tvalerian02a.SC2Map", "ap_tvalerian02b.SC2Map", "ap_tvalerian03.SC2Map",
|
||||
"ap_tzeratul01.SC2Map", "ap_tzeratul02.SC2Map", "ap_tzeratul03.SC2Map", "ap_tzeratul04.SC2Map"
|
||||
]
|
||||
needs_files = False
|
||||
|
||||
# Check for maps.
|
||||
missing_maps = []
|
||||
for mapfile in wol_required_maps:
|
||||
if not os.path.isfile(mapdir / mapfile):
|
||||
missing_maps.append(mapfile)
|
||||
if len(missing_maps) >= 19:
|
||||
sc2_logger.warning(f"All map files missing from {mapdir}.")
|
||||
needs_files = True
|
||||
elif len(missing_maps) > 0:
|
||||
for map in missing_maps:
|
||||
sc2_logger.debug(f"Missing {map} from {mapdir}.")
|
||||
sc2_logger.warning(f"Missing {len(missing_maps)} map files.")
|
||||
needs_files = True
|
||||
else: # Must be no maps missing
|
||||
sc2_logger.info(f"All maps found in {mapdir}.")
|
||||
|
||||
# Check for mods.
|
||||
if os.path.isfile(modfile):
|
||||
sc2_logger.info(f"Archipelago mod found at {modfile}.")
|
||||
else:
|
||||
sc2_logger.warning(f"Archipelago mod could not be found at {modfile}.")
|
||||
needs_files = True
|
||||
|
||||
# Final verdict.
|
||||
if needs_files:
|
||||
sc2_logger.warning(f"Required files are missing. Run /download_data to acquire them.")
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
|
||||
class DllDirectory:
|
||||
@@ -870,6 +936,64 @@ class DllDirectory:
|
||||
return False
|
||||
|
||||
|
||||
def download_latest_release_zip(owner: str, repo: str, current_version: str = None, force_download=False) -> (str, str):
|
||||
"""Downloads the latest release of a GitHub repo to the current directory as a .zip file."""
|
||||
import requests
|
||||
|
||||
headers = {"Accept": 'application/vnd.github.v3+json'}
|
||||
url = f"https://api.github.com/repos/{owner}/{repo}/releases/latest"
|
||||
|
||||
r1 = requests.get(url, headers=headers)
|
||||
if r1.status_code == 200:
|
||||
latest_version = r1.json()["tag_name"]
|
||||
sc2_logger.info(f"Latest version: {latest_version}.")
|
||||
else:
|
||||
sc2_logger.warning(f"Status code: {r1.status_code}")
|
||||
sc2_logger.warning(f"Failed to reach GitHub. Could not find download link.")
|
||||
sc2_logger.warning(f"text: {r1.text}")
|
||||
return "", current_version
|
||||
|
||||
if (force_download is False) and (current_version == latest_version):
|
||||
sc2_logger.info("Latest version already installed.")
|
||||
return "", current_version
|
||||
|
||||
sc2_logger.info(f"Attempting to download version {latest_version} of {repo}.")
|
||||
download_url = r1.json()["assets"][0]["browser_download_url"]
|
||||
|
||||
r2 = requests.get(download_url, headers=headers)
|
||||
if r2.status_code == 200:
|
||||
with open(f"{repo}.zip", "wb") as fh:
|
||||
fh.write(r2.content)
|
||||
sc2_logger.info(f"Successfully downloaded {repo}.zip.")
|
||||
return f"{repo}.zip", latest_version
|
||||
else:
|
||||
sc2_logger.warning(f"Status code: {r2.status_code}")
|
||||
sc2_logger.warning("Download failed.")
|
||||
sc2_logger.warning(f"text: {r2.text}")
|
||||
return "", current_version
|
||||
|
||||
|
||||
def is_mod_update_available(owner: str, repo: str, current_version: str) -> bool:
|
||||
import requests
|
||||
|
||||
headers = {"Accept": 'application/vnd.github.v3+json'}
|
||||
url = f"https://api.github.com/repos/{owner}/{repo}/releases/latest"
|
||||
|
||||
r1 = requests.get(url, headers=headers)
|
||||
if r1.status_code == 200:
|
||||
latest_version = r1.json()["tag_name"]
|
||||
if current_version != latest_version:
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
|
||||
else:
|
||||
sc2_logger.warning(f"Failed to reach GitHub while checking for updates.")
|
||||
sc2_logger.warning(f"Status code: {r1.status_code}")
|
||||
sc2_logger.warning(f"text: {r1.text}")
|
||||
return False
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
colorama.init()
|
||||
asyncio.run(main())
|
||||
|
||||
42
Utils.py
42
Utils.py
@@ -11,6 +11,8 @@ import io
|
||||
import collections
|
||||
import importlib
|
||||
import logging
|
||||
from typing import BinaryIO
|
||||
|
||||
from yaml import load, load_all, dump, SafeLoader
|
||||
|
||||
try:
|
||||
@@ -217,8 +219,11 @@ def get_public_ipv6() -> str:
|
||||
return ip
|
||||
|
||||
|
||||
OptionsType = typing.Dict[str, typing.Dict[str, typing.Any]]
|
||||
|
||||
|
||||
@cache_argsless
|
||||
def get_default_options() -> dict:
|
||||
def get_default_options() -> OptionsType:
|
||||
# Refer to host.yaml for comments as to what all these options mean.
|
||||
options = {
|
||||
"general_options": {
|
||||
@@ -285,12 +290,28 @@ def get_default_options() -> dict:
|
||||
"sni": "SNI",
|
||||
"rom_start": True,
|
||||
},
|
||||
"smw_options": {
|
||||
"rom_file": "Super Mario World (USA).sfc",
|
||||
"sni": "SNI",
|
||||
"rom_start": True,
|
||||
},
|
||||
"zillion_options": {
|
||||
"rom_file": "Zillion (UE) [!].sms",
|
||||
# RetroArch doesn't make it easy to launch a game from the command line.
|
||||
# You have to know the path to the emulator core library on the user's computer.
|
||||
"rom_start": "retroarch",
|
||||
},
|
||||
"pokemon_rb_options": {
|
||||
"red_rom_file": "Pokemon Red (UE) [S][!].gb",
|
||||
"blue_rom_file": "Pokemon Blue (UE) [S][!].gb",
|
||||
"rom_start": True
|
||||
}
|
||||
}
|
||||
|
||||
return options
|
||||
|
||||
|
||||
def update_options(src: dict, dest: dict, filename: str, keys: list) -> dict:
|
||||
def update_options(src: dict, dest: dict, filename: str, keys: list) -> OptionsType:
|
||||
for key, value in src.items():
|
||||
new_keys = keys.copy()
|
||||
new_keys.append(key)
|
||||
@@ -310,9 +331,9 @@ def update_options(src: dict, dest: dict, filename: str, keys: list) -> dict:
|
||||
|
||||
|
||||
@cache_argsless
|
||||
def get_options() -> dict:
|
||||
def get_options() -> OptionsType:
|
||||
filenames = ("options.yaml", "host.yaml")
|
||||
locations = []
|
||||
locations: typing.List[str] = []
|
||||
if os.path.join(os.getcwd()) != local_path():
|
||||
locations += filenames # use files from cwd only if it's not the local_path
|
||||
locations += [user_path(filename) for filename in filenames]
|
||||
@@ -353,7 +374,7 @@ def persistent_load() -> typing.Dict[str, dict]:
|
||||
return storage
|
||||
|
||||
|
||||
def get_adjuster_settings(game_name: str):
|
||||
def get_adjuster_settings(game_name: str) -> typing.Dict[str, typing.Any]:
|
||||
adjuster_settings = persistent_load().get("adjuster", {}).get(game_name, {})
|
||||
return adjuster_settings
|
||||
|
||||
@@ -392,7 +413,8 @@ class RestrictedUnpickler(pickle.Unpickler):
|
||||
# Options and Plando are unpickled by WebHost -> Generate
|
||||
if module == "worlds.generic" and name in {"PlandoItem", "PlandoConnection"}:
|
||||
return getattr(self.generic_properties_module, name)
|
||||
if module.endswith("Options"):
|
||||
# pep 8 specifies that modules should have "all-lowercase names" (options, not Options)
|
||||
if module.lower().endswith("options"):
|
||||
if module == "Options":
|
||||
mod = self.options_module
|
||||
else:
|
||||
@@ -623,3 +645,11 @@ def title_sorted(data: typing.Sequence, key=None, ignore: typing.Set = frozenset
|
||||
else:
|
||||
return element.lower()
|
||||
return sorted(data, key=lambda i: sorter(key(i)) if key else sorter(i))
|
||||
|
||||
|
||||
def read_snes_rom(stream: BinaryIO, strip_header: bool = True) -> bytearray:
|
||||
"""Reads rom into bytearray and optionally strips off any smc header"""
|
||||
buffer = bytearray(stream.read())
|
||||
if strip_header and len(buffer) % 0x400 == 0x200:
|
||||
return buffer[0x200:]
|
||||
return buffer
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import os
|
||||
import sys
|
||||
import multiprocessing
|
||||
import logging
|
||||
import typing
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
import os
|
||||
import uuid
|
||||
import base64
|
||||
import os
|
||||
import socket
|
||||
import uuid
|
||||
|
||||
from pony.flask import Pony
|
||||
from flask import Flask
|
||||
from flask_caching import Cache
|
||||
from flask_compress import Compress
|
||||
from pony.flask import Pony
|
||||
from werkzeug.routing import BaseConverter
|
||||
|
||||
from Utils import title_sorted
|
||||
from .models import *
|
||||
|
||||
UPLOAD_FOLDER = os.path.relpath('uploads')
|
||||
LOGS_FOLDER = os.path.relpath('logs')
|
||||
@@ -73,8 +72,10 @@ def register():
|
||||
"""Import submodules, triggering their registering on flask routing.
|
||||
Note: initializes worlds subsystem."""
|
||||
# has automatic patch integration
|
||||
import Patch
|
||||
app.jinja_env.filters['supports_apdeltapatch'] = lambda game_name: game_name in Patch.AutoPatchRegister.patch_types
|
||||
import worlds.AutoWorld
|
||||
import worlds.Files
|
||||
app.jinja_env.filters['supports_apdeltapatch'] = lambda game_name: \
|
||||
game_name in worlds.Files.AutoPatchRegister.patch_types
|
||||
|
||||
from WebHostLib.customserver import run_server_process
|
||||
# to trigger app routing picking up on it
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
"""API endpoints package."""
|
||||
from uuid import UUID
|
||||
from typing import List, Tuple
|
||||
from uuid import UUID
|
||||
|
||||
from flask import Blueprint, abort
|
||||
|
||||
from ..models import Room, Seed
|
||||
from .. import cache
|
||||
from ..models import Room, Seed
|
||||
|
||||
api_endpoints = Blueprint('api', __name__, url_prefix="/api")
|
||||
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import json
|
||||
import pickle
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from . import api_endpoints
|
||||
from flask import request, session, url_for
|
||||
from pony.orm import commit
|
||||
|
||||
from WebHostLib import app, Generation, STATE_QUEUED, Seed, STATE_ERROR
|
||||
from WebHostLib import app
|
||||
from WebHostLib.check import get_yaml_data, roll_options
|
||||
from WebHostLib.generate import get_meta
|
||||
from WebHostLib.models import Generation, STATE_QUEUED, Seed, STATE_ERROR
|
||||
from . import api_endpoints
|
||||
|
||||
|
||||
@api_endpoints.route('/generate', methods=['POST'])
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from flask import session, jsonify
|
||||
from pony.orm import select
|
||||
|
||||
from WebHostLib.models import *
|
||||
from WebHostLib.models import Room, Seed
|
||||
from . import api_endpoints, get_players
|
||||
|
||||
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
from __future__ import annotations
|
||||
import logging
|
||||
import json
|
||||
import multiprocessing
|
||||
import threading
|
||||
from datetime import timedelta, datetime
|
||||
|
||||
import sys
|
||||
import typing
|
||||
import time
|
||||
import json
|
||||
import logging
|
||||
import multiprocessing
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import typing
|
||||
from datetime import timedelta, datetime
|
||||
|
||||
from pony.orm import db_session, select, commit
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import zipfile
|
||||
from typing import *
|
||||
|
||||
from flask import request, flash, redirect, url_for, session, render_template
|
||||
from flask import request, flash, redirect, url_for, render_template
|
||||
|
||||
from WebHostLib import app
|
||||
|
||||
|
||||
@@ -10,13 +10,14 @@ import random
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
|
||||
import websockets
|
||||
from pony.orm import db_session, commit, select
|
||||
|
||||
import Utils
|
||||
from .models import db_session, Room, select, commit, Command, db
|
||||
|
||||
from MultiServer import Context, server, auto_shutdown, ServerCommandProcessor, ClientMessageProcessor
|
||||
from Utils import get_public_ipv4, get_public_ipv6, restricted_loads, cache_argsless
|
||||
from .models import Room, Command, db
|
||||
|
||||
|
||||
class CustomClientMessageProcessor(ClientMessageProcessor):
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import zipfile
|
||||
import json
|
||||
import zipfile
|
||||
from io import BytesIO
|
||||
|
||||
from flask import send_file, Response, render_template
|
||||
from pony.orm import select
|
||||
|
||||
from Patch import update_patch_data, preferred_endings, AutoPatchRegister
|
||||
from WebHostLib import app, Slot, Room, Seed, cache
|
||||
from worlds.Files import AutoPatchRegister
|
||||
from . import app, cache
|
||||
from .models import Slot, Room, Seed
|
||||
|
||||
|
||||
@app.route("/dl_patch/<suuid:room_id>/<int:patch_id>")
|
||||
@@ -41,12 +42,7 @@ def download_patch(room_id, patch_id):
|
||||
new_file.seek(0)
|
||||
return send_file(new_file, as_attachment=True, download_name=fname)
|
||||
else:
|
||||
patch_data = update_patch_data(patch.data, server=f"{app.config['PATCH_TARGET']}:{last_port}")
|
||||
patch_data = BytesIO(patch_data)
|
||||
|
||||
fname = f"P{patch.player_id}_{patch.player_name}_{app.jinja_env.filters['suuid'](room_id)}." \
|
||||
f"{preferred_endings[patch.game]}"
|
||||
return send_file(patch_data, as_attachment=True, download_name=fname)
|
||||
return "Old Patch file, no longer compatible."
|
||||
|
||||
|
||||
@app.route("/dl_spoiler/<suuid:seed_id>")
|
||||
@@ -79,6 +75,8 @@ def download_slot_file(room_id, player_id: int):
|
||||
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_P{slot_data.player_id}_{slot_data.player_name}.apz5"
|
||||
elif slot_data.game == "VVVVVV":
|
||||
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_SP.apv6"
|
||||
elif slot_data.game == "Zillion":
|
||||
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_SP.apzl"
|
||||
elif slot_data.game == "Super Mario 64":
|
||||
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_SP.apsm64ex"
|
||||
elif slot_data.game == "Dark Souls III":
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
import os
|
||||
import tempfile
|
||||
import random
|
||||
import json
|
||||
import os
|
||||
import pickle
|
||||
import random
|
||||
import tempfile
|
||||
import zipfile
|
||||
from collections import Counter
|
||||
from typing import Dict, Optional, Any
|
||||
from Utils import __version__
|
||||
|
||||
from flask import request, flash, redirect, url_for, session, render_template
|
||||
from pony.orm import commit, db_session
|
||||
|
||||
from worlds.alttp.EntranceRandomizer import parse_arguments
|
||||
from Main import main as ERmain
|
||||
from BaseClasses import seeddigits, get_seed
|
||||
from Generate import handle_name, PlandoSettings
|
||||
import pickle
|
||||
|
||||
from .models import Generation, STATE_ERROR, STATE_QUEUED, commit, db_session, Seed, UUID
|
||||
from Main import main as ERmain
|
||||
from Utils import __version__
|
||||
from WebHostLib import app
|
||||
from worlds.alttp.EntranceRandomizer import parse_arguments
|
||||
from .check import get_yaml_data, roll_options
|
||||
from .models import Generation, STATE_ERROR, STATE_QUEUED, Seed, UUID
|
||||
from .upload import upload_zip_to_db
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
from datetime import timedelta, datetime
|
||||
|
||||
from flask import render_template
|
||||
from pony.orm import count
|
||||
|
||||
from WebHostLib import app, cache
|
||||
from .models import *
|
||||
from datetime import timedelta
|
||||
from .models import Room, Seed
|
||||
|
||||
|
||||
@app.route('/', methods=['GET', 'POST'])
|
||||
@cache.cached(timeout=300) # cache has to appear under app route for caching to work
|
||||
|
||||
@@ -3,10 +3,11 @@ import os
|
||||
|
||||
import jinja2.exceptions
|
||||
from flask import request, redirect, url_for, render_template, Response, session, abort, send_from_directory
|
||||
from pony.orm import count, commit, db_session
|
||||
|
||||
from .models import count, Seed, commit, Room, db_session, Command, UUID, uuid4
|
||||
from worlds.AutoWorld import AutoWorldRegister
|
||||
from . import app, cache
|
||||
from .models import Seed, Room, Command, UUID, uuid4
|
||||
|
||||
|
||||
def get_world_theme(game_name: str):
|
||||
@@ -151,7 +152,7 @@ def favicon():
|
||||
|
||||
@app.route('/discord')
|
||||
def discord():
|
||||
return redirect("https://discord.gg/archipelago")
|
||||
return redirect("https://discord.gg/8Z65BR2")
|
||||
|
||||
|
||||
@app.route('/datapackage')
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from datetime import datetime
|
||||
from uuid import UUID, uuid4
|
||||
from pony.orm import *
|
||||
from pony.orm import Database, PrimaryKey, Required, Set, Optional, buffer, LongStr
|
||||
|
||||
db = Database()
|
||||
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from Utils import __version__, local_path
|
||||
from jinja2 import Template
|
||||
import yaml
|
||||
import json
|
||||
import typing
|
||||
|
||||
from worlds.AutoWorld import AutoWorldRegister
|
||||
import yaml
|
||||
from jinja2 import Template
|
||||
|
||||
import Options
|
||||
from Utils import __version__, local_path
|
||||
from worlds.AutoWorld import AutoWorldRegister
|
||||
|
||||
handled_in_js = {"start_inventory", "local_items", "non_local_items", "start_hints", "start_location_hints",
|
||||
"exclude_locations"}
|
||||
@@ -15,7 +16,13 @@ handled_in_js = {"start_inventory", "local_items", "non_local_items", "start_hin
|
||||
|
||||
def create():
|
||||
target_folder = local_path("WebHostLib", "static", "generated")
|
||||
os.makedirs(os.path.join(target_folder, "configs"), exist_ok=True)
|
||||
yaml_folder = os.path.join(target_folder, "configs")
|
||||
os.makedirs(yaml_folder, exist_ok=True)
|
||||
|
||||
for file in os.listdir(yaml_folder):
|
||||
full_path: str = os.path.join(yaml_folder, file)
|
||||
if os.path.isfile(full_path):
|
||||
os.unlink(full_path)
|
||||
|
||||
def dictify_range(option: typing.Union[Options.Range, Options.SpecialRange]):
|
||||
data = {}
|
||||
@@ -25,9 +32,12 @@ def create():
|
||||
data.update({
|
||||
option.range_start: 0,
|
||||
option.range_end: 0,
|
||||
"random": 0, "random-low": 0, "random-high": 0,
|
||||
option.default: 50
|
||||
})
|
||||
for sub_option in {"random", "random-low", "random-high"}:
|
||||
if sub_option != option.default:
|
||||
data[sub_option] = 0
|
||||
|
||||
notes = {
|
||||
special: "minimum value without special meaning",
|
||||
option.range_start: "minimum value",
|
||||
@@ -43,11 +53,6 @@ def create():
|
||||
|
||||
return data, notes
|
||||
|
||||
def default_converter(default_value):
|
||||
if isinstance(default_value, (set, frozenset)):
|
||||
return list(default_value)
|
||||
return default_value
|
||||
|
||||
def get_html_doc(option_type: type(Options.Option)) -> str:
|
||||
if not option_type.__doc__:
|
||||
return "Please document me!"
|
||||
@@ -64,13 +69,16 @@ def create():
|
||||
|
||||
for game_name, world in AutoWorldRegister.world_types.items():
|
||||
|
||||
all_options = {**Options.per_game_common_options, **world.option_definitions}
|
||||
all_options: typing.Dict[str, Options.AssembleOptions] = {
|
||||
**Options.per_game_common_options,
|
||||
**world.option_definitions
|
||||
}
|
||||
with open(local_path("WebHostLib", "templates", "options.yaml")) as f:
|
||||
file_data = f.read()
|
||||
res = Template(file_data).render(
|
||||
options=all_options,
|
||||
__version__=__version__, game=game_name, yaml_dump=yaml.dump,
|
||||
dictify_range=dictify_range, default_converter=default_converter,
|
||||
dictify_range=dictify_range,
|
||||
)
|
||||
|
||||
del file_data
|
||||
|
||||
@@ -26,24 +26,22 @@ window.addEventListener('load', () => {
|
||||
adjustHeaderWidth();
|
||||
|
||||
// Reset the id of all header divs to something nicer
|
||||
const headers = Array.from(document.querySelectorAll('h1, h2, h3, h4, h5, h6'));
|
||||
const scrollTargetIndex = window.location.href.search(/#[A-z0-9-_]*$/);
|
||||
for (let i=0; i < headers.length; i++){
|
||||
const headerId = headers[i].innerText.replace(/[ ]/g,'-').toLowerCase()
|
||||
headers[i].setAttribute('id', headerId);
|
||||
headers[i].addEventListener('click', () =>
|
||||
window.location.href = window.location.href.substring(0, scrollTargetIndex) + `#${headerId}`);
|
||||
for (const header of document.querySelectorAll('h1, h2, h3, h4, h5, h6')) {
|
||||
const headerId = header.innerText.replace(/\s+/g, '-').toLowerCase();
|
||||
header.setAttribute('id', headerId);
|
||||
header.addEventListener('click', () => {
|
||||
window.location.hash = `#${headerId}`;
|
||||
header.scrollIntoView();
|
||||
});
|
||||
}
|
||||
|
||||
// Manually scroll the user to the appropriate header if anchor navigation is used
|
||||
if (scrollTargetIndex > -1) {
|
||||
try{
|
||||
const scrollTarget = window.location.href.substring(scrollTargetIndex + 1);
|
||||
document.getElementById(scrollTarget).scrollIntoView({ behavior: "smooth" });
|
||||
} catch(error) {
|
||||
console.error(error);
|
||||
document.fonts.ready.finally(() => {
|
||||
if (window.location.hash) {
|
||||
const scrollTarget = document.getElementById(window.location.hash.substring(1));
|
||||
scrollTarget?.scrollIntoView();
|
||||
}
|
||||
}
|
||||
});
|
||||
}).catch((error) => {
|
||||
console.error(error);
|
||||
tutorialWrapper.innerHTML =
|
||||
|
||||
@@ -46,7 +46,7 @@ the website is not required to generate them.
|
||||
## How do I get started?
|
||||
|
||||
If you are ready to start randomizing games, or want to start playing your favorite randomizer with others, please join
|
||||
our discord server at the [Archipelago Discord](https://discord.gg/archipelago). There are always people ready to answer
|
||||
our discord server at the [Archipelago Discord](https://discord.gg/8Z65BR2). There are always people ready to answer
|
||||
any questions you might have.
|
||||
|
||||
## What are some common terms I should know?
|
||||
|
||||
@@ -26,24 +26,22 @@ window.addEventListener('load', () => {
|
||||
adjustHeaderWidth();
|
||||
|
||||
// Reset the id of all header divs to something nicer
|
||||
const headers = Array.from(document.querySelectorAll('h1, h2, h3, h4, h5, h6'));
|
||||
const scrollTargetIndex = window.location.href.search(/#[A-z0-9-_]*$/);
|
||||
for (let i=0; i < headers.length; i++){
|
||||
const headerId = headers[i].innerText.replace(/[ ]/g,'-').toLowerCase()
|
||||
headers[i].setAttribute('id', headerId);
|
||||
headers[i].addEventListener('click', () =>
|
||||
window.location.href = window.location.href.substring(0, scrollTargetIndex) + `#${headerId}`);
|
||||
for (const header of document.querySelectorAll('h1, h2, h3, h4, h5, h6')) {
|
||||
const headerId = header.innerText.replace(/\s+/g, '-').toLowerCase();
|
||||
header.setAttribute('id', headerId);
|
||||
header.addEventListener('click', () => {
|
||||
window.location.hash = `#${headerId}`;
|
||||
header.scrollIntoView();
|
||||
});
|
||||
}
|
||||
|
||||
// Manually scroll the user to the appropriate header if anchor navigation is used
|
||||
if (scrollTargetIndex > -1) {
|
||||
try{
|
||||
const scrollTarget = window.location.href.substring(scrollTargetIndex + 1);
|
||||
document.getElementById(scrollTarget).scrollIntoView({ behavior: "smooth" });
|
||||
} catch(error) {
|
||||
console.error(error);
|
||||
document.fonts.ready.finally(() => {
|
||||
if (window.location.hash) {
|
||||
const scrollTarget = document.getElementById(window.location.hash.substring(1));
|
||||
scrollTarget?.scrollIntoView();
|
||||
}
|
||||
}
|
||||
});
|
||||
}).catch((error) => {
|
||||
console.error(error);
|
||||
gameInfo.innerHTML =
|
||||
|
||||
@@ -26,24 +26,22 @@ window.addEventListener('load', () => {
|
||||
adjustHeaderWidth();
|
||||
|
||||
// Reset the id of all header divs to something nicer
|
||||
const headers = Array.from(document.querySelectorAll('h1, h2, h3, h4, h5, h6'));
|
||||
const scrollTargetIndex = window.location.href.search(/#[A-z0-9-_]*$/);
|
||||
for (let i=0; i < headers.length; i++){
|
||||
const headerId = headers[i].innerText.replace(/[ ]/g,'-').toLowerCase()
|
||||
headers[i].setAttribute('id', headerId);
|
||||
headers[i].addEventListener('click', () =>
|
||||
window.location.href = window.location.href.substring(0, scrollTargetIndex) + `#${headerId}`);
|
||||
for (const header of document.querySelectorAll('h1, h2, h3, h4, h5, h6')) {
|
||||
const headerId = header.innerText.replace(/\s+/g, '-').toLowerCase();
|
||||
header.setAttribute('id', headerId);
|
||||
header.addEventListener('click', () => {
|
||||
window.location.hash = `#${headerId}`;
|
||||
header.scrollIntoView();
|
||||
});
|
||||
}
|
||||
|
||||
// Manually scroll the user to the appropriate header if anchor navigation is used
|
||||
if (scrollTargetIndex > -1) {
|
||||
try{
|
||||
const scrollTarget = window.location.href.substring(scrollTargetIndex + 1);
|
||||
document.getElementById(scrollTarget).scrollIntoView({ behavior: "smooth" });
|
||||
} catch(error) {
|
||||
console.error(error);
|
||||
document.fonts.ready.finally(() => {
|
||||
if (window.location.hash) {
|
||||
const scrollTarget = document.getElementById(window.location.hash.substring(1));
|
||||
scrollTarget?.scrollIntoView();
|
||||
}
|
||||
}
|
||||
});
|
||||
}).catch((error) => {
|
||||
console.error(error);
|
||||
tutorialWrapper.innerHTML =
|
||||
|
||||
@@ -27,25 +27,28 @@ window.addEventListener('load', () => {
|
||||
tutorialWrapper.innerHTML += (new showdown.Converter()).makeHtml(results);
|
||||
adjustHeaderWidth();
|
||||
|
||||
const title = document.querySelector('h1')
|
||||
if (title) {
|
||||
document.title = title.textContent;
|
||||
}
|
||||
|
||||
// Reset the id of all header divs to something nicer
|
||||
const headers = Array.from(document.querySelectorAll('h1, h2, h3, h4, h5, h6'));
|
||||
const scrollTargetIndex = window.location.href.search(/#[A-z0-9-_]*$/);
|
||||
for (let i=0; i < headers.length; i++){
|
||||
const headerId = headers[i].innerText.replace(/[ ]/g,'-').toLowerCase()
|
||||
headers[i].setAttribute('id', headerId);
|
||||
headers[i].addEventListener('click', () =>
|
||||
window.location.href = window.location.href.substring(0, scrollTargetIndex) + `#${headerId}`);
|
||||
for (const header of document.querySelectorAll('h1, h2, h3, h4, h5, h6')) {
|
||||
const headerId = header.innerText.replace(/\s+/g, '-').toLowerCase();
|
||||
header.setAttribute('id', headerId);
|
||||
header.addEventListener('click', () => {
|
||||
window.location.hash = `#${headerId}`;
|
||||
header.scrollIntoView();
|
||||
});
|
||||
}
|
||||
|
||||
// Manually scroll the user to the appropriate header if anchor navigation is used
|
||||
if (scrollTargetIndex > -1) {
|
||||
try{
|
||||
const scrollTarget = window.location.href.substring(scrollTargetIndex + 1);
|
||||
document.getElementById(scrollTarget).scrollIntoView({ behavior: "smooth" });
|
||||
} catch(error) {
|
||||
console.error(error);
|
||||
document.fonts.ready.finally(() => {
|
||||
if (window.location.hash) {
|
||||
const scrollTarget = document.getElementById(window.location.hash.substring(1));
|
||||
scrollTarget?.scrollIntoView();
|
||||
}
|
||||
}
|
||||
});
|
||||
}).catch((error) => {
|
||||
console.error(error);
|
||||
tutorialWrapper.innerHTML =
|
||||
|
||||
@@ -78,13 +78,16 @@ const createDefaultSettings = (settingData) => {
|
||||
break;
|
||||
case 'range':
|
||||
case 'special_range':
|
||||
for (let i = setting.min; i <= setting.max; ++i){
|
||||
newSettings[game][gameSetting][i] =
|
||||
(setting.hasOwnProperty('defaultValue') && setting.defaultValue === i) ? 25 : 0;
|
||||
}
|
||||
newSettings[game][gameSetting][setting.min] = 0;
|
||||
newSettings[game][gameSetting][setting.max] = 0;
|
||||
newSettings[game][gameSetting]['random'] = 0;
|
||||
newSettings[game][gameSetting]['random-low'] = 0;
|
||||
newSettings[game][gameSetting]['random-high'] = 0;
|
||||
if (setting.hasOwnProperty('defaultValue')) {
|
||||
newSettings[game][gameSetting][setting.defaultValue] = 25;
|
||||
} else {
|
||||
newSettings[game][gameSetting][setting.min] = 25;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'items-list':
|
||||
@@ -401,11 +404,17 @@ const buildWeightedSettingsDiv = (game, settings) => {
|
||||
tr.appendChild(tdDelete);
|
||||
|
||||
rangeTbody.appendChild(tr);
|
||||
|
||||
// Save new option to settings
|
||||
range.dispatchEvent(new Event('change'));
|
||||
});
|
||||
|
||||
Object.keys(currentSettings[game][settingName]).forEach((option) => {
|
||||
if (currentSettings[game][settingName][option] > 0) {
|
||||
const tr = document.createElement('tr');
|
||||
// These options are statically generated below, and should always appear even if they are deleted
|
||||
// from localStorage
|
||||
if (['random-low', 'random', 'random-high'].includes(option)) { return; }
|
||||
|
||||
const tr = document.createElement('tr');
|
||||
const tdLeft = document.createElement('td');
|
||||
tdLeft.classList.add('td-left');
|
||||
tdLeft.innerText = option;
|
||||
@@ -439,14 +448,15 @@ const buildWeightedSettingsDiv = (game, settings) => {
|
||||
deleteButton.innerText = '❌';
|
||||
deleteButton.addEventListener('click', () => {
|
||||
range.value = 0;
|
||||
range.dispatchEvent(new Event('change'));
|
||||
const changeEvent = new Event('change');
|
||||
changeEvent.action = 'rangeDelete';
|
||||
range.dispatchEvent(changeEvent);
|
||||
rangeTbody.removeChild(tr);
|
||||
});
|
||||
tdDelete.appendChild(deleteButton);
|
||||
tr.appendChild(tdDelete);
|
||||
|
||||
rangeTbody.appendChild(tr);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -904,8 +914,12 @@ const updateGameSetting = (evt) => {
|
||||
const setting = evt.target.getAttribute('data-setting');
|
||||
const option = evt.target.getAttribute('data-option');
|
||||
document.getElementById(`${game}-${setting}-${option}`).innerText = evt.target.value;
|
||||
options[game][setting][option] = isNaN(evt.target.value) ?
|
||||
evt.target.value : parseInt(evt.target.value, 10);
|
||||
console.log(event);
|
||||
if (evt.action && evt.action === 'rangeDelete') {
|
||||
delete options[game][setting][option];
|
||||
} else {
|
||||
options[game][setting][option] = parseInt(evt.target.value, 10);
|
||||
}
|
||||
localStorage.setItem('weighted-settings', JSON.stringify(options));
|
||||
};
|
||||
|
||||
|
||||
@@ -55,4 +55,6 @@
|
||||
border: 1px solid #2a6c2f;
|
||||
border-radius: 6px;
|
||||
color: #000000;
|
||||
overflow-y: auto;
|
||||
max-height: 400px;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
html{
|
||||
padding-top: 110px;
|
||||
scroll-padding-top: 100px;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
#base-header{
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import typing
|
||||
from collections import Counter, defaultdict
|
||||
from colorsys import hsv_to_rgb
|
||||
from datetime import datetime, timedelta, date
|
||||
from math import tau
|
||||
import typing
|
||||
|
||||
from bokeh.colors import RGB
|
||||
from bokeh.embed import components
|
||||
from bokeh.models import HoverTool
|
||||
from bokeh.plotting import figure, ColumnDataSource
|
||||
from bokeh.resources import INLINE
|
||||
from bokeh.colors import RGB
|
||||
from flask import render_template
|
||||
from pony.orm import select
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
{% extends 'pageWrapper.html' %}
|
||||
|
||||
{% block head %}
|
||||
{{ super() }}
|
||||
<title>Mystery Check Result</title>
|
||||
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/check.css") }}" />
|
||||
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/check.js") }}"></script>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
{% extends 'pageWrapper.html' %}
|
||||
|
||||
{% block head %}
|
||||
{{ super() }}
|
||||
<title>Generate Game</title>
|
||||
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/generate.css") }}" />
|
||||
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/generate.js") }}"></script>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
{% extends 'pageWrapper.html' %}
|
||||
|
||||
{% block head %}
|
||||
{{ super() }}
|
||||
<title>Upload Multidata</title>
|
||||
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/hostGame.css") }}" />
|
||||
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/hostGame.js") }}"></script>
|
||||
|
||||
@@ -43,14 +43,18 @@ requires:
|
||||
{%- if option.range_start is defined and option.range_start is number %}
|
||||
{{- range_option(option) -}}
|
||||
{%- elif option.options -%}
|
||||
{%- for suboption_option_id, sub_option_name in option.name_lookup.items() %}
|
||||
{%- for suboption_option_id, sub_option_name in option.name_lookup.items() %}
|
||||
{{ sub_option_name }}: {% if suboption_option_id == option.default %}50{% else %}0{% endif %}
|
||||
{%- endfor -%}
|
||||
{% if option.default == "random" %}
|
||||
random: 50
|
||||
{%- endif -%}
|
||||
{%- endfor -%}
|
||||
{% if option.name_lookup[option.default] not in option.options %}
|
||||
{{ option.default }}: 50
|
||||
{%- endif -%}
|
||||
{%- elif option.default is string %}
|
||||
{{ option.default }}: 50
|
||||
{%- elif option.default is iterable and option.default is not mapping %}
|
||||
{{ option.default | list }}
|
||||
{%- else %}
|
||||
{{ yaml_dump(default_converter(option.default)) | indent(4, first=False) }}
|
||||
{%- endif -%}
|
||||
{{ yaml_dump(option.default) | indent(4, first=false) }}
|
||||
{%- endif -%}
|
||||
{%- endfor %}
|
||||
{% if not options %}{}{% endif %}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
{% extends 'pageWrapper.html' %}
|
||||
|
||||
{% block head %}
|
||||
{{ super() }}
|
||||
<title>Start Playing</title>
|
||||
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/startPlaying.css") }}" />
|
||||
{% endblock %}
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
<td></td>
|
||||
{% endif %}
|
||||
<td><img src="{{ icons['Elevator Keycard'] }}" class="{{ 'acquired' if 'Elevator Keycard' in acquired_items }}" title="Elevator Keycard" /></td>
|
||||
{% if 'FacebookMode' in options %}
|
||||
{% if 'EyeSpy' in options %}
|
||||
<td><img src="{{ icons['Oculus Ring'] }}" class="{{ 'acquired' if 'Oculus Ring' in acquired_items }}" title="Oculus Ring" /></td>
|
||||
{% else %}
|
||||
<td></td>
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
import collections
|
||||
import datetime
|
||||
import typing
|
||||
from typing import Counter, Optional, Dict, Any, Tuple
|
||||
from uuid import UUID
|
||||
|
||||
from flask import render_template
|
||||
from werkzeug.exceptions import abort
|
||||
import datetime
|
||||
from uuid import UUID
|
||||
|
||||
from worlds.alttp import Items
|
||||
from WebHostLib import app, cache, Room
|
||||
from Utils import restricted_loads
|
||||
from worlds import lookup_any_item_id_to_name, lookup_any_location_id_to_name
|
||||
from MultiServer import Context
|
||||
from NetUtils import SlotType
|
||||
from Utils import restricted_loads
|
||||
from worlds import lookup_any_item_id_to_name, lookup_any_location_id_to_name
|
||||
from worlds.alttp import Items
|
||||
from . import app, cache
|
||||
from .models import Room
|
||||
|
||||
alttp_icons = {
|
||||
"Blue Shield": r"https://www.zeldadungeon.net/wiki/images/8/85/Fighters-Shield.png",
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
import typing
|
||||
import zipfile
|
||||
import lzma
|
||||
import json
|
||||
import base64
|
||||
import MultiServer
|
||||
import json
|
||||
import typing
|
||||
import uuid
|
||||
import zipfile
|
||||
from io import BytesIO
|
||||
|
||||
from flask import request, flash, redirect, url_for, session, render_template
|
||||
from pony.orm import flush, select
|
||||
|
||||
from WebHostLib import app, Seed, Room, Slot
|
||||
from Utils import parse_yaml, VersionException, __version__
|
||||
from Patch import preferred_endings, AutoPatchRegister
|
||||
import MultiServer
|
||||
from NetUtils import NetworkSlot, SlotType
|
||||
from Utils import VersionException, __version__
|
||||
from worlds.Files import AutoPatchRegister
|
||||
from . import app
|
||||
from .models import Seed, Room, Slot
|
||||
|
||||
banned_zip_contents = (".sfc",)
|
||||
|
||||
@@ -22,7 +22,7 @@ def upload_zip_to_db(zfile: zipfile.ZipFile, owner=None, meta={"race": False}, s
|
||||
if not owner:
|
||||
owner = session["_id"]
|
||||
infolist = zfile.infolist()
|
||||
slots = set()
|
||||
slots: typing.Set[Slot] = set()
|
||||
spoiler = ""
|
||||
multidata = None
|
||||
for file in infolist:
|
||||
@@ -38,17 +38,6 @@ def upload_zip_to_db(zfile: zipfile.ZipFile, owner=None, meta={"race": False}, s
|
||||
player_name=patch.player_name,
|
||||
player_id=patch.player,
|
||||
game=patch.game))
|
||||
elif file.filename.endswith(tuple(preferred_endings.values())):
|
||||
data = zfile.open(file, "r").read()
|
||||
yaml_data = parse_yaml(lzma.decompress(data).decode("utf-8-sig"))
|
||||
if yaml_data["version"] < 2:
|
||||
return "Old format cannot be uploaded (outdated .apbp)"
|
||||
metadata = yaml_data["meta"]
|
||||
|
||||
slots.add(Slot(data=data,
|
||||
player_name=metadata["player_name"],
|
||||
player_id=metadata["player_id"],
|
||||
game=yaml_data["game"]))
|
||||
|
||||
elif file.filename.endswith(".apmc"):
|
||||
data = zfile.open(file, "r").read()
|
||||
|
||||
358
ZillionClient.py
Normal file
358
ZillionClient.py
Normal file
@@ -0,0 +1,358 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import platform
|
||||
from typing import Any, Coroutine, Dict, Optional, Type, cast
|
||||
|
||||
# CommonClient import first to trigger ModuleUpdater
|
||||
from CommonClient import CommonContext, server_loop, gui_enabled, \
|
||||
ClientCommandProcessor, logger, get_base_parser
|
||||
from NetUtils import ClientStatus
|
||||
import Utils
|
||||
|
||||
import colorama # type: ignore
|
||||
|
||||
from zilliandomizer.zri.memory import Memory
|
||||
from zilliandomizer.zri import events
|
||||
from zilliandomizer.utils.loc_name_maps import id_to_loc
|
||||
from zilliandomizer.options import Chars
|
||||
from zilliandomizer.patch import RescueInfo
|
||||
|
||||
from worlds.zillion.id_maps import make_id_to_others
|
||||
from worlds.zillion.config import base_id
|
||||
|
||||
|
||||
class ZillionCommandProcessor(ClientCommandProcessor):
|
||||
ctx: "ZillionContext"
|
||||
|
||||
def _cmd_sms(self) -> None:
|
||||
""" Tell the client that Zillion is running in RetroArch. """
|
||||
logger.info("ready to look for game")
|
||||
self.ctx.look_for_retroarch.set()
|
||||
|
||||
|
||||
class ZillionContext(CommonContext):
|
||||
game = "Zillion"
|
||||
command_processor: Type[ClientCommandProcessor] = ZillionCommandProcessor
|
||||
items_handling = 1 # receive items from other players
|
||||
|
||||
from_game: "asyncio.Queue[events.EventFromGame]"
|
||||
to_game: "asyncio.Queue[events.EventToGame]"
|
||||
ap_local_count: int
|
||||
""" local checks watched by server """
|
||||
next_item: int
|
||||
""" index in `items_received` """
|
||||
ap_id_to_name: Dict[int, str]
|
||||
ap_id_to_zz_id: Dict[int, int]
|
||||
start_char: Chars = "JJ"
|
||||
rescues: Dict[int, RescueInfo] = {}
|
||||
loc_mem_to_id: Dict[int, int] = {}
|
||||
got_slot_data: asyncio.Event
|
||||
""" serves as a flag for whether I am logged in to the server """
|
||||
|
||||
look_for_retroarch: asyncio.Event
|
||||
"""
|
||||
There is a bug in Python in Windows
|
||||
https://github.com/python/cpython/issues/91227
|
||||
that makes it so if I look for RetroArch before it's ready,
|
||||
it breaks the asyncio udp transport system.
|
||||
|
||||
As a workaround, we don't look for RetroArch until this event is set.
|
||||
"""
|
||||
|
||||
def __init__(self,
|
||||
server_address: str,
|
||||
password: str) -> None:
|
||||
super().__init__(server_address, password)
|
||||
self.from_game = asyncio.Queue()
|
||||
self.to_game = asyncio.Queue()
|
||||
self.got_slot_data = asyncio.Event()
|
||||
|
||||
self.look_for_retroarch = asyncio.Event()
|
||||
if platform.system() != "Windows":
|
||||
# asyncio udp bug is only on Windows
|
||||
self.look_for_retroarch.set()
|
||||
|
||||
self.reset_game_state()
|
||||
|
||||
def reset_game_state(self) -> None:
|
||||
for _ in range(self.from_game.qsize()):
|
||||
self.from_game.get_nowait()
|
||||
for _ in range(self.to_game.qsize()):
|
||||
self.to_game.get_nowait()
|
||||
self.got_slot_data.clear()
|
||||
|
||||
self.ap_local_count = 0
|
||||
self.next_item = 0
|
||||
self.ap_id_to_name = {}
|
||||
self.ap_id_to_zz_id = {}
|
||||
self.rescues = {}
|
||||
self.loc_mem_to_id = {}
|
||||
|
||||
self.locations_checked.clear()
|
||||
self.missing_locations.clear()
|
||||
self.checked_locations.clear()
|
||||
self.finished_game = False
|
||||
self.items_received.clear()
|
||||
|
||||
# override
|
||||
def on_deathlink(self, data: Dict[str, Any]) -> None:
|
||||
self.to_game.put_nowait(events.DeathEventToGame())
|
||||
return super().on_deathlink(data)
|
||||
|
||||
# override
|
||||
async def server_auth(self, password_requested: bool = False) -> None:
|
||||
if password_requested and not self.password:
|
||||
await super().server_auth(password_requested)
|
||||
if not self.auth:
|
||||
logger.info('waiting for connection to game...')
|
||||
return
|
||||
logger.info("logging in to server...")
|
||||
await self.send_connect()
|
||||
|
||||
# override
|
||||
def run_gui(self) -> None:
|
||||
from kvui import GameManager
|
||||
|
||||
class ZillionManager(GameManager):
|
||||
logging_pairs = [
|
||||
("Client", "Archipelago")
|
||||
]
|
||||
base_title = "Archipelago Zillion Client"
|
||||
|
||||
self.ui = ZillionManager(self)
|
||||
run_co: Coroutine[Any, Any, None] = self.ui.async_run() # type: ignore
|
||||
# kivy types missing
|
||||
self.ui_task = asyncio.create_task(run_co, name="UI")
|
||||
|
||||
def on_package(self, cmd: str, args: Dict[str, Any]) -> None:
|
||||
if cmd == "Connected":
|
||||
logger.info("logged in to Archipelago server")
|
||||
if "slot_data" not in args:
|
||||
logger.warn("`Connected` packet missing `slot_data`")
|
||||
return
|
||||
slot_data = args["slot_data"]
|
||||
|
||||
if "start_char" not in slot_data:
|
||||
logger.warn("invalid Zillion `Connected` packet, `slot_data` missing `start_char`")
|
||||
return
|
||||
self.start_char = slot_data['start_char']
|
||||
if self.start_char not in {"Apple", "Champ", "JJ"}:
|
||||
logger.warn("invalid Zillion `Connected` packet, "
|
||||
f"`slot_data` `start_char` has invalid value: {self.start_char}")
|
||||
|
||||
if "rescues" not in slot_data:
|
||||
logger.warn("invalid Zillion `Connected` packet, `slot_data` missing `rescues`")
|
||||
return
|
||||
rescues = slot_data["rescues"]
|
||||
self.rescues = {}
|
||||
for rescue_id, json_info in rescues.items():
|
||||
assert rescue_id in ("0", "1"), f"invalid rescue_id in Zillion slot_data: {rescue_id}"
|
||||
# TODO: just take start_char out of the RescueInfo so there's no opportunity for a mismatch?
|
||||
assert json_info["start_char"] == self.start_char, \
|
||||
f'mismatch in Zillion slot data: {json_info["start_char"]} {self.start_char}'
|
||||
ri = RescueInfo(json_info["start_char"],
|
||||
json_info["room_code"],
|
||||
json_info["mask"])
|
||||
self.rescues[0 if rescue_id == "0" else 1] = ri
|
||||
|
||||
if "loc_mem_to_id" not in slot_data:
|
||||
logger.warn("invalid Zillion `Connected` packet, `slot_data` missing `loc_mem_to_id`")
|
||||
return
|
||||
loc_mem_to_id = slot_data["loc_mem_to_id"]
|
||||
self.loc_mem_to_id = {}
|
||||
for mem_str, id_str in loc_mem_to_id.items():
|
||||
mem = int(mem_str)
|
||||
id_ = int(id_str)
|
||||
room_i = mem // 256
|
||||
assert 0 <= room_i < 74
|
||||
assert id_ in id_to_loc
|
||||
self.loc_mem_to_id[mem] = id_
|
||||
|
||||
self.got_slot_data.set()
|
||||
|
||||
payload = {
|
||||
"cmd": "Get",
|
||||
"keys": [f"zillion-{self.auth}-doors"]
|
||||
}
|
||||
asyncio.create_task(self.send_msgs([payload]))
|
||||
elif cmd == "Retrieved":
|
||||
if "keys" not in args:
|
||||
logger.warning(f"invalid Retrieved packet to ZillionClient: {args}")
|
||||
return
|
||||
keys = cast(Dict[str, Optional[str]], args["keys"])
|
||||
doors_b64 = keys[f"zillion-{self.auth}-doors"]
|
||||
if doors_b64:
|
||||
logger.info("received door data from server")
|
||||
doors = base64.b64decode(doors_b64)
|
||||
self.to_game.put_nowait(events.DoorEventToGame(doors))
|
||||
|
||||
def process_from_game_queue(self) -> None:
|
||||
if self.from_game.qsize():
|
||||
event_from_game = self.from_game.get_nowait()
|
||||
if isinstance(event_from_game, events.AcquireLocationEventFromGame):
|
||||
server_id = event_from_game.id + base_id
|
||||
loc_name = id_to_loc[event_from_game.id]
|
||||
self.locations_checked.add(server_id)
|
||||
if server_id in self.missing_locations:
|
||||
self.ap_local_count += 1
|
||||
n_locations = len(self.missing_locations) + len(self.checked_locations) - 1 # -1 to ignore win
|
||||
logger.info(f'New Check: {loc_name} ({self.ap_local_count}/{n_locations})')
|
||||
asyncio.create_task(self.send_msgs([
|
||||
{"cmd": 'LocationChecks', "locations": [server_id]}
|
||||
]))
|
||||
else:
|
||||
# This will happen a lot in Zillion,
|
||||
# because all the key words are local and unwatched by the server.
|
||||
logger.debug(f"DEBUG: {loc_name} not in missing")
|
||||
elif isinstance(event_from_game, events.DeathEventFromGame):
|
||||
asyncio.create_task(self.send_death())
|
||||
elif isinstance(event_from_game, events.WinEventFromGame):
|
||||
if not self.finished_game:
|
||||
asyncio.create_task(self.send_msgs([
|
||||
{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}
|
||||
]))
|
||||
self.finished_game = True
|
||||
elif isinstance(event_from_game, events.DoorEventFromGame):
|
||||
if self.auth:
|
||||
doors_b64 = base64.b64encode(event_from_game.doors).decode()
|
||||
payload = {
|
||||
"cmd": "Set",
|
||||
"key": f"zillion-{self.auth}-doors",
|
||||
"operations": [{"operation": "replace", "value": doors_b64}]
|
||||
}
|
||||
asyncio.create_task(self.send_msgs([payload]))
|
||||
else:
|
||||
logger.warning(f"WARNING: unhandled event from game {event_from_game}")
|
||||
|
||||
def process_items_received(self) -> None:
|
||||
if len(self.items_received) > self.next_item:
|
||||
zz_item_ids = [self.ap_id_to_zz_id[item.item] for item in self.items_received]
|
||||
for index in range(self.next_item, len(self.items_received)):
|
||||
ap_id = self.items_received[index].item
|
||||
from_name = self.player_names[self.items_received[index].player]
|
||||
# TODO: colors in this text, like sni client?
|
||||
logger.info(f'received {self.ap_id_to_name[ap_id]} from {from_name}')
|
||||
self.to_game.put_nowait(
|
||||
events.ItemEventToGame(zz_item_ids)
|
||||
)
|
||||
self.next_item = len(self.items_received)
|
||||
|
||||
|
||||
async def zillion_sync_task(ctx: ZillionContext) -> None:
|
||||
logger.info("started zillion sync task")
|
||||
|
||||
# to work around the Python bug where we can't check for RetroArch
|
||||
if not ctx.look_for_retroarch.is_set():
|
||||
logger.info("Start Zillion in RetroArch, then use the /sms command to connect to it.")
|
||||
await asyncio.wait((
|
||||
asyncio.create_task(ctx.look_for_retroarch.wait()),
|
||||
asyncio.create_task(ctx.exit_event.wait())
|
||||
), return_when=asyncio.FIRST_COMPLETED)
|
||||
|
||||
last_log = ""
|
||||
|
||||
def log_no_spam(msg: str) -> None:
|
||||
nonlocal last_log
|
||||
if msg != last_log:
|
||||
last_log = msg
|
||||
logger.info(msg)
|
||||
|
||||
# to only show this message once per client run
|
||||
help_message_shown = False
|
||||
|
||||
with Memory(ctx.from_game, ctx.to_game) as memory:
|
||||
while not ctx.exit_event.is_set():
|
||||
ram = await memory.read()
|
||||
name = memory.get_player_name(ram).decode()
|
||||
if len(name):
|
||||
if name == ctx.auth:
|
||||
# this is the name we know
|
||||
if ctx.server and ctx.server.socket: # type: ignore
|
||||
if memory.have_generation_info():
|
||||
log_no_spam("everything connected")
|
||||
await memory.process_ram(ram)
|
||||
ctx.process_from_game_queue()
|
||||
ctx.process_items_received()
|
||||
else: # no generation info
|
||||
if ctx.got_slot_data.is_set():
|
||||
memory.set_generation_info(ctx.rescues, ctx.loc_mem_to_id)
|
||||
ctx.ap_id_to_name, ctx.ap_id_to_zz_id, _ap_id_to_zz_item = \
|
||||
make_id_to_others(ctx.start_char)
|
||||
ctx.next_item = 0
|
||||
ctx.ap_local_count = len(ctx.checked_locations)
|
||||
else: # no slot data yet
|
||||
asyncio.create_task(ctx.send_connect())
|
||||
log_no_spam("logging in to server...")
|
||||
await asyncio.wait((
|
||||
ctx.got_slot_data.wait(),
|
||||
ctx.exit_event.wait(),
|
||||
asyncio.sleep(6)
|
||||
), return_when=asyncio.FIRST_COMPLETED) # to not spam connect packets
|
||||
else: # server not connected
|
||||
log_no_spam("waiting for server connection...")
|
||||
else: # new game
|
||||
log_no_spam("connected to new game")
|
||||
await ctx.disconnect()
|
||||
ctx.reset_server_state()
|
||||
ctx.reset_game_state()
|
||||
memory.reset_game_state()
|
||||
|
||||
ctx.auth = name
|
||||
asyncio.create_task(ctx.connect())
|
||||
await asyncio.wait((
|
||||
ctx.got_slot_data.wait(),
|
||||
ctx.exit_event.wait(),
|
||||
asyncio.sleep(6)
|
||||
), return_when=asyncio.FIRST_COMPLETED) # to not spam connect packets
|
||||
else: # no name found in game
|
||||
if not help_message_shown:
|
||||
logger.info('In RetroArch, make sure "Settings > Network > Network Commands" is on.')
|
||||
help_message_shown = True
|
||||
log_no_spam("looking for connection to game...")
|
||||
await asyncio.sleep(0.3)
|
||||
|
||||
await asyncio.sleep(0.09375)
|
||||
logger.info("zillion sync task ending")
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
parser = get_base_parser()
|
||||
parser.add_argument('diff_file', default="", type=str, nargs="?",
|
||||
help='Path to a .apzl Archipelago Binary Patch file')
|
||||
# SNI parser.add_argument('--loglevel', default='info', choices=['debug', 'info', 'warning', 'error', 'critical'])
|
||||
args = parser.parse_args()
|
||||
print(args)
|
||||
|
||||
if args.diff_file:
|
||||
import Patch
|
||||
logger.info("patch file was supplied - creating sms rom...")
|
||||
meta, rom_file = Patch.create_rom_file(args.diff_file)
|
||||
if "server" in meta:
|
||||
args.connect = meta["server"]
|
||||
logger.info(f"wrote rom file to {rom_file}")
|
||||
|
||||
ctx = ZillionContext(args.connect, args.password)
|
||||
if ctx.server_task is None:
|
||||
ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop")
|
||||
|
||||
if gui_enabled:
|
||||
ctx.run_gui()
|
||||
ctx.run_cli()
|
||||
|
||||
sync_task = asyncio.create_task(zillion_sync_task(ctx))
|
||||
|
||||
await ctx.exit_event.wait()
|
||||
|
||||
ctx.server_address = None
|
||||
logger.debug("waiting for sync task to end")
|
||||
await sync_task
|
||||
logger.debug("sync task ended")
|
||||
await ctx.shutdown()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
Utils.init_logging("ZillionClient", exception_logger="Client")
|
||||
|
||||
colorama.init()
|
||||
asyncio.run(main())
|
||||
colorama.deinit()
|
||||
Binary file not shown.
BIN
data/basepatch.bsdiff4
Normal file
BIN
data/basepatch.bsdiff4
Normal file
Binary file not shown.
BIN
data/lua/PKMN_RB/core.dll
Normal file
BIN
data/lua/PKMN_RB/core.dll
Normal file
Binary file not shown.
389
data/lua/PKMN_RB/json.lua
Normal file
389
data/lua/PKMN_RB/json.lua
Normal file
@@ -0,0 +1,389 @@
|
||||
--
|
||||
-- json.lua
|
||||
--
|
||||
-- Copyright (c) 2015 rxi
|
||||
--
|
||||
-- This library is free software; you can redistribute it and/or modify it
|
||||
-- under the terms of the MIT license. See LICENSE for details.
|
||||
--
|
||||
|
||||
local json = { _version = "0.1.0" }
|
||||
|
||||
-------------------------------------------------------------------------------
|
||||
-- Encode
|
||||
-------------------------------------------------------------------------------
|
||||
|
||||
local encode
|
||||
|
||||
function error(err)
|
||||
print(err)
|
||||
end
|
||||
|
||||
local escape_char_map = {
|
||||
[ "\\" ] = "\\\\",
|
||||
[ "\"" ] = "\\\"",
|
||||
[ "\b" ] = "\\b",
|
||||
[ "\f" ] = "\\f",
|
||||
[ "\n" ] = "\\n",
|
||||
[ "\r" ] = "\\r",
|
||||
[ "\t" ] = "\\t",
|
||||
}
|
||||
|
||||
local escape_char_map_inv = { [ "\\/" ] = "/" }
|
||||
for k, v in pairs(escape_char_map) do
|
||||
escape_char_map_inv[v] = k
|
||||
end
|
||||
|
||||
|
||||
local function escape_char(c)
|
||||
return escape_char_map[c] or string.format("\\u%04x", c:byte())
|
||||
end
|
||||
|
||||
|
||||
local function encode_nil(val)
|
||||
return "null"
|
||||
end
|
||||
|
||||
|
||||
local function encode_table(val, stack)
|
||||
local res = {}
|
||||
stack = stack or {}
|
||||
|
||||
-- Circular reference?
|
||||
if stack[val] then error("circular reference") end
|
||||
|
||||
stack[val] = true
|
||||
|
||||
if val[1] ~= nil or next(val) == nil then
|
||||
-- Treat as array -- check keys are valid and it is not sparse
|
||||
local n = 0
|
||||
for k in pairs(val) do
|
||||
if type(k) ~= "number" then
|
||||
error("invalid table: mixed or invalid key types")
|
||||
end
|
||||
n = n + 1
|
||||
end
|
||||
if n ~= #val then
|
||||
print("invalid table: sparse array")
|
||||
print(n)
|
||||
print("VAL:")
|
||||
print(val)
|
||||
print("STACK:")
|
||||
print(stack)
|
||||
end
|
||||
-- Encode
|
||||
for i, v in ipairs(val) do
|
||||
table.insert(res, encode(v, stack))
|
||||
end
|
||||
stack[val] = nil
|
||||
return "[" .. table.concat(res, ",") .. "]"
|
||||
|
||||
else
|
||||
-- Treat as an object
|
||||
for k, v in pairs(val) do
|
||||
if type(k) ~= "string" then
|
||||
error("invalid table: mixed or invalid key types")
|
||||
end
|
||||
table.insert(res, encode(k, stack) .. ":" .. encode(v, stack))
|
||||
end
|
||||
stack[val] = nil
|
||||
return "{" .. table.concat(res, ",") .. "}"
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
local function encode_string(val)
|
||||
return '"' .. val:gsub('[%z\1-\31\\"]', escape_char) .. '"'
|
||||
end
|
||||
|
||||
|
||||
local function encode_number(val)
|
||||
-- Check for NaN, -inf and inf
|
||||
if val ~= val or val <= -math.huge or val >= math.huge then
|
||||
error("unexpected number value '" .. tostring(val) .. "'")
|
||||
end
|
||||
return string.format("%.14g", val)
|
||||
end
|
||||
|
||||
|
||||
local type_func_map = {
|
||||
[ "nil" ] = encode_nil,
|
||||
[ "table" ] = encode_table,
|
||||
[ "string" ] = encode_string,
|
||||
[ "number" ] = encode_number,
|
||||
[ "boolean" ] = tostring,
|
||||
}
|
||||
|
||||
|
||||
encode = function(val, stack)
|
||||
local t = type(val)
|
||||
local f = type_func_map[t]
|
||||
if f then
|
||||
return f(val, stack)
|
||||
end
|
||||
error("unexpected type '" .. t .. "'")
|
||||
end
|
||||
|
||||
|
||||
function json.encode(val)
|
||||
return ( encode(val) )
|
||||
end
|
||||
|
||||
|
||||
-------------------------------------------------------------------------------
|
||||
-- Decode
|
||||
-------------------------------------------------------------------------------
|
||||
|
||||
local parse
|
||||
|
||||
local function create_set(...)
|
||||
local res = {}
|
||||
for i = 1, select("#", ...) do
|
||||
res[ select(i, ...) ] = true
|
||||
end
|
||||
return res
|
||||
end
|
||||
|
||||
local space_chars = create_set(" ", "\t", "\r", "\n")
|
||||
local delim_chars = create_set(" ", "\t", "\r", "\n", "]", "}", ",")
|
||||
local escape_chars = create_set("\\", "/", '"', "b", "f", "n", "r", "t", "u")
|
||||
local literals = create_set("true", "false", "null")
|
||||
|
||||
local literal_map = {
|
||||
[ "true" ] = true,
|
||||
[ "false" ] = false,
|
||||
[ "null" ] = nil,
|
||||
}
|
||||
|
||||
|
||||
local function next_char(str, idx, set, negate)
|
||||
for i = idx, #str do
|
||||
if set[str:sub(i, i)] ~= negate then
|
||||
return i
|
||||
end
|
||||
end
|
||||
return #str + 1
|
||||
end
|
||||
|
||||
|
||||
local function decode_error(str, idx, msg)
|
||||
--local line_count = 1
|
||||
--local col_count = 1
|
||||
--for i = 1, idx - 1 do
|
||||
-- col_count = col_count + 1
|
||||
-- if str:sub(i, i) == "\n" then
|
||||
-- line_count = line_count + 1
|
||||
-- col_count = 1
|
||||
-- end
|
||||
-- end
|
||||
-- emu.message( string.format("%s at line %d col %d", msg, line_count, col_count) )
|
||||
end
|
||||
|
||||
|
||||
local function codepoint_to_utf8(n)
|
||||
-- http://scripts.sil.org/cms/scripts/page.php?site_id=nrsi&id=iws-appendixa
|
||||
local f = math.floor
|
||||
if n <= 0x7f then
|
||||
return string.char(n)
|
||||
elseif n <= 0x7ff then
|
||||
return string.char(f(n / 64) + 192, n % 64 + 128)
|
||||
elseif n <= 0xffff then
|
||||
return string.char(f(n / 4096) + 224, f(n % 4096 / 64) + 128, n % 64 + 128)
|
||||
elseif n <= 0x10ffff then
|
||||
return string.char(f(n / 262144) + 240, f(n % 262144 / 4096) + 128,
|
||||
f(n % 4096 / 64) + 128, n % 64 + 128)
|
||||
end
|
||||
error( string.format("invalid unicode codepoint '%x'", n) )
|
||||
end
|
||||
|
||||
|
||||
local function parse_unicode_escape(s)
|
||||
local n1 = tonumber( s:sub(3, 6), 16 )
|
||||
local n2 = tonumber( s:sub(9, 12), 16 )
|
||||
-- Surrogate pair?
|
||||
if n2 then
|
||||
return codepoint_to_utf8((n1 - 0xd800) * 0x400 + (n2 - 0xdc00) + 0x10000)
|
||||
else
|
||||
return codepoint_to_utf8(n1)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
local function parse_string(str, i)
|
||||
local has_unicode_escape = false
|
||||
local has_surrogate_escape = false
|
||||
local has_escape = false
|
||||
local last
|
||||
for j = i + 1, #str do
|
||||
local x = str:byte(j)
|
||||
|
||||
if x < 32 then
|
||||
decode_error(str, j, "control character in string")
|
||||
end
|
||||
|
||||
if last == 92 then -- "\\" (escape char)
|
||||
if x == 117 then -- "u" (unicode escape sequence)
|
||||
local hex = str:sub(j + 1, j + 5)
|
||||
if not hex:find("%x%x%x%x") then
|
||||
decode_error(str, j, "invalid unicode escape in string")
|
||||
end
|
||||
if hex:find("^[dD][89aAbB]") then
|
||||
has_surrogate_escape = true
|
||||
else
|
||||
has_unicode_escape = true
|
||||
end
|
||||
else
|
||||
local c = string.char(x)
|
||||
if not escape_chars[c] then
|
||||
decode_error(str, j, "invalid escape char '" .. c .. "' in string")
|
||||
end
|
||||
has_escape = true
|
||||
end
|
||||
last = nil
|
||||
|
||||
elseif x == 34 then -- '"' (end of string)
|
||||
local s = str:sub(i + 1, j - 1)
|
||||
if has_surrogate_escape then
|
||||
s = s:gsub("\\u[dD][89aAbB]..\\u....", parse_unicode_escape)
|
||||
end
|
||||
if has_unicode_escape then
|
||||
s = s:gsub("\\u....", parse_unicode_escape)
|
||||
end
|
||||
if has_escape then
|
||||
s = s:gsub("\\.", escape_char_map_inv)
|
||||
end
|
||||
return s, j + 1
|
||||
|
||||
else
|
||||
last = x
|
||||
end
|
||||
end
|
||||
decode_error(str, i, "expected closing quote for string")
|
||||
end
|
||||
|
||||
|
||||
local function parse_number(str, i)
|
||||
local x = next_char(str, i, delim_chars)
|
||||
local s = str:sub(i, x - 1)
|
||||
local n = tonumber(s)
|
||||
if not n then
|
||||
decode_error(str, i, "invalid number '" .. s .. "'")
|
||||
end
|
||||
return n, x
|
||||
end
|
||||
|
||||
|
||||
local function parse_literal(str, i)
|
||||
local x = next_char(str, i, delim_chars)
|
||||
local word = str:sub(i, x - 1)
|
||||
if not literals[word] then
|
||||
decode_error(str, i, "invalid literal '" .. word .. "'")
|
||||
end
|
||||
return literal_map[word], x
|
||||
end
|
||||
|
||||
|
||||
local function parse_array(str, i)
|
||||
local res = {}
|
||||
local n = 1
|
||||
i = i + 1
|
||||
while 1 do
|
||||
local x
|
||||
i = next_char(str, i, space_chars, true)
|
||||
-- Empty / end of array?
|
||||
if str:sub(i, i) == "]" then
|
||||
i = i + 1
|
||||
break
|
||||
end
|
||||
-- Read token
|
||||
x, i = parse(str, i)
|
||||
res[n] = x
|
||||
n = n + 1
|
||||
-- Next token
|
||||
i = next_char(str, i, space_chars, true)
|
||||
local chr = str:sub(i, i)
|
||||
i = i + 1
|
||||
if chr == "]" then break end
|
||||
if chr ~= "," then decode_error(str, i, "expected ']' or ','") end
|
||||
end
|
||||
return res, i
|
||||
end
|
||||
|
||||
|
||||
local function parse_object(str, i)
|
||||
local res = {}
|
||||
i = i + 1
|
||||
while 1 do
|
||||
local key, val
|
||||
i = next_char(str, i, space_chars, true)
|
||||
-- Empty / end of object?
|
||||
if str:sub(i, i) == "}" then
|
||||
i = i + 1
|
||||
break
|
||||
end
|
||||
-- Read key
|
||||
if str:sub(i, i) ~= '"' then
|
||||
decode_error(str, i, "expected string for key")
|
||||
end
|
||||
key, i = parse(str, i)
|
||||
-- Read ':' delimiter
|
||||
i = next_char(str, i, space_chars, true)
|
||||
if str:sub(i, i) ~= ":" then
|
||||
decode_error(str, i, "expected ':' after key")
|
||||
end
|
||||
i = next_char(str, i + 1, space_chars, true)
|
||||
-- Read value
|
||||
val, i = parse(str, i)
|
||||
-- Set
|
||||
res[key] = val
|
||||
-- Next token
|
||||
i = next_char(str, i, space_chars, true)
|
||||
local chr = str:sub(i, i)
|
||||
i = i + 1
|
||||
if chr == "}" then break end
|
||||
if chr ~= "," then decode_error(str, i, "expected '}' or ','") end
|
||||
end
|
||||
return res, i
|
||||
end
|
||||
|
||||
|
||||
local char_func_map = {
|
||||
[ '"' ] = parse_string,
|
||||
[ "0" ] = parse_number,
|
||||
[ "1" ] = parse_number,
|
||||
[ "2" ] = parse_number,
|
||||
[ "3" ] = parse_number,
|
||||
[ "4" ] = parse_number,
|
||||
[ "5" ] = parse_number,
|
||||
[ "6" ] = parse_number,
|
||||
[ "7" ] = parse_number,
|
||||
[ "8" ] = parse_number,
|
||||
[ "9" ] = parse_number,
|
||||
[ "-" ] = parse_number,
|
||||
[ "t" ] = parse_literal,
|
||||
[ "f" ] = parse_literal,
|
||||
[ "n" ] = parse_literal,
|
||||
[ "[" ] = parse_array,
|
||||
[ "{" ] = parse_object,
|
||||
}
|
||||
|
||||
|
||||
parse = function(str, idx)
|
||||
local chr = str:sub(idx, idx)
|
||||
local f = char_func_map[chr]
|
||||
if f then
|
||||
return f(str, idx)
|
||||
end
|
||||
decode_error(str, idx, "unexpected character '" .. chr .. "'")
|
||||
end
|
||||
|
||||
|
||||
function json.decode(str)
|
||||
if type(str) ~= "string" then
|
||||
error("expected argument of type string, got " .. type(str))
|
||||
end
|
||||
return ( parse(str, next_char(str, 1, space_chars, true)) )
|
||||
end
|
||||
|
||||
|
||||
return json
|
||||
238
data/lua/PKMN_RB/pkmn_rb.lua
Normal file
238
data/lua/PKMN_RB/pkmn_rb.lua
Normal file
@@ -0,0 +1,238 @@
|
||||
local socket = require("socket")
|
||||
local json = require('json')
|
||||
local math = require('math')
|
||||
|
||||
local STATE_OK = "Ok"
|
||||
local STATE_TENTATIVELY_CONNECTED = "Tentatively Connected"
|
||||
local STATE_INITIAL_CONNECTION_MADE = "Initial Connection Made"
|
||||
local STATE_UNINITIALIZED = "Uninitialized"
|
||||
|
||||
local APIndex = 0x1A6E
|
||||
local APItemAddress = 0x00FF
|
||||
local EventFlagAddress = 0x1735
|
||||
local MissableAddress = 0x161A
|
||||
local HiddenItemsAddress = 0x16DE
|
||||
local RodAddress = 0x1716
|
||||
local InGame = 0x1A71
|
||||
|
||||
local ItemsReceived = nil
|
||||
local playerName = nil
|
||||
local seedName = nil
|
||||
|
||||
local prevstate = ""
|
||||
local curstate = STATE_UNINITIALIZED
|
||||
local gbSocket = nil
|
||||
local frame = 0
|
||||
|
||||
local u8 = nil
|
||||
local wU8 = nil
|
||||
local u16
|
||||
|
||||
--Sets correct memory access functions based on whether NesHawk or QuickNES is loaded
|
||||
local function defineMemoryFunctions()
|
||||
local memDomain = {}
|
||||
local domains = memory.getmemorydomainlist()
|
||||
--if domains[1] == "System Bus" then
|
||||
-- --NesHawk
|
||||
-- isNesHawk = true
|
||||
-- memDomain["systembus"] = function() memory.usememorydomain("System Bus") end
|
||||
-- memDomain["saveram"] = function() memory.usememorydomain("Battery RAM") end
|
||||
-- memDomain["rom"] = function() memory.usememorydomain("PRG ROM") end
|
||||
--elseif domains[1] == "WRAM" then
|
||||
-- --QuickNES
|
||||
-- memDomain["systembus"] = function() memory.usememorydomain("System Bus") end
|
||||
-- memDomain["saveram"] = function() memory.usememorydomain("WRAM") end
|
||||
-- memDomain["rom"] = function() memory.usememorydomain("PRG ROM") end
|
||||
--end
|
||||
memDomain["rom"] = function() memory.usememorydomain("ROM") end
|
||||
memDomain["wram"] = function() memory.usememorydomain("WRAM") end
|
||||
return memDomain
|
||||
end
|
||||
|
||||
local memDomain = defineMemoryFunctions()
|
||||
u8 = memory.read_u8
|
||||
wU8 = memory.write_u8
|
||||
u16 = memory.read_u16_le
|
||||
function uRange(address, bytes)
|
||||
data = memory.readbyterange(address - 1, bytes + 1)
|
||||
data[0] = nil
|
||||
return data
|
||||
end
|
||||
|
||||
|
||||
function table.empty (self)
|
||||
for _, _ in pairs(self) do
|
||||
return false
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
function slice (tbl, s, e)
|
||||
local pos, new = 1, {}
|
||||
for i = s + 1, e do
|
||||
new[pos] = tbl[i]
|
||||
pos = pos + 1
|
||||
end
|
||||
return new
|
||||
end
|
||||
|
||||
function processBlock(block)
|
||||
if block == nil then
|
||||
return
|
||||
end
|
||||
local itemsBlock = block["items"]
|
||||
memDomain.wram()
|
||||
if itemsBlock ~= nil then-- and u8(0x116B) ~= 0x00 then
|
||||
-- print(itemsBlock)
|
||||
ItemsReceived = itemsBlock
|
||||
|
||||
end
|
||||
end
|
||||
|
||||
function difference(a, b)
|
||||
local aa = {}
|
||||
for k,v in pairs(a) do aa[v]=true end
|
||||
for k,v in pairs(b) do aa[v]=nil end
|
||||
local ret = {}
|
||||
local n = 0
|
||||
for k,v in pairs(a) do
|
||||
if aa[v] then n=n+1 ret[n]=v end
|
||||
end
|
||||
return ret
|
||||
end
|
||||
|
||||
function generateLocationsChecked()
|
||||
memDomain.wram()
|
||||
events = uRange(EventFlagAddress, 0x140)
|
||||
missables = uRange(MissableAddress, 0x20)
|
||||
hiddenitems = uRange(HiddenItemsAddress, 0x0E)
|
||||
rod = u8(RodAddress)
|
||||
|
||||
data = {}
|
||||
|
||||
table.foreach(events, function(k, v) table.insert(data, v) end)
|
||||
table.foreach(missables, function(k, v) table.insert(data, v) end)
|
||||
table.foreach(hiddenitems, function(k, v) table.insert(data, v) end)
|
||||
table.insert(data, rod)
|
||||
|
||||
return data
|
||||
end
|
||||
function generateSerialData()
|
||||
memDomain.wram()
|
||||
status = u8(0x1A73)
|
||||
if status == 0 then
|
||||
return nil
|
||||
end
|
||||
return uRange(0x1A76, u8(0x1A74))
|
||||
end
|
||||
local function arrayEqual(a1, a2)
|
||||
if #a1 ~= #a2 then
|
||||
return false
|
||||
end
|
||||
|
||||
for i, v in ipairs(a1) do
|
||||
if v ~= a2[i] then
|
||||
return false
|
||||
end
|
||||
end
|
||||
|
||||
return true
|
||||
end
|
||||
|
||||
function receive()
|
||||
l, e = gbSocket:receive()
|
||||
if e == 'closed' then
|
||||
if curstate == STATE_OK then
|
||||
print("Connection closed")
|
||||
end
|
||||
curstate = STATE_UNINITIALIZED
|
||||
return
|
||||
elseif e == 'timeout' then
|
||||
--print("timeout") -- this keeps happening for some reason? just hide it
|
||||
return
|
||||
elseif e ~= nil then
|
||||
print(e)
|
||||
curstate = STATE_UNINITIALIZED
|
||||
return
|
||||
end
|
||||
if l ~= nil then
|
||||
processBlock(json.decode(l))
|
||||
end
|
||||
-- Determine Message to send back
|
||||
memDomain.rom()
|
||||
newPlayerName = uRange(0xFFF0, 0x10)
|
||||
newSeedName = uRange(0xFFDC, 20)
|
||||
if (playerName ~= nil and not arrayEqual(playerName, newPlayerName)) or (seedName ~= nil and not arrayEqual(seedName, newSeedName)) then
|
||||
print("ROM changed, quitting")
|
||||
curstate = STATE_UNINITIALIZED
|
||||
return
|
||||
end
|
||||
playerName = newPlayerName
|
||||
seedName = newSeedName
|
||||
local retTable = {}
|
||||
retTable["playerName"] = playerName
|
||||
retTable["seedName"] = seedName
|
||||
memDomain.wram()
|
||||
if u8(InGame) == 0xAC then
|
||||
retTable["locations"] = generateLocationsChecked()
|
||||
serialData = generateSerialData()
|
||||
if serialData ~= nil then
|
||||
retTable["serial"] = serialData
|
||||
end
|
||||
end
|
||||
msg = json.encode(retTable).."\n"
|
||||
local ret, error = gbSocket:send(msg)
|
||||
if ret == nil then
|
||||
print(error)
|
||||
elseif curstate == STATE_INITIAL_CONNECTION_MADE then
|
||||
curstate = STATE_TENTATIVELY_CONNECTED
|
||||
elseif curstate == STATE_TENTATIVELY_CONNECTED then
|
||||
print("Connected!")
|
||||
curstate = STATE_OK
|
||||
end
|
||||
end
|
||||
|
||||
function main()
|
||||
if (is23Or24Or25 or is26To28) == false then
|
||||
print("Must use a version of bizhawk 2.3.1 or higher")
|
||||
return
|
||||
end
|
||||
server, error = socket.bind('localhost', 17242)
|
||||
|
||||
while true do
|
||||
if not (curstate == prevstate) then
|
||||
print("Current state: "..curstate)
|
||||
prevstate = curstate
|
||||
end
|
||||
if (curstate == STATE_OK) or (curstate == STATE_INITIAL_CONNECTION_MADE) or (curstate == STATE_TENTATIVELY_CONNECTED) then
|
||||
if (frame % 60 == 0) then
|
||||
receive()
|
||||
if u8(InGame) == 0xAC then
|
||||
ItemIndex = u16(APIndex)
|
||||
if ItemsReceived[ItemIndex + 1] ~= nil then
|
||||
wU8(APItemAddress, ItemsReceived[ItemIndex + 1] - 172000000)
|
||||
end
|
||||
end
|
||||
end
|
||||
elseif (curstate == STATE_UNINITIALIZED) then
|
||||
if (frame % 60 == 0) then
|
||||
|
||||
print("Waiting for client.")
|
||||
|
||||
emu.frameadvance()
|
||||
server:settimeout(2)
|
||||
print("Attempting to connect")
|
||||
local client, timeout = server:accept()
|
||||
if timeout == nil then
|
||||
-- print('Initial Connection Made')
|
||||
curstate = STATE_INITIAL_CONNECTION_MADE
|
||||
gbSocket = client
|
||||
gbSocket:settimeout(0)
|
||||
end
|
||||
end
|
||||
end
|
||||
emu.frameadvance()
|
||||
end
|
||||
end
|
||||
|
||||
main()
|
||||
132
data/lua/PKMN_RB/socket.lua
Normal file
132
data/lua/PKMN_RB/socket.lua
Normal file
@@ -0,0 +1,132 @@
|
||||
-----------------------------------------------------------------------------
|
||||
-- LuaSocket helper module
|
||||
-- Author: Diego Nehab
|
||||
-- RCS ID: $Id: socket.lua,v 1.22 2005/11/22 08:33:29 diego Exp $
|
||||
-----------------------------------------------------------------------------
|
||||
|
||||
-----------------------------------------------------------------------------
|
||||
-- Declare module and import dependencies
|
||||
-----------------------------------------------------------------------------
|
||||
local base = _G
|
||||
local string = require("string")
|
||||
local math = require("math")
|
||||
local socket = require("socket.core")
|
||||
module("socket")
|
||||
|
||||
-----------------------------------------------------------------------------
|
||||
-- Exported auxiliar functions
|
||||
-----------------------------------------------------------------------------
|
||||
function connect(address, port, laddress, lport)
|
||||
local sock, err = socket.tcp()
|
||||
if not sock then return nil, err end
|
||||
if laddress then
|
||||
local res, err = sock:bind(laddress, lport, -1)
|
||||
if not res then return nil, err end
|
||||
end
|
||||
local res, err = sock:connect(address, port)
|
||||
if not res then return nil, err end
|
||||
return sock
|
||||
end
|
||||
|
||||
function bind(host, port, backlog)
|
||||
local sock, err = socket.tcp()
|
||||
if not sock then return nil, err end
|
||||
sock:setoption("reuseaddr", true)
|
||||
local res, err = sock:bind(host, port)
|
||||
if not res then return nil, err end
|
||||
res, err = sock:listen(backlog)
|
||||
if not res then return nil, err end
|
||||
return sock
|
||||
end
|
||||
|
||||
try = newtry()
|
||||
|
||||
function choose(table)
|
||||
return function(name, opt1, opt2)
|
||||
if base.type(name) ~= "string" then
|
||||
name, opt1, opt2 = "default", name, opt1
|
||||
end
|
||||
local f = table[name or "nil"]
|
||||
if not f then base.error("unknown key (".. base.tostring(name) ..")", 3)
|
||||
else return f(opt1, opt2) end
|
||||
end
|
||||
end
|
||||
|
||||
-----------------------------------------------------------------------------
|
||||
-- Socket sources and sinks, conforming to LTN12
|
||||
-----------------------------------------------------------------------------
|
||||
-- create namespaces inside LuaSocket namespace
|
||||
sourcet = {}
|
||||
sinkt = {}
|
||||
|
||||
BLOCKSIZE = 2048
|
||||
|
||||
sinkt["close-when-done"] = function(sock)
|
||||
return base.setmetatable({
|
||||
getfd = function() return sock:getfd() end,
|
||||
dirty = function() return sock:dirty() end
|
||||
}, {
|
||||
__call = function(self, chunk, err)
|
||||
if not chunk then
|
||||
sock:close()
|
||||
return 1
|
||||
else return sock:send(chunk) end
|
||||
end
|
||||
})
|
||||
end
|
||||
|
||||
sinkt["keep-open"] = function(sock)
|
||||
return base.setmetatable({
|
||||
getfd = function() return sock:getfd() end,
|
||||
dirty = function() return sock:dirty() end
|
||||
}, {
|
||||
__call = function(self, chunk, err)
|
||||
if chunk then return sock:send(chunk)
|
||||
else return 1 end
|
||||
end
|
||||
})
|
||||
end
|
||||
|
||||
sinkt["default"] = sinkt["keep-open"]
|
||||
|
||||
sink = choose(sinkt)
|
||||
|
||||
sourcet["by-length"] = function(sock, length)
|
||||
return base.setmetatable({
|
||||
getfd = function() return sock:getfd() end,
|
||||
dirty = function() return sock:dirty() end
|
||||
}, {
|
||||
__call = function()
|
||||
if length <= 0 then return nil end
|
||||
local size = math.min(socket.BLOCKSIZE, length)
|
||||
local chunk, err = sock:receive(size)
|
||||
if err then return nil, err end
|
||||
length = length - string.len(chunk)
|
||||
return chunk
|
||||
end
|
||||
})
|
||||
end
|
||||
|
||||
sourcet["until-closed"] = function(sock)
|
||||
local done
|
||||
return base.setmetatable({
|
||||
getfd = function() return sock:getfd() end,
|
||||
dirty = function() return sock:dirty() end
|
||||
}, {
|
||||
__call = function()
|
||||
if done then return nil end
|
||||
local chunk, err, partial = sock:receive(socket.BLOCKSIZE)
|
||||
if not err then return chunk
|
||||
elseif err == "closed" then
|
||||
sock:close()
|
||||
done = 1
|
||||
return partial
|
||||
else return nil, err end
|
||||
end
|
||||
})
|
||||
end
|
||||
|
||||
|
||||
sourcet["default"] = sourcet["until-closed"]
|
||||
|
||||
source = choose(sourcet)
|
||||
@@ -221,7 +221,7 @@ Starting with version 4 of the APBP format, this is a ZIP file containing metada
|
||||
files required by the game / patching process. For ROM-based games the ZIP will include a `delta.bsdiff4` which is the
|
||||
bsdiff between the original and the randomized ROM.
|
||||
|
||||
To make using APBP easy, they can be generated by inheriting from `Patch.APDeltaPatch`.
|
||||
To make using APBP easy, they can be generated by inheriting from `worlds.Files.APDeltaPatch`.
|
||||
|
||||
### Mod files
|
||||
Games which support modding will usually just let you drag and drop the mod’s files into a folder somewhere.
|
||||
@@ -230,7 +230,7 @@ They can either be generic and modify the game using a seed or `slot_data` from
|
||||
generated per seed.
|
||||
|
||||
If the mod is generated by AP and is installed from a ZIP file, it may be possible to include APBP metadata for easy
|
||||
integration into the Webhost by inheriting from `Patch.APContainer`.
|
||||
integration into the Webhost by inheriting from `worlds.Files.APContainer`.
|
||||
|
||||
|
||||
## Archipelago Integration
|
||||
|
||||
@@ -21,7 +21,7 @@ There are also a number of community-supported libraries available that implemen
|
||||
| | [Archipelago SNIClient](https://github.com/ArchipelagoMW/Archipelago/blob/main/SNIClient.py) | For Super Nintendo Game Support; Utilizes [SNI](https://github.com/alttpo/sni). |
|
||||
| JVM (Java / Kotlin) | [Archipelago.MultiClient.Java](https://github.com/ArchipelagoMW/Archipelago.MultiClient.Java) | |
|
||||
| .NET (C# / C++ / F# / VB.NET) | [Archipelago.MultiClient.Net](https://www.nuget.org/packages/Archipelago.MultiClient.Net) | |
|
||||
| C++ | [apclientpp](https://github.com/black-sliver/apclientpp) | almost-header-only |
|
||||
| C++ | [apclientpp](https://github.com/black-sliver/apclientpp) | header-only |
|
||||
| | [APCpp](https://github.com/N00byKing/APCpp) | CMake |
|
||||
| JavaScript / TypeScript | [archipelago.js](https://www.npmjs.com/package/archipelago.js) | Browser and Node.js Supported |
|
||||
| Haxe | [hxArchipelago](https://lib.haxe.org/p/hxArchipelago) | |
|
||||
@@ -234,6 +234,8 @@ Sent to clients as a response the a [Get](#Get) package.
|
||||
| ---- | ---- | ----- |
|
||||
| keys | dict\[str\, any] | A key-value collection containing all the values for the keys requested in the [Get](#Get) package. |
|
||||
|
||||
If a requested key was not present in the server's data, the associated value will be `null`.
|
||||
|
||||
Additional arguments added to the [Get](#Get) package that triggered this [Retrieved](#Retrieved) will also be passed along.
|
||||
|
||||
### SetReply
|
||||
@@ -371,7 +373,7 @@ Used to write data to the server's data storage, that data can then be shared ac
|
||||
| ------ | ----- | ------ |
|
||||
| key | str | The key to manipulate. |
|
||||
| default | any | The default value to use in case the key has no value on the server. |
|
||||
| want_reply | bool | If set, the server will send a [SetReply](#SetReply) response back to the client. |
|
||||
| want_reply | bool | If true, the server will send a [SetReply](#SetReply) response back to the client. |
|
||||
| operations | list\[[DataStorageOperation](#DataStorageOperation)\] | Operations to apply to the value, multiple operations can be present and they will be executed in order of appearance. |
|
||||
|
||||
Additional arguments sent in this package will also be added to the [SetReply](#SetReply) package it triggers.
|
||||
|
||||
@@ -16,6 +16,14 @@ Then run any of the starting point scripts, like Generate.py, and the included M
|
||||
required modules and after pressing enter proceed to install everything automatically.
|
||||
After this, you should be able to run the programs.
|
||||
|
||||
* With yaml(s) in the `Players` folder, `Generate.py` will generate the multiworld archive.
|
||||
* `MultiServer.py`, with the filename of the generated archive as a command line parameter, will host the multiworld locally.
|
||||
* `--log_network` is a command line parameter useful for debugging.
|
||||
* `WebHost.py` will host the website on your computer.
|
||||
* You can copy `docs/webhost configuration sample.yaml` to `config.yaml`
|
||||
to change WebHost options (like the web hosting port number).
|
||||
* As a side effect, `WebHost.py` creates the template yamls for all the games in `WebHostLib/static/generated`.
|
||||
|
||||
|
||||
## Windows
|
||||
|
||||
|
||||
26
host.yaml
26
host.yaml
@@ -138,3 +138,29 @@ dkc3_options:
|
||||
# True for operating system default program
|
||||
# Alternatively, a path to a program to open the .sfc file with
|
||||
rom_start: true
|
||||
pokemon_rb_options:
|
||||
# File names of the Pokemon Red and Blue roms
|
||||
red_rom_file: "Pokemon Red (UE) [S][!].gb"
|
||||
blue_rom_file: "Pokemon Blue (UE) [S][!].gb"
|
||||
# Set this to false to never autostart a rom (such as after patching)
|
||||
# True for operating system default program
|
||||
# Alternatively, a path to a program to open the .gb file with
|
||||
rom_start: true
|
||||
smw_options:
|
||||
# File name of the SMW US rom
|
||||
rom_file: "Super Mario World (USA).sfc"
|
||||
# Set this to your SNI folder location if you want the MultiClient to attempt an auto start, does nothing if not found
|
||||
sni: "SNI"
|
||||
# Set this to false to never autostart a rom (such as after patching)
|
||||
# True for operating system default program
|
||||
# Alternatively, a path to a program to open the .sfc file with
|
||||
rom_start: true
|
||||
zillion_options:
|
||||
# File name of the Zillion US rom
|
||||
rom_file: "Zillion (UE) [!].sms"
|
||||
# Set this to false to never autostart a rom (such as after patching)
|
||||
# True for operating system default program
|
||||
# Alternatively, a path to a program to open the .sfc file with
|
||||
# RetroArch doesn't make it easy to launch a game from the command line.
|
||||
# You have to know the path to the emulator core library on the user's computer.
|
||||
rom_start: "retroarch"
|
||||
|
||||
204
inno_setup.iss
204
inno_setup.iss
@@ -55,21 +55,30 @@ Name: "core"; Description: "Core Files"; Types: full hosting playing
|
||||
Name: "generator"; Description: "Generator"; Types: full hosting
|
||||
Name: "generator/sm"; Description: "Super Metroid ROM Setup"; Types: full hosting; ExtraDiskSpaceRequired: 3145728; Flags: disablenouninstallwarning
|
||||
Name: "generator/dkc3"; Description: "Donkey Kong Country 3 ROM Setup"; Types: full hosting; ExtraDiskSpaceRequired: 3145728; Flags: disablenouninstallwarning
|
||||
Name: "generator/smw"; Description: "Super Mario World ROM Setup"; Types: full hosting; ExtraDiskSpaceRequired: 3145728; Flags: disablenouninstallwarning
|
||||
Name: "generator/soe"; Description: "Secret of Evermore ROM Setup"; Types: full hosting; ExtraDiskSpaceRequired: 3145728; Flags: disablenouninstallwarning
|
||||
Name: "generator/lttp"; Description: "A Link to the Past ROM Setup and Enemizer"; Types: full hosting; ExtraDiskSpaceRequired: 5191680
|
||||
Name: "generator/oot"; Description: "Ocarina of Time ROM Setup"; Types: full hosting; ExtraDiskSpaceRequired: 100663296; Flags: disablenouninstallwarning
|
||||
Name: "generator/zl"; Description: "Zillion ROM Setup"; Types: full hosting; ExtraDiskSpaceRequired: 150000; Flags: disablenouninstallwarning
|
||||
Name: "generator/pkmn_r"; Description: "Pokemon Red ROM Setup"; Types: full hosting
|
||||
Name: "generator/pkmn_b"; Description: "Pokemon Blue ROM Setup"; Types: full hosting
|
||||
Name: "server"; Description: "Server"; Types: full hosting
|
||||
Name: "client"; Description: "Clients"; Types: full playing
|
||||
Name: "client/sni"; Description: "SNI Client"; Types: full playing
|
||||
Name: "client/sni/lttp"; Description: "SNI Client - A Link to the Past Patch Setup"; Types: full playing; Flags: disablenouninstallwarning
|
||||
Name: "client/sni/sm"; Description: "SNI Client - Super Metroid Patch Setup"; Types: full playing; Flags: disablenouninstallwarning
|
||||
Name: "client/sni/dkc3"; Description: "SNI Client - Donkey Kong Country 3 Patch Setup"; Types: full playing; Flags: disablenouninstallwarning
|
||||
Name: "client/sni/smw"; Description: "SNI Client - Super Mario World Patch Setup"; Types: full playing; Flags: disablenouninstallwarning
|
||||
Name: "client/factorio"; Description: "Factorio"; Types: full playing
|
||||
Name: "client/minecraft"; Description: "Minecraft"; Types: full playing; ExtraDiskSpaceRequired: 226894278
|
||||
Name: "client/oot"; Description: "Ocarina of Time"; Types: full playing
|
||||
Name: "client/ff1"; Description: "Final Fantasy 1"; Types: full playing
|
||||
Name: "client/pkmn"; Description: "Pokemon Client"
|
||||
Name: "client/pkmn/red"; Description: "Pokemon Client - Pokemon Red Setup"; Types: full playing; ExtraDiskSpaceRequired: 1048576
|
||||
Name: "client/pkmn/blue"; Description: "Pokemon Client - Pokemon Blue Setup"; Types: full playing; ExtraDiskSpaceRequired: 1048576
|
||||
Name: "client/cf"; Description: "ChecksFinder"; Types: full playing
|
||||
Name: "client/sc2"; Description: "Starcraft 2"; Types: full playing
|
||||
Name: "client/zl"; Description: "Zillion"; Types: full playing
|
||||
Name: "client/text"; Description: "Text, to !command and chat"; Types: full playing
|
||||
|
||||
[Dirs]
|
||||
@@ -79,8 +88,12 @@ NAME: "{app}"; Flags: setntfscompression; Permissions: everyone-modify users-mod
|
||||
Source: "{code:GetROMPath}"; DestDir: "{app}"; DestName: "Zelda no Densetsu - Kamigami no Triforce (Japan).sfc"; Flags: external; Components: client/sni/lttp or generator/lttp
|
||||
Source: "{code:GetSMROMPath}"; DestDir: "{app}"; DestName: "Super Metroid (JU).sfc"; Flags: external; Components: client/sni/sm or generator/sm
|
||||
Source: "{code:GetDKC3ROMPath}"; DestDir: "{app}"; DestName: "Donkey Kong Country 3 - Dixie Kong's Double Trouble! (USA) (En,Fr).sfc"; Flags: external; Components: client/sni/dkc3 or generator/dkc3
|
||||
Source: "{code:GetSMWROMPath}"; DestDir: "{app}"; DestName: "Super Mario World (USA).sfc"; Flags: external; Components: client/sni/smw or generator/smw
|
||||
Source: "{code:GetSoEROMPath}"; DestDir: "{app}"; DestName: "Secret of Evermore (USA).sfc"; Flags: external; Components: generator/soe
|
||||
Source: "{code:GetOoTROMPath}"; DestDir: "{app}"; DestName: "The Legend of Zelda - Ocarina of Time.z64"; Flags: external; Components: client/oot or generator/oot
|
||||
Source: "{code:GetZlROMPath}"; DestDir: "{app}"; DestName: "Zillion (UE) [!].sms"; Flags: external; Components: client/zl or generator/zl
|
||||
Source: "{code:GetRedROMPath}"; DestDir: "{app}"; DestName: "Pokemon Red (UE) [S][!].gb"; Flags: external; Components: client/pkmn/red or generator/pkmn_r
|
||||
Source: "{code:GetBlueROMPath}"; DestDir: "{app}"; DestName: "Pokemon Blue (UE) [S][!].gb"; Flags: external; Components: client/pkmn/blue or generator/pkmn_b
|
||||
Source: "{#source_path}\*"; Excludes: "*.sfc, *.log, data\sprites\alttpr, SNI, EnemizerCLI, Archipelago*.exe"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs
|
||||
Source: "{#source_path}\SNI\*"; Excludes: "*.sfc, *.log"; DestDir: "{app}\SNI"; Flags: ignoreversion recursesubdirs createallsubdirs; Components: client/sni
|
||||
Source: "{#source_path}\EnemizerCLI\*"; Excludes: "*.sfc, *.log"; DestDir: "{app}\EnemizerCLI"; Flags: ignoreversion recursesubdirs createallsubdirs; Components: generator/lttp
|
||||
@@ -94,7 +107,9 @@ Source: "{#source_path}\ArchipelagoLttPAdjuster.exe"; DestDir: "{app}"; Flags: i
|
||||
Source: "{#source_path}\ArchipelagoMinecraftClient.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/minecraft
|
||||
Source: "{#source_path}\ArchipelagoOoTClient.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/oot
|
||||
Source: "{#source_path}\ArchipelagoOoTAdjuster.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/oot
|
||||
Source: "{#source_path}\ArchipelagoZillionClient.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/zl
|
||||
Source: "{#source_path}\ArchipelagoFF1Client.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/ff1
|
||||
Source: "{#source_path}\ArchipelagoPokemonClient.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/pkmn
|
||||
Source: "{#source_path}\ArchipelagoChecksFinderClient.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/cf
|
||||
Source: "{#source_path}\ArchipelagoStarcraft2Client.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/sc2
|
||||
Source: "vc_redist.x64.exe"; DestDir: {tmp}; Flags: deleteafterinstall
|
||||
@@ -107,7 +122,9 @@ Name: "{group}\{#MyAppName} SNI Client"; Filename: "{app}\ArchipelagoSNIClient.e
|
||||
Name: "{group}\{#MyAppName} Factorio Client"; Filename: "{app}\ArchipelagoFactorioClient.exe"; Components: client/factorio
|
||||
Name: "{group}\{#MyAppName} Minecraft Client"; Filename: "{app}\ArchipelagoMinecraftClient.exe"; Components: client/minecraft
|
||||
Name: "{group}\{#MyAppName} Ocarina of Time Client"; Filename: "{app}\ArchipelagoOoTClient.exe"; Components: client/oot
|
||||
Name: "{group}\{#MyAppName} Zillion Client"; Filename: "{app}\ArchipelagoZillionClient.exe"; Components: client/zl
|
||||
Name: "{group}\{#MyAppName} Final Fantasy 1 Client"; Filename: "{app}\ArchipelagoFF1Client.exe"; Components: client/ff1
|
||||
Name: "{group}\{#MyAppName} Pokemon Client"; Filename: "{app}\ArchipelagoPokemonClient.exe"; Components: client/pkmn
|
||||
Name: "{group}\{#MyAppName} ChecksFinder Client"; Filename: "{app}\ArchipelagoChecksFinderClient.exe"; Components: client/cf
|
||||
Name: "{group}\{#MyAppName} Starcraft 2 Client"; Filename: "{app}\ArchipelagoStarcraft2Client.exe"; Components: client/sc2
|
||||
|
||||
@@ -117,7 +134,9 @@ Name: "{commondesktop}\{#MyAppName} SNI Client"; Filename: "{app}\ArchipelagoSNI
|
||||
Name: "{commondesktop}\{#MyAppName} Factorio Client"; Filename: "{app}\ArchipelagoFactorioClient.exe"; Tasks: desktopicon; Components: client/factorio
|
||||
Name: "{commondesktop}\{#MyAppName} Minecraft Client"; Filename: "{app}\ArchipelagoMinecraftClient.exe"; Tasks: desktopicon; Components: client/minecraft
|
||||
Name: "{commondesktop}\{#MyAppName} Ocarina of Time Client"; Filename: "{app}\ArchipelagoOoTClient.exe"; Tasks: desktopicon; Components: client/oot
|
||||
Name: "{commondesktop}\{#MyAppName} Zillion Client"; Filename: "{app}\ArchipelagoZillionClient.exe"; Tasks: desktopicon; Components: client/zl
|
||||
Name: "{commondesktop}\{#MyAppName} Final Fantasy 1 Client"; Filename: "{app}\ArchipelagoFF1Client.exe"; Tasks: desktopicon; Components: client/ff1
|
||||
Name: "{commondesktop}\{#MyAppName} Pokemon Client"; Filename: "{app}\ArchipelagoPokemonClient.exe"; Tasks: desktopicon; Components: client/pkmn
|
||||
Name: "{commondesktop}\{#MyAppName} ChecksFinder Client"; Filename: "{app}\ArchipelagoChecksFinderClient.exe"; Tasks: desktopicon; Components: client/cf
|
||||
Name: "{commondesktop}\{#MyAppName} Starcraft 2 Client"; Filename: "{app}\ArchipelagoStarcraft2Client.exe"; Tasks: desktopicon; Components: client/sc2
|
||||
|
||||
@@ -151,6 +170,16 @@ Root: HKCR; Subkey: "{#MyAppName}dkc3patch"; ValueData: "Arc
|
||||
Root: HKCR; Subkey: "{#MyAppName}dkc3patch\DefaultIcon"; ValueData: "{app}\ArchipelagoSNIClient.exe,0"; ValueType: string; ValueName: ""; Components: client/sni
|
||||
Root: HKCR; Subkey: "{#MyAppName}dkc3patch\shell\open\command"; ValueData: """{app}\ArchipelagoSNIClient.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/sni
|
||||
|
||||
Root: HKCR; Subkey: ".apsmw"; ValueData: "{#MyAppName}smwpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: client/sni
|
||||
Root: HKCR; Subkey: "{#MyAppName}smwpatch"; ValueData: "Archipelago Super Mario World Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: client/sni
|
||||
Root: HKCR; Subkey: "{#MyAppName}smwpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoSNIClient.exe,0"; ValueType: string; ValueName: ""; Components: client/sni
|
||||
Root: HKCR; Subkey: "{#MyAppName}smwpatch\shell\open\command"; ValueData: """{app}\ArchipelagoSNIClient.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/sni
|
||||
|
||||
Root: HKCR; Subkey: ".apzl"; ValueData: "{#MyAppName}zlpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: client/zl
|
||||
Root: HKCR; Subkey: "{#MyAppName}zlpatch"; ValueData: "Archipelago Zillion Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: client/zl
|
||||
Root: HKCR; Subkey: "{#MyAppName}zlpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoZillionClient.exe,0"; ValueType: string; ValueName: ""; Components: client/zl
|
||||
Root: HKCR; Subkey: "{#MyAppName}zlpatch\shell\open\command"; ValueData: """{app}\ArchipelagoZillionClient.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/zl
|
||||
|
||||
Root: HKCR; Subkey: ".apsmz3"; ValueData: "{#MyAppName}smz3patch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: client/sni
|
||||
Root: HKCR; Subkey: "{#MyAppName}smz3patch"; ValueData: "Archipelago SMZ3 Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: client/sni
|
||||
Root: HKCR; Subkey: "{#MyAppName}smz3patch\DefaultIcon"; ValueData: "{app}\ArchipelagoSNIClient.exe,0"; ValueType: string; ValueName: ""; Components: client/sni
|
||||
@@ -171,6 +200,16 @@ Root: HKCR; Subkey: "{#MyAppName}n64zpf"; ValueData: "Archip
|
||||
Root: HKCR; Subkey: "{#MyAppName}n64zpf\DefaultIcon"; ValueData: "{app}\ArchipelagoOoTClient.exe,0"; ValueType: string; ValueName: ""; Components: client/oot
|
||||
Root: HKCR; Subkey: "{#MyAppName}n64zpf\shell\open\command"; ValueData: """{app}\ArchipelagoOoTClient.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/oot
|
||||
|
||||
Root: HKCR; Subkey: ".apred"; ValueData: "{#MyAppName}pkmnrpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: client/pkmn/red
|
||||
Root: HKCR; Subkey: "{#MyAppName}pkmnrpatch"; ValueData: "Archipelago Pokemon Red Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: client/pkmn/red
|
||||
Root: HKCR; Subkey: "{#MyAppName}pkmnrpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoPokemonClient.exe,0"; ValueType: string; ValueName: ""; Components: client/pkmn/red
|
||||
Root: HKCR; Subkey: "{#MyAppName}pkmnrpatch\shell\open\command"; ValueData: """{app}\ArchipelagoPokemonClient.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/pkmn/red
|
||||
|
||||
Root: HKCR; Subkey: ".apblue"; ValueData: "{#MyAppName}pkmnbpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: client/pkmn/blue
|
||||
Root: HKCR; Subkey: "{#MyAppName}pkmnbpatch"; ValueData: "Archipelago Pokemon Blue Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: client/pkmn/blue
|
||||
Root: HKCR; Subkey: "{#MyAppName}pkmnbpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoPokemonClient.exe,0"; ValueType: string; ValueName: ""; Components: client/pkmn/blue
|
||||
Root: HKCR; Subkey: "{#MyAppName}pkmnbpatch\shell\open\command"; ValueData: """{app}\ArchipelagoPokemonClient.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/pkmn/blue
|
||||
|
||||
Root: HKCR; Subkey: ".archipelago"; ValueData: "{#MyAppName}multidata"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: server
|
||||
Root: HKCR; Subkey: "{#MyAppName}multidata"; ValueData: "Archipelago Server Data"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: server
|
||||
Root: HKCR; Subkey: "{#MyAppName}multidata\DefaultIcon"; ValueData: "{app}\ArchipelagoServer.exe,0"; ValueType: string; ValueName: ""; Components: server
|
||||
@@ -217,12 +256,24 @@ var SMRomFilePage: TInputFileWizardPage;
|
||||
var dkc3rom: string;
|
||||
var DKC3RomFilePage: TInputFileWizardPage;
|
||||
|
||||
var smwrom: string;
|
||||
var SMWRomFilePage: TInputFileWizardPage;
|
||||
|
||||
var soerom: string;
|
||||
var SoERomFilePage: TInputFileWizardPage;
|
||||
|
||||
var ootrom: string;
|
||||
var OoTROMFilePage: TInputFileWizardPage;
|
||||
|
||||
var zlrom: string;
|
||||
var ZlROMFilePage: TInputFileWizardPage;
|
||||
|
||||
var redrom: string;
|
||||
var RedROMFilePage: TInputFileWizardPage;
|
||||
|
||||
var bluerom: string;
|
||||
var BlueROMFilePage: TInputFileWizardPage;
|
||||
|
||||
function GetSNESMD5OfFile(const rom: string): string;
|
||||
var data: AnsiString;
|
||||
begin
|
||||
@@ -236,6 +287,15 @@ begin
|
||||
end;
|
||||
end;
|
||||
|
||||
function GetSMSMD5OfFile(const rom: string): string;
|
||||
var data: AnsiString;
|
||||
begin
|
||||
if LoadStringFromFile(rom, data) then
|
||||
begin
|
||||
Result := GetMD5OfString(data);
|
||||
end;
|
||||
end;
|
||||
|
||||
function CheckRom(name: string; hash: string): string;
|
||||
var rom: string;
|
||||
begin
|
||||
@@ -255,6 +315,25 @@ begin
|
||||
end;
|
||||
end;
|
||||
|
||||
function CheckSMSRom(name: string; hash: string): string;
|
||||
var rom: string;
|
||||
begin
|
||||
log('Handling ' + name)
|
||||
rom := FileSearch(name, WizardDirValue());
|
||||
if Length(rom) > 0 then
|
||||
begin
|
||||
log('existing ROM found');
|
||||
log(IntToStr(CompareStr(GetSMSMD5OfFile(rom), hash)));
|
||||
if CompareStr(GetSMSMD5OfFile(rom), hash) = 0 then
|
||||
begin
|
||||
log('existing ROM verified');
|
||||
Result := rom;
|
||||
exit;
|
||||
end;
|
||||
log('existing ROM failed verification');
|
||||
end;
|
||||
end;
|
||||
|
||||
function AddRomPage(name: string): TInputFileWizardPage;
|
||||
begin
|
||||
Result :=
|
||||
@@ -270,6 +349,37 @@ begin
|
||||
'.sfc');
|
||||
end;
|
||||
|
||||
|
||||
function AddGBRomPage(name: string): TInputFileWizardPage;
|
||||
begin
|
||||
Result :=
|
||||
CreateInputFilePage(
|
||||
wpSelectComponents,
|
||||
'Select ROM File',
|
||||
'Where is your ' + name + ' located?',
|
||||
'Select the file, then click Next.');
|
||||
|
||||
Result.Add(
|
||||
'Location of ROM file:',
|
||||
'GB ROM files|*.gb;*.gbc|All files|*.*',
|
||||
'.gb');
|
||||
end;
|
||||
|
||||
function AddSMSRomPage(name: string): TInputFileWizardPage;
|
||||
begin
|
||||
Result :=
|
||||
CreateInputFilePage(
|
||||
wpSelectComponents,
|
||||
'Select ROM File',
|
||||
'Where is your ' + name + ' located?',
|
||||
'Select the file, then click Next.');
|
||||
|
||||
Result.Add(
|
||||
'Location of ROM file:',
|
||||
'SMS ROM files|*.sms|All files|*.*',
|
||||
'.sms');
|
||||
end;
|
||||
|
||||
procedure AddOoTRomPage();
|
||||
begin
|
||||
ootrom := FileSearch('The Legend of Zelda - Ocarina of Time.z64', WizardDirValue());
|
||||
@@ -308,10 +418,14 @@ begin
|
||||
Result := not (SMROMFilePage.Values[0] = '')
|
||||
else if (assigned(DKC3ROMFilePage)) and (CurPageID = DKC3ROMFilePage.ID) then
|
||||
Result := not (DKC3ROMFilePage.Values[0] = '')
|
||||
else if (assigned(SMWROMFilePage)) and (CurPageID = SMWROMFilePage.ID) then
|
||||
Result := not (SMWROMFilePage.Values[0] = '')
|
||||
else if (assigned(SoEROMFilePage)) and (CurPageID = SoEROMFilePage.ID) then
|
||||
Result := not (SoEROMFilePage.Values[0] = '')
|
||||
else if (assigned(OoTROMFilePage)) and (CurPageID = OoTROMFilePage.ID) then
|
||||
Result := not (OoTROMFilePage.Values[0] = '')
|
||||
else if (assigned(ZlROMFilePage)) and (CurPageID = ZlROMFilePage.ID) then
|
||||
Result := not (ZlROMFilePage.Values[0] = '')
|
||||
else
|
||||
Result := True;
|
||||
end;
|
||||
@@ -364,6 +478,22 @@ begin
|
||||
Result := '';
|
||||
end;
|
||||
|
||||
function GetSMWROMPath(Param: string): string;
|
||||
begin
|
||||
if Length(smwrom) > 0 then
|
||||
Result := smwrom
|
||||
else if Assigned(SMWRomFilePage) then
|
||||
begin
|
||||
R := CompareStr(GetSNESMD5OfFile(SMWROMFilePage.Values[0]), 'cdd3c8c37322978ca8669b34bc89c804')
|
||||
if R <> 0 then
|
||||
MsgBox('Super Mario World ROM validation failed. Very likely wrong file.', mbInformation, MB_OK);
|
||||
|
||||
Result := SMWROMFilePage.Values[0]
|
||||
end
|
||||
else
|
||||
Result := '';
|
||||
end;
|
||||
|
||||
function GetSoEROMPath(Param: string): string;
|
||||
begin
|
||||
if Length(soerom) > 0 then
|
||||
@@ -396,6 +526,54 @@ begin
|
||||
Result := '';
|
||||
end;
|
||||
|
||||
function GetZlROMPath(Param: string): string;
|
||||
begin
|
||||
if Length(zlrom) > 0 then
|
||||
Result := zlrom
|
||||
else if Assigned(ZlROMFilePage) then
|
||||
begin
|
||||
R := CompareStr(GetMD5OfFile(ZlROMFilePage.Values[0]), 'd4bf9e7bcf9a48da53785d2ae7bc4270');
|
||||
if R <> 0 then
|
||||
MsgBox('Zillion ROM validation failed. Very likely wrong file.', mbInformation, MB_OK);
|
||||
|
||||
Result := ZlROMFilePage.Values[0]
|
||||
end
|
||||
else
|
||||
Result := '';
|
||||
end;
|
||||
|
||||
function GetRedROMPath(Param: string): string;
|
||||
begin
|
||||
if Length(redrom) > 0 then
|
||||
Result := redrom
|
||||
else if Assigned(RedRomFilePage) then
|
||||
begin
|
||||
R := CompareStr(GetMD5OfFile(RedROMFilePage.Values[0]), '3d45c1ee9abd5738df46d2bdda8b57dc')
|
||||
if R <> 0 then
|
||||
MsgBox('Pokemon Red ROM validation failed. Very likely wrong file.', mbInformation, MB_OK);
|
||||
|
||||
Result := RedROMFilePage.Values[0]
|
||||
end
|
||||
else
|
||||
Result := '';
|
||||
end;
|
||||
|
||||
function GetBlueROMPath(Param: string): string;
|
||||
begin
|
||||
if Length(bluerom) > 0 then
|
||||
Result := bluerom
|
||||
else if Assigned(BlueRomFilePage) then
|
||||
begin
|
||||
R := CompareStr(GetMD5OfFile(BlueROMFilePage.Values[0]), '50927e843568814f7ed45ec4f944bd8b')
|
||||
if R <> 0 then
|
||||
MsgBox('Pokemon Blue ROM validation failed. Very likely wrong file.', mbInformation, MB_OK);
|
||||
|
||||
Result := BlueROMFilePage.Values[0]
|
||||
end
|
||||
else
|
||||
Result := '';
|
||||
end;
|
||||
|
||||
procedure InitializeWizard();
|
||||
begin
|
||||
AddOoTRomPage();
|
||||
@@ -412,9 +590,25 @@ begin
|
||||
if Length(dkc3rom) = 0 then
|
||||
DKC3RomFilePage:= AddRomPage('Donkey Kong Country 3 - Dixie Kong''s Double Trouble! (USA) (En,Fr).sfc');
|
||||
|
||||
smwrom := CheckRom('Super Mario World (USA).sfc', 'cdd3c8c37322978ca8669b34bc89c804');
|
||||
if Length(smwrom) = 0 then
|
||||
SMWRomFilePage:= AddRomPage('Super Mario World (USA).sfc');
|
||||
|
||||
soerom := CheckRom('Secret of Evermore (USA).sfc', '6e9c94511d04fac6e0a1e582c170be3a');
|
||||
if Length(soerom) = 0 then
|
||||
SoEROMFilePage:= AddRomPage('Secret of Evermore (USA).sfc');
|
||||
|
||||
zlrom := CheckSMSRom('Zillion (UE) [!].sms', 'd4bf9e7bcf9a48da53785d2ae7bc4270');
|
||||
if Length(zlrom) = 0 then
|
||||
ZlROMFilePage:= AddSMSRomPage('Zillion (UE) [!].sms');
|
||||
|
||||
redrom := CheckRom('Pokemon Red (UE) [S][!].gb','3d45c1ee9abd5738df46d2bdda8b57dc');
|
||||
if Length(redrom) = 0 then
|
||||
RedROMFilePage:= AddGBRomPage('Pokemon Red (UE) [S][!].gb');
|
||||
|
||||
bluerom := CheckRom('Pokemon Blue (UE) [S][!].gb','50927e843568814f7ed45ec4f944bd8b');
|
||||
if Length(bluerom) = 0 then
|
||||
BlueROMFilePage:= AddGBRomPage('Pokemon Blue (UE) [S][!].gb');
|
||||
end;
|
||||
|
||||
|
||||
@@ -427,8 +621,16 @@ begin
|
||||
Result := not (WizardIsComponentSelected('client/sni/sm') or WizardIsComponentSelected('generator/sm'));
|
||||
if (assigned(DKC3ROMFilePage)) and (PageID = DKC3ROMFilePage.ID) then
|
||||
Result := not (WizardIsComponentSelected('client/sni/dkc3') or WizardIsComponentSelected('generator/dkc3'));
|
||||
if (assigned(SMWROMFilePage)) and (PageID = SMWROMFilePage.ID) then
|
||||
Result := not (WizardIsComponentSelected('client/sni/smw') or WizardIsComponentSelected('generator/smw'));
|
||||
if (assigned(SoEROMFilePage)) and (PageID = SoEROMFilePage.ID) then
|
||||
Result := not (WizardIsComponentSelected('generator/soe'));
|
||||
if (assigned(OoTROMFilePage)) and (PageID = OoTROMFilePage.ID) then
|
||||
Result := not (WizardIsComponentSelected('generator/oot') or WizardIsComponentSelected('client/oot'));
|
||||
end;
|
||||
if (assigned(ZlROMFilePage)) and (PageID = ZlROMFilePage.ID) then
|
||||
Result := not (WizardIsComponentSelected('generator/zl') or WizardIsComponentSelected('client/zl'));
|
||||
if (assigned(RedROMFilePage)) and (PageID = RedROMFilePage.ID) then
|
||||
Result := not (WizardIsComponentSelected('generator/pkmn_r') or WizardIsComponentSelected('client/pkmn/red'));
|
||||
if (assigned(BlueROMFilePage)) and (PageID = BlueROMFilePage.ID) then
|
||||
Result := not (WizardIsComponentSelected('generator/pkmn_b') or WizardIsComponentSelected('client/pkmn/blue'));
|
||||
end;
|
||||
|
||||
1
setup.py
1
setup.py
@@ -289,6 +289,7 @@ tmp="${{exe#*/}}"
|
||||
if [ ! "${{#tmp}}" -lt "${{#exe}}" ]; then
|
||||
exe="{default_exe.parent}/$exe"
|
||||
fi
|
||||
export LD_LIBRARY_PATH="$LD_LIBRARY_PATH:$APPDIR/{default_exe.parent}/lib"
|
||||
$APPDIR/$exe "$@"
|
||||
""")
|
||||
launcher_filename.chmod(0o755)
|
||||
|
||||
@@ -4,7 +4,7 @@ from worlds.AutoWorld import World
|
||||
from Fill import FillError, balance_multiworld_progression, fill_restrictive, distribute_items_restrictive
|
||||
from BaseClasses import Entrance, LocationProgressType, MultiWorld, Region, RegionType, Item, Location, \
|
||||
ItemClassification
|
||||
from worlds.generic.Rules import CollectionRule, locality_rules, set_rule
|
||||
from worlds.generic.Rules import CollectionRule, add_item_rule, locality_rules, set_rule
|
||||
|
||||
|
||||
def generate_multi_world(players: int = 1) -> MultiWorld:
|
||||
@@ -359,6 +359,46 @@ class TestFillRestrictive(unittest.TestCase):
|
||||
fill_restrictive(multi_world, multi_world.state,
|
||||
locations, player1.prog_items)
|
||||
|
||||
def test_swap_to_earlier_location_with_item_rule(self):
|
||||
# test for PR#1109
|
||||
multi_world = generate_multi_world(1)
|
||||
player1 = generate_player_data(multi_world, 1, 4, 4)
|
||||
locations = player1.locations[:] # copy required
|
||||
items = player1.prog_items[:] # copy required
|
||||
# for the test to work, item and location order is relevant: Sphere 1 last, allowed_item not last
|
||||
for location in locations[:-1]: # Sphere 2
|
||||
# any one provides access to Sphere 2
|
||||
set_rule(location, lambda state: any(state.has(item.name, player1.id) for item in items))
|
||||
# forbid all but 1 item in Sphere 1
|
||||
sphere1_loc = locations[-1]
|
||||
allowed_item = items[1]
|
||||
add_item_rule(sphere1_loc, lambda item_to_place: item_to_place == allowed_item)
|
||||
# test our rules
|
||||
self.assertTrue(location.can_fill(None, allowed_item, False), "Test is flawed")
|
||||
self.assertTrue(location.can_fill(None, items[2], False), "Test is flawed")
|
||||
self.assertTrue(sphere1_loc.can_fill(None, allowed_item, False), "Test is flawed")
|
||||
self.assertFalse(sphere1_loc.can_fill(None, items[2], False), "Test is flawed")
|
||||
# fill has to place items[1] in locations[0] which will result in a swap because of placement order
|
||||
fill_restrictive(multi_world, multi_world.state, player1.locations, player1.prog_items)
|
||||
# assert swap happened
|
||||
self.assertTrue(sphere1_loc.item, "Did not swap required item into Sphere 1")
|
||||
self.assertEqual(sphere1_loc.item, allowed_item, "Wrong item in Sphere 1")
|
||||
|
||||
def test_double_sweep(self):
|
||||
# test for PR1114
|
||||
multi_world = generate_multi_world(1)
|
||||
player1 = generate_player_data(multi_world, 1, 1, 1)
|
||||
location = player1.locations[0]
|
||||
location.address = None
|
||||
location.event = True
|
||||
item = player1.prog_items[0]
|
||||
item.code = None
|
||||
location.place_locked_item(item)
|
||||
multi_world.state.sweep_for_events()
|
||||
multi_world.state.sweep_for_events()
|
||||
self.assertTrue(multi_world.state.prog_items[item.name, item.player], "Sweep did not collect - Test flawed")
|
||||
self.assertEqual(multi_world.state.prog_items[item.name, item.player], 1, "Sweep collected multiple times")
|
||||
|
||||
|
||||
class TestDistributeItemsRestrictive(unittest.TestCase):
|
||||
def test_basic_distribute(self):
|
||||
@@ -575,8 +615,7 @@ class TestDistributeItemsRestrictive(unittest.TestCase):
|
||||
|
||||
multi_world.local_items[player1.id].value = set(names(player1.basic_items))
|
||||
multi_world.local_items[player2.id].value = set(names(player2.basic_items))
|
||||
locality_rules(multi_world, player1.id)
|
||||
locality_rules(multi_world, player2.id)
|
||||
locality_rules(multi_world)
|
||||
|
||||
distribute_items_restrictive(multi_world)
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ class TestImplemented(unittest.TestCase):
|
||||
def testCompletionCondition(self):
|
||||
"""Ensure a completion condition is set that has requirements."""
|
||||
for gamename, world_type in AutoWorldRegister.world_types.items():
|
||||
if not world_type.hidden and gamename not in {"ArchipIDLE", "Final Fantasy"}:
|
||||
if not world_type.hidden and gamename not in {"ArchipIDLE", "Final Fantasy", "Sudoku"}:
|
||||
with self.subTest(gamename):
|
||||
world = setup_default_world(world_type)
|
||||
self.assertFalse(world.completion_condition[1](world.state))
|
||||
|
||||
@@ -20,7 +20,7 @@ class TestBase(unittest.TestCase):
|
||||
for location in world.get_locations():
|
||||
if location.name not in excluded:
|
||||
with self.subTest("Location should be reached", location=location):
|
||||
self.assertTrue(location.can_reach(state))
|
||||
self.assertTrue(location.can_reach(state), f"{location.name} unreachable")
|
||||
|
||||
with self.subTest("Completion Condition"):
|
||||
self.assertTrue(world.can_beat_game(state))
|
||||
@@ -28,7 +28,7 @@ class TestBase(unittest.TestCase):
|
||||
def testEmptyStateCanReachSomething(self):
|
||||
for game_name, world_type in AutoWorldRegister.world_types.items():
|
||||
# Final Fantasy logic is controlled by finalfantasyrandomizer.com
|
||||
if game_name != "Archipelago" and game_name != "Final Fantasy":
|
||||
if game_name not in {"Archipelago", "Final Fantasy", "Sudoku"}:
|
||||
with self.subTest("Game", game=game_name):
|
||||
world = setup_default_world(world_type)
|
||||
state = CollectionState(world)
|
||||
|
||||
@@ -23,10 +23,8 @@ class TestMinor(TestBase):
|
||||
self.world.set_default_common_options()
|
||||
self.world.logic[1] = "minorglitches"
|
||||
self.world.difficulty_requirements[1] = difficulties['normal']
|
||||
create_regions(self.world, 1)
|
||||
create_dungeons(self.world, 1)
|
||||
create_shops(self.world, 1)
|
||||
link_entrances(self.world, 1)
|
||||
self.world.worlds[1].er_seed = 0
|
||||
self.world.worlds[1].create_regions()
|
||||
self.world.worlds[1].create_items()
|
||||
self.world.required_medallions[1] = ['Ether', 'Quake']
|
||||
self.world.itempool.extend(get_dungeon_item_pool(self.world))
|
||||
|
||||
136
test/options/TestPlandoBosses.py
Normal file
136
test/options/TestPlandoBosses.py
Normal file
@@ -0,0 +1,136 @@
|
||||
import unittest
|
||||
import Generate
|
||||
from Options import PlandoBosses
|
||||
|
||||
|
||||
class SingleBosses(PlandoBosses):
|
||||
bosses = {"B1", "B2"}
|
||||
locations = {"L1", "L2"}
|
||||
|
||||
option_vanilla = 0
|
||||
option_shuffle = 1
|
||||
|
||||
@staticmethod
|
||||
def can_place_boss(boss: str, location: str) -> bool:
|
||||
if boss == "b1" and location == "l1":
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
class MultiBosses(SingleBosses):
|
||||
bosses = SingleBosses.bosses # explicit copy required
|
||||
locations = SingleBosses.locations
|
||||
duplicate_bosses = True
|
||||
|
||||
option_singularity = 2 # required when duplicate_bosses = True
|
||||
|
||||
|
||||
class TestPlandoBosses(unittest.TestCase):
|
||||
def testCI(self):
|
||||
"""Bosses, locations and modes are supposed to be case-insensitive"""
|
||||
self.assertEqual(MultiBosses.from_any("L1-B2").value, "l1-b2;vanilla")
|
||||
self.assertEqual(MultiBosses.from_any("ShUfFlE").value, SingleBosses.option_shuffle)
|
||||
|
||||
def testRandom(self):
|
||||
"""Validate random is random"""
|
||||
import random
|
||||
random.seed(0)
|
||||
value1 = MultiBosses.from_any("random")
|
||||
random.seed(0)
|
||||
value2 = MultiBosses.from_text("random")
|
||||
self.assertEqual(value1, value2)
|
||||
for n in range(0, 100):
|
||||
if MultiBosses.from_text("random") != value1:
|
||||
break
|
||||
else:
|
||||
raise Exception("random is not random")
|
||||
|
||||
def testShuffleMode(self):
|
||||
"""Test that simple modes (no Plando) work"""
|
||||
self.assertEqual(MultiBosses.from_any("shuffle"), MultiBosses.option_shuffle)
|
||||
self.assertNotEqual(MultiBosses.from_any("vanilla"), MultiBosses.option_shuffle)
|
||||
|
||||
def testPlacement(self):
|
||||
"""Test that valid placements work and invalid placements fail"""
|
||||
with self.assertRaises(ValueError):
|
||||
MultiBosses.from_any("l1-b1")
|
||||
MultiBosses.from_any("l1-b2;l2-b1")
|
||||
|
||||
def testMixed(self):
|
||||
"""Test that shuffle is applied for remaining locations"""
|
||||
self.assertIn("shuffle", MultiBosses.from_any("l1-b2;l2-b1;shuffle").value)
|
||||
self.assertIn("vanilla", MultiBosses.from_any("l1-b2;l2-b1").value)
|
||||
|
||||
def testUnknown(self):
|
||||
"""Test that unknown values throw exceptions"""
|
||||
# unknown boss
|
||||
with self.assertRaises(ValueError):
|
||||
MultiBosses.from_any("l1-b0")
|
||||
# unknown location
|
||||
with self.assertRaises(ValueError):
|
||||
MultiBosses.from_any("l0-b1")
|
||||
# swapped boss-location
|
||||
with self.assertRaises(ValueError):
|
||||
MultiBosses.from_any("b2-b2")
|
||||
# boss name in place of mode (no singularity)
|
||||
with self.assertRaises(ValueError):
|
||||
SingleBosses.from_any("b1")
|
||||
with self.assertRaises(ValueError):
|
||||
SingleBosses.from_any("l2-b2;b1")
|
||||
# location name in place of mode
|
||||
with self.assertRaises(ValueError):
|
||||
MultiBosses.from_any("l1")
|
||||
with self.assertRaises(ValueError):
|
||||
MultiBosses.from_any("l2-b2;l1")
|
||||
# mode name in place of location
|
||||
with self.assertRaises(ValueError):
|
||||
MultiBosses.from_any("shuffle-b2;vanilla")
|
||||
with self.assertRaises(ValueError):
|
||||
MultiBosses.from_any("shuffle-b2;l2-b2")
|
||||
# mode name in place of boss
|
||||
with self.assertRaises(ValueError):
|
||||
MultiBosses.from_any("l2-shuffle;vanilla")
|
||||
with self.assertRaises(ValueError):
|
||||
MultiBosses.from_any("l1-shuffle;l2-b2")
|
||||
|
||||
def testOrder(self):
|
||||
"""Can't use mode in random places"""
|
||||
with self.assertRaises(ValueError):
|
||||
MultiBosses.from_any("shuffle;l2-b2")
|
||||
|
||||
def testDuplicateBoss(self):
|
||||
"""Can place the same boss twice"""
|
||||
MultiBosses.from_any("l1-b2;l2-b2")
|
||||
with self.assertRaises(ValueError):
|
||||
SingleBosses.from_any("l1-b2;l2-b2")
|
||||
|
||||
def testDuplicateLocation(self):
|
||||
"""Can't use the same location twice"""
|
||||
with self.assertRaises(ValueError):
|
||||
MultiBosses.from_any("l1-b2;l1-b2")
|
||||
|
||||
def testSingularity(self):
|
||||
"""Test automatic singularity mode"""
|
||||
self.assertIn(";singularity", MultiBosses.from_any("b2").value)
|
||||
|
||||
def testPlandoSettings(self):
|
||||
"""Test that plando settings verification works"""
|
||||
plandoed_string = "l1-b2;l2-b1"
|
||||
mixed_string = "l1-b2;shuffle"
|
||||
regular_string = "shuffle"
|
||||
plandoed = MultiBosses.from_any(plandoed_string)
|
||||
mixed = MultiBosses.from_any(mixed_string)
|
||||
regular = MultiBosses.from_any(regular_string)
|
||||
|
||||
# plando should work with boss plando
|
||||
plandoed.verify(None, "Player", Generate.PlandoSettings.bosses)
|
||||
self.assertTrue(plandoed.value.startswith(plandoed_string))
|
||||
# plando should fall back to default without boss plando
|
||||
plandoed.verify(None, "Player", Generate.PlandoSettings.items)
|
||||
self.assertEqual(plandoed, MultiBosses.option_vanilla)
|
||||
# mixed should fall back to mode
|
||||
mixed.verify(None, "Player", Generate.PlandoSettings.items) # should produce a warning and still work
|
||||
self.assertEqual(mixed, MultiBosses.option_shuffle)
|
||||
# mode stuff should just work
|
||||
regular.verify(None, "Player", Generate.PlandoSettings.items)
|
||||
self.assertEqual(regular, MultiBosses.option_shuffle)
|
||||
0
test/options/__init__.py
Normal file
0
test/options/__init__.py
Normal file
142
test/overcooked2/TestOvercooked2.py
Normal file
142
test/overcooked2/TestOvercooked2.py
Normal file
@@ -0,0 +1,142 @@
|
||||
import unittest
|
||||
import json
|
||||
|
||||
from random import Random
|
||||
|
||||
from worlds.overcooked2.Items import *
|
||||
from worlds.overcooked2.Overcooked2Levels import Overcooked2Level, level_id_to_shortname
|
||||
from worlds.overcooked2.Logic import level_logic, level_shuffle_factory
|
||||
from worlds.overcooked2.Locations import oc2_location_name_to_id
|
||||
|
||||
|
||||
class Overcooked2Test(unittest.TestCase):
|
||||
def testItems(self):
|
||||
self.assertEqual(len(item_name_to_id), len(item_id_to_name))
|
||||
self.assertEqual(len(item_name_to_id), len(item_table))
|
||||
|
||||
previous_item = None
|
||||
for item_name in item_table.keys():
|
||||
item: Item = item_table[item_name]
|
||||
self.assertGreaterEqual(item.code, oc2_base_id, "Overcooked Item ID out of range")
|
||||
self.assertLessEqual(item.code, item_table["Calmer Unbread"].code, "Overcooked Item ID out of range")
|
||||
|
||||
if previous_item is not None:
|
||||
self.assertEqual(item.code, previous_item + 1,
|
||||
f"Overcooked Item ID noncontinguous: {item.code-oc2_base_id}")
|
||||
previous_item = item.code
|
||||
|
||||
self.assertEqual(item_table["Ok Emote"].code - item_table["Cooking Emote"].code,
|
||||
5, "Overcooked Emotes noncontigious")
|
||||
|
||||
for item_name in item_frequencies:
|
||||
self.assertIn(item_name, item_table.keys(), "Unexpected Overcooked Item in item_frequencies")
|
||||
|
||||
for item_name in item_name_to_config_name.keys():
|
||||
self.assertIn(item_name, item_table.keys(), "Unexpected Overcooked Item in config mapping")
|
||||
|
||||
for config_name in item_name_to_config_name.values():
|
||||
self.assertIn(config_name, vanilla_values.keys(), "Unexpected Overcooked Item in default config mapping")
|
||||
|
||||
for config_name in vanilla_values.keys():
|
||||
self.assertIn(config_name, item_name_to_config_name.values(),
|
||||
"Unexpected Overcooked Item in default config mapping")
|
||||
|
||||
events = [
|
||||
("Kevin-2", {"action": "UNLOCK_LEVEL", "payload": "38"}),
|
||||
("Curse Emote", {"action": "UNLOCK_EMOTE", "payload": "1"}),
|
||||
("Larger Tip Jar", {"action": "INC_TIP_COMBO", "payload": ""}),
|
||||
("Order Lookahead", {"action": "INC_ORDERS_ON_SCREEN", "payload": ""}),
|
||||
("Control Stick Batteries", {"action": "SET_VALUE", "payload": "DisableControlStick=False"}),
|
||||
]
|
||||
for (item_name, expected_event) in events:
|
||||
expected_event["message"] = f"{item_name} Acquired!"
|
||||
event = item_to_unlock_event(item_name)
|
||||
self.assertEqual(event, expected_event)
|
||||
|
||||
self.assertFalse(is_progression("Preparing Emote"))
|
||||
|
||||
for item_name in item_table:
|
||||
item_to_unlock_event(item_name)
|
||||
|
||||
def testOvercooked2Levels(self):
|
||||
level_count = 0
|
||||
for _ in Overcooked2Level():
|
||||
level_count += 1
|
||||
self.assertEqual(level_count, 44)
|
||||
|
||||
def testOvercooked2ShuffleFactory(self):
|
||||
previous_runs = set()
|
||||
for seed in range(0, 5):
|
||||
levels = level_shuffle_factory(Random(seed), True, False)
|
||||
self.assertEqual(len(levels), 44)
|
||||
previous_level_id = None
|
||||
for level_id in levels.keys():
|
||||
if previous_level_id is not None:
|
||||
self.assertEqual(previous_level_id+1, level_id)
|
||||
previous_level_id = level_id
|
||||
|
||||
self.assertNotIn(levels[15], previous_runs)
|
||||
previous_runs.add(levels[15])
|
||||
|
||||
levels = level_shuffle_factory(Random(123), False, True)
|
||||
self.assertEqual(len(levels), 44)
|
||||
|
||||
def testLevelNameRepresentation(self):
|
||||
shortnames = [level.as_generic_level.shortname for level in Overcooked2Level()]
|
||||
|
||||
for shortname in shortnames:
|
||||
self.assertIn(shortname, level_logic.keys())
|
||||
|
||||
self.assertEqual(len(level_logic), len(level_id_to_shortname))
|
||||
|
||||
for level_name in level_logic.keys():
|
||||
if level_name != "*":
|
||||
self.assertIn(level_name, level_id_to_shortname.values())
|
||||
|
||||
for level_name in level_id_to_shortname.values():
|
||||
if level_name != "Tutorial":
|
||||
self.assertIn(level_name, level_logic.keys())
|
||||
|
||||
region_names = [level.level_name for level in Overcooked2Level()]
|
||||
for location_name in oc2_location_name_to_id.keys():
|
||||
level_name = location_name.split(" ")[0]
|
||||
self.assertIn(level_name, region_names)
|
||||
|
||||
def testLogic(self):
|
||||
for level_name in level_logic.keys():
|
||||
logic = level_logic[level_name]
|
||||
self.assertEqual(len(logic), 3, "Levels must provide logic for 1, 2, and 3 stars")
|
||||
|
||||
for l in logic:
|
||||
self.assertEqual(len(l), 2)
|
||||
(exclusive, additive) = l
|
||||
|
||||
for req in exclusive:
|
||||
self.assertEqual(type(req), str)
|
||||
self.assertIn(req, item_table.keys())
|
||||
|
||||
if len(additive) != 0:
|
||||
self.assertGreater(len(additive), 1)
|
||||
total_weight = 0.0
|
||||
for req in additive:
|
||||
self.assertEqual(len(req), 2)
|
||||
(item_name, weight) = req
|
||||
self.assertEqual(type(item_name), str)
|
||||
self.assertEqual(type(weight), float)
|
||||
total_weight += weight
|
||||
self.assertIn(item_name, item_table.keys())
|
||||
|
||||
self.assertGreaterEqual(total_weight, 0.99, "Additive requirements must add to 1.0 or greater to have any effect")
|
||||
|
||||
def testItemLocationMapping(self):
|
||||
number_of_items = 0
|
||||
for item_name in item_frequencies:
|
||||
freq = item_frequencies[item_name]
|
||||
self.assertGreaterEqual(freq, 0)
|
||||
number_of_items += freq
|
||||
|
||||
for item_name in item_table:
|
||||
if item_name not in item_frequencies.keys():
|
||||
number_of_items += 1
|
||||
|
||||
self.assertLessEqual(number_of_items, len(oc2_location_name_to_id), "Too many items (before fillers placed)")
|
||||
0
test/overcooked2/__init__.py
Normal file
0
test/overcooked2/__init__.py
Normal file
@@ -24,10 +24,8 @@ class TestVanillaOWG(TestBase):
|
||||
self.world.set_default_common_options()
|
||||
self.world.difficulty_requirements[1] = difficulties['normal']
|
||||
self.world.logic[1] = "owglitches"
|
||||
create_regions(self.world, 1)
|
||||
create_dungeons(self.world, 1)
|
||||
create_shops(self.world, 1)
|
||||
link_entrances(self.world, 1)
|
||||
self.world.worlds[1].er_seed = 0
|
||||
self.world.worlds[1].create_regions()
|
||||
self.world.worlds[1].create_items()
|
||||
self.world.required_medallions[1] = ['Ether', 'Quake']
|
||||
self.world.itempool.extend(get_dungeon_item_pool(self.world))
|
||||
|
||||
@@ -22,10 +22,8 @@ class TestVanilla(TestBase):
|
||||
self.world.set_default_common_options()
|
||||
self.world.logic[1] = "noglitches"
|
||||
self.world.difficulty_requirements[1] = difficulties['normal']
|
||||
create_regions(self.world, 1)
|
||||
create_dungeons(self.world, 1)
|
||||
create_shops(self.world, 1)
|
||||
link_entrances(self.world, 1)
|
||||
self.world.worlds[1].er_seed = 0
|
||||
self.world.worlds[1].create_regions()
|
||||
self.world.worlds[1].create_items()
|
||||
self.world.required_medallions[1] = ['Ether', 'Quake']
|
||||
self.world.itempool.extend(get_dungeon_item_pool(self.world))
|
||||
|
||||
0
test/worlds/__init__.py
Normal file
0
test/worlds/__init__.py
Normal file
22
test/worlds/soe/TestAccess.py
Normal file
22
test/worlds/soe/TestAccess.py
Normal file
@@ -0,0 +1,22 @@
|
||||
import typing
|
||||
from . import SoETestBase
|
||||
|
||||
|
||||
class AccessTest(SoETestBase):
|
||||
@staticmethod
|
||||
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):
|
||||
gourds = {
|
||||
"Pyramid bottom": (118, 121, 122, 123, 124, 125),
|
||||
"Pyramid top": (140,)
|
||||
}
|
||||
locations = ["Rimsala"] + self._resolveGourds(gourds)
|
||||
items = [["Bronze Axe"]]
|
||||
self.assertAccessDependency(locations, items)
|
||||
|
||||
def testBronzeSpearPlus(self):
|
||||
locations = ["Megataur"]
|
||||
items = [["Bronze Spear"], ["Lance (Weapon)"], ["Laser Lance"]]
|
||||
self.assertAccessDependency(locations, items)
|
||||
53
test/worlds/soe/TestGoal.py
Normal file
53
test/worlds/soe/TestGoal.py
Normal file
@@ -0,0 +1,53 @@
|
||||
from . import SoETestBase
|
||||
|
||||
|
||||
class TestFragmentGoal(SoETestBase):
|
||||
options = {
|
||||
"energy_core": "fragments",
|
||||
"available_fragments": 21,
|
||||
"required_fragments": 20,
|
||||
}
|
||||
|
||||
def testFragments(self):
|
||||
self.collect_by_name(["Gladiator Sword", "Diamond Eye", "Wheel", "Gauge"])
|
||||
self.assertBeatable(False) # 0 fragments
|
||||
fragments = self.get_items_by_name("Energy Core Fragment")
|
||||
victory = self.get_item_by_name("Victory")
|
||||
self.collect(fragments[:-2]) # 1 too few
|
||||
self.assertEqual(self.count("Energy Core Fragment"), 19)
|
||||
self.assertBeatable(False)
|
||||
self.collect(fragments[-2:-1]) # exact
|
||||
self.assertEqual(self.count("Energy Core Fragment"), 20)
|
||||
self.assertBeatable(True)
|
||||
self.remove([victory]) # reset
|
||||
self.collect(fragments[-1:]) # 1 extra
|
||||
self.assertEqual(self.count("Energy Core Fragment"), 21)
|
||||
self.assertBeatable(True)
|
||||
|
||||
def testNoWeapon(self):
|
||||
self.collect_by_name(["Diamond Eye", "Wheel", "Gauge", "Energy Core Fragment"])
|
||||
self.assertBeatable(False)
|
||||
|
||||
def testNoRocket(self):
|
||||
self.collect_by_name(["Gladiator Sword", "Diamond Eye", "Wheel", "Energy Core Fragment"])
|
||||
self.assertBeatable(False)
|
||||
|
||||
|
||||
class TestShuffleGoal(SoETestBase):
|
||||
options = {
|
||||
"energy_core": "shuffle",
|
||||
}
|
||||
|
||||
def testCore(self):
|
||||
self.collect_by_name(["Gladiator Sword", "Diamond Eye", "Wheel", "Gauge"])
|
||||
self.assertBeatable(False)
|
||||
self.collect_by_name(["Energy Core"])
|
||||
self.assertBeatable(True)
|
||||
|
||||
def testNoWeapon(self):
|
||||
self.collect_by_name(["Diamond Eye", "Wheel", "Gauge", "Energy Core"])
|
||||
self.assertBeatable(False)
|
||||
|
||||
def testNoRocket(self):
|
||||
self.collect_by_name(["Gladiator Sword", "Diamond Eye", "Wheel", "Energy Core"])
|
||||
self.assertBeatable(False)
|
||||
5
test/worlds/soe/__init__.py
Normal file
5
test/worlds/soe/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from test.worlds.test_base import WorldTestBase
|
||||
|
||||
|
||||
class SoETestBase(WorldTestBase):
|
||||
game = "Secret of Evermore"
|
||||
98
test/worlds/test_base.py
Normal file
98
test/worlds/test_base.py
Normal file
@@ -0,0 +1,98 @@
|
||||
import typing
|
||||
import unittest
|
||||
from argparse import Namespace
|
||||
from test.general import gen_steps
|
||||
from BaseClasses import MultiWorld, Item
|
||||
from worlds import AutoWorld
|
||||
from worlds.AutoWorld import call_all
|
||||
|
||||
|
||||
class WorldTestBase(unittest.TestCase):
|
||||
options: typing.Dict[str, typing.Any] = {}
|
||||
world: MultiWorld
|
||||
|
||||
game: typing.ClassVar[str] # define game name in subclass, example "Secret of Evermore"
|
||||
auto_construct: typing.ClassVar[bool] = True
|
||||
""" automatically set up a world for each test in this class """
|
||||
|
||||
def setUp(self) -> None:
|
||||
if self.auto_construct:
|
||||
self.world_setup()
|
||||
|
||||
def world_setup(self) -> None:
|
||||
if not hasattr(self, "game"):
|
||||
raise NotImplementedError("didn't define game name")
|
||||
self.world = MultiWorld(1)
|
||||
self.world.game[1] = self.game
|
||||
self.world.player_name = {1: "Tester"}
|
||||
self.world.set_seed()
|
||||
args = Namespace()
|
||||
for name, option in AutoWorld.AutoWorldRegister.world_types[self.game].option_definitions.items():
|
||||
setattr(args, name, {
|
||||
1: option.from_any(self.options.get(name, getattr(option, "default")))
|
||||
})
|
||||
self.world.set_options(args)
|
||||
self.world.set_default_common_options()
|
||||
for step in gen_steps:
|
||||
call_all(self.world, step)
|
||||
|
||||
def collect_all_but(self, item_names: typing.Union[str, typing.Iterable[str]]) -> None:
|
||||
if isinstance(item_names, str):
|
||||
item_names = (item_names,)
|
||||
for item in self.world.get_items():
|
||||
if item.name not in item_names:
|
||||
self.world.state.collect(item)
|
||||
|
||||
def get_item_by_name(self, item_name: str) -> Item:
|
||||
for item in self.world.get_items():
|
||||
if item.name == item_name:
|
||||
return item
|
||||
raise ValueError("No such item")
|
||||
|
||||
def get_items_by_name(self, item_names: typing.Union[str, typing.Iterable[str]]) -> typing.List[Item]:
|
||||
if isinstance(item_names, str):
|
||||
item_names = (item_names,)
|
||||
return [item for item in self.world.itempool if item.name in item_names]
|
||||
|
||||
def collect_by_name(self, item_names: typing.Union[str, typing.Iterable[str]]) -> typing.List[Item]:
|
||||
""" collect all of the items in the item pool that have the given names """
|
||||
items = self.get_items_by_name(item_names)
|
||||
self.collect(items)
|
||||
return items
|
||||
|
||||
def collect(self, items: typing.Union[Item, typing.Iterable[Item]]) -> None:
|
||||
if isinstance(items, Item):
|
||||
items = (items,)
|
||||
for item in items:
|
||||
self.world.state.collect(item)
|
||||
|
||||
def remove(self, items: typing.Union[Item, typing.Iterable[Item]]) -> None:
|
||||
if isinstance(items, Item):
|
||||
items = (items,)
|
||||
for item in items:
|
||||
if item.location and item.location.event and item.location in self.world.state.events:
|
||||
self.world.state.events.remove(item.location)
|
||||
self.world.state.remove(item)
|
||||
|
||||
def can_reach_location(self, location: str) -> bool:
|
||||
return self.world.state.can_reach(location, "Location", 1)
|
||||
|
||||
def count(self, item_name: str) -> int:
|
||||
return self.world.state.count(item_name, 1)
|
||||
|
||||
def assertAccessDependency(self,
|
||||
locations: typing.List[str],
|
||||
possible_items: typing.Iterable[typing.Iterable[str]]) -> None:
|
||||
all_items = [item_name for item_names in possible_items for item_name in item_names]
|
||||
|
||||
self.collect_all_but(all_items)
|
||||
for location in self.world.get_locations():
|
||||
self.assertEqual(self.world.state.can_reach(location), location.name not in locations)
|
||||
for item_names in possible_items:
|
||||
items = self.collect_by_name(item_names)
|
||||
for location in locations:
|
||||
self.assertTrue(self.can_reach_location(location))
|
||||
self.remove(items)
|
||||
|
||||
def assertBeatable(self, beatable: bool):
|
||||
self.assertEqual(self.world.can_beat_game(self.world.state), beatable)
|
||||
144
test/worlds/zillion/TestGoal.py
Normal file
144
test/worlds/zillion/TestGoal.py
Normal file
@@ -0,0 +1,144 @@
|
||||
from . import ZillionTestBase
|
||||
|
||||
|
||||
class TestGoalVanilla(ZillionTestBase):
|
||||
options = {
|
||||
"start_char": "JJ",
|
||||
"jump_levels": "vanilla",
|
||||
"gun_levels": "vanilla",
|
||||
"floppy_disk_count": 7,
|
||||
"floppy_req": 6,
|
||||
}
|
||||
|
||||
def test_floppies(self):
|
||||
self.collect_by_name(["Apple", "Champ", "Red ID Card"])
|
||||
self.assertBeatable(False) # 0 floppies
|
||||
floppies = self.get_items_by_name("Floppy Disk")
|
||||
win = self.get_item_by_name("Win")
|
||||
self.collect(floppies[:-2]) # 1 too few
|
||||
self.assertEqual(self.count("Floppy Disk"), 5)
|
||||
self.assertBeatable(False)
|
||||
self.collect(floppies[-2:-1]) # exact
|
||||
self.assertEqual(self.count("Floppy Disk"), 6)
|
||||
self.assertBeatable(True)
|
||||
self.remove([win]) # reset
|
||||
self.collect(floppies[-1:]) # 1 extra
|
||||
self.assertEqual(self.count("Floppy Disk"), 7)
|
||||
self.assertBeatable(True)
|
||||
|
||||
def test_with_everything(self):
|
||||
self.collect_by_name(["Apple", "Champ", "Red ID Card", "Floppy Disk"])
|
||||
self.assertBeatable(True)
|
||||
|
||||
def test_no_jump(self):
|
||||
self.collect_by_name(["Champ", "Red ID Card", "Floppy Disk"])
|
||||
self.assertBeatable(False)
|
||||
|
||||
def test_no_gun(self):
|
||||
self.collect_by_name(["Apple", "Red ID Card", "Floppy Disk"])
|
||||
self.assertBeatable(False)
|
||||
|
||||
def test_no_red(self):
|
||||
self.collect_by_name(["Apple", "Champ", "Floppy Disk"])
|
||||
self.assertBeatable(False)
|
||||
|
||||
|
||||
class TestGoalBalanced(ZillionTestBase):
|
||||
options = {
|
||||
"start_char": "JJ",
|
||||
"jump_levels": "balanced",
|
||||
"gun_levels": "balanced",
|
||||
}
|
||||
|
||||
def test_jump(self):
|
||||
self.collect_by_name(["Red ID Card", "Floppy Disk", "Zillion"])
|
||||
self.assertBeatable(False) # not enough jump
|
||||
opas = self.get_items_by_name("Opa-Opa")
|
||||
self.collect(opas[:1]) # too few
|
||||
self.assertEqual(self.count("Opa-Opa"), 1)
|
||||
self.assertBeatable(False)
|
||||
self.collect(opas[1:])
|
||||
self.assertBeatable(True)
|
||||
|
||||
def test_guns(self):
|
||||
self.collect_by_name(["Red ID Card", "Floppy Disk", "Opa-Opa"])
|
||||
self.assertBeatable(False) # not enough gun
|
||||
guns = self.get_items_by_name("Zillion")
|
||||
self.collect(guns[:1]) # too few
|
||||
self.assertEqual(self.count("Zillion"), 1)
|
||||
self.assertBeatable(False)
|
||||
self.collect(guns[1:])
|
||||
self.assertBeatable(True)
|
||||
|
||||
|
||||
class TestGoalRestrictive(ZillionTestBase):
|
||||
options = {
|
||||
"start_char": "JJ",
|
||||
"jump_levels": "restrictive",
|
||||
"gun_levels": "restrictive",
|
||||
}
|
||||
|
||||
def test_jump(self):
|
||||
self.collect_by_name(["Champ", "Red ID Card", "Floppy Disk", "Zillion"])
|
||||
self.assertBeatable(False) # not enough jump
|
||||
self.collect_by_name("Opa-Opa")
|
||||
self.assertBeatable(False) # with all opas, jj champ can't jump
|
||||
self.collect_by_name("Apple")
|
||||
self.assertBeatable(True)
|
||||
|
||||
def test_guns(self):
|
||||
self.collect_by_name(["Apple", "Red ID Card", "Floppy Disk", "Opa-Opa"])
|
||||
self.assertBeatable(False) # not enough gun
|
||||
self.collect_by_name("Zillion")
|
||||
self.assertBeatable(False) # with all guns, jj apple can't gun
|
||||
self.collect_by_name("Champ")
|
||||
self.assertBeatable(True)
|
||||
|
||||
|
||||
class TestGoalAppleStart(ZillionTestBase):
|
||||
""" creation of character rescue items has some special interactions with logic """
|
||||
options = {
|
||||
"start_char": "Apple",
|
||||
"jump_levels": "balanced",
|
||||
"gun_levels": "low",
|
||||
"zillion_count": 5
|
||||
}
|
||||
|
||||
def test_guns_jj_first(self):
|
||||
""" with low gun levels, 5 Zillion is enough to get JJ to gun 3 """
|
||||
self.collect_by_name(["JJ", "Red ID Card", "Floppy Disk", "Opa-Opa"])
|
||||
self.assertBeatable(False) # not enough gun
|
||||
self.collect_by_name("Zillion")
|
||||
self.assertBeatable(True)
|
||||
|
||||
def test_guns_zillions_first(self):
|
||||
""" with low gun levels, 5 Zillion is enough to get JJ to gun 3 """
|
||||
self.collect_by_name(["Zillion", "Red ID Card", "Floppy Disk", "Opa-Opa"])
|
||||
self.assertBeatable(False) # not enough gun
|
||||
self.collect_by_name("JJ")
|
||||
self.assertBeatable(True)
|
||||
|
||||
|
||||
class TestGoalChampStart(ZillionTestBase):
|
||||
""" creation of character rescue items has some special interactions with logic """
|
||||
options = {
|
||||
"start_char": "Champ",
|
||||
"jump_levels": "low",
|
||||
"gun_levels": "balanced",
|
||||
"opa_opa_count": 5,
|
||||
"opas_per_level": 1
|
||||
}
|
||||
|
||||
def test_jump_jj_first(self):
|
||||
""" with low jump levels, 5 level-ups is enough to get JJ to jump 3 """
|
||||
self.collect_by_name(["JJ", "Red ID Card", "Floppy Disk", "Zillion"])
|
||||
self.assertBeatable(False) # not enough jump
|
||||
self.collect_by_name("Opa-Opa")
|
||||
self.assertBeatable(True)
|
||||
|
||||
def test_jump_opa_first(self):
|
||||
""" with low jump levels, 5 level-ups is enough to get JJ to jump 3 """
|
||||
self.collect_by_name(["Opa-Opa", "Red ID Card", "Floppy Disk", "Zillion"])
|
||||
self.assertBeatable(False) # not enough jump
|
||||
self.collect_by_name("JJ")
|
||||
self.assertBeatable(True)
|
||||
26
test/worlds/zillion/TestOptions.py
Normal file
26
test/worlds/zillion/TestOptions.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from test.worlds.zillion import ZillionTestBase
|
||||
|
||||
from worlds.zillion.options import ZillionJumpLevels, ZillionGunLevels, validate
|
||||
from zilliandomizer.options import VBLR_CHOICES
|
||||
|
||||
|
||||
class OptionsTest(ZillionTestBase):
|
||||
auto_construct = False
|
||||
|
||||
def test_validate_default(self) -> None:
|
||||
self.world_setup()
|
||||
validate(self.world, 1)
|
||||
|
||||
def test_vblr_ap_to_zz(self) -> None:
|
||||
""" all of the valid values for the AP options map to valid values for ZZ options """
|
||||
for option_name, vblr_class in (
|
||||
("jump_levels", ZillionJumpLevels),
|
||||
("gun_levels", ZillionGunLevels)
|
||||
):
|
||||
for value in vblr_class.name_lookup.values():
|
||||
self.options = {option_name: value}
|
||||
self.world_setup()
|
||||
zz_options, _item_counts = validate(self.world, 1)
|
||||
assert getattr(zz_options, option_name) in VBLR_CHOICES
|
||||
|
||||
# TODO: test validate with invalid combinations of options
|
||||
13
test/worlds/zillion/__init__.py
Normal file
13
test/worlds/zillion/__init__.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from test.worlds.test_base import WorldTestBase
|
||||
from worlds.zillion.region import ZillionLocation
|
||||
|
||||
|
||||
class ZillionTestBase(WorldTestBase):
|
||||
game = "Zillion"
|
||||
|
||||
def world_setup(self) -> None:
|
||||
super().world_setup()
|
||||
# make sure game requires gun 3 for tests
|
||||
for location in self.world.get_locations():
|
||||
if isinstance(location, ZillionLocation) and location.name.startswith("O-7"):
|
||||
location.zz_loc.req.gun = 3
|
||||
@@ -3,9 +3,9 @@ from __future__ import annotations
|
||||
import logging
|
||||
import sys
|
||||
import pathlib
|
||||
from typing import Dict, FrozenSet, Set, Tuple, List, Optional, TextIO, Any, Callable, Union, TYPE_CHECKING
|
||||
from typing import Dict, FrozenSet, Set, Tuple, List, Optional, TextIO, Any, Callable, Type, Union, TYPE_CHECKING
|
||||
|
||||
from Options import Option
|
||||
from Options import AssembleOptions
|
||||
from BaseClasses import CollectionState
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -13,7 +13,7 @@ if TYPE_CHECKING:
|
||||
|
||||
|
||||
class AutoWorldRegister(type):
|
||||
world_types: Dict[str, type(World)] = {}
|
||||
world_types: Dict[str, Type[World]] = {}
|
||||
|
||||
def __new__(mcs, name: str, bases: Tuple[type, ...], dct: Dict[str, Any]) -> AutoWorldRegister:
|
||||
if "web" in dct:
|
||||
@@ -79,8 +79,16 @@ def call_single(world: "MultiWorld", method_name: str, player: int, *args: Any)
|
||||
def call_all(world: "MultiWorld", method_name: str, *args: Any) -> None:
|
||||
world_types: Set[AutoWorldRegister] = set()
|
||||
for player in world.player_ids:
|
||||
prev_item_count = len(world.itempool)
|
||||
world_types.add(world.worlds[player].__class__)
|
||||
call_single(world, method_name, player, *args)
|
||||
if __debug__:
|
||||
new_items = world.itempool[prev_item_count:]
|
||||
for i, item in enumerate(new_items):
|
||||
for other in new_items[i+1:]:
|
||||
assert item is not other, (
|
||||
f"Duplicate item reference of \"{item.name}\" in \"{world.worlds[player].game}\" "
|
||||
f"of player \"{world.player_name[player]}\". Please make a copy instead.")
|
||||
|
||||
for world_type in world_types:
|
||||
stage_callable = getattr(world_type, f"stage_{method_name}", None)
|
||||
@@ -120,7 +128,7 @@ class World(metaclass=AutoWorldRegister):
|
||||
"""A World object encompasses a game's Items, Locations, Rules and additional data or functionality required.
|
||||
A Game should have its own subclass of World in which it defines the required data structures."""
|
||||
|
||||
option_definitions: Dict[str, Option[Any]] = {} # link your Options mapping
|
||||
option_definitions: Dict[str, AssembleOptions] = {} # link your Options mapping
|
||||
game: str # name the game
|
||||
topology_present: bool = False # indicate if world type has any meaningful layout/pathing
|
||||
|
||||
@@ -229,7 +237,8 @@ class World(metaclass=AutoWorldRegister):
|
||||
pass
|
||||
|
||||
def post_fill(self) -> None:
|
||||
"""Optional Method that is called after regular fill. Can be used to do adjustments before output generation."""
|
||||
"""Optional Method that is called after regular fill. Can be used to do adjustments before output generation.
|
||||
This happens before progression balancing, so the items may not be in their final locations yet."""
|
||||
|
||||
def generate_output(self, output_directory: str) -> None:
|
||||
"""This method gets called from a threadpool, do not use world.random here.
|
||||
@@ -237,7 +246,9 @@ class World(metaclass=AutoWorldRegister):
|
||||
pass
|
||||
|
||||
def fill_slot_data(self) -> Dict[str, Any]: # json of WebHostLib.models.Slot
|
||||
"""Fill in the slot_data field in the Connected network package."""
|
||||
"""Fill in the `slot_data` field in the `Connected` network package.
|
||||
This is a way the generator can give custom data to the client.
|
||||
The client will receive this as JSON in the `Connected` response."""
|
||||
return {}
|
||||
|
||||
def extend_hint_information(self, hint_data: Dict[int, Dict[int, str]]):
|
||||
|
||||
156
worlds/Files.py
Normal file
156
worlds/Files.py
Normal file
@@ -0,0 +1,156 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import zipfile
|
||||
|
||||
from typing import ClassVar, Dict, Tuple, Any, Optional, Union, BinaryIO
|
||||
|
||||
import bsdiff4
|
||||
|
||||
|
||||
class AutoPatchRegister(type):
|
||||
patch_types: ClassVar[Dict[str, AutoPatchRegister]] = {}
|
||||
file_endings: ClassVar[Dict[str, AutoPatchRegister]] = {}
|
||||
|
||||
def __new__(mcs, name: str, bases: Tuple[type, ...], dct: Dict[str, Any]) -> AutoPatchRegister:
|
||||
# construct class
|
||||
new_class = super().__new__(mcs, name, bases, dct)
|
||||
if "game" in dct:
|
||||
AutoPatchRegister.patch_types[dct["game"]] = new_class
|
||||
if not dct["patch_file_ending"]:
|
||||
raise Exception(f"Need an expected file ending for {name}")
|
||||
AutoPatchRegister.file_endings[dct["patch_file_ending"]] = new_class
|
||||
return new_class
|
||||
|
||||
@staticmethod
|
||||
def get_handler(file: str) -> Optional[AutoPatchRegister]:
|
||||
for file_ending, handler in AutoPatchRegister.file_endings.items():
|
||||
if file.endswith(file_ending):
|
||||
return handler
|
||||
return None
|
||||
|
||||
|
||||
current_patch_version: int = 5
|
||||
|
||||
|
||||
class APContainer:
|
||||
"""A zipfile containing at least archipelago.json"""
|
||||
version: int = current_patch_version
|
||||
compression_level: int = 9
|
||||
compression_method: int = zipfile.ZIP_DEFLATED
|
||||
game: Optional[str] = None
|
||||
|
||||
# instance attributes:
|
||||
path: Optional[str]
|
||||
player: Optional[int]
|
||||
player_name: str
|
||||
server: str
|
||||
|
||||
def __init__(self, path: Optional[str] = None, player: Optional[int] = None,
|
||||
player_name: str = "", server: str = ""):
|
||||
self.path = path
|
||||
self.player = player
|
||||
self.player_name = player_name
|
||||
self.server = server
|
||||
|
||||
def write(self, file: Optional[Union[str, BinaryIO]] = None) -> None:
|
||||
zip_file = file if file else self.path
|
||||
if not zip_file:
|
||||
raise FileNotFoundError(f"Cannot write {self.__class__.__name__} due to no path provided.")
|
||||
with zipfile.ZipFile(zip_file, "w", self.compression_method, True, self.compression_level) \
|
||||
as zf:
|
||||
if file:
|
||||
self.path = zf.filename
|
||||
self.write_contents(zf)
|
||||
|
||||
def write_contents(self, opened_zipfile: zipfile.ZipFile) -> None:
|
||||
manifest = self.get_manifest()
|
||||
try:
|
||||
manifest_str = json.dumps(manifest)
|
||||
except Exception as e:
|
||||
raise Exception(f"Manifest {manifest} did not convert to json.") from e
|
||||
else:
|
||||
opened_zipfile.writestr("archipelago.json", manifest_str)
|
||||
|
||||
def read(self, file: Optional[Union[str, BinaryIO]] = None) -> None:
|
||||
"""Read data into patch object. file can be file-like, such as an outer zip file's stream."""
|
||||
zip_file = file if file else self.path
|
||||
if not zip_file:
|
||||
raise FileNotFoundError(f"Cannot read {self.__class__.__name__} due to no path provided.")
|
||||
with zipfile.ZipFile(zip_file, "r") as zf:
|
||||
if file:
|
||||
self.path = zf.filename
|
||||
self.read_contents(zf)
|
||||
|
||||
def read_contents(self, opened_zipfile: zipfile.ZipFile) -> None:
|
||||
with opened_zipfile.open("archipelago.json", "r") as f:
|
||||
manifest = json.load(f)
|
||||
if manifest["compatible_version"] > self.version:
|
||||
raise Exception(f"File (version: {manifest['compatible_version']}) too new "
|
||||
f"for this handler (version: {self.version})")
|
||||
self.player = manifest["player"]
|
||||
self.server = manifest["server"]
|
||||
self.player_name = manifest["player_name"]
|
||||
|
||||
def get_manifest(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"server": self.server, # allow immediate connection to server in multiworld. Empty string otherwise
|
||||
"player": self.player,
|
||||
"player_name": self.player_name,
|
||||
"game": self.game,
|
||||
# minimum version of patch system expected for patching to be successful
|
||||
"compatible_version": 5,
|
||||
"version": current_patch_version,
|
||||
}
|
||||
|
||||
|
||||
class APDeltaPatch(APContainer, metaclass=AutoPatchRegister):
|
||||
"""An APContainer that additionally has delta.bsdiff4
|
||||
containing a delta patch to get the desired file, often a rom."""
|
||||
|
||||
hash: Optional[str] # base checksum of source file
|
||||
patch_file_ending: str = ""
|
||||
delta: Optional[bytes] = None
|
||||
result_file_ending: str = ".sfc"
|
||||
source_data: bytes
|
||||
|
||||
def __init__(self, *args: Any, patched_path: str = "", **kwargs: Any) -> None:
|
||||
self.patched_path = patched_path
|
||||
super(APDeltaPatch, self).__init__(*args, **kwargs)
|
||||
|
||||
def get_manifest(self) -> Dict[str, Any]:
|
||||
manifest = super(APDeltaPatch, self).get_manifest()
|
||||
manifest["base_checksum"] = self.hash
|
||||
manifest["result_file_ending"] = self.result_file_ending
|
||||
manifest["patch_file_ending"] = self.patch_file_ending
|
||||
return manifest
|
||||
|
||||
@classmethod
|
||||
def get_source_data(cls) -> bytes:
|
||||
"""Get Base data"""
|
||||
raise NotImplementedError()
|
||||
|
||||
@classmethod
|
||||
def get_source_data_with_cache(cls) -> bytes:
|
||||
if not hasattr(cls, "source_data"):
|
||||
cls.source_data = cls.get_source_data()
|
||||
return cls.source_data
|
||||
|
||||
def write_contents(self, opened_zipfile: zipfile.ZipFile):
|
||||
super(APDeltaPatch, self).write_contents(opened_zipfile)
|
||||
# write Delta
|
||||
opened_zipfile.writestr("delta.bsdiff4",
|
||||
bsdiff4.diff(self.get_source_data_with_cache(), open(self.patched_path, "rb").read()),
|
||||
compress_type=zipfile.ZIP_STORED) # bsdiff4 is a format with integrated compression
|
||||
|
||||
def read_contents(self, opened_zipfile: zipfile.ZipFile):
|
||||
super(APDeltaPatch, self).read_contents(opened_zipfile)
|
||||
self.delta = opened_zipfile.read("delta.bsdiff4")
|
||||
|
||||
def patch(self, target: str):
|
||||
"""Base + Delta -> Patched"""
|
||||
if not self.delta:
|
||||
self.read()
|
||||
result = bsdiff4.patch(self.get_source_data_with_cache(), self.delta)
|
||||
with open(target, "wb") as f:
|
||||
f.write(result)
|
||||
@@ -1,7 +1,9 @@
|
||||
import importlib
|
||||
import zipimport
|
||||
import os
|
||||
import sys
|
||||
import typing
|
||||
import warnings
|
||||
import zipimport
|
||||
|
||||
folder = os.path.dirname(__file__)
|
||||
|
||||
@@ -39,7 +41,14 @@ world_sources.sort()
|
||||
for world_source in world_sources:
|
||||
if world_source.is_zip:
|
||||
importer = zipimport.zipimporter(os.path.join(folder, world_source.path))
|
||||
importer.load_module(world_source.path.split(".", 1)[0])
|
||||
spec = importer.find_spec(world_source.path.split(".", 1)[0])
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
mod.__package__ = f"worlds.{mod.__package__}"
|
||||
mod.__name__ = f"worlds.{mod.__name__}"
|
||||
sys.modules[mod.__name__] = mod
|
||||
with warnings.catch_warnings():
|
||||
warnings.filterwarnings("ignore", message="__package__ != __spec__.parent")
|
||||
importer.exec_module(mod)
|
||||
else:
|
||||
importlib.import_module(f".{world_source.path}", "worlds")
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ from typing import Optional, Union, List, Tuple, Callable, Dict
|
||||
|
||||
from BaseClasses import Boss
|
||||
from Fill import FillError
|
||||
from .Options import Bosses
|
||||
from .Options import LTTPBosses as Bosses
|
||||
|
||||
|
||||
def BossFactory(boss: str, player: int) -> Optional[Boss]:
|
||||
|
||||
@@ -3834,14 +3834,21 @@ inverted_default_dungeon_connections = [('Desert Palace Entrance (South)', 'Dese
|
||||
|
||||
# Regions that can be required to access entrances through rules, not paths
|
||||
indirect_connections = {
|
||||
'Turtle Rock (Top)': 'Turtle Rock',
|
||||
'East Dark World': 'Pyramid Fairy',
|
||||
'Big Bomb Shop': 'Pyramid Fairy',
|
||||
'Dark Desert': 'Pyramid Fairy',
|
||||
'West Dark World': 'Pyramid Fairy',
|
||||
'South Dark World': 'Pyramid Fairy',
|
||||
'Light World': 'Pyramid Fairy',
|
||||
'Old Man Cave': 'Old Man S&Q'
|
||||
"Turtle Rock (Top)": "Turtle Rock",
|
||||
"East Dark World": "Pyramid Fairy",
|
||||
"Dark Desert": "Pyramid Fairy",
|
||||
"West Dark World": "Pyramid Fairy",
|
||||
"South Dark World": "Pyramid Fairy",
|
||||
"Light World": "Pyramid Fairy",
|
||||
"Old Man Cave": "Old Man S&Q"
|
||||
}
|
||||
|
||||
indirect_connections_inverted = {
|
||||
"Inverted Big Bomb Shop": "Pyramid Fairy",
|
||||
}
|
||||
|
||||
indirect_connections_not_inverted = {
|
||||
"Big Bomb Shop": "Pyramid Fairy",
|
||||
}
|
||||
|
||||
# format:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import typing
|
||||
|
||||
from BaseClasses import MultiWorld
|
||||
from Options import Choice, Range, Option, Toggle, DefaultOnToggle, DeathLink, TextChoice
|
||||
from Options import Choice, Range, Option, Toggle, DefaultOnToggle, DeathLink, TextChoice, PlandoBosses
|
||||
|
||||
|
||||
class Logic(Choice):
|
||||
@@ -138,7 +138,7 @@ class WorldState(Choice):
|
||||
option_inverted = 2
|
||||
|
||||
|
||||
class Bosses(TextChoice):
|
||||
class LTTPBosses(PlandoBosses):
|
||||
"""Shuffles bosses around to different locations.
|
||||
Basic will shuffle all bosses except Ganon and Agahnim anywhere they can be placed.
|
||||
Full chooses 3 bosses at random to be placed twice instead of Lanmolas, Moldorm, and Helmasaur.
|
||||
@@ -152,7 +152,9 @@ class Bosses(TextChoice):
|
||||
option_chaos = 3
|
||||
option_singularity = 4
|
||||
|
||||
bosses: set = {
|
||||
duplicate_bosses = True
|
||||
|
||||
bosses = {
|
||||
"Armos Knights",
|
||||
"Lanmolas",
|
||||
"Moldorm",
|
||||
@@ -165,7 +167,7 @@ class Bosses(TextChoice):
|
||||
"Trinexx",
|
||||
}
|
||||
|
||||
locations: set = {
|
||||
locations = {
|
||||
"Ganons Tower Top",
|
||||
"Tower of Hera",
|
||||
"Skull Woods",
|
||||
@@ -181,99 +183,16 @@ class Bosses(TextChoice):
|
||||
"Ganons Tower Bottom"
|
||||
}
|
||||
|
||||
def __init__(self, value: typing.Union[str, int]):
|
||||
assert isinstance(value, str) or isinstance(value, int), \
|
||||
f"{value} is not a valid option for {self.__class__.__name__}"
|
||||
self.value = value
|
||||
|
||||
@classmethod
|
||||
def from_text(cls, text: str):
|
||||
import random
|
||||
# set all of our text to lower case for name checking
|
||||
text = text.lower()
|
||||
cls.bosses = {boss_name.lower() for boss_name in cls.bosses}
|
||||
cls.locations = {boss_location.lower() for boss_location in cls.locations}
|
||||
if text == "random":
|
||||
return cls(random.choice(list(cls.options.values())))
|
||||
for option_name, value in cls.options.items():
|
||||
if option_name == text:
|
||||
return cls(value)
|
||||
options = text.split(";")
|
||||
|
||||
# since plando exists in the option verify the plando values given are valid
|
||||
cls.validate_plando_bosses(options)
|
||||
|
||||
# find out what type of boss shuffle we should use for placing bosses after plando
|
||||
# and add as a string to look nice in the spoiler
|
||||
if "random" in options:
|
||||
shuffle = random.choice(list(cls.options))
|
||||
options.remove("random")
|
||||
options = ";".join(options) + ";" + shuffle
|
||||
boss_class = cls(options)
|
||||
else:
|
||||
for option in options:
|
||||
if option in cls.options:
|
||||
boss_class = cls(";".join(options))
|
||||
break
|
||||
else:
|
||||
if len(options) == 1:
|
||||
if cls.valid_boss_name(options[0]):
|
||||
options = options[0] + ";singularity"
|
||||
boss_class = cls(options)
|
||||
else:
|
||||
options = options[0] + ";none"
|
||||
boss_class = cls(options)
|
||||
else:
|
||||
options = ";".join(options) + ";none"
|
||||
boss_class = cls(options)
|
||||
return boss_class
|
||||
|
||||
@classmethod
|
||||
def validate_plando_bosses(cls, options: typing.List[str]) -> None:
|
||||
from .Bosses import can_place_boss, format_boss_location
|
||||
for option in options:
|
||||
if option == "random" or option in cls.options:
|
||||
if option != options[-1]:
|
||||
raise ValueError(f"{option} option must be at the end of the boss_shuffle options!")
|
||||
continue
|
||||
if "-" in option:
|
||||
location, boss = option.split("-")
|
||||
level = ''
|
||||
if not cls.valid_boss_name(boss):
|
||||
raise ValueError(f"{boss} is not a valid boss name for location {location}.")
|
||||
if not cls.valid_location_name(location):
|
||||
raise ValueError(f"{location} is not a valid boss location name.")
|
||||
if location.split(" ")[-1] in ("top", "middle", "bottom"):
|
||||
location = location.split(" ")
|
||||
level = location[-1]
|
||||
location = " ".join(location[:-1])
|
||||
location = location.title().replace("Of", "of")
|
||||
if not can_place_boss(boss.title(), location, level):
|
||||
raise ValueError(f"{format_boss_location(location, level)} "
|
||||
f"is not a valid location for {boss.title()}.")
|
||||
else:
|
||||
if not cls.valid_boss_name(option):
|
||||
raise ValueError(f"{option} is not a valid boss name.")
|
||||
|
||||
@classmethod
|
||||
def valid_boss_name(cls, value: str) -> bool:
|
||||
return value.lower() in cls.bosses
|
||||
|
||||
@classmethod
|
||||
def valid_location_name(cls, value: str) -> bool:
|
||||
return value in cls.locations
|
||||
|
||||
def verify(self, world, player_name: str, plando_options) -> None:
|
||||
if isinstance(self.value, int):
|
||||
return
|
||||
from Generate import PlandoSettings
|
||||
if not(PlandoSettings.bosses & plando_options):
|
||||
import logging
|
||||
# plando is disabled but plando options were given so pull the option and change it to an int
|
||||
option = self.value.split(";")[-1]
|
||||
self.value = self.options[option]
|
||||
logging.warning(f"The plando bosses module is turned off, so {self.name_lookup[self.value].title()} "
|
||||
f"boss shuffle will be used for player {player_name}.")
|
||||
def can_place_boss(cls, boss: str, location: str) -> bool:
|
||||
from worlds.alttp.Bosses import can_place_boss
|
||||
level = ''
|
||||
words = location.split(" ")
|
||||
if words[-1] in ("top", "middle", "bottom"):
|
||||
level = words[-1]
|
||||
location = " ".join(words[:-1])
|
||||
location = location.title().replace("Of", "of")
|
||||
return can_place_boss(boss.title(), location, level)
|
||||
|
||||
|
||||
class Enemies(Choice):
|
||||
@@ -497,7 +416,7 @@ alttp_options: typing.Dict[str, type(Option)] = {
|
||||
"hints": Hints,
|
||||
"scams": Scams,
|
||||
"restrict_dungeon_item_on_boss": RestrictBossItem,
|
||||
"boss_shuffle": Bosses,
|
||||
"boss_shuffle": LTTPBosses,
|
||||
"pot_shuffle": PotShuffle,
|
||||
"enemy_shuffle": EnemyShuffle,
|
||||
"killable_thieves": KillableThieves,
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import Utils
|
||||
from Patch import read_rom
|
||||
import worlds.AutoWorld
|
||||
import worlds.Files
|
||||
|
||||
LTTPJPN10HASH = '03a63945398191337e896e5771f77173'
|
||||
RANDOMIZERBASEHASH = '9952c2a3ec1b421e408df0d20c8f0c7f'
|
||||
ROM_PLAYER_LIMIT = 255
|
||||
LTTPJPN10HASH: str = "03a63945398191337e896e5771f77173"
|
||||
RANDOMIZERBASEHASH: str = "9952c2a3ec1b421e408df0d20c8f0c7f"
|
||||
ROM_PLAYER_LIMIT: int = 255
|
||||
|
||||
import io
|
||||
import json
|
||||
@@ -34,7 +35,7 @@ from worlds.alttp.Text import KingsReturn_texts, Sanctuary_texts, Kakariko_texts
|
||||
DeathMountain_texts, \
|
||||
LostWoods_texts, WishingWell_texts, DesertPalace_texts, MountainTower_texts, LinksHouse_texts, Lumberjacks_texts, \
|
||||
SickKid_texts, FluteBoy_texts, Zora_texts, MagicShop_texts, Sahasrahla_names
|
||||
from Utils import local_path, user_path, int16_as_bytes, int32_as_bytes, snes_to_pc, is_frozen, parse_yaml
|
||||
from Utils import local_path, user_path, int16_as_bytes, int32_as_bytes, snes_to_pc, is_frozen, parse_yaml, read_snes_rom
|
||||
from worlds.alttp.Items import ItemFactory, item_table, item_name_groups, progression_items
|
||||
from worlds.alttp.EntranceShuffle import door_addresses
|
||||
from worlds.alttp.Options import smallkey_shuffle
|
||||
@@ -57,13 +58,13 @@ class LocalRom(object):
|
||||
self.orig_buffer = None
|
||||
|
||||
with open(file, 'rb') as stream:
|
||||
self.buffer = read_rom(stream)
|
||||
self.buffer = read_snes_rom(stream)
|
||||
if patch:
|
||||
self.patch_base_rom()
|
||||
self.orig_buffer = self.buffer.copy()
|
||||
if vanillaRom:
|
||||
with open(vanillaRom, 'rb') as vanillaStream:
|
||||
self.orig_buffer = read_rom(vanillaStream)
|
||||
self.orig_buffer = read_snes_rom(vanillaStream)
|
||||
|
||||
def read_byte(self, address: int) -> int:
|
||||
return self.buffer[address]
|
||||
@@ -123,29 +124,24 @@ class LocalRom(object):
|
||||
return expected == buffermd5.hexdigest()
|
||||
|
||||
def patch_base_rom(self):
|
||||
if os.path.isfile(local_path('basepatch.sfc')):
|
||||
with open(local_path('basepatch.sfc'), 'rb') as stream:
|
||||
if os.path.isfile(user_path('basepatch.sfc')):
|
||||
with open(user_path('basepatch.sfc'), 'rb') as stream:
|
||||
buffer = bytearray(stream.read())
|
||||
|
||||
if self.verify(buffer):
|
||||
self.buffer = buffer
|
||||
if not os.path.exists(local_path('data', 'basepatch.apbp')):
|
||||
Patch.create_patch_file(local_path('basepatch.sfc'))
|
||||
return
|
||||
|
||||
if not os.path.isfile(local_path('data', 'basepatch.apbp')):
|
||||
raise RuntimeError('Base patch unverified. Unable to continue.')
|
||||
with open(local_path("data", "basepatch.bsdiff4"), "rb") as f:
|
||||
delta = f.read()
|
||||
|
||||
if os.path.isfile(local_path('data', 'basepatch.apbp')):
|
||||
_, target, buffer = Patch.create_rom_bytes(local_path('data', 'basepatch.apbp'), ignore_version=True)
|
||||
if self.verify(buffer):
|
||||
self.buffer = bytearray(buffer)
|
||||
with open(user_path('basepatch.sfc'), 'wb') as stream:
|
||||
stream.write(buffer)
|
||||
return
|
||||
raise RuntimeError('Base patch unverified. Unable to continue.')
|
||||
|
||||
raise RuntimeError('Could not find Base Patch. Unable to continue.')
|
||||
buffer = bsdiff4.patch(get_base_rom_bytes(), delta)
|
||||
if self.verify(buffer):
|
||||
self.buffer = bytearray(buffer)
|
||||
with open(user_path('basepatch.sfc'), 'wb') as stream:
|
||||
stream.write(buffer)
|
||||
return
|
||||
raise RuntimeError('Base patch unverified. Unable to continue.')
|
||||
|
||||
def write_crc(self):
|
||||
crc = (sum(self.buffer[:0x7FDC] + self.buffer[0x7FE0:]) + 0x01FE) & 0xFFFF
|
||||
@@ -544,7 +540,7 @@ class Sprite():
|
||||
|
||||
def get_vanilla_sprite_data(self):
|
||||
file_name = get_base_rom_path()
|
||||
base_rom_bytes = bytes(read_rom(open(file_name, "rb")))
|
||||
base_rom_bytes = bytes(read_snes_rom(open(file_name, "rb")))
|
||||
Sprite.sprite = base_rom_bytes[0x80000:0x87000]
|
||||
Sprite.palette = base_rom_bytes[0xDD308:0xDD380]
|
||||
Sprite.glove_palette = base_rom_bytes[0xDEDF5:0xDEDF9]
|
||||
@@ -2906,7 +2902,7 @@ hash_alphabet = [
|
||||
]
|
||||
|
||||
|
||||
class LttPDeltaPatch(Patch.APDeltaPatch):
|
||||
class LttPDeltaPatch(worlds.Files.APDeltaPatch):
|
||||
hash = LTTPJPN10HASH
|
||||
game = "A Link to the Past"
|
||||
patch_file_ending = ".aplttp"
|
||||
@@ -2920,7 +2916,7 @@ def get_base_rom_bytes(file_name: str = "") -> bytes:
|
||||
base_rom_bytes = getattr(get_base_rom_bytes, "base_rom_bytes", None)
|
||||
if not base_rom_bytes:
|
||||
file_name = get_base_rom_path(file_name)
|
||||
base_rom_bytes = bytes(read_rom(open(file_name, "rb")))
|
||||
base_rom_bytes = bytes(read_snes_rom(open(file_name, "rb")))
|
||||
|
||||
basemd5 = hashlib.md5()
|
||||
basemd5.update(base_rom_bytes)
|
||||
|
||||
@@ -7,7 +7,8 @@ import typing
|
||||
import Utils
|
||||
from BaseClasses import Item, CollectionState, Tutorial
|
||||
from .Dungeons import create_dungeons
|
||||
from .EntranceShuffle import link_entrances, link_inverted_entrances, plando_connect
|
||||
from .EntranceShuffle import link_entrances, link_inverted_entrances, plando_connect, \
|
||||
indirect_connections, indirect_connections_inverted, indirect_connections_not_inverted
|
||||
from .InvertedRegions import create_inverted_regions, mark_dark_world_regions
|
||||
from .ItemPool import generate_itempool, difficulties
|
||||
from .Items import item_init_table, item_name_groups, item_table, GetBeemizerItem
|
||||
@@ -19,7 +20,7 @@ from .Rom import LocalRom, patch_rom, patch_race_rom, check_enemizer, patch_enem
|
||||
from .Rules import set_rules
|
||||
from .Shops import create_shops, ShopSlotFill
|
||||
from .SubClasses import ALttPItem
|
||||
from ..AutoWorld import World, WebWorld, LogicMixin
|
||||
from worlds.AutoWorld import World, WebWorld, LogicMixin
|
||||
|
||||
lttp_logger = logging.getLogger("A Link to the Past")
|
||||
|
||||
@@ -216,13 +217,24 @@ class ALTTPWorld(World):
|
||||
if world.mode[player] != 'inverted':
|
||||
link_entrances(world, player)
|
||||
mark_light_world_regions(world, player)
|
||||
for region_name, entrance_name in indirect_connections_not_inverted.items():
|
||||
world.register_indirect_condition(self.world.get_region(region_name, player),
|
||||
self.world.get_entrance(entrance_name, player))
|
||||
else:
|
||||
link_inverted_entrances(world, player)
|
||||
mark_dark_world_regions(world, player)
|
||||
for region_name, entrance_name in indirect_connections_inverted.items():
|
||||
world.register_indirect_condition(self.world.get_region(region_name, player),
|
||||
self.world.get_entrance(entrance_name, player))
|
||||
|
||||
world.random = old_random
|
||||
plando_connect(world, player)
|
||||
|
||||
for region_name, entrance_name in indirect_connections.items():
|
||||
world.register_indirect_condition(self.world.get_region(region_name, player),
|
||||
self.world.get_entrance(entrance_name, player))
|
||||
|
||||
|
||||
def collect_item(self, state: CollectionState, item: Item, remove=False):
|
||||
item_name = item.name
|
||||
if item_name.startswith('Progressive '):
|
||||
@@ -396,11 +408,7 @@ class ALTTPWorld(World):
|
||||
deathlink=world.death_link[player],
|
||||
allowcollect=world.allow_collect[player])
|
||||
|
||||
outfilepname = f'_P{player}'
|
||||
outfilepname += f"_{world.get_file_safe_player_name(player).replace(' ', '_')}" \
|
||||
if world.player_name[player] != 'Player%d' % player else ''
|
||||
|
||||
rompath = os.path.join(output_directory, f'AP_{world.seed_name}{outfilepname}.sfc')
|
||||
rompath = os.path.join(output_directory, f"{self.world.get_out_file_name_base(self.player)}.sfc")
|
||||
rom.write_to_file(rompath)
|
||||
patch = LttPDeltaPatch(os.path.splitext(rompath)[0]+LttPDeltaPatch.patch_file_ending, player=player,
|
||||
player_name=world.player_name[player], patched_path=rompath)
|
||||
@@ -490,11 +498,15 @@ class ALTTPWorld(World):
|
||||
|
||||
while gtower_locations and filleritempool and trash_count > 0:
|
||||
spot_to_fill = gtower_locations.pop()
|
||||
item_to_place = filleritempool.pop()
|
||||
if spot_to_fill.item_rule(item_to_place):
|
||||
world.push_item(spot_to_fill, item_to_place, False)
|
||||
fill_locations.remove(spot_to_fill) # very slow, unfortunately
|
||||
trash_count -= 1
|
||||
for index, item in enumerate(filleritempool):
|
||||
if spot_to_fill.item_rule(item):
|
||||
filleritempool.pop(index) # remove from outer fill
|
||||
world.push_item(spot_to_fill, item, False)
|
||||
fill_locations.remove(spot_to_fill) # very slow, unfortunately
|
||||
trash_count -= 1
|
||||
break
|
||||
else:
|
||||
logging.warning(f"Could not trash fill Ganon's Tower for player {player}.")
|
||||
|
||||
def get_filler_item_name(self) -> str:
|
||||
if self.world.goal[self.player] == "icerodhunt":
|
||||
|
||||
33
worlds/bk_sudoku/__init__.py
Normal file
33
worlds/bk_sudoku/__init__.py
Normal file
@@ -0,0 +1,33 @@
|
||||
from BaseClasses import Tutorial
|
||||
from ..AutoWorld import World, WebWorld
|
||||
from typing import Dict
|
||||
|
||||
|
||||
class Bk_SudokuWebWorld(WebWorld):
|
||||
settings_page = "games/Sudoku/info/en"
|
||||
theme = 'partyTime'
|
||||
tutorials = [
|
||||
Tutorial(
|
||||
tutorial_name='Setup Guide',
|
||||
description='A guide to playing BK Sudoku',
|
||||
language='English',
|
||||
file_name='setup_en.md',
|
||||
link='guide/en',
|
||||
authors=['Jarno']
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
class Bk_SudokuWorld(World):
|
||||
"""
|
||||
Play a little Sudoku while you're in BK mode to maybe get some useful hints
|
||||
"""
|
||||
game = "Sudoku"
|
||||
web = Bk_SudokuWebWorld()
|
||||
|
||||
item_name_to_id: Dict[str, int] = {}
|
||||
location_name_to_id: Dict[str, int] = {}
|
||||
|
||||
@classmethod
|
||||
def stage_assert_generate(cls, world):
|
||||
raise Exception("BK Sudoku cannot be used for generating worlds, the client can instead connect to any other world")
|
||||
13
worlds/bk_sudoku/docs/en_Sudoku.md
Normal file
13
worlds/bk_sudoku/docs/en_Sudoku.md
Normal file
@@ -0,0 +1,13 @@
|
||||
# Bk Sudoku
|
||||
|
||||
## What is this game?
|
||||
|
||||
BK Sudoku is not a typical Archipelago game; instead, it is a generic Sudoku client that can connect to any existing multiworld. When connected, you can play Sudoku to unlock random hints for your game. While slow, it will give you something to do when you can't reach the checks in your game.
|
||||
|
||||
## What hints are unlocked?
|
||||
|
||||
After completing a Sudoku puzzle, the game will unlock 1 random hint for an unchecked location in the slot you are connected to. It is possible to hint the same location repeatedly if that location is still unchecked.
|
||||
|
||||
## Where is the settings page?
|
||||
|
||||
There is no settings page; this game cannot be used in your .yamls. Instead, the client can connect to any slot in a multiworld.
|
||||
24
worlds/bk_sudoku/docs/setup_en.md
Normal file
24
worlds/bk_sudoku/docs/setup_en.md
Normal file
@@ -0,0 +1,24 @@
|
||||
# BK Sudoku Setup Guide
|
||||
|
||||
## Required Software
|
||||
- [Bk Sudoku](https://github.com/Jarno458/sudoku)
|
||||
- [.Net 6](https://docs.microsoft.com/en-us/dotnet/core/install/windows?tabs=net60)
|
||||
|
||||
## General Concept
|
||||
|
||||
This is a client that can connect to any multiworld slot, and lets you play Sudoku to unlock random hints for that slot's locations.
|
||||
|
||||
Due to the fact that the Sudoku client may connect to any slot, it is not necessary to generate a YAML for this game as it does not generate any new slots in the multiworld session.
|
||||
|
||||
## Installation Procedures
|
||||
|
||||
Go to the latest release on [BK Sudoku Releases](https://github.com/Jarno458/sudoku/releases). Download and extract the `Bk_Sudoku.zip` file.
|
||||
|
||||
## Joining a MultiWorld Game
|
||||
|
||||
1. Run Bk_Sudoku.exe
|
||||
2. Enter the name of the slot you wish to connect to
|
||||
3. Enter the server url & port number
|
||||
4. Press connect
|
||||
5. Choose difficulty
|
||||
6. Try to solve the Sudoku
|
||||
0
worlds/dkc3/Names/__init__.py
Normal file
0
worlds/dkc3/Names/__init__.py
Normal file
@@ -1,5 +1,6 @@
|
||||
import Utils
|
||||
from Patch import read_rom, APDeltaPatch
|
||||
from Utils import read_snes_rom
|
||||
from worlds.Files import APDeltaPatch
|
||||
from .Locations import lookup_id_to_name, all_locations
|
||||
from .Levels import level_list, level_dict
|
||||
|
||||
@@ -440,13 +441,13 @@ class LocalRom(object):
|
||||
self.orig_buffer = None
|
||||
|
||||
with open(file, 'rb') as stream:
|
||||
self.buffer = read_rom(stream)
|
||||
self.buffer = read_snes_rom(stream)
|
||||
#if patch:
|
||||
# self.patch_rom()
|
||||
# self.orig_buffer = self.buffer.copy()
|
||||
#if vanillaRom:
|
||||
# with open(vanillaRom, 'rb') as vanillaStream:
|
||||
# self.orig_buffer = read_rom(vanillaStream)
|
||||
# self.orig_buffer = read_snes_rom(vanillaStream)
|
||||
|
||||
def read_bit(self, address: int, bit_number: int) -> bool:
|
||||
bitflag = (1 << bit_number)
|
||||
@@ -724,7 +725,7 @@ def get_base_rom_bytes(file_name: str = "") -> bytes:
|
||||
base_rom_bytes = getattr(get_base_rom_bytes, "base_rom_bytes", None)
|
||||
if not base_rom_bytes:
|
||||
file_name = get_base_rom_path(file_name)
|
||||
base_rom_bytes = bytes(read_rom(open(file_name, "rb")))
|
||||
base_rom_bytes = bytes(read_snes_rom(open(file_name, "rb")))
|
||||
|
||||
basemd5 = hashlib.md5()
|
||||
basemd5.update(base_rom_bytes)
|
||||
|
||||
@@ -65,10 +65,6 @@ class DKC3World(World):
|
||||
"active_levels": self.active_level_list,
|
||||
}
|
||||
|
||||
def _create_items(self, name: str):
|
||||
data = item_table[name]
|
||||
return [self.create_item(name)] * data.quantity
|
||||
|
||||
def fill_slot_data(self) -> dict:
|
||||
slot_data = self._get_slot_data()
|
||||
for option_name in dkc3_options:
|
||||
@@ -113,17 +109,17 @@ class DKC3World(World):
|
||||
number_of_bonus_coins = (self.world.krematoa_bonus_coin_cost[self.player] * 5)
|
||||
number_of_bonus_coins += math.ceil((85 - number_of_bonus_coins) * self.world.percentage_of_extra_bonus_coins[self.player] / 100)
|
||||
|
||||
itempool += [self.create_item(ItemName.bonus_coin)] * number_of_bonus_coins
|
||||
itempool += [self.create_item(ItemName.dk_coin)] * 41
|
||||
itempool += [self.create_item(ItemName.banana_bird)] * number_of_banana_birds
|
||||
itempool += [self.create_item(ItemName.krematoa_cog)] * number_of_cogs
|
||||
itempool += [self.create_item(ItemName.progressive_boat)] * 3
|
||||
itempool += [self.create_item(ItemName.bonus_coin) for _ in range(number_of_bonus_coins)]
|
||||
itempool += [self.create_item(ItemName.dk_coin) for _ in range(41)]
|
||||
itempool += [self.create_item(ItemName.banana_bird) for _ in range(number_of_banana_birds)]
|
||||
itempool += [self.create_item(ItemName.krematoa_cog) for _ in range(number_of_cogs)]
|
||||
itempool += [self.create_item(ItemName.progressive_boat) for _ in range(3)]
|
||||
|
||||
total_junk_count = total_required_locations - len(itempool)
|
||||
|
||||
junk_pool = []
|
||||
for item_name in self.world.random.choices(list(junk_table.keys()), k=total_junk_count):
|
||||
junk_pool += [self.create_item(item_name)]
|
||||
junk_pool.append(self.create_item(item_name))
|
||||
|
||||
itempool += junk_pool
|
||||
|
||||
@@ -146,11 +142,7 @@ class DKC3World(World):
|
||||
|
||||
self.active_level_list.append(LocationName.rocket_rush_region)
|
||||
|
||||
outfilepname = f'_P{player}'
|
||||
outfilepname += f"_{world.player_name[player].replace(' ', '_')}" \
|
||||
if world.player_name[player] != 'Player%d' % player else ''
|
||||
|
||||
rompath = os.path.join(output_directory, f'AP_{world.seed_name}{outfilepname}.sfc')
|
||||
rompath = os.path.join(output_directory, f"{self.world.get_out_file_name_base(self.player)}.sfc")
|
||||
rom.write_to_file(rompath)
|
||||
self.rom_name = rom.name
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@ import shutil
|
||||
|
||||
import Utils
|
||||
import Patch
|
||||
import worlds.AutoWorld
|
||||
import worlds.Files
|
||||
from . import Options
|
||||
|
||||
from .Technologies import tech_table, recipes, free_sample_exclusions, progressive_technology_table, \
|
||||
@@ -57,7 +59,7 @@ recipe_time_ranges = {
|
||||
}
|
||||
|
||||
|
||||
class FactorioModFile(Patch.APContainer):
|
||||
class FactorioModFile(worlds.Files.APContainer):
|
||||
game = "Factorio"
|
||||
compression_method = zipfile.ZIP_DEFLATED # Factorio can't load LZMA archives
|
||||
|
||||
|
||||
@@ -118,7 +118,7 @@ This allows you to host your own Factorio game.
|
||||
3. Install the mod into your Factorio Client by copying the zip file into the `mods` folder, which is likely located
|
||||
at `C:\Users\YourName\AppData\Roaming\Factorio\mods`.
|
||||
4. Obtain the Archipelago Server address from the website's host room, or from the server host.
|
||||
5. Run your Archipelago Client, which is named `ArchilepagoFactorioClient.exe`. This was installed along with
|
||||
5. Run your Archipelago Client, which is named `ArchipelagoFactorioClient.exe`. This was installed along with
|
||||
Archipelago if you chose to include it during the installation process.
|
||||
6. Enter `/connect [server-address]` into the input box at the bottom of the Archipelago Client and press "Enter"
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import collections
|
||||
import typing
|
||||
|
||||
from BaseClasses import LocationProgressType
|
||||
from BaseClasses import LocationProgressType, MultiWorld
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
import BaseClasses
|
||||
@@ -12,39 +13,82 @@ else:
|
||||
ItemRule = typing.Callable[[object], bool]
|
||||
|
||||
|
||||
def group_locality_rules(world):
|
||||
def locality_needed(world: MultiWorld) -> bool:
|
||||
for player in world.player_ids:
|
||||
if world.local_items[player].value:
|
||||
return True
|
||||
if world.non_local_items[player].value:
|
||||
return True
|
||||
|
||||
# Group
|
||||
for group_id, group in world.groups.items():
|
||||
if set(world.player_ids) == set(group["players"]):
|
||||
continue
|
||||
if group["local_items"]:
|
||||
for location in world.get_locations():
|
||||
if location.player not in group["players"]:
|
||||
forbid_items_for_player(location, group["local_items"], group_id)
|
||||
return True
|
||||
if group["non_local_items"]:
|
||||
for location in world.get_locations():
|
||||
if location.player in group["players"]:
|
||||
forbid_items_for_player(location, group["non_local_items"], group_id)
|
||||
return True
|
||||
|
||||
|
||||
def locality_rules(world, player: int):
|
||||
if world.local_items[player].value:
|
||||
def locality_rules(world: MultiWorld):
|
||||
if locality_needed(world):
|
||||
|
||||
forbid_data: typing.Dict[int, typing.Dict[int, typing.Set[str]]] = \
|
||||
collections.defaultdict(lambda: collections.defaultdict(set))
|
||||
|
||||
def forbid(sender: int, receiver: int, items: typing.Set[str]):
|
||||
forbid_data[sender][receiver].update(items)
|
||||
|
||||
for receiving_player in world.player_ids:
|
||||
local_items: typing.Set[str] = world.local_items[receiving_player].value
|
||||
if local_items:
|
||||
for sending_player in world.player_ids:
|
||||
if receiving_player != sending_player:
|
||||
forbid(sending_player, receiving_player, local_items)
|
||||
non_local_items: typing.Set[str] = world.non_local_items[receiving_player].value
|
||||
if non_local_items:
|
||||
forbid(receiving_player, receiving_player, non_local_items)
|
||||
|
||||
# Group
|
||||
for receiving_group_id, receiving_group in world.groups.items():
|
||||
if set(world.player_ids) == set(receiving_group["players"]):
|
||||
continue
|
||||
if receiving_group["local_items"]:
|
||||
for sending_player in world.player_ids:
|
||||
if sending_player not in receiving_group["players"]:
|
||||
forbid(sending_player, receiving_group_id, receiving_group["local_items"])
|
||||
if receiving_group["non_local_items"]:
|
||||
for sending_player in world.player_ids:
|
||||
if sending_player in receiving_group["players"]:
|
||||
forbid(sending_player, receiving_group_id, receiving_group["non_local_items"])
|
||||
|
||||
# create fewer lambda's to save memory and cache misses
|
||||
func_cache = {}
|
||||
for location in world.get_locations():
|
||||
if location.player != player:
|
||||
forbid_items_for_player(location, world.local_items[player].value, player)
|
||||
if world.non_local_items[player].value:
|
||||
for location in world.get_locations():
|
||||
if location.player == player:
|
||||
forbid_items_for_player(location, world.non_local_items[player].value, player)
|
||||
if (location.player, location.item_rule) in func_cache:
|
||||
location.item_rule = func_cache[location.player, location.item_rule]
|
||||
# empty rule that just returns True, overwrite
|
||||
elif location.item_rule is location.__class__.item_rule:
|
||||
func_cache[location.player, location.item_rule] = location.item_rule = \
|
||||
lambda i, sending_blockers = forbid_data[location.player], \
|
||||
old_rule = location.item_rule: \
|
||||
i.name not in sending_blockers[i.player]
|
||||
# special rule, needs to also be fulfilled.
|
||||
else:
|
||||
func_cache[location.player, location.item_rule] = location.item_rule = \
|
||||
lambda i, sending_blockers = forbid_data[location.player], \
|
||||
old_rule = location.item_rule: \
|
||||
i.name not in sending_blockers[i.player] and old_rule(i)
|
||||
|
||||
|
||||
def exclusion_rules(world, player: int, exclude_locations: typing.Set[str]):
|
||||
def exclusion_rules(world: MultiWorld, player: int, exclude_locations: typing.Set[str]) -> None:
|
||||
for loc_name in exclude_locations:
|
||||
try:
|
||||
location = world.get_location(loc_name, player)
|
||||
except KeyError as e: # failed to find the given location. Check if it's a legitimate location
|
||||
if loc_name not in world.worlds[player].location_name_to_id:
|
||||
raise Exception(f"Unable to exclude location {loc_name} in player {player}'s world.") from e
|
||||
else:
|
||||
else:
|
||||
add_item_rule(location, lambda i: not (i.advancement or i.useful))
|
||||
location.progress_type = LocationProgressType.EXCLUDED
|
||||
|
||||
@@ -53,17 +97,25 @@ def set_rule(spot: typing.Union["BaseClasses.Location", "BaseClasses.Entrance"],
|
||||
spot.access_rule = rule
|
||||
|
||||
|
||||
def add_rule(spot: typing.Union["BaseClasses.Location", "BaseClasses.Entrance"], rule: CollectionRule, combine='and'):
|
||||
def add_rule(spot: typing.Union["BaseClasses.Location", "BaseClasses.Entrance"], rule: CollectionRule, combine="and"):
|
||||
old_rule = spot.access_rule
|
||||
if combine == 'or':
|
||||
spot.access_rule = lambda state: rule(state) or old_rule(state)
|
||||
# empty rule, replace instead of add
|
||||
if old_rule is spot.__class__.access_rule:
|
||||
spot.access_rule = rule if combine == "and" else old_rule
|
||||
else:
|
||||
spot.access_rule = lambda state: rule(state) and old_rule(state)
|
||||
if combine == "and":
|
||||
spot.access_rule = lambda state: rule(state) and old_rule(state)
|
||||
else:
|
||||
spot.access_rule = lambda state: rule(state) or old_rule(state)
|
||||
|
||||
|
||||
def forbid_item(location: "BaseClasses.Location", item: str, player: int):
|
||||
old_rule = location.item_rule
|
||||
location.item_rule = lambda i: (i.name != item or i.player != player) and old_rule(i)
|
||||
# empty rule
|
||||
if old_rule is location.__class__.item_rule:
|
||||
location.item_rule = lambda i: i.name != item or i.player != player
|
||||
else:
|
||||
location.item_rule = lambda i: (i.name != item or i.player != player) and old_rule(i)
|
||||
|
||||
|
||||
def forbid_items_for_player(location: "BaseClasses.Location", items: typing.Set[str], player: int):
|
||||
@@ -77,9 +129,16 @@ def forbid_items(location: "BaseClasses.Location", items: typing.Set[str]):
|
||||
location.item_rule = lambda i: i.name not in items and old_rule(i)
|
||||
|
||||
|
||||
def add_item_rule(location: "BaseClasses.Location", rule: ItemRule):
|
||||
def add_item_rule(location: "BaseClasses.Location", rule: ItemRule, combine: str = "and"):
|
||||
old_rule = location.item_rule
|
||||
location.item_rule = lambda item: rule(item) and old_rule(item)
|
||||
# empty rule, replace instead of add
|
||||
if old_rule is location.__class__.item_rule:
|
||||
location.item_rule = rule if combine == "and" else old_rule
|
||||
else:
|
||||
if combine == "and":
|
||||
location.item_rule = lambda item: rule(item) and old_rule(item)
|
||||
else:
|
||||
location.item_rule = lambda item: rule(item) or old_rule(item)
|
||||
|
||||
|
||||
def item_in_locations(state: "BaseClasses.CollectionState", item: str, player: int,
|
||||
|
||||
@@ -2,23 +2,24 @@
|
||||
|
||||
## What is Plando?
|
||||
|
||||
The purposes of randomizers is to randomize the items in a game to give a new experience. Plando takes this concept and
|
||||
The purpose of randomizers is to randomize the items in a game to give a new experience. Plando takes this concept and
|
||||
changes it up by allowing you to plan out certain aspects of the game by placing certain items in certain locations,
|
||||
certain bosses in certain rooms, edit text for certain NPCs/signs, or even force certain region connections. Each of
|
||||
these options are going to be detailed separately as `item plando`, `boss plando`, `text plando`,
|
||||
and `connection plando`. Every game in archipelago supports item plando but the other plando options are only supported
|
||||
by certain games. Currently, only LTTP supports text and boss plando. Support for connection plando may vary.
|
||||
and `connection plando`. Every game in Archipelago supports item plando but the other plando options are only supported
|
||||
by certain games. Currently, only A Link to the Past supports text and boss plando. Support for connection plando may
|
||||
vary.
|
||||
|
||||
### Enabling Plando
|
||||
|
||||
On the website plando will already be enabled. If you will be generating the game locally plando features must be
|
||||
On the website, plando will already be enabled. If you will be generating the game locally, plando features must be
|
||||
enabled (opt-in).
|
||||
|
||||
* To opt-in go to the archipelago installation (default: `C:\ProgramData\Archipelago`), open the host.yaml with a text
|
||||
* To opt-in go to the Archipelago installation (default: `C:\ProgramData\Archipelago`), open `host.yaml` with a text
|
||||
editor and find the `plando_options` key. The available plando modules can be enabled by adding them after this such
|
||||
as
|
||||
`plando_options: bosses, items, texts, connections`.
|
||||
* You can add the necessary plando modules for your settings to the `requires` section of your yaml. Doing so will throw an error if the options that you need to generate properly are not enabled to ensure you will get the results you desire. Only enter in the plando modules that you are using here but it should look like:
|
||||
* You can add the necessary plando modules for your settings to the `requires` section of your YAML. Doing so will throw an error if the options that you need to generate properly are not enabled to ensure you will get the results you desire. Only enter in the plando modules that you are using here but it should look like:
|
||||
|
||||
```yaml
|
||||
requires:
|
||||
@@ -27,45 +28,45 @@ enabled (opt-in).
|
||||
```
|
||||
|
||||
## Item Plando
|
||||
Item plando allows a player to place an item in a specific location or specific locations, place multiple items into a
|
||||
Item plando allows a player to place an item in a specific location or specific locations, or place multiple items into a
|
||||
list of specific locations both in their own game or in another player's game.
|
||||
|
||||
* The options for item plando are `from_pool`, `world`, `percentage`, `force`, `count`, and either item and location, or items
|
||||
and locations.
|
||||
* The options for item plando are `from_pool`, `world`, `percentage`, `force`, `count`, and either `item` and
|
||||
`location`, or `items` and `locations`.
|
||||
* `from_pool` determines if the item should be taken *from* the item pool or *added* to it. This can be true or
|
||||
false and defaults to true if omitted.
|
||||
* `world` is the target world to place the item in.
|
||||
* It gets ignored if only one world is generated.
|
||||
* Can be a number, name, true, false, null, or a list. False is the default.
|
||||
* If a number is used it targets that slot or player number in the multiworld.
|
||||
* If a name is used it will target the world with that player name.
|
||||
* If set to true it will be any player's world besides your own.
|
||||
* If set to false it will target your own world.
|
||||
* If set to null it will target a random world in the multiworld.
|
||||
* If a number is used, it targets that slot or player number in the multiworld.
|
||||
* If a name is used, it will target the world with that player name.
|
||||
* If set to true, it will be any player's world besides your own.
|
||||
* If set to false, it will target your own world.
|
||||
* If set to null, it will target a random world in the multiworld.
|
||||
* If a list of names is used, it will target the games with the player names specified.
|
||||
* `force` determines whether the generator will fail if the item can't be placed in the location can be true, false,
|
||||
* `force` determines whether the generator will fail if the item can't be placed in the location. Can be true, false,
|
||||
or silent. Silent is the default.
|
||||
* If set to true the item must be placed and the generator will throw an error if it is unable to do so.
|
||||
* If set to false the generator will log a warning if the placement can't be done but will still generate.
|
||||
* If set to silent and the placement fails it will be ignored entirely.
|
||||
* If set to true, the item must be placed and the generator will throw an error if it is unable to do so.
|
||||
* If set to false, the generator will log a warning if the placement can't be done but will still generate.
|
||||
* If set to silent and the placement fails, it will be ignored entirely.
|
||||
* `percentage` is the percentage chance for the relevant block to trigger. This can be any value from 0 to 100 and
|
||||
if omitted will default to 100.
|
||||
* Single Placement is when you use a plando block to place a single item at a single location.
|
||||
* `item` is the item you would like to place and `location` is the location to place it.
|
||||
* Multi Placement uses a plando block to place multiple items in multiple locations until either list is exhausted.
|
||||
* `items` defines the items to use and a number letting you place multiple of it. You can use true instead of a number to have it use however many of that item are in your item pool.
|
||||
* `items` defines the items to use, each with a number for the amount. Using `true` instead of a number uses however many of that item are in your item pool.
|
||||
* `locations` is a list of possible locations those items can be placed in.
|
||||
* Using the multi placement method, placements are picked randomly.
|
||||
* Instead of a number, you can use true
|
||||
|
||||
* `count` can be used to set the maximum number of items placed from the block. The default is 1 if using `item` and False if using `items`
|
||||
* If a number is used it will try to place this number of items.
|
||||
* If set to false it will try to place as many items from the block as it can.
|
||||
* If `min` and `max` are defined, it will try to place a number of items between these two numbers at random
|
||||
* If a number is used, it will try to place this number of items.
|
||||
* If set to false, it will try to place as many items from the block as it can.
|
||||
* If `min` and `max` are defined, it will try to place a number of items between these two numbers at random.
|
||||
|
||||
|
||||
### Available Items and Locations
|
||||
|
||||
A list of all available items and locations can be found in the [website's datapackage](/datapackage). The items and locations will be in the `"item_name_to_id"` and `"location_name_to_id"` sections of the relevant game. You do not need the quotes but the name must be entered in the same as it appears on that page and is caps-sensitive.
|
||||
A list of all available items and locations can be found in the [website's datapackage](/datapackage). The items and locations will be in the `"item_name_to_id"` and `"location_name_to_id"` sections of the relevant game. You do not need the quotes but the name must be entered in the same as it appears on that page and is case-sensitive.
|
||||
|
||||
### Examples
|
||||
|
||||
@@ -142,43 +143,43 @@ plando_items:
|
||||
min: 1
|
||||
max: 4
|
||||
```
|
||||
1. This block has a 50% chance to occur, and if it does will place either the Empire Orb or Radiant Orb on another player's
|
||||
Starter Chest 1 and removes the chosen item from the item pool.
|
||||
1. This block has a 50% chance to occur, and if it does, it will place either the Empire Orb or Radiant Orb on another
|
||||
player's Starter Chest 1 and removes the chosen item from the item pool.
|
||||
2. This block will always trigger and will place the player's swords, bow, magic meter, strength upgrades, and hookshots
|
||||
in their own dungeon major item chests.
|
||||
3. This block will always trigger and will lock boss relics on the bosses.
|
||||
4. This block has an 80% chance of occurring and when it does will place all but 1 of the items randomly among the four
|
||||
locations chosen here.
|
||||
4. This block has an 80% chance of occurring, and when it does, it will place all but 1 of the items randomly among the
|
||||
four locations chosen here.
|
||||
5. This block will always trigger and will attempt to place a random 2 of Levitate, Revealer and Energize into
|
||||
other players' Master Sword Pedestals or Boss Relic 1 locations.
|
||||
6. This block will always trigger and will attempt to place a random number, between 1 and 4, of progressive swords
|
||||
into any locations within the game slots named BobsSlaytheSpire and BobsRogueLegacy
|
||||
into any locations within the game slots named BobsSlaytheSpire and BobsRogueLegacy.
|
||||
|
||||
|
||||
## Boss Plando
|
||||
|
||||
As this is currently only supported by A Link to the Past instead of explaining here please refer to the
|
||||
As this is currently only supported by A Link to the Past, instead of finding an explanation here, please refer to the
|
||||
relevant guide: [A Link to the Past Plando Guide](/tutorial/A%20Link%20to%20the%20Past/plando/en)
|
||||
|
||||
## Text Plando
|
||||
|
||||
As this is currently only supported by A Link to the Past instead of explaining here please refer to the
|
||||
As this is currently only supported by A Link to the Past, instead of finding an explanation here, please refer to the
|
||||
relevant guide: [A Link to the Past Plando Guide](/tutorial/A%20Link%20to%20the%20Past/plando/en)
|
||||
|
||||
## Connections Plando
|
||||
|
||||
This is currently only supported by Minecraft and A Link to the Past. As the way that these games interact with their
|
||||
connections is different I will only explain the basics here while more specifics for Link to the Past connection plando
|
||||
can be found in its plando guide.
|
||||
connections is different, I will only explain the basics here, while more specifics for A Link to the Past connection
|
||||
plando can be found in its plando guide.
|
||||
|
||||
* The options for connections are `percentage`, `entrance`, `exit`, and `direction`. Each of these options support
|
||||
* The options for connections are `percentage`, `entrance`, `exit`, and `direction`. Each of these options supports
|
||||
subweights.
|
||||
* `percentage` is the percentage chance for this connection from 0 to 100 and defaults to 100.
|
||||
* Every connection has an `entrance` and an `exit`. These can be unlinked like in A Link to the Past insanity entrance
|
||||
shuffle.
|
||||
* `direction` can be `both`, `entrance`, or `exit` and determines in which direction this connection will operate.
|
||||
|
||||
[Link to the Past connections](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/alttp/EntranceShuffle.py#L3852)
|
||||
[A Link to the Past connections](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/alttp/EntranceShuffle.py#L3852)
|
||||
|
||||
[Minecraft connections](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/minecraft/Regions.py#L62)
|
||||
|
||||
@@ -186,7 +187,7 @@ can be found in its plando guide.
|
||||
|
||||
```yaml
|
||||
plando_connections:
|
||||
# example block 1 - Link to the Past
|
||||
# example block 1 - A Link to the Past
|
||||
- entrance: Cave Shop (Lake Hylia)
|
||||
exit: Cave 45
|
||||
direction: entrance
|
||||
@@ -206,9 +207,9 @@ plando_connections:
|
||||
direction: both
|
||||
```
|
||||
|
||||
1. These connections are decoupled so going into the lake hylia cave shop will take you to the inside of cave 45 and
|
||||
when you leave the interior you will exit to the cave 45 ledge. Going into the cave 45 entrance will then take you to
|
||||
the lake hylia cave shop. Walking into the entrance for the old man cave and Agahnim's Tower entrance will both take
|
||||
you to their locations as normal but leaving old man cave will exit at Agahnim's Tower.
|
||||
2. This will force a nether fortress and a village to be the overworld structures for your game. Note that for the
|
||||
1. These connections are decoupled, so going into the Lake Hylia Cave Shop will take you to the inside of Cave 45, and
|
||||
when you leave the interior, you will exit to the Cave 45 ledge. Going into the Cave 45 entrance will then take you to
|
||||
the Lake Hylia Cave Shop. Walking into the entrance for the Old Man Cave and Agahnim's Tower entrance will both take
|
||||
you to their locations as normal, but leaving Old Man Cave will exit at Agahnim's Tower.
|
||||
2. This will force a Nether fortress and a village to be the Overworld structures for your game. Note that for the
|
||||
Minecraft connection plando to work structure shuffle must be enabled.
|
||||
|
||||
@@ -8,36 +8,31 @@ about 5 minutes to read.
|
||||
|
||||
Triggers allow you to customize your game settings by allowing you to define one or many options which only occur under
|
||||
specific conditions. These are essentially "if, then" statements for options in your game. A good example of what you
|
||||
can do with triggers is the custom mercenary mode YAML that was created using entirely triggers and plando.
|
||||
can do with triggers is the [custom mercenary mode YAML
|
||||
](https://github.com/alwaysintreble/Archipelago-yaml-dump/blob/main/Snippets/Mercenary%20Mode%20Snippet.yaml) that was
|
||||
created using entirely triggers and plando.
|
||||
|
||||
Mercenary mode
|
||||
YAML: [Mercenary Mode YAML on GitHub](https://github.com/alwaysintreble/Archipelago-yaml-dump/blob/main/Snippets/Mercenary%20Mode%20Snippet.yaml)
|
||||
|
||||
For more information on plando you can reference the general plando guide or the Link to the Past plando guide.
|
||||
|
||||
General plando guide: [Archipelago Plando Guide](/tutorial/Archipelago/plando/en)
|
||||
|
||||
Link to the Past plando guide: [LttP Plando Guide](/tutorial/A%20Link%20to%20the%20Past/plando/en)
|
||||
For more information on plando, you can reference the [general plando guide](/tutorial/Archipelago/plando/en) or the
|
||||
[A Link to the Past plando guide](/tutorial/A%20Link%20to%20the%20Past/plando/en).
|
||||
|
||||
## Trigger use
|
||||
|
||||
Triggers may be defined in either the root or in the relevant game sections. Generally, The best place to do this is the
|
||||
bottom of the yaml for clear organization.
|
||||
Triggers may be defined in either the root or in the relevant game sections. Generally, the best place to do this is the
|
||||
bottom of the YAML for clear organization.
|
||||
|
||||
- Triggers comprise the trigger section and then each trigger must have an `option_category`, `option_name`, and
|
||||
`option_result` from which it will react to and then an `options` section for the definition of what will happen.
|
||||
- `option_category` is the defining section from which the option is defined in.
|
||||
Each trigger consists of four parts:
|
||||
- `option_category` specifies the section which the triggering option is defined in.
|
||||
- Example: `A Link to the Past`
|
||||
- This is the root category the option is located in. If the option you're triggering off of is in root then you
|
||||
- This is the category the option is located in. If the option you're triggering off of is in root then you
|
||||
would use `null`, otherwise this is the game for which you want this option trigger to activate.
|
||||
- `option_name` is the option setting from which the triggered choice is going to react to.
|
||||
- `option_name` specifies the name of the triggering option.
|
||||
- Example: `shop_item_slots`
|
||||
- This can be any option from any category defined in the yaml file in either root or a game section.
|
||||
- `option_result` is the result of this option setting from which you would like to react.
|
||||
- This can be any option from any category defined in the YAML file in either root or a game section.
|
||||
- `option_result` specifies the value of the option that activates this trigger.
|
||||
- Example: `15`
|
||||
- Each trigger must be used for exactly one option result. If you would like the same thing to occur with multiple
|
||||
results you would need multiple triggers for this.
|
||||
- `options` is where you define what will happen when this is detected. This can be something as simple as ensuring
|
||||
results, you would need multiple triggers for this.
|
||||
- `options` is where you define what will happen when the trigger activates. This can be something as simple as ensuring
|
||||
another option also gets selected or placing an item in a certain location. It is possible to have multiple things
|
||||
happen in this section.
|
||||
- Example:
|
||||
@@ -47,10 +42,10 @@ bottom of the yaml for clear organization.
|
||||
Rupees (300): 2
|
||||
```
|
||||
|
||||
This format must be:
|
||||
The general format is:
|
||||
|
||||
```yaml
|
||||
root option:
|
||||
category:
|
||||
option to change:
|
||||
desired result
|
||||
```
|
||||
@@ -70,8 +65,8 @@ The above examples all together will end up looking like this:
|
||||
Rupees(300): 2
|
||||
```
|
||||
|
||||
For this example if the generator happens to roll 15 shuffled in shop item slots for your game you'll be granted 600
|
||||
rupees at the beginning. These can also be used to change other options.
|
||||
For this example, if the generator happens to roll 15 shuffled in shop item slots for your game, you'll be granted 600
|
||||
rupees at the beginning. Triggers can also be used to change other options.
|
||||
|
||||
For example:
|
||||
|
||||
@@ -85,9 +80,9 @@ For example:
|
||||
Inverted: true
|
||||
```
|
||||
|
||||
In this example if your world happens to roll SpecificKeycards then your game will also start in inverted.
|
||||
In this example, if your world happens to roll SpecificKeycards, then your game will also start in inverted.
|
||||
|
||||
It is also possible to use imaginary names in options to trigger specific settings. You can use these made up names in
|
||||
It is also possible to use imaginary values in options to trigger specific settings. You can use these made-up values in
|
||||
either your main options or to trigger from another trigger. Currently, this is the only way to trigger on "setting 1
|
||||
AND setting 2".
|
||||
|
||||
@@ -97,33 +92,33 @@ For example:
|
||||
triggers:
|
||||
- option_category: Secret of Evermore
|
||||
option_name: doggomizer
|
||||
option_result: pupdunk
|
||||
options:
|
||||
Secret of Evermore:
|
||||
difficulty:
|
||||
normal: 50
|
||||
pupdunk_hard: 25
|
||||
pupdunk_mystery: 25
|
||||
exp_modifier:
|
||||
150: 50
|
||||
200: 50
|
||||
- option_category: Secret of Evermore
|
||||
option_name: difficulty
|
||||
option_result: pupdunk_hard
|
||||
options:
|
||||
Secret of Evermore:
|
||||
fix_wings_glitch: false
|
||||
difficulty: hard
|
||||
- option_category: Secret of Evermore
|
||||
option_name: difficulty
|
||||
option_result: pupdunk_mystery
|
||||
options:
|
||||
Secret of Evermore:
|
||||
fix_wings_glitch: false
|
||||
difficulty: mystery
|
||||
option_result: pupdunk
|
||||
options:
|
||||
Secret of Evermore:
|
||||
difficulty:
|
||||
normal: 50
|
||||
pupdunk_hard: 25
|
||||
pupdunk_mystery: 25
|
||||
exp_modifier:
|
||||
150: 50
|
||||
200: 50
|
||||
- option_category: Secret of Evermore
|
||||
option_name: difficulty
|
||||
option_result: pupdunk_hard
|
||||
options:
|
||||
Secret of Evermore:
|
||||
fix_wings_glitch: false
|
||||
difficulty: hard
|
||||
- option_category: Secret of Evermore
|
||||
option_name: difficulty
|
||||
option_result: pupdunk_mystery
|
||||
options:
|
||||
Secret of Evermore:
|
||||
fix_wings_glitch: false
|
||||
difficulty: mystery
|
||||
```
|
||||
|
||||
In this example (thanks to @Black-Sliver) if the `pupdunk` option is rolled then the difficulty values will be rolled
|
||||
In this example (thanks to @Black-Sliver), if the `pupdunk` option is rolled, then the difficulty values will be rolled
|
||||
again using the new options `normal`, `pupdunk_hard`, and `pupdunk_mystery`, and the exp modifier will be rerolled using
|
||||
new weights for 150 and 200. This allows for two more triggers that will only be used for the new `pupdunk_hard`
|
||||
and `pupdunk_mystery` options so that they will only be triggered on "pupdunk AND hard/mystery".
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user