WebHost: Move module into WebHostLib to prevent shadowing WebHost.py

This commit is contained in:
Berserker66
2020-07-11 16:59:37 +02:00
parent b06f528f72
commit cdee9e5a3a
18 changed files with 10 additions and 10 deletions

112
WebHostLib/__init__.py Normal file
View 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
View 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
View 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
View 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
View 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)

View 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

View 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});
})));

View 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;
}
}

View 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 %}

View 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 %}

View 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>

View 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 %}

View 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 %}

View 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 %}

View 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
View 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
View 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)