2 Commits

Author SHA1 Message Date
Warren H 814eb1998d Android build v1.0.25 2026-06-18 23:28:43 -04:00
Warren H 7fe62d8c82 Android build v1.0.24 2026-06-18 23:21:00 -04:00
8 changed files with 231 additions and 44 deletions
+187 -19
View File
@@ -43,40 +43,208 @@ Keep the game server bound to `127.0.0.1`. Set `TRUST_PROXY=1` only when the
server can be reached solely through your local reverse proxy. This lets account
limits use the visitor's public IP instead of the proxy's address.
## Separate auth server
## TrueNAS single-container hosting
The auth routes can run as their own Node process. This is useful when you want
`auth.phenomrom.com` to stay available while the game server is being rebuilt or
changed.
### TrueNAS SCALE runbook
On the TrueNAS host, run the auth process against the same project data folder:
This is the simplest TrueNAS setup. One container serves the browser game,
auth routes, game API routes, and one SQLite database. Use this when you want
`iwanttoheal.phenomrom.com` to host the playable browser version and you want
code updates to be a Git pull plus app restart.
```sh
npm ci
npm run db:init
AUTH_HOST=127.0.0.1 AUTH_PORT=4174 TRUST_PROXY=1 COOKIE_SECURE=1 AUTH_CORS_ORIGINS=https://phenomrom.com npm run auth:start
Portainer is not required. Use TrueNAS **Apps > Discover > Install via YAML**.
Repository:
```text
https://git.whoagland.com/phenom/i-want-to-heal.git
```
Point `auth.phenomrom.com` at that process through HTTPS:
TrueNAS paths:
```text
/mnt/usbssds/apps/iwanttoheal/app
/mnt/usbssds/apps/iwanttoheal/data
```
Create the app directory and clone the repo:
```sh
sudo mkdir -p /mnt/usbssds/apps/iwanttoheal
cd /mnt/usbssds/apps/iwanttoheal
sudo git clone https://git.whoagland.com/phenom/i-want-to-heal.git app
```
Because the clone was run with `sudo`, give the normal TrueNAS user ownership:
```sh
sudo chown -R truenas_admin:truenas_admin /mnt/usbssds/apps/iwanttoheal
```
Create the persistent data folder:
```sh
mkdir -p /mnt/usbssds/apps/iwanttoheal/data
```
Check that the production server file exists:
```sh
ls /mnt/usbssds/apps/iwanttoheal/app/server/production.mjs
```
If that file is missing, push the latest code to `git.whoagland.com` from the
development machine, then pull on TrueNAS:
```sh
cd /mnt/usbssds/apps/iwanttoheal/app
git pull
```
If Git fails with `chmod ... Operation not permitted`, do not use a media or SMB
dataset for the repo. Git needs normal file locking and chmod behavior. Create or
use a dedicated apps dataset and clone under `/mnt/usbssds/apps/...`.
### TrueNAS app YAML
In TrueNAS:
1. Open **Apps**.
2. Open **Discover**.
3. Click the three-dot menu.
4. Choose **Install via YAML**.
5. Name the app `iwanttoheal`.
6. Paste this YAML:
```yaml
services:
iwanttoheal:
image: node:24-bookworm-slim
working_dir: /app
command: sh -lc "npm ci && npm run db:init && npm run build && npm start"
environment:
HOST: 0.0.0.0
PORT: "4173"
TRUST_PROXY: "1"
COOKIE_SECURE: "1"
CORS_ORIGINS: "http://localhost,https://localhost,capacitor://localhost,https://iwanttoheal.phenomrom.com,https://auth.phenomrom.com"
ports:
- "4173:4173"
volumes:
- /mnt/usbssds/apps/iwanttoheal/app:/app
- /mnt/usbssds/apps/iwanttoheal/data:/app/data
restart: unless-stopped
```
The app listens inside Docker on port `4173`. The database lives at
`/mnt/usbssds/apps/iwanttoheal/data/game.db` because that host directory is
mounted into the container as `/app/data`. The startup command installs
dependencies, applies schema migrations, builds the web app, and starts the
production server.
Test the local TrueNAS service:
```sh
curl http://TRUENAS-IP:4173/api/auth/session
```
Expected response:
```json
{"account":null,"profile":null}
```
### Reverse proxy
Point `iwanttoheal.phenomrom.com` at the TrueNAS app through HTTPS. Do not expose
port `4173` directly to the internet. Put Caddy or another reverse proxy in
front:
```caddyfile
iwanttoheal.phenomrom.com {
reverse_proxy TRUENAS-IP:4173
}
auth.phenomrom.com {
reverse_proxy 127.0.0.1:4174
reverse_proxy TRUENAS-IP:4173
}
```
Build the web or mobile app with the auth base URL set separately from the game
API:
Both hostnames can point at the same container. `iwanttoheal.phenomrom.com`
serves the browser game. `auth.phenomrom.com` stays available as an auth URL for
Android or other clients that need a dedicated auth hostname.
DNS should point both hostnames at the public IP or dynamic DNS name that reaches
the reverse proxy. Forward public ports `80` and `443` to the reverse proxy host.
Test the public game and auth URLs:
```sh
VITE_AUTH_API_BASE_URL=https://auth.phenomrom.com npm run build
curl https://iwanttoheal.phenomrom.com
curl https://auth.phenomrom.com/api/auth/session
```
For a Capacitor wrapper, set `window.CAPACITOR_AUTH_API_BASE_URL` to
`https://auth.phenomrom.com` the same way `window.CAPACITOR_API_BASE_URL` is set.
The app stores the returned bearer token locally and sends it with later API
requests, so auth works across subdomains and inside the mobile WebView. Existing
same-origin cookie sessions still work when auth is served by the game server.
Expected auth response:
```json
{"account":null,"profile":null}
```
### App build config
For the hosted browser game, no separate auth build setting is needed. The web
app can call same-origin routes like `/api/auth/login` and `/api/profile`.
For an Android build that should use the TrueNAS-hosted game API, build with:
```sh
npm run android:apk:truenas
```
If you intentionally want Android auth calls to use `auth.phenomrom.com`, also
set `VITE_AUTH_API_BASE_URL=https://auth.phenomrom.com`. Otherwise, leave it
unset and auth uses the same base URL as the game API.
Android runs the bundled web app from a local Capacitor origin, not from
`iwanttoheal.phenomrom.com`. The hosted server must allow that origin through
CORS, which is why the TrueNAS YAML includes `http://localhost`,
`https://localhost`, and `capacitor://localhost`.
### Updating the TrueNAS game app
Push changes from the development machine to `git.whoagland.com`, then pull them
on TrueNAS:
```sh
cd /mnt/usbssds/apps/iwanttoheal/app
git pull
```
Restart the `iwanttoheal` app in the TrueNAS Apps UI after pulling. The app
command runs `npm ci`, `npm run db:init`, `npm run build`, and `npm start` on
startup, so dependency, schema, and browser bundle changes are applied each time
the container restarts.
Normal update workflow:
```sh
# development machine
git add .
git commit -m "Update game"
git push origin main
# TrueNAS shell
cd /mnt/usbssds/apps/iwanttoheal/app
git pull
```
Then restart the TrueNAS app.
### Existing auth-only app
If `iwanttoheal-auth` was already created during earlier testing, the simplest
path is to stop that app and use the single `iwanttoheal` app above. The single
container serves both domains and avoids two processes sharing one SQLite file.
## Account limits
Binary file not shown.
Binary file not shown.
Binary file not shown.
+2 -2
View File
@@ -7,8 +7,8 @@ android {
applicationId "com.warren.iwanttoheal"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 39
versionName "1.0.23"
versionCode 42
versionName "1.0.25"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
+2
View File
@@ -8,8 +8,10 @@
"dev": "vite",
"build": "npm run offline:export && npx tsc -b --noEmit && npx vite build && node scripts/generate-service-worker.mjs",
"android:sync": "npm run build && cap sync android",
"android:sync:truenas": "VITE_API_BASE_URL=https://iwanttoheal.phenomrom.com npm run android:sync",
"android:open": "cap open android",
"android:apk": "npm run android:sync && cd android && ./gradlew clean assembleDebug",
"android:apk:truenas": "VITE_API_BASE_URL=https://iwanttoheal.phenomrom.com npm run android:apk",
"accounts:ip": "node scripts/manage-ip-allowance.mjs",
"db:backup": "node scripts/backup-db.mjs",
"db:init": "node scripts/init-db.mjs",
+10
View File
@@ -131,6 +131,13 @@ function App() {
})
}, [screen])
useEffect(() => {
if (!authChecked || !account || !profile || screen === 'combat') return
window.requestAnimationFrame(() => {
focusFirstControl()
})
}, [account, authChecked, profile, screen])
useEffect(() => {
window.localStorage.setItem(LAST_DIFFICULTY_KEY, String(selectedDifficultyId))
}, [selectedDifficultyId])
@@ -148,6 +155,9 @@ function App() {
setScreen('menu')
setError('')
setServerMessage('')
window.requestAnimationFrame(() => {
focusFirstControl()
})
}
async function signOut() {
+30 -23
View File
@@ -374,6 +374,7 @@ export function CombatScreen({
const partyRef = useRef(partyTemplate)
const enemyHealthRef = useRef(encounters[initialEncounterIndex].maxHealth)
const elapsedTicksRef = useRef(0)
const selectedIdRef = useRef(partyTemplate[0].id)
const encounter = encounters[encounterIndex]
const currentPart = getCurrentPart(encounterIndex)
const firstEncounterIndex = (startPart - 1) * 3
@@ -415,6 +416,11 @@ export function CombatScreen({
})
}, [paused])
const setSelectedTargetId = useCallback((id: string) => {
selectedIdRef.current = id
setSelectedId(id)
}, [])
const addLog = useCallback((text: string, tone: CombatLogEntry['tone']) => {
const entry = { id: nextLogId.current++, text, tone }
setLog((current) => [entry, ...current].slice(0, 60))
@@ -467,7 +473,7 @@ export function CombatScreen({
if (roguelikeMode) setRoguelikeEncounters(nextRoguelikeEncounters)
setRoguelikeStage(1)
setParty(freshParty)
setSelectedId(partyTemplate[0].id)
setSelectedTargetId(partyTemplate[0].id)
setResource(maxResource)
setEncounterIndex(initialEncounterIndex)
setEnemyHealth(nextEncounters[initialEncounterIndex].maxHealth)
@@ -494,7 +500,7 @@ export function CombatScreen({
runStartedAtRef.current = Date.now()
partStartTimesRef.current = { [startPart]: runStartedAtRef.current }
setLog([{ id: nextLogId.current++, text: 'A new run begins.', tone: 'system' }])
}, [difficulty, initialEncounterIndex, maxResource, partyTemplate, roguelikeMode, roguelikePool, startPart, staticEncounters])
}, [difficulty, initialEncounterIndex, maxResource, partyTemplate, roguelikeMode, roguelikePool, setSelectedTargetId, startPart, staticEncounters])
const castSpell = useCallback(
(spell: Spell) => {
@@ -502,26 +508,27 @@ export function CombatScreen({
if (status !== 'playing' || cooldowns[spell.id] > 0 || resource < effectiveCost) return
const healer = partyRef.current.find((member) => member.id === 'mira')
if (!healer || healer.health <= 0) return
const selected = partyRef.current.find((member) => member.id === selectedId)
const targetId = selectedIdRef.current
const selected = partyRef.current.find((member) => member.id === targetId)
if (!selected || selected.health <= 0) return
const extraTarget = (blockedIds: string[]) => partyRef.current
.filter((member) => member.health > 0 && !blockedIds.includes(member.id))
.sort((left, right) => (left.health / left.maxHealth) - (right.health / right.maxHealth))[0]
const directTargets = new Set([selectedId])
const directTargets = new Set([targetId])
const hotTargets = new Set<string>()
const shieldTargets = new Set<string>()
if (spell.kind === 'hot') hotTargets.add(selectedId)
if (spell.kind === 'shield') shieldTargets.add(selectedId)
if (spell.kind === 'hot') hotTargets.add(targetId)
if (spell.kind === 'shield') shieldTargets.add(targetId)
if (spell.name === 'Mend' && activeSetEffects.has('mend_extra_target')) {
const extra = extraTarget([selectedId])
const extra = extraTarget([targetId])
if (extra) directTargets.add(extra.id)
}
if (spell.name === 'Renew' && activeSetEffects.has('renew_extra_target')) {
const extra = extraTarget([selectedId])
const extra = extraTarget([targetId])
if (extra) hotTargets.add(extra.id)
}
if (spell.name === 'Mend' && activeSetEffects.has('mend_applies_renew')) {
hotTargets.add(selectedId)
hotTargets.add(targetId)
}
const extraTargets = upgradeStackCount(roguelikeUpgrades, `slot${spell.key as SlotKey}-extra-target` as RoguelikeUpgradeId)
for (let index = 0; index < extraTargets; index += 1) {
@@ -599,7 +606,7 @@ export function CombatScreen({
setParty(nextParty)
addLog(`${spell.name} cast on ${spell.kind === 'group' ? 'the party' : selected.name}${effectiveCost === 0 ? ' for free' : ''}.`, 'heal')
},
[activeSetEffects, addFloatingHeal, addLog, cooldowns, freeCastReady, resource, roguelikeUpgrades, selectedId, status],
[activeSetEffects, addFloatingHeal, addLog, cooldowns, freeCastReady, resource, roguelikeUpgrades, status],
)
const finishRun = useCallback(
@@ -664,18 +671,18 @@ export function CombatScreen({
const selectRelativeTarget = useCallback((direction: -1 | 1) => {
const living = partyRef.current.filter((member) => member.health > 0)
if (living.length === 0) return
const currentIndex = living.findIndex((member) => member.id === selectedId)
const currentIndex = living.findIndex((member) => member.id === selectedIdRef.current)
const nextIndex = currentIndex < 0
? 0
: (currentIndex + direction + living.length) % living.length
setSelectedId(living[nextIndex].id)
}, [selectedId])
setSelectedTargetId(living[nextIndex].id)
}, [setSelectedTargetId])
const selectDirectionalTarget = useCallback((action: InputAction) => {
const columns = dungeon.partySize >= 10 ? 6 : 3
const currentIndex = partyRef.current.findIndex((member) => member.id === selectedId)
const currentIndex = partyRef.current.findIndex((member) => member.id === selectedIdRef.current)
if (currentIndex < 0) {
setSelectedId(partyRef.current[0].id)
setSelectedTargetId(partyRef.current[0].id)
return
}
const currentRow = Math.floor(currentIndex / columns)
@@ -709,14 +716,14 @@ export function CombatScreen({
: Math.abs(b.column - currentColumn)
return aPrimary - bPrimary || aSecondary - bSecondary
})
if (candidates[0]) setSelectedId(candidates[0].member.id)
}, [dungeon.partySize, selectedId])
if (candidates[0]) setSelectedTargetId(candidates[0].member.id)
}, [dungeon.partySize, setSelectedTargetId])
const selectDirectTarget = useCallback((slot: number) => {
const index = slot + (dungeon.partySize > 6 ? targetGroup * 6 : 0)
const member = partyRef.current[index]
if (member) setSelectedId(member.id)
}, [dungeon.partySize, targetGroup])
if (member) setSelectedTargetId(member.id)
}, [dungeon.partySize, setSelectedTargetId, targetGroup])
const chooseRoguelikeUpgrade = useCallback((upgrade: RoguelikeUpgrade) => {
if (!roguelikeMode) return
@@ -778,9 +785,9 @@ export function CombatScreen({
setTargetGroup((current) => {
const groupCount = Math.max(1, Math.ceil(partyRef.current.length / 6))
const next = ((current + 1) % groupCount) as 0 | 1 | 2
const selectedIndex = partyRef.current.findIndex((member) => member.id === selectedId)
const selectedIndex = partyRef.current.findIndex((member) => member.id === selectedIdRef.current)
const nextMember = partyRef.current[(selectedIndex < 0 ? 0 : selectedIndex % 6) + next * 6]
if (nextMember) setSelectedId(nextMember.id)
if (nextMember) setSelectedTargetId(nextMember.id)
return next
})
return
@@ -1134,7 +1141,7 @@ export function CombatScreen({
<button
className={`party-member ${selectedId === member.id ? 'selected' : ''} ${member.health <= 0 ? 'dead' : ''}`}
key={member.id}
onClick={() => setSelectedId(member.id)}
onClick={() => setSelectedTargetId(member.id)}
aria-pressed={selectedId === member.id}
type="button"
>
@@ -1219,7 +1226,7 @@ export function CombatScreen({
{dualScreenEnabled && (
<DualScreenTopCombat
state={dualScreenState}
onSelectTarget={setSelectedId}
onSelectTarget={setSelectedTargetId}
/>
)}