Files
i-want-to-heal/scripts/release_game_cli.py
T
2026-07-02 22:42:48 -04:00

305 lines
11 KiB
Python
Executable File

#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import os
import re
import shutil
import sqlite3
import subprocess
import sys
import urllib.error
import urllib.parse
import urllib.request
from datetime import datetime
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
GRADLE_FILE = REPO_ROOT / "android" / "app" / "build.gradle"
JAVA_HOME = "/Applications/Android Studio.app/Contents/jbr/Contents/Home"
API_BASE_URL = "https://iwanttoheal.phenomrom.com"
GITEA_URL = "https://git.whoagland.com"
GITEA_OWNER = "phenom"
GITEA_REPO = "i-want-to-heal"
GITEA_TOKEN = "ed2db3fd54546e9658377d0551b3fc3961583f1d"
BRANCH = "main"
TRUENAS_PATH = Path("/mnt/usbssds/apps/iwanttoheal/app")
PLAYER_DATA_TABLES = [
"encounter_loot_roll_items",
"encounter_loot_rolls",
"dungeon_runs",
"character_pvp_stats",
"character_boss_stats",
"character_inventory",
"character_talents",
"character_ability_slots",
"action_encounter_loot_rolls",
"action_dungeon_runs",
"action_character_inventory",
"action_gear_items",
"action_characters",
"sessions",
"characters",
"accounts",
]
def run(args: list[str], *, cwd: Path, env: dict[str, str] | None = None) -> None:
print(f"$ {' '.join(args)}", flush=True)
subprocess.run(args, cwd=cwd, env=env, check=True)
def parse_args(argv: list[str]) -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Build and publish an Android release.")
parser.add_argument(
"--reset-player-data",
action="store_true",
help="After release, back up the database and delete all accounts, characters, sessions, runs, loot, inventory, and PvP stats.",
)
parser.add_argument(
"--reset-db",
type=Path,
help="SQLite game.db path to reset. Defaults to mounted TrueNAS data/game.db when present, otherwise local data/game.db.",
)
parser.add_argument(
"--yes-reset",
action="store_true",
help="Skip the RESET confirmation when used with --reset-player-data.",
)
args = parser.parse_args(argv)
if args.yes_reset and not args.reset_player_data:
parser.error("--yes-reset requires --reset-player-data")
return args
def prompt_version() -> str:
current = GRADLE_FILE.read_text()
match = re.search(r'versionName\s+"([^"]+)"', current)
current_version = match.group(1) if match else ""
suffix = f" [{current_version}]" if current_version else ""
version = input(f"Version{suffix}: ").strip() or current_version
if not re.fullmatch(r"\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?", version):
raise SystemExit("Version must look like 1.1.2")
return version
def update_gradle_version(version: str) -> None:
text = GRADLE_FILE.read_text()
code_match = re.search(r"versionCode\s+(\d+)", text)
if not code_match:
raise SystemExit(f"Could not find versionCode in {GRADLE_FILE}")
next_code = int(code_match.group(1)) + 1
text = re.sub(r"versionCode\s+\d+", f"versionCode {next_code}", text, count=1)
text = re.sub(r'versionName\s+"[^"]+"', f'versionName "{version}"', text, count=1)
GRADLE_FILE.write_text(text)
print(f"Android version: {version}, versionCode: {next_code}")
def build_apk(version: str) -> Path:
update_gradle_version(version)
env = os.environ.copy()
env["JAVA_HOME"] = JAVA_HOME
env["PATH"] = f"{JAVA_HOME}/bin:{env.get('PATH', '')}"
env["VITE_API_BASE_URL"] = API_BASE_URL
run(["npm", "run", "android:sync"], cwd=REPO_ROOT, env=env)
run(["./gradlew", "clean", "assembleDebug"], cwd=REPO_ROOT / "android", env=env)
source = REPO_ROOT / "android" / "app" / "build" / "outputs" / "apk" / "debug" / "app-debug.apk"
apk = REPO_ROOT / f"IWantToHeal-Thor-v{version}.apk"
shutil.copy2(source, apk)
print(f"APK: {apk}")
return apk
def commit_and_push(version: str) -> None:
run(["git", "add", "."], cwd=REPO_ROOT)
run(["git", "commit", "-m", f"Android build v{version}"], cwd=REPO_ROOT)
run(["git", "push", "origin", BRANCH], cwd=REPO_ROOT)
def request_json(method: str, path: str, token: str, data: dict | None = None) -> dict:
body = json.dumps(data).encode("utf-8") if data is not None else None
headers = {"Authorization": f"token {token}", "Content-Type": "application/json"}
req = urllib.request.Request(GITEA_URL + path, data=body, headers=headers, method=method)
try:
with urllib.request.urlopen(req) as resp:
return json.loads(resp.read().decode("utf-8"))
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", "replace")
raise SystemExit(f"Gitea {method} failed ({exc.code}): {detail}") from exc
def upload_asset(path: str, token: str, apk: Path) -> dict:
boundary = "----iwanttoheal-release-boundary"
body = (
f"--{boundary}\r\n"
f'Content-Disposition: form-data; name="attachment"; filename="{apk.name}"\r\n'
"Content-Type: application/vnd.android.package-archive\r\n\r\n"
).encode("utf-8")
body += apk.read_bytes()
body += f"\r\n--{boundary}--\r\n".encode("utf-8")
headers = {
"Authorization": f"token {token}",
"Content-Type": f"multipart/form-data; boundary={boundary}",
}
req = urllib.request.Request(GITEA_URL + path, data=body, headers=headers, method="POST")
try:
with urllib.request.urlopen(req) as resp:
return json.loads(resp.read().decode("utf-8"))
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", "replace")
raise SystemExit(f"Gitea asset upload failed ({exc.code}): {detail}") from exc
def create_gitea_release(version: str, apk: Path) -> None:
token = GITEA_TOKEN.strip()
if not token or token == "PASTE_YOUR_GITEA_TOKEN_HERE":
raise SystemExit("Set GITEA_TOKEN near top of scripts/release_game_cli.py")
repo_path = f"/api/v1/repos/{GITEA_OWNER}/{GITEA_REPO}"
release = request_json(
"POST",
repo_path + "/releases",
token,
{
"tag_name": f"v{version}",
"target_commitish": BRANCH,
"name": f"v{version}",
"body": f"I Want to Heal Android build v{version}",
"draft": False,
"prerelease": False,
},
)
release_id = release.get("id")
if not release_id:
raise SystemExit(f"Gitea release missing id: {release}")
asset_name = urllib.parse.quote(apk.name)
upload_asset(f"{repo_path}/releases/{release_id}/assets?name={asset_name}", token, apk)
print(f"Gitea release uploaded: v{version}")
def update_truenas() -> None:
if TRUENAS_PATH.exists():
run(["git", "pull"], cwd=TRUENAS_PATH)
print("Restart app in TrueNAS UI.")
return
print("TrueNAS path not mounted here. Run on TrueNAS:")
print(f"cd {TRUENAS_PATH}")
print("git pull")
print("Then restart app in TrueNAS UI.")
def default_reset_database_path() -> Path:
truenas_database = TRUENAS_PATH / "data" / "game.db"
if truenas_database.exists():
return truenas_database
return REPO_ROOT / "data" / "game.db"
def table_exists(database: sqlite3.Connection, table: str) -> bool:
return database.execute(
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?",
(table,),
).fetchone() is not None
def count_rows(database: sqlite3.Connection, table: str) -> int:
if not table_exists(database, table):
return 0
return int(database.execute(f'SELECT COUNT(*) FROM "{table}"').fetchone()[0])
def backup_database(database_path: Path) -> Path:
backup_dir = database_path.parent / "backups"
backup_dir.mkdir(parents=True, exist_ok=True)
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
backup_path = backup_dir / f"{database_path.stem}-pre-player-reset-{timestamp}{database_path.suffix}"
source = sqlite3.connect(database_path)
try:
destination = sqlite3.connect(backup_path)
try:
source.backup(destination)
finally:
destination.close()
finally:
source.close()
return backup_path
def reset_player_data(database_path: Path, *, assume_yes: bool) -> None:
database_path = database_path.expanduser().resolve()
if not database_path.exists():
raise SystemExit(f"Database not found: {database_path}")
if not assume_yes:
print(f"Reset target: {database_path}")
confirmation = input("Type RESET to delete all accounts/characters/player data: ").strip()
if confirmation != "RESET":
raise SystemExit("Player data reset cancelled.")
backup_path = backup_database(database_path)
database = sqlite3.connect(database_path)
try:
before = {
"accounts": count_rows(database, "accounts"),
"characters": count_rows(database, "characters"),
"action_characters": count_rows(database, "action_characters"),
"dungeon_runs": count_rows(database, "dungeon_runs"),
"encounter_loot_rolls": count_rows(database, "encounter_loot_rolls"),
}
database.execute("PRAGMA foreign_keys = ON")
database.execute("BEGIN")
try:
for table in PLAYER_DATA_TABLES:
if table_exists(database, table):
database.execute(f'DELETE FROM "{table}"')
database.execute("COMMIT")
except Exception:
database.execute("ROLLBACK")
raise
after_accounts = count_rows(database, "accounts")
after_characters = count_rows(database, "characters")
if after_accounts or after_characters:
raise SystemExit(
f"Player data reset incomplete: {after_accounts} accounts, {after_characters} characters remain."
)
print(f"Database backup: {backup_path}")
print(
"Player data reset: "
f"{before['accounts']} accounts, "
f"{before['characters']} characters, "
f"{before['action_characters']} action characters, "
f"{before['dungeon_runs']} dungeon runs, "
f"{before['encounter_loot_rolls']} loot rolls removed."
)
finally:
database.close()
def main(argv: list[str] | None = None) -> int:
args = parse_args(sys.argv[1:] if argv is None else argv)
os.chdir(REPO_ROOT)
version = prompt_version()
apk = build_apk(version)
commit_and_push(version)
create_gitea_release(version, apk)
update_truenas()
if args.reset_player_data:
reset_player_data(args.reset_db or default_reset_database_path(), assume_yes=args.yes_reset)
print("Done.")
return 0
if __name__ == "__main__":
raise SystemExit(main())