diff --git a/IWantToHeal-Thor-v1.1.10.apk b/IWantToHeal-Thor-v1.1.10.apk new file mode 100644 index 0000000..160e59d Binary files /dev/null and b/IWantToHeal-Thor-v1.1.10.apk differ diff --git a/android/app/build.gradle b/android/app/build.gradle index 930e129..709ca0d 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -7,8 +7,8 @@ android { applicationId "com.warren.iwanttoheal" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 88 - versionName "1.1.9" + versionCode 89 + versionName "1.1.10" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" aaptOptions { // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. diff --git a/scripts/release_game_cli.py b/scripts/release_game_cli.py index 4bbbb79..610c4a8 100755 --- a/scripts/release_game_cli.py +++ b/scripts/release_game_cli.py @@ -1,15 +1,18 @@ #!/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 @@ -23,6 +26,24 @@ 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: @@ -30,6 +51,29 @@ def run(args: list[str], *, cwd: Path, env: dict[str, str] | None = None) -> Non 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) @@ -151,13 +195,107 @@ def update_truenas() -> None: print("Then restart app in TrueNAS UI.") -def main() -> int: +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 diff --git a/src/App.css b/src/App.css index 2d4e14c..331039e 100644 --- a/src/App.css +++ b/src/App.css @@ -55,6 +55,21 @@ textarea:focus-visible, box-shadow: 0 0 0 5px #8b6726, 0 0 18px rgba(229, 185, 95, 0.65); } +html[data-input-device='controller'] button:focus, +html[data-input-device='controller'] input:focus, +html[data-input-device='controller'] select:focus, +html[data-input-device='controller'] textarea:focus, +html[data-input-device='controller'] [tabindex]:focus { + outline: 3px solid #fff4a8 !important; + box-shadow: 0 0 0 5px #8b6726, 0 0 18px rgba(229, 185, 95, 0.65); +} + +html[data-input-device='controller'] [data-game-nav-active='true'] button:focus:not(.game-selected), +html[data-input-device='controller'] [data-game-nav-active='true'] [tabindex]:focus:not(.game-selected) { + outline: 2px solid #42414c !important; + box-shadow: none; +} + .settings-heading { align-items: end; border-bottom: 2px solid #34343d; @@ -1849,10 +1864,13 @@ h2 { .main-menu-grid { display: grid; - gap: 15px; + gap: 10px; grid-template-columns: repeat(2, minmax(0, 1fr)); + grid-template-rows: repeat(4, minmax(0, 1fr)); + height: min(100%, 388px); margin: 0 auto; - max-width: 930px; + max-width: 900px; + width: 100%; } .roguelike-mode-grid { @@ -1901,24 +1919,33 @@ h2 { color: var(--ink); cursor: pointer; display: flex; - gap: 16px; - min-height: 105px; + gap: 12px; + min-height: 0; outline: 2px solid #42414c; - padding: 16px; + padding: 12px; text-align: left; } -.menu-card:first-child { - grid-column: 1 / -1; -} - .menu-card:hover { outline-color: var(--gold); transform: translateY(-2px); } +.menu-card.game-selected { + outline: 3px solid #fff4a8; + box-shadow: 0 0 0 5px #8b6726, 0 0 18px rgba(229, 185, 95, 0.65); +} + +.menu-card.game-selected > span { + background: var(--gold); + color: #13141a; +} + .cloud-sync-card { cursor: default; + display: grid; + gap: 10px; + grid-template-columns: auto minmax(0, 1fr); justify-content: space-between; } @@ -1931,6 +1958,13 @@ h2 { display: grid; flex: 1; gap: 6px; + min-width: 0; +} + +.cloud-sync-card .text-button { + grid-column: 1 / -1; + min-height: 28px; + padding: 5px 8px; } .cloud-sync-card .text-button:disabled { @@ -1948,10 +1982,10 @@ h2 { border: 2px solid var(--gold); color: var(--gold); display: flex; - flex: 0 0 58px; + flex: 0 0 48px; font-family: var(--pixel-font); - font-size: 19px; - height: 58px; + font-size: 16px; + height: 48px; justify-content: center; } @@ -1962,13 +1996,13 @@ h2 { .menu-card strong { font-family: var(--pixel-font); - font-size: 11px; - margin-bottom: 9px; + font-size: 10px; + margin-bottom: 5px; } .menu-card small { color: var(--muted); - font-size: 18px; + font-size: 14px; line-height: 1.05; } diff --git a/src/App.tsx b/src/App.tsx index 8aac20b..32638a0 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -19,7 +19,7 @@ import { syncCloudSave, type GameMode, } from './gameRepository' -import { focusFirstControl } from './input.tsx' +import { focusFirstControl, useGameAction } from './input.tsx' import { barFillStyle } from './components/barStyles' const CombatScreen = lazy(() => import('./components/CombatScreen').then((module) => ({ default: module.CombatScreen }))) @@ -62,6 +62,7 @@ const MENU_ITEMS: Array<{ const LAST_DIFFICULTY_KEY = 'i-want-to-heal:last-difficulty' const SHOW_LEADERBOARDS = false const ACTIVITY_PAGE_SIZE = 4 +const HOME_MENU_COLUMNS = 2 function activityInitials(name: string) { return name @@ -115,6 +116,7 @@ function App() { const [error, setError] = useState('') const [syncingCloud, setSyncingCloud] = useState(false) const [syncMessage, setSyncMessage] = useState('') + const [homeSelectedIndex, setHomeSelectedIndex] = useState(0) useEffect(() => { loadAuthSession() @@ -336,6 +338,49 @@ function App() { } } + const cloudSync = getCloudSyncStatus() + const canShowCloudSync = Boolean(account && account.id !== -1 && cloudSync.available) + const homeMenuOffset = canShowCloudSync ? 1 : 0 + const homeMenuEntryCount = MENU_ITEMS.length + homeMenuOffset + const homeActiveIndex = Math.min(homeSelectedIndex, homeMenuEntryCount - 1) + + function openHomeMenuIndex(index: number) { + if (canShowCloudSync && index === 0) { + if (!syncingCloud && cloudSync.dirty) void syncSaveNow() + return + } + const item = MENU_ITEMS[index - homeMenuOffset] + if (!item) return + if (item.screen === 'pvp') { + setRoguelikeVariant('pvp') + setScreen('roguelike') + return + } + setScreen(item.screen) + } + + useGameAction((action, device) => { + if (device !== 'controller' || screen !== 'menu') return + if (action === 'confirm') { + openHomeMenuIndex(homeActiveIndex) + return + } + if (!action.startsWith('navigate')) return + setHomeSelectedIndex((current) => { + const bounded = Math.min(current, homeMenuEntryCount - 1) + const column = bounded % HOME_MENU_COLUMNS + if (action === 'navigateLeft') return column > 0 ? bounded - 1 : bounded + if (action === 'navigateRight') { + const next = bounded + 1 + return column < HOME_MENU_COLUMNS - 1 && next < homeMenuEntryCount ? next : bounded + } + if (action === 'navigateUp') return bounded >= HOME_MENU_COLUMNS ? bounded - HOME_MENU_COLUMNS : bounded + const next = bounded + HOME_MENU_COLUMNS + if (next < homeMenuEntryCount) return next + return column > 0 ? homeMenuEntryCount - 1 : bounded + }) + }) + if (error) { return (
@@ -455,9 +500,6 @@ function App() { const activity = selectedActivityOption ?? dungeon const selectedDifficulty = selectedDifficultyOption ?? activity.difficulties[0] const difficultyLocked = profile.character.level < selectedDifficulty.unlockLevel - const cloudSync = getCloudSyncStatus() - const canShowCloudSync = account.id !== -1 && cloudSync.available - return (
{screen !== 'hunter-profile' && ( @@ -484,10 +526,14 @@ function App() { )} {screen === 'menu' && ( -
+
{canShowCloudSync && ( -
+
setHomeSelectedIndex(0)} + > {cloudSync.dirty ? 'S' : 'C'}
Cloud Save @@ -500,6 +546,7 @@ function App() {
)} - {MENU_ITEMS.map((item) => ( - - ))} + {MENU_ITEMS.map((item, index) => { + const homeIndex = index + homeMenuOffset + return ( + + ) + })}
)} diff --git a/src/input.tsx b/src/input.tsx index bd29fb9..099b611 100644 --- a/src/input.tsx +++ b/src/input.tsx @@ -312,10 +312,17 @@ function focusControl(element: HTMLElement) { element.scrollIntoView({ block: 'nearest', inline: 'nearest' }) } -function currentFocusableControl(candidates = focusableElements()) { +function currentFocusableControl(candidates = focusableElements(), preferControllerFocus = false) { + if ( + preferControllerFocus + && lastControllerFocus + && candidates.includes(lastControllerFocus) + && isVisible(lastControllerFocus) + ) { + return lastControllerFocus + } const active = document.activeElement if (active instanceof HTMLElement && candidates.includes(active)) { - rememberFocusableControl(active) return active } if (lastControllerFocus && candidates.includes(lastControllerFocus) && isVisible(lastControllerFocus)) { @@ -347,10 +354,10 @@ export function focusFirstControl() { return first } -function moveFocus(action: InputAction) { +function moveFocus(action: InputAction, preferControllerFocus = false) { const candidates = focusableElements() if (candidates.length === 0) return - const current = currentFocusableControl(candidates) + const current = currentFocusableControl(candidates, preferControllerFocus) if (!current) { focusFirstControl() return @@ -415,6 +422,18 @@ function hasUiOverlay() { ).some(isVisible) } +function hasDedicatedGameNavigation() { + return Array.from( + document.querySelectorAll('[data-game-nav-active="true"]'), + ).some(isVisible) +} + +function dispatchGameAction(action: InputAction, device: InputDevice) { + window.dispatchEvent(new CustomEvent(GAME_ACTION_EVENT, { + detail: { action, device }, + })) +} + const BUTTON_LABELS: Record = { 0: 'A / Cross', 1: 'B / Circle', @@ -585,10 +604,17 @@ export function InputProvider({ children }: { children: ReactNode }) { const dispatchAction = useCallback((action: InputAction, device: InputDevice) => { const uiOverlay = hasUiOverlay() const combatActive = Boolean(document.querySelector('[data-combat-active="true"]')) + const controllerUiInput = device === 'controller' && (uiOverlay || !combatActive) + const dedicatedNavAction = action.startsWith('navigate') || action === 'confirm' || action === 'back' setLastDevice(device) document.documentElement.dataset.inputDevice = device + if (controllerUiInput && dedicatedNavAction && hasDedicatedGameNavigation()) { + dispatchGameAction(action, device) + return + } + if (action === 'toggleTouchLock') { setPreferences((current) => ({ ...current, @@ -602,22 +628,24 @@ export function InputProvider({ children }: { children: ReactNode }) { }, 1400) } else if (action.startsWith('navigate')) { if (uiOverlay || !combatActive) { - const active = currentFocusableControl() + const active = currentFocusableControl(focusableElements(), controllerUiInput) if ( active instanceof HTMLSelectElement && (action === 'navigateUp' || action === 'navigateDown') && changeSelectOption(active, action === 'navigateUp' ? -1 : 1) ) { + if (controllerUiInput) focusControl(active) return } - moveFocus(action) + moveFocus(action, controllerUiInput) } } else if (action === 'confirm') { - const active = currentFocusableControl() + const active = currentFocusableControl(focusableElements(), controllerUiInput) if (isTextInput(active)) { setKeyboardInput(active) window.requestAnimationFrame(() => focusFirstControl()) } else if (active instanceof HTMLSelectElement) { + if (controllerUiInput) focusControl(active) const select = active as HTMLSelectElement & { showPicker?: () => void } if (select.showPicker) select.showPicker() else active.click() @@ -626,6 +654,7 @@ export function InputProvider({ children }: { children: ReactNode }) { && active.matches('button:not(:disabled), [role="button"]') && isVisible(active) ) { + if (controllerUiInput) focusControl(active) active.click() } else { focusFirstControl() @@ -641,9 +670,7 @@ export function InputProvider({ children }: { children: ReactNode }) { } } - window.dispatchEvent(new CustomEvent(GAME_ACTION_EVENT, { - detail: { action, device }, - })) + dispatchGameAction(action, device) }, [closeKeyboard]) const dispatchControllerToken = useCallback((token: string, repeat = false) => { @@ -743,6 +770,7 @@ export function InputProvider({ children }: { children: ReactNode }) { const target = event.target if (!(target instanceof HTMLElement)) return if (!target.matches(FOCUSABLE_SELECTOR) || !isVisible(target)) return + if (document.documentElement.dataset.inputDevice !== 'controller') return rememberFocusableControl(target) } const onPointerDown = (event: PointerEvent) => { @@ -753,7 +781,7 @@ export function InputProvider({ children }: { children: ReactNode }) { if (!(target instanceof Element)) return const control = target.closest(FOCUSABLE_SELECTOR) if (!control || !isVisible(control)) return - rememberFocusableControl(control) + if (event.pointerType !== 'touch') rememberFocusableControl(control) } document.addEventListener('focusin', onFocusIn) document.addEventListener('pointerdown', onPointerDown, { capture: true }) @@ -1042,7 +1070,5 @@ export function dispatchExternalGameAction( action: InputAction, device: InputDevice, ) { - window.dispatchEvent(new CustomEvent(GAME_ACTION_EVENT, { - detail: { action, device }, - })) + dispatchGameAction(action, device) }