Add accounts, presets, live sheet panel and equipment tiles

- Login with registration code, sessions, rate limiting, per-user characters
- Share characters with other users (read-only), admin CLI
- Rules presets with a Presets tab, applied/pending changes with diff in a side sheet
- Equipment as tiles with amounts, starting kit removed
- Rename nav tab to "Character", add "New character" button
- Stop tracking _to_delete/

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
This commit is contained in:
Claude
2026-10-20 20:37:09 +00:00
parent afbdc4b139
commit 5f3b2b2874
27 changed files with 857 additions and 244 deletions
+34
View File
@@ -0,0 +1,34 @@
import { useState } from 'react'
export default function Account({ user, onLogout }) {
const [f, setF] = useState({ old: '', new: '', new2: '' })
const [msg, setMsg] = useState('')
const [err, setErr] = useState('')
const set = (k) => (e) => setF({ ...f, [k]: e.target.value })
const change = async (e) => {
e.preventDefault(); setMsg(''); setErr('')
if (f.new !== f.new2) return setErr('The two new passwords do not match.')
const r = await fetch('/api/auth/password', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ old: f.old, new: f.new }) })
const data = await r.json().catch(() => ({}))
if (!r.ok) return setErr(typeof data.detail === 'string' ? data.detail : 'Could not change the password.')
setF({ old: '', new: '', new2: '' }); setMsg('Password changed. Other devices were logged out.')
}
return (
<section className="page narrow">
<h1>Account</h1>
<p>Logged in as <strong>{user}</strong>.</p>
<form className="card" onSubmit={change}>
<h3>Change password</h3>
<label>Current password<input type="password" value={f.old} onChange={set('old')} autoComplete="current-password" required /></label>
<label>New password<input type="password" value={f.new} onChange={set('new')} autoComplete="new-password" required /></label>
<label>Repeat new password<input type="password" value={f.new2} onChange={set('new2')} autoComplete="new-password" required /></label>
{err && <p className="warn" role="alert">{err}</p>}
{msg && <p className="toast" role="status">{msg}</p>}
<button type="submit">Change password</button>
</form>
<p><button className="ghost" onClick={onLogout}>Log out</button></p>
</section>
)
}
+36 -7
View File
@@ -3,6 +3,9 @@ import Creator from './Creator.jsx'
import Rules from './Rules.jsx'
import Characters from './Characters.jsx'
import Presets from './Presets.jsx'
import Login from './Login.jsx'
import Account from './Account.jsx'
import { claimLegacy } from './legacy.js'
const useHash = () => {
const [h, setH] = useState(window.location.hash.slice(1) || 'creator')
@@ -16,16 +19,40 @@ const useHash = () => {
export default function App() {
const hash = useHash()
const [auth, setAuth] = useState(null) // null = checking, {user, registration_open}
const [rules, setRules] = useState(null)
const [error, setError] = useState('')
// Used to load a saved character into the creator
const [loadRequest, setLoadRequest] = useState(null)
const [charName, setCharName] = useState('') // name of the character being worked on, shown in the nav
// Ask the server who we are; any later 401 (session expired) sends us back to the login
useEffect(() => {
fetch('/api/auth/me').then((r) => r.json()).then(setAuth).catch(() => setError('Could not reach the server.'))
const out = () => { setAuth((a) => ({ user: null, registration_open: a?.registration_open })); setRules(null) }
window.addEventListener('aw-unauthorized', out)
return () => window.removeEventListener('aw-unauthorized', out)
}, [])
const username = auth?.user?.username
const loadRules = () => fetch('/api/rules').then((r) => r.json()).then(setRules).catch(() => setError('Could not load the rules.'))
useEffect(() => { loadRules() }, [])
useEffect(() => {
if (!username) return
claimLegacy().finally(loadRules)
}, [username]) // eslint-disable-line
const logout = async () => {
await fetch('/api/auth/logout', { method: 'POST' }).catch(() => {})
window.location.hash = 'creator'
setAuth({ user: null, registration_open: auth.registration_open }); setRules(null)
}
if (error) return <main><p className="warn">{error}</p></main>
if (!auth) return <main><p className="muted">Loading …</p></main>
if (!auth.user) return <main><Login registrationOpen={auth.registration_open} onDone={(u) => setAuth({ ...auth, user: u })} /></main>
const page = hash.split('/')[0]
const nav = [['creator', 'Create character'], ['rules', 'Rules'], ['presets', 'Presets'], ['characters', 'My characters']]
const nav = [['creator', charName ? `Character: ${charName}` : 'Character'], ['rules', 'Rules'], ['presets', 'Presets'], ['characters', 'My characters']]
return (
<>
@@ -36,19 +63,21 @@ export default function App() {
</a>
<nav>
{nav.map(([id, label]) => (
<a key={id} href={`#${id}`} className={page === id ? 'active' : ''}>{label}</a>
<a key={id} href={`#${id}`} className={`${page === id ? 'active' : ''} ${id === 'creator' ? 'navchar' : ''}`}>{label}</a>
))}
<a href="#account" className={page === 'account' ? 'active' : ''} title="Account">{username}</a>
</nav>
</header>
<main>
{error && <p className="warn">{error}</p>}
{!rules && !error && <p className="muted">Loading rules …</p>}
{rules && page === 'creator' && <Creator rules={rules} loadRequest={loadRequest} clearLoad={() => setLoadRequest(null)} />}
{!rules && <p className="muted">Loading rules …</p>}
{rules && page === 'creator' && <Creator rules={rules} user={username} loadRequest={loadRequest} clearLoad={() => setLoadRequest(null)} onName={setCharName} />}
{rules && page === 'rules' && <Rules rules={rules} section={hash.split('/')[1]} />}
{rules && page === 'presets' && <Presets rules={rules} reload={loadRules} />}
{rules && page === 'characters' && (
<Characters onOpen={(entry) => { setLoadRequest(entry); window.location.hash = 'creator' }} />
<Characters onOpen={(entry) => { setLoadRequest(entry); window.location.hash = 'creator' }}
onNew={() => { setLoadRequest({ new: true }); window.location.hash = 'creator' }} />
)}
{page === 'account' && <Account user={username} onLogout={logout} />}
</main>
</>
)
+89 -34
View File
@@ -1,50 +1,105 @@
import { useState } from 'react'
import { useEffect, useState } from 'react'
const KEY = 'aw_chars'
export const loadList = () => {
try { return JSON.parse(localStorage.getItem(KEY)) || [] } catch { return [] }
}
export const saveList = (l) => {
try { localStorage.setItem(KEY, JSON.stringify(l)) } catch { /* storage blocked */ }
}
export const rememberCharacter = (id, token, name) => {
const l = loadList().filter((c) => c.id !== id)
l.unshift({ id, token, name: name || 'Nameless', updated: Date.now() })
saveList(l)
}
function SharePanel({ c, reload }) {
const [name, setName] = useState('')
const [err, setErr] = useState('')
export default function Characters({ onOpen }) {
const [list, setList] = useState(loadList)
const remove = async (c) => {
if (!window.confirm(`Really delete "${c.name}"?`)) return
await fetch(`/api/characters/${c.id}`, { method: 'DELETE', headers: { 'X-Edit-Token': c.token } }).catch(() => {})
const l = list.filter((x) => x.id !== c.id)
saveList(l); setList(l)
const add = async (e) => {
e.preventDefault(); setErr('')
if (!name.trim()) return
const r = await fetch(`/api/characters/${c.id}/shares`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: name }) })
const data = await r.json().catch(() => ({}))
if (!r.ok) return setErr(typeof data.detail === 'string' ? data.detail : 'Could not share.')
setName(''); reload()
}
const remove = async (u) => {
await fetch(`/api/characters/${c.id}/shares/${encodeURIComponent(u)}`, { method: 'DELETE' }).catch(() => {})
reload()
}
return (
<div className="sharepanel">
<p className="small muted">These users can view this character (read-only), for example your DM:</p>
<div className="chips">
{c.shared_with.length === 0 && <span className="small muted">Nobody yet.</span>}
{c.shared_with.map((u) => (
<span key={u} className="chip on">{u} <button className="linklike" onClick={() => remove(u)} aria-label={`Stop sharing with ${u}`}>×</button></span>
))}
</div>
<form className="row" onSubmit={add}>
<input value={name} onChange={(e) => setName(e.target.value)} placeholder="Username" aria-label="Username to share with" />
<button type="submit">Share</button>
</form>
{err && <p className="warn small">{err}</p>}
</div>
)
}
export default function Characters({ onOpen, onNew }) {
const [data, setData] = useState(null)
const [open, setOpen] = useState(null) // id of the character whose share panel is open
const [err, setErr] = useState('')
const load = () => fetch('/api/characters').then((r) => r.json()).then(setData).catch(() => setErr('Could not load your characters.'))
useEffect(() => { load() }, [])
const remove = async (c) => {
if (!window.confirm(`Really delete "${c.name}"?`)) return
const r = await fetch(`/api/characters/${c.id}`, { method: 'DELETE' }).catch(() => null)
if (!r || !r.ok) return setErr('Deleting failed.')
load()
}
const when = (t) => new Date(t * 1000).toLocaleString('en-GB')
return (
<section className="page narrow">
<h1>My characters</h1>
<div className="row">
<h1>My characters</h1>
<button onClick={onNew}>+ New character</button>
</div>
<p className="muted">
This list is stored in this browser. The characters themselves live on the server and can be shared by link
(<code>#creator/&lt;ID&gt;</code>), but only this browser holds the key needed to change them.
These characters are saved in your account. Only you and the users you share a character with can see it.
Shared users can look at it but not change it.
</p>
{list.length === 0 && <p className="card">Nothing saved yet. Create a character and click "Save".</p>}
{err && <p className="warn">{err}</p>}
{data && data.mine.length === 0 && <p className="card">Nothing saved yet. Use "New character", then click "Save" on the Summary page.</p>}
<ul className="charlist">
{list.map((c) => (
<li key={c.id} className="card row">
<div>
<strong>{c.name}</strong>
<div className="muted small">ID {c.id} · {new Date(c.updated).toLocaleString('en-GB')}</div>
</div>
<div className="actions">
<button onClick={() => onOpen(c)}>Open</button>
<button className="ghost danger" onClick={() => remove(c)}>Delete</button>
{(data?.mine || []).map((c) => (
<li key={c.id} className="card">
<div className="row">
<div>
<strong>{c.name}</strong>
<div className="muted small">
ID {c.id} · {when(c.updated)}{c.shared_with.length > 0 && ` · shared with ${c.shared_with.join(', ')}`}
</div>
</div>
<div className="actions">
<button onClick={() => onOpen(c)}>Open</button>
<button className="ghost" onClick={() => setOpen(open === c.id ? null : c.id)}>Share</button>
<button className="ghost danger" onClick={() => remove(c)}>Delete</button>
</div>
</div>
{open === c.id && <SharePanel c={c} reload={load} />}
</li>
))}
</ul>
{data && data.shared.length > 0 && (
<>
<h2>Shared with me</h2>
<ul className="charlist">
{data.shared.map((c) => (
<li key={c.id} className="card row">
<div>
<strong>{c.name}</strong>
<div className="muted small">from {c.owner} · {when(c.updated)}</div>
</div>
<div className="actions"><button onClick={() => onOpen(c)}>View</button></div>
</li>
))}
</ul>
</>
)}
</section>
)
}
+66 -74
View File
@@ -1,26 +1,25 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { applyPreset, applyProfession, compute, effectiveRules, emptyCharacter, kitItems, normalize, resetStep, setProfessionPicks } from './calc.js'
import { rememberCharacter } from './Characters.jsx'
import SheetPanel from './SheetPanel.jsx'
const DRAFT = 'aw_draft'
const STEPS = ['Concept', 'Attributes', 'Profession', 'Skills', 'Talents', 'Mutations', 'Equipment', 'Summary']
const SOURCE = { noxium: 'Noxium', diesium: 'Diesium', hybrid: 'Hybrid', precyber: 'Pre-meteor cybernetics' }
const Badge = () => <span className="badge" title="Not yet worked out in the brainstorm">Placeholder</span>
function readDraft() {
try { return JSON.parse(localStorage.getItem(DRAFT)) } catch { return null }
function readDraft(key) {
try { return JSON.parse(localStorage.getItem(key)) } catch { return null }
}
export default function Creator({ rules: baseRules, loadRequest, clearLoad }) {
export default function Creator({ rules: baseRules, user, loadRequest, clearLoad, onName }) {
const DRAFT = `aw_draft:${user}` // drafts are kept per account
const freshCharacter = () => applyPreset(emptyCharacter(baseRules), baseRules, baseRules.default_preset || 'standard')
const [ch, setCh] = useState(() => {
const d = readDraft()
const d = readDraft(DRAFT)
return d?.ch ? normalize(d.ch, baseRules) : freshCharacter()
})
// `ch` is the working copy the pages edit; `committed` is the applied state shown as "before" in the side panel
const [committed, setCommitted] = useState(() => {
const d = readDraft()
const d = readDraft(DRAFT)
const src = d?.committed || d?.ch
return src ? normalize(src, baseRules) : freshCharacter()
})
@@ -28,7 +27,7 @@ export default function Creator({ rules: baseRules, loadRequest, clearLoad }) {
const pending = JSON.stringify(ch) !== JSON.stringify(committed)
const apply = () => setCommitted(ch)
const discard = () => setCh(committed)
const [saved, setSaved] = useState(() => readDraft()?.saved || null) // {id, token}
const [saved, setSaved] = useState(() => readDraft(DRAFT)?.saved || null) // {id}
const [step, setStep] = useState(0)
const [msg, setMsg] = useState('')
const fileRef = useRef(null)
@@ -42,6 +41,8 @@ export default function Creator({ rules: baseRules, loadRequest, clearLoad }) {
const set = (patch) => setCh((x) => ({ ...x, ...patch }))
const cr = rules.creation
useEffect(() => { onName?.(ch.name) }, [ch.name]) // eslint-disable-line
// Keep the draft in this browser
useEffect(() => {
try { localStorage.setItem(DRAFT, JSON.stringify({ ch, committed, saved })) } catch { /* ignore */ }
@@ -50,8 +51,17 @@ export default function Creator({ rules: baseRules, loadRequest, clearLoad }) {
// Load a character picked in "My characters"
useEffect(() => {
if (!loadRequest) return
if (loadRequest.new) { // "New character" button on the My characters page
const untouched = JSON.stringify(ch) === JSON.stringify(freshCharacter()) && !saved
if (untouched || window.confirm('Start a new character? Your current draft is discarded unless you saved it.')) {
setBoth(freshCharacter()); setSaved(null); setStep(0)
}
clearLoad()
return
}
fetch(`/api/characters/${loadRequest.id}`).then((r) => { if (!r.ok) throw new Error(); return r.json() })
.then((data) => { setBoth(normalize(data, baseRules)); setSaved({ id: loadRequest.id, token: loadRequest.token }); setStep(7) })
.then((data) => { setBoth(normalize(data, baseRules)); setSaved(loadRequest.owner ? null : { id: loadRequest.id }); setStep(7)
if (loadRequest.owner) setMsg(`This is ${loadRequest.owner}'s character. You can look at it; "Save" creates your own copy.`) })
.catch(() => setMsg('Could not load the character.'))
.finally(clearLoad)
}, [loadRequest]) // eslint-disable-line
@@ -62,7 +72,7 @@ export default function Creator({ rules: baseRules, loadRequest, clearLoad }) {
if (!id || loadRequest) return
fetch(`/api/characters/${id}`).then((r) => { if (!r.ok) throw new Error(); return r.json() })
.then((data) => { setBoth(normalize(data, baseRules)); setSaved(null); setStep(7); setMsg('Shared character loaded. "Save" creates your own copy.') })
.catch(() => setMsg('Shared character not found.'))
.catch(() => setMsg('Character not found, or it is not shared with you.'))
}, []) // eslint-disable-line
// Moving between pages: pending edits are applied first (Next applies silently, other jumps ask).
@@ -89,15 +99,14 @@ export default function Creator({ rules: baseRules, loadRequest, clearLoad }) {
const save = async () => {
try {
if (saved) {
const r = await fetch(`/api/characters/${saved.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json', 'X-Edit-Token': saved.token }, body: JSON.stringify(ch) })
const r = await fetch(`/api/characters/${saved.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(ch) })
if (!r.ok) throw new Error()
rememberCharacter(saved.id, saved.token, ch.name)
flash('Saved.')
} else {
const r = await fetch('/api/characters', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(ch) })
if (!r.ok) throw new Error()
const { id, token } = await r.json()
setSaved({ id, token }); rememberCharacter(id, token, ch.name)
const { id } = await r.json()
setSaved({ id })
flash('Saved. The share link is shown below.')
}
} catch { flash('Saving failed.') }
@@ -357,69 +366,44 @@ export default function Creator({ rules: baseRules, loadRequest, clearLoad }) {
)
// --- Equipment ---
const kitRule = cr.kit
const items = rules.equipment
const catName = (id) => rules.equipment_categories.find((x) => x.id === id).name
const setKit = (patch) => set({ kit: { ...ch.kit, ...patch } })
const weaponItems = items.filter((e) => kitRule.weapon_cats.includes(e.cat))
const armorItems = items.filter((e) => e.cat === 'armor' && kitRule.armor_tiers.includes(e.tier))
const resourceItems = items.filter((e) => e.cat === 'resource')
const toolItems = items.filter((e) => e.cat === 'tool')
const ItemCard = ({ e, on, onClick, disabled }) => (
<button className={`card choice ${on ? 'on' : ''}`} onClick={onClick} disabled={disabled}>
<strong>{e.name}</strong>
<span className="small muted">{e.stat}{e.tier ? ` (${e.tier})` : ''} · {e.kg} kg</span>
{e.note && <span className="small">{e.note}</span>}
</button>
)
const SingleSlot = ({ title, list, slot }) => (
<>
<h3>{title}</h3>
<div className="cards">{list.map((e) => (
<ItemCard key={e.id} e={e} on={ch.kit[slot] === e.id} onClick={() => setKit({ [slot]: ch.kit[slot] === e.id ? '' : e.id })} />
))}</div>
</>
)
const setExtra = (id, qty) => {
const extras = { ...ch.kit.extras }
if (qty <= 0) delete extras[id]; else extras[id] = qty
setKit({ extras })
set({ kit: { ...ch.kit, extras } })
}
const overWeight = c.weight > c.carryMax
const stepGear = (
<>
<h2>Equipment</h2>
<p className="muted">Starting gear: one weapon (melee or firearm), light armor or clothing, {kitRule.resources_min}-{kitRule.resources_max} resources and one tool. Everything else can be added as extra items below.</p>
<p className="muted">Pick everything your character carries. Ask your GM what is allowed. Use + and − on a tile to change the amount.</p>
<div className={`pool ${overWeight ? 'over' : ''}`}>Carried: <strong>{c.weight}</strong> / {c.carryMax} kg (Strength Mod x {cr.carry_per_strength_mod}){overWeight && ' – overloaded'}</div>
<SingleSlot title="Weapon" list={weaponItems} slot="weapon" />
<SingleSlot title="Armor or clothing" list={armorItems} slot="armor" />
<h3>Resources ({ch.kit.resources.length}/{kitRule.resources_max})</h3>
<div className="cards">{resourceItems.map((e) => {
const on = ch.kit.resources.includes(e.id)
return <ItemCard key={e.id} e={e} on={on} disabled={!on && ch.kit.resources.length >= kitRule.resources_max}
onClick={() => setKit({ resources: on ? ch.kit.resources.filter((x) => x !== e.id) : [...ch.kit.resources, e.id] })} />
})}</div>
<SingleSlot title="Tool or utility item" list={toolItems} slot="tool" />
<h3>Extra items</h3>
<p className="small muted">Anything else from the document's item lists. Ask your GM what you may take along.</p>
{rules.equipment_categories.map((cat) => (
<details key={cat.id}>
<summary>{cat.name} <span className="muted small">({items.filter((e) => e.cat === cat.id).reduce((n, e) => n + (ch.kit.extras[e.id] || 0), 0)} chosen)</span></summary>
<div className="extras">{items.filter((e) => e.cat === cat.id).map((e) => (
<div key={e.id} className="extra">
<div><strong>{e.name}</strong><div className="small muted">{e.stat}{e.tier ? ` (${e.tier})` : ''} · {e.kg} kg{e.note ? ` · ${e.note}` : ''}</div></div>
<div className="stepper">
<button onClick={() => setExtra(e.id, (ch.kit.extras[e.id] || 0) - 1)} aria-label={`${e.name} minus`}>−</button>
<span className="val">{ch.kit.extras[e.id] || 0}</span>
<button onClick={() => setExtra(e.id, (ch.kit.extras[e.id] || 0) + 1)} aria-label={`${e.name} plus`}>+</button>
</div>
</div>
))}</div>
</details>
))}
{rules.equipment_categories.map((cat) => {
const list = items.filter((e) => e.cat === cat.id)
const n = list.reduce((sum, e) => sum + (ch.kit.extras[e.id] || 0), 0)
return (
<div key={cat.id}>
<h4>{cat.name} {n > 0 && <span className="muted small">({n} chosen)</span>}</h4>
<div className="cards">{list.map((e) => {
const qty = ch.kit.extras[e.id] || 0
return (
<div key={e.id} className={`card tile ${qty > 0 ? 'on' : ''}`}>
<strong>{e.name}</strong>
<span className="small muted">{e.stat}{e.tier ? ` (${e.tier})` : ''} · {e.kg} kg</span>
{e.note && <span className="small">{e.note}</span>}
<div className="stepper tilestepper">
<button className="ghost" onClick={() => setExtra(e.id, qty - 1)} disabled={qty === 0} aria-label={`${e.name} minus`}>−</button>
<span className="val">{qty}</span>
<button onClick={() => setExtra(e.id, qty + 1)} aria-label={`${e.name} plus`}>+</button>
</div>
</div>
)
})}</div>
</div>
)
})}
<label>Other items (free text)<textarea rows="3" value={ch.gear.other} onChange={(e) => set({ gear: { ...ch.gear, other: e.target.value } })} placeholder="Anything not in the lists, e.g. a unique starter multi-tool" /></label>
<label>Notes<textarea rows="4" value={ch.notes} onChange={(e) => set({ notes: e.target.value })} /></label>
</>
@@ -438,10 +422,6 @@ export default function Creator({ rules: baseRules, loadRequest, clearLoad }) {
if (c.profLeft > 0) warnings.push(`${c.profLeft} proficiency pick${c.profLeft > 1 ? 's' : ''} left.`)
if (ch.talents.length < cr.talents_min) warnings.push('No starting talent chosen.')
if (over) warnings.push('Augmentation Capacity exceeded.')
if (!ch.kit.weapon) warnings.push('No starting weapon chosen.')
if (!ch.kit.armor) warnings.push('No armor or clothing chosen.')
if (ch.kit.resources.length < kitRule.resources_min) warnings.push('No starting resource chosen.')
if (!ch.kit.tool) warnings.push('No tool or utility item chosen.')
if (overWeight) warnings.push('Carrying more than the carry capacity.')
const gearLines = kitItems(ch, rules)
@@ -460,11 +440,6 @@ export default function Creator({ rules: baseRules, loadRequest, clearLoad }) {
<table><tbody>{rules.derived.map((d) => <tr key={d.id}><td>{d.name}</td><td>{c.derived[d.id]}</td></tr>)}</tbody></table>
</div>
<div>
<h4>Skills</h4>
<table><tbody>
{c.skills.filter((s) => s.points || s.prof || (s.unlocked && s.type === 'advanced')).map((s) => <tr key={s.id}><td>{s.name}</td><td>{s.total}</td><td className="muted">{s.pips === 2 ? 'Expertise' : s.pips === 1 ? 'Proficient' : ''}</td></tr>)}
{!c.skills.some((s) => s.points || s.prof || s.unlocked) && <tr><td className="muted">None</td></tr>}
</tbody></table>
<h4>Talents</h4>
<ul className="desclist">{rules.talents.filter((t) => ch.talents.includes(t.id)).map((t) => <li key={t.id}><strong>{t.name}</strong><div className="rowdesc">{t.desc}</div></li>)}</ul>
{!ch.talents.length && <p>–</p>}
@@ -477,6 +452,23 @@ export default function Creator({ rules: baseRules, loadRequest, clearLoad }) {
{ch.gear.other && <p className="small"><strong>Other:</strong> {ch.gear.other}</p>}
</div>
</div>
<h4>Skills</h4>
<div className="skillcols">
{rules.skill_groups.map((g) => (
<div key={g.id} className="skillgroup">
<h5>{g.name}</h5>
<table><tbody>
{c.skills.filter((s) => s.group === g.id).map((s) => (
<tr key={s.id} className={s.usable ? '' : 'dim'}>
<td>{s.name}{s.type === 'advanced' && <span className="muted small"> (adv.)</span>}</td>
<td>{s.total}</td>
<td className="muted">{s.pips === 2 ? 'Expertise' : s.pips === 1 ? 'Proficient' : !s.usable ? 'Locked' : s.points ? `+${s.points}` : ''}</td>
</tr>
))}
</tbody></table>
</div>
))}
</div>
</div>
<div className="actions wrap">
<button onClick={() => download('pdf')}>Download as PDF</button>
+53
View File
@@ -0,0 +1,53 @@
import { useState } from 'react'
export default function Login({ registrationOpen, onDone }) {
const [mode, setMode] = useState('login')
const [f, setF] = useState({ username: '', password: '', password2: '', code: '' })
const [err, setErr] = useState('')
const [busy, setBusy] = useState(false)
const set = (k) => (e) => setF({ ...f, [k]: e.target.value })
const register = mode === 'register'
const submit = async (e) => {
e.preventDefault()
setErr('')
if (register && f.password !== f.password2) return setErr('The two passwords do not match.')
setBusy(true)
try {
const r = await fetch(`/api/auth/${register ? 'register' : 'login'}`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(register ? { username: f.username, password: f.password, code: f.code } : { username: f.username, password: f.password }),
})
const data = await r.json().catch(() => ({}))
if (!r.ok) throw new Error(typeof data.detail === 'string' ? data.detail : 'Something went wrong.')
onDone(data.user)
} catch (ex) { setErr(ex.message) } finally { setBusy(false) }
}
return (
<section className="page narrow loginbox">
<h1>Arcane Wasteland</h1>
<p className="muted">Character creator. Please {register ? 'create an account' : 'log in'} to continue.</p>
<div className="chips">
<button className={`chip ${!register ? 'on' : ''}`} onClick={() => { setMode('login'); setErr('') }}>Log in</button>
{registrationOpen && <button className={`chip ${register ? 'on' : ''}`} onClick={() => { setMode('register'); setErr('') }}>Create account</button>}
</div>
<form className="card" onSubmit={submit}>
<label>Username<input value={f.username} onChange={set('username')} autoComplete="username" autoFocus required /></label>
<label>Password<input type="password" value={f.password} onChange={set('password')} autoComplete={register ? 'new-password' : 'current-password'} required /></label>
{register && (
<>
<label>Repeat password<input type="password" value={f.password2} onChange={set('password2')} autoComplete="new-password" required /></label>
<label>Registration code
<input type="password" value={f.code} onChange={set('code')} autoComplete="off" required />
<span className="small muted">Ask the person who runs this server.</span>
</label>
<p className="small muted">Username: 3-32 characters (letters, digits, . _ -). Password: at least 8 characters.</p>
</>
)}
{err && <p className="warn" role="alert">{err}</p>}
<button type="submit" disabled={busy}>{register ? 'Create account' : 'Log in'}</button>
</form>
</section>
)
}
+6 -17
View File
@@ -1,20 +1,15 @@
import { useState } from 'react'
const KEY = 'aw_presets' // {presetId: editToken}, only for presets created in this browser
const loadTokens = () => { try { return JSON.parse(localStorage.getItem(KEY)) || {} } catch { return {} } }
const saveTokens = (t) => { try { localStorage.setItem(KEY, JSON.stringify(t)) } catch { /* storage blocked */ } }
const BLANK = { id: null, name: '', desc: '', creation: {} }
export default function Presets({ rules, reload }) {
const [tokens, setTokens] = useState(loadTokens)
const [sel, setSel] = useState(rules.default_preset || 'standard')
const [draft, setDraft] = useState(null) // preset being edited/created, null = viewing
const [msg, setMsg] = useState('')
const presets = rules.presets || []
const current = draft || presets.find((p) => p.id === sel) || presets[0]
const editable = !!draft
const own = !!current?.id && !!tokens[current.id]
const own = !!current?.mine
const base = rules.creation
const groups = [...new Set((rules.tunables || []).map((x) => x.group))]
const value = (k) => (current.creation?.[k] ?? base[k])
@@ -36,17 +31,13 @@ export default function Presets({ rules, reload }) {
try {
const res = await fetch(draft.id ? `/api/presets/${draft.id}` : '/api/presets', {
method: draft.id ? 'PUT' : 'POST',
headers: { 'Content-Type': 'application/json', ...(draft.id ? { 'X-Edit-Token': tokens[draft.id] } : {}) },
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).detail || 'Saving failed')
const data = await res.json()
let id = draft.id
if (!id) {
id = data.id
const t = { ...tokens, [id]: data.token }
saveTokens(t); setTokens(t)
}
if (!id) id = data.id
await reload()
setSel(id); setDraft(null); flash('Preset saved.')
} catch (e) { flash(String(e.message || e)) }
@@ -55,22 +46,20 @@ export default function Presets({ rules, reload }) {
const remove = async () => {
if (!window.confirm(`Delete the preset "${current.name}"? Characters that already use it keep their numbers.`)) return
try {
const res = await fetch(`/api/presets/${current.id}`, { method: 'DELETE', headers: { 'X-Edit-Token': tokens[current.id] } })
const res = await fetch(`/api/presets/${current.id}`, { method: 'DELETE' })
if (!res.ok) throw new Error('Deleting failed')
const t = { ...tokens }; delete t[current.id]
saveTokens(t); setTokens(t)
await reload(); setSel(rules.default_preset || 'standard')
} catch (e) { flash(String(e.message || e)) }
}
const kind = (p) => (p.builtin ? 'Built in' : tokens[p.id] ? 'Yours' : 'Shared')
const kind = (p) => (p.builtin ? 'Built in' : p.mine ? 'Yours' : p.author ? `By ${p.author}` : 'Shared')
return (
<section className="page">
<h1>Rules presets</h1>
<p className="muted narrow">
A preset is a named set of creation numbers (points for attributes and skills, talents, proficiency ...). Pick one on the Concept page of the creator.
Presets are stored on the server, so everyone using this site can use them; only the browser that created a preset can change or delete it.
Presets are stored on the server, so everyone using this site can use them; only the account that created a preset can change or delete it.
Built-in presets come from <code>rules.json</code>. Duplicate one to make your own version.
</p>
<div className="presets-layout">
+1 -1
View File
@@ -7,7 +7,7 @@ function Rows({ now, before, valueLabel }) {
return (
<table className="difftable"><tbody>
{rows.map((r) => (
<tr key={r.k} className={`d-${r.status}`}>
<tr key={r.k} className={`d-${r.status} ${r.dim ? 'dim' : ''}`}>
<td>{r.status === 'added' ? '+ ' : r.status === 'removed' ? '− ' : ''}{r.label}{r.desc && <div className="rowdesc">{r.desc}</div>}</td>
<td>
{r.status === 'changed' && r.old.val !== r.val && r.old.val !== undefined && <span className="was">{r.old.val} → </span>}
+13 -8
View File
@@ -12,7 +12,7 @@ export function emptyCharacter(rules) {
preset: rules.default_preset || 'standard', creation: {}, // creation = overrides of rules.creation for this character
skill_points: {}, proficiencies: {}, proficiency_picks: {}, unlocked: [],
talents: [], augments: [],
kit: { weapon: '', armor: '', tool: '', resources: [], extras: {} },
kit: { extras: {} }, // item id -> quantity
gear: { other: '' },
notes: '',
}
@@ -65,7 +65,7 @@ export function normalize(ch, rules) {
const preset = ch.preset || rules.default_preset || 'standard'
const creation = ch.creation ?? presetOverrides(rules, preset)
const base = emptyCharacter(effectiveRules(rules, { creation }))
const out = { ...base, ...ch, preset, creation, kit: { ...base.kit, ...(ch.kit || {}) }, gear: { ...base.gear, ...(ch.gear || {}) } }
const out = { ...base, ...ch, preset, creation, kit: { extras: {}, ...(ch.kit || {}) }, gear: { ...base.gear, ...(ch.gear || {}) } }
if (typeof out.skill_pool === 'number') { // older characters stored the pool size on the character
if (out.skill_pool !== effectiveRules(rules, out).creation.skill_points_default) {
out.creation = { ...out.creation, skill_points_default: out.skill_pool }
@@ -73,6 +73,13 @@ export function normalize(ch, rules) {
}
}
delete out.skill_pool
// Older characters had starting-kit slots (weapon, armor, tool, resources): they become ordinary items
const k = out.kit
if (k.weapon || k.armor || k.tool || (k.resources || []).length) {
const extras = { ...k.extras }
;[k.weapon, k.armor, k.tool, ...(k.resources || [])].filter(Boolean).forEach((id) => { extras[id] = (extras[id] || 0) + 1 })
out.kit = { extras }
}
// The preset may have been deleted since: fall back to whatever matches the numbers
if (out.preset !== 'custom' && !(rules.presets || []).some((x) => x.id === out.preset)) out.preset = matchPreset(rules, out.creation)
// Older characters: advanced skills that already hold points count as unlocked
@@ -92,12 +99,10 @@ export function pruneLocked(ch, rules) {
export function kitItems(ch, rules) {
const items = Object.fromEntries(rules.equipment.map((e) => [e.id, e]))
const k = ch.kit
const out = []
;[['Weapon', k.weapon], ['Armor', k.armor], ['Tool', k.tool]].forEach(([label, id]) => { if (items[id]) out.push({ label, item: items[id], qty: 1 }) })
k.resources.forEach((id) => { if (items[id]) out.push({ label: 'Resource', item: items[id], qty: 1 }) })
Object.entries(k.extras).forEach(([id, qty]) => { if (items[id] && qty > 0) out.push({ label: 'Extra', item: items[id], qty }) })
return out
const cats = Object.fromEntries((rules.equipment_categories || []).map((c) => [c.id, c.name]))
return Object.entries(ch.kit.extras || {})
.filter(([id, qty]) => items[id] && qty > 0)
.map(([id, qty]) => ({ label: cats[items[id].cat] || '', item: items[id], qty }))
}
export function compute(ch, rules) {
+16
View File
@@ -0,0 +1,16 @@
// Characters and presets made before accounts existed were protected by secret keys in this browser.
// After login they are handed over to the account once; the old keys are then removed.
export async function claimLegacy() {
const post = (kind, id, token) =>
fetch(`/api/claim/${kind}/${id}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token }) }).catch(() => null)
try {
const chars = JSON.parse(localStorage.getItem('aw_chars')) || []
for (const c of chars) if (c?.id && c?.token) await post('characters', c.id, c.token)
localStorage.removeItem('aw_chars')
} catch { /* nothing to claim */ }
try {
const presets = JSON.parse(localStorage.getItem('aw_presets')) || {}
for (const [id, token] of Object.entries(presets)) await post('presets', id, token)
localStorage.removeItem('aw_presets')
} catch { /* nothing to claim */ }
}
+9
View File
@@ -3,4 +3,13 @@ import { createRoot } from 'react-dom/client'
import App from './App.jsx'
import './styles.css'
// A 401 from any data call means the session ended: tell the app so it shows the login page.
const realFetch = window.fetch.bind(window)
window.fetch = async (...args) => {
const res = await realFetch(...args)
const url = String(typeof args[0] === 'string' ? args[0] : args[0]?.url || '')
if (res.status === 401 && url.startsWith('/api/') && !url.startsWith('/api/auth/login')) window.dispatchEvent(new Event('aw-unauthorized'))
return res
}
createRoot(document.getElementById('root')).render(<App />)
+2 -3
View File
@@ -8,7 +8,7 @@ export function sheetModel(ch, baseRules) {
const cls = rules.classes.find((k) => k.id === ch.class_id)
const sub = cls?.subclasses.find((x) => x.id === ch.subclass_id)
const preset = ch.preset === 'custom' ? 'Custom' : baseRules.presets?.find((p) => p.id === ch.preset)?.name || 'Custom'
const training = (s) => (s.pips === 2 ? 'Expertise' : s.pips === 1 ? 'Proficient' : s.type === 'advanced' && s.unlocked ? 'Unlocked' : '')
const training = (s) => (s.pips === 2 ? 'Expertise' : s.pips === 1 ? 'Proficient' : !s.usable ? 'Locked' : s.type === 'advanced' ? 'Unlocked' : '')
return {
c,
head: [
@@ -19,8 +19,7 @@ export function sheetModel(ch, baseRules) {
],
attrs: rules.attributes.map((a) => ({ k: a.id, label: a.name, val: c.attrs[a.id], extra: `Mod ${c.mods[a.id]}` })),
derived: rules.derived.map((d) => ({ k: d.id, label: d.name, val: c.derived[d.id] })),
skills: c.skills.filter((s) => s.points || s.prof || (s.unlocked && s.type === 'advanced'))
.map((s) => ({ k: s.id, label: s.name, val: s.total, extra: training(s) })),
skills: c.skills.map((s) => ({ k: s.id, label: s.name, val: s.total, extra: training(s), dim: !s.usable })),
talents: rules.talents.filter((t) => ch.talents.includes(t.id)).map((t) => ({ k: t.id, label: t.name, desc: t.desc })),
augments: rules.augments.filter((a) => ch.augments.includes(a.id)).map((a) => ({ k: a.id, label: a.name, desc: `${a.kind === 'implant' ? 'Implant' : 'Mutation'} · cost ${a.cost}${a.desc ? ` · ${a.desc}` : ''}` })),
gear: kitItems(ch, rules).map((g) => ({ k: g.item.id, label: g.item.name, val: g.qty, desc: [g.item.stat, `${g.item.kg} kg`, g.item.note].filter(Boolean).join(' · ') })),
+15
View File
@@ -147,3 +147,18 @@ ul.desclist { list-style: none; padding: 0; margin: 0 0 .5rem; display: grid; ga
main { max-width: 1280px; }
@media (max-width: 1100px) { .creator-body { grid-template-columns: minmax(0, 1fr) 360px; } }
@media (max-width: 800px) { .creator-body { grid-template-columns: 1fr; } }
tr.dim td { opacity: .55; }
.skillcols { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 0 2rem; }
.skillgroup h5 { margin: .8em 0 .2em; color: var(--diesium); font-size: .9rem; }
.loginbox { margin-top: 3rem; }
.loginbox form button[type="submit"] { margin-top: .5rem; }
.sharepanel { margin-top: .8rem; padding-top: .6rem; border-top: 1px solid var(--line); }
.sharepanel form input { flex: 1; }
.chip button.linklike { color: inherit; text-decoration: none; margin-left: .2rem; }
.top nav a.navchar { max-width: 22ch; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.card.tile { display: flex; flex-direction: column; gap: .3rem; }
.card.tile.on { border-color: var(--noxium); box-shadow: inset 0 0 0 1px var(--noxium); background: var(--panel2); }
.tilestepper { margin-top: auto; padding-top: .4rem; justify-content: flex-end; }