Compare commits

..

5 Commits

Author SHA1 Message Date
Fabian Dill
62b3fd4d37 WebHost: invert multitracker control back to webhost 2023-08-28 17:18:13 +02:00
Fabian Dill
e2f7153312 Factorio: convert multitracker to Template once, instead of per render 2023-08-28 13:52:56 +02:00
Fabian Dill
96d4143030 WebHost: move new API hooks to WebWorld 2023-08-28 13:49:14 +02:00
Fabian Dill
a1dcaf52e3 WebHost: offer API to modify WebHost 2023-08-28 01:37:50 +02:00
Fabian Dill
aab8f31345 Factorio: fix website multitracker 2023-08-28 01:08:19 +02:00
32 changed files with 744 additions and 741 deletions

View File

@@ -853,6 +853,14 @@ class Region:
state.update_reachable_regions(self.player) state.update_reachable_regions(self.player)
return self in state.reachable_regions[self.player] return self in state.reachable_regions[self.player]
def can_reach_private(self, state: CollectionState) -> bool:
for entrance in self.entrances:
if entrance.can_reach(state):
if not self in state.path:
state.path[self] = (self.name, state.path.get(entrance, None))
return True
return False
@property @property
def hint_text(self) -> str: def hint_text(self) -> str:
return self._hint_text if self._hint_text else self.name return self._hint_text if self._hint_text else self.name

View File

@@ -23,7 +23,8 @@ from urllib.request import urlopen
import ModuleUpdate import ModuleUpdate
ModuleUpdate.update() ModuleUpdate.update()
from worlds.alttp.Rom import Sprite, LocalRom, apply_rom_settings, get_base_rom_bytes from worlds.alttp.Rom import LocalRom, apply_rom_settings, get_base_rom_bytes
from worlds.alttp.Sprites import Sprite
from Utils import output_path, local_path, user_path, open_file, get_cert_none_ssl_context, persistent_store, \ from Utils import output_path, local_path, user_path, open_file, get_cert_none_ssl_context, persistent_store, \
get_adjuster_settings, get_adjuster_settings_no_defaults, tkinter_center_window, init_logging get_adjuster_settings, get_adjuster_settings_no_defaults, tkinter_center_window, init_logging

View File

