giant freakin documentation and reorganization pass, it also uses cmake because all the building was getting too complicated for shell scripts

This commit is contained in:
2025-09-29 20:31:42 -07:00
parent a5f837189b
commit 0edb4b50d2
71 changed files with 4895 additions and 127 deletions

View File

@@ -1,8 +1,16 @@
<?xml version="1.0" standalone="yes"?>
<xs:schema id="UpgradeRules" targetNamespace="seagull:rules/upgrades" xmlns="seagull:rules/upgrades" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata" attributeFormDefault="unqualified" elementFormDefault="qualified">
<xs:element name="UpgradeRules" msdata:IsDataSet="true" msdata:UseCurrentLocale="true">
<xs:element name="UpgradeRules">
<xs:complexType>
<xs:choice minOccurs="0" maxOccurs="unbounded">
<xs:element name="TreeData" maxOccurs="1">
<xs:complexType>
<xs:choice maxOccurs="unbounded">
<xs:element name="Name" maxOccurs="1" type="xs:string" />
<xs:element name="PrimaryColor" maxOccurs="1" type="xs:string" />
</xs:choice>
</xs:complexType>
</xs:element>
<xs:element name="Upgrade">
<xs:complexType>
<xs:choice maxOccurs="unbounded">
@@ -23,6 +31,13 @@
</xs:sequence>
</xs:complexType>
</xs:element>
<xs:element name="Requirements" maxOccurs="1" minOccurs="0">
<xs:complexType>
<xs:sequence>
<xs:element name="Require" maxOccurs="unbounded" type="xs:string" />
</xs:sequence>
</xs:complexType>
</xs:element>
</xs:choice>
</xs:complexType>
</xs:element>

View File

@@ -0,0 +1,5 @@
function structuredClone(val) {
var output = JSON.parse(JSON.stringify(val));
if (window.pywebview.api.debug_mode) { console.log(("structuredClone:" + val) + " => " + outval); }
return output;
}

View File

@@ -0,0 +1,164 @@
/*
* Konami-JS ~
* :: Now with support for touch events and multiple instances for
* :: those situations that call for multiple easter eggs!
* Code: https://github.com/georgemandis/konami-js
* Copyright (c) 2009 George Mandis (https://george.mand.is)
* Version: 1.7.0 (09/03/2024)
* Licensed under the MIT License (http://opensource.org/licenses/MIT)
* Tested in: Safari 4+, Google Chrome 4+, Firefox 3+, IE7+, Mobile Safari 2.2.1+ and Android
*/
var Konami = function (callback) {
var konami = {
addEvent: function (obj, type, fn, ref_obj) {
if (obj.addEventListener) obj.addEventListener(type, fn, false);
else if (obj.attachEvent) {
// IE
obj["e" + type + fn] = fn;
obj[type + fn] = function () {
obj["e" + type + fn](window.event, ref_obj);
};
obj.attachEvent("on" + type, obj[type + fn]);
}
},
removeEvent: function (obj, eventName, eventCallback) {
if (obj.removeEventListener) {
obj.removeEventListener(eventName, eventCallback);
} else if (obj.attachEvent) {
obj.detachEvent(eventName);
}
},
input: "",
pattern: "38384040373937396665",
keydownHandler: function (e, ref_obj) {
if (ref_obj) {
konami = ref_obj;
} // IE
konami.input += e ? e.keyCode : event.keyCode;
if (konami.input.length > konami.pattern.length) {
konami.input = konami.input.substr(
konami.input.length - konami.pattern.length,
);
}
if (konami.input === konami.pattern) {
konami.code(konami._currentLink);
konami.input = "";
e.preventDefault();
return false;
}
},
load: function (link) {
this._currentLink = link;
this.addEvent(document, "keydown", this.keydownHandler, this);
this.iphone.load(link);
},
unload: function () {
this.removeEvent(document, "keydown", this.keydownHandler);
this.iphone.unload();
},
code: function (link) {
window.location = link;
},
iphone: {
start_x: 0,
start_y: 0,
stop_x: 0,
stop_y: 0,
tap: false,
capture: false,
orig_keys: "",
keys: [
"UP",
"UP",
"DOWN",
"DOWN",
"LEFT",
"RIGHT",
"LEFT",
"RIGHT",
"TAP",
"TAP",
],
input: [],
code: function (link) {
konami.code(link);
},
touchmoveHandler: function (e) {
if (e.touches.length === 1 && konami.iphone.capture === true) {
var touch = e.touches[0];
konami.iphone.stop_x = touch.pageX;
konami.iphone.stop_y = touch.pageY;
konami.iphone.tap = false;
konami.iphone.capture = false;
}
},
touchendHandler: function () {
konami.iphone.input.push(konami.iphone.check_direction());
if (konami.iphone.input.length > konami.iphone.keys.length)
konami.iphone.input.shift();
if (konami.iphone.input.length === konami.iphone.keys.length) {
var match = true;
for (var i = 0; i < konami.iphone.keys.length; i++) {
if (konami.iphone.input[i] !== konami.iphone.keys[i]) {
match = false;
}
}
if (match) {
konami.iphone.code(konami._currentLink);
}
}
},
touchstartHandler: function (e) {
konami.iphone.start_x = e.changedTouches[0].pageX;
konami.iphone.start_y = e.changedTouches[0].pageY;
konami.iphone.tap = true;
konami.iphone.capture = true;
},
load: function (link) {
this.orig_keys = this.keys;
konami.addEvent(document, "touchmove", this.touchmoveHandler);
konami.addEvent(document, "touchend", this.touchendHandler, false);
konami.addEvent(document, "touchstart", this.touchstartHandler);
},
unload: function () {
konami.removeEvent(document, "touchmove", this.touchmoveHandler);
konami.removeEvent(document, "touchend", this.touchendHandler);
konami.removeEvent(document, "touchstart", this.touchstartHandler);
},
check_direction: function () {
var x_magnitude = Math.abs(this.start_x - this.stop_x);
var y_magnitude = Math.abs(this.start_y - this.stop_y);
var x = this.start_x - this.stop_x < 0 ? "RIGHT" : "LEFT";
var y = this.start_y - this.stop_y < 0 ? "DOWN" : "UP";
var result =
this.tap === true ? "TAP" : x_magnitude > y_magnitude ? x : y;
return result;
},
},
};
typeof callback === "string" && konami.load(callback);
if (typeof callback === "function") {
konami.code = callback;
konami.load();
}
return konami;
};
if (typeof module !== "undefined" && typeof module.exports !== "undefined") {
module.exports = Konami;
} else {
if (typeof define === "function" && define.amd) {
define([], function () {
return Konami;
});
} else {
window.Konami = Konami;
}
}

