I Want To Heal 2 build v1.0.2

This commit is contained in:
Warren H
2026-06-24 11:51:44 -04:00
parent de0a892484
commit 8abb44ea02
15 changed files with 897 additions and 36 deletions
+1
View File
@@ -1,4 +1,5 @@
node_modules
dist
.gradle
.DS_Store
*.local
Binary file not shown.
+2 -2
View File
@@ -7,8 +7,8 @@ android {
applicationId "com.phenomrom.iwanttoheal2"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 3
versionName "1.0.1"
versionCode 10002
versionName "1.0.2"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
+1 -19
View File
@@ -26,26 +26,8 @@ set -e
cd /Users/warren/Documents/action-mode
export JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home"
export PATH="$JAVA_HOME/bin:$PATH"
VERSION="1.0.0"
CURRENT_CODE=$(grep -E 'versionCode [0-9]+' android/app/build.gradle | awk '{print $2}')
NEXT_CODE=$((CURRENT_CODE + 1))
perl -0pi -e "s/versionCode\s+\d+/versionCode $NEXT_CODE/" android/app/build.gradle
perl -0pi -e "s/versionName\s+\"[^\"]+\"/versionName \"$VERSION\"/" android/app/build.gradle
npm ci
VITE_API_BASE_URL="https://iwanttoheal.phenomrom.com" npm run android:sync
cd android
./gradlew clean assembleDebug
cd ..
cp android/app/build/outputs/apk/debug/app-debug.apk "IWantToHeal2-Thor-v$VERSION.apk"
ls -lh "IWantToHeal2-Thor-v$VERSION.apk"
scripts/build-thor-apk.sh "$VERSION"
```
## Step 2: Web/Server Build Check
+1 -1
View File
@@ -5,7 +5,7 @@ import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
export default tseslint.config(
{ ignores: ['dist'] },
{ ignores: ['dist', 'android/**/build/**', 'android/app/src/main/assets/**'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
+40
View File
@@ -0,0 +1,40 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT_DIR"
VERSION="${1:-${VERSION:-1.0.2}}"
if [[ ! "$VERSION" =~ ^[0-9]+[.][0-9]+[.][0-9]+$ ]]; then
echo "VERSION must look like 1.0.2" >&2
exit 1
fi
IFS=. read -r VERSION_MAJOR VERSION_MINOR VERSION_PATCH <<< "$VERSION"
VERSION_CODE=$((VERSION_MAJOR * 10000 + VERSION_MINOR * 100 + VERSION_PATCH))
export JAVA_HOME="${JAVA_HOME:-/Applications/Android Studio.app/Contents/jbr/Contents/Home}"
export PATH="$JAVA_HOME/bin:$PATH"
export GRADLE_USER_HOME="${GRADLE_USER_HOME:-$ROOT_DIR/.gradle}"
if [[ ! -x "$JAVA_HOME/bin/java" ]]; then
echo "Java not found at $JAVA_HOME/bin/java" >&2
echo "Set JAVA_HOME to Android Studio bundled JBR." >&2
exit 1
fi
perl -0pi -e "s/versionCode\s+\d+/versionCode $VERSION_CODE/" android/app/build.gradle
perl -0pi -e "s/versionName\s+\"[^\"]+\"/versionName \"$VERSION\"/" android/app/build.gradle
npm ci
VITE_API_BASE_URL="${VITE_API_BASE_URL:-https://iwanttoheal.phenomrom.com}" npm run android:sync
(
cd android
./gradlew --no-daemon --stacktrace clean assembleDebug
)
APK="IWantToHeal2-Thor-v$VERSION.apk"
cp android/app/build/outputs/apk/debug/app-debug.apk "$APK"
ls -lh "$APK"
+22
View File
@@ -12,12 +12,32 @@ try {
id uuid primary key default gen_random_uuid(),
auth_issuer text not null,
auth_subject text not null,
username text,
password_hash text,
password_salt text,
display_name text not null default 'Healer',
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (auth_issuer, auth_subject)
);
alter table app_users add column if not exists username text;
alter table app_users add column if not exists password_hash text;
alter table app_users add column if not exists password_salt text;
create unique index if not exists app_users_username_lower_idx
on app_users (lower(username))
where username is not null;
create table if not exists sessions (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references app_users(id) on delete cascade,
token_hash text not null unique,
expires_at timestamptz not null,
created_ip text not null,
created_at timestamptz not null default now()
);
create table if not exists save_slots (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references app_users(id) on delete cascade,
@@ -64,6 +84,8 @@ try {
);
create index if not exists save_slots_user_id_idx on save_slots(user_id);
create index if not exists sessions_token_hash_idx on sessions(token_hash);
create index if not exists sessions_expires_at_idx on sessions(expires_at);
create index if not exists pvp_queue_mode_status_idx on pvp_queue(mode, status, queued_at);
create index if not exists pvp_matches_player_one_idx on pvp_matches(player_one_id);
create index if not exists pvp_matches_player_two_idx on pvp_matches(player_two_id);
+235 -5
View File
@@ -1,5 +1,11 @@
import { createReadStream, existsSync, statSync } from 'node:fs'
import { readFile } from 'node:fs/promises'
import {
createHash,
randomBytes,
scryptSync,
timingSafeEqual,
} from 'node:crypto'
import { createServer } from 'node:http'
import { extname, join, normalize } from 'node:path'
import { fileURLToPath } from 'node:url'
@@ -11,6 +17,8 @@ const distDir = join(rootDir, 'dist')
const host = process.env.HOST ?? '0.0.0.0'
const port = Number(process.env.PORT ?? 4173)
const pool = createPool()
const sessionCookieName = 'iwanttoheal2_session'
const sessionLifetimeSeconds = 60 * 60 * 24 * 30
const corsOrigins = new Set(
(process.env.CORS_ORIGINS ?? '')
@@ -49,8 +57,15 @@ const server = createServer(async (request, response) => {
await serveStatic(response, url.pathname)
} catch (error) {
console.error(error)
sendJson(response, 500, { error: 'internal_error' })
const status = Number(error?.status) || (request.url?.startsWith('/api/') ? 400 : 500)
if (status >= 500) console.error(error)
sendJson(response, status, {
error: status >= 500
? 'internal_error'
: error instanceof Error
? error.message
: 'Unable to process request.',
})
}
})
@@ -70,6 +85,8 @@ function applyCors(request, response) {
}
async function handleApi(request, response, url) {
if (await handleAuthApi(request, response, url)) return
if (request.method === 'GET' && url.pathname === '/api/health') {
const db = await pool.query('select now() as now')
sendJson(response, 200, { ok: true, database: 'ok', now: db.rows[0].now })
@@ -154,7 +171,49 @@ async function handleApi(request, response, url) {
sendJson(response, 404, { error: 'not_found' })
}
async function handleAuthApi(request, response, url) {
if (!url.pathname.startsWith('/api/auth/')) return false
if (request.method === 'POST' && url.pathname === '/api/auth/register') {
const body = await readJson(request)
const result = await registerAccount(request, body)
sendJson(response, 201, result, { 'Set-Cookie': sessionCookie(result.token, request) })
return true
}
if (request.method === 'POST' && url.pathname === '/api/auth/login') {
const body = await readJson(request)
const result = await loginAccount(request, body)
sendJson(response, 200, result, { 'Set-Cookie': sessionCookie(result.token, request) })
return true
}
if (request.method === 'GET' && url.pathname === '/api/auth/session') {
const session = await currentSession(request)
sendJson(response, 200, {
account: session ? accountPayload(session) : null,
profile: null,
})
return true
}
if (request.method === 'POST' && url.pathname === '/api/auth/logout') {
const token = requestSessionToken(request)
if (token) {
await pool.query('delete from sessions where token_hash = $1', [tokenHash(token)])
}
sendJson(response, 200, { ok: true }, { 'Set-Cookie': sessionCookie('', request, 0) })
return true
}
sendJson(response, 404, { error: 'not_found' })
return true
}
async function requireUser(request, response) {
const session = await currentSession(request)
if (session) return session
if (process.env.TRUST_AUTH_HEADERS !== '1') {
sendJson(response, 401, { error: 'auth_not_configured' })
return null
@@ -188,15 +247,186 @@ async function requireUser(request, response) {
return result.rows[0]
}
async function readJson(request) {
function normalizeUsername(value) {
const username = String(value ?? '').trim()
if (!/^[A-Za-z0-9_]{3,20}$/.test(username)) {
throw new Error('Username must be 3-20 letters, numbers, or underscores.')
}
return username
}
function normalizeDisplayName(value, fallback) {
const name = String(value ?? fallback).trim()
if (!/^[A-Za-z][A-Za-z0-9 '-]{1,19}$/.test(name)) {
throw new Error('Character name must be 2-20 characters and start with a letter.')
}
return name
}
function validatePassword(value) {
const password = String(value ?? '')
if (password.length < 10 || password.length > 128) {
throw new Error('Password must be 10-128 characters.')
}
return password
}
function passwordDigest(password, salt) {
return scryptSync(password, salt, 64).toString('hex')
}
function verifyPassword(password, user) {
const actual = Buffer.from(passwordDigest(password, user.password_salt), 'hex')
const expected = Buffer.from(user.password_hash, 'hex')
return actual.length === expected.length && timingSafeEqual(actual, expected)
}
function tokenHash(token) {
return createHash('sha256').update(token).digest('hex')
}
function parseCookies(request) {
return Object.fromEntries(
String(request.headers.cookie ?? '')
.split(';')
.map((part) => part.trim())
.filter(Boolean)
.map((part) => {
const separator = part.indexOf('=')
return separator < 0
? [part, '']
: [part.slice(0, separator), decodeURIComponent(part.slice(separator + 1))]
}),
)
}
function bearerToken(request) {
const authorization = request.headers.authorization
if (typeof authorization !== 'string') return ''
const match = authorization.match(/^Bearer\s+(.+)$/i)
return match ? match[1].trim() : ''
}
function requestSessionToken(request) {
return bearerToken(request) || parseCookies(request)[sessionCookieName] || ''
}
function sessionCookie(token, request, maxAge = sessionLifetimeSeconds) {
const secure = request.headers['x-forwarded-proto'] === 'https'
|| Boolean(request.socket.encrypted)
|| process.env.COOKIE_SECURE === '1'
return [
`${sessionCookieName}=${encodeURIComponent(token)}`,
'HttpOnly',
'Path=/',
'SameSite=Lax',
`Max-Age=${maxAge}`,
secure ? 'Secure' : '',
].filter(Boolean).join('; ')
}
function requestIp(request) {
if (process.env.TRUST_PROXY === '1') {
const forwarded = request.headers['x-forwarded-for']
if (typeof forwarded === 'string') return forwarded.split(',')[0].trim()
}
return request.socket.remoteAddress ?? 'unknown'
}
async function createSession(userId, ip) {
const token = randomBytes(32).toString('base64url')
await pool.query(
`
insert into sessions (user_id, token_hash, expires_at, created_ip)
values ($1, $2, now() + interval '30 days', $3)
`,
[userId, tokenHash(token), ip],
)
return token
}
async function currentSession(request) {
const token = requestSessionToken(request)
if (!token) return null
await pool.query('delete from sessions where expires_at <= now()')
const result = await pool.query(
`
select app_users.id, app_users.username, app_users.display_name, app_users.auth_issuer, app_users.auth_subject
from sessions
join app_users on app_users.id = sessions.user_id
where sessions.token_hash = $1
and sessions.expires_at > now()
`,
[tokenHash(token)],
)
return result.rows[0] ?? null
}
function accountPayload(user) {
return {
id: user.id,
username: user.username ?? user.auth_subject,
displayName: user.display_name,
}
}
async function registerAccount(request, payload) {
const username = normalizeUsername(payload?.username)
const normalizedSubject = username.toLowerCase()
const password = validatePassword(payload?.password)
const displayName = normalizeDisplayName(payload?.characterName, username)
const existing = await pool.query(
'select id from app_users where username is not null and lower(username) = lower($1)',
[username],
)
if (existing.rows[0]) throw new Error('That username is already taken.')
const salt = randomBytes(16).toString('hex')
const result = await pool.query(
`
insert into app_users (auth_issuer, auth_subject, username, password_hash, password_salt, display_name)
values ('local', $1, $2, $3, $4, $5)
returning id, username, display_name, auth_issuer, auth_subject
`,
[normalizedSubject, username, passwordDigest(password, salt), salt, displayName],
)
const user = result.rows[0]
const token = await createSession(user.id, requestIp(request))
return { account: accountPayload(user), profile: null, token }
}
async function loginAccount(request, payload) {
const username = normalizeUsername(payload?.username)
const password = String(payload?.password ?? '')
const result = await pool.query(
`
select id, username, password_hash, password_salt, display_name, auth_issuer, auth_subject
from app_users
where username is not null
and lower(username) = lower($1)
`,
[username],
)
const user = result.rows[0]
if (!user || !user.password_hash || !user.password_salt || !verifyPassword(password, user)) {
throw new Error('Invalid username or password.')
}
const token = await createSession(user.id, requestIp(request))
return { account: accountPayload(user), profile: null, token }
}
async function readJson(request, maxSize = 512 * 1024) {
const chunks = []
let size = 0
for await (const chunk of request) chunks.push(chunk)
for (const chunk of chunks) size += chunk.length
if (size > maxSize) throw new Error('Request body is too large.')
if (chunks.length === 0) return null
return JSON.parse(Buffer.concat(chunks).toString('utf8'))
}
function sendJson(response, status, payload) {
response.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' })
function sendJson(response, status, payload, headers = {}) {
response.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', ...headers })
response.end(JSON.stringify(payload))
}
+101
View File
@@ -0,0 +1,101 @@
import { useEffect, useState } from 'react'
import {
hydrateLocalSaveFromServer,
loadSession,
localCloudSave,
logoutAccount,
pushCloudSave,
type AuthSession,
} from './actionApi'
import type { ActionMechanicConfig } from './actionBoss/actionEncounterConfig'
import type { ActionCharacter } from './actionMode'
import { ActionModeScreen } from './components/ActionModeScreen'
import { AuthScreen } from './components/AuthScreen'
export function App() {
const [session, setSession] = useState<AuthSession | null>(null)
const [loading, setLoading] = useState(true)
const [message, setMessage] = useState('')
const [screenKey, setScreenKey] = useState(0)
useEffect(() => {
let cancelled = false
async function start() {
try {
const nextSession = await loadSession()
if (!nextSession.account) {
if (!cancelled) setSession(nextSession)
return
}
const syncStatus = await hydrateLocalSaveFromServer()
if (!cancelled) {
setSession(nextSession)
setScreenKey((current) => current + 1)
setMessage(syncStatus === 'loaded' ? 'Server save loaded.' : '')
}
} catch {
if (!cancelled) setMessage('Server unavailable. Sign in when it comes back online.')
} finally {
if (!cancelled) setLoading(false)
}
}
start()
return () => {
cancelled = true
}
}, [])
async function handleAuthenticated(nextSession: AuthSession) {
if (nextSession.account) {
const syncStatus = await hydrateLocalSaveFromServer()
setMessage(syncStatus === 'loaded' ? 'Server save loaded.' : 'Local save uploaded.')
setScreenKey((current) => current + 1)
}
setSession(nextSession)
}
async function handleLogout() {
await logoutAccount()
setSession({ account: null })
setMessage('Signed out.')
}
function syncCharacter(character: ActionCharacter) {
if (!session?.account) return
pushCloudSave({ ...localCloudSave(), character }).catch(() => null)
}
function syncMechanics(mechanics: ActionMechanicConfig) {
if (!session?.account) return
pushCloudSave({ ...localCloudSave(), mechanics }).catch(() => null)
}
if (loading) {
return (
<main className="auth-shell">
<section className="auth-panel single">
<div className="auth-card">
<p className="eyebrow">Loading</p>
<h1>Connecting</h1>
<p className="auth-message">Checking server session.</p>
</div>
</section>
</main>
)
}
if (!session?.account) {
return <AuthScreen onAuthenticated={handleAuthenticated} serverMessage={message} />
}
return (
<ActionModeScreen
key={screenKey}
account={session.account}
onCharacterSaved={syncCharacter}
onLogout={handleLogout}
onMechanicsSaved={syncMechanics}
serverMessage={message}
/>
)
}
+152
View File
@@ -0,0 +1,152 @@
import {
ACTION_SAVE_KEY,
loadActionCharacter,
saveActionCharacter,
type ActionCharacter,
} from './actionMode'
import {
ACTION_MECHANIC_CONFIG_KEY,
loadActionMechanicConfig,
saveActionMechanicConfig,
type ActionMechanicConfig,
} from './actionBoss/actionEncounterConfig'
const AUTH_TOKEN_KEY = 'i-want-to-heal-2:auth-token:v1'
export type AuthAccount = {
id: string
username: string
displayName: string
}
export type AuthSession = {
account: AuthAccount | null
profile?: null
token?: string
}
export type ActionCloudSave = {
character?: ActionCharacter
mechanics?: ActionMechanicConfig
updatedAt?: string
}
type SavedSlotResponse = {
save: null | {
save_json?: ActionCloudSave
saveJson?: ActionCloudSave
}
}
function configuredBaseUrl(value: string | undefined) {
return value ? value.replace(/\/+$/, '') : ''
}
function apiBaseUrl() {
if (typeof window !== 'undefined') {
const runtimeBase = (window as Window & { CAPACITOR_API_BASE_URL?: string }).CAPACITOR_API_BASE_URL
if (runtimeBase) return configuredBaseUrl(runtimeBase)
}
return configuredBaseUrl(import.meta.env.VITE_API_BASE_URL)
}
function readAuthToken() {
return window.localStorage.getItem(AUTH_TOKEN_KEY) ?? ''
}
function writeAuthToken(token: string) {
window.localStorage.setItem(AUTH_TOKEN_KEY, token)
}
export function clearAuthToken() {
window.localStorage.removeItem(AUTH_TOKEN_KEY)
}
export async function requestActionApiJson<T>(path: string, init: RequestInit = {}): Promise<T> {
const baseUrl = apiBaseUrl()
const headers = new Headers(init.headers)
const token = readAuthToken()
if (token && !headers.has('Authorization')) headers.set('Authorization', `Bearer ${token}`)
const response = await fetch(baseUrl ? `${baseUrl}${path}` : path, {
...init,
credentials: 'include',
headers,
})
const body = await response.json().catch(() => ({}))
if (!response.ok) throw new Error(body.error ?? 'Unable to reach the game server.')
return body as T
}
export async function loadSession() {
const session = await requestActionApiJson<AuthSession>('/api/auth/session')
if (session.token) writeAuthToken(session.token)
return session
}
export async function loginAccount(username: string, password: string) {
const session = await requestActionApiJson<AuthSession>('/api/auth/login', {
body: JSON.stringify({ username, password }),
headers: { 'Content-Type': 'application/json' },
method: 'POST',
})
if (session.token) writeAuthToken(session.token)
return session
}
export async function registerAccount(username: string, password: string, characterName: string) {
const session = await requestActionApiJson<AuthSession>('/api/auth/register', {
body: JSON.stringify({ username, password, characterName }),
headers: { 'Content-Type': 'application/json' },
method: 'POST',
})
if (session.token) writeAuthToken(session.token)
return session
}
export async function logoutAccount() {
await requestActionApiJson('/api/auth/logout', { method: 'POST' }).catch(() => null)
clearAuthToken()
}
export function localCloudSave(): ActionCloudSave {
return {
character: loadActionCharacter(),
mechanics: loadActionMechanicConfig(),
updatedAt: new Date().toISOString(),
}
}
export function applyCloudSave(save: ActionCloudSave) {
if (save.character) saveActionCharacter(save.character)
if (save.mechanics) saveActionMechanicConfig(save.mechanics)
}
export async function loadCloudSave() {
const result = await requestActionApiJson<SavedSlotResponse>('/api/save/default')
return result.save?.save_json ?? result.save?.saveJson ?? null
}
export async function pushCloudSave(save: ActionCloudSave = localCloudSave()) {
return requestActionApiJson('/api/save/default', {
body: JSON.stringify({
clientUpdatedAt: save.updatedAt ?? new Date().toISOString(),
save,
saveVersion: 1,
}),
headers: { 'Content-Type': 'application/json' },
method: 'PUT',
})
}
export async function hydrateLocalSaveFromServer() {
const remote = await loadCloudSave()
if (remote) {
applyCloudSave(remote)
return 'loaded'
}
if (window.localStorage.getItem(ACTION_SAVE_KEY) || window.localStorage.getItem(ACTION_MECHANIC_CONFIG_KEY)) {
await pushCloudSave()
return 'uploaded'
}
return 'empty'
}
+1 -1
View File
@@ -128,7 +128,7 @@ export const ACTION_DIFFICULTY_TIERS: ActionDifficultyTier[] = [
},
]
const ACTION_SAVE_KEY = 'i-want-to-heal:action-mode-save:v2'
export const ACTION_SAVE_KEY = 'i-want-to-heal:action-mode-save:v2'
const LEGACY_ACTION_SAVE_KEY = 'i-want-to-heal:action-mode-save:v1'
const MAX_ACTION_LEVEL = 25
export const BULLDROME_UPGRADE_COST = 5
+29 -6
View File
@@ -31,9 +31,15 @@ import {
type ActionRunReward,
} from '../actionMode'
import { BulldromeBossSlice } from './BulldromeBossSlice'
import type { AuthAccount } from '../actionApi'
type ActionModeScreenProps = {
account?: AuthAccount
onCharacterSaved?: (character: ActionCharacter) => void
onBack?: () => void
onLogout?: () => void
onMechanicsSaved?: (config: ActionMechanicConfig) => void
serverMessage?: string
}
type ActionHubTab = 'dungeons' | 'raids' | 'pvp' | 'roguelike' | 'customize' | 'settings'
@@ -54,7 +60,14 @@ const ACTION_HUB_ITEMS: Array<{
{ id: 'settings', label: 'Settings', glyph: 'S', description: 'Tune action mode controls.' },
]
export function ActionModeScreen({ onBack }: ActionModeScreenProps) {
export function ActionModeScreen({
account,
onBack,
onCharacterSaved,
onLogout,
onMechanicsSaved,
serverMessage = '',
}: ActionModeScreenProps) {
const [character, setCharacter] = useState<ActionCharacter>(() => loadActionCharacter())
const [activeDifficulty, setActiveDifficulty] = useState<ActionDifficulty | null>(null)
const [activeDungeonId, setActiveDungeonId] = useState<PlayableActionDungeonId>('bulldrome')
@@ -67,7 +80,8 @@ export function ActionModeScreen({ onBack }: ActionModeScreenProps) {
useEffect(() => {
saveActionCharacter(character)
}, [character])
onCharacterSaved?.(character)
}, [character, onCharacterSaved])
if (activeDifficulty) {
return (
@@ -121,12 +135,18 @@ export function ActionModeScreen({ onBack }: ActionModeScreenProps) {
<div className="action-heading-meta">
<div className="action-character-strip">
<strong>{character.name}</strong>
{account && <small>@{account.username}</small>}
<small>Healer</small>
<small>Level {character.level}</small>
<div className="header-xp" title={`${character.experience} action experience`}>
<span style={{ width: `${progress.percent}%` }} />
</div>
</div>
{onLogout && (
<button className="back-button" onClick={onLogout} type="button">
Logout
</button>
)}
{(activeScreen !== 'menu' || onBack) && (
<button
className="back-button"
@@ -161,7 +181,7 @@ export function ActionModeScreen({ onBack }: ActionModeScreenProps) {
</nav>
)}
{message && <p className="action-mode-message">{message}</p>}
{(message || serverMessage) && <p className="action-mode-message">{message || serverMessage}</p>}
{activeScreen === 'dungeons' && (
<DungeonsPanel
@@ -201,7 +221,7 @@ export function ActionModeScreen({ onBack }: ActionModeScreenProps) {
)}
{activeScreen === 'settings' && (
<ActionMechanicsAdmin />
<ActionMechanicsAdmin onConfigSaved={onMechanicsSaved} />
)}
{activeScreen !== 'menu' && activeScreen !== 'dungeons' && activeScreen !== 'customize' && activeScreen !== 'settings' && (
@@ -216,7 +236,7 @@ export function ActionModeScreen({ onBack }: ActionModeScreenProps) {
)
}
function ActionMechanicsAdmin() {
function ActionMechanicsAdmin({ onConfigSaved }: { onConfigSaved?: (config: ActionMechanicConfig) => void }) {
const [config, setConfig] = useState<ActionMechanicConfig>(() => loadActionMechanicConfig())
const [enemyPage, setEnemyPage] = useState(0)
const [attackPageByEnemy, setAttackPageByEnemy] = useState<Partial<Record<EnemyKind, number>>>({})
@@ -233,10 +253,13 @@ function ActionMechanicsAdmin() {
}
setConfig(next)
saveActionMechanicConfig(next)
onConfigSaved?.(next)
}
function resetConfig() {
setConfig(resetActionMechanicConfig())
const next = resetActionMechanicConfig()
setConfig(next)
onConfigSaved?.(next)
setEnemyPage(0)
setAttackPageByEnemy({})
}
+131
View File
@@ -0,0 +1,131 @@
import { useState } from 'react'
import {
loginAccount,
registerAccount,
type AuthSession,
} from '../actionApi'
type AuthScreenProps = {
onAuthenticated: (session: AuthSession) => void
serverMessage?: string
}
export function AuthScreen({ onAuthenticated, serverMessage = '' }: AuthScreenProps) {
const [mode, setMode] = useState<'login' | 'register'>('login')
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [characterName, setCharacterName] = useState('')
const [busy, setBusy] = useState(false)
const [message, setMessage] = useState('')
async function submit(event: React.FormEvent) {
event.preventDefault()
setBusy(true)
setMessage('')
try {
const session = mode === 'login'
? await loginAccount(username, password)
: await registerAccount(username, password, characterName)
onAuthenticated(session)
} catch (reason) {
setMessage(reason instanceof Error ? reason.message : 'Unable to authenticate.')
} finally {
setBusy(false)
}
}
return (
<main className="auth-shell">
<section className="auth-panel">
<div className="auth-brand">
<p className="eyebrow">Action Healer</p>
<h1>I Want To Heal 2</h1>
<p>
Sign in to sync saves to the server, keep progression across devices,
and queue for PvP matchmaking.
</p>
</div>
<div className="auth-card">
<div className="auth-tabs">
<button
className={mode === 'login' ? 'selected' : ''}
onClick={() => {
setMode('login')
setMessage('')
}}
type="button"
>
Sign In
</button>
<button
className={mode === 'register' ? 'selected' : ''}
onClick={() => {
setMode('register')
setMessage('')
}}
type="button"
>
Create
</button>
</div>
<form onSubmit={submit}>
<label>
Username
<input
autoComplete="username"
maxLength={20}
minLength={3}
onChange={(event) => setUsername(event.target.value)}
pattern="[A-Za-z0-9_]+"
required
value={username}
/>
</label>
{mode === 'register' && (
<label>
Character Name
<input
autoComplete="nickname"
maxLength={20}
minLength={2}
onChange={(event) => setCharacterName(event.target.value)}
required
value={characterName}
/>
</label>
)}
<label>
Password
<input
autoComplete={mode === 'login' ? 'current-password' : 'new-password'}
maxLength={128}
minLength={10}
onChange={(event) => setPassword(event.target.value)}
required
type="password"
value={password}
/>
</label>
<button className="primary-button" disabled={busy} type="submit">
{busy
? 'Working...'
: mode === 'login'
? 'Enter'
: 'Create Account'}
</button>
</form>
<p className={`auth-message ${message ? 'error' : ''}`}>
{message || serverMessage || (
mode === 'register'
? 'Creates an account and uploads the current local save if one exists.'
: 'Sign in to load your server save.'
)}
</p>
</div>
</section>
</main>
)
}
+2 -2
View File
@@ -1,10 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { ActionModeScreen } from './components/ActionModeScreen'
import { App } from './App'
import './styles.css'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<ActionModeScreen />
<App />
</StrictMode>,
)
+179
View File
@@ -197,6 +197,122 @@ h2 {
position: relative;
}
.auth-shell {
align-items: center;
background:
radial-gradient(circle at 20% 0%, rgba(229, 185, 95, 0.08), transparent 26%),
linear-gradient(180deg, #0d0f15 0%, #07080b 100%);
display: flex;
min-height: 100dvh;
overflow: hidden;
padding: 24px;
}
.auth-panel {
display: grid;
gap: 18px;
grid-template-columns: minmax(0, 1.05fr) minmax(320px, 0.95fr);
margin: 0 auto;
max-width: 980px;
width: 100%;
}
.auth-panel.single {
grid-template-columns: minmax(0, 520px);
justify-content: center;
}
.auth-brand,
.auth-card {
background: var(--panel);
border: 3px solid #0c0d11;
box-shadow: 7px 7px 0 #08090c;
min-width: 0;
outline: 2px solid var(--edge);
padding: 24px;
}
.auth-brand {
display: grid;
align-content: center;
gap: 16px;
min-height: 360px;
}
.auth-brand h1 {
font-size: clamp(20px, 3.4vw, 34px);
}
.auth-brand > p:last-child {
color: var(--muted);
font-size: 22px;
line-height: 1.2;
}
.auth-tabs {
display: grid;
gap: 8px;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.auth-tabs button {
background: #17181e;
border: 2px solid #090a0d;
color: var(--muted);
cursor: pointer;
font-family: 'Press Start 2P', monospace;
font-size: 7px;
min-height: 42px;
outline: 2px solid #3e3d47;
}
.auth-tabs button.selected {
color: var(--gold);
outline-color: var(--gold);
}
.auth-card form {
display: grid;
gap: 14px;
margin-top: 18px;
}
.auth-card label {
color: var(--muted);
display: grid;
font-family: 'Press Start 2P', monospace;
font-size: 7px;
gap: 8px;
text-transform: uppercase;
}
.auth-card input {
background: #111217;
border: 2px solid #090a0d;
color: var(--ink);
font: inherit;
font-size: 16px;
outline: 2px solid #3e3d47;
padding: 10px;
width: 100%;
}
.auth-card input:focus {
outline-color: var(--gold);
}
.auth-message {
color: var(--muted);
font-size: 16px;
line-height: 1.2;
margin-top: 16px;
min-height: 38px;
}
.auth-message.error {
color: #ff8190;
}
.action-run-reward-backdrop {
align-items: center;
background: rgba(5, 6, 9, 0.78);
@@ -1617,6 +1733,61 @@ h2 {
width: 100%;
}
.auth-shell {
padding: 10px;
}
.auth-panel {
gap: 10px;
grid-template-columns: minmax(0, 0.95fr) minmax(260px, 1.05fr);
max-width: none;
}
.auth-brand,
.auth-card {
box-shadow: none;
padding: 14px;
}
.auth-brand {
gap: 10px;
min-height: 0;
}
.auth-brand h1 {
font-size: 18px;
}
.auth-brand > p:last-child,
.auth-message {
font-size: 14px;
}
.auth-card form {
gap: 10px;
margin-top: 12px;
}
.auth-tabs button {
font-size: 6px;
min-height: 34px;
}
.auth-card label {
font-size: 6px;
gap: 6px;
}
.auth-card input {
font-size: 12px;
padding: 7px;
}
.auth-message {
margin-top: 10px;
min-height: 30px;
}
.content-screen,
.message-panel {
box-shadow: none;
@@ -2005,6 +2176,14 @@ h2 {
padding: 8px;
}
.auth-panel {
grid-template-columns: minmax(0, 1fr);
}
.auth-brand {
display: none;
}
.screen-heading {
align-items: start;
}