From 0bd2f4827b6aad88793eed1597b4cac8a9a4754b Mon Sep 17 00:00:00 2001 From: Nicole O'Connor Date: Tue, 2 Sep 2025 16:44:28 -0700 Subject: [PATCH] state sync, many changes: * separated css/js/rule files to pak file (glorified zip) to reduce full rebuilds * implemented build cache * some frontend UI spiffing up --- .gitignore | 8 +++- .rsync-include | 1 - app/desktop.py | 7 +++- app/index.wsgi | 2 +- app/pylocal/core.py | 18 ++++++--- app/pylocal/desktop.py | 6 ++- app/pylocal/gamedata.py | 79 ++++++++++++++++++++++++++++++++++++++ app/pylocal/items.py | 14 ++++--- app/pylocal/tick.py | 16 +++++++- app/requirements.txt | 7 ++-- app/templates/main_page.j2 | 9 ++++- build-desktop.sh | 31 ++++++++++----- build-pak.sh | 10 +++++ seagull.spec | 6 +-- static/css/seagull.css | 27 ++++++++++++- static/js/seagull.js | 27 +++++++++++-- 16 files changed, 226 insertions(+), 42 deletions(-) create mode 100644 app/pylocal/gamedata.py create mode 100755 build-pak.sh diff --git a/.gitignore b/.gitignore index 10232ac..42e2646 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,11 @@ .vscode/** scratch.ipynb .docker-login-credentials + seagull -build/** \ No newline at end of file +seagull.pak +build/** +build_cache/** + +**/__pycache__/** +**.pyc \ No newline at end of file diff --git a/.rsync-include b/.rsync-include index c3962d1..86f9f87 100644 --- a/.rsync-include +++ b/.rsync-include @@ -1,4 +1,3 @@ app/** ext/imsky/wordlists/** -static/** seagull.spec \ No newline at end of file diff --git a/app/desktop.py b/app/desktop.py index 189df0a..8ed2823 100755 --- a/app/desktop.py +++ b/app/desktop.py @@ -8,8 +8,9 @@ import threading import webview import flask +import fs.tree -from pylocal import core, actions, desktop, dev, items, tick +from pylocal import core, actions, desktop, dev, gamedata, items, tick core.desktop_mode = True sig_exit = threading.Event() @@ -24,6 +25,7 @@ def index(): core.render_base_context() core.base_context["scripts"].insert(0, core.app.url_for("static", filename="js/desktop-structuredclone.js")) core.base_context["scripts"].insert(1, core.app.url_for("static", filename="js/seagull-desktop.js")) + gamedata.vfs.copy_out("templates/main_page.j2", dest=core.path_appdir.as_posix()) return flask.render_template("main_page.j2", **core.base_context) if __name__ == "__main__": @@ -37,6 +39,9 @@ if __name__ == "__main__": storage_dir = pathlib.Path(os.environ["HOME"]) / ".local/share/seagull" desktop.path_storagedir = storage_dir + gamedata.vfs.load_data_source("basepak") + gamedata.vfs.load_data_source("seagull.pak", proto="zip") + if argo.debug: desktop.api.debug_mode = True storage_dir.mkdir(exist_ok=True, parents=True) diff --git a/app/index.wsgi b/app/index.wsgi index 4851d55..8daa4fa 100755 --- a/app/index.wsgi +++ b/app/index.wsgi @@ -10,7 +10,7 @@ import threading import flask from gevent.pywsgi import WSGIServer -from pylocal import core, actions, dev, items, tick +from pylocal import core, actions, dev, gamedata, items, tick sig_exit = threading.Event() diff --git a/app/pylocal/core.py b/app/pylocal/core.py index 440aae3..eefbcf7 100644 --- a/app/pylocal/core.py +++ b/app/pylocal/core.py @@ -18,7 +18,9 @@ log.critical(path_appdir) desktop_mode = False -app = flask.Flask("seagull-game", root_path=path_appdir) +from . import gamedata + +app = flask.Flask("seagull-game", root_path=path_appdir, template_folder="templates", static_folder="static") orig_url_for = app.url_for xml_namespaces = { @@ -34,12 +36,15 @@ xml_namespaces = { def url_for_override(endpoint, *posargs, _anchor=None, _method=None, _scheme=None, _external=None, self=app, **values): if endpoint == "static": - # bandaid for #1 - if not os.path.exists(path_appdir / "static" / values["filename"]): + if not gamedata.vfs.exists(f"static/{values["filename"]}"): log.warning("requested {0} from local file, but it doesn't exist in this container. Redirecting to CDN...\n".format(values["filename"])) return "https://cdn.otl-hga.net/seagull/" + values["filename"] - - return orig_url_for(endpoint, *posargs, _anchor=_anchor, _method=_method, _scheme=_scheme, _external=_external, **values) + else: + gamedata.vfs.copy_out(f"static/{values["filename"]}", dest=path_appdir.as_posix()) + return orig_url_for(endpoint, *posargs, _anchor=_anchor, _method=_method, _scheme=_scheme, _external=_external, **values) + else: + print(endpoint) + return orig_url_for(endpoint, *posargs, _anchor=_anchor, _method=_method, _scheme=_scheme, _external=_external, **values) app.url_for = url_for_override @@ -48,7 +53,8 @@ base_context_live = False @app.route("/dialog/") def render_dialog(dialog): - if os.path.exists(path_appdir / f"templates/{dialog}.j2"): + if gamedata.vfs.exists(f"templates/{dialog}.j2"): + gamedata.vfs.copy_out(f"templates/{dialog}.j2", dest=path_appdir.as_posix()) return flask.render_template(f"{dialog}.j2") else: return "", 404 diff --git a/app/pylocal/desktop.py b/app/pylocal/desktop.py index 972560c..832653b 100644 --- a/app/pylocal/desktop.py +++ b/app/pylocal/desktop.py @@ -5,8 +5,10 @@ from . import core path_storagedir = pathlib.Path() class JS_API: - def __init__(self): - self.debug_mode = False + debug_mode = False + + def __init__(self, debug_mode=False): + self.debug_mode = debug_mode def load_data(self, key): if not (path_storagedir / key).exists(): diff --git a/app/pylocal/gamedata.py b/app/pylocal/gamedata.py new file mode 100644 index 0000000..27290b8 --- /dev/null +++ b/app/pylocal/gamedata.py @@ -0,0 +1,79 @@ +import logging +import os +import pathlib +import shutil +import typing +import tempfile + +import fs +import fs.base +import fs.copy +import fs.osfs + +from . import core + +class GameVFSHandler(object): + vfs = None + log = logging.getLogger().getChild("vfs") + proto_handlers = {} + + def _osfs_handle(self, path): + if self.osfs_cwd.exists(path): + return self.osfs_cwd.opendir(path) + elif self.osfs_appdir.exists(path): + return self.osfs_appdir.opendir(path) + else: + raise FileNotFoundError + + def __init__(self) -> None: + self.vfs = fs.open_fs("mem://") + self.proto_handlers["osfs"] = self._osfs_handle + self.osfs_cwd = fs.osfs.OSFS(os.getcwd()) + self.osfs_appdir = fs.osfs.OSFS(core.path_appdir.as_posix()) + self.pth_temp = pathlib.Path(tempfile.mkdtemp()) + self.osfs_temp = fs.osfs.OSFS(self.pth_temp.as_posix()) + + def __del__(self): + if self.pth_temp and self.pth_temp.exists(): + shutil.rmtree(self.pth_temp) + + def __getattr__(self, name: str) -> typing.Any: + try: + return getattr(self.vfs, name) + except: + raise + + def load_data_source(self, source: pathlib.Path | fs.base.FS | typing.Text, proto="osfs"): + print(f"data source: {source} ({proto})") + assert self.vfs is not None + if isinstance(source, pathlib.Path): + assert isinstance(source, pathlib.Path) # for linter + self.log.info(f"loading vfs source: {source.as_posix()}") + if proto in self.proto_handlers: + fs_source = self.proto_handlers[proto](source.as_posix()) + fs.copy.copy_fs(fs_source, self.vfs) + else: + fs_source = fs.open_fs(f"{proto}:/{source.as_posix()}") + fs.copy.copy_fs(fs_source, self.vfs) + else: + if proto in self.proto_handlers: + fs_source = self.proto_handlers[proto](source) + else: + fs_source = fs.open_fs(f"{proto}://{source}") + self.log.info(f"loading vfs source: {fs_source} (pyfilesystem2 handler)") + fs.copy.copy_fs(fs_source, self.vfs) + + def copy_out(self, filepath, dest=None): + if not dest: + self.osfs_temp.makedirs(pathlib.Path(filepath).parent.as_posix(), recreate=True) + fs.copy.copy_file(self.vfs, filepath, self.osfs_temp, filepath) + return self.pth_temp / filepath + else: + pth_dest = pathlib.Path(dest) + pth_file = pathlib.Path(filepath) + osfs_dest = fs.osfs.OSFS(dest) + osfs_dest.makedirs(pth_file.parent.as_posix(), recreate=True) + fs.copy.copy_file(self.vfs, filepath, dest, filepath) + return (pth_dest / filepath).as_posix() + +vfs = GameVFSHandler() \ No newline at end of file diff --git a/app/pylocal/items.py b/app/pylocal/items.py index a1a55c6..c890579 100644 --- a/app/pylocal/items.py +++ b/app/pylocal/items.py @@ -4,17 +4,18 @@ import subprocess import lxml.etree as xmltree -from . import core +from . import core, gamedata valid_resources = [ "food", "shinies", "psi" # early game ] rant_env = os.environ.copy() -rant_env["RANT_MODULES_PATH"] = (core.path_appdir / "rant").as_posix() +rant_env["RANT_MODULES_PATH"] = (core.path_appdir / "basepak/rant").as_posix() -fd_item_schema = xmltree.parse(core.path_appdir / "rules/schemas/items.xsd") -item_schema = xmltree.XMLSchema(fd_item_schema) +pth_item_schema = core.path_appdir / "basepak/rules/schemas/items.xsd" +doc_item_schema = xmltree.parse(pth_item_schema.as_posix()) +item_schema = xmltree.XMLSchema(doc_item_schema) item_schema_parser = xmltree.XMLParser(schema=item_schema) def generate_item_description(resource, target): @@ -22,7 +23,8 @@ def generate_item_description(resource, target): rant_path = core.path_appdir / "opt/rant/bin/rant" else: rant_path = "rant" # rely on OS PATH - proc_rant = subprocess.run([rant_path, (core.path_appdir / f"rant/{resource}/{target}.rant").as_posix()], env=rant_env, capture_output=True) + pth_rantfile = gamedata.vfs.copy_out(f"rant/{resource}/{target}.rant") + proc_rant = subprocess.run([rant_path, (core.path_appdir / pth_rantfile).as_posix()], env=rant_env, capture_output=True) if proc_rant.stderr: core.log.warning("rant is throwing up:\n" + proc_rant.stderr.decode()) return proc_rant.stdout.decode().strip() @@ -30,7 +32,7 @@ def generate_item_description(resource, target): def generate_item_list(resource, target, min, max, storybeat=0): count = random.randint(min, max) result = [] - rulefile = xmltree.parse(core.path_appdir / f"rules/items/{target}.xml", item_schema_parser) + rulefile = xmltree.parse(gamedata.vfs.open(f"/rules/items/{target}.xml"), item_schema_parser) ruleset = rulefile.getroot() resource_rules = [] for res_rule in ruleset.iter(f"{{seagull:rules/items}}{resource.title()}"): diff --git a/app/pylocal/tick.py b/app/pylocal/tick.py index ea3e8ee..d024287 100644 --- a/app/pylocal/tick.py +++ b/app/pylocal/tick.py @@ -1,3 +1,4 @@ +import os import json import random import subprocess @@ -6,12 +7,15 @@ import flask from . import core, items, jsonizer +rant_env = os.environ.copy() +rant_env["RANT_MODULES_PATH"] = (core.path_appdir / "basepak/rant").as_posix() + def generate_flavor_text(): if core.desktop_mode: rant_path = core.path_appdir / "opt/rant/bin/rant" else: rant_path = "rant" # rely on OS PATH - proc_rant = subprocess.run([rant_path, (core.path_appdir / "rant/flavor.rant").as_posix()], capture_output=True) + proc_rant = subprocess.run([rant_path, (core.path_appdir / "basepak/rant/flavor.rant").as_posix()], env=rant_env, capture_output=True) return proc_rant.stdout.decode() class TickEvent(object): @@ -61,4 +65,12 @@ def tick(): case _: core.log.warning("undefined tick: {0}".format(result["event_type"])) - return flask.Response(json.dumps(result, cls=jsonizer.JSONizer), status=200, content_type="application/json") \ No newline at end of file + return flask.Response(json.dumps(result, cls=jsonizer.JSONizer), status=200, content_type="application/json") + +@core.app.route("/tick/colony", methods=["POST"]) +def tick_colony() -> flask.Response: + req = flask.request.json + if not req: + return flask.make_response("Bad request", 400) + + \ No newline at end of file diff --git a/app/requirements.txt b/app/requirements.txt index ffc29d9..262860a 100644 --- a/app/requirements.txt +++ b/app/requirements.txt @@ -1,3 +1,4 @@ -Flask==3.1.1 -gevent==25.5.1 -lxml>=6.0.0 \ No newline at end of file +Flask>=3.1.1 +gevent>=25.5.1 +lxml>=6.0.0 +fs>=2.4.16 \ No newline at end of file diff --git a/app/templates/main_page.j2 b/app/templates/main_page.j2 index d1b46f2..f0f3960 100644 --- a/app/templates/main_page.j2 +++ b/app/templates/main_page.j2 @@ -49,7 +49,14 @@
-
It has been a cosmically unknowable number of days.
+
+
It has been a cosmically unknowable number of days.
+
+ + + +
+
diff --git a/build-desktop.sh b/build-desktop.sh index 89979a3..15e365a 100755 --- a/build-desktop.sh +++ b/build-desktop.sh @@ -2,6 +2,7 @@ srcdir=$(pwd) BUILD_DIR=${BUILD_DIR:-$srcdir/build} +CACHE_DIR=${CACHE_DIR:-$srcdir/build_cache} echo "$srcdir => $BUILD_DIR" die () { @@ -18,21 +19,33 @@ findcmd python findcmd rsync mkdir -p $BUILD_DIR && cd $BUILD_DIR +mkdir -p $CACHE_DIR rsync -rv --include-from=$srcdir/.rsync-include $srcdir/ $BUILD_DIR/ # rant -mkdir -p opt/rant -cargo install rant --version 4.0.0-alpha.33 --root $BUILD_DIR/opt/rant --features cli +if [[ -f $CACHE_DIR/rant.tar.zst ]]; then + pv -bprt -N "unpacking: rant" $CACHE_DIR/rant.tar.zst | tar -x --zstd -f - +else + mkdir -p opt/rant + cargo install rant --version 4.0.0-alpha.33 --root $BUILD_DIR/opt/rant --features cli + tar -c -f - --zstd opt/rant | pv -bprt -N "caching: rant" > $CACHE_DIR/rant.tar.zst +fi python $srcdir/render-wordlists.py -i $srcdir/ext/imsky/wordlists -o rant $BUILD_DIR/app/rant/wordlist.rant # python venv -python -m venv pyvenv -source pyvenv/bin/activate -pip install -r app/requirements.txt -pip install -r app/requirements-build-desktop.txt -pip install -r app/requirements-desktop.txt -[[ $(uname -s) == "Linux" ]] && pip install -r app/requirements-desktop-linux.txt -#[[ $(uname -s) == "Darwin" ]] && pip install -r app/requirements-desktop-macos.txt +if [[ -f $CACHE_DIR/pyvenv.tar.zst ]]; then + pv -bprt -N "unpacking: pyvenv" $CACHE_DIR/pyvenv.tar.zst | tar -x --zstd -f - + source pyvenv/bin/activate +else + python -m venv pyvenv + source pyvenv/bin/activate + pip install -r app/requirements.txt + pip install -r app/requirements-build-desktop.txt + pip install -r app/requirements-desktop.txt + [[ $(uname -s) == "Linux" ]] && pip install -r app/requirements-desktop-linux.txt + #[[ $(uname -s) == "Darwin" ]] && pip install -r app/requirements-desktop-macos.txt + tar -c -f - --zstd pyvenv | pv -bprt -N "caching: pyvenv" > $CACHE_DIR/pyvenv.tar.zst +fi pyinstaller seagull.spec deactivate diff --git a/build-pak.sh b/build-pak.sh new file mode 100755 index 0000000..206250a --- /dev/null +++ b/build-pak.sh @@ -0,0 +1,10 @@ +#!/bin/bash +# ('app/templates', './templates'), +# ('app/rules', './rules'), +# ('static', './static'), +# ('app/rant', './rant'), +# ('opt', './opt') + +zip -7rv seagull.pak static +cd app +zip -7rv ../seagull.pak templates rules rant \ No newline at end of file diff --git a/seagull.spec b/seagull.spec index b9a2061..2b9c6fb 100644 --- a/seagull.spec +++ b/seagull.spec @@ -6,10 +6,8 @@ a = Analysis( pathex=[], binaries=[], datas=[ - ('app/templates', './templates'), - ('app/rules', './rules'), - ('static', './static'), - ('app/rant', './rant'), + ('app/rant', 'basepak/rant'), + ('app/rules/schemas', 'basepak/rules/schemas'), ('opt', './opt') ], hiddenimports=[], diff --git a/static/css/seagull.css b/static/css/seagull.css index 31c8b8d..2f8ab00 100644 --- a/static/css/seagull.css +++ b/static/css/seagull.css @@ -35,13 +35,30 @@ div#main-content { padding-left: 5px; } -div#main-day-stats { + +div#main-header { + display: flex; + flex-direction: row; min-height: 100px; vertical-align: middle; border-bottom: 0.125em solid rgb(192,192,192); } +div#main-day-stats { + width: 100%; + margin-top: auto; + margin-bottom: auto; + vertical-align: middle; +} + +div#main-button-bar { + display: flex; + flex-direction: row; + min-height: 125px; + vertical-align: middle; +} + div#main-log { display: flex; flex-direction: column-reverse; @@ -109,4 +126,12 @@ div#modal { height: 90%; border: 1.25em double rgba(192, 192, 192, 255); background-color: rgba(255, 255, 255, 255); +} + +button.main-bar { + width: 2.5em; + height: 2.5em; + background-color: rgba(0,0,0,0); + border: 1px solid black; + font-size: 2em; } \ No newline at end of file diff --git a/static/js/seagull.js b/static/js/seagull.js index 7fafefd..a407bf0 100644 --- a/static/js/seagull.js +++ b/static/js/seagull.js @@ -24,7 +24,8 @@ const gamestate_default = { "enc_human": "pause", "enc_seagull": "pause", "agility": 0, - "instinct": 0 + "instinct": 0, + "leadership": 0 }; var bool_log_alt = false @@ -109,7 +110,8 @@ async function open_modal_dialog(dialog) { if (!modal_dialog_open) { tick_meter_running = false; modal_dialog_open = true; - modal_background.style = "z-index: 10 !important; visibility: visible !important;"; + modal_background.style.zIndex = "10 !important"; + modal_background.style.visibility = "visible !important"; } dialog_data = await fetch(`/dialog/${dialog}`) @@ -162,6 +164,15 @@ function reward_xp(amount) { gamestate["xp"] -= old_xp_next; gamestate["level"] += 1; gamestate["xp_next"] = (old_xp_next * 1.5) + (gamestate["level"] * 5); + + if (gamestate["level"] == 2) { + gamestate["story_beat"] = 1; + record_log("The humans have fired off some sort of large rocket from a nearby platform. You watch it as it pierces the sky above you and fades into the heavens."); + } else if (gamestate["level"] == 3) { + gamestate["story_beat"] = 2; + gamestate["class"] = "Seagull"; + record_log("You have grown up from a young, eager seaglet to a full blown Seagull. As your colony participates in the ritual honoring your coming of age, you begin to detect a shift in the winds, though you're not certain exactly how."); + } } } @@ -180,6 +191,10 @@ async function steal_resource(resource, target, amount, itemstr) { } async function recruit(amount) { + if (gamestate["shinies"] < amount) { + record_log("You do not have enough shinies to recruit this seagull."); + return; + } var stealdata = await fetch("/act/recruit", {method: "POST", body: JSON.stringify({gamestate: gamestate})}) .then(res => { return res.json(); }) .catch(e => { throw e; }); @@ -375,9 +390,13 @@ target.addEventListener(start_event, function (ev) { page_elements["lbl_level"] = document.querySelector("#lbl-seagull-lvl"); page_elements["menu_enc_human"] = document.querySelector("#menu-enc-human"); page_elements["menu_enc_seagull"] = document.querySelector("#menu-enc-seagull"); + page_elements["btn_charsheet"] = document.querySelector("#button-charsheet"); + page_elements["btn_settings"] = document.querySelector("#button-settings"); + page_elements["btn_about"] = document.querySelector("#button-about"); - page_elements["menu_enc_human"].addEventListener("change", (ev) => {update_action("human", ev.target.value)}) - page_elements["menu_enc_seagull"].addEventListener("change", (ev) => {update_action("seagull", ev.target.value)}) + page_elements["menu_enc_human"].addEventListener("change", (ev) => {update_action("human", ev.target.value)}); + page_elements["menu_enc_seagull"].addEventListener("change", (ev) => {update_action("seagull", ev.target.value)}); + page_elements["btn_charsheet"].addEventListener("click", (ev) => {open_modal_dialog("charsheet")}); prepare_gamestate();