Skip to content

Commit

Permalink
Merge branch 'main' into migrate-descriptions
Browse files Browse the repository at this point in the history
  • Loading branch information
ThePhar authored Dec 13, 2023
2 parents 439733b + 0eefe9e commit bb4126e
Show file tree
Hide file tree
Showing 97 changed files with 9,147 additions and 436 deletions.
15 changes: 11 additions & 4 deletions BaseClasses.py
Original file line number Diff line number Diff line change
Expand Up @@ -491,7 +491,7 @@ def has_beaten_game(self, state: CollectionState, player: Optional[int] = None)
else:
return all((self.has_beaten_game(state, p) for p in range(1, self.players + 1)))

def can_beat_game(self, starting_state: Optional[CollectionState] = None):
def can_beat_game(self, starting_state: Optional[CollectionState] = None) -> bool:
if starting_state:
if self.has_beaten_game(starting_state):
return True
Expand All @@ -504,7 +504,7 @@ def can_beat_game(self, starting_state: Optional[CollectionState] = None):
and location.item.advancement and location not in state.locations_checked}

while prog_locations:
sphere = set()
sphere: Set[Location] = set()
# build up spheres of collection radius.
# Everything in each sphere is independent from each other in dependencies and only depends on lower spheres
for location in prog_locations:
Expand All @@ -524,12 +524,19 @@ def can_beat_game(self, starting_state: Optional[CollectionState] = None):

return False

def get_spheres(self):
def get_spheres(self) -> Iterator[Set[Location]]:
"""
yields a set of locations for each logical sphere
If there are unreachable locations, the last sphere of reachable
locations is followed by an empty set, and then a set of all of the
unreachable locations.
"""
state = CollectionState(self)
locations = set(self.get_filled_locations())

while locations:
sphere = set()
sphere: Set[Location] = set()

for location in locations:
if location.can_reach(state):
Expand Down
41 changes: 20 additions & 21 deletions Fill.py
Original file line number Diff line number Diff line change
Expand Up @@ -550,36 +550,36 @@ def flood_items(world: MultiWorld) -> None:
break


def balance_multiworld_progression(world: MultiWorld) -> None:
def balance_multiworld_progression(multiworld: MultiWorld) -> None:
# A system to reduce situations where players have no checks remaining, popularly known as "BK mode."
# Overall progression balancing algorithm:
# Gather up all locations in a sphere.
# Define a threshold value based on the player with the most available locations.
# If other players are below the threshold value, swap progression in this sphere into earlier spheres,
# which gives more locations available by this sphere.
balanceable_players: typing.Dict[int, float] = {
player: world.worlds[player].options.progression_balancing / 100
for player in world.player_ids
if world.worlds[player].options.progression_balancing > 0
player: multiworld.worlds[player].options.progression_balancing / 100
for player in multiworld.player_ids
if multiworld.worlds[player].options.progression_balancing > 0
}
if not balanceable_players:
logging.info('Skipping multiworld progression balancing.')
else:
logging.info(f'Balancing multiworld progression for {len(balanceable_players)} Players.')
logging.debug(balanceable_players)
state: CollectionState = CollectionState(world)
state: CollectionState = CollectionState(multiworld)
checked_locations: typing.Set[Location] = set()
unchecked_locations: typing.Set[Location] = set(world.get_locations())
unchecked_locations: typing.Set[Location] = set(multiworld.get_locations())

