first commit
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Arcane Wasteland Character Sheet</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+1698
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "aw-character-creator",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"vite": "^5.4.11"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import Creator from './Creator.jsx'
|
||||
import Rules from './Rules.jsx'
|
||||
import Characters from './Characters.jsx'
|
||||
import Presets from './Presets.jsx'
|
||||
|
||||
const useHash = () => {
|
||||
const [h, setH] = useState(window.location.hash.slice(1) || 'creator')
|
||||
useEffect(() => {
|
||||
const f = () => setH(window.location.hash.slice(1) || 'creator')
|
||||
window.addEventListener('hashchange', f)
|
||||
return () => window.removeEventListener('hashchange', f)
|
||||
}, [])
|
||||
return h
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const hash = useHash()
|
||||
const [rules, setRules] = useState(null)
|
||||
const [error, setError] = useState('')
|
||||
// Used to load a saved character into the creator
|
||||
const [loadRequest, setLoadRequest] = useState(null)
|
||||
|
||||
const loadRules = () => fetch('/api/rules').then((r) => r.json()).then(setRules).catch(() => setError('Could not load the rules.'))
|
||||
useEffect(() => { loadRules() }, [])
|
||||
|
||||
const page = hash.split('/')[0]
|
||||
const nav = [['creator', 'Create character'], ['rules', 'Rules'], ['presets', 'Presets'], ['characters', 'My characters']]
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="top">
|
||||
<a className="brand" href="#creator">
|
||||
<span className="brand-mark" aria-hidden="true" />
|
||||
<span>Arcane Wasteland</span>
|
||||
</a>
|
||||
<nav>
|
||||
{nav.map(([id, label]) => (
|
||||
<a key={id} href={`#${id}`} className={page === id ? 'active' : ''}>{label}</a>
|
||||
))}
|
||||
</nav>
|
||||
</header>
|
||||
<main>
|
||||
{error && <p className="warn">{error}</p>}
|
||||
{!rules && !error && <p className="muted">Loading rules …</p>}
|
||||
{rules && page === 'creator' && <Creator rules={rules} loadRequest={loadRequest} clearLoad={() => setLoadRequest(null)} />}
|
||||
{rules && page === 'rules' && <Rules rules={rules} section={hash.split('/')[1]} />}
|
||||
{rules && page === 'presets' && <Presets rules={rules} reload={loadRules} />}
|
||||
{rules && page === 'characters' && (
|
||||
<Characters onOpen={(entry) => { setLoadRequest(entry); window.location.hash = 'creator' }} />
|
||||
)}
|
||||
</main>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
const KEY = 'aw_chars'
|
||||
export const loadList = () => {
|
||||
try { return JSON.parse(localStorage.getItem(KEY)) || [] } catch { return [] }
|
||||
}
|
||||
export const saveList = (l) => {
|
||||
try { localStorage.setItem(KEY, JSON.stringify(l)) } catch { /* storage blocked */ }
|
||||
}
|
||||
export const rememberCharacter = (id, token, name) => {
|
||||
const l = loadList().filter((c) => c.id !== id)
|
||||
l.unshift({ id, token, name: name || 'Nameless', updated: Date.now() })
|
||||
saveList(l)
|
||||
}
|
||||
|
||||
export default function Characters({ onOpen }) {
|
||||
const [list, setList] = useState(loadList)
|
||||
|
||||
const remove = async (c) => {
|
||||
if (!window.confirm(`Really delete "${c.name}"?`)) return
|
||||
await fetch(`/api/characters/${c.id}`, { method: 'DELETE', headers: { 'X-Edit-Token': c.token } }).catch(() => {})
|
||||
const l = list.filter((x) => x.id !== c.id)
|
||||
saveList(l); setList(l)
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="page narrow">
|
||||
<h1>My characters</h1>
|
||||
<p className="muted">
|
||||
This list is stored in this browser. The characters themselves live on the server and can be shared by link
|
||||
(<code>#creator/<ID></code>), but only this browser holds the key needed to change them.
|
||||
</p>
|
||||
{list.length === 0 && <p className="card">Nothing saved yet. Create a character and click "Save".</p>}
|
||||
<ul className="charlist">
|
||||
{list.map((c) => (
|
||||
<li key={c.id} className="card row">
|
||||
<div>
|
||||
<strong>{c.name}</strong>
|
||||
<div className="muted small">ID {c.id} · {new Date(c.updated).toLocaleString('en-GB')}</div>
|
||||
</div>
|
||||
<div className="actions">
|
||||
<button onClick={() => onOpen(c)}>Open</button>
|
||||
<button className="ghost danger" onClick={() => remove(c)}>Delete</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
const KEY = 'aw_presets' // {presetId: editToken}, only for presets created in this browser
|
||||
const loadTokens = () => { try { return JSON.parse(localStorage.getItem(KEY)) || {} } catch { return {} } }
|
||||
const saveTokens = (t) => { try { localStorage.setItem(KEY, JSON.stringify(t)) } catch { /* storage blocked */ } }
|
||||
|
||||
const BLANK = { id: null, name: '', desc: '', creation: {} }
|
||||
|
||||
export default function Presets({ rules, reload }) {
|
||||
const [tokens, setTokens] = useState(loadTokens)
|
||||
const [sel, setSel] = useState(rules.default_preset || 'standard')
|
||||
const [draft, setDraft] = useState(null) // preset being edited/created, null = viewing
|
||||
const [msg, setMsg] = useState('')
|
||||
const presets = rules.presets || []
|
||||
const current = draft || presets.find((p) => p.id === sel) || presets[0]
|
||||
const editable = !!draft
|
||||
const own = !!current?.id && !!tokens[current.id]
|
||||
const base = rules.creation
|
||||
const groups = [...new Set((rules.tunables || []).map((x) => x.group))]
|
||||
const value = (k) => (current.creation?.[k] ?? base[k])
|
||||
|
||||
const flash = (t) => { setMsg(t); setTimeout(() => setMsg(''), 4000) }
|
||||
|
||||
const startNew = (from) => setMsg('') || setDraft({ id: null, name: from ? `${from.name} (copy)` : '', desc: from?.desc || '', creation: { ...(from?.creation || {}) } })
|
||||
const edit = () => setMsg('') || setDraft({ id: current.id, name: current.name, desc: current.desc || '', creation: { ...current.creation } })
|
||||
|
||||
const setVal = (x, raw) => {
|
||||
const v = parseInt(raw, 10)
|
||||
if (!Number.isFinite(v)) return
|
||||
setDraft((d) => ({ ...d, creation: { ...d.creation, [x.key]: Math.max(x.min, Math.min(x.max, v)) } }))
|
||||
}
|
||||
|
||||
const save = async () => {
|
||||
const body = { name: draft.name, desc: draft.desc, creation: draft.creation }
|
||||
if (!draft.name.trim()) return flash('Give the preset a name.')
|
||||
try {
|
||||
const res = await fetch(draft.id ? `/api/presets/${draft.id}` : '/api/presets', {
|
||||
method: draft.id ? 'PUT' : 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...(draft.id ? { 'X-Edit-Token': tokens[draft.id] } : {}) },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).detail || 'Saving failed')
|
||||
const data = await res.json()
|
||||
let id = draft.id
|
||||
if (!id) {
|
||||
id = data.id
|
||||
const t = { ...tokens, [id]: data.token }
|
||||
saveTokens(t); setTokens(t)
|
||||
}
|
||||
await reload()
|
||||
setSel(id); setDraft(null); flash('Preset saved.')
|
||||
} catch (e) { flash(String(e.message || e)) }
|
||||
}
|
||||
|
||||
const remove = async () => {
|
||||
if (!window.confirm(`Delete the preset "${current.name}"? Characters that already use it keep their numbers.`)) return
|
||||
try {
|
||||
const res = await fetch(`/api/presets/${current.id}`, { method: 'DELETE', headers: { 'X-Edit-Token': tokens[current.id] } })
|
||||
if (!res.ok) throw new Error('Deleting failed')
|
||||
const t = { ...tokens }; delete t[current.id]
|
||||
saveTokens(t); setTokens(t)
|
||||
await reload(); setSel(rules.default_preset || 'standard')
|
||||
} catch (e) { flash(String(e.message || e)) }
|
||||
}
|
||||
|
||||
const kind = (p) => (p.builtin ? 'Built in' : tokens[p.id] ? 'Yours' : 'Shared')
|
||||
|
||||
return (
|
||||
<section className="page">
|
||||
<h1>Rules presets</h1>
|
||||
<p className="muted narrow">
|
||||
A preset is a named set of creation numbers (points for attributes and skills, talents, proficiency ...). Pick one on the Concept page of the creator.
|
||||
Presets are stored on the server, so everyone using this site can use them; only the browser that created a preset can change or delete it.
|
||||
Built-in presets come from <code>rules.json</code>. Duplicate one to make your own version.
|
||||
</p>
|
||||
<div className="presets-layout">
|
||||
<aside>
|
||||
<ul className="presetlist">
|
||||
{presets.map((p) => (
|
||||
<li key={p.id}>
|
||||
<button className={`card choice ${!draft && current?.id === p.id ? 'on' : ''}`} onClick={() => { setMsg(''); setDraft(null); setSel(p.id) }}>
|
||||
<strong>{p.name}</strong>
|
||||
<span className="small muted">{kind(p)}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<button className="ghost" onClick={() => startNew(null)}>+ New preset</button>
|
||||
</aside>
|
||||
|
||||
<div className="card">
|
||||
{editable ? (
|
||||
<>
|
||||
<h2>{draft.id ? 'Edit preset' : 'New preset'}</h2>
|
||||
<label>Name<input value={draft.name} maxLength={60} onChange={(e) => setDraft({ ...draft, name: e.target.value })} /></label>
|
||||
<label>Description<input value={draft.desc} maxLength={300} onChange={(e) => setDraft({ ...draft, desc: e.target.value })} /></label>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h2>{current.name} <span className="badge">{kind(current)}</span></h2>
|
||||
<p className="muted">{current.desc || 'No description.'}</p>
|
||||
</>
|
||||
)}
|
||||
{groups.map((g) => (
|
||||
<div key={g}><h4>{g}</h4>
|
||||
<div className="tunables">
|
||||
{rules.tunables.filter((x) => x.group === g).map((x) => (
|
||||
<label key={x.key}>{x.label}
|
||||
<input type="number" min={x.min} max={x.max} value={value(x.key)} readOnly={!editable} disabled={!editable}
|
||||
onChange={(e) => setVal(x, e.target.value)} />
|
||||
{value(x.key) !== base[x.key] && <span className="small muted"> Standard: {base[x.key]}</span>}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{msg && <p className="toast">{msg}</p>}
|
||||
<div className="actions wrap">
|
||||
{editable ? (
|
||||
<>
|
||||
<button onClick={save}>Save preset</button>
|
||||
<button className="ghost" onClick={() => setDraft(null)}>Cancel</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{own && <button onClick={edit}>Edit</button>}
|
||||
<button className="ghost" onClick={() => startNew(current)}>Duplicate</button>
|
||||
{own && <button className="ghost danger" onClick={remove}>Delete</button>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
const Badge = () => <span className="badge" title="Not yet worked out in the brainstorm">Placeholder</span>
|
||||
const SOURCE = { noxium: 'Noxium', diesium: 'Diesium', hybrid: 'Hybrid', precyber: 'Pre-meteor cybernetics' }
|
||||
|
||||
function Block({ b, rules }) {
|
||||
if (b.h) return <h3>{b.h}</h3>
|
||||
if (b.p) return <p className={b.placeholder ? 'placeholder-text' : ''}>{b.p} {b.placeholder && <Badge />}</p>
|
||||
if (b.list) return <ul>{b.list.map((x, i) => <li key={i}>{x}</li>)}</ul>
|
||||
if (b.table) return (
|
||||
<div className="tablewrap"><table>
|
||||
<thead><tr>{b.table.head.map((h) => <th key={h}>{h}</th>)}</tr></thead>
|
||||
<tbody>{b.table.rows.map((r, i) => <tr key={i}>{r.map((c, j) => <td key={j}>{c}</td>)}</tr>)}</tbody>
|
||||
</table></div>
|
||||
)
|
||||
if (b.attributes) return (
|
||||
<div className="tablewrap"><table>
|
||||
<thead><tr><th>Attribute</th><th>Short</th><th>Meaning</th></tr></thead>
|
||||
<tbody>{rules.attributes.map((a) => <tr key={a.id}><td>{a.name}</td><td>{a.short}</td><td>{a.desc}</td></tr>)}</tbody>
|
||||
</table></div>
|
||||
)
|
||||
if (b.derived) return (
|
||||
<div className="tablewrap"><table>
|
||||
<thead><tr><th>Stat</th><th>Formula</th><th>Note</th></tr></thead>
|
||||
<tbody>{rules.derived.map((d) => <tr key={d.id}><td>{d.name}</td><td>{d.text}</td><td>{d.note || ''}</td></tr>)}</tbody>
|
||||
</table></div>
|
||||
)
|
||||
if (b.skilllist) return rules.skill_groups.map((g) => (
|
||||
<div key={g.id}>
|
||||
<h3>{g.name}</h3>
|
||||
<div className="tablewrap"><table>
|
||||
<thead><tr><th>Skill</th><th>Attribute</th><th>Type</th><th>Description</th></tr></thead>
|
||||
<tbody>{rules.skills.filter((s) => s.group === g.id).map((s) => (
|
||||
<tr key={s.id}><td>{s.name} {s.placeholder && <Badge />}</td>
|
||||
<td>{s.attrs.map((id) => rules.attributes.find((a) => a.id === id).short).join(' / ')}</td>
|
||||
<td>{s.type === 'basic' ? 'Basic' : 'Advanced'}</td>
|
||||
<td>{s.desc}</td></tr>
|
||||
))}</tbody>
|
||||
</table></div>
|
||||
</div>
|
||||
))
|
||||
if (b.professions) return (
|
||||
<div className="cards">{rules.professions.map((p) => (
|
||||
<div key={p.id} className="card">
|
||||
<strong>{p.name}</strong> {p.placeholder && <Badge />}
|
||||
<p className="small">{p.desc}</p>
|
||||
{p.note && <p className="small muted">{p.note}</p>}
|
||||
</div>
|
||||
))}</div>
|
||||
)
|
||||
if (b.classes) return (
|
||||
<div className="cards">{rules.classes.map((k) => (
|
||||
<div key={k.id} className="card">
|
||||
<strong>{k.name}</strong>
|
||||
<ul className="small">{k.subclasses.map((x) => <li key={x.id}><strong>{x.name}</strong>: {x.desc}</li>)}</ul>
|
||||
</div>
|
||||
))}</div>
|
||||
)
|
||||
if (b.conditions) return (
|
||||
<div className="tablewrap"><table>
|
||||
<thead><tr><th>Condition</th><th>Level I</th><th>Level II</th><th>Level III</th><th>Removal</th></tr></thead>
|
||||
<tbody>{rules.conditions.map((c) => (
|
||||
<tr key={c.id}><td><strong>{c.name}</strong></td>{c.levels.map((l, i) => <td key={i}>{l}</td>)}<td>{c.removal}</td></tr>
|
||||
))}</tbody>
|
||||
</table></div>
|
||||
)
|
||||
if (b.augmentlist) return (
|
||||
<div className="tablewrap"><table>
|
||||
<thead><tr><th>Name</th><th>Type</th><th>Source</th><th>Cost</th><th>Effect</th></tr></thead>
|
||||
<tbody>{rules.augments.map((a) => (
|
||||
<tr key={a.id}><td>{a.name} {a.placeholder && <Badge />}</td><td>{a.kind === 'mutation' ? 'Mutation' : 'Implant'}</td>
|
||||
<td>{SOURCE[a.source] || '–'}</td><td>{a.cost}</td><td>{a.desc}</td></tr>
|
||||
))}</tbody>
|
||||
</table></div>
|
||||
)
|
||||
if (b.talentlist) return rules.talent_categories.map((c) => {
|
||||
const ts = rules.talents.filter((t) => t.category === c.id)
|
||||
if (!ts.length) return null
|
||||
return (
|
||||
<div key={c.id}><h3>{c.name}</h3>
|
||||
<ul>{ts.map((t) => <li key={t.id}><strong>{t.name}</strong> {t.placeholder && <Badge />}<br />{t.desc}</li>)}</ul>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
if (b.equipment) return rules.equipment_categories.map((cat) => (
|
||||
<div key={cat.id}>
|
||||
<h3>{cat.name}</h3>
|
||||
<div className="tablewrap"><table>
|
||||
<thead><tr><th>Name</th><th>{cat.stat}</th><th>Weight</th><th>Notes</th></tr></thead>
|
||||
<tbody>{rules.equipment.filter((e) => e.cat === cat.id).map((e) => (
|
||||
<tr key={e.id}><td>{e.name}</td><td>{e.stat}{e.tier ? ` (${e.tier})` : ''}</td><td>{e.kg} kg</td><td>{e.note}</td></tr>
|
||||
))}</tbody>
|
||||
</table></div>
|
||||
</div>
|
||||
))
|
||||
return null
|
||||
}
|
||||
|
||||
// All text of a section, for the search
|
||||
const sectionText = (s, rules) => JSON.stringify(s).toLowerCase() +
|
||||
(s.blocks.some((b) => b.skilllist) ? rules.skills.map((x) => x.name + x.desc).join(' ').toLowerCase() : '') +
|
||||
(s.blocks.some((b) => b.talentlist) ? rules.talents.map((x) => x.name + x.desc).join(' ').toLowerCase() : '') +
|
||||
(s.blocks.some((b) => b.augmentlist) ? rules.augments.map((x) => x.name + x.desc).join(' ').toLowerCase() : '') +
|
||||
(s.blocks.some((b) => b.equipment) ? rules.equipment.map((x) => x.name + x.note).join(' ').toLowerCase() : '') +
|
||||
(s.blocks.some((b) => b.conditions) ? JSON.stringify(rules.conditions).toLowerCase() : '') +
|
||||
(s.blocks.some((b) => b.classes) ? JSON.stringify(rules.classes).toLowerCase() : '')
|
||||
|
||||
export default function Rules({ rules, section }) {
|
||||
const [q, setQ] = useState('')
|
||||
const shown = useMemo(() => {
|
||||
const t = q.trim().toLowerCase()
|
||||
return t ? rules.sections.filter((s) => sectionText(s, rules).includes(t)) : rules.sections
|
||||
}, [q, rules])
|
||||
|
||||
useEffect(() => {
|
||||
if (section) document.getElementById(`sec-${section}`)?.scrollIntoView({ behavior: 'smooth' })
|
||||
}, [section])
|
||||
|
||||
return (
|
||||
<div className="page rules">
|
||||
<aside>
|
||||
<input type="search" placeholder="Search the rules …" value={q} onChange={(e) => setQ(e.target.value)} aria-label="Search the rules" />
|
||||
<ul>{rules.sections.map((s) => (
|
||||
<li key={s.id}><a href={`#rules/${s.id}`} className={shown.includes(s) ? '' : 'dim'}>{s.title}</a></li>
|
||||
))}</ul>
|
||||
</aside>
|
||||
<div className="content">
|
||||
<h1>{rules.meta.name} <span className="muted small">Rules {rules.meta.version}</span></h1>
|
||||
{shown.length === 0 && <p className="card">No results.</p>}
|
||||
{shown.map((s) => (
|
||||
<section key={s.id} id={`sec-${s.id}`}>
|
||||
<h2>{s.title}</h2>
|
||||
{s.blocks.map((b, i) => <Block key={i} b={b} rules={rules} />)}
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useMemo } from 'react'
|
||||
import { countChanges, diffRows, sheetModel } from './sheetModel.js'
|
||||
|
||||
function Rows({ now, before, valueLabel }) {
|
||||
const rows = diffRows(now, before)
|
||||
if (!rows.length) return <p className="muted small">–</p>
|
||||
return (
|
||||
<table className="difftable"><tbody>
|
||||
{rows.map((r) => (
|
||||
<tr key={r.k} className={`d-${r.status}`}>
|
||||
<td>{r.status === 'added' ? '+ ' : r.status === 'removed' ? '− ' : ''}{r.label}{r.desc && <div className="rowdesc">{r.desc}</div>}</td>
|
||||
<td>
|
||||
{r.status === 'changed' && r.old.val !== r.val && r.old.val !== undefined && <span className="was">{r.old.val} → </span>}
|
||||
{valueLabel ? valueLabel(r) : r.val}
|
||||
</td>
|
||||
{r.extra !== undefined && <td className="muted">{r.status === 'changed' && (r.old.extra || '') !== (r.extra || '') ? `${r.old.extra || '–'} → ${r.extra || '–'}` : r.extra}</td>}
|
||||
</tr>
|
||||
))}
|
||||
</tbody></table>
|
||||
)
|
||||
}
|
||||
|
||||
// Always-visible character sheet next to the wizard; shows the pending changes against the applied state.
|
||||
export default function SheetPanel({ ch, committed, baseRules, onApply, onDiscard }) {
|
||||
const now = useMemo(() => sheetModel(ch, baseRules), [ch, baseRules])
|
||||
const before = useMemo(() => sheetModel(committed, baseRules), [committed, baseRules])
|
||||
const changes = countChanges(now, before)
|
||||
const pending = changes > 0 || JSON.stringify(ch) !== JSON.stringify(committed)
|
||||
const c = now.c
|
||||
|
||||
return (
|
||||
<aside className="live card sheetpanel">
|
||||
<h3 className="sheet-name">{committed.name !== ch.name && <s className="muted">{committed.name || 'Nameless'} </s>}{ch.name || 'Nameless'}</h3>
|
||||
<div className={`applybar ${pending ? 'pending' : ''}`}>
|
||||
{pending ? (
|
||||
<>
|
||||
<span className="small">{changes > 0 ? `${changes} change${changes > 1 ? 's' : ''} not applied yet` : 'Unapplied edits'}</span>
|
||||
<span className="actions">
|
||||
<button onClick={onApply}>Apply</button>
|
||||
<button className="ghost" onClick={onDiscard}>Discard</button>
|
||||
</span>
|
||||
</>
|
||||
) : <span className="small muted">All changes applied</span>}
|
||||
</div>
|
||||
<Rows now={now.head.filter((h) => h.k !== 'name')} before={before.head.filter((h) => h.k !== 'name')} />
|
||||
<h4>Attributes</h4>
|
||||
<Rows now={now.attrs} before={before.attrs} />
|
||||
<h4>Derived</h4>
|
||||
<Rows now={now.derived} before={before.derived} />
|
||||
<h4>Skills</h4>
|
||||
<Rows now={now.skills} before={before.skills} />
|
||||
<h4>Talents</h4>
|
||||
<Rows now={now.talents} before={before.talents} />
|
||||
<h4>Mutations / implants <span className="muted small">{c.capacityUsed}/{c.capacityMax}</span></h4>
|
||||
<Rows now={now.augments} before={before.augments} />
|
||||
<h4>Equipment <span className="muted small">{c.weight}/{c.carryMax} kg</span></h4>
|
||||
<Rows now={now.gear} before={before.gear} valueLabel={(r) => (r.val > 1 ? `x${r.val}` : '')} />
|
||||
<h4>Points left</h4>
|
||||
<div className="small muted">Attributes {c.attrLeft} · Skills {c.skillLeft} · Proficiency {c.profLeft}</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
// Mirror of backend/app/derive.py: when formulas change, adjust both.
|
||||
|
||||
export const modOf = (v) => Math.floor(v / 10)
|
||||
|
||||
export function emptyCharacter(rules) {
|
||||
const attributes = {}
|
||||
rules.attributes.forEach((a) => (attributes[a.id] = rules.creation.attr_start))
|
||||
return {
|
||||
name: '', player: '', concept: '', background: '', class_id: '', subclass_id: '',
|
||||
attributes,
|
||||
profession: '', profession_bonus_attr: '', profession_picks: [],
|
||||
preset: rules.default_preset || 'standard', creation: {}, // creation = overrides of rules.creation for this character
|
||||
skill_points: {}, proficiencies: {}, proficiency_picks: {}, unlocked: [],
|
||||
talents: [], augments: [],
|
||||
kit: { weapon: '', armor: '', tool: '', resources: [], extras: {} },
|
||||
gear: { other: '' },
|
||||
notes: '',
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Rules presets ----------
|
||||
export const effectiveRules = (rules, ch) => ({ ...rules, creation: { ...rules.creation, ...(ch.creation || {}) } })
|
||||
|
||||
const presetOverrides = (rules, id) => ({ ...(rules.presets?.find((p) => p.id === id)?.creation || {}) })
|
||||
|
||||
// Overrides that actually differ from the base values, as a comparable string.
|
||||
const sig = (rules, creation) => JSON.stringify(Object.entries(creation).filter(([k, v]) => v !== rules.creation[k]).sort(([a], [b]) => a.localeCompare(b)))
|
||||
|
||||
// Which preset matches these values exactly ("custom" if none).
|
||||
export function matchPreset(rules, creation) {
|
||||
const s = sig(rules, creation)
|
||||
return (rules.presets || []).find((p) => sig(rules, p.creation) === s)?.id || 'custom'
|
||||
}
|
||||
|
||||
// After the creation numbers changed: re-base attributes to the new start value and keep everything within the new caps.
|
||||
function reconcile(ch, oldRules, newRules) {
|
||||
const o = oldRules.creation, n = newRules.creation
|
||||
const cap = Math.min(n.attr_max, n.attr_start + n.attr_max_raise)
|
||||
const attributes = {}
|
||||
newRules.attributes.forEach((a) => {
|
||||
const v = (ch.attributes[a.id] ?? o.attr_start) - o.attr_start + n.attr_start
|
||||
attributes[a.id] = Math.max(n.attr_start, Math.min(cap, v))
|
||||
})
|
||||
const out = { ...ch, attributes }
|
||||
const sp = { ...out.skill_points }
|
||||
compute(out, newRules).skills.forEach((s) => { if (sp[s.id] > s.max) sp[s.id] = s.max })
|
||||
return { ...out, skill_points: sp }
|
||||
}
|
||||
|
||||
export function applyPreset(ch, rules, id) {
|
||||
const next = { ...ch, preset: id, creation: presetOverrides(rules, id) }
|
||||
return reconcile(next, effectiveRules(rules, ch), effectiveRules(rules, next))
|
||||
}
|
||||
|
||||
export function setTunable(ch, rules, key, value) {
|
||||
if (!Number.isFinite(value)) return ch
|
||||
const creation = { ...ch.creation, [key]: value }
|
||||
if (value === rules.creation[key]) delete creation[key]
|
||||
const next = { ...ch, creation, preset: matchPreset(rules, creation) }
|
||||
return reconcile(next, effectiveRules(rules, ch), effectiveRules(rules, next))
|
||||
}
|
||||
|
||||
// Keeps old drafts and imports valid after the data model grew. `rules` are the base rules (without per-character overrides).
|
||||
export function normalize(ch, rules) {
|
||||
const preset = ch.preset || rules.default_preset || 'standard'
|
||||
const creation = ch.creation ?? presetOverrides(rules, preset)
|
||||
const base = emptyCharacter(effectiveRules(rules, { creation }))
|
||||
const out = { ...base, ...ch, preset, creation, kit: { ...base.kit, ...(ch.kit || {}) }, gear: { ...base.gear, ...(ch.gear || {}) } }
|
||||
if (typeof out.skill_pool === 'number') { // older characters stored the pool size on the character
|
||||
if (out.skill_pool !== effectiveRules(rules, out).creation.skill_points_default) {
|
||||
out.creation = { ...out.creation, skill_points_default: out.skill_pool }
|
||||
out.preset = matchPreset(rules, out.creation)
|
||||
}
|
||||
}
|
||||
delete out.skill_pool
|
||||
// The preset may have been deleted since: fall back to whatever matches the numbers
|
||||
if (out.preset !== 'custom' && !(rules.presets || []).some((x) => x.id === out.preset)) out.preset = matchPreset(rules, out.creation)
|
||||
// Older characters: advanced skills that already hold points count as unlocked
|
||||
const unlocked = new Set(out.unlocked)
|
||||
rules.skills.forEach((s) => { if (s.type === 'advanced' && (out.skill_points[s.id] || 0) > 0) unlocked.add(s.id) })
|
||||
return { ...out, unlocked: [...unlocked] }
|
||||
}
|
||||
|
||||
// Clears points on advanced skills that are neither unlocked nor granted by a profession proficiency.
|
||||
export function pruneLocked(ch, rules) {
|
||||
const points = { ...ch.skill_points }
|
||||
rules.skills.forEach((s) => {
|
||||
if (s.type === 'advanced' && !ch.unlocked.includes(s.id) && !ch.proficiencies[s.id] && !(ch.proficiency_picks[s.id] > 0)) delete points[s.id]
|
||||
})
|
||||
return { ...ch, skill_points: points }
|
||||
}
|
||||
|
||||
export function kitItems(ch, rules) {
|
||||
const items = Object.fromEntries(rules.equipment.map((e) => [e.id, e]))
|
||||
const k = ch.kit
|
||||
const out = []
|
||||
;[['Weapon', k.weapon], ['Armor', k.armor], ['Tool', k.tool]].forEach(([label, id]) => { if (items[id]) out.push({ label, item: items[id], qty: 1 }) })
|
||||
k.resources.forEach((id) => { if (items[id]) out.push({ label: 'Resource', item: items[id], qty: 1 }) })
|
||||
Object.entries(k.extras).forEach(([id, qty]) => { if (items[id] && qty > 0) out.push({ label: 'Extra', item: items[id], qty }) })
|
||||
return out
|
||||
}
|
||||
|
||||
export function compute(ch, rules) {
|
||||
const cr = rules.creation
|
||||
const bonusAttr = ch.profession ? ch.profession_bonus_attr : null
|
||||
const attrs = {}
|
||||
rules.attributes.forEach((a) => {
|
||||
attrs[a.id] = (ch.attributes[a.id] ?? cr.attr_start) + (a.id === bonusAttr ? cr.profession_attr_bonus : 0)
|
||||
})
|
||||
const mods = Object.fromEntries(Object.entries(attrs).map(([k, v]) => [k, modOf(v)]))
|
||||
const derived = Object.fromEntries(rules.derived.map((d) => [d.id, d.formula.reduce((s, x) => s + mods[x], 0)]))
|
||||
|
||||
const skills = rules.skills.map((s) => {
|
||||
const points = ch.skill_points[s.id] || 0
|
||||
// Proficiency "pips": 1 from the profession plus free picks, at most 2 (2 = Expertise)
|
||||
const profPip = ch.proficiencies[s.id] ? 1 : 0
|
||||
const freePips = Math.max(0, Math.min(ch.proficiency_picks[s.id] || 0, (cr.max_proficiency_pips ?? 2) - profPip))
|
||||
const pips = profPip + freePips
|
||||
const prof = [0, cr.proficiency_bonus, cr.expertise_bonus][pips]
|
||||
const best = Math.max(...s.attrs.map((a) => attrs[a])) // with several attributes the higher one counts
|
||||
const unlocked = ch.unlocked.includes(s.id)
|
||||
return { ...s, points, prof, profPip, freePips, pips, unlocked, total: best + points + prof,
|
||||
usable: s.type === 'basic' || unlocked || prof > 0, // advanced skills must be unlocked
|
||||
free: s.type === 'advanced' && prof > 0, // unlocked by the profession, costs nothing
|
||||
max: Math.floor(best / cr.skill_max_divisor) }
|
||||
})
|
||||
|
||||
const profSpent = skills.reduce((s, x) => s + x.freePips, 0)
|
||||
const profAllowed = (cr.free_proficiencies ?? 0) + rules.talents.filter((t) => ch.talents.includes(t.id)).reduce((s, t) => s + (t.proficiency_picks || 0), 0)
|
||||
const unlockCost = cr.advanced_unlock_cost ?? 0
|
||||
const paidUnlocks = skills.filter((s) => s.type === 'advanced' && s.unlocked && !s.prof).length
|
||||
const attrSpent = rules.attributes.reduce((s, a) => s + (ch.attributes[a.id] - cr.attr_start), 0)
|
||||
const skillSpent = Object.values(ch.skill_points).reduce((s, v) => s + v, 0) + paidUnlocks * unlockCost
|
||||
const chosen = rules.augments.filter((a) => ch.augments.includes(a.id))
|
||||
const capacityUsed = chosen.reduce((s, a) => s + a.cost, 0)
|
||||
const weight = Math.round(kitItems(ch, rules).reduce((s, x) => s + x.item.kg * x.qty, 0) * 10) / 10
|
||||
|
||||
return { attrs, mods, derived, skills, attrSpent, attrLeft: cr.attr_points - attrSpent,
|
||||
skillSpent, skillLeft: cr.skill_points_default - skillSpent, unlockCost,
|
||||
profSpent, profAllowed, profLeft: profAllowed - profSpent,
|
||||
capacityUsed, capacityMax: derived.augmentation ?? 0,
|
||||
weight, carryMax: Math.max(0, mods.strength) * (cr.carry_per_strength_mod ?? 10) }
|
||||
}
|
||||
|
||||
// Removes all profession proficiencies (fixed and freely picked, "Proficient" level only) from the map.
|
||||
function stripProfessionProfs(ch, rules) {
|
||||
const prev = rules.professions.find((p) => p.id === ch.profession)
|
||||
const prof = { ...ch.proficiencies }
|
||||
const ids = [...(prev?.proficiencies || []), ...(ch.profession_picks || [])]
|
||||
ids.forEach((id) => { if (prof[id]) delete prof[id] })
|
||||
return prof
|
||||
}
|
||||
|
||||
// Applies a profession choice: grants its fixed proficiencies (+10) and removes the previous profession's.
|
||||
export function applyProfession(ch, rules, newId) {
|
||||
const next = rules.professions.find((p) => p.id === newId)
|
||||
const prof = stripProfessionProfs(ch, rules)
|
||||
next?.proficiencies.forEach((id) => { if (!prof[id]) prof[id] = 1 })
|
||||
const bonus = next?.bonus_attrs.length === 1 ? next.bonus_attrs[0] : (next?.bonus_attrs.includes(ch.profession_bonus_attr) ? ch.profession_bonus_attr : '')
|
||||
return pruneLocked({ ...ch, profession: newId, profession_bonus_attr: bonus, profession_picks: [], proficiencies: prof }, rules)
|
||||
}
|
||||
|
||||
// Freely chosen profession proficiencies (Civilian: 2 basic skills).
|
||||
export function setProfessionPicks(ch, rules, picks) {
|
||||
const prev = rules.professions.find((p) => p.id === ch.profession)
|
||||
const prof = { ...ch.proficiencies }
|
||||
;(ch.profession_picks || []).forEach((id) => {
|
||||
if (prof[id] && !prev?.proficiencies.includes(id)) delete prof[id]
|
||||
})
|
||||
picks.forEach((id) => { if (!prof[id]) prof[id] = 1 })
|
||||
return { ...ch, profession_picks: picks, proficiencies: prof }
|
||||
}
|
||||
|
||||
// Resets only the data of one wizard step (0 concept, 1 attributes, 2 profession, 3 skills, 4 talents, 5 augments, 6 equipment).
|
||||
export function resetStep(ch, rules, step) {
|
||||
const e = emptyCharacter(rules)
|
||||
switch (step) {
|
||||
case 0: return { ...ch, name: '', player: '', concept: '', background: '', class_id: '', subclass_id: '' }
|
||||
case 1: return { ...ch, attributes: e.attributes }
|
||||
case 2: return pruneLocked({ ...ch, proficiencies: stripProfessionProfs(ch, rules), profession: '', profession_bonus_attr: '', profession_picks: [] }, rules)
|
||||
case 3: return { ...ch, skill_points: {}, unlocked: [], proficiency_picks: {} }
|
||||
case 4: return { ...ch, talents: [] }
|
||||
case 5: return { ...ch, augments: [] }
|
||||
case 6: return { ...ch, kit: e.kit, gear: e.gear, notes: '' }
|
||||
default: return ch
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import React from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import App from './App.jsx'
|
||||
import './styles.css'
|
||||
|
||||
createRoot(document.getElementById('root')).render(<App />)
|
||||
@@ -0,0 +1,47 @@
|
||||
import { compute, effectiveRules, kitItems } from './calc.js'
|
||||
|
||||
// Flat, comparable view of a character, used by the side panel to show what changed.
|
||||
export function sheetModel(ch, baseRules) {
|
||||
const rules = effectiveRules(baseRules, ch)
|
||||
const c = compute(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 preset = ch.preset === 'custom' ? 'Custom' : baseRules.presets?.find((p) => p.id === ch.preset)?.name || 'Custom'
|
||||
const training = (s) => (s.pips === 2 ? 'Expertise' : s.pips === 1 ? 'Proficient' : s.type === 'advanced' && s.unlocked ? 'Unlocked' : '')
|
||||
return {
|
||||
c,
|
||||
head: [
|
||||
{ k: 'name', label: 'Name', val: ch.name || 'Nameless' },
|
||||
{ k: 'profession', label: 'Profession', val: prof?.name || '–' },
|
||||
{ k: 'class', label: 'Class', val: cls ? [cls.name, sub?.name].filter(Boolean).join(' / ') : '–' },
|
||||
{ k: 'preset', label: 'Rules', val: preset },
|
||||
],
|
||||
attrs: rules.attributes.map((a) => ({ k: a.id, label: a.name, val: c.attrs[a.id], extra: `Mod ${c.mods[a.id]}` })),
|
||||
derived: rules.derived.map((d) => ({ k: d.id, label: d.name, val: c.derived[d.id] })),
|
||||
skills: c.skills.filter((s) => s.points || s.prof || (s.unlocked && s.type === 'advanced'))
|
||||
.map((s) => ({ k: s.id, label: s.name, val: s.total, extra: training(s) })),
|
||||
talents: rules.talents.filter((t) => ch.talents.includes(t.id)).map((t) => ({ k: t.id, label: t.name, desc: t.desc })),
|
||||
augments: rules.augments.filter((a) => ch.augments.includes(a.id)).map((a) => ({ k: a.id, label: a.name, desc: `${a.kind === 'implant' ? 'Implant' : 'Mutation'} · cost ${a.cost}${a.desc ? ` · ${a.desc}` : ''}` })),
|
||||
gear: kitItems(ch, rules).map((g) => ({ k: g.item.id, label: g.item.name, val: g.qty, desc: [g.item.stat, `${g.item.kg} kg`, g.item.note].filter(Boolean).join(' · ') })),
|
||||
}
|
||||
}
|
||||
|
||||
// Merges two row lists into one with a status per row: same | changed | added | removed.
|
||||
export function diffRows(now, before) {
|
||||
const old = new Map(before.map((r) => [r.k, r]))
|
||||
const seen = new Set()
|
||||
const out = now.map((r) => {
|
||||
seen.add(r.k)
|
||||
const o = old.get(r.k)
|
||||
if (!o) return { ...r, status: 'added' }
|
||||
return { ...r, old: o, status: o.val === r.val && (o.extra || '') === (r.extra || '') ? 'same' : 'changed' }
|
||||
})
|
||||
before.forEach((o) => { if (!seen.has(o.k)) out.push({ ...o, status: 'removed' }) })
|
||||
return out
|
||||
}
|
||||
|
||||
export const countChanges = (a, b) => {
|
||||
const n = (rows, bef) => diffRows(rows, bef).filter((r) => r.status !== 'same').length
|
||||
return n(a.head, b.head) + n(a.attrs, b.attrs) + n(a.skills, b.skills) + n(a.talents, b.talents) + n(a.augments, b.augments) + n(a.gear, b.gear)
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
:root {
|
||||
--bg: #12150f; --panel: #1b2017; --panel2: #232a1e; --line: #343d2c;
|
||||
--text: #e6e9de; --muted: #9aa58c; --noxium: #8fd14f; --diesium: #e0a43a; --danger: #e5604d;
|
||||
--radius: 8px;
|
||||
}
|
||||
:root[data-theme="light"] { color-scheme: light; }
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root:not([data-theme="dark"]) {
|
||||
--bg: #f3f4ee; --panel: #ffffff; --panel2: #eaede1; --line: #cfd5c0; --text: #1c2117; --muted: #5d6a50;
|
||||
--noxium: #3d7d12; --diesium: #a5680a; --danger: #b8341f;
|
||||
}
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html { font-size: 16px; }
|
||||
body { margin: 0; background: var(--bg); color: var(--text); font-family: system-ui, "Segoe UI", Roboto, sans-serif; line-height: 1.5; }
|
||||
h1, h2, h3, h4 { line-height: 1.2; font-family: "Bahnschrift", "Segoe UI", system-ui, sans-serif; letter-spacing: .02em; }
|
||||
h1 { font-size: 1.8rem; margin: .2em 0 .6em; } h2 { font-size: 1.35rem; margin-top: 0; }
|
||||
h3 { font-size: 1.05rem; color: var(--diesium); margin: 1.2em 0 .4em; } h4 { margin: 1em 0 .3em; color: var(--noxium); }
|
||||
a { color: var(--noxium); }
|
||||
code { background: var(--panel2); padding: .1em .35em; border-radius: 4px; word-break: break-all; }
|
||||
.muted { color: var(--muted); } .small { font-size: .85rem; }
|
||||
|
||||
.top { display: flex; flex-wrap: wrap; gap: .5rem 1.5rem; align-items: center; justify-content: space-between; padding: .7rem 1rem; border-bottom: 1px solid var(--line); background: var(--panel); position: sticky; top: 0; z-index: 5; }
|
||||
.brand { display: flex; align-items: center; gap: .6rem; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; color: var(--text); text-decoration: none; }
|
||||
.brand-mark { width: 16px; height: 16px; background: linear-gradient(135deg, var(--noxium) 50%, var(--diesium) 50%); border-radius: 3px; transform: rotate(45deg); }
|
||||
.top nav { display: flex; gap: .25rem; flex-wrap: wrap; }
|
||||
.top nav a { padding: .4rem .8rem; border-radius: var(--radius); text-decoration: none; color: var(--muted); }
|
||||
.top nav a:hover { color: var(--text); background: var(--panel2); }
|
||||
.top nav a.active { color: var(--bg); background: var(--noxium); font-weight: 600; }
|
||||
main { max-width: 1100px; margin: 0 auto; padding: 1rem; }
|
||||
|
||||
button { font: inherit; cursor: pointer; border: 1px solid var(--noxium); background: var(--noxium); color: #12150f; padding: .5rem 1rem; border-radius: var(--radius); font-weight: 600; }
|
||||
button:hover:not(:disabled) { filter: brightness(1.1); }
|
||||
button:disabled { opacity: .4; cursor: not-allowed; }
|
||||
button.ghost { background: transparent; color: var(--text); border-color: var(--line); }
|
||||
button.ghost:hover:not(:disabled) { border-color: var(--noxium); }
|
||||
button.danger { color: var(--danger); }
|
||||
:focus-visible { outline: 2px solid var(--diesium); outline-offset: 2px; }
|
||||
input, textarea, select { font: inherit; width: 100%; background: var(--panel2); color: var(--text); border: 1px solid var(--line); border-radius: var(--radius); padding: .5rem .7rem; }
|
||||
input.inline { width: 5rem; display: inline-block; margin-left: .4rem; }
|
||||
label { display: block; margin: .8rem 0; font-size: .9rem; color: var(--muted); }
|
||||
label > input, label > textarea, label > select { margin-top: .25rem; color: var(--text); }
|
||||
.grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 0 1rem; }
|
||||
|
||||
.card { background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); padding: .8rem 1rem; }
|
||||
.cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: .7rem; }
|
||||
button.card.choice { display: flex; flex-direction: column; gap: .3rem; text-align: left; font-weight: 400; color: var(--text); background: var(--panel); border-color: var(--line); }
|
||||
button.card.choice.on { border-color: var(--noxium); box-shadow: inset 0 0 0 1px var(--noxium); background: var(--panel2); }
|
||||
.badge { font-size: .65rem; text-transform: uppercase; letter-spacing: .06em; color: var(--diesium); border: 1px solid var(--diesium); border-radius: 4px; padding: 0 .35em; vertical-align: middle; white-space: nowrap; }
|
||||
.placeholder-text { color: var(--muted); }
|
||||
|
||||
.steps { list-style: none; display: flex; gap: .3rem; padding: 0; margin: 0 0 1rem; overflow-x: auto; }
|
||||
.steps button { background: var(--panel); border-color: var(--line); color: var(--muted); display: flex; align-items: center; gap: .5rem; font-weight: 500; white-space: nowrap; padding: .4rem .7rem; }
|
||||
.steps .n { display: inline-grid; place-items: center; width: 1.4rem; height: 1.4rem; border-radius: 50%; border: 1px solid currentColor; font-size: .75rem; }
|
||||
.steps button.done { color: var(--noxium); } .steps button.on { background: var(--noxium); color: #12150f; border-color: var(--noxium); }
|
||||
.creator-body { display: grid; grid-template-columns: 1fr 240px; gap: 1rem; align-items: start; }
|
||||
.live { position: sticky; top: 4.5rem; } .live h4 { margin-top: 0; }
|
||||
table { width: 100%; border-collapse: collapse; } td, th { padding: .3rem .5rem; border-bottom: 1px solid var(--line); text-align: left; vertical-align: top; }
|
||||
th { color: var(--muted); font-weight: 600; font-size: .85rem; }
|
||||
.tablewrap { overflow-x: auto; margin-bottom: .6rem; }
|
||||
|
||||
.chips { display: flex; flex-wrap: wrap; gap: .4rem; margin-bottom: 1rem; }
|
||||
.chip { background: var(--panel); color: var(--text); border-color: var(--line); font-weight: 400; padding: .25rem .7rem; border-radius: 999px; }
|
||||
.chip.on { background: var(--noxium); color: #12150f; border-color: var(--noxium); font-weight: 600; }
|
||||
.pool { display: inline-block; padding: .4rem .8rem; border: 1px solid var(--noxium); border-radius: var(--radius); margin: .4rem 0 1rem; }
|
||||
.pool.over { border-color: var(--danger); color: var(--danger); }
|
||||
.attrs, .skills { display: grid; gap: .5rem; }
|
||||
.attr, .skill { display: flex; justify-content: space-between; align-items: center; gap: 1rem; flex-wrap: wrap; }
|
||||
.skill { background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); padding: .5rem .8rem; }
|
||||
.skill.locked { opacity: .75; }
|
||||
.skills { grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); }
|
||||
.stepper { display: flex; align-items: center; gap: .3rem; }
|
||||
.stepper button { padding: .25rem .6rem; min-width: 2.2rem; background: var(--panel2); color: var(--text); border-color: var(--line); }
|
||||
.stepper .val { min-width: 2.2rem; text-align: center; font-weight: 700; font-size: 1.1rem; }
|
||||
.stepper .mod { min-width: 4rem; text-align: right; color: var(--diesium); font-size: .9rem; }
|
||||
|
||||
.nav { display: flex; justify-content: space-between; gap: .5rem; margin-top: 1.5rem; padding-top: 1rem; border-top: 1px solid var(--line); }
|
||||
.actions { display: flex; gap: .5rem; } .actions.wrap { flex-wrap: wrap; margin-top: 1rem; }
|
||||
.warn { color: var(--danger); border: 1px solid var(--danger); border-radius: var(--radius); padding: .5rem 1rem .5rem 2rem; }
|
||||
p.warn { padding: .5rem 1rem; }
|
||||
.toast { position: sticky; bottom: 1rem; background: var(--panel2); border: 1px solid var(--diesium); padding: .5rem 1rem; border-radius: var(--radius); }
|
||||
.sheet-name { font-size: 1.6rem; color: var(--text); margin: 0; }
|
||||
.sheet-cols { display: grid; grid-template-columns: 1fr 1fr; gap: 0 2rem; }
|
||||
|
||||
.rules { display: grid; grid-template-columns: 220px 1fr; gap: 2rem; }
|
||||
.rules aside { position: sticky; top: 4.5rem; align-self: start; max-height: calc(100vh - 6rem); overflow: auto; }
|
||||
.rules aside ul { list-style: none; padding: 0; margin: .8rem 0; }
|
||||
.rules aside a { display: block; padding: .3rem .5rem; text-decoration: none; color: var(--text); border-left: 2px solid var(--line); }
|
||||
.rules aside a:hover { border-color: var(--noxium); } .rules aside a.dim { opacity: .35; }
|
||||
.rules section { margin-bottom: 2.5rem; scroll-margin-top: 5rem; }
|
||||
.rules ul { padding-left: 1.2rem; }
|
||||
.narrow { max-width: 760px; }
|
||||
.charlist { list-style: none; padding: 0; display: grid; gap: .6rem; }
|
||||
.row { display: flex; justify-content: space-between; align-items: center; gap: 1rem; flex-wrap: wrap; }
|
||||
|
||||
@media (max-width: 800px) {
|
||||
.creator-body { grid-template-columns: 1fr; } .live { position: static; order: -1; }
|
||||
.rules { grid-template-columns: 1fr; } .rules aside { position: static; max-height: none; }
|
||||
.grid2, .sheet-cols { grid-template-columns: 1fr; }
|
||||
}
|
||||
@media print { .top, .nav, .steps, .live, .actions { display: none; } body { background: #fff; color: #000; } }
|
||||
|
||||
details { background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); margin: .4rem 0; padding: .4rem .8rem; }
|
||||
details summary { cursor: pointer; font-weight: 600; }
|
||||
.extras { display: grid; gap: .4rem; margin-top: .6rem; }
|
||||
.extra { display: flex; justify-content: space-between; align-items: center; gap: 1rem; flex-wrap: wrap; padding: .3rem 0; border-top: 1px solid var(--line); }
|
||||
.resets { display: flex; gap: .5rem; flex-wrap: wrap; justify-content: center; }
|
||||
|
||||
button.unlock { background: transparent; color: var(--diesium); border-color: var(--diesium); }
|
||||
button.small-btn { padding: .15rem .5rem; font-size: .75rem; font-weight: 500; min-width: 0; }
|
||||
|
||||
.pools { display: flex; gap: .6rem; flex-wrap: wrap; }
|
||||
.pips { display: flex; align-items: center; gap: .1rem; margin-top: .15rem; }
|
||||
button.pip { background: transparent; border: none; color: var(--muted); padding: 0 .2rem; font-size: 1.05rem; line-height: 1; min-width: 0; font-weight: 400; }
|
||||
button.pip.on { color: var(--noxium); }
|
||||
button.pip.from-prof { color: var(--diesium); opacity: 1; cursor: default; }
|
||||
|
||||
.presetbox { margin-bottom: 1rem; }
|
||||
.tunables { display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: 0 1rem; }
|
||||
.presetchip { margin: 0 0 .5rem; }
|
||||
button.linklike { background: none; border: none; color: var(--noxium); padding: 0; font-weight: 600; text-decoration: underline; cursor: pointer; }
|
||||
|
||||
.presets-layout { display: grid; grid-template-columns: 240px 1fr; gap: 1.5rem; align-items: start; margin-top: 1rem; }
|
||||
.presetlist { list-style: none; padding: 0; margin: 0 0 .6rem; display: grid; gap: .4rem; }
|
||||
.presetlist button.card.choice { width: 100%; }
|
||||
@media (max-width: 800px) { .presets-layout { grid-template-columns: 1fr; } }
|
||||
|
||||
.creator-body { grid-template-columns: minmax(0, 1fr) 460px; }
|
||||
.creator-body.wide { grid-template-columns: 1fr; }
|
||||
.sheetpanel { max-height: calc(100vh - 6rem); overflow: auto; font-size: .9rem; }
|
||||
.sheetpanel h3.sheet-name { font-size: 1.15rem; }
|
||||
.sheetpanel h4 { margin: .8em 0 .2em; font-size: .85rem; text-transform: uppercase; letter-spacing: .05em; }
|
||||
.sheetpanel td { padding: .12rem .3rem; border-bottom: 1px solid var(--line); }
|
||||
.sheetpanel td:nth-child(2) { text-align: right; white-space: nowrap; }
|
||||
.applybar { position: sticky; top: 0; z-index: 1; display: flex; justify-content: space-between; align-items: center; gap: .5rem; flex-wrap: wrap; padding: .4rem .5rem; margin: .3rem 0 .5rem; border-radius: var(--radius); background: var(--panel2); border: 1px solid var(--line); }
|
||||
.applybar.pending { border-color: var(--diesium); }
|
||||
.applybar button { padding: .2rem .7rem; font-size: .85rem; }
|
||||
.d-added td { color: var(--noxium); font-weight: 600; }
|
||||
.d-changed td:nth-child(2) { color: var(--diesium); font-weight: 600; }
|
||||
.d-removed td { color: var(--danger); text-decoration: line-through; }
|
||||
.was { color: var(--muted); font-weight: 400; }
|
||||
@media (max-width: 800px) { .creator-body { grid-template-columns: 1fr; } .sheetpanel { position: static; max-height: none; order: 1; } }
|
||||
.rowdesc { font-size: .8rem; color: var(--muted); font-weight: 400; line-height: 1.3; text-decoration: none; }
|
||||
.d-removed .rowdesc { text-decoration: line-through; }
|
||||
.d-added .rowdesc { color: var(--muted); }
|
||||
ul.desclist { list-style: none; padding: 0; margin: 0 0 .5rem; display: grid; gap: .35rem; }
|
||||
main { max-width: 1280px; }
|
||||
@media (max-width: 1100px) { .creator-body { grid-template-columns: minmax(0, 1fr) 360px; } }
|
||||
@media (max-width: 800px) { .creator-body { grid-template-columns: 1fr; } }
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// Dev: `npm run dev` im Ordner frontend, Backend separat auf Port 8000.
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: { proxy: { '/api': 'http://localhost:8000' } },
|
||||
})
|
||||
Reference in New Issue
Block a user