File diff suppressed because one or more lines are too long

View File

@@ -1,5 +1,8 @@
#!/usr/bin/env python
## \file desktop.py
# \brief Entrypoint file for desktop versions of the Seagull Game.
import argparse
import os
import pathlib
@@ -9,8 +12,19 @@ import webview
import flask
import fs.tree
try:
import pyi_splash
except ImportError:
class NullModule():
def update_text(self, *args, **kwargs):
pass
from pylocal import core, actions, desktop, dev, gamedata, items, tick
def close(self):
pass
pyi_splash = NullModule()
from pylocal import core, actions, desktop, dev, gamedata, items, tick, upgrades
core.desktop_mode = True
sig_exit = threading.Event()
@@ -23,13 +37,14 @@ argo = argp.parse_args()
def index():
if not core.base_context_live:
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"))
core.base_context["scripts"].insert(0, (core.app.url_for("static", filename="js/desktop-structuredclone.js"), False))
core.base_context["scripts"].insert(1, (core.app.url_for("static", filename="js/seagull-desktop.js"), False))
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__":
try:
pyi_splash.update_text("Determining storage directory")
if sys.platform.startswith("win"):
storage_dir = pathlib.Path(os.environ["APPDATA"]) / "seagull"
elif sys.platform.startswith("darwin"): # macos
@@ -39,13 +54,16 @@ if __name__ == "__main__":
storage_dir = pathlib.Path(os.environ["HOME"]) / ".local/share/seagull"
desktop.path_storagedir = storage_dir
pyi_splash.update_text("Loading VFS")
gamedata.vfs.load_data_source("basepak")
gamedata.vfs.load_data_source("seagull.pak", proto="zip")
if argo.debug:
desktop.api.debug_mode = True
pyi_splash.update_text("Starting browser shell")
storage_dir.mkdir(exist_ok=True, parents=True)
webview.create_window("Seagull Game", core.app, js_api=desktop.api)
webview.create_window("Seagull Game", core.app, js_api=desktop.api, width=1280, height=720)
pyi_splash.close()
webview.start(private_mode=False, storage_path=storage_dir.as_posix(), debug=True if argo.debug else False)
except KeyboardInterrupt:
core.log.info("Goodnight, moon ...")

View File

