diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 25b1d7e..a08552c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -12,11 +12,11 @@ jobs: os: [windows-latest, macos-latest] runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 # Nastavíme Python (bez toho by příkaz "python setup.py --version" nemusel fungovat) - name: Set up Python - uses: actions/setup-python@v2 + uses: actions/setup-python@v4 with: python-version: '3.x' @@ -26,12 +26,18 @@ jobs: python -m pip install --upgrade pip pip install setuptools - # Získáme verzi z ForrestHub-app/setup.py - - name: Get version from setup.py + # Get version from VERSION file + - name: Get version from VERSION file id: get_version run: | cd ForrestHub-app - echo "VERSION=$(python setup.py --version)" >> $GITHUB_ENV + if [ "$RUNNER_OS" == "Windows" ]; then + VERSION=$(type VERSION) + else + VERSION=$(cat VERSION) + fi + echo "VERSION=$VERSION" >> $GITHUB_ENV + shell: bash - name: Install dependencies run: | @@ -89,5 +95,5 @@ jobs: with: upload_url: ${{ steps.create_release.outputs.upload_url }} asset_path: ./ForrestHub-macOS/ForrestHub - asset_name: ForrestHub-macOS + asset_name: ForrestHub-${{ env.VERSION }}-macOS asset_content_type: application/octet-stream diff --git a/.github/workflows/test-build.yml b/.github/workflows/test-build.yml index daea31d..5bf9dc9 100644 --- a/.github/workflows/test-build.yml +++ b/.github/workflows/test-build.yml @@ -15,10 +15,10 @@ jobs: permissions: write-all runs-on: windows-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Set up Python - uses: actions/setup-python@v2 + uses: actions/setup-python@v4 with: python-version: '3.x' @@ -28,11 +28,11 @@ jobs: python -m pip install --upgrade pip pip install setuptools - # Načtení verze z ForrestHub-app/setup.py a zjištění data (PowerShell) + # Get version from VERSION file and date (PowerShell) - name: Get version and date run: | cd ForrestHub-app - $version = python setup.py --version + $version = Get-Content VERSION echo "VERSION=$version" >> $env:GITHUB_ENV $dateStr = Get-Date -Format "yyyyMMdd" echo "DATESTR=$dateStr" >> $env:GITHUB_ENV @@ -60,30 +60,30 @@ jobs: permissions: write-all runs-on: macos-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Set up Python - uses: actions/setup-python@v2 + uses: actions/setup-python@v4 with: python-version: '3.x' - # Install setuptools - - name: Install setuptools + # Install Hatch + - name: Install Hatch run: | python -m pip install --upgrade pip pip install setuptools - # Načtení verze z ForrestHub-app/setup.py a zjištění data (bash) + # Get version from VERSION file and date (bash) - name: Get version and date run: | cd ForrestHub-app - VERSION=$(python setup.py --version) + VERSION=$(cat VERSION) echo "VERSION=$VERSION" >> $GITHUB_ENV DATESTR=$(date +'%Y%m%d') echo "DATESTR=$DATESTR" >> $GITHUB_ENV shell: bash - - name: Install dependencies + - name: Install dependencies and build run: | python -m pip install --upgrade pip cd ForrestHub-app diff --git a/.github/workflows/web.yml b/.github/workflows/web.yml index 5f3ec88..9f4e2fd 100644 --- a/.github/workflows/web.yml +++ b/.github/workflows/web.yml @@ -4,6 +4,7 @@ on: branches: - master - main + permissions: contents: write jobs: diff --git a/ForrestHub-app/MANIFEST.in b/ForrestHub-app/MANIFEST.in new file mode 100644 index 0000000..82b6550 --- /dev/null +++ b/ForrestHub-app/MANIFEST.in @@ -0,0 +1,24 @@ +include VERSION +include requirements.txt +include *.conf.py +include *.spec +include *.cfg +include *.ini +include *.json +include *.pem +include *.key +include *.crt +recursive-include assets * +recursive-include templates * +recursive-include pages * +recursive-include games * +recursive-include docs * +global-exclude *.pyc +global-exclude __pycache__ +global-exclude .git* +global-exclude .DS_Store +global-exclude *.log +global-exclude .venv +global-exclude build +global-exclude dist +global-exclude *.egg-info diff --git a/ForrestHub-app/VERSION b/ForrestHub-app/VERSION index bc80560..dc1e644 100644 --- a/ForrestHub-app/VERSION +++ b/ForrestHub-app/VERSION @@ -1 +1 @@ -1.5.0 +1.6.0 diff --git a/ForrestHub-app/app/cron_runner.py b/ForrestHub-app/app/cron_runner.py new file mode 100644 index 0000000..de5878c --- /dev/null +++ b/ForrestHub-app/app/cron_runner.py @@ -0,0 +1,469 @@ +import os +import re +import json +import time +import logging +from pathlib import Path + +import js2py +import requests +from eventlet import sleep, Timeout + +_log = logging.getLogger(__name__) + + +# --------------------- Utils --------------------- +def _deep_to_py(obj): + """ + Convert Js2Py values to native Python recursively. + Handles: + - Js2Py primitives/wrappers via .to_python() + - JsObjectWrapper-like via _obj + - dict/list/tuple/set + Falls back to str() for unknowns to avoid crashes. + """ + if obj is None or isinstance(obj, (str, int, float, bool)): + return obj + + # Js2Py values (PyJs* etc.) + if hasattr(obj, "to_python") and callable(getattr(obj, "to_python")): + try: + return _deep_to_py(obj.to_python()) + except Exception: + pass + + # JsObjectWrapper-like + inner = getattr(obj, "_obj", None) + if inner is not None and inner is not obj: + try: + return _deep_to_py(inner) + except Exception: + pass + + if isinstance(obj, dict): + return {str(k): _deep_to_py(v) for k, v in obj.items()} + if isinstance(obj, (list, tuple, set)): + return [_deep_to_py(v) for v in obj] + + return str(obj) + + +def _to_str(obj) -> str: + v = _deep_to_py(obj) + return v if isinstance(v, str) else str(v) + + +# --------------------- Game discovery --------------------- +def _discover_game_dirs(app) -> list[Path]: + """Find game directories (prefer live), ignore dot-prefixed.""" + dirs: list[Path] = [] + for key in ("GAMES_FOLDER_LIVE", "GAMES_FOLDER"): + base = app.config.get(key) + if not base: + continue + base_path = Path(base) + if not base_path.exists(): + continue + for child in base_path.iterdir(): + if child.is_dir() and not child.name.startswith("."): + dirs.append(child) + return dirs + + +# --------------------- JS prelude --------------------- +def _build_js_prelude() -> str: + """ + Minimal standard lib for cron.js (ES5). No sockets/listeners. + Provides: + - console.log / console.error + - ENV (FH_* vars) + - httpGet/httpPost + - Raw DB helpers (getVar/setVar/addRecord/removeRecord etc.) + - ForrestHubLib class with the same data API as in the browser + Note: any data coming from Python is deep-cloned (JSON) to avoid + JsObjectWrapper references being passed back. + """ + return r""" +// -------- Console -------- +if (typeof console === 'undefined') { console = {}; } +console.log = function(){ __py_log([].slice.call(arguments).join(" ")); }; +console.error = function(){ __py_err([].slice.call(arguments).join(" ")); }; + +// -------- ENV -------- +var ENV = __py_env(); // plain object with FH_* keys + +// -------- Helpers -------- +function cloneJSON(x){ return JSON.parse(JSON.stringify(x)); } + +// -------- HTTP (sync) -------- +function httpGet(url, headers) { return __py_http_get(String(url), headers || {}); } +function httpPost(url, body, headers) { + return __py_http_post(String(url), (typeof body==='undefined' ? '' : body), headers || {}); +} + +// -------- Raw DB bridge (sync) -------- +function getVar(project, key, defaultValue) { return __py_get_var(String(project), String(key), (typeof defaultValue==='undefined'? null : defaultValue)); } +function setVar(project, key, value) { return __py_set_var(String(project), String(key), value); } +function varExists(project, key) { return __py_var_exist(String(project), String(key)); } +function varDelete(project, key) { return __py_var_delete(String(project), String(key)); } + +function addRecord(project, arrayName, value) { return __py_arr_add(String(project), String(arrayName), value); } +function removeRecord(project, arrayName, id) { return __py_arr_remove(String(project), String(arrayName), String(id)); } +function updateRecord(project, arrayName, id, value) { return __py_arr_update(String(project), String(arrayName), String(id), value); } +function getAllRecords(project, arrayName) { return cloneJSON(__py_arr_get_all(String(project), String(arrayName))); } +function clearRecords(project, arrayName) { return __py_arr_clear(String(project), String(arrayName)); } +function listProjects() { return cloneJSON(__py_arr_list_projects()); } + +function dbAll() { return cloneJSON(__py_db_all()); } +function dbClear() { __py_db_clear(); } + +// -------- Minimal ForrestHubLib (no sockets/listeners) -------- +function ForrestHubLib(isGame, url) { + if (ForrestHubLib.instance) return ForrestHubLib.instance; + + this.RUNNING = "running"; + this.PAUSED = "paused"; + this.STOPPED = "stopped"; + + this.project = ENV.FH_GAME_NAME || "global"; + this.isGamePage = !!isGame; + + ForrestHubLib.instance = this; + return this; +} + +ForrestHubLib.getInstance = function(isGame, url) { + if (!ForrestHubLib.instance) { + ForrestHubLib.instance = new ForrestHubLib(isGame, url); + } + return ForrestHubLib.instance; +}; + +// ---- Project helpers +ForrestHubLib.prototype.dbSetProject = function(project) { + this.project = String(project || ""); +}; +ForrestHubLib.prototype.dbResolveProjectName = function(projectOverride) { + return String(projectOverride || this.project || "global"); +}; + +// ---- Whole DB +ForrestHubLib.prototype.dbFetchAllData = function() { + return dbAll() || {}; +}; +ForrestHubLib.prototype.dbClearAllData = function() { + dbClear(); +}; + +// ---- VAR +ForrestHubLib.prototype.dbVarSetKey = function(key, value, projectOverride) { + if (!key || typeof key !== "string") throw new Error("dbVarSetKey: key must be non-empty string"); + var project = this.dbResolveProjectName(projectOverride); + setVar(project, key, value); +}; +ForrestHubLib.prototype.dbVarGetKey = function(key, projectOverride) { + if (!key || typeof key !== "string") throw new Error("dbVarGetKey: key must be non-empty string"); + var project = this.dbResolveProjectName(projectOverride); + return getVar(project, key, null); +}; +ForrestHubLib.prototype.dbVarKeyExists = function(key, projectOverride) { + if (!key || typeof key !== "string") throw new Error("dbVarKeyExists: key must be non-empty string"); + var project = this.dbResolveProjectName(projectOverride); + return !!varExists(project, key); +}; +ForrestHubLib.prototype.dbVarDeleteKey = function(key, projectOverride) { + if (!key || typeof key !== "string") throw new Error("dbVarDeleteKey: key must be non-empty string"); + var project = this.dbResolveProjectName(projectOverride); + varDelete(project, key); +}; + +// ---- ARRAY +ForrestHubLib.prototype.dbArrayAddRecord = function(arrayName, value, projectOverride) { + if (!arrayName || typeof arrayName !== "string") throw new Error("dbArrayAddRecord: arrayName must be non-empty string"); + var project = this.dbResolveProjectName(projectOverride); + addRecord(project, arrayName, value); +}; +ForrestHubLib.prototype.dbArrayRemoveRecord = function(arrayName, recordId, projectOverride) { + if (!arrayName || typeof arrayName !== "string") throw new Error("dbArrayRemoveRecord: arrayName must be non-empty string"); + if (!recordId || typeof recordId !== "string") throw new Error("dbArrayRemoveRecord: recordId must be non-empty string"); + var project = this.dbResolveProjectName(projectOverride); + removeRecord(project, arrayName, recordId); +}; +ForrestHubLib.prototype.dbArrayUpdateRecord = function(arrayName, recordId, value, projectOverride) { + if (!arrayName || typeof arrayName !== "string") throw new Error("dbArrayUpdateRecord: arrayName must be non-empty string"); + if (!recordId || typeof recordId !== "string") throw new Error("dbArrayUpdateRecord: recordId must be non-empty string"); + var project = this.dbResolveProjectName(projectOverride); + updateRecord(project, arrayName, recordId, value); +}; +ForrestHubLib.prototype.dbArrayFetchAllRecords = function(arrayName, projectOverride) { + if (!arrayName || typeof arrayName !== "string") throw new Error("dbArrayFetchAllRecords: arrayName must be non-empty string"); + var project = this.dbResolveProjectName(projectOverride); + return getAllRecords(project, arrayName) || {}; +}; +ForrestHubLib.prototype.dbArrayClearRecords = function(arrayName, projectOverride) { + if (!arrayName || typeof arrayName !== "string") throw new Error("dbArrayClearRecords: arrayName must be non-empty string"); + var project = this.dbResolveProjectName(projectOverride); + clearRecords(project, arrayName); +}; +ForrestHubLib.prototype.dbArrayFetchProjects = function() { + return listProjects() || []; +}; + +// ---- small helpers +ForrestHubLib.prototype.logDebug = function(msg){ console.log("[ForrestHubLib] " + msg); }; + +// ---- Create singleton for convenience +var forrestHubLib = ForrestHubLib.getInstance(true); +""" + + +# --------------------- JS context --------------------- +def _make_js_context(app, game_name: str, timeout_sec: int): + """ + Create a Js2Py sandbox and bridge Python helpers. + """ + from app.init import db # lazy import (avoid init cycles) + + base_url = f"http://{app.config['HOST']}:{app.config['PORT']}" + + def _env(): + return { + "FH_HOST": str(app.config["HOST"]), + "FH_PORT": str(app.config["PORT"]), + "FH_BASE_URL": base_url, + "FH_EXECUTABLE_DIR": str(app.config["EXECUTABLE_DIR"]), + "FH_DATA_DIR": str(app.config["DATA_DIR"]), + "FH_GAMES_DIR": str(app.config["GAMES_FOLDER"]), + "FH_GAMES_DIR_LIVE": str(app.config["GAMES_FOLDER_LIVE"]), + "FH_GAME_NAME": game_name, + "FH_CRON_TIMEOUT_SEC": str(timeout_sec), + } + + def _log_py(msg): + _log.info("[cron.js][%s] %s", game_name, str(msg)) + + def _err_py(msg): + _log.error("[cron.js][%s] %s", game_name, str(msg)) + + def _http_get(url: str, headers: dict): + try: + hdrs = _deep_to_py(headers or {}) + r = requests.get(_to_str(url), headers=dict(hdrs or {}), timeout=max(1, timeout_sec - 1)) + return {"status": r.status_code, "text": r.text} + except Exception as e: + return {"status": 0, "text": f"ERROR: {e}"} + + def _http_post(url: str, body, headers: dict): + try: + data = _deep_to_py(body) + hdrs = _deep_to_py(headers or {}) + if isinstance(data, (dict, list)): + data = json.dumps(data) + hdrs = dict(hdrs or {}) + hdrs.setdefault("content-type", "application/json") + r = requests.post(_to_str(url), data=data, headers=hdrs, timeout=max(1, timeout_sec - 1)) + return {"status": r.status_code, "text": r.text} + except Exception as e: + return {"status": 0, "text": f"ERROR: {e}"} + + # ----- DB bridges (direct; no HTTP) ----- + def _get_var(project, key, default_value=None): + return db.var_key_get(_to_str(project), _to_str(key), _deep_to_py(default_value)) + + def _set_var(project, key, value): + db.var_key_set(_to_str(project), _to_str(key), _deep_to_py(value)) + return True + + def _var_exist(project, key): + return bool(db.var_key_exists(_to_str(project), _to_str(key))) + + def _var_delete(project, key): + return bool(db.var_key_delete(_to_str(project), _to_str(key))) + + def _arr_add(project, array_name, value): + db.array_add_record(_to_str(project), _to_str(array_name), _deep_to_py(value)) + return True + + def _arr_remove(project, array_name, record_id): + return bool(db.array_remove_record(_to_str(project), _to_str(array_name), _to_str(record_id))) + + def _arr_update(project, array_name, record_id, value): + return bool(db.array_update_record(_to_str(project), _to_str(array_name), _to_str(record_id), _deep_to_py(value))) + + def _arr_get_all(project, array_name): + # Return raw Python dict; JS prelude clones it to avoid wrappers. + return db.array_get_all_records(_to_str(project), _to_str(array_name)) or {} + + def _arr_clear(project, array_name): + db.array_clear_records(_to_str(project), _to_str(array_name)) + return True + + def _arr_list_projects(): + return db.array_list_projects() + + def _db_all(): + return db.get_all_data() or {} + + def _db_clear(): + db.clear_data() + return True + + ctx = js2py.EvalJs({}) + + # inject bridges: + ctx.__py_env = _env + ctx.__py_log = _log_py + ctx.__py_err = _err_py + ctx.__py_http_get = _http_get + ctx.__py_http_post = _http_post + + ctx.__py_get_var = _get_var + ctx.__py_set_var = _set_var + ctx.__py_var_exist = _var_exist + ctx.__py_var_delete = _var_delete + + ctx.__py_arr_add = _arr_add + ctx.__py_arr_remove = _arr_remove + ctx.__py_arr_update = _arr_update + ctx.__py_arr_get_all = _arr_get_all + ctx.__py_arr_clear = _arr_clear + ctx.__py_arr_list_projects = _arr_list_projects + + ctx.__py_db_all = _db_all + ctx.__py_db_clear = _db_clear + + return ctx + + +# --------------------- Runner --------------------- +def _run_js_file(app, script_path: Path, timeout_sec: int): + """Execute a cron.js file with a hard timeout.""" + game_name = script_path.parent.name + code = script_path.read_text(encoding="utf-8") + + ctx = _make_js_context(app, game_name, timeout_sec) + prelude = _build_js_prelude() + + try: + with Timeout(timeout_sec, False): + ctx.execute(prelude + "\n" + code) + except Exception as e: + _log.exception("Error executing cron.js in '%s': %s", game_name, e) + + +# --------------------- Scheduling --------------------- +# Accept "cron.js", "cron..js", and also tolerate typos "cont..js" or "corn..js" +_CRON_FILE_RE = re.compile(r"^(?:cron|cont|corn)(?:\.(\d+))?\.js$", re.IGNORECASE) + + +def _discover_cron_scripts(game_dir: Path, default_interval: int) -> list[tuple[Path, int]]: + """ + Return list of (script_path, interval_sec) for a game_dir. + - cron.js -> default_interval + - cron..js (or cont./corn) -> N seconds + """ + out: list[tuple[Path, int]] = [] + if not game_dir.exists(): + return out + for child in game_dir.iterdir(): + if not child.is_file() or child.suffix.lower() != ".js": + continue + m = _CRON_FILE_RE.match(child.name) + if not m: + continue + secs = int(m.group(1)) if m.group(1) else default_interval + if secs <= 0: + continue + out.append((child, secs)) + return out + + +def start_cron_scheduler(app) -> None: + """ + Background loop with per-file scheduling, gated by global game_status: + - Rescans game dirs every FH_CRON_RESCAN_SEC (default 5s) + - Each cron file runs at its own interval (cron..js) + - cron.js uses FH_CRON_DEFAULT_SEC (default 10s) + - Executes jobs only if db VAR 'game_status' == 'running' (case-insensitive) + otherwise pauses and pushes next_due forward (no catch-up). + """ + default_interval = int(os.getenv("FH_CRON_DEFAULT_SEC", "10")) + rescan_every = float(os.getenv("FH_CRON_RESCAN_SEC", "5")) + timeout = int(os.getenv("FH_CRON_TIMEOUT_SEC", "8")) + + # State: path -> {interval, next_due} + schedule: dict[Path, dict] = {} + + _log.info( + "Starting JS cron scheduler (Js2Py): default=%ss, timeout/run=%ss, rescan=%ss", + default_interval, timeout, rescan_every + ) + + def _rescan(): + """Rebuild/refresh schedule for current files while keeping next_due for unchanged ones.""" + nonlocal schedule + current: dict[Path, dict] = {} + for game_dir in _discover_game_dirs(app): + for script_path, secs in _discover_cron_scripts(game_dir, default_interval): + prev = schedule.get(script_path) + if prev and prev["interval"] == secs: + # keep its next_due + current[script_path] = prev + else: + # new or interval changed + nd = time.monotonic() + secs # first run after one period + current[script_path] = {"interval": secs, "next_due": nd} + _log.info("Scheduled %s (%ss) in %s", script_path.name, secs, game_dir.name) + # any removed files are dropped implicitly + schedule = current + + last_rescan = 0.0 + + # lazy import to avoid cycles + from app.init import db + + with app.app_context(): + while True: + now = time.monotonic() + + # periodic rescan + if now - last_rescan >= rescan_every: + _rescan() + last_rescan = now + + # Check game status from DB (global VAR_game_status) + status = db.var_key_get("global", "game_status", "running") + is_running = str(status).lower() == "running" or status is True + + if not is_running: + # Pause: push any overdue jobs forward so they don't burst on resume + for meta in schedule.values(): + if meta["next_due"] <= now: + meta["next_due"] = now + meta["interval"] + _log.debug("Cron paused (status=%r).", status) + + # run due jobs only if running + if is_running: + for script_path, meta in list(schedule.items()): + if meta["next_due"] <= now: + secs = meta["interval"] + _log.info("Running %s (interval=%ss)", script_path, secs) + try: + _run_js_file(app, script_path, timeout) + except Exception as loop_err: + _log.exception("Execution error for %s: %s", script_path, loop_err) + finally: + # schedule next run strictly by interval + meta["next_due"] = now + secs + + # compute sleep until next job or rescan + if schedule: + next_times = [meta["next_due"] for meta in schedule.values()] + next_due = min(next_times) if next_times else now + rescan_every + # wake up at the earlier of next_due or next rescan + wake_at = min(next_due, last_rescan + rescan_every) + delay = max(0.1, wake_at - time.monotonic()) + else: + delay = rescan_every + + sleep(delay) diff --git a/ForrestHub-app/app/database.py b/ForrestHub-app/app/database.py index 94b0f17..2563168 100644 --- a/ForrestHub-app/app/database.py +++ b/ForrestHub-app/app/database.py @@ -12,7 +12,7 @@ class DatabaseKeys(Enum): ARR = "ARR_" def __add__(self, other): - return self.value + other + return self.value + str(other) def save_data(func): def wrapper(self, *args, **kwargs): diff --git a/ForrestHub-app/app/init.py b/ForrestHub-app/app/init.py index 1b70c39..d4a483f 100644 --- a/ForrestHub-app/app/init.py +++ b/ForrestHub-app/app/init.py @@ -2,6 +2,7 @@ from app.custom_loader import CustomLoader from app.database import Database +from app.cron_runner import start_cron_scheduler from flask import Flask from flask_cors import CORS from flask_socketio import SocketIO @@ -59,5 +60,6 @@ def create_app(config_class: object | str): # Spustit asynchronní úkol na pozadí socketio.start_background_task(db.save_data_periodically) + socketio.start_background_task(start_cron_scheduler, app) return app diff --git a/ForrestHub-app/app/socketio_events.py b/ForrestHub-app/app/socketio_events.py index 8ab980f..31e2bdd 100644 --- a/ForrestHub-app/app/socketio_events.py +++ b/ForrestHub-app/app/socketio_events.py @@ -12,7 +12,13 @@ connected_clients = 0 connected_admins = 0 -game_status = "running" +game_status = "running" # default; DB will override if set +try: + _db_status = db.var_key_get("global", "game_status", game_status) + if _db_status: + game_status = _db_status +except Exception: + pass ################## Admin Config ############################ @socketio.on("send_admin_message") @@ -35,16 +41,22 @@ def handle_disconnect(): @socketio.on("game_status_get") -def handle_game_status(demo): - global game_status - emit("game_status", game_status) +def handle_game_status(_payload): + # Serve the current (DB-backed) status + current = db.var_key_get("global", "game_status", game_status) + emit("game_status", current) @socketio.on("game_status_set") def handle_game_status_set(status: str): + # Normalize + persist + s = (status or "").strip().lower() + if s not in ("running", "paused", "stopped"): + s = "paused" # fallback on invalid inputs global game_status - game_status = status - emit("game_status", game_status, broadcast=True) + game_status = s + db.var_key_set("global", "game_status", s) + emit("game_status", s, broadcast=True) ################## Edit mode ############################ diff --git a/ForrestHub-app/assets/types/forresthublib.d.ts b/ForrestHub-app/assets/types/forresthublib.d.ts new file mode 100644 index 0000000..b80d56b --- /dev/null +++ b/ForrestHub-app/assets/types/forresthublib.d.ts @@ -0,0 +1,23 @@ +declare class ForrestHubLib { + + static getInstance(): ForrestHubLib; + dbSetProject(project: string): void; + dbResolveProjectName(projectOverride?: string | null): string; + + dbFetchAllData(): any; + dbClearAllData(): void; + + dbVarSetKey(key: string, value: any, projectOverride?: string | null): void; + dbVarGetKey(key: string, projectOverride?: string | null): any; + dbVarKeyExists(key: string, projectOverride?: string | null): boolean; + dbVarDeleteKey(key: string, projectOverride?: string | null): void; + + dbArrayAddRecord(arrayName: string, value: any, projectOverride?: string | null): void; + dbArrayRemoveRecord(arrayName: string, recordId: string, projectOverride?: string | null): void; + dbArrayUpdateRecord(arrayName: string, recordId: string, value: any, projectOverride?: string | null): void; + dbArrayFetchAllRecords(arrayName: string, projectOverride?: string | null): any; + dbArrayClearRecords(arrayName: string, projectOverride?: string | null): void; + dbArrayFetchProjects(): any[]; +} +declare const forrestHubLib: ForrestHubLib; +declare const ENV: Record; diff --git a/ForrestHub-app/pyinstaller.spec b/ForrestHub-app/pyinstaller.spec index ea5bc7a..c8ca7e7 100644 --- a/ForrestHub-app/pyinstaller.spec +++ b/ForrestHub-app/pyinstaller.spec @@ -21,6 +21,8 @@ a = Analysis( 'app.errors', 'app.routes', 'app.socketio_events', + 'app.cron_runner', + 'js2py', 'app.utils', 'engineio.async_drivers.threading', 'socketio', diff --git a/ForrestHub-app/pyproject.toml b/ForrestHub-app/pyproject.toml new file mode 100644 index 0000000..2ade54f --- /dev/null +++ b/ForrestHub-app/pyproject.toml @@ -0,0 +1,182 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "ForrestHub App" +dynamic = ["version"] +license = "" +dependencies = [ + "flask>=3.1.0", + "flask-socketio>=5.5.0", + "gevent-websocket>=0.10.1", + "requests>=2.32.0", + "websocket-client>=1.8.0", + "flask-cors>=6.0.0", + "pyinstaller>=6.15.0", + "pyopenssl>=25.1.0", + "nanoid>=2.0.0", + "eventlet>=0.40.0", + "gunicorn>=23.0.0", + "python-dotenv>=1.1.0", + "click>=8.2.0", + "dotenv>=0.9.9", + "Js2Py @ git+https://github.com/a-j-albert/Js2Py---supports-python-3.13.git", +] + +[project.entry-points.forrestHub] +forrestHub = "run:app.run" + +[tool.hatch.version] +path = "app/__init__.py" + +[tool.hatch.build.targets.sdist] +include = [ + "/app", +] + +[project.urls] +Homepage = "https://forresthub.helceletka.cz" +Repository = "https://github.com/Helceletka/ForrestHub" +Documentation = "https://forresthub.helceletka.cz/docs" +"Bug Reports" = "https://github.com/Helceletka/ForrestHub/issues" +Changelog = "https://github.com/Helceletka/ForrestHub/blob/main/CHANGELOG.md" + +[project.scripts] +forresthub = "run:main" + +[tool.hatch.metadata] +allow-direct-references = true + +[tool.hatch.build.targets.wheel] +packages = ["app", "assets", "games", "pages", "templates"] + +[tool.hatch.build.targets.wheel.force-include] +"VERSION" = "VERSION" +"requirements.txt" = "requirements.txt" +"gunicorn.conf.py" = "gunicorn.conf.py" +"gunicorn-https.conf.py" = "gunicorn-https.conf.py" + +[tool.hatch.envs.default] +dependencies = [ + "pytest>=7.0.0", + "pytest-cov>=4.0.0", + "black>=23.0.0", + "isort>=5.12.0", + "flake8>=6.0.0", + "mypy>=1.0.0", + "pre-commit>=3.0.0", +] + +[tool.hatch.envs.default.scripts] +test = "pytest {args:tests}" +test-cov = "pytest --cov-report=term-missing --cov-config=pyproject.toml --cov=app {args:tests}" +lint = [ + "black --check --diff .", + "isort --check-only --diff .", + "flake8 .", +] +format = [ + "black .", + "isort .", +] +type-check = "mypy app" + +[tool.hatch.envs.docs] +dependencies = [ + "mkdocs>=1.4.0", + "mkdocs-material>=9.0.0", +] + +[tool.hatch.envs.docs.scripts] +build = "mkdocs build" +serve = "mkdocs serve" + +[tool.black] +line-length = 88 +target-version = ['py38', 'py39', 'py310', 'py311', 'py312', 'py313'] +include = '\.pyi?$' +extend-exclude = ''' +/( + # directories + \.eggs + | \.git + | \.hg + | \.mypy_cache + | \.tox + | \.venv + | build + | dist +)/ +''' + +[tool.isort] +profile = "black" +multi_line_output = 3 +line_length = 88 +known_first_party = ["app"] +skip = ["__init__.py"] + +[tool.flake8] +max-line-length = 88 +extend-ignore = ["E203", "W503"] +exclude = [ + ".git", + "__pycache__", + "build", + "dist", + "*.egg-info", + ".venv", + ".tox", +] + +[tool.mypy] +python_version = "3.8" +warn_return_any = true +warn_unused_configs = true +disallow_untyped_defs = true +disallow_incomplete_defs = true +check_untyped_defs = true +disallow_untyped_decorators = true +no_implicit_optional = true +warn_redundant_casts = true +warn_unused_ignores = true +warn_no_return = true +warn_unreachable = true +strict_equality = true +show_error_codes = true + +[[tool.mypy.overrides]] +module = [ + "gevent.*", + "eventlet.*", + "flask_socketio.*", + "pyinstaller.*", +] +ignore_missing_imports = true + +[tool.pytest.ini_options] +minversion = "7.0" +addopts = "-ra -q" +testpaths = [ + "tests", +] +python_files = "test_*.py" +python_classes = "Test*" +python_functions = "test_*" + +[tool.coverage.run] +source = ["app"] +omit = [ + "*/tests/*", + "*/venv/*", + "*/__pycache__/*", +] + +[tool.coverage.report] +exclude_lines = [ + "pragma: no cover", + "def __repr__", + "raise AssertionError", + "raise NotImplementedError", +] diff --git a/ForrestHub-app/requirements.txt b/ForrestHub-app/requirements.txt index c758f8c..f0b4fa4 100644 --- a/ForrestHub-app/requirements.txt +++ b/ForrestHub-app/requirements.txt @@ -1,14 +1,15 @@ -flask~=3.1.0 -flask-socketio~=5.5.1 -gevent-websocket~=0.10.1 -requests~=2.32.3 -websocket-client~=1.8.0 -flask-cors~=5.0.1 -pyinstaller~=6.12.0 -pyopenssl~=25.0.0 -pyinstaller~=6.12.0 -nanoid~=2.0.0 -eventlet~=0.39.1 -python-dotenv~=1.0.1 -click~=8.1.8 -dotenv~=0.9.9 +flask>=3.1.0 +flask-socketio>=5.5.0 +gevent-websocket>=0.10.1 +requests>=2.32.0 +websocket-client>=1.8.0 +flask-cors>=6.0.0 +pyinstaller>=6.15.0 +pyopenssl>=25.1.0 +nanoid>=2.0.0 +eventlet>=0.40.0 +gunicorn>=23.0.0 +python-dotenv>=1.1.0 +click>=8.2.0 +dotenv>=0.9.9 +Js2Py @ git+https://github.com/a-j-albert/Js2Py---supports-python-3.13.git diff --git a/ForrestHub-app/setup.py b/ForrestHub-app/setup.py deleted file mode 100644 index 0a4e3a0..0000000 --- a/ForrestHub-app/setup.py +++ /dev/null @@ -1,22 +0,0 @@ -from setuptools import setup, find_packages -from pathlib import Path - -__version__ = (Path(__file__).parent / "VERSION").read_text().strip() - -# get install_requires from requirements.txt -def get_requirements(): - with open('requirements.txt') as f: - return f.read().splitlines() - -setup( - name='ForrestHub App', - version=__version__, - packages=find_packages(), - include_package_data=True, - install_requires=get_requirements(), - entry_points={ - 'forrestHub': [ - 'forrestHub = run:app.run' - ] - } -) diff --git a/docs/game/cron.md b/docs/game/cron.md new file mode 100644 index 0000000..bfce738 --- /dev/null +++ b/docs/game/cron.md @@ -0,0 +1,26 @@ +# Cron Job - Periodické úlohy ve ForrestHubu (experimentální) + +Ve ForrestHubu můžete vytvářet cron joby, což jsou skripty, které se spouštějí automaticky v pravidelných intervalech. Tyto úlohy jsou užitečné pro úkoly, které je třeba provádět opakovaně, jako kontrola skóre, globální zvyšování herní obtížnosti, či odesílání pravidelných oznámení hráčům. + +## Jak fungují cron joby +Cron joby jsou umístěny v souboru `cron..js` ve složce vaší hry (např. `games//cron.30.js`). Tento skript se spouští každých `N` sekund a může obsahovat jakýkoli JavaScriptový kód (bez async + await), který potřebujete pro správu vaší hry. Měla by být dostupné ořezané API prostředí ForrestHubu, což znamená, že můžete přistupovat k databázím, uživatelským datům a dalším funkcím platformy. +Ve složce vaší hry můžete mít více cron jobů s různými intervaly, například `cron.10.js` pro úlohy spouštěné každých 10 sekund a `cron.60.js` pro úlohy spouštěné každou minutu. Ale také například `cron.300.js` pro úlohy spouštěné každých 5 minut. Kratší intervaly než 1 sekundu nejsou podporovány a pravděpodobně by ani nebylo možné je spolehlivě dodržet. Vykonávání cron jobů může být ovlivněno zatížením serveru a dalšími faktory, takže není zaručeno, že se úloha spustí přesně v daném intervalu, ale vždy se spustí a vykoná celý skript. Cron job je aktivní pouze tehdy, pokud běží hra. Během pozastavení hry se cron joby nespouštějí. + +## Příklad cron jobu +Zde je jednoduchý příklad cron jobu, který se spouští každých 12 sekund a zvyšuje hodnotu proměnné + + +`cron.12.js`: +```js +const forrestHubLib = ForrestHubLib.getInstance(); +let proj = ENV.FH_GAME_NAME || "global"; + +// increment a counter +let ticks = forrestHubLib.dbVarGetKey("ticks", proj) || 0; +forrestHubLib.dbVarSetKey("ticks", ticks + 1, proj); + +// add a chat message +forrestHubLib.dbArrayAddRecord("chatMessages", { text: `tick #${ticks + 1}`, time: new Date().toISOString() }, proj); +``` + +Pokud narazíte na problém s cron joby, prosíme o nahlášení chyby na [GitHub Issues](https://github.com/Helceletka/ForrestHub/issues). \ No newline at end of file diff --git a/docs/game/index.md b/docs/game/index.md index 3271744..f2f7e4e 100644 --- a/docs/game/index.md +++ b/docs/game/index.md @@ -4,4 +4,8 @@ title: Hry Pro generování her lze využít náš předtrénovaný model ChatGPT. -Více informací o tom, jak model funguje, najdete na stránce [Generování her](/generate/). \ No newline at end of file +Více informací o tom, jak model funguje, najdete na stránce [Generování her](/generate/). + +## Cron Job - Periodické úlohy ve ForrestHubu + +Nová funkce, která umožňuje vytvářet skripty spouštěné v pravidelných intervalech. Více informací najdete na stránce [Cron Job - Periodické úlohy ve ForrestHubu (experimentální)](cron.md). \ No newline at end of file