mirror of
https://github.com/ArchipelagoMW/Archipelago.git
synced 2026-03-27 04:13:27 -07:00
Compare commits
5 Commits
0.4.2
...
custom_web
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
62b3fd4d37 | ||
|
|
e2f7153312 | ||
|
|
96d4143030 | ||
|
|
a1dcaf52e3 | ||
|
|
aab8f31345 |
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
18
SNIClient.py
18
SNIClient.py
@@ -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
|
||||||
|
|
||||||
|
|||||||
24
WebHost.py
24
WebHost.py
@@ -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"]:
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,7 +37,7 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
<th class="center-column">Checks</th>
|
<th class="center-column">Checks</th>
|
||||||
<th class="center-column">%</th>
|
<th class="center-column">%</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 %}
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
11
setup.py
11
setup.py
@@ -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 []
|
||||||
|
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
393
worlds/alttp/Sprites.py
Normal 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)
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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):
|
||||||
|
|||||||
@@ -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]:
|
||||||
|
|||||||
@@ -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|
|
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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.")
|
|
||||||
@@ -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."
|
||||||
|
|||||||
18
worlds/musedash/test/TestNames.py
Normal file
18
worlds/musedash/test/TestNames.py
Normal 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}"
|
||||||
@@ -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"
|
||||||
25
worlds/musedash/test/TestRemovedSongs.py
Normal file
25
worlds/musedash/test/TestRemovedSongs.py
Normal 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."
|
||||||
@@ -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))
|
||||||
|
|||||||
@@ -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)
|
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
Reference in New Issue
Block a user