import PortraitEditor from './Portrait.jsx' 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 PaperSheet from './PaperSheet.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 = () => Placeholder 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/ (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

{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.

set({ portrait: p })} />
{sub &&

{sub.desc}

}