(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('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).
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' }, body: JSON.stringify(ch) })
if (!r.ok) throw new Error()
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 } = await r.json()
setSaved({ id })
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) => {
if (!s.usable) return flash('Unlock this advanced skill first.')
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 = (
<>
Concept
Rules preset
e.target.value !== 'custom' && setCh((x) => applyPreset(x, baseRules, e.target.value))}>
{(baseRules.presets || []).map((p) => {p.name} )}
{ch.preset === 'custom' && Custom }
{ch.preset === 'custom' ? 'Custom values (from an older version of this character).' : baseRules.presets?.find((p) => p.id === ch.preset)?.desc}
{(() => {
const diff = (baseRules.tunables || []).filter((x) => cr[x.key] !== baseRules.creation[x.key])
return diff.length > 0 &&
Differs from Standard: {diff.map((x) => `${x.label} ${cr[x.key]}`).join(' · ')}
})()}
Create or edit presets on the Presets page.
Character name set({ name: e.target.value })} />
Player set({ player: e.target.value })} />
Class (optional)
set({ class_id: e.target.value, subclass_id: '' })}>
–
{rules.classes.map((k) => {k.name} )}
Sub-class
set({ subclass_id: e.target.value })}>
–
{(cls?.subclasses || []).map((x) => {x.name} )}
{sub && {sub.desc}
}
Concept in one sentence set({ concept: e.target.value })} placeholder="e.g. Former militia fighter searching for his family" />
Background
>
)
const stepAttrs = (
<>
Attributes
All attributes start at {cr.attr_start}. Distribute {cr.attr_points} points (at most +{cr.attr_max_raise} per attribute, cap {cr.attr_max}).
Points left : {c.attrLeft} / {cr.attr_points}
{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 (
{a.name} {a.short} {a.desc}
setV(Math.max(v - 5, cr.attr_start))} aria-label={`${a.name} minus 5`}>−5
setV(Math.max(v - 1, cr.attr_start))} aria-label={`${a.name} minus 1`}>−
{v}
setV(Math.min(v + 1, cap))} disabled={c.attrLeft <= 0} aria-label={`${a.name} plus 1`}>+
setV(Math.min(v + Math.min(5, c.attrLeft), cap))} disabled={c.attrLeft <= 0} aria-label={`${a.name} plus 5`}>+5
Mod {c.mods[a.id]}
)
})}
>
)
const stepProfession = (
<>
Profession
Your profession gives +{cr.profession_attr_bonus} to one attribute and proficiencies (+{cr.proficiency_bonus} to the skill).
{rules.professions.map((p) => (
setCh(applyProfession(ch, rules, p.id))}>
{p.name} {p.placeholder && }
{p.desc}
{p.note && {p.note} }
))}
{prof && (
<>
+{cr.profession_attr_bonus} to:
set({ profession_bonus_attr: e.target.value })}>
{prof.bonus_attrs.length !== 1 && Please choose }
{rules.attributes.filter((a) => !prof.bonus_attrs.length || prof.bonus_attrs.includes(a.id)).map((a) => {a.name} )}
{prof.picks > 0 && (
<>
Choose {prof.picks} basic skills ({ch.profession_picks.length}/{prof.picks})
{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 (
= prof.picks}
onClick={() => setCh(setProfessionPicks(ch, rules, next))}>{s.name}
)
})}
>
)}
>
)}
>
)
const stepSkills = (
<>
Skills
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 proficiency in them). The pool size comes from the rules preset.
Points left : {c.skillLeft} / {cr.skill_points_default}
Proficiency picks left : {c.profLeft} / {c.profAllowed}
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 picks only work on skills you can use, so unlock an advanced skill first. The talent "Versatile Training" grants 2 more picks.
{rules.skill_groups.map((g) => (
{g.name}
{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 (
{s.name} {s.placeholder &&
}
{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'}
Training
{[1, 2].map((k) => (
= k ? 'on' : ''} ${s.profPip >= k ? 'from-prof' : ''}`}
disabled={s.profPip >= k || !s.usable} onClick={() => clickPip(s, k)}
title={!s.usable ? 'Unlock the skill first' : s.profPip >= k ? 'From your profession' : k === 2 ? 'Expertise (uses 2 picks in total)' : 'Proficiency'}
aria-label={`${s.name} proficiency ${k}`}>{s.pips >= k ? '◆' : '◇'}
))}
{!s.usable ? (
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)
) : (
{s.type === 'advanced' && !s.free && (
setCh((x) => ({ ...x, unlocked: x.unlocked.filter((id) => id !== s.id), skill_points: { ...x.skill_points, [s.id]: 0 }, proficiency_picks: { ...x.proficiency_picks, [s.id]: 0 } }))}>Lock
)}
setPts(s.points - 5)} disabled={s.points <= 0} aria-label={`${s.name} minus 5`}>−5
setPts(s.points - 1)} disabled={s.points <= 0} aria-label={`${s.name} minus`}>−
{s.points}
setPts(Math.min(s.points + 1, s.max, s.points + c.skillLeft))} disabled={s.points >= s.max || c.skillLeft <= 0} aria-label={`${s.name} plus`}>+
setPts(Math.min(s.points + 5, s.max, s.points + c.skillLeft))} disabled={s.points >= s.max || c.skillLeft <= 0} aria-label={`${s.name} plus 5`}>+5
{s.total} max {s.max}
)}
)
})}
))}
>
)
const stepTalents = (
<>
Talents
Choose {cr.talents_min}-{cr.talents_max} starting talents. Chosen: {ch.talents.length}
{rules.talent_categories.map((cat) => {
const ts = rules.talents.filter((t) => t.category === cat.id)
if (!ts.length) return null
return (
{cat.name}
{ts.map((t) => (
toggle('talents', t.id, cr.talents_max)}>
{t.name} {t.placeholder && }
{t.desc}
))}
)
})}
>
)
const over = c.capacityUsed > c.capacityMax
const stepAugments = (
<>
Mutations and implants
Optional. Both share the Augmentation Capacity. Exceeding it risks corruption or rejection.
Capacity: {c.capacityUsed} / {c.capacityMax}{over && ' – exceeded, talk to your GM'}
{['mutation', 'implant'].map((kind) => (
{kind === 'mutation' ? 'Mutations' : 'Implants'}
{rules.augments.filter((a) => a.kind === kind).map((a) => (
toggle('augments', a.id)}>
{a.name} {a.placeholder && }
{SOURCE[a.source] ? `${SOURCE[a.source]} · ` : ''}Cost {a.cost}
{a.desc}
))}
))}
>
)
// --- Equipment ---
const items = rules.equipment
const catName = (id) => rules.equipment_categories.find((x) => x.id === id).name
const setExtra = (id, qty) => {
const extras = { ...ch.kit.extras }
if (qty <= 0) delete extras[id]; else extras[id] = qty
set({ kit: { ...ch.kit, extras } })
}
const overWeight = c.weight > c.carryMax
const stepGear = (
<>
Equipment
Pick everything your character carries. Ask your GM what is allowed. Use + and − on a tile to change the amount.
Carried : {c.weight} / {c.carryMax} kg (Strength Mod x {cr.carry_per_strength_mod}){overWeight && ' – overloaded'}
{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 (
{cat.name} {n > 0 && ({n} chosen) }
{list.map((e) => {
const qty = ch.kit.extras[e.id] || 0
return (
0 ? 'on' : ''}`}>
{e.name}
{e.stat}{e.tier ? ` (${e.tier})` : ''} · {e.kg} kg
{e.note &&
{e.note} }
setExtra(e.id, qty - 1)} disabled={qty === 0} aria-label={`${e.name} minus`}>−
{qty}
setExtra(e.id, qty + 1)} aria-label={`${e.name} plus`}>+
)
})}
)
})}
Other items (free text)
Notes
>
)
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 (overWeight) warnings.push('Carrying more than the carry capacity.')
const gearLines = kitItems(ch, rules)
const stepSummary = (
<>
Summary
{warnings.length > 0 && {warnings.map((w) => {w} )} }
download('pdf')}>Download as PDF
download('docx')}>Download as Word
{saved ? 'Save changes' : 'Save on server'}
JSON backup
fileRef.current.click()}>Import JSON
{saved && (
Share link (view only): {`${window.location.origin}/#creator/${saved.id}`}
)}
>
)
const views = [stepConcept, stepAttrs, stepProfession, stepSkills, stepTalents, stepAugments, stepGear, stepSummary]
return (
Rules: go(0)} title="Change on the Concept page">{ch.preset === 'custom' ? 'Custom' : baseRules.presets?.find((p) => p.id === ch.preset)?.name}
{STEPS.map((s, i) => (
go(i)}>
{i + 1} {s}
))}
{views[step]}
{msg && {msg}
}
go(step - 1)} disabled={step === 0}>Back
{step < STEPS.length - 1 && Reset this page }
Start over
go(step + 1, true)} disabled={step === STEPS.length - 1}>{pending ? 'Apply & next' : 'Next'}
{step < STEPS.length - 1 &&
}
)
}