mirror of
https://github.com/ArchipelagoMW/Archipelago.git
synced 2026-03-18 21:38:13 -07:00
Compare commits
5 Commits
0.6.1
...
custom_web
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
62b3fd4d37 | ||
|
|
e2f7153312 | ||
|
|
96d4143030 | ||
|
|
a1dcaf52e3 | ||
|
|
aab8f31345 |
@@ -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
|
||||||
|
|
||||||
|
|||||||
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
|
|
||||||
@@ -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,90 +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)}
|
|
||||||
|
|
||||||
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,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
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()}
|
||||||
@@ -1435,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):
|
||||||
@@ -1586,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()
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
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):
|
||||||
"""
|
"""
|
||||||
@@ -807,7 +815,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", {})
|
||||||
|
|||||||
@@ -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>
|
||||||
Reference in New Issue
Block a user