Advanced skills must be unlocked with the pool cost. Only a profession's own proficiency still unlocks its skill for free. Proficiency picks need an unlocked skill, locking a skill returns its picks, old characters that used picks to unlock a skill get it unlocked automatically. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
520 lines
28 KiB
React
520 lines
28 KiB
React
import { useEffect, useMemo, useRef, useState } from 'react'
|
||
import { applyPreset, applyProfession, compute, effectiveRules, emptyCharacter, kitItems, normalize, resetStep, setProfessionPicks } from './calc.js'
|
||
import SheetPanel from './SheetPanel.jsx'
|
||
import Tip from './Tip.jsx'
|
||
import { explainAttr, explainAttrPool, explainCapacity, explainCarry, explainDerived, explainProfPool, explainSkill, explainSkillPool } from './explain.js'
|
||
|
||
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(key) {
|
||
try { return JSON.parse(localStorage.getItem(key)) } catch { return null }
|
||
}
|
||
|
||
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(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(DRAFT)
|
||
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(DRAFT)?.saved || null) // {id}
|
||
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
|
||
const ctx = { ch, rules, c }
|
||
|
||
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 */ }
|
||
}, [ch, committed, saved])
|
||
|
||
// 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(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
|
||
|
||
// 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('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 = (
|
||
<>
|
||
<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' : ''}`}><Tip text={explainAttrPool(ctx)}>Points left</Tip>: <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><Tip icon text={explainAttr(a, ctx)} /><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>
|
||
<Tip text={explainAttr(a, ctx)}><span className="mod">Mod {c.mods[a.id]}</span></Tip>
|
||
</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 proficiency in them). The pool size comes from the rules preset.
|
||
</p>
|
||
<div className="pools">
|
||
<div className={`pool ${c.skillLeft < 0 ? 'over' : ''}`}>
|
||
<Tip text={[...explainSkillPool(ctx), 'The pool size comes from the rules preset (Concept page).']}>Points left</Tip>: <strong>{c.skillLeft}</strong> / {cr.skill_points_default}
|
||
</div>
|
||
<div className={`pool ${c.profLeft < 0 ? 'over' : ''}`}>
|
||
<Tip text={explainProfPool(ctx)}>Proficiency picks left</Tip>: <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 picks only work on skills you can use, so unlock an advanced skill first. 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 />}<Tip icon text={explainSkill(s, ctx)} />
|
||
<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 || !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 ? '◆' : '◇'}</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: you get ${c.unlockCost} points back, its points and proficiency picks are returned`}
|
||
onClick={() => 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</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>
|
||
<Tip text={explainSkill(s, ctx)}><span className="mod">{s.total}</span></Tip><span className="small muted" title="Most points you can add"> max {s.max}</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 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 = (
|
||
<>
|
||
<h2>Equipment</h2>
|
||
<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' : ''}`}><Tip text={explainCarry(ctx)}>Carried</Tip>: <strong>{c.weight}</strong> / {c.carryMax} kg (Strength Mod x {cr.carry_per_strength_mod}){overWeight && ' – overloaded'}</div>
|
||
{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>
|
||
</>
|
||
)
|
||
|
||
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 = (
|
||
<>
|
||
<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><Tip text={explainAttr(a, ctx)}>{a.name}</Tip></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><Tip text={explainDerived(d, ctx)}>{d.name}</Tip></td><td>{c.derived[d.id]}</td></tr>)}</tbody></table>
|
||
</div>
|
||
<div>
|
||
<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><Tip text={explainCapacity(ctx)}>Mutations / implants ({c.capacityUsed}/{c.capacityMax})</Tip></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><Tip text={explainCarry(ctx)}>Equipment ({c.weight}/{c.carryMax} kg)</Tip></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>
|
||
<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><Tip text={explainSkill(s, ctx)}>{s.name}</Tip>{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>
|
||
<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>
|
||
)
|
||
}
|