@@ -5,6 +5,15 @@ import flask
from . import core
## \brief Rolls a random integer between `min` and `max`, and applies all described `modifiers`.
# \param min The lowest possible number. Default is 0.
# \param max The highest possible number. Default is 100.
# \param modifiers
# \return The result of the dice roll, after modifiers.
#
# Dice roll is actually a slight misnomer; the game primarily uses percentages for chance
# calculations. However, the recognized term for these chance calculations, "dice rolls",
# is D&D old, so is used here for mental ease.
def dice_roll(min=0, max=100, modifiers=[]):
result = random.randint(min, max)
for _, mod in modifiers:
@@ -12,12 +21,29 @@ def dice_roll(min=0, max=100, modifiers=[]):
return result
def calculate_speed(agl):
return 3+(agl * 1.5)
## \brief Attempts to steal a given resource from a target.
# \param resource The resource to steal.
# \param target The class of target to steal from.
# \api{POST} /act/steal/`<resource>`/`<target>`
# \apidata Gamestate.
@core.app.route("/act/steal/<resource>/<target>", methods=["POST"])
def steal_resource(resource, target):
core.log.debug(flask.request.get_data())
gamestate = flask.request.get_json()["gamestate"]
agl = gamestate["agility"]
roll = dice_roll()
speed = calculate_speed(agl)
result = (roll + speed) + (agl/4)
return flask.Response(json.dumps({
"success": (dice_roll() >= 50)
"success": (result >= 50)
}), status=200, content_type="application/json")
## \brief Attempts to recruit a seagull.
# \api{POST} /act/recruit
# \apidata Gamestate.
@core.app.route("/act/recruit", methods=["POST"])
def recruit():
return flask.Response(json.dumps({

View File

@@ -4,6 +4,7 @@ import pathlib
import sys
import flask
from flask_cors import CORS
log = logging.getLogger()
pipe_stderr = logging.StreamHandler(sys.stderr)
@@ -16,11 +17,16 @@ else:
path_appdir = pathlib.Path.cwd()
log.critical(path_appdir)
## \internal
# \brief Signals whether we are a desktop application (as opposed to a Docker container).
desktop_mode = False
from . import gamedata
## \internal
# \brief The Flask instance. See <a href="https://flask.palletsprojects.com/en/stable/api/">Flask documentation</a>.
app = flask.Flask("seagull-game", root_path=path_appdir, template_folder="templates", static_folder="static")
CORS(app)
orig_url_for = app.url_for
xml_namespaces = {
@@ -34,6 +40,10 @@ xml_namespaces = {
#REDIS_PASS="i am not a real password"
#state_cache = redis.Redis(host=REDIS_HOST, port=REDIS_PORT, username=REDIS_USER, password=REDIS_PASS)
# ham5 standing by...
## \internal
# \brief Returns CDN URLs for files not present within the container itself, or copies VFS data out so Flask etc can use it.
def url_for_override(endpoint, *posargs, _anchor=None, _method=None, _scheme=None, _external=None, self=app, **values):
if endpoint == "static":
if not gamedata.vfs.exists(f"static/{values["filename"]}"):
@@ -48,17 +58,25 @@ def url_for_override(endpoint, *posargs, _anchor=None, _method=None, _scheme=Non
app.url_for = url_for_override
## \internal
# \brief Base Flask rendering context. Generated with render_base_context().
base_context = {}
base_context_live = False
## \brief Renders a dialog template and sends it to the client.
# \param dialog The dialog to render.
# \api{GET} /dialog/`<dialog>`
@app.route("/dialog/<dialog>")
def render_dialog(dialog):
if gamedata.vfs.exists(f"templates/{dialog}.j2"):
gamedata.vfs.copy_out(f"templates/{dialog}.j2", dest=path_appdir.as_posix())
if gamedata.vfs.exists(f"static/js/dlg-{dialog}.js"):
gamedata.vfs.copy_out(f"static/js/dlg-{dialog}.js", dest=path_appdir.as_posix())
return flask.render_template(f"{dialog}.j2")
else:
return "", 404
## \brief Prepares the base rendering context for Flask to serve our content.
def render_base_context():
global base_context
global base_context_live
@@ -68,19 +86,32 @@ def render_base_context():
domain_components = flask.request.host.split(".")
base_domain = ".".join(domain_components[-2:])
gamedata.vfs.copy_out("static/js/mermaid.esm.min.mjs", dest=path_appdir.as_posix())
# all this wind up for...
if base_domain == "otl-hga.net": # production, use assets from S3
base_context["styles"] = ["https://cdn.otl-hga.net/seagull/css/seagull.css"]
base_context["scripts"] = ["https://cdn.otl-hga.net/seagull/js/seagull.js"]
base_context["scripts"] = ["https://cdn.otl-hga.net/seagull/js/seagull.js", "https://cdn.otl-hga.net/seagull/js/konami.js"]
base_context["seagull_pic"] = "https://cdn.otl-hga.net/seagull/image/seagull.jpg"
else: # dev, serve files from here
#print(base_domain)
base_context["styles"] = [app.url_for("static", filename="css/seagull.css")]
base_context["scripts"] = [app.url_for("static", filename="js/seagull.js")]
base_context["scripts"] = [(app.url_for("static", filename="js/konami.js"), True), (app.url_for("static", filename="js/seagull.js"), True)]
base_context["seagull_pic"] = app.url_for("static", filename="image/seagull.jpg")
base_context_live = True
## \brief Returns OK. Useful for health checks.
# \api{GET} /core/ping
@app.route("/core/ping")
def healthcheck_ping():
return flask.Response("OK", content_type="text/plain")
## \brief Informs the game we're about to request a file from JavaScript.
# \internal
# \api{POST} /core/ready_file
# \apidata Plaintext path to the intended file.
@app.route("/core/ready_file", methods=["POST"])
def ready_file():
gamedata.vfs.copy_out(flask.request.data)
return flask.Response("OK", content_type="text/plain")

View File

@@ -2,14 +2,29 @@ import pathlib
from . import core
## \brief A <a href="https://docs.python.org/3/library/pathlib.html#pathlib.Path">standard Path object</a> pointing to where the user's local storage is.
#
# Exactly where this is depends on your operating system:
# - On Windows, this is defined as `%%APPDATA%\seagull`, which typically evaluates to something like `"C:\Users\YourNameHere\AppData\Roaming\seagull"`
# - On MacOS, this is defined as `~/Library/Application Support/seagull`, where ~ is your home directory.
# - On Linux, this is defined as `~/.local/share/seagull`.
# \note On Linux, the XDG specification for defining alternative data directories is not currently respected. This will likely change in a future version.
path_storagedir = pathlib.Path()
## \internal
# \brief Defines JavaScript API functions for pywebview.
class JS_API:
# \brief Whether or not we're in debug mode.
debug_mode = False
def __init__(self, debug_mode=False):
self.debug_mode = debug_mode
## \brief Loads data stored under a specific "key" in the local filesystem.
# \param key The key to load.
# \return File contents, or `None`.
# In the current implementation, key is just a raw filename loaded relative to path_storagedir,
# which is calculated as part of main thread startup.
def load_data(self, key):
if not (path_storagedir / key).exists():
return None
@@ -21,6 +36,11 @@ class JS_API:
core.log.error(f"problem loading {key} (from {path_storagedir}): {exc}")
return None
## \brief Saves data stored under a specific "key" in the local filesystem.
# \param key The key to save to.
# \param data The data to write.
# In the current implementation, key is just a raw filename loaded relative to path_storagedir,
# which is calculated as part of main thread startup.
def save_data(self, key, data):
with open(path_storagedir / key, "w") as fd_datafile:
try:
@@ -28,6 +48,8 @@ class JS_API:
except Exception as exc:
core.log.error(f"problem saving {key} (to {path_storagedir}): {exc}")
## \brief Deletes all data stored at key.
# \param key The key to delete.
def delete_data(self, key):
if (path_storagedir / key).exists():
(path_storagedir / key).unlink()

View File

@@ -1,7 +1,11 @@
import flask
from . import core
from . import core, gamedata
## \brief Renders the dev toolbox for the client
# \api{GET} /dev/get-toolbox
@core.app.route("/dev/get-toolbox")
def dev_toolbox():
gamedata.vfs.copy_out("static/sound/open_dev_toolkit.wav", dest=core.path_appdir.as_posix())
gamedata.vfs.copy_out("templates/dev_toolbox.j2", dest=core.path_appdir.as_posix())
return flask.render_template("dev_toolbox.j2", ipaddr=flask.request.remote_addr, desktop=core.desktop_mode)

View File

@@ -12,6 +12,8 @@ import fs.osfs
from . import core
## \brief Handler for virtual filesystem.
# \internal
class GameVFSHandler(object):
vfs = None
log = logging.getLogger().getChild("vfs")
@@ -43,6 +45,10 @@ class GameVFSHandler(object):
except:
raise
## \brief Load files from a given data source.
# \param source The data source. This can be a pyfilesystem2 object, or any string or path-like
# object that can reasonably be interpreted as a directory or zip file.
# \param proto The <a href="https://pyfilesystem2.readthedocs.io/en/latest/builtin.html">PyFilesystem2 filesystem protocol</a> to use. Defaults to `"osfs"`, which loads directories.
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
@@ -63,6 +69,10 @@ class GameVFSHandler(object):
self.log.info(f"loading vfs source: {fs_source} (pyfilesystem2 handler)")
fs.copy.copy_fs(fs_source, self.vfs)
## \brief Copies a file out of VFS into the real filesystem.
# \param filepath The source file path to copy out.
# \param dest The path to write the file to. Defaults to temporary directory.
# \return The full destination file path.
def copy_out(self, filepath, dest=None):
if not dest:
self.osfs_temp.makedirs(pathlib.Path(filepath).parent.as_posix(), recreate=True)
@@ -76,4 +86,6 @@ class GameVFSHandler(object):
fs.copy.copy_file(self.vfs, filepath, dest, filepath)
return (pth_dest / filepath).as_posix()
## \brief Primary VFS handler.
# \internal
vfs = GameVFSHandler()

View File

@@ -10,6 +10,8 @@ valid_resources = [
"food", "shinies", "psi" # early game
]
## \internal
# \brief The environment variable map to run Rant with.
rant_env = os.environ.copy()
rant_env["RANT_MODULES_PATH"] = (core.path_appdir / "basepak/rant").as_posix()
@@ -18,6 +20,12 @@ 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)
## \brief Generates an absolutely reasonable description of a given item that a target might have.
# \param resource The resource to generate a description for.
# \param target The poor soul carrying the item.
# \return An absolutely reasonable description of an item.
#
# \include{doc,local} res/doc/python/items.generate_item_description.mdpart
def generate_item_description(resource, target):
if core.desktop_mode:
rant_path = core.path_appdir / "opt/rant/bin/rant"
@@ -29,6 +37,13 @@ def generate_item_description(resource, target):
core.log.warning("rant is throwing up:\n" + proc_rant.stderr.decode())
return proc_rant.stdout.decode().strip()
## \brief Generates a list of items worth `min`-`max` `resource` that `target` would reasonably have.
# \param resource The resource to generate a description for.
# \param target The poor soul carrying the item.
# \param min The lowest possible value, per item.
# \param max The highest possible value, per item.
# \param storybeat Inform the rule parsing engine where we are in the story.
# \return A list of TickItem instances.
def generate_item_list(resource, target, min, max, storybeat=0):
count = random.randint(min, max)
result = []
@@ -43,12 +58,12 @@ def generate_item_list(resource, target, min, max, storybeat=0):
maxdata = res_rule.xpath("./items:Max", namespaces=core.xml_namespaces)[0]
resource_rules.append((res_rule, int(mindata.text), int(maxdata.text)))
for i in range(0, count):
core.log.warning("TODO: we don't know which rule this parses yet")
core.log.warning(f"{resource} vs humans: {resource_rules[0]}")
core.log.warning(f"{resource} vs humans: {resource_rules[0][1]} <-> {resource_rules[0][2]} (sb: {resource_rules[0][0].get("StoryBeat", "0")})")
result.append(TickItem(resource, round(random.uniform(resource_rules[0][1], resource_rules[0][2]), 2), target))
return result
## \brief A tick item.
class TickItem(object):
def __init__(self, resource, amount, target):
if resource not in valid_resources:

View File

@@ -2,6 +2,8 @@ import json
from . import items
## \brief Extended JSON encoder object to handle seagull-specific items.
# \internal
class JSONizer(json.JSONEncoder):
def default(self, obj):
if isinstance(obj, items.TickItem):

View File

@@ -5,7 +5,7 @@ import subprocess
import flask
from . import core, items, jsonizer
from . import core, gamedata, items, jsonizer
rant_env = os.environ.copy()
rant_env["RANT_MODULES_PATH"] = (core.path_appdir / "basepak/rant").as_posix()
@@ -15,7 +15,8 @@ def generate_flavor_text():
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 / "basepak/rant/flavor.rant").as_posix()], env=rant_env, capture_output=True)
flavor_file = gamedata.vfs.copy_out("rant/flavor.rant")
proc_rant = subprocess.run([rant_path, flavor_file], env=rant_env, capture_output=True)
return proc_rant.stdout.decode()
class TickEvent(object):
@@ -30,6 +31,9 @@ tick_event_list.append(TickEvent(1, 1, "FLAVOR")) # procedurally generated
tick_event_list.append(TickEvent(10, 2, "ENCHUMAN")) # encounter: human
tick_event_list.append(TickEvent(11, 2, "ENCGULL"))
## \brief Returns the results of a game tick.
# \api{GET} /tick
# \return Game tick events and results.
@core.app.route("/tick")
def tick():
#return random.choices([json.dumps({"code": 200, "event_type": 0}), json.dumps({"code": 200, "event_type": 1, "log": generate_flavor_text()})], weights=[16, 1])[0]
@@ -67,10 +71,28 @@ def tick():
return flask.Response(json.dumps(result, cls=jsonizer.JSONizer), status=200, content_type="application/json")
## \brief Returns the results of a colony tick.
# \api{GET} /tick/colony
# \return The result of a colony tick.
@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)
colony = req["colony"]
modifiers = req["modifiers"]
inc_food = req["avg_food"]
inc_shinies = req["avg_shinies"]
out_food = (inc_food * 0.35) * (colony / 2)
out_shinies = (inc_shinies * 0.25) * (colony / 3)
result = {
"success": True,
"food": out_food,
"shinies": out_shinies
}
return flask.Response(json.dumps(result, cls=jsonizer.JSONizer), status=200, content_type="application/json")

93
app/pylocal/upgrades.py Normal file
View File

@@ -0,0 +1,93 @@
import io
import logging
import textwrap
from collections import namedtuple
import flask
import lxml.etree as xmltree
import mermaid
from . import core, gamedata
pth_upgrade_schema = core.path_appdir / "basepak/rules/schemas/upgrades.xsd"
doc_upgrade_schema = xmltree.parse(pth_upgrade_schema.as_posix())
upgrade_schema = xmltree.XMLSchema(doc_upgrade_schema)
upgrade_schema_parser = xmltree.XMLParser(schema=upgrade_schema)
UpgradeData = namedtuple("UpgradeData", ["id", "name", "desc", "requires"])
## \brief Renders an upgrade tree.
# \param tree The upgrade tree to retrieve.
# \api{GET} /upgrades/`<tree>`
# \return An SVG.
@core.app.route("/upgrades/<tree>")
def get_upgrade_tree(tree):
buf_mmd = io.StringIO()
rulefile = xmltree.parse(gamedata.vfs.open(f"/rules/upgrades/{tree}.xml"), upgrade_schema_parser)
ruleset = rulefile.getroot()
hnd_treedata = ruleset.xpath("./upgrades:TreeData", namespaces=core.xml_namespaces)[0]
hnd_name = hnd_treedata.xpath("./upgrades:Name", namespaces=core.xml_namespaces)[0]
hnd_primary_color = hnd_treedata.xpath("./upgrades:PrimaryColor", namespaces=core.xml_namespaces)[0]
buf_mmd.write(textwrap.dedent(f"""
----
title: {hnd_name.text}
theme: base
themeVariables:
primaryColor: {hnd_primary_color.text}
----
flowchart LR
"""))
tree_upgrades = []
for hnd_upgrade in ruleset.iter("{seagull:rules/upgrades}Upgrade"):
hnd_id = hnd_upgrade.xpath("./upgrades:Id", namespaces=core.xml_namespaces)[0]
hnd_upgrade_name = hnd_upgrade.xpath("./upgrades:Name", namespaces=core.xml_namespaces)[0]
hnd_desc = hnd_upgrade.xpath("./upgrades:Desc", namespaces=core.xml_namespaces)[0]
try:
hnd_requires = hnd_upgrade.xpath("./upgrades:Requirements", namespaces=core.xml_namespaces)[0]
require_list = [elem.text for elem in hnd_requires.iter("{seagull:rules/upgrades}Require")]
except IndexError:
require_list = []
upgrade = UpgradeData(
id=hnd_id.text,
name=hnd_upgrade_name.text,
desc=hnd_desc.text,
requires=require_list
)
tree_upgrades.append(upgrade)
tiers = {}
dependency_lines = []
for upgrade in tree_upgrades:
deptier = 0
buf_mmd.write(f" {upgrade.id}@{{label: \"{upgrade.name}\"}}\n")
collected_tiers = []
for require in upgrade.requires:
if require in tiers:
collected_tiers.append(tiers[require])
dependency_lines.append(f" {require} --> {upgrade.id}\n")
if len(collected_tiers) > 0:
deptier = max(collected_tiers)
tiers[upgrade.id] = deptier + 1
for line in dependency_lines:
buf_mmd.write(line)
buf_mmd.seek(0)
print(buf_mmd.read())
return get_upgrade_tree_mmd(tree) # TEMP
def get_upgrade_tree_mmd(tree):
if not gamedata.vfs.exists(f"upgrades/{tree}.mmd"):
return flask.make_response("No Upgrade Tree", 404)
with gamedata.vfs.open(f"upgrades/{tree}.mmd") as fd_upgradetree:
mmd_upgradetree = mermaid.Mermaid(fd_upgradetree.read(), height=400)
return mmd_upgradetree.svg_response.content

View File

@@ -1,29 +0,0 @@
@require "wordlist"
[$title: str] @text {
<$split_str = [split: <str>]>
[cat: [upper: <split_str/0>] [lower: [join: <split_str/1..>]]]
}
{
You meet {
a `[pick: <wordlist/nouns/birds>] |
[title: `{
[pick: <wordlist/names/people/butlers>] |
[pick: <wordlist/names/people/computing>] |
[pick: <wordlist/names/people/founders>] |
[pick: <wordlist/names/streets/chicago>] |
[pick: <wordlist/names/streets/newyork>] |
[pick: <wordlist/names/surnames/english>] |
[pick: <wordlist/names/surnames/irish>] |
[pick: <wordlist/names/surnames/scottish>]
}] the `[pick: <wordlist/nouns/birds>]
}. {
It completely ignores you. |
You have a polite conversation about birdly affairs. |
It scoffs and flies away.
}
} |
{
A nearby {`[pick: <wordlist/nouns/birds>]|colony of `[pick: <wordlist/nouns/birds>]s} seems to be harassing a human.
}

View File

@@ -1,55 +0,0 @@
@require "wordlist"
[$desc_food] @text {
{
[pick: <wordlist/adjectives/food>] @weight 1.25 |
[pick: <wordlist/adjectives/taste>] @weight 1.1 |
[pick: <wordlist/names/cities/united_states>] @weight 1.1 |
[pick: <wordlist/names/cities/canada>] @weight 0.9 |
[pick: <wordlist/names/cities/spain>] @weight 0.75 |
[pick: <wordlist/names/cities/alpha>] @weight 0.5 |
{ # stuffed/filled/covered
`{
[pick: <wordlist/nouns/fruit>] @weight 1 |
[pick: <wordlist/nouns/meat>] @weight 1 |
[pick: <wordlist/nouns/food>] @weight 1 |
[pick: <wordlist/nouns/cheese>] @weight 1 |
[pick: <wordlist/nouns/condiments>] @weight 1 |
[pick: <wordlist/nouns/music_theory>] @weight 0.5 |
[pick: <wordlist/nouns/music_production>] @weight 0.5 |
[pick: <wordlist/nouns/set_theory>] @weight 0.25 |
[pick: <wordlist/nouns/ghosts>] @weight 0.33 |
[pick: <wordlist/nouns/web_development>] @weight 0.25
} `{stuffed|filled|covered|dipped|coated}
} @weight 1 # stuffed/filled/covered
}
}
[$get_entree] @text {
{
[pick: <wordlist/nouns/food>] |
[pick: <wordlist/nouns/fast_food>]
}
}
##
[$mod_order] @text {
{
add | no | sub | extra |
half | left | right | side
} `{
[pick: <wordlist/nouns/condiments>] |
[pick: <wordlist/nouns/cheese>] |
[pick: <wordlist/nouns/food>] |
[pick: <wordlist/nouns/seasonings>] |
[pick: <wordlist/nouns/plants>]
}
}
##
{
a piece of `[pick: <wordlist/nouns/cheese>] cheese |
a `{
[if: [maybe]{[desc_food]}] [get_entree] |
[pick: <wordlist/nouns/fruit>]
}
}

View File

@@ -1,55 +0,0 @@
@require "wordlist"
[$desc_food] @text {
{
[pick: <wordlist/adjectives/food>] @weight 1.25 |
[pick: <wordlist/adjectives/taste>] @weight 1.1 |
[pick: <wordlist/names/cities/united_states>] @weight 1.1 |
[pick: <wordlist/names/cities/canada>] @weight 0.9 |
[pick: <wordlist/names/cities/spain>] @weight 0.75 |
[pick: <wordlist/names/cities/alpha>] @weight 0.5 |
{ # stuffed/filled/covered
`{
[pick: <wordlist/nouns/fruit>] @weight 1 |
[pick: <wordlist/nouns/meat>] @weight 1 |
[pick: <wordlist/nouns/food>] @weight 1 |
[pick: <wordlist/nouns/cheese>] @weight 1 |
[pick: <wordlist/nouns/condiments>] @weight 1 |
[pick: <wordlist/nouns/music_theory>] @weight 0.5 |
[pick: <wordlist/nouns/music_production>] @weight 0.5 |
[pick: <wordlist/nouns/set_theory>] @weight 0.25 |
[pick: <wordlist/nouns/ghosts>] @weight 0.33 |
[pick: <wordlist/nouns/web_development>] @weight 0.25
} `{stuffed|filled|covered|dipped|coated}
} @weight 1 # stuffed/filled/covered
}
}
[$get_entree] @text {
{
[pick: <wordlist/nouns/food>] |
[pick: <wordlist/nouns/fast_food>]
}
}
##
[$mod_order] @text {
{
add | no | sub | extra |
half | left | right | side
} `{
[pick: <wordlist/nouns/condiments>] |
[pick: <wordlist/nouns/cheese>] |
[pick: <wordlist/nouns/food>] |
[pick: <wordlist/nouns/seasonings>] |
[pick: <wordlist/nouns/plants>]
}
}
##
{
a piece of `[pick: <wordlist/nouns/cheese>] cheese |
part of a `{
[if: [maybe]{[desc_food]}] [get_entree] |
[pick: <wordlist/nouns/fruit>]
}
}

View File

@@ -1,4 +0,0 @@
{
a watch |
a bracelet
}

View File

@@ -1,4 +0,0 @@
{
a watch |
a bracelet
}

View File

@@ -1,3 +1,3 @@
pyqt6>=6.9.1
pyqtwebengine>=5.15.7
pywebview[qt]>=5.4
pyqt6-webengine>=6.9.0
pywebview[qt6]>=5.4

View File

@@ -1,4 +1,6 @@
Flask>=3.1.1
flask-cors>=6.0.1
gevent>=25.5.1
lxml>=6.0.0
fs>=2.4.16
fs>=2.4.16
mermaid-py==0.8.0

View File

@@ -1,23 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<ItemRules xmlns="seagull:rules/items">
<Food>
<Min>1</Min>
<Max>10</Max>
</Food>
<Shinies>
<Min>1</Min>
<Max>20</Max>
</Shinies>
<Food StoryBeat="3">
<Min>1</Min>
<Max>20</Max>
</Food>
<Shinies StoryBeat="3">
<Min>0</Min>
<Max>50</Max>
</Shinies>
<Psi StoryBeat="3">
<Min>0</Min>
<Max>15</Max>
</Psi>
</ItemRules>

View File

@@ -1,23 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<ItemRules xmlns="seagull:rules/items">
<Food>
<Min>1</Min>
<Max>5</Max>
</Food>
<Shinies>
<Min>1</Min>
<Max>10</Max>
</Shinies>
<Food StoryBeat="3">
<Min>5</Min>
<Max>20</Max>
</Food>
<Shinies StoryBeat="3">
<Min>5</Min>
<Max>50</Max>
</Shinies>
<Psi StoryBeat="3">
<Min>0</Min>
<Max>15</Max>
</Psi>
</ItemRules>

View File

@@ -1,19 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<UpgradeRules xmlns="seagull:rules/upgrades">
<Upgrade>
<Id>speed1</Id>
<Name>Speedier Seagull</Name>
<Modifiers>
<Mod Id="up_speed1" Name="Upgrade: Speedier Seagull" Speed="5" />
</Modifiers>
<Desc>You become just a little bit faster, which makes it easier to steal things before your prey's previous owners notice you're coming.</Desc>
</Upgrade>
<Upgrade>
<Id>theft_chance1</Id>
<Name>Swooping Techniques</Name>
<Modifiers>
<Mod Id="up_theft_chance1" Name="Upgrade: Swooping Techniques" ChanceSteal="10" />
</Modifiers>
<Desc>It's all in the neck. The wings are just the steering wheel. You gain a bonus on all dice rolls for stealing.</Desc>
</Upgrade>
</UpgradeRules>

View File

@@ -1,20 +0,0 @@
<div id="charsheet-leftside">
<div class="attr" id="attr-agility">
Agility: <span id="lbl-attr-agility">0</span>
</div>
<div class="attr" id="attr-instinct">
<span id="lbl-attr-instinct-txt">Instinct</span>: <span id="lbl-attr-instinct">0</span>
</div>
</div>
<div id="charsheet-rightside">
<div id="charsheet-upgrade-tabbar">
<nav id="nav-upgrades"><ul>
<li><button id="btn-upgrade-agility">Agility Upgrades</button></li>
<li><button id="btn-upgrade-instinct">Instinct Upgrades</button></li>
</ul></nav>
</div>
<div id="charsheet-upgrade-tree">
<pre class="mermaid" id="upgrade-tree">
</pre>
</div>
</div>

View File

@@ -1,2 +0,0 @@
{% if not desktop %}IP: {{ipaddr}}<br />{% endif %}
<button id="dev-reset" onClick="reset_game()">Reset Game</button>

View File

@@ -1,65 +0,0 @@
<!DOCTYPE html>
<!-- Your server today is {{ svchost }} -->
<html>
<head>
<title>Seagull Game</title>
{%- for style in styles -%}
<link rel="stylesheet" href="{{ style }}">
{%- endfor -%}
{%- for script in scripts -%}
<script src="{{ script }}"></script>
{%- endfor -%}
</head>
<body>
<noscript>
<div style="background:yellow;border:2px red;">
<h1>This doesn't work without JavaScript.</h1><br />
<h2>You're probably using a browser extension or privacy tool that disables it.</h2>
</div>
</noscript>
<div id="root">
<div id="main-sidebar">
<div id="side-seagull-image"> <img width="256" src={{ seagull_pic }}> </div>
<div id="side-seagull-name"><span id="lbl-seagull-name">Nameless</span> <a href="javascript:change_seagull_name()">✏️</a></div>
<div id="side-seagull-name-editor"><input type="text" id="edt-seagull-name"> <a href="javascript:confirm_seagull_name()">✅</a><a href="javascript:cancel_seagull_name()">❌</a></div>
<div id="side-seagull-stats">
<p id="side-seagull-lvl">Lv <span id="lbl-seagull-lvl">1</span> <span id="lbl-seagull-class">LoadError</span></p>
<p id="side-seagull-xp">XP: <span id="lbl-seagull-xp-current">0</span>/<span id="lbl-seagull-xp-next">0</span></p>
<p id="side-seagull-misc">
Colony: <span id="lbl-seagull-colony">1337</span><br />
Shinies: <span id="lbl-seagull-shinies">420</span><br />
Food: <span id="lbl-seagull-food">69</span>
</p>
</div>
<hr />
<div id="side-action-bar">
<p>Human encounters: <select id="menu-enc-human">
<option value="pause">Stop and ask</option>
<option value="steal-food">Steal food</option>
<option value="steal-shinies">Steal shiny things</option>
</select></p>
<p>Seagull encounters: <select id="menu-enc-seagull">
<option value="pause">Stop and ask</option>
<option value="recruit">Attempt recruiting</option>
<option value="steal-food">Steal food</option>
<option value="steal-shinies">Steal shiny things</option>
</select></p>
</div>
</div>
<div id="main-content">
<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>
</div>
</body>
</html>

View File

@@ -1,12 +0,0 @@
---
title: Agility Upgrades
---
flowchart LR
speed1["Speedier Seagull"]
speed2["Greased Wings"]
theft_chance1["Swooping Techniques"]
theft_chance2["The Element of Surprise"]
speed1-->speed2
theft_chance1-->speed1
theft_chance1-->theft_chance2