first commit

This commit is contained in:
DuOtto
2026-10-19 22:27:05 +02:00
commit afbdc4b139
28 changed files with 4242 additions and 0 deletions
+523
View File
@@ -0,0 +1,523 @@
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 }
}
export default function Creator({ rules: baseRules, loadRequest, clearLoad }) {
const freshCharacter = () => applyPreset(emptyCharacter(baseRules), baseRules, baseRules.default_preset || 'standard')
const [ch, setCh] = useState(() => {
const d = readDraft()
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 src = d?.committed || d?.ch
return src ? normalize(src, baseRules) : freshCharacter()
})
const setBoth = (x) => { setCh(x); setCommitted(x) }
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 [step, setStep] = useState(0)
const [msg, setMsg] = useState('')
const fileRef = useRef(null)
// Rules for this character: base rules plus the creation numbers of the chosen preset
const rules = useMemo(() => effectiveRules(baseRules, ch), [baseRules, ch.creation]) // eslint-disable-line
const c = useMemo(() => compute(ch, rules), [ch, rules])
const prof = rules.professions.find((p) => p.id === ch.profession)
const cls = rules.classes.find((k) => k.id === ch.class_id)
const sub = cls?.subclasses.find((x) => x.id === ch.subclass_id)
const set = (patch) => setCh((x) => ({ ...x, ...patch }))
const cr = rules.creation
// Keep the draft in this browser
useEffect(() => {
try { localStorage.setItem(DRAFT, JSON.stringify({ ch, committed, saved })) } catch { /* ignore */ }
}, [ch, committed, saved])
// Load a character picked in "My characters"
useEffect(() => {
if (!loadRequest) 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) })
.catch(() => setMsg('Could not load the character.'))
.finally(clearLoad)
}, [loadRequest]) // eslint-disable-line
// Direct link #creator/<id> (read only; saving creates a copy)
useEffect(() => {
const id = window.location.hash.split('/')[1]
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.'))
}, []) // eslint-disable-line
// Moving between pages: pending edits are applied first (Next applies silently, other jumps ask).
const go = (i, silent) => {
if (i === step) return
if (pending) {
if (!silent && !window.confirm('You have changes that are not applied yet.\nOK = apply them and continue, Cancel = stay on this page.')) return
apply()
}
setStep(Math.max(0, Math.min(STEPS.length - 1, i)))
}
const flash = (t) => { setMsg(t); setTimeout(() => setMsg(''), 4000) }
const resetAll = () => {
if (!window.confirm('Discard all input and start over?')) return
setBoth({ ...emptyCharacter(rules), preset: ch.preset, creation: ch.creation }); setSaved(null); setStep(0)
}
const resetThisStep = () => {
if (!window.confirm(`Reset only the "${STEPS[step]}" page? Everything else is kept.`)) return
setCh((x) => resetStep(x, rules, step))
}
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) })
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)
flash('Saved. The share link is shown below.')
}
} catch { flash('Saving failed.') }
}
const fileName = (ext) => `${(ch.name || 'Character').replace(/[^\w-]+/g, '_')}.${ext}`
const download = async (fmt) => {
const r = await fetch(`/api/export/${fmt}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(ch) })
if (!r.ok) return flash('Export failed.')
const url = URL.createObjectURL(await r.blob())
const a = document.createElement('a'); a.href = url; a.download = fileName(fmt); a.click(); URL.revokeObjectURL(url)
}
const exportJson = () => {
const url = URL.createObjectURL(new Blob([JSON.stringify(ch, null, 2)], { type: 'application/json' }))
const a = document.createElement('a'); a.href = url; a.download = fileName('json'); a.click(); URL.revokeObjectURL(url)
}
const importJson = (e) => {
const f = e.target.files[0]; if (!f) return
f.text().then((t) => { setBoth(normalize(JSON.parse(t), baseRules)); setSaved(null); flash('Imported.') }).catch(() => flash('Invalid file.'))
e.target.value = ''
}
// Click on pip k (1 = Proficient, 2 = Expertise) of a skill: sets the skill's total pips to k, or one lower if already there.
const clickPip = (s, k) => {
const target = s.pips >= k ? k - 1 : k
const wantFree = Math.max(0, target - s.profPip)
if (wantFree - s.freePips > c.profLeft) return flash('No proficiency picks left.')
set({ proficiency_picks: { ...ch.proficiency_picks, [s.id]: wantFree } })
}
const toggle = (key, id, max) => {
const cur = ch[key]
if (cur.includes(id)) return set({ [key]: cur.filter((x) => x !== id) })
if (max && cur.length >= max) return flash(`At most ${max} can be chosen.`)
set({ [key]: [...cur, id] })
}
// ---------- Steps ----------
const stepConcept = (
<>
<h2>Concept</h2>
<div className="card presetbox">
<label>Rules preset
<select value={ch.preset} onChange={(e) => e.target.value !== 'custom' && setCh((x) => applyPreset(x, baseRules, e.target.value))}>
{(baseRules.presets || []).map((p) => <option key={p.id} value={p.id}>{p.name}</option>)}
{ch.preset === 'custom' && <option value="custom">Custom</option>}
</select>
</label>
<p className="small muted">{ch.preset === 'custom' ? 'Custom values (from an older version of this character).' : baseRules.presets?.find((p) => p.id === ch.preset)?.desc}</p>
{(() => {
const diff = (baseRules.tunables || []).filter((x) => cr[x.key] !== baseRules.creation[x.key])
return diff.length > 0 && <p className="small muted">Differs from Standard: {diff.map((x) => `${x.label} ${cr[x.key]}`).join(' · ')}</p>
})()}
<p className="small">Create or edit presets on the <a href="#presets">Presets</a> page.</p>
</div>
<div className="grid2">
<label>Character name<input value={ch.name} onChange={(e) => set({ name: e.target.value })} /></label>
<label>Player<input value={ch.player} onChange={(e) => set({ player: e.target.value })} /></label>
</div>
<div className="grid2">
<label>Class (optional)
<select value={ch.class_id} onChange={(e) => set({ class_id: e.target.value, subclass_id: '' })}>
<option value="">–</option>
{rules.classes.map((k) => <option key={k.id} value={k.id}>{k.name}</option>)}
</select>
</label>
<label>Sub-class
<select value={ch.subclass_id} disabled={!ch.class_id} onChange={(e) => set({ subclass_id: e.target.value })}>
<option value="">–</option>
{(cls?.subclasses || []).map((x) => <option key={x.id} value={x.id}>{x.name}</option>)}
</select>
</label>
</div>
{sub && <p className="small muted">{sub.desc}</p>}
<label>Concept in one sentence<input value={ch.concept} onChange={(e) => set({ concept: e.target.value })} placeholder="e.g. Former militia fighter searching for his family" /></label>
<label>Background<textarea rows="5" value={ch.background} onChange={(e) => set({ background: e.target.value })} /></label>
</>
)
const stepAttrs = (
<>
<h2>Attributes</h2>
<p className="muted">All attributes start at {cr.attr_start}. Distribute {cr.attr_points} points (at most +{cr.attr_max_raise} per attribute, cap {cr.attr_max}).</p>
<div className={`pool ${c.attrLeft < 0 ? 'over' : ''}`}>Points left: <strong>{c.attrLeft}</strong> / {cr.attr_points}</div>
<div className="attrs">
{rules.attributes.map((a) => {
const v = ch.attributes[a.id]
const cap = Math.min(cr.attr_max, cr.attr_start + cr.attr_max_raise)
const setV = (n) => set({ attributes: { ...ch.attributes, [a.id]: n } })
return (
<div key={a.id} className="attr card">
<div><strong>{a.name}</strong> <span className="muted small">{a.short}</span><div className="small muted">{a.desc}</div></div>
<div className="stepper">
<button onClick={() => setV(Math.max(v - 5, cr.attr_start))} aria-label={`${a.name} minus 5`}>−5</button>
<button onClick={() => setV(Math.max(v - 1, cr.attr_start))} aria-label={`${a.name} minus 1`}>−</button>
<span className="val">{v}</span>
<button onClick={() => setV(Math.min(v + 1, cap))} disabled={c.attrLeft <= 0} aria-label={`${a.name} plus 1`}>+</button>
<button onClick={() => setV(Math.min(v + Math.min(5, c.attrLeft), cap))} disabled={c.attrLeft <= 0} aria-label={`${a.name} plus 5`}>+5</button>
<span className="mod" title="Modifier">Mod {c.mods[a.id]}</span>
</div>
</div>
)
})}
</div>
</>
)
const stepProfession = (
<>
<h2>Profession</h2>
<p className="muted">Your profession gives +{cr.profession_attr_bonus} to one attribute and proficiencies (+{cr.proficiency_bonus} to the skill).</p>
<div className="cards">
{rules.professions.map((p) => (
<button key={p.id} className={`card choice ${ch.profession === p.id ? 'on' : ''}`} onClick={() => setCh(applyProfession(ch, rules, p.id))}>
<strong>{p.name}</strong> {p.placeholder && <Badge />}
<span className="small">{p.desc}</span>
{p.note && <span className="small muted">{p.note}</span>}
</button>
))}
</div>
{prof && (
<>
<label>+{cr.profession_attr_bonus} to:
<select value={ch.profession_bonus_attr} onChange={(e) => set({ profession_bonus_attr: e.target.value })}>
{prof.bonus_attrs.length !== 1 && <option value="">Please choose</option>}
{rules.attributes.filter((a) => !prof.bonus_attrs.length || prof.bonus_attrs.includes(a.id)).map((a) => <option key={a.id} value={a.id}>{a.name}</option>)}
</select>
</label>
{prof.picks > 0 && (
<>
<h3>Choose {prof.picks} basic skills ({ch.profession_picks.length}/{prof.picks})</h3>
<div className="chips">
{rules.skills.filter((s) => s.type === 'basic').map((s) => {
const on = ch.profession_picks.includes(s.id)
const next = on ? ch.profession_picks.filter((x) => x !== s.id) : [...ch.profession_picks, s.id]
return (
<button key={s.id} className={`chip ${on ? 'on' : ''}`} disabled={!on && ch.profession_picks.length >= prof.picks}
onClick={() => setCh(setProfessionPicks(ch, rules, next))}>{s.name}</button>
)
})}
</div>
</>
)}
</>
)}
</>
)
const stepSkills = (
<>
<h2>Skills</h2>
<p className="muted">
At most half the attribute per skill. Advanced skills must be unlocked first for {c.unlockCost} points from the pool
(free if your profession grants them). The pool size comes from the rules preset.
</p>
<div className="pools">
<div className={`pool ${c.skillLeft < 0 ? 'over' : ''}`} title="Change the pool size in the rules preset on the Concept page">
Points left: <strong>{c.skillLeft}</strong> / {cr.skill_points_default}
</div>
<div className={`pool ${c.profLeft < 0 ? 'over' : ''}`} title="Two picks on the same skill make it Expertise">
Proficiency picks left: <strong>{c.profLeft}</strong> / {c.profAllowed}
</div>
</div>
<p className="small muted">
Proficiency adds +{cr.proficiency_bonus}. Two picks on the same skill make it Expertise (+{cr.expertise_bonus}); a profession proficiency counts as one of them.
Proficiency in an advanced skill unlocks it for free. The talent "Versatile Training" grants 2 more picks.
</p>
{rules.skill_groups.map((g) => (
<div key={g.id}>
<h3>{g.name}</h3>
<div className="skills">
{c.skills.filter((s) => s.group === g.id).map((s) => {
const setPts = (n) => set({ skill_points: { ...ch.skill_points, [s.id]: Math.max(0, Math.min(n, s.max)) } })
return (
<div key={s.id} className={`skill ${s.usable ? '' : 'locked'}`} title={s.desc}>
<div>
<strong>{s.name}</strong> {s.placeholder && <Badge />}
<div className="small muted">
{s.attrs.map((id) => rules.attributes.find((a) => a.id === id).short).join('/')} · {s.type === 'basic' ? 'Basic' : s.usable ? 'Advanced (unlocked)' : 'Advanced (locked)'}
{s.pips === 1 && ' · Proficient'}{s.pips === 2 && ' · Expertise'}
</div>
<div className="pips" title={`Proficiency: ${s.pips === 2 ? 'Expertise' : s.pips === 1 ? 'Proficient' : 'none'}`}>
<span className="small muted">Training</span>
{[1, 2].map((k) => (
<button key={k} className={`pip ${s.pips >= k ? 'on' : ''} ${s.profPip >= k ? 'from-prof' : ''}`}
disabled={s.profPip >= k} onClick={() => clickPip(s, k)}
title={s.profPip >= k ? 'From your profession' : k === 2 ? 'Expertise (uses 2 picks in total)' : 'Proficiency'}
aria-label={`${s.name} proficiency ${k}`}>{s.pips >= k ? '◆' : '◇'}</button>
))}
</div>
</div>
{!s.usable ? (
<button className="unlock" onClick={() => set({ unlocked: [...ch.unlocked, s.id] })} disabled={c.skillLeft < c.unlockCost}
title={c.skillLeft < c.unlockCost ? 'Not enough points left' : 'Unlock this advanced skill'}>
Unlock ({c.unlockCost} pts)
</button>
) : (
<div className="stepper">
{s.type === 'advanced' && !s.free && (
<button className="ghost small-btn" title={`Lock again and get ${c.unlockCost} points back`}
onClick={() => setCh((x) => ({ ...x, unlocked: x.unlocked.filter((id) => id !== s.id), skill_points: { ...x.skill_points, [s.id]: 0 } }))}>Lock</button>
)}
<button onClick={() => setPts(s.points - 1)} aria-label={`${s.name} minus`}>−</button>
<span className="val">{s.points}</span>
<button onClick={() => setPts(s.points + 1)} disabled={s.points >= s.max || c.skillLeft <= 0} aria-label={`${s.name} plus`}>+</button>
<span className="mod" title="Target number">{s.total}</span>
</div>
)}
</div>
)
})}
</div>
</div>
))}
</>
)
const stepTalents = (
<>
<h2>Talents</h2>
<p className="muted">Choose {cr.talents_min}-{cr.talents_max} starting talents. Chosen: {ch.talents.length}</p>
{rules.talent_categories.map((cat) => {
const ts = rules.talents.filter((t) => t.category === cat.id)
if (!ts.length) return null
return (
<div key={cat.id}><h3>{cat.name}</h3>
<div className="cards">{ts.map((t) => (
<button key={t.id} className={`card choice ${ch.talents.includes(t.id) ? 'on' : ''}`} onClick={() => toggle('talents', t.id, cr.talents_max)}>
<strong>{t.name}</strong> {t.placeholder && <Badge />}
<span className="small">{t.desc}</span>
</button>
))}</div>
</div>
)
})}
</>
)
const over = c.capacityUsed > c.capacityMax
const stepAugments = (
<>
<h2>Mutations and implants</h2>
<p className="muted">Optional. Both share the Augmentation Capacity. Exceeding it risks corruption or rejection.</p>
<div className={`pool ${over ? 'over' : ''}`}>Capacity: <strong>{c.capacityUsed}</strong> / {c.capacityMax}{over && ' – exceeded, talk to your GM'}</div>
{['mutation', 'implant'].map((kind) => (
<div key={kind}><h3>{kind === 'mutation' ? 'Mutations' : 'Implants'}</h3>
<div className="cards">{rules.augments.filter((a) => a.kind === kind).map((a) => (
<button key={a.id} className={`card choice ${ch.augments.includes(a.id) ? 'on' : ''}`} onClick={() => toggle('augments', a.id)}>
<strong>{a.name}</strong> {a.placeholder && <Badge />}
<span className="small muted">{SOURCE[a.source] ? `${SOURCE[a.source]} · ` : ''}Cost {a.cost}</span>
<span className="small">{a.desc}</span>
</button>
))}</div>
</div>
))}
</>
)
// --- 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 })
}
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>
<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>
))}
<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>
</>
)
const warnings = []
if (c.attrLeft < 0) warnings.push('Too many attribute points spent.')
if (c.attrLeft > 0) warnings.push(`${c.attrLeft} attribute points left.`)
if (!ch.profession) warnings.push('No profession chosen.')
else {
if (!ch.profession_bonus_attr) warnings.push('No attribute chosen for the profession bonus.')
if ((prof?.picks || 0) > ch.profession_picks.length) warnings.push('Profession proficiencies not fully chosen.')
}
if (c.skillLeft < 0) warnings.push('More skill points spent than the pool allows.')
if (c.profLeft < 0) warnings.push('More proficiency picks used than you have.')
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)
const stepSummary = (
<>
<h2>Summary</h2>
{warnings.length > 0 && <ul className="warn">{warnings.map((w) => <li key={w}>{w}</li>)}</ul>}
<div className="sheet card">
<h3 className="sheet-name">{ch.name || 'Nameless'}</h3>
<p className="muted">{ch.player && `Player: ${ch.player} · `}Profession: {prof?.name || '–'}{cls && ` · Class: ${[cls.name, sub?.name].filter(Boolean).join(' / ')}`}</p>
<div className="sheet-cols">
<div>
<h4>Attributes</h4>
<table><tbody>{rules.attributes.map((a) => <tr key={a.id}><td>{a.name}</td><td>{c.attrs[a.id]}</td><td className="muted">Mod {c.mods[a.id]}</td></tr>)}</tbody></table>
<h4>Derived stats</h4>
<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>}
<h4>Mutations / implants ({c.capacityUsed}/{c.capacityMax})</h4>
<ul className="desclist">{rules.augments.filter((a) => ch.augments.includes(a.id)).map((a) => <li key={a.id}><strong>{a.name}</strong> <span className="muted small">cost {a.cost}</span><div className="rowdesc">{a.desc}</div></li>)}</ul>
{!ch.augments.length && <p>–</p>}
<h4>Equipment ({c.weight}/{c.carryMax} kg)</h4>
<ul className="desclist">{gearLines.map((g) => <li key={g.item.id}><strong>{g.item.name}{g.qty > 1 ? ` x${g.qty}` : ''}</strong><div className="rowdesc">{[g.item.stat, `${Math.round(g.item.kg * g.qty * 10) / 10} kg`, g.item.note].filter(Boolean).join(' · ')}</div></li>)}</ul>
{!gearLines.length && <p>–</p>}
{ch.gear.other && <p className="small"><strong>Other:</strong> {ch.gear.other}</p>}
</div>
</div>
</div>
<div className="actions wrap">
<button onClick={() => download('pdf')}>Download as PDF</button>
<button onClick={() => download('docx')}>Download as Word</button>
<button className="ghost" onClick={save}>{saved ? 'Save changes' : 'Save on server'}</button>
<button className="ghost" onClick={exportJson}>JSON backup</button>
<button className="ghost" onClick={() => fileRef.current.click()}>Import JSON</button>
<input ref={fileRef} type="file" accept="application/json" hidden onChange={importJson} />
</div>
{saved && (
<p className="small muted">Share link (view only): <code>{`${window.location.origin}/#creator/${saved.id}`}</code></p>
)}
</>
)
const views = [stepConcept, stepAttrs, stepProfession, stepSkills, stepTalents, stepAugments, stepGear, stepSummary]
return (
<div className="page creator">
<p className="small muted presetchip">Rules: <button className="linklike" onClick={() => go(0)} title="Change on the Concept page">{ch.preset === 'custom' ? 'Custom' : baseRules.presets?.find((p) => p.id === ch.preset)?.name}</button></p>
<ol className="steps">
{STEPS.map((s, i) => (
<li key={s}><button className={`${i === step ? 'on' : ''} ${i < step ? 'done' : ''}`} onClick={() => go(i)}>
<span className="n">{i + 1}</span><span className="t">{s}</span></button></li>
))}
</ol>
<div className={`creator-body ${step === STEPS.length - 1 ? 'wide' : ''}`}>
<section className="step">
{views[step]}
{msg && <p className="toast" role="status">{msg}</p>}
<div className="nav">
<button className="ghost" onClick={() => go(step - 1)} disabled={step === 0}>Back</button>
<span className="resets">
{step < STEPS.length - 1 && <button className="ghost danger" onClick={resetThisStep}>Reset this page</button>}
<button className="ghost danger" onClick={resetAll}>Start over</button>
</span>
<button onClick={() => go(step + 1, true)} disabled={step === STEPS.length - 1}>{pending ? 'Apply & next' : 'Next'}</button>
</div>
</section>
{step < STEPS.length - 1 && <SheetPanel ch={ch} committed={committed} baseRules={baseRules} onApply={apply} onDiscard={discard} />}
</div>
</div>
)
}