feat(entity): Chunk #4 data layer + entity UI (characters & world-building)

Data layer:
- Fuzzy scorer (packages/core/src/fuzzy.ts) with index recovery + unit tests
- Backlink computation (packages/core/src/backlinks.ts) incl. entity-level backlinks
- Consistency checker (packages/core/src/consistency.ts) incl. reciprocity/scope + unit tests
- StorageAdapter contract extended with listEntities/searchEntities/saveEntity/deleteEntity;
  implemented in Local (Dexie) and Server adapters; contract tests cover entity CRUD + ?q= search
- WorldItem.fields now supports list values (keyPeople, rivals); expanded RelationshipKind
- @mention autocomplete extension + picker in the editor package

Entity UI (apps/web/src/features/entities/):
- Generic field renderer (EntityFields) driven by ENTITY_FIELDS/FieldDef
- Tabbed directory with fuzzy search + entity cards (EntitiesView)
- Create/edit/delete drawer (EntityEditor) reusing shared fields
- Relationship editor + force-directed SVG relationship map
- Entity engine stylesheet; wired into nav

Store: project-store entity methods (load/create/update/delete) over the StorageAdapter contract.

CI-green: typecheck, lint, 65 tests, and web build all pass.
This commit is contained in:
Schwitzz
2026-10-25 22:42:55 -05:00
parent 81a44b6e95
commit 4efa71515e
36 changed files with 2764 additions and 82 deletions
+130
View File
@@ -0,0 +1,130 @@
/**
* Consistency checks across entities and chapters. These catch the kinds of
* contradictions that creep in as a manuscript grows: a character mentioned in
* prose whose entity was deleted, or a relationship that only one side records.
* See CHUNK #4 step 2.4.
*/
import type { Character, Entity, ProseMirrorDoc } from "./types.js";
export type RelationshipSymmetry = "symmetric" | "directed";
/**
* Relationship kinds that should hold in both directions. If Ada lists "Bram:
* sibling", then Bram should list "Ada: sibling" too.
*/
export const SYMMETRIC_RELATIONSHIPS = new Set(["sibling", "friend", "spouse", "partner", "family", "rival"]);
export interface MentionIssue {
kind: "orphan-mention";
chapterId: string;
chapterTitle: string;
entityId: string;
/** The mention text, if any. */
name?: string | undefined;
}
export interface RelationshipIssue {
kind: "relationship-asymmetry";
fromEntity: string;
fromName: string;
toEntity: string;
toName: string;
relationship: string;
}
export type ConsistencyIssue = MentionIssue | RelationshipIssue;
/**
* Find mention nodes that reference an entity id that no longer exists (e.g.
* the entity was deleted). Returns one issue per offending mention.
*/
export function findOrphanedMentions(
chapters: Array<{ id: string; title: string; doc: ProseMirrorDoc }>,
entityIds: Set<string>,
): MentionIssue[] {
const issues: MentionIssue[] = [];
for (const ch of chapters) {
const visit = (content: unknown[], _path: number[]): void => {
for (const node of content as Record<string, unknown>[]) {
if (!node || typeof node !== "object") continue;
if (node.type === "CharacterMention") {
const attrs = node.attrs as { entityId?: string; name?: string } | undefined;
if (attrs?.entityId && !entityIds.has(attrs.entityId)) {
issues.push({
kind: "orphan-mention",
chapterId: ch.id,
chapterTitle: ch.title,
entityId: attrs.entityId,
name: attrs.name,
});
}
}
if (Array.isArray(node.content)) visit(node.content as unknown[], _path);
}
};
visit(ch.doc.content ?? [], []);
}
return issues;
}
/**
* Parse the `relationships` array of a character into
* `{ [targetId]: { kind, note } }`.
*/
export function parseRelationships(character: Character): Record<string, { kind: string; note: string }> {
const out: Record<string, { kind: string; note: string }> = {};
for (const r of character.relationships ?? []) {
out[r.targetId] = { kind: r.kind, note: r.note };
}
return out;
}
/**
* Find relationships recorded by one character but not reciprocated by the
* other, for symmetric relationship kinds. Returns one issue per asymmetric
* pair (reported once, from the recording side).
*/
export function findRelationshipAsymmetry(entities: Entity[]): RelationshipIssue[] {
const characters = entities.filter((e): e is Character => !("fields" in e) && !("body" in e));
// index by id -> character (targetId is an entity id)
const byId = new Map<string, Character>();
for (const c of characters) byId.set(c.id, c);
const issues: RelationshipIssue[] = [];
for (const c of characters) {
const rels = parseRelationships(c);
for (const [targetId, rel] of Object.entries(rels)) {
const sym = rel.kind.toLowerCase();
if (!SYMMETRIC_RELATIONSHIPS.has(sym)) continue;
const other = byId.get(targetId);
if (!other || other.id === c.id) continue;
// Does the other side record this character back with a symmetric kind?
const otherRel = parseRelationships(other);
const back = otherRel[c.id];
if (!back || !SYMMETRIC_RELATIONSHIPS.has(back.kind.toLowerCase())) {
issues.push({
kind: "relationship-asymmetry",
fromEntity: c.id,
fromName: c.name,
toEntity: other.id,
toName: other.name,
relationship: rel.kind,
});
}
}
}
return issues;
}
/** Run every consistency check and return the combined issue list. */
export function findInconsistencies(
chapters: Array<{ id: string; title: string; doc: ProseMirrorDoc }>,
entities: Entity[],
): ConsistencyIssue[] {
const entityIds = new Set(entities.map((e) => e.id));
const issues: ConsistencyIssue[] = [
...findOrphanedMentions(chapters, entityIds),
...findRelationshipAsymmetry(entities),
];
return issues;
}