first commit

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