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:
+66
-74
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user