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)
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
def hint_text(self) -> str:
return self._hint_text if self._hint_text else self.name

View File

@@ -23,7 +23,8 @@ from urllib.request import urlopen
import ModuleUpdate
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, \
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'}
try:
for address, data in write_list:
while data:
# Divide the write into packets of 256 bytes.
PutAddress_Request['Operands'] = [hex(address)[2:], hex(min(len(data), 256))[2:]]
if ctx.snes_socket is not None:
await ctx.snes_socket.send(dumps(PutAddress_Request))
await ctx.snes_socket.send(data[:256])
address += 256
data = data[256:]
else:
snes_logger.warning(f"Could not send data to SNES: {data}")
PutAddress_Request['Operands'] = [hex(address)[2:], hex(len(data))[2:]]
# REVIEW: above: `if snes_socket is None: return False`
# Does it need to be checked again?
if ctx.snes_socket is not None:
await ctx.snes_socket.send(dumps(PutAddress_Request))
await ctx.snes_socket.send(data)
else:
snes_logger.warning(f"Could not send data to SNES: {data}")
except ConnectionClosed:
return False

View File

@@ -19,8 +19,8 @@ from waitress import serve
from WebHostLib.models import db
from WebHostLib.autolauncher import autohost, autogen
from WebHostLib.lttpsprites import update_sprites_lttp
from WebHostLib.options import create as create_options_files
import worlds
settings.no_gui = True
configpath = os.path.abspath("config.yaml")
@@ -42,6 +42,13 @@ def get_app():
db.bind(**app.config["PONY"])
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
@@ -120,12 +127,17 @@ if __name__ == "__main__":
multiprocessing.freeze_support()
multiprocessing.set_start_method('spawn')
logging.basicConfig(format='[%(asctime)s] %(message)s', level=logging.INFO)
try:
update_sprites_lttp()
except Exception as e:
logging.exception(e)
logging.warning("Could not update LttP sprites.")
for world in worlds.AutoWorldRegister.world_types.values():
try:
world.web.run_webhost_setup()
except Exception as e:
logging.exception(e)
app = get_app()
del world, worlds
create_options_files()
create_ordered_tutorials_file()
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', () => {
const tables = $(".table").DataTable({
paging: false,
@@ -38,18 +27,7 @@ window.addEventListener('load', () => {
stateLoadCallback: function(settings) {
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: [
{
targets: 'last-activity',
name: 'lastActivity'
},
{
targets: 'hours',
render: function (data, type, row) {
@@ -62,7 +40,11 @@ window.addEventListener('load', () => {
if (data === "None")
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") {
target.find(".table").each(function (i, new_table) {
const new_trs = $(new_table).find("tbody>tr");
const footer_tr = $(new_table).find("tfoot>tr");
const old_table = tables.eq(i);
const topscroll = $(old_table.settings()[0].nScrollBody).scrollTop();
const leftscroll = $(old_table.settings()[0].nScrollBody).scrollLeft();
old_table.clear();
if (footer_tr.length) {
$(old_table.table).find("tfoot").html(footer_tr);
}
old_table.rows.add(new_trs);
old_table.draw();
old_table.rows.add(new_trs).draw();
$(old_table.settings()[0].nScrollBody).scrollTop(topscroll);
$(old_table.settings()[0].nScrollBody).scrollLeft(leftscroll);
});

View File

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

View File

@@ -37,7 +37,7 @@
{% endblock %}
<th class="center-column">Checks</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>
</thead>
<tbody>
@@ -64,19 +64,6 @@
</tr>
{%- endfor -%}
</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>
</div>
{% endfor %}

View File

@@ -1,7 +1,7 @@
{%- if enabled_multiworld_trackers|length > 1 -%}
<div id="tracker-navigation">
{% 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 %}"
href="{{ tracker_url }}">{{ enabled_tracker.name }}</a>
{% endfor %}

View File

@@ -1,17 +1,18 @@
import collections
import datetime
import typing
import pkgutil
from typing import Counter, Optional, Dict, Any, Tuple, List
from uuid import UUID
from flask import render_template
from jinja2 import pass_context, runtime
from jinja2 import pass_context, runtime, Template
from werkzeug.exceptions import abort
from MultiServer import Context, get_saving_second
from NetUtils import SlotType, NetworkSlot
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 . import app, cache
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)
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]]:
inventory = {teamnumber: {playernumber: collections.Counter() for playernumber in team_data}
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
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')
@cache.memoize(timeout=60) # multisave is currently created at most every minute
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
}
# 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] = {
"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}\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}\ArchipelagoServer.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: server
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 = [
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 ""),
icon=resolve_icon(c.icon),
base="Win32GUI" if is_windows and not c.cli else None
) 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_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 . import GamesPackage
from settings import Group
from flask import Flask
class AutoWorldRegister(type):
@@ -155,9 +156,22 @@ class WebWorld:
"""Choose a theme for you /game/* pages.
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."""
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):
"""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)
return res
# any methods attached to this can be used as part of CollectionState,
# please use a prefix as all of them get clobbered together
class LogicMixin(metaclass=AutoLogicRegister):

View File

@@ -7,21 +7,19 @@ LTTPJPN10HASH: str = "03a63945398191337e896e5771f77173"
RANDOMIZERBASEHASH: str = "9952c2a3ec1b421e408df0d20c8f0c7f"
ROM_PLAYER_LIMIT: int = 255
import io
import json
import hashlib
import logging
import os
import random
import struct
import subprocess
import threading
import concurrent.futures
import bsdiff4
from typing import Optional, List
from typing import List
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 .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 .EntranceShuffle import door_addresses
from .Options import smallkey_shuffle
from .Sprites import apply_random_sprite_on_event
try:
from maseya import z3pr
@@ -212,73 +211,6 @@ def check_enemizer(enemizercli):
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):
player = world.player
multiworld = world.multiworld
@@ -487,271 +419,6 @@ class TileSet:
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,
0x4D09E, 0x4D0A8, 0x4D0AB, 0x4D0AE, 0x4D0BE, 0x4D0DD,
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!')
if world.players == 1:
world.get_region('Menu', player).can_reach_private = lambda state: True
no_logic_rules(world, player)
for exit in world.get_region('Menu', player).exits:
exit.hide_path = True
@@ -195,6 +196,7 @@ def global_rules(world, player):
add_item_rule(world.get_location(prize_location, 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
world.get_region('Menu', player).can_reach_private = lambda state: True
for exit in world.get_region('Menu', player).exits:
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]
@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):
"""
@@ -807,7 +815,6 @@ class ALTTPWorld(World):
}
)
return slot_data
def get_same_seed(world, seed_def: tuple) -> str:
seeds: typing.Dict[tuple, str] = getattr(world, "__named_seeds", {})

View File

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

View File

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

View File

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

View File

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

View File

@@ -464,8 +464,4 @@ Songs Are Judged 90% by Chorus feat. Mameko|64-3|COSMIC RADIO PEROLIST|True|6|8|
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|
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
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|
mew mew magical summer|64-7|COSMIC RADIO PEROLIST|False|5|8|10|11

View File

@@ -40,14 +40,14 @@ class MuseDashWorld(World):
game = "Muse Dash"
option_definitions = musedash_options
topology_present = False
data_version = 9
data_version = 8
web = MuseDashWebWorld()
# 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()}
location_name_to_id = {name: code for name, code in md_collection.location_names_to_id.items()}
item_name_to_id = md_collection.item_names_to_id
location_name_to_id = md_collection.location_names_to_id
# Working Data
victory_song_name: str = ""
@@ -167,12 +167,11 @@ class MuseDashWorld(World):
if trap:
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)
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:
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]
def test_range(inputRange, lower, upper):
self.assertEqual(inputRange[0], lower)
self.assertEqual(inputRange[1], upper)
assert inputRange[0] == lower and 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])
for songKey in songs:
@@ -24,7 +24,7 @@ class DifficultyRanges(MuseDashTestBase):
if (song.master is not None and inputRange[0] <= song.master <= inputRange[1]):
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
difficulty_choice.value = 0
@@ -65,5 +65,5 @@ class DifficultyRanges(MuseDashTestBase):
for song_name in muse_dash_world.md_collection.DIFF_OVERRIDES:
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,
f"Song '{song_name}' difficulty not set when it should be.")
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."

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
class TestPlandoSettings(MuseDashTestBase):
class TestIncludedSongSizeDoesntGrow(MuseDashTestBase):
options = {
"additional_song_count": 15,
"allow_just_as_planned_dlc_songs": True,
@@ -14,14 +14,14 @@ class TestPlandoSettings(MuseDashTestBase):
def test_included_songs_didnt_grow_item_count(self) -> None:
muse_dash_world = self.multiworld.worlds[1]
self.assertEqual(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)}")
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)}"
def test_included_songs_plando(self) -> None:
muse_dash_world = self.multiworld.worlds[1]
songs = muse_dash_world.included_songs.copy()
songs.append(muse_dash_world.victory_song_name)
self.assertIn("Operation Blade", songs, "Logical songs is missing a plando song: Operation Blade")
self.assertIn("Autumn Moods", songs, "Logical songs is missing a plando song: Autumn Moods")
self.assertIn("Fireflies", songs, "Logical songs is missing a plando song: Fireflies")
assert "Operation Blade" in songs, "Logical songs is missing a plando song: Operation Blade"
assert "Autumn Moods" in songs, "Logical songs is missing a plando song: Autumn Moods"
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, "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, "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, "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))

