NPC editor, NPC tokens from library, admin management

- NPC library (own + shared) built on the character wizard with a free NPC preset
- NPC tokens in map editor and lobby, DM sheet on token click, DM checks for NPCs
- Admins can promote/demote other users

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
This commit is contained in:
Claude
2026-10-23 21:36:09 +00:00
parent 57f262b6aa
commit 1db8a45cae
13 changed files with 364 additions and 86 deletions
+40 -17
View File
@@ -14,9 +14,13 @@ function readDraft(key) {
try { return JSON.parse(localStorage.getItem(key)) } catch { return null }
}
export default function Creator({ rules: baseRules, user, loadRequest, clearLoad, onName }) {
const DRAFT = `aw_draft:${user}` // drafts are kept per account
const freshCharacter = () => applyPreset(emptyCharacter(baseRules), baseRules, baseRules.default_preset || 'standard')
const NPC_KINDS = [['npc', 'NPC'], ['enemy', 'Enemy'], ['monster', 'Monster'], ['creature', 'Creature'], ['animal', 'Animal'], ['robot', 'Robot / machine']]
export default function Creator({ rules: baseRules, user, loadRequest, clearLoad, onName, npc = false }) {
const DRAFT = `${npc ? 'aw_draft_npc' : 'aw_draft'}:${user}` // drafts are kept per account
const freshCharacter = () => npc
? { ...applyPreset(emptyCharacter(baseRules), baseRules, 'npc'), npc: true, npc_kind: 'enemy', token_size: 1, abilities: '', loot: '' }
: applyPreset(emptyCharacter(baseRules), baseRules, baseRules.default_preset || 'standard')
const [ch, setCh] = useState(() => {
const d = readDraft(DRAFT)
return d?.ch ? normalize(d.ch, baseRules) : freshCharacter()
@@ -58,7 +62,7 @@ export default function Creator({ rules: baseRules, user, loadRequest, clearLoad
if (!loadRequest) return
if (loadRequest.new) { // "New character" button on the My characters page
const untouched = JSON.stringify(ch) === JSON.stringify(freshCharacter()) && !saved
if (untouched || window.confirm('Start a new character? Your current draft is discarded unless you saved it.')) {
if (untouched || window.confirm('Start a new ' + (npc ? 'NPC' : 'character') + '? Your current draft is discarded unless you saved it.')) {
setBoth(freshCharacter()); setSaved(null); setStep(0)
}
clearLoad()
@@ -74,7 +78,7 @@ export default function Creator({ rules: baseRules, user, loadRequest, clearLoad
// Direct link #creator/<id> (read only; saving creates a copy)
useEffect(() => {
const id = window.location.hash.split('/')[1]
if (!id || loadRequest) return
if (!id || loadRequest || npc) return
fetch(`/api/characters/${id}`).then((r) => { if (!r.ok) throw new Error(); return r.json() })
.then((data) => { setBoth(normalize(data, baseRules)); setSaved(null); setStep(7); setMsg('Shared character loaded. "Save" creates your own copy.') })
.catch(() => setMsg('Character not found, or it is not shared with you.'))
@@ -94,7 +98,7 @@ export default function Creator({ rules: baseRules, user, loadRequest, clearLoad
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)
setBoth({ ...emptyCharacter(rules), preset: ch.preset, creation: ch.creation, ...(npc ? { npc: true, npc_kind: ch.npc_kind, token_size: ch.token_size, abilities: '', loot: '' } : {}) }); setSaved(null); setStep(0)
}
const resetThisStep = () => {
if (!window.confirm(`Reset only the "${STEPS[step]}" page? Everything else is kept.`)) return
@@ -154,10 +158,25 @@ export default function Creator({ rules: baseRules, user, loadRequest, clearLoad
const stepConcept = (
<>
<h2>Concept</h2>
<div className="card presetbox">
{npc && (
<div className="card presetbox">
<div className="grid2">
<label>Type
<select value={ch.npc_kind || 'npc'} onChange={(e) => set({ npc_kind: e.target.value })}>
{NPC_KINDS.map(([id, label]) => <option key={id} value={id}>{label}</option>)}
</select>
</label>
<label>Token size (grid squares)
<input type="number" min="1" max="8" value={ch.token_size || 1} onChange={(e) => set({ token_size: Math.max(1, Math.min(8, Number(e.target.value) || 1)) })} />
</label>
</div>
<p className="small muted">NPCs use generous pools (attributes, skills, talents), so you can build anything from a rat to a boss.</p>
</div>
)}
{!npc && <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>)}
{(baseRules.presets || []).filter((p) => !p.npc).map((p) => <option key={p.id} value={p.id}>{p.name}</option>)}
{ch.preset === 'custom' && <option value="custom">Custom</option>}
</select>
</label>
@@ -167,11 +186,11 @@ export default function Creator({ rules: baseRules, user, loadRequest, clearLoad
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>}
<div className="card portraitbox"><PortraitEditor value={ch.portrait} onChange={(p) => set({ portrait: 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>
<label>{npc ? 'NPC name' : 'Character name'}<input value={ch.name} onChange={(e) => set({ name: e.target.value })} /></label>
{!npc && <label>Player<input value={ch.player} onChange={(e) => set({ player: e.target.value })} /></label>}
</div>
<div className="grid2">
<label>Class (optional)
@@ -189,7 +208,9 @@ export default function Creator({ rules: baseRules, user, loadRequest, clearLoad
</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>
<label>{npc ? 'Description' : 'Background'}<textarea rows="5" value={ch.background} onChange={(e) => set({ background: e.target.value })} /></label>
{npc && <label>Special abilities and attacks<textarea rows="5" value={ch.abilities || ''} onChange={(e) => set({ abilities: e.target.value })} placeholder="e.g. Bite 2d6, poison spit (range 10, VIT check or poisoned), regenerates 2 HP per round" /></label>}
{npc && <label>Loot and drops<textarea rows="3" value={ch.loot || ''} onChange={(e) => set({ loot: e.target.value })} placeholder="e.g. 2 scrap, hide, rusty knife" /></label>}
</>
)
@@ -419,6 +440,7 @@ export default function Creator({ rules: baseRules, user, loadRequest, clearLoad
)
const warnings = []
if (npc) { if (c.attrLeft < 0) warnings.push('Too many attribute points spent.'); if (c.skillLeft < 0) warnings.push('More skill points spent than the pool allows.') } else {
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.')
@@ -432,6 +454,7 @@ export default function Creator({ rules: baseRules, user, loadRequest, clearLoad
if (ch.talents.length < cr.talents_min) warnings.push('No starting talent chosen.')
if (over) warnings.push('Augmentation Capacity exceeded.')
if (overWeight) warnings.push('Carrying more than the carry capacity.')
}
const gearLines = kitItems(ch, rules)
const stepSummary = (
@@ -440,14 +463,14 @@ export default function Creator({ rules: baseRules, user, loadRequest, clearLoad
{warnings.length > 0 && <ul className="warn">{warnings.map((w) => <li key={w}>{w}</li>)}</ul>}
<PaperSheet data={ch} baseRules={baseRules} />
<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>
{!npc && <button onClick={() => download('pdf')}>Download as PDF</button>}
{!npc && <button onClick={() => download('docx')}>Download as Word</button>}
<button className="ghost" onClick={save}>{saved ? 'Save changes' : npc ? 'Save NPC' : '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 && (
{saved && !npc && (
<p className="small muted">Share link (view only): <code>{`${window.location.origin}/#creator/${saved.id}`}</code></p>
)}
</>
@@ -457,7 +480,7 @@ export default function Creator({ rules: baseRules, user, loadRequest, clearLoad
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>
{!npc && <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)}>