@@ -565,16 +565,14 @@ async def snes_write(ctx: SNIContext, write_list: typing.List[typing.Tuple[int,
PutAddress_Request: SNESRequest = {"Opcode": "PutAddress", "Operands": [], 'Space': 'SNES'} PutAddress_Request: SNESRequest = {"Opcode": "PutAddress", "Operands": [], 'Space': 'SNES'}
try: try:
for address, data in write_list: for address, data in write_list:
while data: PutAddress_Request['Operands'] = [hex(address)[2:], hex(len(data))[2:]]
# Divide the write into packets of 256 bytes. # REVIEW: above: `if snes_socket is None: return False`
PutAddress_Request['Operands'] = [hex(address)[2:], hex(min(len(data), 256))[2:]] # Does it need to be checked again?
if ctx.snes_socket is not None: if ctx.snes_socket is not None:
await ctx.snes_socket.send(dumps(PutAddress_Request)) await ctx.snes_socket.send(dumps(PutAddress_Request))
await ctx.snes_socket.send(data[:256]) await ctx.snes_socket.send(data)
address += 256 else:
data = data[256:] snes_logger.warning(f"Could not send data to SNES: {data}")
else:
snes_logger.warning(f"Could not send data to SNES: {data}")
except ConnectionClosed: except ConnectionClosed:
return False return False

View File

@@ -19,8 +19,8 @@ from waitress import serve
from WebHostLib.models import db from WebHostLib.models import db
from WebHostLib.autolauncher import autohost, autogen from WebHostLib.autolauncher import autohost, autogen
from WebHostLib.lttpsprites import update_sprites_lttp
from WebHostLib.options import create as create_options_files from WebHostLib.options import create as create_options_files
import worlds
settings.no_gui = True settings.no_gui = True
configpath = os.path.abspath("config.yaml") configpath = os.path.abspath("config.yaml")
@@ -42,6 +42,13 @@ def get_app():
db.bind(**app.config["PONY"]) db.bind(**app.config["PONY"])
db.generate_mapping(create_tables=True) db.generate_mapping(create_tables=True)
for world in worlds.AutoWorldRegister.world_types.values():
try:
world.web.run_webhost_app_setup(app)
except Exception as e:
logging.exception(e)
return app return app
@@ -120,12 +127,17 @@ if __name__ == "__main__":
multiprocessing.freeze_support() multiprocessing.freeze_support()
multiprocessing.set_start_method('spawn') multiprocessing.set_start_method('spawn')
logging.basicConfig(format='[%(asctime)s] %(message)s', level=logging.INFO) logging.basicConfig(format='[%(asctime)s] %(message)s', level=logging.INFO)
try:
update_sprites_lttp() for world in worlds.AutoWorldRegister.world_types.values():
except Exception as e: try:
logging.exception(e) world.web.run_webhost_setup()
logging.warning("Could not update LttP sprites.") except Exception as e:
logging.exception(e)
app = get_app() app = get_app()
del world, worlds
create_options_files() create_options_files()
create_ordered_tutorials_file() create_ordered_tutorials_file()
if app.config["SELFLAUNCH"]: if app.config["SELFLAUNCH"]:

View File

@@ -1,50 +0,0 @@
import os
import threading
import json
from Utils import local_path, user_path
from worlds.alttp.Rom import Sprite
def update_sprites_lttp():
from tkinter import Tk
from LttPAdjuster import get_image_for_sprite
from LttPAdjuster import BackgroundTaskProgress
from LttPAdjuster import BackgroundTaskProgressNullWindow
from LttPAdjuster import update_sprites
# Target directories
input_dir = user_path("data", "sprites", "alttpr")
output_dir = local_path("WebHostLib", "static", "generated") # TODO: move to user_path
os.makedirs(os.path.join(output_dir, "sprites"), exist_ok=True)
# update sprites through gui.py's functions
done = threading.Event()
try:
top = Tk()
except:
task = BackgroundTaskProgressNullWindow(update_sprites, lambda successful, resultmessage: done.set())
else:
top.withdraw()
task = BackgroundTaskProgress(top, update_sprites, "Updating Sprites", lambda succesful, resultmessage: done.set())
while not done.isSet():
task.do_events()
spriteData = []
for file in (file for file in os.listdir(input_dir) if not file.startswith(".")):
sprite = Sprite(os.path.join(input_dir, file))
if not sprite.name:
print("Warning:", file, "has no name.")
sprite.name = file.split(".", 1)[0]
if sprite.valid:
with open(os.path.join(output_dir, "sprites", f"{os.path.splitext(file)[0]}.gif"), 'wb') as image:
image.write(get_image_for_sprite(sprite, True))
spriteData.append({"file": file, "author": sprite.author_name, "name": sprite.name})
else:
print(file, "dropped, as it has no valid sprite data.")
spriteData.sort(key=lambda entry: entry["name"])
with open(f'{output_dir}/spriteData.json', 'w') as file:
json.dump({"sprites": spriteData}, file, indent=1)
return spriteData

View File

@@ -14,17 +14,6 @@ const adjustTableHeight = () => {
} }
}; };
/**
* Convert an integer number of seconds into a human readable HH:MM format
* @param {Number} seconds
* @returns {string}
*/
const secondsToHours = (seconds) => {
let hours = Math.floor(seconds / 3600);
let minutes = Math.floor((seconds - (hours * 3600)) / 60).toString().padStart(2, '0');
return `${hours}:${minutes}`;
};
window.addEventListener('load', () => { window.addEventListener('load', () => {
const tables = $(".table").DataTable({ const tables = $(".table").DataTable({
paging: false, paging: false,
@@ -38,18 +27,7 @@ window.addEventListener('load', () => {
stateLoadCallback: function(settings) { stateLoadCallback: function(settings) {
return JSON.parse(localStorage.getItem(`DataTables_${settings.sInstance}_/tracker`)); return JSON.parse(localStorage.getItem(`DataTables_${settings.sInstance}_/tracker`));
}, },
footerCallback: function(tfoot, data, start, end, display) {
if (tfoot) {
const activityData = this.api().column('lastActivity:name').data().toArray().filter(x => !isNaN(x));
Array.from(tfoot?.children).find(td => td.classList.contains('last-activity')).innerText =
(activityData.length) ? secondsToHours(Math.min(...activityData)) : 'None';
}
},
columnDefs: [ columnDefs: [
{
targets: 'last-activity',
name: 'lastActivity'
},
{ {
targets: 'hours', targets: 'hours',
render: function (data, type, row) { render: function (data, type, row) {
@@ -62,7 +40,11 @@ window.addEventListener('load', () => {
if (data === "None") if (data === "None")
return data; return data;
return secondsToHours(data); let hours = Math.floor(data / 3600);
let minutes = Math.floor((data - (hours * 3600)) / 60);
if (minutes < 10) {minutes = "0"+minutes;}
return hours+':'+minutes;
} }
}, },
{ {
@@ -132,16 +114,11 @@ window.addEventListener('load', () => {
if (status === "success") { if (status === "success") {
target.find(".table").each(function (i, new_table) { target.find(".table").each(function (i, new_table) {
const new_trs = $(new_table).find("tbody>tr"); const new_trs = $(new_table).find("tbody>tr");
const footer_tr = $(new_table).find("tfoot>tr");
const old_table = tables.eq(i); const old_table = tables.eq(i);
const topscroll = $(old_table.settings()[0].nScrollBody).scrollTop(); const topscroll = $(old_table.settings()[0].nScrollBody).scrollTop();
const leftscroll = $(old_table.settings()[0].nScrollBody).scrollLeft(); const leftscroll = $(old_table.settings()[0].nScrollBody).scrollLeft();
old_table.clear(); old_table.clear();
if (footer_tr.length) { old_table.rows.add(new_trs).draw();
$(old_table.table).find("tfoot").html(footer_tr);
}
old_table.rows.add(new_trs);
old_table.draw();
$(old_table.settings()[0].nScrollBody).scrollTop(topscroll); $(old_table.settings()[0].nScrollBody).scrollTop(topscroll);
$(old_table.settings()[0].nScrollBody).scrollLeft(leftscroll); $(old_table.settings()[0].nScrollBody).scrollLeft(leftscroll);
}); });

View File

@@ -55,16 +55,16 @@ table.dataTable thead{
font-family: LexendDeca-Regular, sans-serif; font-family: LexendDeca-Regular, sans-serif;
} }
table.dataTable tbody, table.dataTable tfoot{ table.dataTable tbody{
background-color: #dce2bd; background-color: #dce2bd;
font-family: LexendDeca-Light, sans-serif; font-family: LexendDeca-Light, sans-serif;
} }
table.dataTable tbody tr:hover, table.dataTable tfoot tr:hover{ table.dataTable tbody tr:hover{
background-color: #e2eabb; background-color: #e2eabb;
} }
table.dataTable tbody td, table.dataTable tfoot td{ table.dataTable tbody td{
padding: 4px 6px; padding: 4px 6px;
} }
@@ -97,14 +97,10 @@ table.dataTable thead th.lower-row{
top: 46px; top: 46px;
} }
table.dataTable tbody td, table.dataTable tfoot td{ table.dataTable tbody td{
border: 1px solid #bba967; border: 1px solid #bba967;
} }
table.dataTable tfoot td{
font-weight: bold;
}
div.dataTables_scrollBody{ div.dataTables_scrollBody{
background-color: inherit !important; background-color: inherit !important;
} }

View File

@@ -37,7 +37,7 @@
{% endblock %} {% endblock %}
<th class="center-column">Checks</th> <th class="center-column">Checks</th>
<th class="center-column">&percnt;</th> <th class="center-column">&percnt;</th>
<th class="center-column hours last-activity">Last<br>Activity</th> <th class="center-column hours">Last<br>Activity</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -64,19 +64,6 @@
</tr> </tr>
{%- endfor -%} {%- endfor -%}
</tbody> </tbody>
{% if not self.custom_table_headers() | trim %}
<tfoot>
<tr>
<td></td>
<td>Total</td>
<td>All Games</td>
<td>{{ completed_worlds }}/{{ players|length }} Complete</td>
<td class="center-column">{{ players.values()|sum(attribute='Total') }}/{{ total_locations[team] }}</td>
<td class="center-column">{{ (players.values()|sum(attribute='Total') / total_locations[team] * 100) | int }}</td>
<td class="center-column last-activity"></td>
</tr>
</tfoot>
{% endif %}
</table> </table>
</div> </div>
{% endfor %} {% endfor %}

View File

@@ -1,7 +1,7 @@
{%- if enabled_multiworld_trackers|length > 1 -%} {%- if enabled_multiworld_trackers|length > 1 -%}
<div id="tracker-navigation"> <div id="tracker-navigation">
{% for enabled_tracker in enabled_multiworld_trackers %} {% for enabled_tracker in enabled_multiworld_trackers %}
{% set tracker_url = url_for(enabled_tracker.endpoint, tracker=room.tracker) %} {% set tracker_url = url_for(enabled_tracker.endpoint, tracker=room.tracker, game=enabled_tracker.name) %}
<a class="tracker-navigation-button{% if enabled_tracker.current %} selected{% endif %}" <a class="tracker-navigation-button{% if enabled_tracker.current %} selected{% endif %}"
href="{{ tracker_url }}">{{ enabled_tracker.name }}</a> href="{{ tracker_url }}">{{ enabled_tracker.name }}</a>
{% endfor %} {% endfor %}

View File

@@ -1,17 +1,18 @@
import collections import collections
import datetime import datetime
import typing import typing
import pkgutil
from typing import Counter, Optional, Dict, Any, Tuple, List from typing import Counter, Optional, Dict, Any, Tuple, List
from uuid import UUID from uuid import UUID
from flask import render_template from flask import render_template
from jinja2 import pass_context, runtime from jinja2 import pass_context, runtime, Template
from werkzeug.exceptions import abort from werkzeug.exceptions import abort
from MultiServer import Context, get_saving_second from MultiServer import Context, get_saving_second
from NetUtils import SlotType, NetworkSlot from NetUtils import SlotType, NetworkSlot
from Utils import restricted_loads from Utils import restricted_loads
from worlds import lookup_any_item_id_to_name, lookup_any_location_id_to_name, network_data_package from worlds import lookup_any_item_id_to_name, lookup_any_location_id_to_name, network_data_package, AutoWorldRegister
from worlds.alttp import Items from worlds.alttp import Items
from . import app, cache from . import app, cache
from .models import GameDataPackage, Room from .models import GameDataPackage, Room
@@ -1331,98 +1332,6 @@ def __renderGenericTracker(multisave: Dict[str, Any], room: Room, locations: Dic
custom_items=custom_items, custom_locations=custom_locations) custom_items=custom_items, custom_locations=custom_locations)
def get_enabled_multiworld_trackers(room: Room, current: str):
enabled = [
{
"name": "Generic",
"endpoint": "get_multiworld_tracker",
"current": current == "Generic"
}
]
for game_name, endpoint in multi_trackers.items():
if any(slot.game == game_name for slot in room.seed.slots) or current == game_name:
enabled.append({
"name": game_name,
"endpoint": endpoint.__name__,
"current": current == game_name}
)
return enabled
def _get_multiworld_tracker_data(tracker: UUID) -> typing.Optional[typing.Dict[str, typing.Any]]:
room: Room = Room.get(tracker=tracker)
if not room:
return None
locations, names, use_door_tracker, checks_in_area, player_location_to_area, \
precollected_items, games, slot_data, groups, saving_second, custom_locations, custom_items = \
get_static_room_data(room)
checks_done = {teamnumber: {playernumber: {loc_name: 0 for loc_name in default_locations}
for playernumber in range(1, len(team) + 1) if playernumber not in groups}
for teamnumber, team in enumerate(names)}
percent_total_checks_done = {teamnumber: {playernumber: 0
for playernumber in range(1, len(team) + 1) if playernumber not in groups}
for teamnumber, team in enumerate(names)}
total_locations = {teamnumber: sum(len(locations[playernumber])
for playernumber in range(1, len(team) + 1) if playernumber not in groups)
for teamnumber, team in enumerate(names)}
hints = {team: set() for team in range(len(names))}
if room.multisave:
multisave = restricted_loads(room.multisave)
else:
multisave = {}
if "hints" in multisave:
for (team, slot), slot_hints in multisave["hints"].items():
hints[team] |= set(slot_hints)
for (team, player), locations_checked in multisave.get("location_checks", {}).items():
if player in groups:
continue
player_locations = locations[player]
checks_done[team][player]["Total"] = len(locations_checked)
percent_total_checks_done[team][player] = int(checks_done[team][player]["Total"] /
len(player_locations) * 100) \
if player_locations else 100
activity_timers = {}
now = datetime.datetime.utcnow()
for (team, player), timestamp in multisave.get("client_activity_timers", []):
activity_timers[team, player] = now - datetime.datetime.utcfromtimestamp(timestamp)
player_names = {}
completed_worlds = 0
states: typing.Dict[typing.Tuple[int, int], int] = {}
for team, names in enumerate(names):
for player, name in enumerate(names, 1):
player_names[team, player] = name
states[team, player] = multisave.get("client_game_state", {}).get((team, player), 0)
if states[team, player] == 30: # Goal Completed
completed_worlds += 1
long_player_names = player_names.copy()
for (team, player), alias in multisave.get("name_aliases", {}).items():
player_names[team, player] = alias
long_player_names[(team, player)] = f"{alias} ({long_player_names[team, player]})"
video = {}
for (team, player), data in multisave.get("video", []):
video[team, player] = data
return dict(
player_names=player_names, room=room, checks_done=checks_done,
percent_total_checks_done=percent_total_checks_done, checks_in_area=checks_in_area,
activity_timers=activity_timers, video=video, hints=hints,
long_player_names=long_player_names,
multisave=multisave, precollected_items=precollected_items, groups=groups,
locations=locations, total_locations=total_locations, games=games, states=states,
completed_worlds=completed_worlds,
custom_locations=custom_locations, custom_items=custom_items,
)
def _get_inventory_data(data: typing.Dict[str, typing.Any]) -> typing.Dict[int, typing.Dict[int, int]]: def _get_inventory_data(data: typing.Dict[str, typing.Any]) -> typing.Dict[int, typing.Dict[int, int]]:
inventory = {teamnumber: {playernumber: collections.Counter() for playernumber in team_data} inventory = {teamnumber: {playernumber: collections.Counter() for playernumber in team_data}
for teamnumber, team_data in data["checks_done"].items()} for teamnumber, team_data in data["checks_done"].items()}
@@ -1443,32 +1352,6 @@ def _get_inventory_data(data: typing.Dict[str, typing.Any]) -> typing.Dict[int,
inventory[team][recipient][item_id] += 1 inventory[team][recipient][item_id] += 1
return inventory return inventory
@app.route('/tracker/<suuid:tracker>')
@cache.memoize(timeout=60) # multisave is currently created at most every minute
def get_multiworld_tracker(tracker: UUID):
data = _get_multiworld_tracker_data(tracker)
if not data:
abort(404)
data["enabled_multiworld_trackers"] = get_enabled_multiworld_trackers(data["room"], "Generic")
return render_template("multiTracker.html", **data)
@app.route('/tracker/<suuid:tracker>/Factorio')
@cache.memoize(timeout=60) # multisave is currently created at most every minute
def get_Factorio_multiworld_tracker(tracker: UUID):
data = _get_multiworld_tracker_data(tracker)
if not data:
abort(404)
data["inventory"] = _get_inventory_data(data)
data["enabled_multiworld_trackers"] = get_enabled_multiworld_trackers(data["room"], "Factorio")
return render_template("multiFactorioTracker.html", **data)
@app.route('/tracker/<suuid:tracker>/A Link to the Past') @app.route('/tracker/<suuid:tracker>/A Link to the Past')
@cache.memoize(timeout=60) # multisave is currently created at most every minute @cache.memoize(timeout=60) # multisave is currently created at most every minute
def get_LttP_multiworld_tracker(tracker: UUID): def get_LttP_multiworld_tracker(tracker: UUID):
@@ -1594,7 +1477,142 @@ game_specific_trackers: typing.Dict[str, typing.Callable] = {
"Starcraft 2 Wings of Liberty": __renderSC2WoLTracker "Starcraft 2 Wings of Liberty": __renderSC2WoLTracker
} }
# MultiTrackers
@app.route('/tracker/<suuid:tracker>')
@cache.memoize(timeout=60) # multisave is currently created at most every minute
def get_multiworld_tracker(tracker: UUID) -> str:
data = _get_multiworld_tracker_data(tracker)
if not data:
abort(404)
data["enabled_multiworld_trackers"] = get_enabled_multiworld_trackers(data["room"], "Generic")
return render_template("multiTracker.html", **data)
def get_enabled_multiworld_trackers(room: Room, current: str) -> typing.List[typing.Dict[str, typing.Any]]:
enabled = [
{
"name": "Generic",
"endpoint": "get_multiworld_tracker",
"current": current == "Generic"
}
]
for game_name, endpoint in multi_trackers.items():
if any(slot.game == game_name for slot in room.seed.slots) or current == game_name:
enabled.append({
"name": game_name,
"endpoint": endpoint.__name__,
"current": current == game_name}
)
return enabled
def _get_multiworld_tracker_data(tracker: UUID) -> typing.Optional[typing.Dict[str, typing.Any]]:
room: Room = Room.get(tracker=tracker)
if not room:
return None
locations, names, use_door_tracker, checks_in_area, player_location_to_area, \
precollected_items, games, slot_data, groups, saving_second, custom_locations, custom_items = \
get_static_room_data(room)
checks_done = {teamnumber: {playernumber: {loc_name: 0 for loc_name in default_locations}
for playernumber in range(1, len(team) + 1) if playernumber not in groups}
for teamnumber, team in enumerate(names)}
percent_total_checks_done = {teamnumber: {playernumber: 0
for playernumber in range(1, len(team) + 1) if playernumber not in groups}
for teamnumber, team in enumerate(names)}
hints = {team: set() for team in range(len(names))}
if room.multisave:
multisave = restricted_loads(room.multisave)
else:
multisave = {}
if "hints" in multisave:
for (team, slot), slot_hints in multisave["hints"].items():
hints[team] |= set(slot_hints)
for (team, player), locations_checked in multisave.get("location_checks", {}).items():
if player in groups:
continue
player_locations = locations[player]
checks_done[team][player]["Total"] = len(locations_checked)
percent_total_checks_done[team][player] = int(checks_done[team][player]["Total"] /
len(player_locations) * 100) \
if player_locations else 100
activity_timers = {}
now = datetime.datetime.utcnow()
for (team, player), timestamp in multisave.get("client_activity_timers", []):
activity_timers[team, player] = now - datetime.datetime.utcfromtimestamp(timestamp)
player_names = {}
states: typing.Dict[typing.Tuple[int, int], int] = {}
for team, names in enumerate(names):
for player, name in enumerate(names, 1):
player_names[team, player] = name
states[team, player] = multisave.get("client_game_state", {}).get((team, player), 0)
long_player_names = player_names.copy()
for (team, player), alias in multisave.get("name_aliases", {}).items():
player_names[team, player] = alias
long_player_names[(team, player)] = f"{alias} ({long_player_names[team, player]})"
video = {}
for (team, player), data in multisave.get("video", []):
video[team, player] = data
return dict(
player_names=player_names, room=room, checks_done=checks_done,
percent_total_checks_done=percent_total_checks_done, checks_in_area=checks_in_area,
activity_timers=activity_timers, video=video, hints=hints,
long_player_names=long_player_names,
multisave=multisave, precollected_items=precollected_items, groups=groups,
locations=locations, games=games, states=states,
custom_locations=custom_locations, custom_items=custom_items,
)
multi_trackers: typing.Dict[str, typing.Callable] = { multi_trackers: typing.Dict[str, typing.Callable] = {
"A Link to the Past": get_LttP_multiworld_tracker, "A Link to the Past": get_LttP_multiworld_tracker,
"Factorio": get_Factorio_multiworld_tracker,
} }
class MultiTrackerData(typing.NamedTuple):
template: Template
item_name_to_id: typing.Dict[str, int]
location_name_to_id: typing.Dict[str, int]
multi_tracker_data: typing.Dict[str, MultiTrackerData] = {}
@app.route("/tracker/<suuid:tracker>/<game>")
@cache.memoize(timeout=60) # multisave is currently created up to every minute
def get_game_multiworld_tracker(tracker: UUID, game: str) -> str:
current_multi_tracker_data = multi_tracker_data.get(game, None)
if not current_multi_tracker_data:
abort(404)
data = _get_multiworld_tracker_data(tracker)
if not data:
abort(404)
data["inventory"] = _get_inventory_data(data)
data["enabled_multiworld_trackers"] = get_enabled_multiworld_trackers(data["room"], game)
data["item_name_to_id"] = current_multi_tracker_data.item_name_to_id
data["location_name_to_id"] = current_multi_tracker_data.location_name_to_id
return render_template(current_multi_tracker_data.template, **data)
def register_multitrackers() -> None:
for world in AutoWorldRegister.world_types.values():
multitracker = world.web.multitracker_template
if multitracker:
multitracker_template = pkgutil.get_data(world.__module__, multitracker).decode()
multitracker_template = app.jinja_env.from_string(multitracker_template)
multi_trackers[world.game] = get_game_multiworld_tracker
multi_tracker_data[world.game] = MultiTrackerData(
multitracker_template,
world.item_name_to_id,
world.location_name_to_id,
)
register_multitrackers()

View File

@@ -116,7 +116,6 @@ Source: "{#source_path}\SNI\*"; Excludes: "*.sfc, *.log"; DestDir: "{app}\SNI";
Source: "{#source_path}\EnemizerCLI\*"; Excludes: "*.sfc, *.log"; DestDir: "{app}\EnemizerCLI"; Flags: ignoreversion recursesubdirs createallsubdirs; Components: generator/lttp Source: "{#source_path}\EnemizerCLI\*"; Excludes: "*.sfc, *.log"; DestDir: "{app}\EnemizerCLI"; Flags: ignoreversion recursesubdirs createallsubdirs; Components: generator/lttp
Source: "{#source_path}\ArchipelagoLauncher.exe"; DestDir: "{app}"; Flags: ignoreversion; Source: "{#source_path}\ArchipelagoLauncher.exe"; DestDir: "{app}"; Flags: ignoreversion;
Source: "{#source_path}\ArchipelagoLauncher(DEBUG).exe"; DestDir: "{app}"; Flags: ignoreversion;
Source: "{#source_path}\ArchipelagoGenerate.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: generator Source: "{#source_path}\ArchipelagoGenerate.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: generator
Source: "{#source_path}\ArchipelagoServer.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: server Source: "{#source_path}\ArchipelagoServer.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: server
Source: "{#source_path}\ArchipelagoFactorioClient.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/factorio Source: "{#source_path}\ArchipelagoFactorioClient.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/factorio

View File

@@ -185,22 +185,13 @@ def resolve_icon(icon_name: str):
exes = [ exes = [
cx_Freeze.Executable( cx_Freeze.Executable(
script=f"{c.script_name}.py", script=f'{c.script_name}.py',
target_name=c.frozen_name + (".exe" if is_windows else ""), target_name=c.frozen_name + (".exe" if is_windows else ""),
icon=resolve_icon(c.icon), icon=resolve_icon(c.icon),
base="Win32GUI" if is_windows and not c.cli else None base="Win32GUI" if is_windows and not c.cli else None
) for c in components if c.script_name and c.frozen_name ) for c in components if c.script_name and c.frozen_name
] ]
if is_windows:
# create a duplicate Launcher for Windows, which has a working stdout/stderr, for debugging and --help
c = next(component for component in components if component.script_name == "Launcher")
exes.append(cx_Freeze.Executable(
script=f"{c.script_name}.py",
target_name=f"{c.frozen_name}(DEBUG).exe",
icon=resolve_icon(c.icon),
))
extra_data = ["LICENSE", "data", "EnemizerCLI", "SNI"] extra_data = ["LICENSE", "data", "EnemizerCLI", "SNI"]
extra_libs = ["libssl.so", "libcrypto.so"] if is_linux else [] extra_libs = ["libssl.so", "libcrypto.so"] if is_linux else []

View File

@@ -15,6 +15,7 @@ if TYPE_CHECKING:
from BaseClasses import MultiWorld, Item, Location, Tutorial from BaseClasses import MultiWorld, Item, Location, Tutorial
from . import GamesPackage from . import GamesPackage
from settings import Group from settings import Group
from flask import Flask
class AutoWorldRegister(type): class AutoWorldRegister(type):
@@ -155,9 +156,22 @@ class WebWorld:
"""Choose a theme for you /game/* pages. """Choose a theme for you /game/* pages.
Available: dirt, grass, grassFlowers, ice, jungle, ocean, partyTime, stone""" Available: dirt, grass, grassFlowers, ice, jungle, ocean, partyTime, stone"""
bug_report_page: Optional[str] bug_report_page: Optional[str] = None
"""display a link to a bug report page, most likely a link to a GitHub issue page.""" """display a link to a bug report page, most likely a link to a GitHub issue page."""
multitracker_template: Optional[str] = None
"""relative path with /-seperator to a MultiTracker Template file."""
# allows modification of webhost during startup, this is run once
@classmethod
def run_webhost_setup(cls):
pass
# allows modification of webhost during startup,
# this is run whenever a Flask app is created (per-thread/per-process)
@classmethod
def run_webhost_app_setup(cls, app: "Flask"):
pass
class World(metaclass=AutoWorldRegister): class World(metaclass=AutoWorldRegister):
"""A World object encompasses a game's Items, Locations, Rules and additional data or functionality required. """A World object encompasses a game's Items, Locations, Rules and additional data or functionality required.
@@ -412,7 +426,6 @@ class World(metaclass=AutoWorldRegister):
res["checksum"] = data_package_checksum(res) res["checksum"] = data_package_checksum(res)
return res return res
# any methods attached to this can be used as part of CollectionState, # any methods attached to this can be used as part of CollectionState,
# please use a prefix as all of them get clobbered together # please use a prefix as all of them get clobbered together
class LogicMixin(metaclass=AutoLogicRegister): class LogicMixin(metaclass=AutoLogicRegister):

View File

@@ -7,21 +7,19 @@ LTTPJPN10HASH: str = "03a63945398191337e896e5771f77173"
RANDOMIZERBASEHASH: str = "9952c2a3ec1b421e408df0d20c8f0c7f" RANDOMIZERBASEHASH: str = "9952c2a3ec1b421e408df0d20c8f0c7f"
ROM_PLAYER_LIMIT: int = 255 ROM_PLAYER_LIMIT: int = 255
import io
import json import json
import hashlib import hashlib
import logging import logging
import os import os
import random import random
import struct
import subprocess import subprocess
import threading import threading
import concurrent.futures import concurrent.futures
import bsdiff4 import bsdiff4
from typing import Optional, List from typing import List
from BaseClasses import CollectionState, Region, Location, MultiWorld from BaseClasses import CollectionState, Region, Location, MultiWorld
from Utils import local_path, user_path, int16_as_bytes, int32_as_bytes, snes_to_pc, is_frozen, parse_yaml, read_snes_rom from Utils import local_path, user_path, int16_as_bytes, int32_as_bytes, snes_to_pc, is_frozen, read_snes_rom
from .Shops import ShopType, ShopPriceType from .Shops import ShopType, ShopPriceType
from .Dungeons import dungeon_music_addresses from .Dungeons import dungeon_music_addresses
@@ -37,6 +35,7 @@ from .Text import KingsReturn_texts, Sanctuary_texts, Kakariko_texts, Blacksmith
from .Items import ItemFactory, item_table, item_name_groups, progression_items from .Items import ItemFactory, item_table, item_name_groups, progression_items
from .EntranceShuffle import door_addresses from .EntranceShuffle import door_addresses
from .Options import smallkey_shuffle from .Options import smallkey_shuffle
from .Sprites import apply_random_sprite_on_event
try: try:
from maseya import z3pr from maseya import z3pr
@@ -212,73 +211,6 @@ def check_enemizer(enemizercli):
check_enemizer.done = True check_enemizer.done = True
def apply_random_sprite_on_event(rom: LocalRom, sprite, local_random, allow_random_on_event, sprite_pool):
userandomsprites = False
if sprite and not isinstance(sprite, Sprite):
sprite = sprite.lower()
userandomsprites = sprite.startswith('randomon')
racerom = rom.read_byte(0x180213)
if allow_random_on_event or not racerom:
# Changes to this byte for race rom seeds are only permitted on initial rolling of the seed.
# However, if the seed is not a racerom seed, then it is always allowed.
rom.write_byte(0x186381, 0x00 if userandomsprites else 0x01)
onevent = 0
if sprite == 'randomonall':
onevent = 0xFFFF # Support all current and future events that can cause random sprite changes.
elif sprite == 'randomonnone':
# Allows for opting into random on events on race rom seeds, without actually enabling any of the events initially.
onevent = 0x0000
elif sprite == 'randomonrandom':
# Allows random to take the wheel on which events apply. (at least one event will be applied.)
onevent = local_random.randint(0x0001, 0x003F)
elif userandomsprites:
onevent = 0x01 if 'hit' in sprite else 0x00
onevent += 0x02 if 'enter' in sprite else 0x00
onevent += 0x04 if 'exit' in sprite else 0x00
onevent += 0x08 if 'slash' in sprite else 0x00
onevent += 0x10 if 'item' in sprite else 0x00
onevent += 0x20 if 'bonk' in sprite else 0x00
rom.write_int16(0x18637F, onevent)
sprite = Sprite(sprite) if os.path.isfile(sprite) else Sprite.get_sprite_from_name(sprite, local_random)
# write link sprite if required
if sprite:
sprites = list()
sprite.write_to_rom(rom)
_populate_sprite_table()
if userandomsprites:
if sprite_pool:
if isinstance(sprite_pool, str):
sprite_pool = sprite_pool.split(':')
for spritename in sprite_pool:
sprite = Sprite(spritename) if os.path.isfile(spritename) else Sprite.get_sprite_from_name(
spritename, local_random)
if sprite:
sprites.append(sprite)
else:
logging.info(f"Sprite {spritename} was not found.")
else:
sprites = list(set(_sprite_table.values())) # convert to list and remove dupes
else:
sprites.append(sprite)
if sprites:
while len(sprites) < 32:
sprites.extend(sprites)
local_random.shuffle(sprites)
for i, sprite in enumerate(sprites[:32]):
if not i and not userandomsprites:
continue
rom.write_bytes(0x300000 + (i * 0x8000), sprite.sprite)
rom.write_bytes(0x307000 + (i * 0x8000), sprite.palette)
rom.write_bytes(0x307078 + (i * 0x8000), sprite.glove_palette)
def patch_enemizer(world, rom: LocalRom, enemizercli, output_directory): def patch_enemizer(world, rom: LocalRom, enemizercli, output_directory):
player = world.player player = world.player
multiworld = world.multiworld multiworld = world.multiworld
@@ -487,271 +419,6 @@ class TileSet:
return localrandom.choice(tile_sets) return localrandom.choice(tile_sets)
sprite_list_lock = threading.Lock()
_sprite_table = {}
def _populate_sprite_table():
with sprite_list_lock:
if not _sprite_table:
def load_sprite_from_file(file):
sprite = Sprite(file)
if sprite.valid:
_sprite_table[sprite.name.lower()] = sprite
_sprite_table[os.path.basename(file).split(".")[0].lower()] = sprite # alias for filename base
else:
logging.debug(f"Spritefile {file} could not be loaded as a valid sprite.")
with concurrent.futures.ThreadPoolExecutor() as pool:
for dir in [user_path('data', 'sprites', 'alttpr'), user_path('data', 'sprites', 'custom')]:
for file in os.listdir(dir):
pool.submit(load_sprite_from_file, os.path.join(dir, file))
class Sprite():
sprite_size = 28672
palette_size = 120
glove_size = 4
author_name: Optional[str] = None
base_data: bytes
def __init__(self, filename):
if not hasattr(Sprite, "base_data"):
self.get_vanilla_sprite_data()
with open(filename, 'rb') as file:
filedata = file.read()
self.name = os.path.basename(filename)
self.valid = True
if filename.endswith(".apsprite"):
self.from_ap_sprite(filedata)
elif len(filedata) == 0x7000:
# sprite file with graphics and without palette data
self.sprite = filedata[:0x7000]
elif len(filedata) == 0x7078:
# sprite file with graphics and palette data
self.sprite = filedata[:0x7000]
self.palette = filedata[0x7000:]
self.glove_palette = filedata[0x7036:0x7038] + filedata[0x7054:0x7056]
elif len(filedata) == 0x707C:
# sprite file with graphics and palette data including gloves
self.sprite = filedata[:0x7000]
self.palette = filedata[0x7000:0x7078]
self.glove_palette = filedata[0x7078:]
elif len(filedata) in [0x100000, 0x200000, 0x400000]:
# full rom with patched sprite, extract it
self.sprite = filedata[0x80000:0x87000]
self.palette = filedata[0xDD308:0xDD380]
self.glove_palette = filedata[0xDEDF5:0xDEDF9]
elif filedata.startswith(b'ZSPR'):
self.from_zspr(filedata, filename)
else:
self.valid = False
def get_vanilla_sprite_data(self):
file_name = get_base_rom_path()
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]
Sprite.base_data = Sprite.sprite + Sprite.palette + Sprite.glove_palette
def from_ap_sprite(self, filedata):
# noinspection PyBroadException
try:
obj = parse_yaml(filedata.decode("utf-8-sig"))
if obj["min_format_version"] > 1:
raise Exception("Sprite file requires an updated reader.")
self.author_name = obj["author"]
self.name = obj["name"]
if obj["data"]: # skip patching for vanilla content
data = bsdiff4.patch(Sprite.base_data, obj["data"])
self.sprite = data[:self.sprite_size]
self.palette = data[self.sprite_size:self.palette_size]
self.glove_palette = data[self.sprite_size + self.palette_size:]
except Exception:
logger = logging.getLogger("apsprite")
logger.exception("Error parsing apsprite file")
self.valid = False
@property
def author_game_display(self) -> str:
name = getattr(self, "_author_game_display", "")
if not name:
name = self.author_name
# At this point, may need some filtering to displayable characters
return name
def to_ap_sprite(self, path):
import yaml
payload = {"format_version": 1,
"min_format_version": 1,
"sprite_version": 1,
"name": self.name,
"author": self.author_name,
"game": "A Link to the Past",
"data": self.get_delta()}
with open(path, "w") as f:
f.write(yaml.safe_dump(payload))
def get_delta(self):
modified_data = self.sprite + self.palette + self.glove_palette
return bsdiff4.diff(Sprite.base_data, modified_data)
def from_zspr(self, filedata, filename):
result = self.parse_zspr(filedata, 1)
if result is None:
self.valid = False
return
(sprite, palette, self.name, self.author_name, self._author_game_display) = result
if self.name == "":
self.name = os.path.split(filename)[1].split(".")[0]
if len(sprite) != 0x7000:
self.valid = False
return
self.sprite = sprite
if len(palette) == 0:
pass
elif len(palette) == 0x78:
self.palette = palette
elif len(palette) == 0x7C:
self.palette = palette[:0x78]
self.glove_palette = palette[0x78:]
else:
self.valid = False
@staticmethod
def get_sprite_from_name(name: str, local_random=random) -> Optional[Sprite]:
_populate_sprite_table()
name = name.lower()
if name.startswith('random'):
sprites = list(set(_sprite_table.values()))
sprites.sort(key=lambda x: x.name)
return local_random.choice(sprites)
return _sprite_table.get(name, None)
@staticmethod
def default_link_sprite():
return Sprite(local_path('data', 'default.apsprite'))
def decode8(self, pos):
arr = [[0 for _ in range(8)] for _ in range(8)]
for y in range(8):
for x in range(8):
position = 1 << (7 - x)
val = 0
if self.sprite[pos + 2 * y] & position:
val += 1
if self.sprite[pos + 2 * y + 1] & position:
val += 2
if self.sprite[pos + 2 * y + 16] & position:
val += 4
if self.sprite[pos + 2 * y + 17] & position:
val += 8
arr[y][x] = val
return arr
def decode16(self, pos):
arr = [[0 for _ in range(16)] for _ in range(16)]
top_left = self.decode8(pos)
top_right = self.decode8(pos + 0x20)
bottom_left = self.decode8(pos + 0x200)
bottom_right = self.decode8(pos + 0x220)
for x in range(8):
for y in range(8):
arr[y][x] = top_left[y][x]
arr[y][x + 8] = top_right[y][x]
arr[y + 8][x] = bottom_left[y][x]
arr[y + 8][x + 8] = bottom_right[y][x]
return arr
@staticmethod
def parse_zspr(filedata, expected_kind):
logger = logging.getLogger("ZSPR")
headerstr = "<4xBHHIHIHH6x"
headersize = struct.calcsize(headerstr)
if len(filedata) < headersize:
return None
version, csum, icsum, sprite_offset, sprite_size, palette_offset, palette_size, kind = struct.unpack_from(
headerstr, filedata)
if version not in [1]:
logger.error("Error parsing ZSPR file: Version %g not supported", version)
return None
if kind != expected_kind:
return None
stream = io.BytesIO(filedata)
stream.seek(headersize)
def read_utf16le(stream):
"""Decodes a null-terminated UTF-16_LE string of unknown size from a stream"""
raw = bytearray()
while True:
char = stream.read(2)
if char in [b"", b"\x00\x00"]:
break
raw += char
return raw.decode("utf-16_le")
# noinspection PyBroadException
try:
sprite_name = read_utf16le(stream)
author_name = read_utf16le(stream)
author_credits_name = stream.read().split(b"\x00", 1)[0].decode()
# Ignoring the Author Rom name for the time being.
real_csum = sum(filedata) % 0x10000
if real_csum != csum or real_csum ^ 0xFFFF != icsum:
logger.warning("ZSPR file has incorrect checksum. It may be corrupted.")
sprite = filedata[sprite_offset:sprite_offset + sprite_size]
palette = filedata[palette_offset:palette_offset + palette_size]
if len(sprite) != sprite_size or len(palette) != palette_size:
logger.error("Error parsing ZSPR file: Unexpected end of file")
return None
return sprite, palette, sprite_name, author_name, author_credits_name
except Exception:
logger.exception("Error parsing ZSPR file")
return None
def decode_palette(self):
"""Returns the palettes as an array of arrays of 15 colors"""
def array_chunk(arr, size):
return list(zip(*[iter(arr)] * size))
def make_int16(pair):
return pair[1] << 8 | pair[0]
def expand_color(i):
return (i & 0x1F) * 8, (i >> 5 & 0x1F) * 8, (i >> 10 & 0x1F) * 8
# turn palette data into a list of RGB tuples with 8 bit values
palette_as_colors = [expand_color(make_int16(chnk)) for chnk in array_chunk(self.palette, 2)]
# split into palettes of 15 colors
return array_chunk(palette_as_colors, 15)
def __hash__(self):
return hash(self.name)
def write_to_rom(self, rom: LocalRom):
if not self.valid:
logging.warning("Tried writing invalid sprite to rom, skipping.")
return
rom.write_bytes(0x80000, self.sprite)
rom.write_bytes(0xDD308, self.palette)
rom.write_bytes(0xDEDF5, self.glove_palette)
rom.write_bytes(0x300000, self.sprite)
rom.write_bytes(0x307000, self.palette)
rom.write_bytes(0x307078, self.glove_palette)
bonk_addresses = [0x4CF6C, 0x4CFBA, 0x4CFE0, 0x4CFFB, 0x4D018, 0x4D01B, 0x4D028, 0x4D03C, 0x4D059, 0x4D07A, bonk_addresses = [0x4CF6C, 0x4CFBA, 0x4CFE0, 0x4CFFB, 0x4D018, 0x4D01B, 0x4D028, 0x4D03C, 0x4D059, 0x4D07A,
0x4D09E, 0x4D0A8, 0x4D0AB, 0x4D0AE, 0x4D0BE, 0x4D0DD, 0x4D09E, 0x4D0A8, 0x4D0AB, 0x4D0AE, 0x4D0BE, 0x4D0DD,
0x4D16A, 0x4D1E5, 0x4D1EE, 0x4D20B, 0x4CBBF, 0x4CBBF, 0x4CC17, 0x4CC1A, 0x4CC4A, 0x4CC4D, 0x4D16A, 0x4D1E5, 0x4D1EE, 0x4D20B, 0x4CBBF, 0x4CBBF, 0x4CC17, 0x4CC1A, 0x4CC4A, 0x4CC4D,

View File

@@ -32,6 +32,7 @@ def set_rules(world):
'WARNING! Seeds generated under this logic often require major glitches and may be impossible!') 'WARNING! Seeds generated under this logic often require major glitches and may be impossible!')
if world.players == 1: if world.players == 1:
world.get_region('Menu', player).can_reach_private = lambda state: True
no_logic_rules(world, player) no_logic_rules(world, player)
for exit in world.get_region('Menu', player).exits: for exit in world.get_region('Menu', player).exits:
exit.hide_path = True exit.hide_path = True
@@ -195,6 +196,7 @@ def global_rules(world, player):
add_item_rule(world.get_location(prize_location, player), add_item_rule(world.get_location(prize_location, player),
lambda item: item.name in crystals_and_pendants and item.player == player) lambda item: item.name in crystals_and_pendants and item.player == player)
# determines which S&Q locations are available - hide from paths since it isn't an in-game location # determines which S&Q locations are available - hide from paths since it isn't an in-game location
world.get_region('Menu', player).can_reach_private = lambda state: True
for exit in world.get_region('Menu', player).exits: for exit in world.get_region('Menu', player).exits:
exit.hide_path = True exit.hide_path = True

393
worlds/alttp/Sprites.py Normal file
View File

@@ -0,0 +1,393 @@
from __future__ import annotations
import concurrent.futures
import io
import json
import logging
import os
import random
import struct
import threading
from typing import Optional, TYPE_CHECKING
import bsdiff4
from Utils import user_path, read_snes_rom, parse_yaml, local_path
if TYPE_CHECKING:
from .Rom import LocalRom
sprite_list_lock = threading.Lock()
_sprite_table = {}
def _populate_sprite_table():
with sprite_list_lock:
if not _sprite_table:
def load_sprite_from_file(file):
sprite = Sprite(file)
if sprite.valid:
_sprite_table[sprite.name.lower()] = sprite
_sprite_table[os.path.basename(file).split(".")[0].lower()] = sprite # alias for filename base
else:
logging.debug(f"Spritefile {file} could not be loaded as a valid sprite.")
with concurrent.futures.ThreadPoolExecutor() as pool:
for dir in [user_path('data', 'sprites', 'alttpr'), user_path('data', 'sprites', 'custom')]:
for file in os.listdir(dir):
pool.submit(load_sprite_from_file, os.path.join(dir, file))
class Sprite():
sprite_size = 28672
palette_size = 120
glove_size = 4
author_name: Optional[str] = None
base_data: bytes
def __init__(self, filename):
if not hasattr(Sprite, "base_data"):
self.get_vanilla_sprite_data()
with open(filename, 'rb') as file:
filedata = file.read()
self.name = os.path.basename(filename)
self.valid = True
if filename.endswith(".apsprite"):
self.from_ap_sprite(filedata)
elif len(filedata) == 0x7000:
# sprite file with graphics and without palette data
self.sprite = filedata[:0x7000]
elif len(filedata) == 0x7078:
# sprite file with graphics and palette data
self.sprite = filedata[:0x7000]
self.palette = filedata[0x7000:]
self.glove_palette = filedata[0x7036:0x7038] + filedata[0x7054:0x7056]
elif len(filedata) == 0x707C:
# sprite file with graphics and palette data including gloves
self.sprite = filedata[:0x7000]
self.palette = filedata[0x7000:0x7078]
self.glove_palette = filedata[0x7078:]
elif len(filedata) in [0x100000, 0x200000, 0x400000]:
# full rom with patched sprite, extract it
self.sprite = filedata[0x80000:0x87000]
self.palette = filedata[0xDD308:0xDD380]
self.glove_palette = filedata[0xDEDF5:0xDEDF9]
elif filedata.startswith(b'ZSPR'):
self.from_zspr(filedata, filename)
else:
self.valid = False
def get_vanilla_sprite_data(self):
from .Rom import get_base_rom_path
file_name = get_base_rom_path()
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]
Sprite.base_data = Sprite.sprite + Sprite.palette + Sprite.glove_palette
def from_ap_sprite(self, filedata):
# noinspection PyBroadException
try:
obj = parse_yaml(filedata.decode("utf-8-sig"))
if obj["min_format_version"] > 1:
raise Exception("Sprite file requires an updated reader.")
self.author_name = obj["author"]
self.name = obj["name"]
if obj["data"]: # skip patching for vanilla content
data = bsdiff4.patch(Sprite.base_data, obj["data"])
self.sprite = data[:self.sprite_size]
self.palette = data[self.sprite_size:self.palette_size]
self.glove_palette = data[self.sprite_size + self.palette_size:]
except Exception:
logger = logging.getLogger("apsprite")
logger.exception("Error parsing apsprite file")
self.valid = False
@property
def author_game_display(self) -> str:
name = getattr(self, "_author_game_display", "")
if not name:
name = self.author_name
# At this point, may need some filtering to displayable characters
return name
def to_ap_sprite(self, path):
import yaml
payload = {"format_version": 1,
"min_format_version": 1,
"sprite_version": 1,
"name": self.name,
"author": self.author_name,
"game": "A Link to the Past",
"data": self.get_delta()}
with open(path, "w") as f:
f.write(yaml.safe_dump(payload))
def get_delta(self):
modified_data = self.sprite + self.palette + self.glove_palette
return bsdiff4.diff(Sprite.base_data, modified_data)
def from_zspr(self, filedata, filename):
result = self.parse_zspr(filedata, 1)
if result is None:
self.valid = False
return
(sprite, palette, self.name, self.author_name, self._author_game_display) = result
if self.name == "":
self.name = os.path.split(filename)[1].split(".")[0]
if len(sprite) != 0x7000:
self.valid = False
return
self.sprite = sprite
if len(palette) == 0:
pass
elif len(palette) == 0x78:
self.palette = palette
elif len(palette) == 0x7C:
self.palette = palette[:0x78]
self.glove_palette = palette[0x78:]
else:
self.valid = False
@staticmethod
def get_sprite_from_name(name: str, local_random=random) -> Optional[Sprite]:
_populate_sprite_table()
name = name.lower()
if name.startswith('random'):
sprites = list(set(_sprite_table.values()))
sprites.sort(key=lambda x: x.name)
return local_random.choice(sprites)
return _sprite_table.get(name, None)
@staticmethod
def default_link_sprite():
return Sprite(local_path('data', 'default.apsprite'))
def decode8(self, pos):
arr = [[0 for _ in range(8)] for _ in range(8)]
for y in range(8):
for x in range(8):
position = 1 << (7 - x)
val = 0
if self.sprite[pos + 2 * y] & position:
val += 1
if self.sprite[pos + 2 * y + 1] & position:
val += 2
if self.sprite[pos + 2 * y + 16] & position:
val += 4
if self.sprite[pos + 2 * y + 17] & position:
val += 8
arr[y][x] = val
return arr
def decode16(self, pos):
arr = [[0 for _ in range(16)] for _ in range(16)]
top_left = self.decode8(pos)
top_right = self.decode8(pos + 0x20)
bottom_left = self.decode8(pos + 0x200)
bottom_right = self.decode8(pos + 0x220)
for x in range(8):
for y in range(8):
arr[y][x] = top_left[y][x]
arr[y][x + 8] = top_right[y][x]
arr[y + 8][x] = bottom_left[y][x]
arr[y + 8][x + 8] = bottom_right[y][x]
return arr
@staticmethod
def parse_zspr(filedata, expected_kind):
logger = logging.getLogger("ZSPR")
headerstr = "<4xBHHIHIHH6x"
headersize = struct.calcsize(headerstr)
if len(filedata) < headersize:
return None
version, csum, icsum, sprite_offset, sprite_size, palette_offset, palette_size, kind = struct.unpack_from(
headerstr, filedata)
if version not in [1]:
logger.error("Error parsing ZSPR file: Version %g not supported", version)
return None
if kind != expected_kind:
return None
stream = io.BytesIO(filedata)
stream.seek(headersize)
def read_utf16le(stream):
"""Decodes a null-terminated UTF-16_LE string of unknown size from a stream"""
raw = bytearray()
while True:
char = stream.read(2)
if char in [b"", b"\x00\x00"]:
break
raw += char
return raw.decode("utf-16_le")
# noinspection PyBroadException
try:
sprite_name = read_utf16le(stream)
author_name = read_utf16le(stream)
author_credits_name = stream.read().split(b"\x00", 1)[0].decode()
# Ignoring the Author Rom name for the time being.
real_csum = sum(filedata) % 0x10000
if real_csum != csum or real_csum ^ 0xFFFF != icsum:
logger.warning("ZSPR file has incorrect checksum. It may be corrupted.")
sprite = filedata[sprite_offset:sprite_offset + sprite_size]
palette = filedata[palette_offset:palette_offset + palette_size]
if len(sprite) != sprite_size or len(palette) != palette_size:
logger.error("Error parsing ZSPR file: Unexpected end of file")
return None
return sprite, palette, sprite_name, author_name, author_credits_name
except Exception:
logger.exception("Error parsing ZSPR file")
return None
def decode_palette(self):
"""Returns the palettes as an array of arrays of 15 colors"""
def array_chunk(arr, size):
return list(zip(*[iter(arr)] * size))
def make_int16(pair):
return pair[1] << 8 | pair[0]
def expand_color(i):
return (i & 0x1F) * 8, (i >> 5 & 0x1F) * 8, (i >> 10 & 0x1F) * 8
# turn palette data into a list of RGB tuples with 8 bit values
palette_as_colors = [expand_color(make_int16(chnk)) for chnk in array_chunk(self.palette, 2)]
# split into palettes of 15 colors
return array_chunk(palette_as_colors, 15)
def __hash__(self):
return hash(self.name)
def write_to_rom(self, rom: "LocalRom"):
if not self.valid:
logging.warning("Tried writing invalid sprite to rom, skipping.")
return
rom.write_bytes(0x80000, self.sprite)
rom.write_bytes(0xDD308, self.palette)
rom.write_bytes(0xDEDF5, self.glove_palette)
rom.write_bytes(0x300000, self.sprite)
rom.write_bytes(0x307000, self.palette)
rom.write_bytes(0x307078, self.glove_palette)
def update_sprites():
from tkinter import Tk
from LttPAdjuster import get_image_for_sprite
from LttPAdjuster import BackgroundTaskProgress
from LttPAdjuster import BackgroundTaskProgressNullWindow
from LttPAdjuster import update_sprites
# Target directories
input_dir = user_path("data", "sprites", "alttpr")
output_dir = local_path("WebHostLib", "static", "generated") # TODO: move to user_path
os.makedirs(os.path.join(output_dir, "sprites"), exist_ok=True)
# update sprites through gui.py's functions
done = threading.Event()
try:
top = Tk()
except:
task = BackgroundTaskProgressNullWindow(update_sprites, lambda successful, resultmessage: done.set())
else:
top.withdraw()
task = BackgroundTaskProgress(top, update_sprites, "Updating Sprites", lambda succesful, resultmessage: done.set())
while not done.isSet():
task.do_events()
spriteData = []
for file in (file for file in os.listdir(input_dir) if not file.startswith(".")):
sprite = Sprite(os.path.join(input_dir, file))
if not sprite.name:
print("Warning:", file, "has no name.")
sprite.name = file.split(".", 1)[0]
if sprite.valid:
with open(os.path.join(output_dir, "sprites", f"{os.path.splitext(file)[0]}.gif"), 'wb') as image:
image.write(get_image_for_sprite(sprite, True))
spriteData.append({"file": file, "author": sprite.author_name, "name": sprite.name})
else:
print(file, "dropped, as it has no valid sprite data.")
spriteData.sort(key=lambda entry: entry["name"])
with open(f'{output_dir}/spriteData.json', 'w') as file:
json.dump({"sprites": spriteData}, file, indent=1)
return spriteData
def apply_random_sprite_on_event(rom: "LocalRom", sprite, local_random, allow_random_on_event, sprite_pool):
userandomsprites = False
if sprite and not isinstance(sprite, Sprite):
sprite = sprite.lower()
userandomsprites = sprite.startswith('randomon')
racerom = rom.read_byte(0x180213)
if allow_random_on_event or not racerom:
# Changes to this byte for race rom seeds are only permitted on initial rolling of the seed.
# However, if the seed is not a racerom seed, then it is always allowed.
rom.write_byte(0x186381, 0x00 if userandomsprites else 0x01)
onevent = 0
if sprite == 'randomonall':
onevent = 0xFFFF # Support all current and future events that can cause random sprite changes.
elif sprite == 'randomonnone':
# Allows for opting into random on events on race rom seeds, without actually enabling any of the events initially.
onevent = 0x0000
elif sprite == 'randomonrandom':
# Allows random to take the wheel on which events apply. (at least one event will be applied.)
onevent = local_random.randint(0x0001, 0x003F)
elif userandomsprites:
onevent = 0x01 if 'hit' in sprite else 0x00
onevent += 0x02 if 'enter' in sprite else 0x00
onevent += 0x04 if 'exit' in sprite else 0x00
onevent += 0x08 if 'slash' in sprite else 0x00
onevent += 0x10 if 'item' in sprite else 0x00
onevent += 0x20 if 'bonk' in sprite else 0x00
rom.write_int16(0x18637F, onevent)
sprite = Sprite(sprite) if os.path.isfile(sprite) else Sprite.get_sprite_from_name(sprite, local_random)
# write link sprite if required
if sprite:
sprites = list()
sprite.write_to_rom(rom)
_populate_sprite_table()
if userandomsprites:
if sprite_pool:
if isinstance(sprite_pool, str):
sprite_pool = sprite_pool.split(':')
for spritename in sprite_pool:
sprite = Sprite(spritename) if os.path.isfile(spritename) else Sprite.get_sprite_from_name(
spritename, local_random)
if sprite:
sprites.append(sprite)
else:
logging.info(f"Sprite {spritename} was not found.")
else:
sprites = list(set(_sprite_table.values())) # convert to list and remove dupes
else:
sprites.append(sprite)
if sprites:
while len(sprites) < 32:
sprites.extend(sprites)
local_random.shuffle(sprites)
for i, sprite in enumerate(sprites[:32]):
if not i and not userandomsprites:
continue
rom.write_bytes(0x300000 + (i * 0x8000), sprite.sprite)
rom.write_bytes(0x307000 + (i * 0x8000), sprite.palette)
rom.write_bytes(0x307078 + (i * 0x8000), sprite.glove_palette)

View File

@@ -124,6 +124,14 @@ class ALTTPWeb(WebWorld):
tutorials = [setup_en, setup_de, setup_es, setup_fr, msu, msu_es, msu_fr, plando, oof_sound] tutorials = [setup_en, setup_de, setup_es, setup_fr, msu, msu_es, msu_fr, plando, oof_sound]
@classmethod
def run_webhost_setup(cls):
rom_file = get_base_rom_path()
if os.path.exists(rom_file):
from .Sprites import update_sprites
update_sprites()
else:
logging.warning("Could not update LttP sprites.")
class ALTTPWorld(World): class ALTTPWorld(World):
""" """
@@ -808,7 +816,6 @@ class ALTTPWorld(World):
) )
return slot_data return slot_data
def get_same_seed(world, seed_def: tuple) -> str: def get_same_seed(world, seed_def: tuple) -> str:
seeds: typing.Dict[tuple, str] = getattr(world, "__named_seeds", {}) seeds: typing.Dict[tuple, str] = getattr(world, "__named_seeds", {})
if seed_def in seeds: if seed_def in seeds:

View File

@@ -61,6 +61,8 @@ class FactorioWeb(WebWorld):
["Berserker, Farrak Kilhn"] ["Berserker, Farrak Kilhn"]
)] )]
multitracker_template = "data/web/templates/MultiTracker.html"
class FactorioItem(Item): class FactorioItem(Item):
game = "Factorio" game = "Factorio"
@@ -524,7 +526,6 @@ class Factorio(World):
all_items[name], self.player) all_items[name], self.player)
return item return item
class FactorioLocation(Location): class FactorioLocation(Location):
game: str = Factorio.game game: str = Factorio.game

View File

@@ -28,13 +28,13 @@
{% block custom_table_row scoped %} {% block custom_table_row scoped %}
{% if games[player] == "Factorio" %} {% if games[player] == "Factorio" %}
{% set player_inventory = inventory[team][player] %} {% set player_inventory = inventory[team][player] %}
{% set prog_science = player_inventory[custom_items["progressive-science-pack"]] %} {% set prog_science = player_inventory[item_name_to_id["progressive-science-pack"]] %}
<td class="center-column">{% if player_inventory[custom_items["logistic-science-pack"]] or prog_science %}✔{% endif %}</td> <td class="center-column">{% if player_inventory[item_name_to_id["logistic-science-pack"]] or prog_science %}✔{% endif %}</td>
<td class="center-column">{% if player_inventory[custom_items["military-science-pack"]] or prog_science > 1%}✔{% endif %}</td> <td class="center-column">{% if player_inventory[item_name_to_id["military-science-pack"]] or prog_science > 1%}✔{% endif %}</td>
<td class="center-column">{% if player_inventory[custom_items["chemical-science-pack"]] or prog_science > 2%}✔{% endif %}</td> <td class="center-column">{% if player_inventory[item_name_to_id["chemical-science-pack"]] or prog_science > 2%}✔{% endif %}</td>
<td class="center-column">{% if player_inventory[custom_items["production-science-pack"]] or prog_science > 3%}✔{% endif %}</td> <td class="center-column">{% if player_inventory[item_name_to_id["production-science-pack"]] or prog_science > 3%}✔{% endif %}</td>
<td class="center-column">{% if player_inventory[custom_items["utility-science-pack"]] or prog_science > 4%}✔{% endif %}</td> <td class="center-column">{% if player_inventory[item_name_to_id["utility-science-pack"]] or prog_science > 4%}✔{% endif %}</td>
<td class="center-column">{% if player_inventory[custom_items["space-science-pack"]] or prog_science > 5%}✔{% endif %}</td> <td class="center-column">{% if player_inventory[item_name_to_id["space-science-pack"]] or prog_science > 5%}✔{% endif %}</td>
{% else %} {% else %}
<td class="center-column"></td> <td class="center-column"></td>
<td class="center-column"></td> <td class="center-column"></td>

View File

@@ -8,9 +8,10 @@ class SongData(NamedTuple):
code: Optional[int] code: Optional[int]
song_is_free: bool song_is_free: bool
streamer_mode: bool streamer_mode: bool
easy: Optional[int] easy: str = Optional[int]
hard: Optional[int] hard: int = Optional[int]
master: Optional[int] master: int = Optional[int]
secret: int = Optional[int]
class AlbumData(NamedTuple): class AlbumData(NamedTuple):

View File

@@ -10,22 +10,21 @@ def load_text_file(name: str) -> str:
class MuseDashCollections: class MuseDashCollections:
"""Contains all the data of Muse Dash, loaded from MuseDashData.txt.""" """Contains all the data of Muse Dash, loaded from MuseDashData.txt."""
STARTING_CODE = 2900000
MUSIC_SHEET_NAME: str = "Music Sheet" MUSIC_SHEET_NAME: str = "Music Sheet"
MUSIC_SHEET_CODE: int = STARTING_CODE MUSIC_SHEET_CODE: int
FREE_ALBUMS = [ FREE_ALBUMS = [
"Default Music", "Default Music",
"Budget Is Burning: Nano Core", "Budget Is Burning: Nano Core",
"Budget Is Burning Vol.1", "Budget is Burning Vol.1"
] ]
DIFF_OVERRIDES = [ DIFF_OVERRIDES = [
"MuseDash ka nanika hi", "MuseDash ka nanika hi",
"Rush-Hour", "Rush-Hour",
"Find this Month's Featured Playlist", "Find this Month's Featured Playlist",
"PeroPero in the Universe", "PeroPero in the Universe"
] ]
album_items: Dict[str, AlbumData] = {} album_items: Dict[str, AlbumData] = {}
@@ -34,43 +33,47 @@ class MuseDashCollections:
song_locations: Dict[str, int] = {} song_locations: Dict[str, int] = {}
vfx_trap_items: Dict[str, int] = { vfx_trap_items: Dict[str, int] = {
"Bad Apple Trap": STARTING_CODE + 1, "Bad Apple Trap": 1,
"Pixelate Trap": STARTING_CODE + 2, "Pixelate Trap": 2,
"Random Wave Trap": STARTING_CODE + 3, "Random Wave Trap": 3,
"Shadow Edge Trap": STARTING_CODE + 4, "Shadow Edge Trap": 4,
"Chromatic Aberration Trap": STARTING_CODE + 5, "Chromatic Aberration Trap": 5,
"Background Freeze Trap": STARTING_CODE + 6, "Background Freeze Trap": 6,
"Gray Scale Trap": STARTING_CODE + 7, "Gray Scale Trap": 7,
} }
sfx_trap_items: Dict[str, int] = { sfx_trap_items: Dict[str, int] = {
"Nyaa SFX Trap": STARTING_CODE + 8, "Nyaa SFX Trap": 8,
"Error SFX Trap": STARTING_CODE + 9, "Error SFX Trap": 9,
} }
item_names_to_id = ChainMap({}, sfx_trap_items, vfx_trap_items) item_names_to_id = ChainMap({}, sfx_trap_items, vfx_trap_items)
location_names_to_id = ChainMap(song_locations, album_locations) location_names_to_id = ChainMap(song_locations, album_locations)
def __init__(self) -> None: def __init__(self, start_item_id: int, items_per_location: int):
self.MUSIC_SHEET_CODE = start_item_id
self.item_names_to_id[self.MUSIC_SHEET_NAME] = self.MUSIC_SHEET_CODE self.item_names_to_id[self.MUSIC_SHEET_NAME] = self.MUSIC_SHEET_CODE
item_id_index = self.STARTING_CODE + 50 self.vfx_trap_items.update({k: (v + start_item_id) for (k, v) in self.vfx_trap_items.items()})
self.sfx_trap_items.update({k: (v + start_item_id) for (k, v) in self.sfx_trap_items.items()})
item_id_index = start_item_id + 50
location_id_index = start_item_id
full_file = load_text_file("MuseDashData.txt") full_file = load_text_file("MuseDashData.txt")
seen_albums = set()
for line in full_file.splitlines(): for line in full_file.splitlines():
line = line.strip() line = line.strip()
sections = line.split("|") sections = line.split("|")
album = sections[2] if sections[2] not in self.album_items:
if album not in seen_albums: self.album_items[sections[2]] = AlbumData(item_id_index)
seen_albums.add(album)
self.album_items[album] = AlbumData(item_id_index)
item_id_index += 1 item_id_index += 1
# Data is in the format 'Song|UID|Album|StreamerMode|EasyDiff|HardDiff|MasterDiff|SecretDiff' # Data is in the format 'Song|UID|Album|StreamerMode|EasyDiff|HardDiff|MasterDiff|SecretDiff'
song_name = sections[0] song_name = sections[0]
# [1] is used in the client copy to make sure item id's match. # [1] is used in the client copy to make sure item id's match.
song_is_free = album in self.FREE_ALBUMS song_is_free = sections[2] in self.FREE_ALBUMS
steamer_mode = sections[3] == "True" steamer_mode = sections[3] == "True"
if song_name in self.DIFF_OVERRIDES: if song_name in self.DIFF_OVERRIDES:
@@ -91,16 +94,17 @@ class MuseDashCollections:
self.item_names_to_id.update({name: data.code for name, data in self.song_items.items()}) self.item_names_to_id.update({name: data.code for name, data in self.song_items.items()})
self.item_names_to_id.update({name: data.code for name, data in self.album_items.items()}) self.item_names_to_id.update({name: data.code for name, data in self.album_items.items()})
location_id_index = self.STARTING_CODE
for name in self.album_items.keys(): for name in self.album_items.keys():
self.album_locations[f"{name}-0"] = location_id_index for i in range(0, items_per_location):
self.album_locations[f"{name}-1"] = location_id_index + 1 new_name = f"{name}-{i}"
location_id_index += 2 self.album_locations[new_name] = location_id_index
location_id_index += 1
for name in self.song_items.keys(): for name in self.song_items.keys():
self.song_locations[f"{name}-0"] = location_id_index for i in range(0, items_per_location):
self.song_locations[f"{name}-1"] = location_id_index + 1 new_name = f"{name}-{i}"
location_id_index += 2 self.song_locations[new_name] = location_id_index
location_id_index += 1
def get_songs_with_settings(self, dlc_songs: bool, streamer_mode_active: bool, def get_songs_with_settings(self, dlc_songs: bool, streamer_mode_active: bool,
diff_lower: int, diff_higher: int) -> List[str]: diff_lower: int, diff_higher: int) -> List[str]:

View File

@@ -465,7 +465,3 @@ Kawai Splendid Space Thief|64-4|COSMIC RADIO PEROLIST|False|6|8|10|11
Night City Runway|64-5|COSMIC RADIO PEROLIST|True|4|6|8| Night City Runway|64-5|COSMIC RADIO PEROLIST|True|4|6|8|
Chaos Shotgun feat. ChumuNote|64-6|COSMIC RADIO PEROLIST|True|6|8|10| Chaos Shotgun feat. ChumuNote|64-6|COSMIC RADIO PEROLIST|True|6|8|10|
mew mew magical summer|64-7|COSMIC RADIO PEROLIST|False|5|8|10|11 mew mew magical summer|64-7|COSMIC RADIO PEROLIST|False|5|8|10|11
BrainDance|65-0|Neon Abyss|True|3|6|9|
My Focus!|65-1|Neon Abyss|True|5|7|10|
ABABABA BURST|65-2|Neon Abyss|True|5|7|9|
ULTRA HIGHER|65-3|Neon Abyss|True|4|7|10|

View File

@@ -40,14 +40,14 @@ class MuseDashWorld(World):
game = "Muse Dash" game = "Muse Dash"
option_definitions = musedash_options option_definitions = musedash_options
topology_present = False topology_present = False
data_version = 9 data_version = 8
web = MuseDashWebWorld() web = MuseDashWebWorld()
# Necessary Data # Necessary Data
md_collection = MuseDashCollections() md_collection = MuseDashCollections(2900000, 2)
item_name_to_id = {name: code for name, code in md_collection.item_names_to_id.items()} item_name_to_id = md_collection.item_names_to_id
location_name_to_id = {name: code for name, code in md_collection.location_names_to_id.items()} location_name_to_id = md_collection.location_names_to_id
# Working Data # Working Data
victory_song_name: str = "" victory_song_name: str = ""
@@ -167,12 +167,11 @@ class MuseDashWorld(World):
if trap: if trap:
return MuseDashFixedItem(name, ItemClassification.trap, trap, self.player) return MuseDashFixedItem(name, ItemClassification.trap, trap, self.player)
album = self.md_collection.album_items.get(name)
if album:
return MuseDashSongItem(name, self.player, album)
song = self.md_collection.song_items.get(name) song = self.md_collection.song_items.get(name)
return MuseDashSongItem(name, self.player, song) if song:
return MuseDashSongItem(name, self.player, song)
return MuseDashFixedItem(name, ItemClassification.filler, None, self.player)
def create_items(self) -> None: def create_items(self) -> None:
song_keys_in_pool = self.included_songs.copy() song_keys_in_pool = self.included_songs.copy()

View File

@@ -1,49 +0,0 @@
import unittest
from ..MuseDashCollection import MuseDashCollections
class CollectionsTest(unittest.TestCase):
REMOVED_SONGS = [
"CHAOS Glitch",
"FM 17314 SUGAR RADIO",
]
def test_all_names_are_ascii(self) -> None:
bad_names = list()
collection = MuseDashCollections()
for name in collection.song_items.keys():
for c in name:
# This is taken directly from OoT. Represents the generally excepted characters.
if (0x20 <= ord(c) < 0x7e):
continue
bad_names.append(name)
break
self.assertEqual(len(bad_names), 0, f"Muse Dash has {len(bad_names)} songs with non-ASCII characters.\n{bad_names}")
def test_ids_dont_change(self) -> None:
collection = MuseDashCollections()
itemsBefore = {name: code for name, code in collection.item_names_to_id.items()}
locationsBefore = {name: code for name, code in collection.location_names_to_id.items()}
collection.__init__()
itemsAfter = {name: code for name, code in collection.item_names_to_id.items()}
locationsAfter = {name: code for name, code in collection.location_names_to_id.items()}
self.assertDictEqual(itemsBefore, itemsAfter, "Item ID changed after secondary init.")
self.assertDictEqual(locationsBefore, locationsAfter, "Location ID changed after secondary init.")
def test_free_dlc_included_in_base_songs(self) -> None:
collection = MuseDashCollections()
songs = collection.get_songs_with_settings(False, False, 0, 11)
self.assertIn("Glimmer", songs, "Budget Is Burning Vol.1 is not being included in base songs")
self.assertIn("Out of Sense", songs, "Budget Is Burning: Nano Core is not being included in base songs")
def test_remove_songs_are_not_generated(self) -> None:
collection = MuseDashCollections()
songs = collection.get_songs_with_settings(True, False, 0, 11)
for song_name in self.REMOVED_SONGS:
self.assertNotIn(song_name, songs, f"Song '{song_name}' wasn't removed correctly.")

View File

@@ -9,8 +9,8 @@ class DifficultyRanges(MuseDashTestBase):
difficulty_max = self.multiworld.song_difficulty_max[1] difficulty_max = self.multiworld.song_difficulty_max[1]
def test_range(inputRange, lower, upper): def test_range(inputRange, lower, upper):
self.assertEqual(inputRange[0], lower) assert inputRange[0] == lower and inputRange[1] == upper, \
self.assertEqual(inputRange[1], upper) f"Output incorrect. Got: {inputRange[0]} to {inputRange[1]}. Expected: {lower} to {upper}"
songs = muse_dash_world.md_collection.get_songs_with_settings(True, False, inputRange[0], inputRange[1]) songs = muse_dash_world.md_collection.get_songs_with_settings(True, False, inputRange[0], inputRange[1])
for songKey in songs: for songKey in songs:
@@ -24,7 +24,7 @@ class DifficultyRanges(MuseDashTestBase):
if (song.master is not None and inputRange[0] <= song.master <= inputRange[1]): if (song.master is not None and inputRange[0] <= song.master <= inputRange[1]):
continue continue
self.fail(f"Invalid song '{songKey}' was given for range '{inputRange[0]} to {inputRange[1]}'") assert False, f"Invalid song '{songKey}' was given for range '{inputRange[0]} to {inputRange[1]}'"
#auto ranges #auto ranges
difficulty_choice.value = 0 difficulty_choice.value = 0
@@ -65,5 +65,5 @@ class DifficultyRanges(MuseDashTestBase):
for song_name in muse_dash_world.md_collection.DIFF_OVERRIDES: for song_name in muse_dash_world.md_collection.DIFF_OVERRIDES:
song = muse_dash_world.md_collection.song_items[song_name] song = muse_dash_world.md_collection.song_items[song_name]
self.assertTrue(song.easy is not None and song.hard is not None and song.master is not None, assert song.easy is not None and song.hard is not None and song.master is not None, \
f"Song '{song_name}' difficulty not set when it should be.") f"Song '{song_name}' difficulty not set when it should be."

View File

@@ -0,0 +1,18 @@
import unittest
from ..MuseDashCollection import MuseDashCollections
class NamesTest(unittest.TestCase):
def test_all_names_are_ascii(self) -> None:
bad_names = list()
collection = MuseDashCollections(0, 1)
for name in collection.song_items.keys():
for c in name:
# This is taken directly from OoT. Represents the generally excepted characters.
if (0x20 <= ord(c) < 0x7e):
continue
bad_names.append(name)
break
assert len(bad_names) == 0, f"Muse Dash has {len(bad_names)} songs with non-ASCII characters.\n{bad_names}"

View File

@@ -1,7 +1,7 @@
from . import MuseDashTestBase from . import MuseDashTestBase
class TestPlandoSettings(MuseDashTestBase): class TestIncludedSongSizeDoesntGrow(MuseDashTestBase):
options = { options = {
"additional_song_count": 15, "additional_song_count": 15,
"allow_just_as_planned_dlc_songs": True, "allow_just_as_planned_dlc_songs": True,
@@ -14,14 +14,14 @@ class TestPlandoSettings(MuseDashTestBase):
def test_included_songs_didnt_grow_item_count(self) -> None: def test_included_songs_didnt_grow_item_count(self) -> None:
muse_dash_world = self.multiworld.worlds[1] muse_dash_world = self.multiworld.worlds[1]
self.assertEqual(len(muse_dash_world.included_songs), 15, assert len(muse_dash_world.included_songs) == 15, \
f"Logical songs size grew when it shouldn't. Expected 15. Got {len(muse_dash_world.included_songs)}") f"Logical songs size grew when it shouldn't. Expected 15. Got {len(muse_dash_world.included_songs)}"
def test_included_songs_plando(self) -> None: def test_included_songs_plando(self) -> None:
muse_dash_world = self.multiworld.worlds[1] muse_dash_world = self.multiworld.worlds[1]
songs = muse_dash_world.included_songs.copy() songs = muse_dash_world.included_songs.copy()
songs.append(muse_dash_world.victory_song_name) songs.append(muse_dash_world.victory_song_name)
self.assertIn("Operation Blade", songs, "Logical songs is missing a plando song: Operation Blade") assert "Operation Blade" in songs, "Logical songs is missing a plando song: Operation Blade"
self.assertIn("Autumn Moods", songs, "Logical songs is missing a plando song: Autumn Moods") assert "Autumn Moods" in songs, "Logical songs is missing a plando song: Autumn Moods"
self.assertIn("Fireflies", songs, "Logical songs is missing a plando song: Fireflies") assert "Fireflies" in songs, "Logical songs is missing a plando song: Fireflies"

View File

@@ -0,0 +1,25 @@
from . import MuseDashTestBase
class TestRemovedSongs(MuseDashTestBase):
options = {
"starting_song_count": 10,
"allow_just_as_planned_dlc_songs": True,
"additional_song_count": 500,
}
removed_songs = [
"CHAOS Glitch",
"FM 17314 SUGAR RADIO"
]
def test_remove_songs_are_not_generated(self) -> None:
# This test is done on a world where every song should be added.
muse_dash_world = self.multiworld.worlds[1]
for song_name in self.removed_songs:
assert song_name not in muse_dash_world.starting_songs, \
f"Song '{song_name}' was included into the starting songs when it shouldn't."
assert song_name not in muse_dash_world.included_songs, \
f"Song '{song_name}' was included into the included songs when it shouldn't."

View File

@@ -1681,6 +1681,7 @@ def create_regions(self):
connect(multiworld, player, "Fuchsia City", "Fuchsia Fishing", lambda state: state.has("Super Rod", player), one_way=True) connect(multiworld, player, "Fuchsia City", "Fuchsia Fishing", lambda state: state.has("Super Rod", player), one_way=True)
connect(multiworld, player, "Pallet Town", "Old Rod Fishing", lambda state: state.has("Old Rod", player), one_way=True) connect(multiworld, player, "Pallet Town", "Old Rod Fishing", lambda state: state.has("Old Rod", player), one_way=True)
connect(multiworld, player, "Pallet Town", "Good Rod Fishing", lambda state: state.has("Good Rod", player), one_way=True) connect(multiworld, player, "Pallet Town", "Good Rod Fishing", lambda state: state.has("Good Rod", player), one_way=True)
connect(multiworld, player, "Cinnabar Lab Fossil Room", "Good Rod Fishing", one_way=True)
connect(multiworld, player, "Cinnabar Lab Fossil Room", "Fossil Level", lambda state: logic.fossil_checks(state, 1, player), one_way=True) connect(multiworld, player, "Cinnabar Lab Fossil Room", "Fossil Level", lambda state: logic.fossil_checks(state, 1, player), one_way=True)
connect(multiworld, player, "Route 5 Gate-N", "Route 5 Gate-S", lambda state: logic.can_pass_guards(state, player)) connect(multiworld, player, "Route 5 Gate-N", "Route 5 Gate-S", lambda state: logic.can_pass_guards(state, player))
connect(multiworld, player, "Route 6 Gate-N", "Route 6 Gate-S", lambda state: logic.can_pass_guards(state, player)) connect(multiworld, player, "Route 6 Gate-N", "Route 6 Gate-S", lambda state: logic.can_pass_guards(state, player))

View File

@@ -492,39 +492,35 @@ class StardewLogic:
}) })
self.special_order_rules.update({ self.special_order_rules.update({
SpecialOrder.island_ingredients: self.can_meet(NPC.caroline) & self.has_island_transport() & self.can_farm_perfectly() & SpecialOrder.island_ingredients: self.has_island_transport() & self.can_farm_perfectly() &
self.can_ship(Vegetable.taro_root) & self.can_ship(Fruit.pineapple) & self.can_ship(Forageable.ginger), self.has(Vegetable.taro_root) & self.has(Fruit.pineapple) & self.has(Forageable.ginger),
SpecialOrder.cave_patrol: self.can_meet(NPC.clint) & self.can_mine_perfectly() & self.can_mine_to_floor(120), SpecialOrder.cave_patrol: self.can_mine_perfectly() & self.can_mine_to_floor(120),
SpecialOrder.aquatic_overpopulation: self.can_meet(NPC.demetrius) & self.can_fish_perfectly(), SpecialOrder.aquatic_overpopulation: self.can_fish_perfectly(),
SpecialOrder.biome_balance: self.can_meet(NPC.demetrius) & self.can_fish_perfectly(), SpecialOrder.biome_balance: self.can_fish_perfectly(),
SpecialOrder.rock_rejuivenation: self.has_relationship(NPC.emily, 4) & self.has(Mineral.ruby) & self.has(Mineral.topaz) & SpecialOrder.rock_rejuivenation: self.has(Mineral.ruby) & self.has(Mineral.topaz) & self.has(Mineral.emerald) &
self.has(Mineral.emerald) & self.has(Mineral.jade) & self.has(Mineral.amethyst) & self.has(Mineral.jade) & self.has(Mineral.amethyst) & self.has_relationship(NPC.emily, 4) &
self.has(ArtisanGood.cloth) & self.can_reach_region(Region.haley_house), self.has(ArtisanGood.cloth) & self.can_reach_region(Region.haley_house),
SpecialOrder.gifts_for_george: self.can_reach_region(Region.alex_house) & self.has_season(Season.spring) & self.has(Forageable.leek), SpecialOrder.gifts_for_george: self.has_season(Season.spring) & self.has(Forageable.leek),
SpecialOrder.fragments_of_the_past: self.can_reach_region(Region.museum) & self.can_reach_region(Region.dig_site) & self.has_tool(Tool.pickaxe), SpecialOrder.fragments_of_the_past: self.can_reach_region(Region.dig_site),
SpecialOrder.gus_famous_omelet: self.can_reach_region(Region.saloon) & self.has(AnimalProduct.any_egg), SpecialOrder.gus_famous_omelet: self.has(AnimalProduct.any_egg),
SpecialOrder.crop_order: self.can_farm_perfectly() & self.can_ship(), SpecialOrder.crop_order: self.can_farm_perfectly(),
SpecialOrder.community_cleanup: self.can_reach_region(Region.railroad) & self.can_crab_pot(), SpecialOrder.community_cleanup: self.can_crab_pot(),
SpecialOrder.the_strong_stuff: self.can_reach_region(Region.trailer) & self.can_keg(Vegetable.potato), SpecialOrder.the_strong_stuff: self.can_keg(Vegetable.potato),
SpecialOrder.pierres_prime_produce: self.can_reach_region(Region.pierre_store) & self.can_farm_perfectly(), SpecialOrder.pierres_prime_produce: self.can_farm_perfectly(),
SpecialOrder.robins_project: self.can_meet(NPC.robin) & self.can_reach_region(Region.carpenter) & self.can_chop_perfectly() & SpecialOrder.robins_project: self.can_chop_perfectly() & self.has(Material.hardwood),
self.has(Material.hardwood), SpecialOrder.robins_resource_rush: self.can_chop_perfectly() & self.has(Fertilizer.tree) & self.can_mine_perfectly(),
SpecialOrder.robins_resource_rush: self.can_meet(NPC.robin) & self.can_reach_region(Region.carpenter) & self.can_chop_perfectly() & SpecialOrder.juicy_bugs_wanted_yum: self.has(Loot.bug_meat),
self.has(Fertilizer.tree) & self.can_mine_perfectly(), SpecialOrder.tropical_fish: self.has_island_transport() & self.has(Fish.stingray) & self.has(Fish.blue_discus) & self.has(Fish.lionfish),
SpecialOrder.juicy_bugs_wanted_yum: self.can_reach_region(Region.beach) & self.has(Loot.bug_meat), SpecialOrder.a_curious_substance: self.can_mine_perfectly() & self.can_mine_to_floor(80),
SpecialOrder.tropical_fish: self.can_meet(NPC.willy) & self.received("Island Resort") & self.has_island_transport() & SpecialOrder.prismatic_jelly: self.can_mine_perfectly() & self.can_mine_to_floor(40),
self.has(Fish.stingray) & self.has(Fish.blue_discus) & self.has(Fish.lionfish),
SpecialOrder.a_curious_substance: self.can_reach_region(Region.wizard_tower) & self.can_mine_perfectly() & self.can_mine_to_floor(80),
SpecialOrder.prismatic_jelly: self.can_reach_region(Region.wizard_tower) & self.can_mine_perfectly() & self.can_mine_to_floor(40),
SpecialOrder.qis_crop: self.can_farm_perfectly() & self.can_reach_region(Region.greenhouse) & SpecialOrder.qis_crop: self.can_farm_perfectly() & self.can_reach_region(Region.greenhouse) &
self.can_reach_region(Region.island_west) & self.has_total_skill_level(50) & self.can_reach_region(Region.island_west) & self.has_total_skill_level(50) &
self.has(Machine.seed_maker) & self.has_building(Building.shipping_bin), self.has(Machine.seed_maker),
SpecialOrder.lets_play_a_game: self.has_junimo_kart_max_level(), SpecialOrder.lets_play_a_game: self.has_junimo_kart_max_level(),
SpecialOrder.four_precious_stones: self.has_lived_months(MAX_MONTHS) & self.has("Prismatic Shard") & SpecialOrder.four_precious_stones: self.has_lived_months(MAX_MONTHS) & self.has("Prismatic Shard") &
self.can_mine_perfectly_in_the_skull_cavern(), self.can_mine_perfectly_in_the_skull_cavern(),
SpecialOrder.qis_hungry_challenge: self.can_mine_perfectly_in_the_skull_cavern() & self.has_max_buffs(), SpecialOrder.qis_hungry_challenge: self.can_mine_perfectly_in_the_skull_cavern() & self.has_max_buffs(),
SpecialOrder.qis_cuisine: self.can_cook() & (self.can_spend_money_at(Region.saloon, 205000) | self.can_spend_money_at(Region.pierre_store, 170000)) & SpecialOrder.qis_cuisine: self.can_cook() & (self.can_spend_money_at(Region.saloon, 205000) | self.can_spend_money_at(Region.pierre_store, 170000)),
self.can_ship(),
SpecialOrder.qis_kindness: self.can_give_loved_gifts_to_everyone(), SpecialOrder.qis_kindness: self.can_give_loved_gifts_to_everyone(),
SpecialOrder.extended_family: self.can_fish_perfectly() & self.has(Fish.angler) & self.has(Fish.glacierfish) & SpecialOrder.extended_family: self.can_fish_perfectly() & self.has(Fish.angler) & self.has(Fish.glacierfish) &
self.has(Fish.crimsonfish) & self.has(Fish.mutant_carp) & self.has(Fish.legend), self.has(Fish.crimsonfish) & self.has(Fish.mutant_carp) & self.has(Fish.legend),
@@ -1099,8 +1095,6 @@ class StardewLogic:
rules = [self.can_reach_any_region(villager.locations)] rules = [self.can_reach_any_region(villager.locations)]
if npc == NPC.kent: if npc == NPC.kent:
rules.append(self.has_year_two()) rules.append(self.has_year_two())
elif npc == NPC.leo:
rules.append(self.received("Island West Turtle"))
return And(rules) return And(rules)
@@ -1161,7 +1155,7 @@ class StardewLogic:
item_rules.append(bundle_item.item.name) item_rules.append(bundle_item.item.name)
if bundle_item.quality > highest_quality_yet: if bundle_item.quality > highest_quality_yet:
highest_quality_yet = bundle_item.quality highest_quality_yet = bundle_item.quality
return self.can_reach_region(Region.wizard_tower) & self.has(item_rules, number_required) & self.can_grow_gold_quality(highest_quality_yet) return self.has(item_rules, number_required) & self.can_grow_gold_quality(highest_quality_yet)
def can_grow_gold_quality(self, quality: int) -> StardewRule: def can_grow_gold_quality(self, quality: int) -> StardewRule:
if quality <= 0: if quality <= 0:
@@ -1609,9 +1603,3 @@ class StardewLogic:
rules.append(self.received(f"Rarecrow #{rarecrow_number}")) rules.append(self.received(f"Rarecrow #{rarecrow_number}"))
return And(rules) return And(rules)
def can_ship(self, item: str = "") -> StardewRule:
shipping_bin_rule = self.has_building(Building.shipping_bin)
if item == "":
return shipping_bin_rule
return shipping_bin_rule & self.has(item)

View File

@@ -36,7 +36,7 @@ Warning: Currently it is not checked whether a loaded savegame belongs to the mu
The mod adds the following console commands: The mod adds the following console commands:
- `say` sends the text following it to Archipelago as a chat message. - `say` sends the text following it to Archipelago as a chat message.
- For example, to use the [`!hint` command](/tutorial/Archipelago/commands/en#remote-commands), type `say !hint`. - `!` is not an allowed character, use `/` in its place. For example, to use the [`!hint` command](/tutorial/Archipelago/commands/en#remote-commands), type `say /hint`.
- `silent` toggles Archipelago messages appearing. - `silent` toggles Archipelago messages appearing.
- `tracker` rotates through the possible settings for the in-game tracker that displays the closest uncollected location. - `tracker` rotates through the possible settings for the in-game tracker that displays the closest uncollected location.
- `deathlink` toggles death link. - `deathlink` toggles death link.

View File

@@ -10,7 +10,7 @@ joke_hints = [
"You can do it!", "You can do it!",
"I believe in you!", "I believe in you!",
"The person playing is cute. <3", "The person playing is cute. <3",
"dash dot, dash dash dash,\ndash, dot dot dot dot, dot dot,\ndash dot, dash dash dot", "dash dot, dash dash dash, dash, dot dot dot dot, dot dot, dash dot, dash dash dot",
"When you think about it, there are actually a lot of bubbles in a stream.", "When you think about it, there are actually a lot of bubbles in a stream.",
"Never gonna give you up\nNever gonna let you down\nNever gonna run around and desert you", "Never gonna give you up\nNever gonna let you down\nNever gonna run around and desert you",
"Thanks to the Archipelago developers for making this possible.", "Thanks to the Archipelago developers for making this possible.",