View File

@@ -492,39 +492,35 @@ class StardewLogic:
})
self.special_order_rules.update({
SpecialOrder.island_ingredients: self.can_meet(NPC.caroline) & self.has_island_transport() & self.can_farm_perfectly() &
self.can_ship(Vegetable.taro_root) & self.can_ship(Fruit.pineapple) & self.can_ship(Forageable.ginger),
SpecialOrder.cave_patrol: self.can_meet(NPC.clint) & self.can_mine_perfectly() & self.can_mine_to_floor(120),
SpecialOrder.aquatic_overpopulation: self.can_meet(NPC.demetrius) & self.can_fish_perfectly(),
SpecialOrder.biome_balance: self.can_meet(NPC.demetrius) & self.can_fish_perfectly(),
SpecialOrder.rock_rejuivenation: self.has_relationship(NPC.emily, 4) & self.has(Mineral.ruby) & self.has(Mineral.topaz) &
self.has(Mineral.emerald) & self.has(Mineral.jade) & self.has(Mineral.amethyst) &
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.fragments_of_the_past: self.can_reach_region(Region.museum) & self.can_reach_region(Region.dig_site) & self.has_tool(Tool.pickaxe),
SpecialOrder.gus_famous_omelet: self.can_reach_region(Region.saloon) & self.has(AnimalProduct.any_egg),
SpecialOrder.crop_order: self.can_farm_perfectly() & self.can_ship(),
SpecialOrder.community_cleanup: self.can_reach_region(Region.railroad) & self.can_crab_pot(),
SpecialOrder.the_strong_stuff: self.can_reach_region(Region.trailer) & self.can_keg(Vegetable.potato),
SpecialOrder.pierres_prime_produce: self.can_reach_region(Region.pierre_store) & self.can_farm_perfectly(),
SpecialOrder.robins_project: self.can_meet(NPC.robin) & self.can_reach_region(Region.carpenter) & self.can_chop_perfectly() &
self.has(Material.hardwood),
SpecialOrder.robins_resource_rush: self.can_meet(NPC.robin) & self.can_reach_region(Region.carpenter) & self.can_chop_perfectly() &
self.has(Fertilizer.tree) & self.can_mine_perfectly(),
SpecialOrder.juicy_bugs_wanted_yum: self.can_reach_region(Region.beach) & self.has(Loot.bug_meat),
SpecialOrder.tropical_fish: self.can_meet(NPC.willy) & self.received("Island Resort") & self.has_island_transport() &
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.island_ingredients: self.has_island_transport() & self.can_farm_perfectly() &
self.has(Vegetable.taro_root) & self.has(Fruit.pineapple) & self.has(Forageable.ginger),
SpecialOrder.cave_patrol: self.can_mine_perfectly() & self.can_mine_to_floor(120),
SpecialOrder.aquatic_overpopulation: self.can_fish_perfectly(),
SpecialOrder.biome_balance: self.can_fish_perfectly(),
SpecialOrder.rock_rejuivenation: self.has(Mineral.ruby) & self.has(Mineral.topaz) & self.has(Mineral.emerald) &
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),
SpecialOrder.gifts_for_george: self.has_season(Season.spring) & self.has(Forageable.leek),
SpecialOrder.fragments_of_the_past: self.can_reach_region(Region.dig_site),
SpecialOrder.gus_famous_omelet: self.has(AnimalProduct.any_egg),
SpecialOrder.crop_order: self.can_farm_perfectly(),
SpecialOrder.community_cleanup: self.can_crab_pot(),
SpecialOrder.the_strong_stuff: self.can_keg(Vegetable.potato),
SpecialOrder.pierres_prime_produce: self.can_farm_perfectly(),
SpecialOrder.robins_project: self.can_chop_perfectly() & self.has(Material.hardwood),
SpecialOrder.robins_resource_rush: self.can_chop_perfectly() & self.has(Fertilizer.tree) & self.can_mine_perfectly(),
SpecialOrder.juicy_bugs_wanted_yum: self.has(Loot.bug_meat),
SpecialOrder.tropical_fish: self.has_island_transport() & self.has(Fish.stingray) & self.has(Fish.blue_discus) & self.has(Fish.lionfish),
SpecialOrder.a_curious_substance: self.can_mine_perfectly() & self.can_mine_to_floor(80),
SpecialOrder.prismatic_jelly: self.can_mine_perfectly() & self.can_mine_to_floor(40),
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.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.four_precious_stones: self.has_lived_months(MAX_MONTHS) & self.has("Prismatic Shard") &
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_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_cuisine: self.can_cook() & (self.can_spend_money_at(Region.saloon, 205000) | self.can_spend_money_at(Region.pierre_store, 170000)),
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) &
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)]
if npc == NPC.kent:
rules.append(self.has_year_two())
elif npc == NPC.leo:
rules.append(self.received("Island West Turtle"))
return And(rules)
@@ -1161,7 +1155,7 @@ class StardewLogic:
item_rules.append(bundle_item.item.name)
if bundle_item.quality > highest_quality_yet:
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:
if quality <= 0:
@@ -1609,9 +1603,3 @@ class StardewLogic:
rules.append(self.received(f"Rarecrow #{rarecrow_number}"))
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:
- `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.
- `tracker` rotates through the possible settings for the in-game tracker that displays the closest uncollected location.
- `deathlink` toggles death link.

View File

@@ -10,7 +10,7 @@ joke_hints = [
"You can do it!",
"I believe in you!",
"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.",
"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.",