mirror of
https://github.com/MarioSpore/Grinch-AP.git
synced 2025-10-21 12:11:33 -06:00
WebHost: Move module into WebHostLib to prevent shadowing WebHost.py
This commit is contained in:
112
WebHostLib/__init__.py
Normal file
112
WebHostLib/__init__.py
Normal file
@@ -0,0 +1,112 @@
|
||||
"""Friendly reminder that if you want to host this somewhere on the internet, that it's licensed under MIT Berserker66
|
||||
So unless you're Berserker you need to include license information."""
|
||||
|
||||
import os
|
||||
import threading
|
||||
|
||||
from pony.flask import Pony
|
||||
from flask import Flask, request, redirect, url_for, render_template, Response, session, abort
|
||||
from flask_caching import Cache
|
||||
from flaskext.autoversion import Autoversion
|
||||
from flask_compress import Compress
|
||||
|
||||
from .models import *
|
||||
|
||||
UPLOAD_FOLDER = os.path.relpath('uploads')
|
||||
LOGS_FOLDER = os.path.relpath('logs')
|
||||
os.makedirs(LOGS_FOLDER, exist_ok=True)
|
||||
|
||||
|
||||
def allowed_file(filename):
|
||||
return filename.endswith(('multidata', ".zip"))
|
||||
|
||||
|
||||
app = Flask(__name__)
|
||||
Pony(app)
|
||||
|
||||
app.config["SELFHOST"] = True
|
||||
app.config["SELFLAUNCH"] = True
|
||||
app.config["DEBUG"] = False
|
||||
app.config["PORT"] = 80
|
||||
app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER
|
||||
app.config['MAX_CONTENT_LENGTH'] = 4 * 1024 * 1024 # 4 megabyte limit
|
||||
# if you want persistent sessions on your server, make sure you make this a constant in your config.yaml
|
||||
app.config["SECRET_KEY"] = os.urandom(32)
|
||||
app.config['SESSION_PERMANENT'] = True
|
||||
app.config[
|
||||
"WAITRESS_THREADS"] = 10 # waitress uses one thread for I/O, these are for processing of views that then get sent
|
||||
app.config["PONY"] = {
|
||||
'provider': 'sqlite',
|
||||
'filename': os.path.abspath('db.db3'),
|
||||
'create_db': True
|
||||
}
|
||||
app.config["CACHE_TYPE"] = "simple"
|
||||
app.autoversion = True
|
||||
av = Autoversion(app)
|
||||
cache = Cache(app)
|
||||
Compress(app)
|
||||
|
||||
# this local cache is risky business if app hosting is done with subprocesses as it will not sync. Waitress is fine though
|
||||
|
||||
|
||||
@app.before_request
|
||||
def register_session():
|
||||
session.permanent = True # technically 31 days after the last visit
|
||||
if not session.get("_id", None):
|
||||
session["_id"] = uuid4() # uniquely identify each session without needing a login
|
||||
|
||||
|
||||
@app.route('/seed/<uuid:seed>')
|
||||
def view_seed(seed: UUID):
|
||||
seed = Seed.get(id=seed)
|
||||
if not seed:
|
||||
abort(404)
|
||||
return render_template("view_seed.html", seed=seed,
|
||||
rooms=[room for room in seed.rooms if room.owner == session["_id"]])
|
||||
|
||||
|
||||
@app.route('/new_room/<uuid:seed>')
|
||||
def new_room(seed: UUID):
|
||||
seed = Seed.get(id=seed)
|
||||
if not seed:
|
||||
abort(404)
|
||||
room = Room(seed=seed, owner=session["_id"], tracker=uuid4())
|
||||
commit()
|
||||
return redirect(url_for("host_room", room=room.id))
|
||||
|
||||
|
||||
def _read_log(path: str):
|
||||
if os.path.exists(path):
|
||||
with open(path, encoding="utf-8-sig") as log:
|
||||
yield from log
|
||||
else:
|
||||
yield f"Logfile {path} does not exist. " \
|
||||
f"Likely a crash during spinup of multiworld instance or it is still spinning up."
|
||||
|
||||
|
||||
@app.route('/log/<uuid:room>')
|
||||
def display_log(room: UUID):
|
||||
# noinspection PyTypeChecker
|
||||
return Response(_read_log(os.path.join("logs", str(room) + ".txt")), mimetype="text/plain;charset=UTF-8")
|
||||
|
||||
|
||||
|
||||
@app.route('/hosted/<uuid:room>', methods=['GET', 'POST'])
|
||||
def host_room(room: UUID):
|
||||
room = Room.get(id=room)
|
||||
if room is None:
|
||||
return abort(404)
|
||||
if request.method == "POST":
|
||||
if room.owner == session["_id"]:
|
||||
cmd = request.form["cmd"]
|
||||
Command(room=room, commandtext=cmd)
|
||||
commit()
|
||||
|
||||
with db_session:
|
||||
room.last_activity = datetime.utcnow() # will trigger a spinup, if it's not already running
|
||||
|
||||
return render_template("host_room.html", room=room)
|
||||
|
||||
|
||||
from WebHostLib.customserver import run_server_process
|
||||
from . import tracker, upload, landing # to trigger app routing picking up on it
|
121
WebHostLib/autolauncher.py
Normal file
121
WebHostLib/autolauncher.py
Normal file
@@ -0,0 +1,121 @@
|
||||
from __future__ import annotations
|
||||
import logging
|
||||
import multiprocessing
|
||||
from datetime import timedelta, datetime
|
||||
import sys
|
||||
import typing
|
||||
|
||||
from pony.orm import db_session, select
|
||||
|
||||
|
||||
class CommonLocker():
|
||||
"""Uses a file lock to signal that something is already running"""
|
||||
|
||||
def __init__(self, lockname: str):
|
||||
self.lockname = lockname
|
||||
self.lockfile = f"./{self.lockname}.lck"
|
||||
|
||||
|
||||
class AlreadyRunningException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
if sys.platform == 'win32':
|
||||
import os
|
||||
|
||||
|
||||
class Locker(CommonLocker):
|
||||
def __enter__(self):
|
||||
try:
|
||||
if os.path.exists(self.lockfile):
|
||||
os.unlink(self.lockfile)
|
||||
self.fp = os.open(
|
||||
self.lockfile, os.O_CREAT | os.O_EXCL | os.O_RDWR)
|
||||
except OSError as e:
|
||||
raise AlreadyRunningException() from e
|
||||
|
||||
def __exit__(self, _type, value, tb):
|
||||
fp = getattr(self, "fp", None)
|
||||
if fp:
|
||||
os.close(self.fp)
|
||||
os.unlink(self.lockfile)
|
||||
else: # unix
|
||||
import fcntl
|
||||
|
||||
|
||||
class Locker(CommonLocker):
|
||||
def __enter__(self):
|
||||
try:
|
||||
self.fp = open(self.lockfile, "wb")
|
||||
fcntl.flock(self.fp.fileno(), fcntl.LOCK_EX)
|
||||
except OSError as e:
|
||||
raise AlreadyRunningException() from e
|
||||
|
||||
def __exit__(self, _type, value, tb):
|
||||
fcntl.flock(self.fp.fileno(), fcntl.LOCK_UN)
|
||||
self.fp.close()
|
||||
|
||||
|
||||
def launch_room(room: Room, config: dict):
|
||||
# requires db_session!
|
||||
if room.last_activity >= datetime.utcnow() - timedelta(seconds=room.timeout):
|
||||
multiworld = multiworlds.get(room.id, None)
|
||||
if not multiworld:
|
||||
multiworld = MultiworldInstance(room, config)
|
||||
|
||||
multiworld.start()
|
||||
|
||||
|
||||
def autohost(config: dict):
|
||||
import time
|
||||
|
||||
def keep_running():
|
||||
try:
|
||||
with Locker("autohost"):
|
||||
logging.info("Starting autohost service")
|
||||
# db.bind(**config["PONY"])
|
||||
# db.generate_mapping(check_tables=False)
|
||||
while 1:
|
||||
time.sleep(3)
|
||||
with db_session:
|
||||
rooms = select(
|
||||
room for room in Room if
|
||||
room.last_activity >= datetime.utcnow() - timedelta(days=3))
|
||||
for room in rooms:
|
||||
launch_room(room, config)
|
||||
|
||||
except AlreadyRunningException:
|
||||
pass
|
||||
|
||||
import threading
|
||||
threading.Thread(target=keep_running).start()
|
||||
|
||||
|
||||
multiworlds = {}
|
||||
|
||||
|
||||
class MultiworldInstance():
|
||||
def __init__(self, room: Room, config: dict):
|
||||
self.room_id = room.id
|
||||
self.process: typing.Optional[multiprocessing.Process] = None
|
||||
multiworlds[self.room_id] = self
|
||||
self.ponyconfig = config["PONY"]
|
||||
|
||||
def start(self):
|
||||
if self.process and self.process.is_alive():
|
||||
return False
|
||||
|
||||
logging.info(f"Spinning up {self.room_id}")
|
||||
self.process = multiprocessing.Process(group=None, target=run_server_process,
|
||||
args=(self.room_id, self.ponyconfig),
|
||||
name="MultiHost")
|
||||
self.process.start()
|
||||
|
||||
def stop(self):
|
||||
if self.process:
|
||||
self.process.terminate()
|
||||
self.process = None
|
||||
|
||||
|
||||
from .models import Room
|
||||
from .customserver import run_server_process
|
142
WebHostLib/customserver.py
Normal file
142
WebHostLib/customserver.py
Normal file
@@ -0,0 +1,142 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import logging
|
||||
import os
|
||||
import websockets
|
||||
import asyncio
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
import random
|
||||
|
||||
|
||||
from .models import *
|
||||
|
||||
from MultiServer import Context, server, auto_shutdown, ServerCommandProcessor, ClientMessageProcessor
|
||||
from Utils import get_public_ipv4, get_public_ipv6
|
||||
|
||||
|
||||
class CustomClientMessageProcessor(ClientMessageProcessor):
|
||||
ctx: WebHostContext
|
||||
def _cmd_video(self, platform, user):
|
||||
"""Set a link for your name in the WebHostLib tracker pointing to a video stream"""
|
||||
if platform.lower().startswith("t"): # twitch
|
||||
self.ctx.video[self.client.team, self.client.slot] = "Twitch", user
|
||||
self.ctx.save()
|
||||
self.output(f"Registered Twitch Stream https://www.twitch.tv/{user}")
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# inject
|
||||
import MultiServer
|
||||
MultiServer.client_message_processor = CustomClientMessageProcessor
|
||||
del (MultiServer)
|
||||
|
||||
|
||||
class DBCommandProcessor(ServerCommandProcessor):
|
||||
def output(self, text: str):
|
||||
logging.info(text)
|
||||
|
||||
|
||||
class WebHostContext(Context):
|
||||
def __init__(self):
|
||||
super(WebHostContext, self).__init__("", 0, "", 1, 40, True, "enabled", "enabled", 0)
|
||||
self.main_loop = asyncio.get_running_loop()
|
||||
self.video = {}
|
||||
self.tags = ["Berserker", "WebHostLib"]
|
||||
|
||||
def listen_to_db_commands(self):
|
||||
cmdprocessor = DBCommandProcessor(self)
|
||||
|
||||
while self.running:
|
||||
with db_session:
|
||||
commands = select(command for command in Command if command.room.id == self.room_id)
|
||||
if commands:
|
||||
for command in commands:
|
||||
self.main_loop.call_soon_threadsafe(cmdprocessor, command.commandtext)
|
||||
command.delete()
|
||||
commit()
|
||||
time.sleep(5)
|
||||
|
||||
@db_session
|
||||
def load(self, room_id: int):
|
||||
self.room_id = room_id
|
||||
room = Room.get(id=room_id)
|
||||
if room.last_port:
|
||||
self.port = room.last_port
|
||||
else:
|
||||
self.port = get_random_port()
|
||||
return self._load(room.seed.multidata, True)
|
||||
|
||||
@db_session
|
||||
def init_save(self, enabled: bool = True):
|
||||
self.saving = enabled
|
||||
if self.saving:
|
||||
existings_savegame = Room.get(id=self.room_id).multisave
|
||||
if existings_savegame:
|
||||
self.set_save(existings_savegame)
|
||||
self._start_async_saving()
|
||||
threading.Thread(target=self.listen_to_db_commands, daemon=True).start()
|
||||
|
||||
@db_session
|
||||
def _save(self) -> bool:
|
||||
room = Room.get(id=self.room_id)
|
||||
room.multisave = self.get_save()
|
||||
# saving only occurs on activity, so we can "abuse" this information to mark this as last_activity
|
||||
room.last_activity = datetime.utcnow()
|
||||
return True
|
||||
|
||||
def get_save(self) -> dict:
|
||||
d = super(WebHostContext, self).get_save()
|
||||
d["video"] = [(tuple(playerslot), videodata) for playerslot, videodata in self.video.items()]
|
||||
return d
|
||||
|
||||
def get_random_port():
|
||||
return random.randint(49152, 65535)
|
||||
|
||||
def run_server_process(room_id, ponyconfig: dict):
|
||||
# establish DB connection for multidata and multisave
|
||||
db.bind(**ponyconfig)
|
||||
db.generate_mapping(check_tables=False)
|
||||
|
||||
async def main():
|
||||
|
||||
logging.basicConfig(format='[%(asctime)s] %(message)s',
|
||||
level=logging.INFO,
|
||||
handlers=[
|
||||
logging.FileHandler(os.path.join(LOGS_FOLDER, f"{room_id}.txt"), 'a', 'utf-8-sig')])
|
||||
ctx = WebHostContext()
|
||||
ctx.load(room_id)
|
||||
ctx.init_save()
|
||||
|
||||
try:
|
||||
ctx.server = websockets.serve(functools.partial(server, ctx=ctx), ctx.host, ctx.port, ping_timeout=None,
|
||||
ping_interval=None)
|
||||
|
||||
await ctx.server
|
||||
except Exception: # likely port in use - in windows this is OSError, but I didn't check the others
|
||||
ctx.server = websockets.serve(functools.partial(server, ctx=ctx), ctx.host, 0, ping_timeout=None,
|
||||
ping_interval=None)
|
||||
|
||||
await ctx.server
|
||||
for wssocket in ctx.server.ws_server.sockets:
|
||||
socketname = wssocket.getsockname()
|
||||
if wssocket.family == socket.AF_INET6:
|
||||
logging.info(f'Hosting game at [{get_public_ipv6()}]:{socketname[1]}')
|
||||
with db_session:
|
||||
room = Room.get(id=ctx.room_id)
|
||||
room.last_port = socketname[1]
|
||||
elif wssocket.family == socket.AF_INET:
|
||||
logging.info(f'Hosting game at {get_public_ipv4()}:{socketname[1]}')
|
||||
with db_session:
|
||||
ctx.auto_shutdown = Room.get(id=room_id).timeout
|
||||
ctx.shutdown_task = asyncio.create_task(auto_shutdown(ctx, []))
|
||||
await ctx.shutdown_task
|
||||
logging.info("Shutting down")
|
||||
|
||||
asyncio.run(main())
|
||||
|
||||
|
||||
from WebHostLib import LOGS_FOLDER
|
8
WebHostLib/landing.py
Normal file
8
WebHostLib/landing.py
Normal file
@@ -0,0 +1,8 @@
|
||||
from flask import render_template
|
||||
from WebHostLib import app, cache
|
||||
|
||||
|
||||
@cache.memoize(timeout=300)
|
||||
@app.route('/', methods=['GET', 'POST'])
|
||||
def landing():
|
||||
return render_template("landing.html")
|
42
WebHostLib/models.py
Normal file
42
WebHostLib/models.py
Normal file
@@ -0,0 +1,42 @@
|
||||
from datetime import datetime
|
||||
from uuid import UUID, uuid4
|
||||
from pony.orm import *
|
||||
|
||||
db = Database()
|
||||
|
||||
|
||||
class Patch(db.Entity):
|
||||
id = PrimaryKey(int, auto=True)
|
||||
player = Required(int)
|
||||
data = Required(buffer, lazy=True)
|
||||
seed = Optional('Seed')
|
||||
|
||||
|
||||
class Room(db.Entity):
|
||||
id = PrimaryKey(UUID, default=uuid4)
|
||||
last_activity = Required(datetime, default=lambda: datetime.utcnow(), index=True)
|
||||
creation_time = Required(datetime, default=lambda: datetime.utcnow())
|
||||
owner = Required(UUID, index=True)
|
||||
commands = Set('Command')
|
||||
seed = Required('Seed', index=True)
|
||||
multisave = Optional(Json, lazy=True)
|
||||
show_spoiler = Required(int, default=0) # 0 -> never, 1 -> after completion, -> 2 always
|
||||
timeout = Required(int, default=lambda: 6 * 60 * 60) # seconds since last activity to shutdown
|
||||
tracker = Optional(UUID, index=True)
|
||||
last_port = Optional(int, default=lambda: 0)
|
||||
|
||||
|
||||
class Seed(db.Entity):
|
||||
id = PrimaryKey(UUID, default=uuid4)
|
||||
rooms = Set(Room)
|
||||
multidata = Optional(Json, lazy=True)
|
||||
owner = Required(UUID, index=True)
|
||||
creation_time = Required(datetime, default=lambda: datetime.utcnow())
|
||||
patches = Set(Patch)
|
||||
spoiler = Optional(str, lazy=True)
|
||||
|
||||
|
||||
class Command(db.Entity):
|
||||
id = PrimaryKey(int, auto=True)
|
||||
room = Required(Room)
|
||||
commandtext = Required(str)
|
6
WebHostLib/requirements.txt
Normal file
6
WebHostLib/requirements.txt
Normal file
@@ -0,0 +1,6 @@
|
||||
flask>=1.1.2
|
||||
pony>=0.7.13
|
||||
waitress>=1.4.4
|
||||
flask-caching>=1.9.0
|
||||
Flask-Autoversion>=0.2.0
|
||||
Flask-Compress>=1.5.0
|
114
WebHostLib/static/jquery.scrollsync.js
Normal file
114
WebHostLib/static/jquery.scrollsync.js
Normal file
@@ -0,0 +1,114 @@
|
||||
(function (global, factory) {
|
||||
typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports, require('jquery')) :
|
||||
typeof define === 'function' && define.amd ? define(['exports', 'jquery'], factory) :
|
||||
(factory((global.$ = global.$ || {}, global.$.fn = global.$.fn || {}), global.$));
|
||||
}(this, (function (exports, $) {
|
||||
'use strict';
|
||||
|
||||
$ = $ && $.hasOwnProperty('default') ? $['default'] : $;
|
||||
|
||||
// 参考了(reference):
|
||||
// debouncing function from John Hann
|
||||
// http://unscriptable.com/index.php/2009/03/20/debouncing-javascript-methods/
|
||||
function debounce(func, threshold) {
|
||||
var timeout;
|
||||
return function debounced() {
|
||||
var obj = this, args = arguments;
|
||||
|
||||
function delayed() {
|
||||
// 让调用smartresize的对象执行
|
||||
func.apply(obj, args);
|
||||
/*
|
||||
timeout = null;:这个语句只是单纯将timeout指向null,
|
||||
而timeout指向的定时器还存在,
|
||||
要想清除定时器(让setTimeout调用的函数不执行)要用clearTimeout(timeout)。
|
||||
eg:
|
||||
var timeout = setTimeout(function(){
|
||||
alert('timeout = null');// 执行
|
||||
},1000);
|
||||
timeout = null;
|
||||
var timeout = setTimeout(function(){
|
||||
alert('clearTimeout(timeout)');// 不执行
|
||||
},1000);
|
||||
clearTimeout(timeout);
|
||||
var timeout = setTimeout(function(){
|
||||
clearTimeout(timeout);
|
||||
alert('clearTimeout(timeout)');// 执行(已经开始执行匿名函数了)
|
||||
},1000);
|
||||
*/
|
||||
timeout = null;
|
||||
}
|
||||
|
||||
// 如果有timeout正在倒计时,则清除当前timeout
|
||||
timeout && clearTimeout(timeout);
|
||||
timeout = setTimeout(delayed, threshold || 100);
|
||||
};
|
||||
}
|
||||
|
||||
function smartscroll(fn, threshold) {
|
||||
return fn ? this.bind('scroll', debounce(fn, threshold)) : this.trigger('smartscroll');
|
||||
}
|
||||
|
||||
//jquery-smartscroll
|
||||
$.fn.smartscroll = smartscroll;
|
||||
|
||||
function scrollsync(options) {
|
||||
var defaluts = {
|
||||
x_sync: true,
|
||||
y_sync: true,
|
||||
use_smartscroll: false,
|
||||
smartscroll_delay: 10,
|
||||
};
|
||||
|
||||
// 使用jQuery.extend 覆盖插件默认参数
|
||||
var options = $.extend({}, defaluts, options);
|
||||
console.log(options);
|
||||
|
||||
var scroll_type = options.use_smartscroll ? 'smartscroll' : 'scroll';
|
||||
var $containers = this;
|
||||
|
||||
// 滚动后设置scrolling的值,调用set同步滚动条
|
||||
var scrolling = {};
|
||||
Object.defineProperty(scrolling, 'top', {
|
||||
set: function (val) {
|
||||
$containers.each(function () {
|
||||
$(this).scrollTop(val);
|
||||
});
|
||||
}
|
||||
});
|
||||
Object.defineProperty(scrolling, 'left', {
|
||||
set: function (val) {
|
||||
$containers.each(function () {
|
||||
$(this).scrollLeft(val);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
$containers.on({
|
||||
mouseover: function () {
|
||||
if (scroll_type == 'smartscroll') {
|
||||
$(this).smartscroll(function () {
|
||||
options.x_sync && (scrolling.top = $(this).scrollTop());
|
||||
options.y_sync && (scrolling.left = $(this).scrollLeft());
|
||||
}, options.smartscroll_delay);
|
||||
return;
|
||||
}
|
||||
$(this).bind('scroll', function () {
|
||||
options.x_sync && (scrolling.top = $(this).scrollTop());
|
||||
options.y_sync && (scrolling.left = $(this).scrollLeft());
|
||||
});
|
||||
},
|
||||
mouseout: function () {
|
||||
$(this).unbind('scroll');
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
exports.scrollsync = scrollsync;
|
||||
|
||||
Object.defineProperty(exports, '__esModule', {value: true});
|
||||
|
||||
})));
|
31
WebHostLib/static/static.css
Normal file
31
WebHostLib/static/static.css
Normal file
@@ -0,0 +1,31 @@
|
||||
table.dataTable.table-sm > thead > tr > th :not(.sorting_disabled) {
|
||||
padding: 1px;
|
||||
}
|
||||
|
||||
.dataTable > thead > tr > th[class*="sort"]:before,
|
||||
.dataTable > thead > tr > th[class*="sort"]:after {
|
||||
content: "" !important;
|
||||
}
|
||||
|
||||
th {
|
||||
padding: 1px !important;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
img.alttp-sprite {
|
||||
height: 32px;
|
||||
width: 32px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
/* this is specific to the tracker right now */
|
||||
@media all and (max-width: 1750px) {
|
||||
img.alttp-sprite {
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
object-fit: contain;
|
||||
}
|
||||
}
|
43
WebHostLib/templates/host_room.html
Normal file
43
WebHostLib/templates/host_room.html
Normal file
@@ -0,0 +1,43 @@
|
||||
{% extends 'layout.html' %}
|
||||
{% block head %}
|
||||
<title>Multiworld {{ room.id }}</title>
|
||||
{% endblock %}
|
||||
{% block body %}
|
||||
{% if room.owner == session["_id"] %}
|
||||
Room created from <a href="{{ url_for("view_seed", seed=room.seed.id) }}">Seed #{{ room.seed.id }}</a><br>
|
||||
{% endif %}
|
||||
{% if room.tracker %}
|
||||
This room has a <a href="{{ url_for("get_tracker", tracker=room.tracker) }}">Multiworld Tracker</a> enabled.<br>
|
||||
{% endif %}
|
||||
This room will be closed after {{ room.timeout//60//60 }} hours of inactivity. Should you wish to continue later,
|
||||
you can simply refresh this page and the server will be started again.<br>
|
||||
{% if room.owner == session["_id"] %}
|
||||
<form method=post>
|
||||
<div class="form-group">
|
||||
<label for="cmd"></label>
|
||||
<input class="form-control" type="text" id="cmd" name="cmd"
|
||||
placeholder="Server Command. /help to list them, list gets appended to log.">
|
||||
</div>
|
||||
</form>
|
||||
{% endif %}
|
||||
Log:
|
||||
<div id="logger"></div>
|
||||
<script>
|
||||
var xmlhttp = new XMLHttpRequest();
|
||||
var url = '{{ url_for('display_log', room = room.id) }}';
|
||||
|
||||
xmlhttp.onreadystatechange = function () {
|
||||
if (this.readyState == 4 && this.status == 200) {
|
||||
document.getElementById("logger").innerText = this.responseText;
|
||||
}
|
||||
};
|
||||
|
||||
function request_new() {
|
||||
xmlhttp.open("GET", url, true);
|
||||
xmlhttp.send();
|
||||
}
|
||||
|
||||
window.setTimeout(request_new, 1000);
|
||||
window.setInterval(request_new, 3000);
|
||||
</script>
|
||||
{% endblock %}
|
56
WebHostLib/templates/landing.html
Normal file
56
WebHostLib/templates/landing.html
Normal file
@@ -0,0 +1,56 @@
|
||||
{% extends 'layout.html' %}
|
||||
{% block head %}
|
||||
<title>Berserker's Multiworld</title>
|
||||
{% endblock %}
|
||||
{% block body %}
|
||||
<nav class="navbar navbar-dark bg-dark navbar-expand-sm">
|
||||
<ul class="navbar-nav">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for("uploads") }}">Start a Group</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for("uploads") }}">Upload a Multiworld</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for("uploads") }}">Your Content</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
<br>
|
||||
|
||||
<div class="container container-fluid d-flex">
|
||||
<div class="jumbotron jumbotron-fluid">
|
||||
<div class="container">
|
||||
<div class="col-md-5 p-lg-2 mx-auto my-2">
|
||||
<h1 class="text-center display-4 font-weight-normal">Berserker's Multiworld</h1>
|
||||
<p class="text-center lead font-weight-normal"><a
|
||||
href="https://github.com/Berserker66/MultiWorld-Utilities">Source Code</a>
|
||||
- <a href="https://github.com/Berserker66/MultiWorld-Utilities/wiki">Wiki</a>
|
||||
-
|
||||
<a href="https://github.com/Berserker66/MultiWorld-Utilities/graphs/contributors">Contributors</a>
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="lead">This webpage is still under heavy construction. Database may be wiped as I see fit
|
||||
and some stuff may be broken.</p>
|
||||
<p class="lead">This is a randomizer for The Legend of Zelda: A Link to the Past.</p>
|
||||
<p class="lead">It is a multiworld, meaning items get shuffled across multiple players' worlds
|
||||
which get exchanged on pickup through the internet.</p>
|
||||
<p class="lead">This website allows hosting such a Multiworld and comes with an item and location
|
||||
tracker.</p>
|
||||
<p class="lead">Currently you still require a locally installed client to play, that handles
|
||||
connecting to the server and patching a vanilla game to the randomized one. Get started on the
|
||||
<a href="https://github.com/Berserker66/MultiWorld-Utilities/wiki">Wiki</a>.</p>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<iframe src="https://discordapp.com/widget?id=731205301247803413&theme=light" width="300" height="500"
|
||||
allowtransparency="true" frameborder="0"></iframe>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{% endblock %}
|
31
WebHostLib/templates/layout.html
Normal file
31
WebHostLib/templates/layout.html
Normal file
@@ -0,0 +1,31 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.0/css/bootstrap.min.css"
|
||||
integrity="sha384-9aIt2nRpC12Uk9gS9baDl411NQApFmC26EwAOH8WgZl5MYYxFfc+NcPb1dKGj7Sk" crossorigin="anonymous">
|
||||
{% block head %}<title>Berserker's Multiworld</title>
|
||||
{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
|
||||
{% with messages = get_flashed_messages() %}
|
||||
{% if messages %}
|
||||
<div class=".container-fluid">
|
||||
{% for message in messages %}
|
||||
<div class="alert alert-danger" role="alert">{{ message }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
{% block body %}{% endblock %}
|
||||
|
||||
<br> {# spacing for notice #}
|
||||
<footer class="page-footer" style="position: fixed; left: 0; bottom: 0; width: 100%; text-align: center">
|
||||
<div class="container">
|
||||
<span class="text-muted">This site uses a cookie to track your session in order to give you ownership over uploaded files and created instances.</span>
|
||||
{# <button type="button" class="btn btn-secondary btn-sm" onclick="document.getElementById('cookiefooter').remove()">X</button> #}
|
||||
</div>
|
||||
</footer>
|
||||
</body>
|
10
WebHostLib/templates/macros.html
Normal file
10
WebHostLib/templates/macros.html
Normal file
@@ -0,0 +1,10 @@
|
||||
{% macro list_rooms(rooms) -%}
|
||||
Rooms:
|
||||
<ul class="list-group">
|
||||
{% for room in rooms %}
|
||||
<li class="list-group-item"><a href="{{ url_for("host_room", room=room.id) }}">Room #{{ room.id }}</a></li>
|
||||
{% endfor %}
|
||||
{{ caller() }}
|
||||
|
||||
</ul>
|
||||
{%- endmacro %}
|
172
WebHostLib/templates/tracker.html
Normal file
172
WebHostLib/templates/tracker.html
Normal file
@@ -0,0 +1,172 @@
|
||||
{% extends 'layout.html' %}
|
||||
{% block head %}
|
||||
<title>Multiworld Tracker for Room {{ room.id }}</title>
|
||||
|
||||
<link rel="stylesheet" type="text/css"
|
||||
href="https://cdn.datatables.net/v/bs4/jq-3.3.1/dt-1.10.21/fh-3.1.7/datatables.min.css"/>
|
||||
<link rel="stylesheet" type="text/css" href="{{ static_autoversion("static.css") }}"/>
|
||||
<script type="text/javascript"
|
||||
src="https://cdn.datatables.net/v/bs4/jq-3.3.1/dt-1.10.21/fh-3.1.7/datatables.min.js"></script>
|
||||
<script src="{{ static_autoversion("jquery.scrollsync.js") }}"></script>
|
||||
|
||||
<script>
|
||||
|
||||
$(document).ready(function () {
|
||||
var tables = $(".table").DataTable({
|
||||
"paging": false,
|
||||
"ordering": true,
|
||||
"info": false,
|
||||
"dom": "t",
|
||||
"scrollY": "39vh",
|
||||
"scrollCollapse": true,
|
||||
});
|
||||
|
||||
$('#searchbox').keyup(function () {
|
||||
tables.search($(this).val()).draw();
|
||||
});
|
||||
|
||||
function update() {
|
||||
var target = $("<div></div>");
|
||||
target.load("/tracker/{{ room.tracker }}", function (response, status) {
|
||||
if (status === "success") {
|
||||
target.find(".table").each(function (i, new_table) {
|
||||
var new_trs = $(new_table).find("tbody>tr");
|
||||
var old_table = tables.eq(i);
|
||||
var topscroll = $(old_table.settings()[0].nScrollBody).scrollTop();
|
||||
var leftscroll = $(old_table.settings()[0].nScrollBody).scrollLeft();
|
||||
old_table.clear();
|
||||
old_table.rows.add(new_trs).draw();
|
||||
$(old_table.settings()[0].nScrollBody).scrollTop(topscroll);
|
||||
$(old_table.settings()[0].nScrollBody).scrollLeft(leftscroll);
|
||||
});
|
||||
} else {
|
||||
console.log("Failed to connect to Server, in order to update Table Data.");
|
||||
console.log(response);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
setInterval(update, 30000);
|
||||
|
||||
$(".dataTables_scrollBody").scrollsync({
|
||||
y_sync: true,
|
||||
x_sync: true
|
||||
})
|
||||
$(window).resize(function () {
|
||||
tables.draw();
|
||||
});
|
||||
setTimeout(
|
||||
tables.draw, {# this fixes the top header misalignment, for some reason #}
|
||||
500
|
||||
);
|
||||
})
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
{% block body %}
|
||||
<input id="searchbox" class="form-control" type="text" placeholder="Search">
|
||||
<div>
|
||||
{% for team, players in inventory.items() %}
|
||||
<table class="table table-striped table-bordered table-hover table-sm">
|
||||
<thead class="thead-dark">
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Name</th>
|
||||
{% for name in tracking_names %}
|
||||
{% if name in icons %}
|
||||
<th style="text-align: center"><img class="alttp-sprite"
|
||||
src="{{ icons[name] }}"
|
||||
alt="{{ name|e }}"></th>
|
||||
{% else %}
|
||||
<th>{{ name|e }}</th>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for player, items in players.items() %}
|
||||
<tr>
|
||||
<td class="table-info">{{ loop.index }}</td>
|
||||
{% if (team, loop.index) in video %}
|
||||
<td class="table-info">
|
||||
<a target="_blank" href="https://www.twitch.tv/{{ video[(team, loop.index)][1] }}">
|
||||
{{ player_names[(team, loop.index)] }}
|
||||
▶️</a></td>
|
||||
{% else %}
|
||||
<td class="table-info">{{ player_names[(team, loop.index)] }}</td>{% endif %}
|
||||
{% for id in tracking_ids %}
|
||||
|
||||
{% if items[id] %}
|
||||
<td style="text-align: center" class="table-success">
|
||||
{% if id in multi_items %}{{ items[id] }}{% else %}✔️{% endif %}</td>
|
||||
{% else %}
|
||||
<td></td>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endfor %}
|
||||
|
||||
{% for team, players in checks_done.items() %}
|
||||
<table class="table table-striped table-bordered table-hover table-sm">
|
||||
<thead class="thead-dark">
|
||||
<tr>
|
||||
<th rowspan="2">#</th>
|
||||
<th rowspan="2">Name</th>
|
||||
{% for area in ordered_areas %}
|
||||
{% set colspan = (3 if area in key_locations else 1) %}
|
||||
{% if area in icons %}
|
||||
<th colspan="{{ colspan }}" style="text-align: center"><img class="alttp-sprite"
|
||||
src="{{ icons[area] }}"
|
||||
alt="{{ area }}"></th>
|
||||
{% else %}
|
||||
<th colspan="{{ colspan }}">{{ area }}</th>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
<th rowspan="2">Last Activity</th>
|
||||
</tr>
|
||||
<tr>
|
||||
{% for area in ordered_areas %}
|
||||
<th style="text-align: center"><img class="alttp-sprite" src="{{ icons["Chest"] }}" alt="Checks">
|
||||
</th>
|
||||
{% if area in key_locations %}
|
||||
<th style="text-align: center"><img class="alttp-sprite"
|
||||
src="{{ icons["Small Key"] }}" alt="Small Key"></th>
|
||||
<th style="text-align: center"><img class="alttp-sprite"
|
||||
src="{{ icons["Big Key"] }}" alt="Big Key"></th>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for player, checks in players.items() %}
|
||||
<tr>
|
||||
<td class="table-info">{{ loop.index }}</td>
|
||||
<td class="table-info">{{ player_names[(team, loop.index)]|e }}</td>
|
||||
{% for area in ordered_areas %}
|
||||
{% set checks_done = checks[area] %}
|
||||
{% set checks_total = checks_in_area[area] %}
|
||||
{% if checks_done == checks_total %}
|
||||
<td style="text-align: center" class="table-success">
|
||||
{{ checks_done }}/{{ checks_total }}</td>
|
||||
{% else %}
|
||||
<td style="text-align: center">{{ checks_done }}/{{ checks_total }}</td>
|
||||
{% endif %}
|
||||
{% if area in key_locations %}
|
||||
<td>{{ inventory[team][player][small_key_ids[area]] }}</td>
|
||||
<td>{% if inventory[team][player][big_key_ids[area]] %}✔️{% endif %}</td>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% if activity_timers[(team, player)] %}
|
||||
<td class="table-info">{{ activity_timers[(team, player)] | render_timedelta }}</td>
|
||||
{% else %}
|
||||
<td class="table-warning">None</td>{% endif %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endblock %}
|
24
WebHostLib/templates/uploads.html
Normal file
24
WebHostLib/templates/uploads.html
Normal file
@@ -0,0 +1,24 @@
|
||||
{% extends 'layout.html' %}
|
||||
{% block head %}
|
||||
<title>Upload Multidata</title>
|
||||
{% endblock %}
|
||||
{% block body %}
|
||||
<h1>Upload Multidata or Multiworld Zip</h1>
|
||||
<form method=post enctype=multipart/form-data>
|
||||
<input type=file name=file>
|
||||
<input type=submit value=Upload>
|
||||
</form>
|
||||
<br>
|
||||
{% if rooms %}
|
||||
<h1>Your Rooms:</h1>
|
||||
<ul class="list-group">
|
||||
{% for room in rooms %}
|
||||
<li class="list-group-item"><a href="{{ url_for("host_room", room=room.id) }}">Room #{{ room.id }}</a>
|
||||
based on <a href="{{ url_for("view_seed", seed=room.seed.id) }}">Seed #{{ room.seed.id }}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<h3>No rooms owned by you were found. Upload a Multiworld to get started.</h3>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
25
WebHostLib/templates/view_seed.html
Normal file
25
WebHostLib/templates/view_seed.html
Normal file
@@ -0,0 +1,25 @@
|
||||
{% extends 'layout.html' %}
|
||||
{% import "macros.html" as macros %}
|
||||
{% block head %}
|
||||
<title>Multiworld Seed {{ seed.id }}</title>
|
||||
{% endblock %}
|
||||
{% block body %}
|
||||
Seed #{{ seed.id }}<br>
|
||||
Created: {{ seed.creation_time }} UTC <br>
|
||||
Players:
|
||||
<ul class="list-group">
|
||||
{% for team in seed.multidata["names"] %}
|
||||
<li class="list-group-item">Team #{{ loop.index }} - {{ team | length }}
|
||||
<ul class="list-group">
|
||||
{% for player in team %}
|
||||
<li class="list-group-item">{{ player }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% call macros.list_rooms(rooms) %}
|
||||
<li class="list-group-item list-group-item-action"><a href="{{ url_for("new_room", seed=seed.id) }}">new
|
||||
room</a></li>
|
||||
{% endcall %}
|
||||
{% endblock %}
|
266
WebHostLib/tracker.py
Normal file
266
WebHostLib/tracker.py
Normal file
@@ -0,0 +1,266 @@
|
||||
import collections
|
||||
|
||||
from flask import render_template
|
||||
from werkzeug.exceptions import abort
|
||||
import datetime
|
||||
import logging
|
||||
from uuid import UUID
|
||||
|
||||
import Items
|
||||
from WebHostLib import app, cache, Room
|
||||
|
||||
|
||||
def get_id(item_name):
|
||||
return Items.item_table[item_name][3]
|
||||
|
||||
|
||||
icons = {
|
||||
"Progressive Sword":
|
||||
r"https://gamepedia.cursecdn.com/zelda_gamepedia_en/c/cc/ALttP_Master_Sword_Sprite.png?version=55869db2a20e157cd3b5c8f556097725",
|
||||
"Pegasus Boots":
|
||||
r"https://gamepedia.cursecdn.com/zelda_gamepedia_en/e/ed/ALttP_Pegasus_Shoes_Sprite.png?version=405f42f97240c9dcd2b71ffc4bebc7f9",
|
||||
"Progressive Glove":
|
||||
r"https://gamepedia.cursecdn.com/zelda_gamepedia_en/5/53/ALttP_Titan's_Mitt_Sprite.png?version=6ac54c3016a23b94413784881fcd3c75",
|
||||
"Flippers":
|
||||
r"https://gamepedia.cursecdn.com/zelda_gamepedia_en/8/88/ALttP_Zora's_Flippers_Sprite.png?version=b9d7521bb3a5a4d986879f70a70bc3da",
|
||||
"Moon Pearl":
|
||||
r"https://gamepedia.cursecdn.com/zelda_gamepedia_en/6/63/ALttP_Moon_Pearl_Sprite.png?version=d601542d5abcc3e006ee163254bea77e",
|
||||
"Progressive Bow":
|
||||
r"https://gamepedia.cursecdn.com/zelda_gamepedia_en/b/bc/ALttP_Bow_%26_Arrows_Sprite.png?version=cfb7648b3714cccc80e2b17b2adf00ed",
|
||||
"Blue Boomerang":
|
||||
r"https://gamepedia.cursecdn.com/zelda_gamepedia_en/c/c3/ALttP_Boomerang_Sprite.png?version=96127d163759395eb510b81a556d500e",
|
||||
"Red Boomerang":
|
||||
r"https://gamepedia.cursecdn.com/zelda_gamepedia_en/b/b9/ALttP_Magical_Boomerang_Sprite.png?version=47cddce7a07bc3e4c2c10727b491f400",
|
||||
"Hookshot":
|
||||
r"https://gamepedia.cursecdn.com/zelda_gamepedia_en/2/24/Hookshot.png?version=c90bc8e07a52e8090377bd6ef854c18b",
|
||||
"Mushroom":
|
||||
r"https://gamepedia.cursecdn.com/zelda_gamepedia_en/3/35/ALttP_Mushroom_Sprite.png?version=1f1acb30d71bd96b60a3491e54bbfe59",
|
||||
"Magic Powder":
|
||||
r"https://gamepedia.cursecdn.com/zelda_gamepedia_en/e/e5/ALttP_Magic_Powder_Sprite.png?version=deaf51f8636823558bd6e6307435fb01",
|
||||
"Fire Rod":
|
||||
r"https://gamepedia.cursecdn.com/zelda_gamepedia_en/d/d6/FireRod.png?version=6eabc9f24d25697e2c4cd43ddc8207c0",
|
||||
"Ice Rod":
|
||||
r"https://gamepedia.cursecdn.com/zelda_gamepedia_en/d/d7/ALttP_Ice_Rod_Sprite.png?version=1f944148223d91cfc6a615c92286c3bc",
|
||||
"Bombos":
|
||||
r"https://gamepedia.cursecdn.com/zelda_gamepedia_en/8/8c/ALttP_Bombos_Medallion_Sprite.png?version=f4d6aba47fb69375e090178f0fc33b26",
|
||||
"Ether":
|
||||
r"https://gamepedia.cursecdn.com/zelda_gamepedia_en/3/3c/Ether.png?version=34027651a5565fcc5a83189178ab17b5",
|
||||
"Quake":
|
||||
r"https://gamepedia.cursecdn.com/zelda_gamepedia_en/5/56/ALttP_Quake_Medallion_Sprite.png?version=efd64d451b1831bd59f7b7d6b61b5879",
|
||||
"Lamp":
|
||||
r"https://gamepedia.cursecdn.com/zelda_gamepedia_en/6/63/ALttP_Lantern_Sprite.png?version=e76eaa1ec509c9a5efb2916698d5a4ce",
|
||||
"Hammer":
|
||||
r"https://gamepedia.cursecdn.com/zelda_gamepedia_en/d/d1/ALttP_Hammer_Sprite.png?version=e0adec227193818dcaedf587eba34500",
|
||||
"Shovel":
|
||||
r"https://gamepedia.cursecdn.com/zelda_gamepedia_en/c/c4/ALttP_Shovel_Sprite.png?version=e73d1ce0115c2c70eaca15b014bd6f05",
|
||||
"Flute":
|
||||
r"https://gamepedia.cursecdn.com/zelda_gamepedia_en/d/db/Flute.png?version=ec4982b31c56da2c0c010905c5c60390",
|
||||
"Bug Catching Net":
|
||||
r"https://gamepedia.cursecdn.com/zelda_gamepedia_en/5/54/Bug-CatchingNet.png?version=4d40e0ee015b687ff75b333b968d8be6",
|
||||
"Book of Mudora":
|
||||
r"https://gamepedia.cursecdn.com/zelda_gamepedia_en/2/22/ALttP_Book_of_Mudora_Sprite.png?version=11e4632bba54f6b9bf921df06ac93744",
|
||||
"Bottle":
|
||||
r"https://gamepedia.cursecdn.com/zelda_gamepedia_en/e/ef/ALttP_Magic_Bottle_Sprite.png?version=fd98ab04db775270cbe79fce0235777b",
|
||||
"Cane of Somaria":
|
||||
r"https://gamepedia.cursecdn.com/zelda_gamepedia_en/e/e1/ALttP_Cane_of_Somaria_Sprite.png?version=8cc1900dfd887890badffc903bb87943",
|
||||
"Cane of Byrna":
|
||||
r"https://gamepedia.cursecdn.com/zelda_gamepedia_en/b/bc/ALttP_Cane_of_Byrna_Sprite.png?version=758b607c8cbe2cf1900d42a0b3d0fb54",
|
||||
"Cape":
|
||||
r"https://gamepedia.cursecdn.com/zelda_gamepedia_en/1/1c/ALttP_Magic_Cape_Sprite.png?version=6b77f0d609aab0c751307fc124736832",
|
||||
"Magic Mirror":
|
||||
r"https://gamepedia.cursecdn.com/zelda_gamepedia_en/e/e5/ALttP_Magic_Mirror_Sprite.png?version=e035dbc9cbe2a3bd44aa6d047762b0cc",
|
||||
"Triforce":
|
||||
r"https://gamepedia.cursecdn.com/zelda_gamepedia_en/4/4e/TriforceALttPTitle.png?version=dc398e1293177581c16303e4f9d12a48",
|
||||
"Small Key":
|
||||
r"https://gamepedia.cursecdn.com/zelda_gamepedia_en/f/f1/ALttP_Small_Key_Sprite.png?version=4f35d92842f0de39d969181eea03774e",
|
||||
"Big Key":
|
||||
r"https://gamepedia.cursecdn.com/zelda_gamepedia_en/3/33/ALttP_Big_Key_Sprite.png?version=136dfa418ba76c8b4e270f466fc12f4d",
|
||||
"Chest":
|
||||
r"https://gamepedia.cursecdn.com/zelda_gamepedia_en/7/73/ALttP_Treasure_Chest_Sprite.png?version=5f530ecd98dcb22251e146e8049c0dda"
|
||||
}
|
||||
|
||||
links = {"Bow": "Progressive Bow",
|
||||
"Silver Arrows": "Progressive Bow",
|
||||
"Silver Bow": "Progressive Bow",
|
||||
"Progressive Bow (Alt)": "Progressive Bow",
|
||||
"Bottle (Red Potion)": "Bottle",
|
||||
"Bottle (Green Potion)": "Bottle",
|
||||
"Bottle (Blue Potion)": "Bottle",
|
||||
"Bottle (Fairy)": "Bottle",
|
||||
"Bottle (Bee)": "Bottle",
|
||||
"Bottle (Good Bee)": "Bottle",
|
||||
"Fighter Sword": "Progressive Sword",
|
||||
"Master Sword": "Progressive Sword",
|
||||
"Tempered Sword": "Progressive Sword",
|
||||
"Golden Sword": "Progressive Sword",
|
||||
"Power Glove": "Progressive Glove",
|
||||
"Titans Mitts": "Progressive Glove"
|
||||
}
|
||||
|
||||
levels = {"Fighter Sword": 1,
|
||||
"Master Sword": 2,
|
||||
"Tempered Sword": 3,
|
||||
"Golden Sword": 4,
|
||||
"Power Glove": 1,
|
||||
"Titans Mitts": 2,
|
||||
"Silver Bow": 2}
|
||||
|
||||
multi_items = {get_id(name) for name in ("Progressive Sword", "Progressive Bow", "Bottle", "Progressive Glove")}
|
||||
links = {get_id(key): get_id(value) for key, value in links.items()}
|
||||
levels = {get_id(key): value for key, value in levels.items()}
|
||||
|
||||
tracking_names = ["Progressive Sword", "Progressive Bow", "Book of Mudora", "Hammer",
|
||||
"Hookshot", "Magic Mirror", "Flute",
|
||||
"Pegasus Boots", "Progressive Glove", "Flippers", "Moon Pearl", "Blue Boomerang",
|
||||
"Red Boomerang", "Bug Catching Net", "Cape", "Shovel", "Lamp",
|
||||
"Mushroom", "Magic Powder",
|
||||
"Cane of Somaria", "Cane of Byrna", "Fire Rod", "Ice Rod", "Bombos", "Ether", "Quake",
|
||||
"Bottle", "Triforce"] # TODO make sure this list has what we need and sort it better
|
||||
|
||||
default_locations = {
|
||||
'Light World': {1572864, 1572865, 60034, 1572867, 1572868, 60037, 1572869, 1572866, 60040, 59788, 60046, 60175,
|
||||
1572880, 60049, 60178, 1572883, 60052, 60181, 1572885, 60055, 60184, 191256, 60058, 60187, 1572884,
|
||||
1572886, 1572887, 1572906, 60202, 60205, 59824, 166320, 1010170, 60208, 60211, 60214, 60217, 59836,
|
||||
60220, 60223, 59839, 1573184, 60226, 975299, 1573188, 1573189, 188229, 60229, 60232, 1573193,
|
||||
1573194, 60235, 1573187, 59845, 59854, 211407, 60238, 59857, 1573185, 1573186, 1572882, 212328,
|
||||
59881, 59761, 59890, 59770, 193020, 212605},
|
||||
'Dark World': {59776, 59779, 975237, 1572870, 60043, 1572881, 60190, 60193, 60196, 60199, 60840, 1573190, 209095,
|
||||
1573192, 1573191, 60241, 60244, 60247, 60250, 59884, 59887, 60019, 60022, 60028, 60031},
|
||||
'Desert Palace': {1573216, 59842, 59851, 59791, 1573201, 59830},
|
||||
'Eastern Palace': {1573200, 59827, 59893, 59767, 59833, 59773},
|
||||
'Hyrule Castle': {60256, 60259, 60169, 60172, 59758, 59764, 60025, 60253},
|
||||
'Agahnims Tower': {60082, 60085},
|
||||
'Tower of Hera': {1573218, 59878, 59821, 1573202, 59896, 59899},
|
||||
'Swamp Palace': {60064, 60067, 60070, 59782, 59785, 60073, 60076, 60079, 1573204, 60061},
|
||||
'Thieves Town': {59905, 59908, 59911, 59914, 59917, 59920, 59923, 1573206},
|
||||
'Skull Woods': {59809, 59902, 59848, 59794, 1573205, 59800, 59803, 59806},
|
||||
'Ice Palace': {59872, 59875, 59812, 59818, 59860, 59797, 1573207, 59869},
|
||||
'Misery Mire': {60001, 60004, 60007, 60010, 60013, 1573208, 59866, 59998},
|
||||
'Turtle Rock': {59938, 59941, 59944, 1573209, 59947, 59950, 59953, 59956, 59926, 59929, 59932, 59935},
|
||||
'Palace of Darkness': {59968, 59971, 59974, 59977, 59980, 59983, 59986, 1573203, 59989, 59959, 59992, 59962, 59995,
|
||||
59965},
|
||||
'Ganons Tower': {60160, 60163, 60166, 60088, 60091, 60094, 60097, 60100, 60103, 60106, 60109, 60112, 60115, 60118,
|
||||
60121, 60124, 60127, 1573217, 60130, 60133, 60136, 60139, 60142, 60145, 60148, 60151, 60157},
|
||||
'Total': set()}
|
||||
|
||||
key_locations = {"Desert Palace", "Eastern Palace", "Hyrule Castle", "Agahnims Tower", "Tower of Hera", "Swamp Palace",
|
||||
"Thieves Town", "Skull Woods", "Ice Palace", "Misery Mire", "Turtle Rock", "Palace of Darkness",
|
||||
"Ganons Tower"}
|
||||
|
||||
location_to_area = {}
|
||||
for area, locations in default_locations.items():
|
||||
for location in locations:
|
||||
location_to_area[location] = area
|
||||
|
||||
checks_in_area = {area: len(checks) for area, checks in default_locations.items()}
|
||||
checks_in_area["Total"] = 216
|
||||
|
||||
ordered_areas = ('Light World', 'Dark World', 'Hyrule Castle', 'Agahnims Tower', 'Eastern Palace', 'Desert Palace',
|
||||
'Tower of Hera', 'Palace of Darkness', 'Swamp Palace', 'Skull Woods', 'Thieves Town', 'Ice Palace',
|
||||
'Misery Mire', 'Turtle Rock', 'Ganons Tower', "Total")
|
||||
|
||||
tracking_ids = []
|
||||
|
||||
for item in tracking_names:
|
||||
tracking_ids.append(get_id(item))
|
||||
|
||||
small_key_ids = {}
|
||||
big_key_ids = {}
|
||||
|
||||
for item_name, data in Items.item_table.items():
|
||||
if "Key" in item_name:
|
||||
area = item_name.split("(")[1][:-1]
|
||||
if "Small" in item_name:
|
||||
small_key_ids[area] = data[3]
|
||||
else:
|
||||
big_key_ids[area] = data[3]
|
||||
|
||||
from MultiServer import get_item_name_from_id
|
||||
|
||||
|
||||
def attribute_item(inventory, team, recipient, item):
|
||||
target_item = links.get(item, item)
|
||||
if item in levels: # non-progressive
|
||||
inventory[team][recipient][target_item] = max(inventory[team][recipient][target_item], levels[item])
|
||||
else:
|
||||
inventory[team][recipient][target_item] += 1
|
||||
|
||||
|
||||
@app.template_filter()
|
||||
def render_timedelta(delta: datetime.timedelta):
|
||||
hours, minutes = divmod(delta.total_seconds() / 60, 60)
|
||||
hours = str(int(hours))
|
||||
minutes = str(int(minutes)).zfill(2)
|
||||
return f"{hours}:{minutes}"
|
||||
|
||||
|
||||
_multidata_cache = {}
|
||||
|
||||
|
||||
def get_static_room_data(room: Room):
|
||||
result = _multidata_cache.get(room.seed.id, None)
|
||||
if result:
|
||||
return result
|
||||
multidata = room.seed.multidata
|
||||
# in > 100 players this can take a bit of time and is the main reason for the cache
|
||||
locations = {tuple(k): tuple(v) for k, v in multidata['locations']}
|
||||
names = multidata["names"]
|
||||
_multidata_cache[room.seed.id] = locations, names
|
||||
return locations, names
|
||||
|
||||
|
||||
@app.route('/tracker/<uuid:tracker>')
|
||||
@cache.memoize(timeout=30) # update every 30 seconds
|
||||
def get_tracker(tracker: UUID):
|
||||
room = Room.get(tracker=tracker)
|
||||
if not room:
|
||||
abort(404)
|
||||
locations, names = get_static_room_data(room)
|
||||
|
||||
inventory = {teamnumber: {playernumber: collections.Counter() for playernumber in range(1, len(team) + 1)}
|
||||
for teamnumber, team in enumerate(names)}
|
||||
|
||||
checks_done = {teamnumber: {playernumber: {loc_name: 0 for loc_name in default_locations}
|
||||
for playernumber in range(1, len(team) + 1)}
|
||||
for teamnumber, team in enumerate(names)}
|
||||
precollected_items = room.seed.multidata.get("precollected_items", None)
|
||||
|
||||
for (team, player), locations_checked in room.multisave.get("location_checks", {}):
|
||||
if precollected_items:
|
||||
precollected = precollected_items[player - 1]
|
||||
for item_id in precollected:
|
||||
attribute_item(inventory, team, player, item_id)
|
||||
for location in locations_checked:
|
||||
item, recipient = locations[location, player]
|
||||
attribute_item(inventory, team, recipient, item)
|
||||
checks_done[team][player][location_to_area[location]] += 1
|
||||
checks_done[team][player]["Total"] += 1
|
||||
|
||||
for (team, player), game_state in room.multisave.get("client_game_state", []):
|
||||
if game_state:
|
||||
inventory[team][player][106] = 1 # Triforce
|
||||
|
||||
activity_timers = {}
|
||||
now = datetime.datetime.utcnow()
|
||||
for (team, player), timestamp in room.multisave.get("client_activity_timers", []):
|
||||
activity_timers[team, player] = now - datetime.datetime.utcfromtimestamp(timestamp)
|
||||
|
||||
player_names = {}
|
||||
for team, names in enumerate(names):
|
||||
for player, name in enumerate(names, 1):
|
||||
player_names[(team, player)] = name
|
||||
|
||||
for (team, player), alias in room.multisave.get("name_aliases", []):
|
||||
player_names[(team, player)] = alias
|
||||
|
||||
video = {}
|
||||
for (team, player), data in room.multisave.get("video", []):
|
||||
video[(team, player)] = data
|
||||
|
||||
return render_template("tracker.html", inventory=inventory, get_item_name_from_id=get_item_name_from_id,
|
||||
lookup_id_to_name=Items.lookup_id_to_name, player_names=player_names,
|
||||
tracking_names=tracking_names, tracking_ids=tracking_ids, room=room, icons=icons,
|
||||
multi_items=multi_items, checks_done=checks_done, ordered_areas=ordered_areas,
|
||||
checks_in_area=checks_in_area, activity_timers=activity_timers,
|
||||
key_locations=key_locations, small_key_ids=small_key_ids, big_key_ids=big_key_ids,
|
||||
video=video)
|
73
WebHostLib/upload.py
Normal file
73
WebHostLib/upload.py
Normal file
@@ -0,0 +1,73 @@
|
||||
import json
|
||||
import zlib
|
||||
import zipfile
|
||||
import logging
|
||||
|
||||
from flask import request, flash, redirect, url_for, session, render_template
|
||||
from pony.orm import commit, select
|
||||
|
||||
from WebHostLib import app, allowed_file, Seed, Room, Patch
|
||||
|
||||
accepted_zip_contents = {"patches": ".bmbp",
|
||||
"spoiler": ".txt",
|
||||
"multidata": "multidata"}
|
||||
|
||||
banned_zip_contents = (".sfc",)
|
||||
|
||||
|
||||
@app.route('/uploads', methods=['GET', 'POST'])
|
||||
def uploads():
|
||||
if request.method == 'POST':
|
||||
# check if the post request has the file part
|
||||
if 'file' not in request.files:
|
||||
flash('No file part')
|
||||
else:
|
||||
file = request.files['file']
|
||||
# if user does not select file, browser also
|
||||
# submit an empty part without filename
|
||||
if file.filename == '':
|
||||
flash('No selected file')
|
||||
elif file and allowed_file(file.filename):
|
||||
if file.filename.endswith(".zip"):
|
||||
patches = set()
|
||||
spoiler = ""
|
||||
multidata = None
|
||||
with zipfile.ZipFile(file, 'r') as zfile:
|
||||
infolist = zfile.infolist()
|
||||
|
||||
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(".bmbp"):
|
||||
player = int(file.filename.split("P")[-1].split(".")[0].split("_")[0])
|
||||
patches.add(Patch(data=zfile.open(file, "r").read(), player=player))
|
||||
elif file.filename.endswith(".txt"):
|
||||
spoiler = zfile.open(file, "rt").read().decode("utf-8-sig")
|
||||
elif file.filename.endswith("multidata"):
|
||||
try:
|
||||
multidata = json.loads(zlib.decompress(zfile.open(file).read()).decode("utf-8-sig"))
|
||||
except:
|
||||
flash("Could not load multidata. File may be corrupted or incompatible.")
|
||||
if multidata:
|
||||
commit() # commit patches
|
||||
seed = Seed(multidata=multidata, spoiler=spoiler, patches=patches, owner=session["_id"])
|
||||
commit() # create seed
|
||||
for patch in patches:
|
||||
patch.seed = seed
|
||||
|
||||
return redirect(url_for("view_seed", seed=seed.id))
|
||||
else:
|
||||
flash("No multidata was found in the zip file, which is required.")
|
||||
else:
|
||||
try:
|
||||
multidata = json.loads(zlib.decompress(file.read()).decode("utf-8-sig"))
|
||||
except:
|
||||
flash("Could not load multidata. File may be corrupted or incompatible.")
|
||||
else:
|
||||
seed = Seed(multidata=multidata, owner=session["_id"])
|
||||
commit() # place into DB and generate ids
|
||||
return redirect(url_for("view_seed", seed=seed.id))
|
||||
else:
|
||||
flash("Not recognized file format. Awaiting a .multidata file.")
|
||||
rooms = select(room for room in Room if room.owner == session["_id"])
|
||||
return render_template("uploads.html", rooms=rooms)
|
Reference in New Issue
Block a user