total_locations_count: typing.Counter[int] = Counter(
location.player
for location in world.get_locations()
for location in multiworld.get_locations()
if not location.locked
)
reachable_locations_count: typing.Dict[int, int] = {
player: 0
for player in world.player_ids
if total_locations_count[player] and len(world.get_filled_locations(player)) != 0
for player in multiworld.player_ids
if total_locations_count[player] and len(multiworld.get_filled_locations(player)) != 0
}
balanceable_players = {
player: balanceable_players[player]
Expand Down Expand Up @@ -658,7 +658,7 @@ def item_percentage(player: int, num: int) -> float:
balancing_unchecked_locations.remove(location)
if not location.locked:
balancing_reachables[location.player] += 1
if world.has_beaten_game(balancing_state) or all(
if multiworld.has_beaten_game(balancing_state) or all(
item_percentage(player, reachables) >= threshold_percentages[player]
for player, reachables in balancing_reachables.items()
if player in threshold_percentages):
Expand All @@ -675,7 +675,7 @@ def item_percentage(player: int, num: int) -> float:
locations_to_test = unlocked_locations[player]
items_to_test = list(candidate_items[player])
items_to_test.sort()
world.random.shuffle(items_to_test)
multiworld.random.shuffle(items_to_test)
while items_to_test:
testing = items_to_test.pop()
reducing_state = state.copy()
Expand All @@ -687,42 +687,41 @@ def item_percentage(player: int, num: int) -> float:

reducing_state.sweep_for_events(locations=locations_to_test)

if world.has_beaten_game(balancing_state):
if not world.has_beaten_game(reducing_state):
if multiworld.has_beaten_game(balancing_state):
if not multiworld.has_beaten_game(reducing_state):
items_to_replace.append(testing)
else:
reduced_sphere = get_sphere_locations(reducing_state, locations_to_test)
p = item_percentage(player, reachable_locations_count[player] + len(reduced_sphere))
if p < threshold_percentages[player]:
items_to_replace.append(testing)

replaced_items = False
old_moved_item_count = moved_item_count

# sort then shuffle to maintain deterministic behaviour,
# while allowing use of set for better algorithm growth behaviour elsewhere
replacement_locations = sorted(l for l in checked_locations if not l.event and not l.locked)
world.random.shuffle(replacement_locations)
multiworld.random.shuffle(replacement_locations)
items_to_replace.sort()
world.random.shuffle(items_to_replace)
multiworld.random.shuffle(items_to_replace)

# Start swapping items. Since we swap into earlier spheres, no need for accessibility checks.
while replacement_locations and items_to_replace:
old_location = items_to_replace.pop()
for new_location in replacement_locations:
for i, new_location in enumerate(replacement_locations):
if new_location.can_fill(state, old_location.item, False) and \
old_location.can_fill(state, new_location.item, False):
replacement_locations.remove(new_location)
replacement_locations.pop(i)
swap_location_item(old_location, new_location)
logging.debug(f"Progression balancing moved {new_location.item} to {new_location}, "
f"displacing {old_location.item} into {old_location}")
moved_item_count += 1
state.collect(new_location.item, True, new_location)
replaced_items = True
break
else:
logging.warning(f"Could not Progression Balance {old_location.item}")

if replaced_items:
if old_moved_item_count < moved_item_count:
logging.debug(f"Moved {moved_item_count} items so far\n")
unlocked = {fresh for player in balancing_players for fresh in unlocked_locations[player]}
for location in get_sphere_locations(state, unlocked):
Expand All @@ -736,7 +735,7 @@ def item_percentage(player: int, num: int) -> float:
state.collect(location.item, True, location)
checked_locations |= sphere_locations

if world.has_beaten_game(state):
if multiworld.has_beaten_game(state):
break
elif not sphere_locations:
logging.warning("Progression Balancing ran out of paths.")
Expand Down
11 changes: 11 additions & 0 deletions Main.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,17 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
for item_name, count in world.start_inventory_from_pool.setdefault(player, StartInventoryPool({})).value.items():
for _ in range(count):
world.push_precollected(world.create_item(item_name, player))
# remove from_pool items also from early items handling, as starting is plenty early.
early = world.early_items[player].get(item_name, 0)
if early:
world.early_items[player][item_name] = max(0, early-count)
remaining_count = count-early
if remaining_count > 0:
local_early = world.early_local_items[player].get(item_name, 0)
if local_early:
world.early_items[player][item_name] = max(0, local_early - remaining_count)
del local_early
del early

logger.info('Creating World.')
AutoWorld.call_all(world, "create_regions")
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ Currently, the following games are supported:
* Shivers
* Heretic
* Landstalker: The Treasures of King Nole
* Final Fantasy Mystic Quest

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
Expand Down
59 changes: 31 additions & 28 deletions WebHostLib/check.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import os
import zipfile
import base64
from typing import Union, Dict, Set, Tuple
Expand All @@ -6,13 +7,7 @@
from markupsafe import Markup

from WebHostLib import app

banned_zip_contents = (".sfc",)


def allowed_file(filename):
return filename.endswith(('.txt', ".yaml", ".zip"))

from WebHostLib.upload import allowed_options, allowed_options_extensions, banned_file

from Generate import roll_settings, PlandoOptions
from Utils import parse_yamls
Expand Down Expand Up @@ -51,33 +46,41 @@ def mysterycheck():
def get_yaml_data(files) -> Union[Dict[str, str], str, Markup]:
options = {}
for uploaded_file in files:
# if user does not select file, browser also
# submit an empty part without filename
if uploaded_file.filename == '':
return 'No selected file'
if banned_file(uploaded_file.filename):
return ("Uploaded data contained a rom file, which is likely to contain copyrighted material. "
"Your file was deleted.")
# If the user does not select file, the browser will still submit an empty string without a file name.
elif uploaded_file.filename == "":
return "No selected file."
elif uploaded_file.filename in options:
return f'Conflicting files named {uploaded_file.filename} submitted'
elif uploaded_file and allowed_file(uploaded_file.filename):
return f"Conflicting files named {uploaded_file.filename} submitted."
elif uploaded_file and allowed_options(uploaded_file.filename):
if uploaded_file.filename.endswith(".zip"):

with zipfile.ZipFile(uploaded_file, 'r') as zfile:
infolist = zfile.infolist()

if any(file.filename.endswith(".archipelago") for file in infolist):
return Markup("Error: Your .zip file contains an .archipelago file. "
'Did you mean to <a href="/uploads">host a game</a>?')

for file in infolist:
if file.filename.endswith(banned_zip_contents):
return ("Uploaded data contained a rom file, "
"which is likely to contain copyrighted material. "
"Your file was deleted.")
elif file.filename.endswith((".yaml", ".json", ".yml", ".txt")):
if not zipfile.is_zipfile(uploaded_file):
return f"Uploaded file {uploaded_file.filename} is not a valid .zip file and cannot be opened."

uploaded_file.seek(0) # offset from is_zipfile check
with zipfile.ZipFile(uploaded_file, "r") as zfile:
for file in zfile.infolist():
# Remove folder pathing from str (e.g. "__MACOSX/" folder paths from archives created by macOS).
base_filename = os.path.basename(file.filename)

if base_filename.endswith(".archipelago"):
return Markup("Error: Your .zip file contains an .archipelago file. "
'Did you mean to <a href="/uploads">host a game</a>?')
elif base_filename.endswith(".zip"):
return "Nested .zip files inside a .zip are not supported."
elif banned_file(base_filename):
return ("Uploaded data contained a rom file, which is likely to contain copyrighted "
"material. Your file was deleted.")
# Ignore dot-files.
elif not base_filename.startswith(".") and allowed_options(base_filename):
options[file.filename] = zfile.open(file, "r").read()
else:
options[uploaded_file.filename] = uploaded_file.read()

if not options:
return "Did not find a .yaml file to process."
return f"Did not find any valid files to process. Accepted formats: {allowed_options_extensions}"
return options


Expand Down
6 changes: 6 additions & 0 deletions WebHostLib/customserver.py
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,12 @@ async def main():
ctx.auto_shutdown = Room.get(id=room_id).timeout
ctx.shutdown_task = asyncio.create_task(auto_shutdown(ctx, []))
await ctx.shutdown_task

# ensure auto launch is on the same page in regard to room activity.
with db_session:
room: Room = Room.get(id=ctx.room_id)
room.last_activity = datetime.datetime.utcnow() - datetime.timedelta(seconds=room.timeout + 60)

logging.info("Shutting down")

with Locker(room_id):
Expand Down
2 changes: 2 additions & 0 deletions WebHostLib/downloads.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,8 @@ def download_slot_file(room_id, player_id: int):
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}.json"
elif slot_data.game == "Kingdom Hearts 2":
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_P{slot_data.player_id}_{slot_data.player_name}.zip"
elif slot_data.game == "Final Fantasy Mystic Quest":
fname = f"AP+{app.jinja_env.filters['suuid'](room_id)}_P{slot_data.player_id}_{slot_data.player_name}.apmq"
else:
return "Game download not supported."
return send_file(io.BytesIO(slot_data.data), as_attachment=True, download_name=fname)
Expand Down
2 changes: 1 addition & 1 deletion WebHostLib/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,5 @@ Flask-Caching>=2.1.0
Flask-Compress>=1.14
Flask-Limiter>=3.5.0
bokeh>=3.1.1; python_version <= '3.8'
bokeh>=3.2.2; python_version >= '3.9'
bokeh>=3.3.2; python_version >= '3.9'
markupsafe>=2.1.3
2 changes: 1 addition & 1 deletion WebHostLib/static/assets/player-options.js
Original file line number Diff line number Diff line change
Expand Up @@ -369,7 +369,7 @@ const setPresets = (optionsData, presetName) => {
break;
}

case 'special_range': {
case 'named_range': {
const selectElement = document.querySelector(`select[data-key='${option}']`);
const rangeElement = document.querySelector(`input[data-key='${option}']`);
const randomElement = document.querySelector(`.randomize-button[data-key='${option}']`);
Expand Down
2 changes: 1 addition & 1 deletion WebHostLib/static/assets/weighted-options.js
Original file line number Diff line number Diff line change
Expand Up @@ -576,7 +576,7 @@ class GameSettings {
option = parseInt(option, 10);

let optionAcceptable = false;
if ((option > setting.min) && (option < setting.max)) {
if ((option >= setting.min) && (option <= setting.max)) {
optionAcceptable = true;
}
if (setting.hasOwnProperty('value_names') && Object.values(setting.value_names).includes(option)){
Expand Down
3 changes: 3 additions & 0 deletions WebHostLib/templates/macros.html
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@
{% elif patch.game == "Dark Souls III" %}
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
Download JSON File...</a>
{% elif patch.game == "Final Fantasy Mystic Quest" %}
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
Download APMQ File...</a>
{% else %}
No file to download for this game.
{% endif %}
Expand Down
2 changes: 1 addition & 1 deletion WebHostLib/templates/pageWrapper.html
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
{% with messages = get_flashed_messages() %}
{% if messages %}
<div>
{% for message in messages %}
{% for message in messages | unique %}
<div class="user-message">{{ message }}</div>
{% endfor %}
</div>
Expand Down
2 changes: 1 addition & 1 deletion WebHostLib/templates/supportedGames.html
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ <h2 class="collapse-toggle" data-game="{{ game_name }}">
{% endif %}
{% if world.web.options_page is string %}
<span class="link-spacer">|</span>
<a href="{{ world.web.settings_page }}">Options Page</a>
<a href="{{ world.web.options_page }}">Options Page</a>
{% elif world.web.options_page %}
<span class="link-spacer">|</span>
<a href="{{ url_for("player_options", game=game_name) }}">Options Page</a>
Expand Down
Loading

0 comments on commit bb4126e

Please sign in to comment.