diff --git a/BaseClasses.py b/BaseClasses.py
index 604321a8dd..6f9775b58f 100644
--- a/BaseClasses.py
+++ b/BaseClasses.py
@@ -950,9 +950,12 @@ class Location():
class Item():
location: Optional[Location] = None
world: Optional[MultiWorld] = None
- code: Optional[str] = None # an item with ID None is called an Event, and does not get written to multidata
+ code: Optional[int] = None # an item with ID None is called an Event, and does not get written to multidata
+ name: str
game: str = "Generic"
type: str = None
+ # indicates if this is a negative impact item. Causes these to be handled differently by various games.
+ trap: bool = False
# change manually to ensure that a specific non-progression item never goes on an excluded location
never_exclude = False
@@ -1054,17 +1057,19 @@ class Spoiler():
listed_locations.update(other_locations)
self.shops = []
- from worlds.alttp.Shops import ShopType
+ from worlds.alttp.Shops import ShopType, price_type_display_name, price_rate_display
for shop in self.world.shops:
if not shop.custom:
continue
- shopdata = {'location': str(shop.region),
- 'type': 'Take Any' if shop.type == ShopType.TakeAny else 'Shop'
- }
+ shopdata = {
+ 'location': str(shop.region),
+ 'type': 'Take Any' if shop.type == ShopType.TakeAny else 'Shop'
+ }
for index, item in enumerate(shop.inventory):
if item is None:
continue
- shopdata['item_{}'.format(index)] = "{} — {}".format(item['item'], item['price']) if item['price'] else item['item']
+ my_price = item['price'] // price_rate_display.get(item['price_type'], 1)
+ shopdata['item_{}'.format(index)] = f"{item['item']} — {my_price} {price_type_display_name[item['price_type']]}"
if item['player'] > 0:
shopdata['item_{}'.format(index)] = shopdata['item_{}'.format(index)].replace('—', '(Player {}) — '.format(item['player']))
@@ -1075,7 +1080,7 @@ class Spoiler():
if item['replacement'] is None:
continue
- shopdata['item_{}'.format(index)] += ", {} - {}".format(item['replacement'], item['replacement_price']) if item['replacement_price'] else item['replacement']
+ shopdata['item_{}'.format(index)] += f", {item['replacement']} - {item['replacement_price']} {price_type_display_name[item['replacement_price_type']]}"
self.shops.append(shopdata)
for player in self.world.get_game_players("A Link to the Past"):
diff --git a/CommonClient.py b/CommonClient.py
index 9187159faa..5c52fe8fe3 100644
--- a/CommonClient.py
+++ b/CommonClient.py
@@ -4,12 +4,13 @@ import typing
import asyncio
import urllib.parse
import sys
+import os
import websockets
import Utils
from MultiServer import CommandProcessor
-from NetUtils import Endpoint, decode, NetworkItem, encode, JSONtoTextParser, color, ClientStatus
+from NetUtils import Endpoint, decode, NetworkItem, encode, JSONtoTextParser, ClientStatus, Permission
from Utils import Version
from worlds import network_data_package, AutoWorldRegister
@@ -17,6 +18,9 @@ logger = logging.getLogger("Client")
gui_enabled = Utils.is_frozen() or "--nogui" not in sys.argv
+log_folder = Utils.local_path("logs")
+os.makedirs(log_folder, exist_ok=True)
+
class ClientCommandProcessor(CommandProcessor):
def __init__(self, ctx: CommonContext):
@@ -198,7 +202,7 @@ class CommonContext():
def event_invalid_game(self):
raise Exception('Invalid Game; please verify that you connected with the right game to the correct world.')
- async def server_auth(self, password_requested):
+ async def server_auth(self, password_requested: bool = False):
if password_requested and not self.password:
logger.info('Enter the password required to join this game:')
self.password = await self.console_input()
@@ -315,16 +319,17 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
logger.info("Server protocol tags: " + ", ".join(args["tags"]))
if args['password']:
logger.info('Password required')
- logger.info(f"Forfeit setting: {args['forfeit_mode']}")
- logger.info(f"Remaining setting: {args['remaining_mode']}")
+
+ for permission_name, permission_flag in args.get("permissions", {}).items():
+ flag = Permission(permission_flag)
+ logger.info(f"{permission_name.capitalize()} permission: {flag.name}")
logger.info(
f"A !hint costs {args['hint_cost']}% of your total location count as points"
f" and you get {args['location_check_points']}"
f" for each location checked. Use !hint for more information.")
ctx.hint_cost = int(args['hint_cost'])
ctx.check_points = int(args['location_check_points'])
- ctx.forfeit_mode = args['forfeit_mode']
- ctx.remaining_mode = args['remaining_mode']
+
if len(args['players']) < 1:
logger.info('No player connected')
else:
@@ -452,3 +457,78 @@ async def console_loop(ctx: CommonContext):
commandprocessor(input_text)
except Exception as e:
logger.exception(e)
+
+
+def init_logging(name: str):
+ if gui_enabled:
+ logging.basicConfig(format='[%(name)s]: %(message)s', level=logging.INFO,
+ filename=os.path.join(log_folder, f"{name}.txt"), filemode="w", force=True)
+ else:
+ logging.basicConfig(format='[%(name)s]: %(message)s', level=logging.INFO, force=True)
+ logging.getLogger().addHandler(logging.FileHandler(os.path.join(log_folder, f"{name}.txt"), "w"))
+
+
+if __name__ == '__main__':
+ # Text Mode to use !hint and such with games that have no text entry
+ init_logging("TextClient")
+
+ class TextContext(CommonContext):
+ async def server_auth(self, password_requested: bool = False):
+ if password_requested and not self.password:
+ await super(TextContext, self).server_auth(password_requested)
+ if not self.auth:
+ logger.info('Enter slot name:')
+ self.auth = await self.console_input()
+
+ await self.send_msgs([{"cmd": 'Connect',
+ 'password': self.password, 'name': self.auth, 'version': Utils.version_tuple,
+ 'tags': ['AP', 'IgnoreGame'],
+ 'uuid': Utils.get_unique_identifier(), 'game': self.game
+ }])
+
+ async def main(args):
+ ctx = TextContext(args.connect, args.password)
+ ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop")
+ if gui_enabled:
+ input_task = None
+ from kvui import TextManager
+ ctx.ui = TextManager(ctx)
+ ui_task = asyncio.create_task(ctx.ui.async_run(), name="UI")
+ else:
+ input_task = asyncio.create_task(console_loop(ctx), name="Input")
+ ui_task = None
+ await ctx.exit_event.wait()
+
+ ctx.server_address = None
+ if ctx.server and not ctx.server.socket.closed:
+ await ctx.server.socket.close()
+ if ctx.server_task:
+ await ctx.server_task
+
+ while ctx.input_requests > 0:
+ ctx.input_queue.put_nowait(None)
+ ctx.input_requests -= 1
+
+ if ui_task:
+ await ui_task
+
+ if input_task:
+ input_task.cancel()
+
+
+ import argparse
+ import colorama
+
+ parser = argparse.ArgumentParser(description="Gameless Archipelago Client, for text interfaction.")
+ parser.add_argument('--connect', default=None, help='Address of the multiworld host.')
+ parser.add_argument('--password', default=None, help='Password of the multiworld host.')
+ if not Utils.is_frozen(): # Frozen state has no cmd window in the first place
+ parser.add_argument('--nogui', default=False, action='store_true', help="Turns off Client GUI.")
+
+ args, rest = parser.parse_known_args()
+ colorama.init()
+
+ loop = asyncio.get_event_loop()
+ loop.run_until_complete(main(args))
+ loop.close()
+ colorama.deinit()
diff --git a/FactorioClient.py b/FactorioClient.py
index e7fd0d367d..d9a4d856fa 100644
--- a/FactorioClient.py
+++ b/FactorioClient.py
@@ -10,7 +10,8 @@ import factorio_rcon
import colorama
import asyncio
from queue import Queue
-from CommonClient import CommonContext, server_loop, console_loop, ClientCommandProcessor, logger, gui_enabled
+from CommonClient import CommonContext, server_loop, console_loop, ClientCommandProcessor, logger, gui_enabled, \
+ init_logging
from MultiServer import mark_raw
import Utils
@@ -19,17 +20,7 @@ from NetUtils import NetworkItem, ClientStatus, JSONtoTextParser, JSONMessagePar
from worlds.factorio import Factorio
-log_folder = Utils.local_path("logs")
-
-os.makedirs(log_folder, exist_ok=True)
-
-
-if gui_enabled:
- logging.basicConfig(format='[%(name)s]: %(message)s', level=logging.INFO,
- filename=os.path.join(log_folder, "FactorioClient.txt"), filemode="w", force=True)
-else:
- logging.basicConfig(format='[%(name)s]: %(message)s', level=logging.INFO, force=True)
- logging.getLogger().addHandler(logging.FileHandler(os.path.join(log_folder, "FactorioClient.txt"), "w"))
+init_logging("FactorioClient")
class FactorioCommandProcessor(ClientCommandProcessor):
@@ -66,7 +57,7 @@ class FactorioContext(CommonContext):
self.awaiting_bridge = False
self.factorio_json_text_parser = FactorioJSONtoTextParser(self)
- async def server_auth(self, password_requested):
+ async def server_auth(self, password_requested: bool = False):
if password_requested and not self.password:
await super(FactorioContext, self).server_auth(password_requested)
@@ -99,14 +90,8 @@ class FactorioContext(CommonContext):
return f"AP_{self.seed_name}_{self.auth}.zip"
def print_to_game(self, text):
- # TODO: remove around version 0.2
- if self.mod_version < Utils.Version(0, 1, 6):
- text = text.replace('"', '')
- self.rcon_client.send_command(f"/sc game.print(\"[font=default-large-bold]Archipelago:[/font] "
- f"{text}\")")
- else:
- self.rcon_client.send_command(f"/ap-print [font=default-large-bold]Archipelago:[/font] "
- f"{text}")
+ self.rcon_client.send_command(f"/ap-print [font=default-large-bold]Archipelago:[/font] "
+ f"{text}")
def on_package(self, cmd: str, args: dict):
if cmd == "Connected":
@@ -194,10 +179,6 @@ async def factorio_server_watcher(ctx: FactorioContext):
factorio_server_logger.info(msg)
if not ctx.rcon_client and "Starting RCON interface at IP ADDR:" in msg:
ctx.rcon_client = factorio_rcon.RCONClient("localhost", rcon_port, rcon_password)
- # TODO: remove around version 0.2
- if ctx.mod_version < Utils.Version(0, 1, 6):
- ctx.rcon_client.send_command("/sc game.print('Starting Archipelago Bridge')")
- ctx.rcon_client.send_command("/sc game.print('Starting Archipelago Bridge')")
if not ctx.server:
logger.info("Established bridge to Factorio Server. "
"Ready to connect to Archipelago via /connect")
@@ -278,13 +259,15 @@ async def factorio_spinup_server(ctx: FactorioContext) -> bool:
ctx.exit_event.set()
else:
- logger.info(f"Got World Information from AP Mod {tuple(ctx.mod_version)} for seed {ctx.seed_name} in slot {ctx.auth}")
+ logger.info(
+ f"Got World Information from AP Mod {tuple(ctx.mod_version)} for seed {ctx.seed_name} in slot {ctx.auth}")
return True
finally:
factorio_process.terminate()
factorio_process.wait(5)
return False
+
async def main(args):
ctx = FactorioContext(args.connect, args.password)
ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop")
@@ -355,7 +338,8 @@ if __name__ == '__main__':
args, rest = parser.parse_known_args()
colorama.init()
rcon_port = args.rcon_port
- rcon_password = args.rcon_password if args.rcon_password else ''.join(random.choice(string.ascii_letters) for x in range(32))
+ rcon_password = args.rcon_password if args.rcon_password else ''.join(
+ random.choice(string.ascii_letters) for x in range(32))
factorio_server_logger = logging.getLogger("FactorioServer")
options = Utils.get_options()
diff --git a/LttPClient.py b/LttPClient.py
index 4d499ad244..a9c1d1c2b2 100644
--- a/LttPClient.py
+++ b/LttPClient.py
@@ -26,24 +26,14 @@ from NetUtils import *
from worlds.alttp import Regions, Shops
from worlds.alttp import Items
import Utils
-from CommonClient import CommonContext, server_loop, console_loop, ClientCommandProcessor, gui_enabled
+from CommonClient import CommonContext, server_loop, console_loop, ClientCommandProcessor, gui_enabled, init_logging
+
+init_logging("LttPClient")
snes_logger = logging.getLogger("SNES")
from MultiServer import mark_raw
-log_folder = Utils.local_path("logs")
-os.makedirs(log_folder, exist_ok=True)
-
-# Log to file in gui case
-if gui_enabled:
- logging.basicConfig(format='[%(name)s]: %(message)s', level=logging.INFO,
- filename=os.path.join(log_folder, "LttPClient.txt"), filemode="w", force=True)
-else:
- logging.basicConfig(format='[%(name)s]: %(message)s', level=logging.INFO, force=True)
- logging.getLogger().addHandler(logging.FileHandler(os.path.join(log_folder, "LttPClient.txt"), "w"))
-
-
class LttPCommandProcessor(ClientCommandProcessor):
def _cmd_slow_mode(self, toggle: str = ""):
"""Toggle slow mode, which limits how fast you send / receive items."""
diff --git a/Main.py b/Main.py
index 2f62bf7992..f02aa15535 100644
--- a/Main.py
+++ b/Main.py
@@ -278,6 +278,12 @@ def main(args, seed=None):
for slot in world.player_ids:
slot_data[slot] = world.worlds[slot].fill_slot_data()
+ def precollect_hint(location):
+ hint = NetUtils.Hint(location.item.player, location.player, location.address,
+ location.item.code, False)
+ precollected_hints[location.player].add(hint)
+ precollected_hints[location.item.player].add(hint)
+
locations_data: Dict[int, Dict[int, Tuple[int, int]]] = {player: {} for player in world.player_ids}
for location in world.get_filled_locations():
if type(location.address) == int:
@@ -285,16 +291,11 @@ def main(args, seed=None):
assert location.item.code is not None
locations_data[location.player][location.address] = location.item.code, location.item.player
if location.player in sending_visible_players and location.item.player != location.player:
- hint = NetUtils.Hint(location.item.player, location.player, location.address,
- location.item.code, False)
- precollected_hints[location.player].add(hint)
- precollected_hints[location.item.player].add(hint)
- elif location.item.name in args.start_hints[location.item.player]:
- hint = NetUtils.Hint(location.item.player, location.player, location.address,
- location.item.code, False,
- er_hint_data.get(location.player, {}).get(location.address, ""))
- precollected_hints[location.player].add(hint)
- precollected_hints[location.item.player].add(hint)
+ precollect_hint(location)
+ elif location.name in world.start_location_hints[location.player]:
+ precollect_hint(location)
+ elif location.item.name in world.start_hints[location.item.player]:
+ precollect_hint(location)
multidata = {
"slot_data": slot_data,
diff --git a/MultiServer.py b/MultiServer.py
index ddf98100df..28a510b9a0 100644
--- a/MultiServer.py
+++ b/MultiServer.py
@@ -31,7 +31,7 @@ from worlds import network_data_package, lookup_any_item_id_to_name, lookup_any_
import Utils
from Utils import get_item_name_from_id, get_location_name_from_id, \
version_tuple, restricted_loads, Version
-from NetUtils import Endpoint, ClientStatus, NetworkItem, decode, encode, NetworkPlayer
+from NetUtils import Endpoint, ClientStatus, NetworkItem, decode, encode, NetworkPlayer, Permission
colorama.init()
@@ -469,8 +469,13 @@ async def on_client_connected(ctx: Context, client: Client):
# Name them by feature or fork, as you feel is appropriate.
'tags': ctx.tags,
'version': Utils.version_tuple,
+ # TODO ~0.2.0 remove forfeit_mode and remaining_mode in favor of permissions
'forfeit_mode': ctx.forfeit_mode,
'remaining_mode': ctx.remaining_mode,
+ 'permissions': {
+ "forfeit": Permission.from_text(ctx.forfeit_mode),
+ "remaining": Permission.from_text(ctx.remaining_mode),
+ },
'hint_cost': ctx.hint_cost,
'location_check_points': ctx.location_check_points,
'datapackage_version': network_data_package["version"],
diff --git a/NetUtils.py b/NetUtils.py
index 6b45c3ca33..ee89ccc843 100644
--- a/NetUtils.py
+++ b/NetUtils.py
@@ -25,6 +25,25 @@ class ClientStatus(enum.IntEnum):
CLIENT_GOAL = 30
+class Permission(enum.IntEnum):
+ disabled = 0b000 # 0, completely disables access
+ enabled = 0b001 # 1, allows manual use
+ goal = 0b010 # 2, allows manual use after goal completion
+ auto = 0b110 # 6, forces use after goal completion, only works for forfeit
+ auto_enabled = 0b111 # 7, forces use after goal completion, allows manual use any time
+
+ @staticmethod
+ def from_text(text: str):
+ data = 0
+ if "auto" in text:
+ data |= 0b110
+ elif "goal" in text:
+ data |= 0b010
+ if "enabled" in text:
+ data |= 0b001
+ return Permission(data)
+
+
class NetworkPlayer(typing.NamedTuple):
team: int
slot: int
diff --git a/Options.py b/Options.py
index c28241977d..ffc3b527b0 100644
--- a/Options.py
+++ b/Options.py
@@ -29,7 +29,9 @@ class AssembleOptions(type):
def validate(self, *args, **kwargs):
func(self, *args, **kwargs)
self.value = self.schema.validate(self.value)
+
return validate
+
attrs["__init__"] = validate_decorator(attrs["__init__"])
return super(AssembleOptions, mcs).__new__(mcs, name, bases, attrs)
@@ -241,9 +243,10 @@ class OptionNameSet(Option):
class OptionDict(Option):
default = {}
supports_weighting = False
+ value: typing.Dict[str, typing.Any]
def __init__(self, value: typing.Dict[str, typing.Any]):
- self.value: typing.Dict[str, typing.Any] = value
+ self.value = value
@classmethod
def from_any(cls, data: typing.Dict[str, typing.Any]) -> OptionDict:
@@ -255,8 +258,11 @@ class OptionDict(Option):
def get_option_name(self, value):
return ", ".join(f"{key}: {value}" for key, value in self.value.items())
+ def __contains__(self, item):
+ return item in self.value
-class OptionList(Option, list):
+
+class OptionList(Option):
default = []
supports_weighting = False
value: list
@@ -278,8 +284,11 @@ class OptionList(Option, list):
def get_option_name(self, value):
return ", ".join(self.value)
+ def __contains__(self, item):
+ return item in self.value
-class OptionSet(Option, set):
+
+class OptionSet(Option):
default = frozenset()
supports_weighting = False
value: set
@@ -303,6 +312,9 @@ class OptionSet(Option, set):
def get_option_name(self, value):
return ", ".join(self.value)
+ def __contains__(self, item):
+ return item in self.value
+
local_objective = Toggle # local triforce pieces, local dungeon prizes etc.
@@ -356,6 +368,10 @@ class StartHints(ItemSet):
displayname = "Start Hints"
+class StartLocationHints(OptionSet):
+ displayname = "Start Location Hints"
+
+
class ExcludeLocations(OptionSet):
"""Prevent these locations from having an important item"""
displayname = "Excluded Locations"
@@ -363,11 +379,11 @@ class ExcludeLocations(OptionSet):
per_game_common_options = {
- # placeholder until they're actually implemented
"local_items": LocalItems,
"non_local_items": NonLocalItems,
"start_inventory": StartInventory,
"start_hints": StartHints,
+ "start_location_hints": StartLocationHints,
"exclude_locations": OptionSet
}
diff --git a/README.md b/README.md
index 06a04b1305..0f1e49f63f 100644
--- a/README.md
+++ b/README.md
@@ -10,6 +10,7 @@ Currently, the following games are supported:
* Slay the Spire
* Risk of Rain 2
* The Legend of Zelda: Ocarina of Time
+* Timespinner
For setup and instructions check out our [tutorials page](http://archipelago.gg:48484/tutorial).
Downloads can be found at [Releases](https://github.com/ArchipelagoMW/Archipelago/releases), including compiled
@@ -38,7 +39,7 @@ If you are running Archipelago from a non-Windows system then the likely scenari
## Related Repositories
This project makes use of multiple other projects. We wouldn't be here without these other repositories and the contributions of their developers, past and present.
-* [z3randomizer](https://github.com/CaitSith2/z3randomizer)
+* [z3randomizer](https://github.com/ArchipelagoMW/z3randomizer)
* [Enemizer](https://github.com/Ijwu/Enemizer)
* [Ocarina of Time Randomizer](https://github.com/TestRunnerSRL/OoT-Randomizer)
diff --git a/Utils.py b/Utils.py
index 8ff1834aa9..53e540904b 100644
--- a/Utils.py
+++ b/Utils.py
@@ -13,7 +13,7 @@ class Version(typing.NamedTuple):
build: int
-__version__ = "0.1.8"
+__version__ = "0.1.9"
version_tuple = tuplize_version(__version__)
import builtins
diff --git a/WebHostLib/check.py b/WebHostLib/check.py
index e365765980..8b31ccc3ca 100644
--- a/WebHostLib/check.py
+++ b/WebHostLib/check.py
@@ -49,9 +49,7 @@ def get_yaml_data(file) -> Union[Dict[str, str], str]:
for file in infolist:
if file.filename.endswith(banned_zip_contents):
return "Uploaded data contained a rom file, which is likely to contain copyrighted material. Your file was deleted."
- elif file.filename.endswith(".yaml"):
- options[file.filename] = zfile.open(file, "r").read()
- elif file.filename.endswith(".txt"):
+ elif file.filename.endswith((".yaml", ".json", ".yml", ".txt")):
options[file.filename] = zfile.open(file, "r").read()
else:
options = {file.filename: file.read()}
diff --git a/WebHostLib/static/assets/gameInfo/en_Timespinner.md b/WebHostLib/static/assets/gameInfo/en_Timespinner.md
new file mode 100644
index 0000000000..577b164d9d
--- /dev/null
+++ b/WebHostLib/static/assets/gameInfo/en_Timespinner.md
@@ -0,0 +1,28 @@
+# Timespinner
+
+## Where is the settings page?
+The player settings page for this game is located here. It contains all the options
+you need to configure and export a config file.
+
+## What does randomization do to this game?
+Items which the player would normally acquire throughout the game have been moved around. Logic remains, so the game
+is always able to be completed, but because of the item shuffle the player may need to access certain areas before
+they would in the vanilla game. All rings and spells are also randomized into those item locations, therefor you can no longer craft them at the alchemist
+
+## What is the goal of Timespinner when randomized?
+The goal remains unchanged. Kill the Sandman\Nightmare!
+
+## What items and locations get shuffled?
+All main inventory items, orbs, collectables, and familiers can be shuffled, and all locations in the game which could
+contain any of those items may have their contents changed.
+
+## Which items can be in another player's world?
+Any of the items which can be shuffled may also be placed into another player's world. It is possible to choose to
+limit certain items to your own world.
+
+## What does another world's item look like in Timespinner?
+Items belonging to other worlds are represented by the vanilla item [Elemental Beads](https://timespinnerwiki.com/Use_Items), Elemental Beads have no use in the randomizer
+
+## When the player receives an item, what happens?
+When the player receives an item, the same items popup will be displayed as when you would normally obtain the item
+
diff --git a/WebHostLib/static/assets/ootTracker.js b/WebHostLib/static/assets/ootTracker.js
new file mode 100644
index 0000000000..3c4448a7d7
--- /dev/null
+++ b/WebHostLib/static/assets/ootTracker.js
@@ -0,0 +1,52 @@
+window.addEventListener('load', () => {
+ // Reload tracker every 15 seconds
+ const url = window.location;
+ setInterval(() => {
+ const ajax = new XMLHttpRequest();
+ ajax.onreadystatechange = () => {
+ if (ajax.readyState !== 4) { return; }
+
+ // Create a fake DOM using the returned HTML
+ const domParser = new DOMParser();
+ const fakeDOM = domParser.parseFromString(ajax.responseText, 'text/html');
+
+ // Update item tracker
+ document.getElementById('inventory-table').innerHTML = fakeDOM.getElementById('inventory-table').innerHTML;
+ // Update only counters, small keys, and boss keys in the location-table
+ const types = ['counter', 'smallkeys', 'bosskeys'];
+ for (let j = 0; j < types.length; j++) {
+ let counters = document.getElementsByClassName(types[j]);
+ const fakeCounters = fakeDOM.getElementsByClassName(types[j]);
+ for (let i = 0; i < counters.length; i++) {
+ counters[i].innerHTML = fakeCounters[i].innerHTML;
+ }
+ }
+ };
+ ajax.open('GET', url);
+ ajax.send();
+ }, 15000)
+
+ // Collapsible advancement sections
+ const categories = document.getElementsByClassName("location-category");
+ for (let i = 0; i < categories.length; i++) {
+ let hide_id = categories[i].id.split('-')[0];
+ if (hide_id == 'Total') {
+ continue;
+ }
+ categories[i].addEventListener('click', function() {
+ // Toggle the advancement list
+ document.getElementById(hide_id).classList.toggle("hide");
+ // Change text of the header
+ const tab_header = document.getElementById(hide_id+'-header').children[0];
+ const orig_text = tab_header.innerHTML;
+ let new_text;
+ if (orig_text.includes("▼")) {
+ new_text = orig_text.replace("▼", "▲");
+ }
+ else {
+ new_text = orig_text.replace("▲", "▼");
+ }
+ tab_header.innerHTML = new_text;
+ });
+ }
+});
diff --git a/WebHostLib/static/assets/tutorial/ror2/setup_en.md b/WebHostLib/static/assets/tutorial/ror2/setup_en.md
index df3c63785d..2c8fa68c58 100644
--- a/WebHostLib/static/assets/tutorial/ror2/setup_en.md
+++ b/WebHostLib/static/assets/tutorial/ror2/setup_en.md
@@ -4,13 +4,14 @@
### Install r2modman
Head on over to the r2modman page on Thunderstore and follow the installation instructions.
-https://thunderstore.io/package/ebkr/r2modman/
+[https://thunderstore.io/package/ebkr/r2modman/](https://thunderstore.io/package/ebkr/r2modman/)
### Install Archipelago Mod using r2modman
-You can install the Archipelago mod using r2modman in one of two ways.
-One, you can use the Thunderstore website and click on the "Install with Mod Manager" link.
+You can install the Archipelago mod using r2modman in one of two ways.
-https://thunderstore.io/package/ArchipelagoMW/Archipelago/
+[https://thunderstore.io/package/ArchipelagoMW/Archipelago/](https://thunderstore.io/package/ArchipelagoMW/Archipelago/)
+
+One, you can use the Thunderstore website and click on the "Install with Mod Manager" link.
You can also search for the "Archipelago" mod in the r2modman interface.
The mod manager should automatically install all necessary dependencies as well.
@@ -72,7 +73,7 @@ Risk of Rain 2:
| Name | Description | Allowed values |
| ---- | ----------- | -------------- |
-| total_locations | The total number of location checks that will be attributed to the Risk of Rain player. This option is ALSO the total number of items in the item pool for the Risk of Rain player. | 10 - 50 |
+| total_locations | The total number of location checks that will be attributed to the Risk of Rain player. This option is ALSO the total number of items in the item pool for the Risk of Rain player. | 10 - 100 |
| total_revivals | The total number of items in the Risk of Rain player's item pool (items other players pick up for them) replaced with `Dio's Best Friend`. | 0 - 5 |
| start_with_revive | Starts the player off with a `Dio's Best Friend`. Functionally equivalent to putting a `Dio's Best Friend` in your `starting_inventory`. | true/false |
| item_pickup_step | The number of item pickups which you are allowed to claim before they become an Archipelago location check. | 0 - 5 |
diff --git a/WebHostLib/static/assets/tutorial/timespinner/setup_en.md b/WebHostLib/static/assets/tutorial/timespinner/setup_en.md
new file mode 100644
index 0000000000..9dec349b5e
--- /dev/null
+++ b/WebHostLib/static/assets/tutorial/timespinner/setup_en.md
@@ -0,0 +1,60 @@
+# Timespinner Randomizer Setup Guide
+
+## Required Software
+
+- [Timespinner (steam)](https://store.steampowered.com/app/368620/Timespinner/) or [Timespinner (drm free)](https://www.humblebundle.com/store/timespinner)
+- [Timespinner Randomizer](https://github.com/JarnoWesthof/TsRandomizer)
+
+## General Concept
+
+The timespinner Randomizer loads Timespinner.exe from the same folder, and alters its state in memory to allow for randomization of the items
+
+## Installation Procedures
+
+Download latest version of [Timespinner Randomizer](https://github.com/JarnoWesthof/TsRandomizer) you can find the .zip files on the releases page, download the zip for your current platform. Then extract the zip to the folder where your Timespinner game is installed. Then just run TsRandomizer.exe instead of Timespinner.exe to start the game in randomized mode, for more info see the [readme](https://github.com/JarnoWesthof/TsRandomizer)
+
+## Joining a MultiWorld Game
+
+1. Run TsRandomizer.exe
+2. Select "New Game"
+3. Switch "<< Select Seed >>" to "<< Archiplago >>" by pressing left on the controller or keyboard
+4. Select "<< Archiplago >>" to open a new menu where you can enter your Archipelago login credentails
+ * NOTE: the input fields support Ctrl + V pasting of values
+5. Select "Connect"
+6. If all went well you will be taken back the difficulty selection menu and the game will start as soon as you select a difficulty
+
+## YAML Settings
+An example YAML would look like this:
+```yaml
+description: Default Timespinner Template
+name: Lunais{number} # Your name in-game. Spaces will be replaced with underscores and there is a 16 character limit
+game:
+ Timespinner: 1
+requires:
+ version: 0.1.8
+Timespinner:
+ StartWithJewelryBox: # Start with Jewelry Box unlocked
+ false: 50
+ true: 0
+ DownloadableItems: # With the tablet you will be able to download items at terminals
+ false: 50
+ true: 50
+ FacebookMode: # Requires Oculus Rift(ng) to spot the weakspots in walls and floors
+ false: 50
+ true: 0
+ StartWithMeyef: # Start with Meyef, ideal for when you want to play multiplayer
+ false: 50
+ true: 50
+ QuickSeed: # Start with Talaria Attachment, Nyoom!
+ false: 50
+ true: 0
+ SpecificKeycards: # Keycards can only open corresponding doors
+ false: 0
+ true: 50
+ Inverted: # Start in the past
+ false: 50
+ true: 50
+```
+* All Options are either enabled or not, if values are specified for both true & false the generator will select one based on weight
+* The Timespinner Randomizer option "StinkyMaw" is currently always enabled for Archipelago generated seeds
+* The Timespinner Randomizer options "ProgressiveVerticalMovement" & "ProgressiveKeycards" are currently not supported on Archipelago generated seeds
\ No newline at end of file
diff --git a/WebHostLib/static/assets/tutorial/tutorials.json b/WebHostLib/static/assets/tutorial/tutorials.json
index 4931e4b2f7..b477ea9416 100644
--- a/WebHostLib/static/assets/tutorial/tutorials.json
+++ b/WebHostLib/static/assets/tutorial/tutorials.json
@@ -158,5 +158,24 @@
]
}
]
+ },
+ {
+ "gameTitle": "Timespinner",
+ "tutorials": [
+ {
+ "name": "Multiworld Setup Guide",
+ "description": "A guide to setting up the Timespinner randomizer connected to an Archipelago Multiworld",
+ "files": [
+ {
+ "language": "English",
+ "filename": "timespinner/setup_en.md",
+ "link": "timespinner/setup/en",
+ "authors": [
+ "Jarno"
+ ]
+ }
+ ]
+ }
+ ]
}
]
diff --git a/WebHostLib/static/assets/tutorial/zelda3/multiworld_en.md b/WebHostLib/static/assets/tutorial/zelda3/multiworld_en.md
index ea10c84b51..c93a98d88b 100644
--- a/WebHostLib/static/assets/tutorial/zelda3/multiworld_en.md
+++ b/WebHostLib/static/assets/tutorial/zelda3/multiworld_en.md
@@ -57,7 +57,7 @@ If you would like to validate your YAML file to make sure it works, you may do s
[YAML Validator](/mysterycheck) page.
## Generating a Single-Player Game
-1. Navigate to the [Generate Game](/player-settings), configure your options, and click the "Generate Game" button.
+1. Navigate to the [Player Settings](/games/A%20Link%20to%20the%20Past/player-settings) page, configure your options, and click the "Generate Game" button.
2. You will be presented with a "Seed Info" page, where you can download your patch file.
3. Double-click on your patch file, and the emulator should launch with your game automatically. As the
Client is unnecessary for single player games, you may close it and the WebUI.
diff --git a/WebHostLib/static/assets/tutorial/zelda3/multiworld_es.md b/WebHostLib/static/assets/tutorial/zelda3/multiworld_es.md
index a83bb68b24..b8383b6ac2 100644
--- a/WebHostLib/static/assets/tutorial/zelda3/multiworld_es.md
+++ b/WebHostLib/static/assets/tutorial/zelda3/multiworld_es.md
@@ -43,7 +43,7 @@ Cada jugador en una partida de multiworld proveerá su propio fichero YAML. Esta
que cada jugador disfrute de una experiencia personalizada a su gusto, y cada jugador dentro de la misma partida de multiworld puede tener diferentes opciones.
### Donde puedo obtener un fichero YAML?
-La página "[Generate Game](/player-settings)" en el sitio web te permite configurar tu configuración personal y
+La página "[Generate Game](/games/A%20Link%20to%20the%20Past/player-settings)" en el sitio web te permite configurar tu configuración personal y
descargar un fichero "YAML".
### Configuración YAML avanzada
@@ -67,7 +67,7 @@ Si quieres validar que tu fichero YAML para asegurarte que funciona correctament
[YAML Validator](/mysterycheck).
## Generar una partida para un jugador
-1. Navega a [la pagina Generate game](/player-settings), configura tus opciones, haz click en el boton "Generate game".
+1. Navega a [la pagina Generate game](/games/A%20Link%20to%20the%20Past/player-settings), configura tus opciones, haz click en el boton "Generate game".
2. Se te redigirá a una pagina "Seed Info", donde puedes descargar tu archivo de parche.
3. Haz doble click en tu fichero de parche, y el emulador debería ejecutar tu juego automáticamente. Como el
Cliente no es necesario para partidas de un jugador, puedes cerrarlo junto a la pagina web (que tiene como titulo "Multiworld WebUI") que se ha abierto automáticamente.
diff --git a/WebHostLib/static/assets/tutorial/zelda3/multiworld_fr.md b/WebHostLib/static/assets/tutorial/zelda3/multiworld_fr.md
index 877e75a092..b8e5e71d35 100644
--- a/WebHostLib/static/assets/tutorial/zelda3/multiworld_fr.md
+++ b/WebHostLib/static/assets/tutorial/zelda3/multiworld_fr.md
@@ -49,7 +49,7 @@ sur comment il devrait générer votre seed. Chaque joueur d'un multiwolrd devra
joueur d'apprécier une expérience customisée selon ses goûts, et les différents joueurs d'un même multiworld peuvent avoir différentes options.
### Où est-ce que j'obtiens un fichier YAML ?
-La page [Génération de partie](/player-settings) vous permet de configurer vos paramètres personnels et de les exporter vers un fichier YAML.
+La page [Génération de partie](/games/A%20Link%20to%20the%20Past/player-settings) vous permet de configurer vos paramètres personnels et de les exporter vers un fichier YAML.
### Configuration avancée du fichier YAML
Une version plus avancée du fichier YAML peut être créée en utilisant la page des [paramètres de pondération](/weighted-settings), qui vous permet
@@ -71,7 +71,7 @@ Si vous voulez valider votre fichier YAML pour être sûr qu'il fonctionne, vous
[Validateur de YAML](/mysterycheck).
## Générer une partie pour un joueur
-1. Aller sur la page [Génération de partie](/player-settings), configurez vos options, et cliquez sur le bouton "Generate Game".
+1. Aller sur la page [Génération de partie](/games/A%20Link%20to%20the%20Past/player-settings), configurez vos options, et cliquez sur le bouton "Generate Game".
2. Il vous sera alors présenté une page d'informations sur la seed, où vous pourrez télécharger votre patch.
3. Double-cliquez sur le patch et l'émulateur devrait se lancer automatiquement avec la seed. Etant donné que le client
n'est pas requis pour les parties à un joueur, vous pouvez le fermer ainsi que l'interface Web (WebUI).
diff --git a/WebHostLib/static/styles/ootTracker.css b/WebHostLib/static/styles/ootTracker.css
new file mode 100644
index 0000000000..579ed37716
--- /dev/null
+++ b/WebHostLib/static/styles/ootTracker.css
@@ -0,0 +1,136 @@
+#player-tracker-wrapper{
+ margin: 0;
+}
+
+#inventory-table{
+ border-top: 2px solid #000000;
+ border-left: 2px solid #000000;
+ border-right: 2px solid #000000;
+ border-top-left-radius: 4px;
+ border-top-right-radius: 4px;
+ padding: 3px 3px 10px;
+ width: 448px;
+ background-color: rgb(60, 114, 157);
+}
+
+#inventory-table td{
+ width: 40px;
+ height: 40px;
+ text-align: center;
+ vertical-align: middle;
+}
+
+#inventory-table img{
+ height: 100%;
+ max-width: 40px;
+ max-height: 40px;
+ filter: grayscale(100%) contrast(75%) brightness(30%);
+}
+
+#inventory-table img.acquired{
+ filter: none;
+}
+
+#inventory-table div.counted-item {
+ position: relative;
+}
+
+#inventory-table div.item-count {
+ position: absolute;
+ color: white;
+ font-family: monospace;
+ font-weight: bold;
+ font-size: 1.1em;
+ bottom: 0px;
+ right: 8px;
+}
+
+#location-table{
+ width: 448px;
+ border-left: 2px solid #000000;
+ border-right: 2px solid #000000;
+ border-bottom: 2px solid #000000;
+ border-bottom-left-radius: 4px;
+ border-bottom-right-radius: 4px;
+ background-color: rgb(60, 114, 157);
+ padding: 0 3px 3px;
+ font-family: monospace;
+ font-size: 15px;
+ cursor: default;
+}
+
+#location-table th{
+ vertical-align: middle;
+ text-align: left;
+ padding-right: 10px;
+}
+
+#location-table td{
+ padding-top: 2px;
+ padding-bottom: 2px;
+ line-height: 20px;
+}
+
+#location-table td.counter {
+ text-align: right;
+ font-size: 15px;
+}
+
+#location-table td.toggle-arrow {
+ text-align: right;
+}
+
+#location-table tr#Total-header {
+ font-weight: bold;
+}
+
+#location-table img{
+ height: 100%;
+ max-width: 30px;
+ max-height: 30px;
+}
+
+#location-table tbody.locations {
+ font-size: 13px;
+}
+
+#location-table td.location-name {
+ padding-left: 16px;
+}
+
+.hide {
+ display: none;
+}
+
+.right-align {
+ text-align: right;
+ font-weight: bold;
+}
+
+#location-table td:first-child {
+ width: 272px;
+}
+
+.location-category td:first-child {
+ padding-right: 16px;
+}
+
+#inventory-table img.acquired#lullaby{
+ filter: sepia(100%) hue-rotate(-60deg); /* css trick to hue-shift a static image */
+}
+
+#inventory-table img.acquired#epona{
+ filter: sepia(100%) hue-rotate(-20deg) saturate(250%);
+}
+
+#inventory-table img.acquired#saria{
+ filter: sepia(100%) hue-rotate(60deg) saturate(150%);
+}
+
+#inventory-table img.acquired#sun{
+ filter: sepia(100%) hue-rotate(15deg) saturate(200%) brightness(120%);
+}
+
+#inventory-table img.acquired#time{
+ filter: sepia(100%) hue-rotate(160deg) saturate(150%);
+}
diff --git a/WebHostLib/templates/ootTracker.html b/WebHostLib/templates/ootTracker.html
new file mode 100644
index 0000000000..ea7a6d5a4c
--- /dev/null
+++ b/WebHostLib/templates/ootTracker.html
@@ -0,0 +1,180 @@
+
+
+