Add a persistent, book-scoped Character Library (new Characters section, IndexedDB) that auto-fills from Character-sheet analysis with editable cards. Enrich extraction with six narrative fields (backstory, relationships, motivation, fears, mannerisms, voice/speech) plus the greyscale Good↔Evil alignment bar, arc arrow, and 5-area Deep Analysis. Add ⋯ separators between non-contiguous passages in Recast unknown, and harden dialogue attribution against hallucination with same-language emotion tags. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
282 lines
13 KiB
JavaScript
282 lines
13 KiB
JavaScript
// ── Character library ─────────────────────────────────────────────────────────
|
||
//
|
||
// Persistent, book-scoped character sheets. Running "Character sheets" in Read
|
||
// Aloud or the Rehearser upserts each extracted character into this library,
|
||
// keyed by (book + name). Cards reuse the renderers from character-sheets.js
|
||
// (csCardHtml, csAlignmentBar, csDeepAnalysis), which loads before this module.
|
||
|
||
const CL_DB_NAME = 'character-library';
|
||
const CL_STORE = 'characters';
|
||
|
||
// Fields a user can edit, mirroring CS_SCALAR_FIELDS plus the labelled basics.
|
||
const CL_EDIT_FIELDS = [
|
||
['name', 'Name'], ['aliases', 'Aliases'], ['archetype', 'Archetype'],
|
||
['physical', 'Physical'], ['clothing', 'Clothing & Appearance'],
|
||
['alignment', 'Alignment & Ethos'], ['arc_note', 'Arc note'],
|
||
['skills', 'Trained Skills'], ['capabilities', 'Capabilities'],
|
||
['backstory', 'Backstory & Origin'], ['relationships', 'Relationships'],
|
||
['motivation', 'Motivation'], ['fears', 'Fears'], ['mannerisms', 'Mannerisms & Habits'],
|
||
['voice_pattern', 'Voice & Speech'], ['secret', 'Dark Secret / Fatal Flaw'],
|
||
['conflict_style', 'Conflict Style'], ['win_condition', 'Win Condition'],
|
||
];
|
||
|
||
// ── IndexedDB ─────────────────────────────────────────────────────────────────
|
||
|
||
function clDbOpen() {
|
||
return new Promise((resolve, reject) => {
|
||
const req = indexedDB.open(CL_DB_NAME, 1);
|
||
req.onupgradeneeded = e => {
|
||
const db = e.target.result;
|
||
if (!db.objectStoreNames.contains(CL_STORE)) {
|
||
const store = db.createObjectStore(CL_STORE, { keyPath: 'id' });
|
||
store.createIndex('book', 'book', { unique: false });
|
||
}
|
||
};
|
||
req.onsuccess = e => resolve(e.target.result);
|
||
req.onerror = e => reject(e.target.error);
|
||
});
|
||
}
|
||
|
||
async function clDbOp(mode, fn) {
|
||
const db = await clDbOpen();
|
||
return new Promise((resolve, reject) => {
|
||
const tx = db.transaction(CL_STORE, mode);
|
||
const req = fn(tx.objectStore(CL_STORE));
|
||
req.onsuccess = e => resolve(e.target.result);
|
||
req.onerror = e => reject(e.target.error);
|
||
});
|
||
}
|
||
|
||
async function clGetAll() {
|
||
const db = await clDbOpen();
|
||
return new Promise((resolve, reject) => {
|
||
const req = db.transaction(CL_STORE, 'readonly').objectStore(CL_STORE).getAll();
|
||
req.onsuccess = e => resolve(e.target.result || []);
|
||
req.onerror = e => reject(e.target.error);
|
||
});
|
||
}
|
||
|
||
async function clGet(id) { return clDbOp('readonly', s => s.get(id)); }
|
||
async function clPut(rec) { return clDbOp('readwrite', s => s.put(rec)); }
|
||
async function clDelete(id) { return clDbOp('readwrite', s => s.delete(id)); }
|
||
|
||
function clKey(book, name) {
|
||
return `${String(book || '').trim()}::${String(name || '').trim()}`.toLowerCase();
|
||
}
|
||
|
||
// Merge an incoming sheet into an existing one without blanking solid facts —
|
||
// same rule as csMerge in character-sheets.js (longer value wins; sources append;
|
||
// alignment score averages).
|
||
function clMergeSheet(existing, incoming) {
|
||
const e = { ...existing };
|
||
const scalars = (typeof CS_SCALAR_FIELDS !== 'undefined') ? CS_SCALAR_FIELDS
|
||
: CL_EDIT_FIELDS.map(f => f[0]).filter(k => k !== 'name');
|
||
scalars.forEach(f => { if ((incoming[f] || '').length > (e[f] || '').length) e[f] = incoming[f]; });
|
||
if (incoming.tier === 'main') e.tier = 'main';
|
||
if (incoming.moral_alignment_score != null) {
|
||
e.moral_alignment_score = e.moral_alignment_score != null
|
||
? Math.round((e.moral_alignment_score + incoming.moral_alignment_score) / 2)
|
||
: incoming.moral_alignment_score;
|
||
}
|
||
if (incoming.arc_direction && incoming.arc_direction !== 'neutral') e.arc_direction = incoming.arc_direction;
|
||
e.inventory = [...(existing.inventory || [])];
|
||
(incoming.inventory || []).forEach(it => { if (it && !e.inventory.includes(it) && e.inventory.length < 3) e.inventory.push(it); });
|
||
e.sources = [...(existing.sources || [])];
|
||
(incoming.sources || []).forEach(src => {
|
||
if (src && src.quote && e.sources.length < 5 && !e.sources.some(x => x.quote === src.quote)) e.sources.push(src);
|
||
});
|
||
return e;
|
||
}
|
||
|
||
// Upsert one sheet into the library under a book. Returns the stored record.
|
||
async function clUpsert(book, sheet) {
|
||
const name = (sheet.name || '').trim();
|
||
if (!name) return null;
|
||
const bk = (book || '').trim() || 'Unsorted';
|
||
const id = clKey(bk, name);
|
||
const now = new Date();
|
||
const prev = await clGet(id).catch(() => null);
|
||
const merged = prev ? clMergeSheet(prev.sheet || {}, sheet) : { ...sheet, name };
|
||
const rec = {
|
||
id, book: bk, name,
|
||
sheet: merged,
|
||
analysis: prev?.analysis || null,
|
||
voice: prev?.voice || null,
|
||
created: prev?.created || now,
|
||
updated: now,
|
||
};
|
||
await clPut(rec);
|
||
return rec;
|
||
}
|
||
|
||
// Bulk upsert a list of sheets for one book (used by the analysis side-effect).
|
||
async function clUpsertMany(book, sheets) {
|
||
let n = 0;
|
||
for (const s of (sheets || [])) { if (await clUpsert(book, s)) n++; }
|
||
return n;
|
||
}
|
||
|
||
// ── Rendering ─────────────────────────────────────────────────────────────────
|
||
|
||
let _clRecords = [];
|
||
|
||
async function clRender() {
|
||
const grid = document.getElementById('cl-grid');
|
||
if (!grid) return;
|
||
// Bind filter/search once the section fragment exists (it loads after this JS).
|
||
const filterEl = document.getElementById('cl-book-filter');
|
||
const searchEl = document.getElementById('cl-search');
|
||
if (filterEl && !filterEl.dataset.bound) { filterEl.dataset.bound = '1'; filterEl.addEventListener('change', clApplyFilter); }
|
||
if (searchEl && !searchEl.dataset.bound) { searchEl.dataset.bound = '1'; searchEl.addEventListener('input', clApplyFilter); }
|
||
try { _clRecords = await clGetAll(); } catch (e) { _clRecords = []; }
|
||
|
||
// Populate the book filter (preserve current selection if still present).
|
||
const filter = document.getElementById('cl-book-filter');
|
||
const books = [...new Set(_clRecords.map(r => r.book))].sort((a, b) => a.localeCompare(b));
|
||
if (filter) {
|
||
const cur = filter.value;
|
||
filter.innerHTML = `<option value="">All books (${books.length})</option>` +
|
||
books.map(b => `<option value="${escHtml(b)}">${escHtml(b)}</option>`).join('');
|
||
if (cur && books.includes(cur)) filter.value = cur;
|
||
}
|
||
clApplyFilter();
|
||
}
|
||
|
||
function clApplyFilter() {
|
||
const grid = document.getElementById('cl-grid');
|
||
if (!grid) return;
|
||
const book = document.getElementById('cl-book-filter')?.value || '';
|
||
const q = (document.getElementById('cl-search')?.value || '').trim().toLowerCase();
|
||
|
||
let recs = _clRecords.slice();
|
||
if (book) recs = recs.filter(r => r.book === book);
|
||
if (q) recs = recs.filter(r =>
|
||
(r.name || '').toLowerCase().includes(q) ||
|
||
(r.sheet?.aliases || '').toLowerCase().includes(q) ||
|
||
(r.sheet?.archetype || '').toLowerCase().includes(q) ||
|
||
(r.book || '').toLowerCase().includes(q));
|
||
|
||
if (!recs.length) {
|
||
grid.innerHTML = `<div class="cl-empty">
|
||
<span class="mdi mdi-account-box-multiple-outline" style="font-size:48px;opacity:0.4"></span>
|
||
<p>${_clRecords.length ? 'No characters match your filter.' : 'No characters yet.'}</p>
|
||
<p class="cl-empty-hint">Run <b>Character sheets</b> from Read Aloud or the Script Rehearser to populate your library.</p>
|
||
</div>`;
|
||
return;
|
||
}
|
||
|
||
// Group by book.
|
||
const byBook = new Map();
|
||
for (const r of recs) { if (!byBook.has(r.book)) byBook.set(r.book, []); byBook.get(r.book).push(r); }
|
||
const groups = [...byBook.keys()].sort((a, b) => a.localeCompare(b));
|
||
|
||
grid.innerHTML = groups.map(bk => {
|
||
const list = byBook.get(bk).sort((a, b) => {
|
||
const t = (a.sheet?.tier === 'main' ? 0 : 1) - (b.sheet?.tier === 'main' ? 0 : 1);
|
||
return t || (a.name || '').localeCompare(b.name || '');
|
||
});
|
||
return `<div class="cl-book-group">
|
||
<div class="cl-book-head"><span class="mdi mdi-book-open-page-variant-outline"></span> ${escHtml(bk)}
|
||
<span class="cl-book-count">${list.length}</span></div>
|
||
<div class="cl-book-cards">${list.map(clCardHtml).join('')}</div>
|
||
</div>`;
|
||
}).join('');
|
||
}
|
||
|
||
function clCardHtml(rec) {
|
||
return `<div class="cl-card-wrap" data-id="${escHtml(rec.id)}">
|
||
<div class="cl-card-tools">
|
||
<button class="btn-secondary btn-sm cl-edit" data-id="${escHtml(rec.id)}" title="Edit this character"><span class="mdi mdi-pencil-outline"></span> Edit</button>
|
||
<button class="btn-secondary btn-sm cl-delete" data-id="${escHtml(rec.id)}" title="Delete from library"><span class="mdi mdi-trash-can-outline"></span></button>
|
||
</div>
|
||
${csCardHtml(rec.sheet)}
|
||
</div>`;
|
||
}
|
||
|
||
// ── Edit modal ──────────────────────────────────────────────────────────────
|
||
|
||
function clEdit(id) {
|
||
const rec = _clRecords.find(r => r.id === id);
|
||
if (!rec) return;
|
||
const s = rec.sheet || {};
|
||
const modal = document.createElement('div');
|
||
modal.className = 'audiobook-overlay';
|
||
const rows = CL_EDIT_FIELDS.map(([k, label]) => {
|
||
const multiline = !['name', 'aliases', 'archetype'].includes(k);
|
||
const val = escHtml(String(s[k] || ''));
|
||
return `<label class="cl-edit-row"><span>${label}</span>${
|
||
multiline
|
||
? `<textarea data-field="${k}" rows="2">${val}</textarea>`
|
||
: `<input type="text" data-field="${k}" value="${val}">`
|
||
}</label>`;
|
||
}).join('');
|
||
modal.innerHTML = `<div class="audiobook-box cl-edit-box">
|
||
<div class="cs-titlebar">
|
||
<span class="audiobook-title"><span class="mdi mdi-pencil-outline"></span> Edit — ${escHtml(rec.name)}</span>
|
||
<span style="flex:1"></span>
|
||
<button class="btn-secondary btn-sm" id="cl-edit-cancel">Cancel</button>
|
||
<button class="btn-primary btn-sm" id="cl-edit-save"><span class="mdi mdi-content-save-outline"></span> Save</button>
|
||
</div>
|
||
<div class="cl-edit-body">
|
||
<label class="cl-edit-row"><span>Moral alignment (0 evil – 100 good)</span>
|
||
<input type="number" min="0" max="100" data-field="moral_alignment_score" value="${s.moral_alignment_score ?? 50}"></label>
|
||
<label class="cl-edit-row"><span>Arc direction</span>
|
||
<select data-field="arc_direction">${
|
||
['stable-good', 'stable-bad', 'neutral', 'good-to-bad', 'bad-to-good', 'complex']
|
||
.map(o => `<option value="${o}"${(s.arc_direction || 'neutral') === o ? ' selected' : ''}>${o}</option>`).join('')
|
||
}</select></label>
|
||
${rows}
|
||
</div>
|
||
</div>`;
|
||
document.body.appendChild(modal);
|
||
const close = () => modal.remove();
|
||
modal.querySelector('#cl-edit-cancel').addEventListener('click', close);
|
||
modal.addEventListener('click', e => { if (e.target === modal) close(); });
|
||
modal.querySelector('#cl-edit-save').addEventListener('click', async () => {
|
||
const patch = {};
|
||
modal.querySelectorAll('[data-field]').forEach(el => { patch[el.dataset.field] = el.value; });
|
||
const newName = (patch.name || '').trim() || rec.name;
|
||
let mas = parseInt(patch.moral_alignment_score, 10);
|
||
mas = isNaN(mas) ? 50 : Math.max(0, Math.min(100, mas));
|
||
const newSheet = { ...s, ...patch, name: newName, moral_alignment_score: mas };
|
||
// If the name changed, the key changes too — delete old, write new.
|
||
const newId = clKey(rec.book, newName);
|
||
rec.sheet = newSheet; rec.name = newName; rec.updated = new Date();
|
||
try {
|
||
if (newId !== rec.id) { await clDelete(rec.id); rec.id = newId; }
|
||
await clPut(rec);
|
||
toast('Character saved', 'success');
|
||
close();
|
||
clRender();
|
||
} catch (e) { toast('Save failed: ' + (e.message || e), 'error'); }
|
||
});
|
||
}
|
||
|
||
async function clDeleteRec(id) {
|
||
const rec = _clRecords.find(r => r.id === id);
|
||
if (!rec) return;
|
||
if (!confirm(`Delete "${rec.name}" from ${rec.book}?`)) return;
|
||
try { await clDelete(id); toast('Deleted', 'success'); clRender(); }
|
||
catch (e) { toast('Delete failed: ' + (e.message || e), 'error'); }
|
||
}
|
||
|
||
// ── Wiring ──────────────────────────────────────────────────────────────────
|
||
|
||
// Document-level delegation — the #cl-grid is (re)built on every render and the
|
||
// section fragment loads after this script, so we can't bind to it directly.
|
||
document.addEventListener('click', e => {
|
||
const edit = e.target.closest?.('.cl-edit');
|
||
if (edit) { e.stopPropagation(); clEdit(edit.dataset.id); return; }
|
||
const del = e.target.closest?.('.cl-delete');
|
||
if (del) { e.stopPropagation(); clDeleteRec(del.dataset.id); return; }
|
||
const deep = e.target.closest?.('.cl-card-wrap .cs-deep-btn');
|
||
if (deep) {
|
||
e.stopPropagation();
|
||
const rec = _clRecords.find(r => r.id === deep.closest('.cl-card-wrap')?.dataset.id);
|
||
if (rec && typeof csDeepAnalysis === 'function') csDeepAnalysis(rec.sheet, '');
|
||
}
|
||
});
|
||
|
||
window.clRender = clRender;
|
||
window.clUpsertMany = clUpsertMany;
|