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
This commit is contained in:
2025-09-02 16:44:28 -07:00
parent 621d65b9e5
commit 0bd2f4827b
16 changed files with 226 additions and 42 deletions

8
.gitignore vendored
View File

@@ -1,5 +1,11 @@
.vscode/** .vscode/**
scratch.ipynb scratch.ipynb
.docker-login-credentials .docker-login-credentials
seagull seagull
build/** seagull.pak
build/**
build_cache/**
**/__pycache__/**
**.pyc

View File

@@ -1,4 +1,3 @@
app/** app/**
ext/imsky/wordlists/** ext/imsky/wordlists/**
static/**
seagull.spec seagull.spec

View File

@@ -8,8 +8,9 @@ import threading
import webview import webview
import flask 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 core.desktop_mode = True
sig_exit = threading.Event() sig_exit = threading.Event()
@@ -24,6 +25,7 @@ def index():
core.render_base_context() 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(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")) 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) return flask.render_template("main_page.j2", **core.base_context)
if __name__ == "__main__": if __name__ == "__main__":
@@ -37,6 +39,9 @@ if __name__ == "__main__":
storage_dir = pathlib.Path(os.environ["HOME"]) / ".local/share/seagull" storage_dir = pathlib.Path(os.environ["HOME"]) / ".local/share/seagull"
desktop.path_storagedir = storage_dir desktop.path_storagedir = storage_dir
gamedata.vfs.load_data_source("basepak")
gamedata.vfs.load_data_source("seagull.pak", proto="zip")
if argo.debug: if argo.debug:
desktop.api.debug_mode = True desktop.api.debug_mode = True
storage_dir.mkdir(exist_ok=True, parents=True) storage_dir.mkdir(exist_ok=True, parents=True)

View File

@@ -10,7 +10,7 @@ import threading
import flask import flask
from gevent.pywsgi import WSGIServer 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() sig_exit = threading.Event()

View File

@@ -18,7 +18,9 @@ log.critical(path_appdir)
desktop_mode = False 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 orig_url_for = app.url_for
xml_namespaces = { 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): def url_for_override(endpoint, *posargs, _anchor=None, _method=None, _scheme=None, _external=None, self=app, **values):
if endpoint == "static": if endpoint == "static":
# bandaid for #1 if not gamedata.vfs.exists(f"static/{values["filename"]}"):
if not os.path.exists(path_appdir / "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"])) 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 "https://cdn.otl-hga.net/seagull/" + values["filename"]
else:
return orig_url_for(endpoint, *posargs, _anchor=_anchor, _method=_method, _scheme=_scheme, _external=_external, **values) 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 app.url_for = url_for_override
@@ -48,7 +53,8 @@ base_context_live = False
@app.route("/dialog/<dialog>") @app.route("/dialog/<dialog>")
def render_dialog(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") return flask.render_template(f"{dialog}.j2")
else: else:
return "", 404 return "", 404

View File

@@ -5,8 +5,10 @@ from . import core
path_storagedir = pathlib.Path() path_storagedir = pathlib.Path()
class JS_API: class JS_API:
def __init__(self): debug_mode = False
self.debug_mode = False
def __init__(self, debug_mode=False):
self.debug_mode = debug_mode
def load_data(self, key): def load_data(self, key):
if not (path_storagedir / key).exists(): if not (path_storagedir / key).exists():

79
app/pylocal/gamedata.py Normal file
View File

@@ -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()

View File

@@ -4,17 +4,18 @@ import subprocess
import lxml.etree as xmltree import lxml.etree as xmltree
from . import core from . import core, gamedata
valid_resources = [ valid_resources = [
"food", "shinies", "psi" # early game "food", "shinies", "psi" # early game
] ]
rant_env = os.environ.copy() 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") pth_item_schema = core.path_appdir / "basepak/rules/schemas/items.xsd"
item_schema = xmltree.XMLSchema(fd_item_schema) 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) item_schema_parser = xmltree.XMLParser(schema=item_schema)
def generate_item_description(resource, target): 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" rant_path = core.path_appdir / "opt/rant/bin/rant"
else: else:
rant_path = "rant" # rely on OS PATH 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: if proc_rant.stderr:
core.log.warning("rant is throwing up:\n" + proc_rant.stderr.decode()) core.log.warning("rant is throwing up:\n" + proc_rant.stderr.decode())
return proc_rant.stdout.decode().strip() 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): def generate_item_list(resource, target, min, max, storybeat=0):
count = random.randint(min, max) count = random.randint(min, max)
result = [] 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() ruleset = rulefile.getroot()
resource_rules = [] resource_rules = []
for res_rule in ruleset.iter(f"{{seagull:rules/items}}{resource.title()}"): for res_rule in ruleset.iter(f"{{seagull:rules/items}}{resource.title()}"):

View File

@@ -1,3 +1,4 @@
import os
import json import json
import random import random
import subprocess import subprocess
@@ -6,12 +7,15 @@ import flask
from . import core, items, jsonizer 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(): def generate_flavor_text():
if core.desktop_mode: if core.desktop_mode:
rant_path = core.path_appdir / "opt/rant/bin/rant" rant_path = core.path_appdir / "opt/rant/bin/rant"
else: else:
rant_path = "rant" # rely on OS PATH 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() return proc_rant.stdout.decode()
class TickEvent(object): class TickEvent(object):
@@ -61,4 +65,12 @@ def tick():
case _: case _:
core.log.warning("undefined tick: {0}".format(result["event_type"])) 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") 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)

View File

@@ -1,3 +1,4 @@
Flask==3.1.1 Flask>=3.1.1
gevent==25.5.1 gevent>=25.5.1
lxml>=6.0.0 lxml>=6.0.0
fs>=2.4.16

View File

@@ -49,7 +49,14 @@
</div> </div>
<div id="main-content"> <div id="main-content">
<div id="main-day-stats">It has been <span id="main-day-counter">a cosmically unknowable number of</span> days.</div> <div id="main-header">
<div id="main-day-stats">It has been <span id="main-day-counter">a cosmically unknowable number of</span> days.</div>
<div id="main-button-bar">
<button id="button-charsheet" class="main-bar">📊</button>
<button id="button-settings" class="main-bar">⚙️</button>
<button id="button-about" class="main-bar"></button>
</div>
</div>
<div id="main-log"></div> <div id="main-log"></div>
</div> </div>

View File

@@ -2,6 +2,7 @@
srcdir=$(pwd) srcdir=$(pwd)
BUILD_DIR=${BUILD_DIR:-$srcdir/build} BUILD_DIR=${BUILD_DIR:-$srcdir/build}
CACHE_DIR=${CACHE_DIR:-$srcdir/build_cache}
echo "$srcdir => $BUILD_DIR" echo "$srcdir => $BUILD_DIR"
die () { die () {
@@ -18,21 +19,33 @@ findcmd python
findcmd rsync findcmd rsync
mkdir -p $BUILD_DIR && cd $BUILD_DIR mkdir -p $BUILD_DIR && cd $BUILD_DIR
mkdir -p $CACHE_DIR
rsync -rv --include-from=$srcdir/.rsync-include $srcdir/ $BUILD_DIR/ rsync -rv --include-from=$srcdir/.rsync-include $srcdir/ $BUILD_DIR/
# rant # rant
mkdir -p opt/rant if [[ -f $CACHE_DIR/rant.tar.zst ]]; then
cargo install rant --version 4.0.0-alpha.33 --root $BUILD_DIR/opt/rant --features cli 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 $srcdir/render-wordlists.py -i $srcdir/ext/imsky/wordlists -o rant $BUILD_DIR/app/rant/wordlist.rant
# python venv # python venv
python -m venv pyvenv if [[ -f $CACHE_DIR/pyvenv.tar.zst ]]; then
source pyvenv/bin/activate pv -bprt -N "unpacking: pyvenv" $CACHE_DIR/pyvenv.tar.zst | tar -x --zstd -f -
pip install -r app/requirements.txt source pyvenv/bin/activate
pip install -r app/requirements-build-desktop.txt else
pip install -r app/requirements-desktop.txt python -m venv pyvenv
[[ $(uname -s) == "Linux" ]] && pip install -r app/requirements-desktop-linux.txt source pyvenv/bin/activate
#[[ $(uname -s) == "Darwin" ]] && pip install -r app/requirements-desktop-macos.txt 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 pyinstaller seagull.spec
deactivate deactivate

10
build-pak.sh Executable file
View File

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

View File

@@ -6,10 +6,8 @@ a = Analysis(
pathex=[], pathex=[],
binaries=[], binaries=[],
datas=[ datas=[
('app/templates', './templates'), ('app/rant', 'basepak/rant'),
('app/rules', './rules'), ('app/rules/schemas', 'basepak/rules/schemas'),
('static', './static'),
('app/rant', './rant'),
('opt', './opt') ('opt', './opt')
], ],
hiddenimports=[], hiddenimports=[],

View File

@@ -35,13 +35,30 @@ div#main-content {
padding-left: 5px; padding-left: 5px;
} }
div#main-day-stats {
div#main-header {
display: flex;
flex-direction: row;
min-height: 100px; min-height: 100px;
vertical-align: middle; vertical-align: middle;
border-bottom: 0.125em solid rgb(192,192,192); 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 { div#main-log {
display: flex; display: flex;
flex-direction: column-reverse; flex-direction: column-reverse;
@@ -109,4 +126,12 @@ div#modal {
height: 90%; height: 90%;
border: 1.25em double rgba(192, 192, 192, 255); border: 1.25em double rgba(192, 192, 192, 255);
background-color: rgba(255, 255, 255, 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;
} }

View File

@@ -24,7 +24,8 @@ const gamestate_default = {
"enc_human": "pause", "enc_human": "pause",
"enc_seagull": "pause", "enc_seagull": "pause",
"agility": 0, "agility": 0,
"instinct": 0 "instinct": 0,
"leadership": 0
}; };
var bool_log_alt = false var bool_log_alt = false
@@ -109,7 +110,8 @@ async function open_modal_dialog(dialog) {
if (!modal_dialog_open) { if (!modal_dialog_open) {
tick_meter_running = false; tick_meter_running = false;
modal_dialog_open = true; 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}`) dialog_data = await fetch(`/dialog/${dialog}`)
@@ -162,6 +164,15 @@ function reward_xp(amount) {
gamestate["xp"] -= old_xp_next; gamestate["xp"] -= old_xp_next;
gamestate["level"] += 1; gamestate["level"] += 1;
gamestate["xp_next"] = (old_xp_next * 1.5) + (gamestate["level"] * 5); 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) { 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})}) var stealdata = await fetch("/act/recruit", {method: "POST", body: JSON.stringify({gamestate: gamestate})})
.then(res => { return res.json(); }) .then(res => { return res.json(); })
.catch(e => { throw e; }); .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["lbl_level"] = document.querySelector("#lbl-seagull-lvl");
page_elements["menu_enc_human"] = document.querySelector("#menu-enc-human"); page_elements["menu_enc_human"] = document.querySelector("#menu-enc-human");
page_elements["menu_enc_seagull"] = document.querySelector("#menu-enc-seagull"); 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_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_seagull"].addEventListener("change", (ev) => {update_action("seagull", ev.target.value)});
page_elements["btn_charsheet"].addEventListener("click", (ev) => {open_modal_dialog("charsheet")});
prepare_gamestate(); prepare_gamestate();