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

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