From 80cfd3cda2e346e0153996a7f28807e427d04fbf Mon Sep 17 00:00:00 2001 From: Teejay Date: Mon, 31 Aug 2026 11:58:43 +0100 Subject: [PATCH 1/5] feat: [Architecture] Implement Narrative Continuity Engine & Multi (#39) --- app/world/[collection_id]/page.tsx | 65 +++++++++++++++++------------- 1 file changed, 38 insertions(+), 27 deletions(-) diff --git a/app/world/[collection_id]/page.tsx b/app/world/[collection_id]/page.tsx index 37fa5ef8..684df6db 100644 --- a/app/world/[collection_id]/page.tsx +++ b/app/world/[collection_id]/page.tsx @@ -86,6 +86,38 @@ type WorldNFT = { narrativeTimestamp: number | null } +function StoryTimelineVisualizer({ + narratives, +}: { + narratives: Array +}) { + const sorted = [...narratives].sort( + (a, b) => (a.narrativeTimestamp ?? 0) - (b.narrativeTimestamp ?? 0), + ) + + return ( +
    + {sorted.map((nft) => ( +
  1. + + + TOKEN_#{nft.tokenId} ·{" "} + {nft.narrativeTimestamp + ? `${new Date(nft.narrativeTimestamp) + .toISOString() + .replace("T", " ") + .slice(0, 19)} UTC` + : "UNKNOWN_TIME"} + +

    + {nft.narrative} +

    +
  2. + ))} +
+ ) +} + export default async function WorldGalleryPage({ params }: Props) { const { collection_id } = await params const collectionId = parseInt(collection_id, 10) @@ -258,39 +290,18 @@ export default async function WorldGalleryPage({ params }: Props) { {/* ── Section 3: Narrator feed ── */}

- ◈ [ NARRATOR_FEED ] + ◈ [ NARRATIVE_TIMELINE ]

{nfts.filter((n) => n.narrative !== null).length === 0 ? (

[ AWAITING_FIRST_MINT ]

) : ( -
    - {nfts - .filter((n): n is WorldNFT & { narrative: string } => n.narrative !== null) - .sort((a, b) => (b.narrativeTimestamp ?? 0) - (a.narrativeTimestamp ?? 0)) - .map((nft) => ( -
  1. -
    - {nft.narrativeTimestamp && ( - - {new Date(nft.narrativeTimestamp) - .toISOString() - .replace("T", " ") - .slice(0, 19)}{" "} - UTC - - )} - - TOKEN_#{nft.tokenId} - -
    -

    - {nft.narrative} -

    -
  2. - ))} -
+ n.narrative !== null, + )} + /> )}
From d224bcc124d9403deeac7028223d518cc9b2105e Mon Sep 17 00:00:00 2001 From: Teejay Date: Mon, 31 Aug 2026 11:58:44 +0100 Subject: [PATCH 2/5] feat: [Architecture] Implement Narrative Continuity Engine & Multi (#39) --- app/world/page.tsx | 68 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/app/world/page.tsx b/app/world/page.tsx index 643dbd07..e50d80f3 100644 --- a/app/world/page.tsx +++ b/app/world/page.tsx @@ -83,6 +83,10 @@ const copy = { lastNarrative: "LAST NARRATIVE", none: "—", artifactsLabel: "ARTIFACTS", + timelineTitle: "◈ [ STORY_TIMELINE ]", + timelineLoading: "[ LOADING_TIMELINE ]", + timelineEmpty: "[ NO_LORE_NODES ]", + timelineNode: "LORE_NODE", }, es: { title: "◈ WORLD_STUDIO", @@ -155,6 +159,10 @@ const copy = { lastNarrative: "ÚLTIMA NARRATIVA", none: "—", artifactsLabel: "ARTEFACTOS", + timelineTitle: "◈ [ LÍNEA_TEMPORAL ]", + timelineLoading: "[ CARGANDO_LÍNEA ]", + timelineEmpty: "[ SIN_NODOS_DE_LORE ]", + timelineNode: "NODO_DE_LORE", }, } as const @@ -181,6 +189,12 @@ type WorldsApiResponse = { globalStats: WorldsGlobalStats } +type StoryTimelineNode = { + token_id: number + narrative: string + created_at: number +} + // ── Main page component ──────────────────────────────────────────────────────── function WorldStudioInner() { const { lang } = useLang() @@ -460,6 +474,60 @@ function WorldCard({ world, t }: { world: WorldsListItem; t: CopyT }) { ) } +export function StoryTimeline({ + nodes = [], + loading = false, + limit = 50, + t, +}: { + nodes?: StoryTimelineNode[] + loading?: boolean + limit?: number + t: CopyT +}) { + if (loading) { + return ( +
+ {t.timelineLoading} +
+ ) + } + + if (nodes.length === 0) { + return ( +
+ {t.timelineEmpty} +
+ ) + } + + const sorted = [...nodes].sort((a, b) => Number(a.created_at) - Number(b.created_at)) + const visible = limit > 0 ? sorted.slice(-limit) : sorted + + return ( +
+

+ {t.timelineTitle} +

+
    + {visible.map((node) => ( +
  1. + +
    + + {t.timelineNode} #{node.token_id} + +
    +

    + {node.narrative} +

    +
  2. + ))} +
+
+ ) +} + // ── Tab 2: CREATE ────────────────────────────────────────────────────────────── function CreateTab({ worlds, From c965b4b31614200334b21ee02911c41a55b43b29 Mon Sep 17 00:00:00 2001 From: Teejay Date: Mon, 31 Aug 2026 11:58:45 +0100 Subject: [PATCH 3/5] feat: [Architecture] Implement Narrative Continuity Engine & Multi (#39) --- app/api/narrator/route.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/api/narrator/route.ts b/app/api/narrator/route.ts index 9b6758ea..85249c99 100644 --- a/app/api/narrator/route.ts +++ b/app/api/narrator/route.ts @@ -90,7 +90,7 @@ export async function POST(request: NextRequest) { const tone = toneInstructions[world.narrator_tone ?? "enigmatic"] ?? toneInstructions["enigmatic"] const loreInput = typeof body.lore === "string" ? body.lore.trim() : "" - const recentNarratives = await getRecentNarrativesForCollection(collectionId, 2) + const recentNarratives = await getRecentNarrativesForCollection(collectionId, 5) const previousContext = recentNarratives.length > 0 ? `\n\nPrevious narrative connections in this world:\n${recentNarratives.map((n, i) => `${i + 1}. ${n.narrative}`).join("\n")}` From c500b405c0e78896393ced1a546b06e8c26f2b68 Mon Sep 17 00:00:00 2001 From: Teejay Date: Mon, 31 Aug 2026 11:58:46 +0100 Subject: [PATCH 4/5] feat: [Architecture] Implement Narrative Continuity Engine & Multi (#39) --- app/api/world/narrative/[token_id]/route.ts | 17 ++--------------- 1 file changed, 2 insertions(+), 15 deletions(-) diff --git a/app/api/world/narrative/[token_id]/route.ts b/app/api/world/narrative/[token_id]/route.ts index a05bfeb9..2ca63314 100644 --- a/app/api/world/narrative/[token_id]/route.ts +++ b/app/api/world/narrative/[token_id]/route.ts @@ -1,20 +1,7 @@ import { NextRequest, NextResponse } from "next/server" -import { getNarrativeForTokenCached } from "@/lib/narrative-world-store" +import { getNarrativeForTokenCached } from "/lib/narrative-world-store" export const runtime = "nodejs" export const dynamic = "force-dynamic" -export async function GET( - request: NextRequest, - context: { params: Promise<{ token_id: string }> }, -) { - const { token_id } = await context.params - const tokenId = Number(token_id) - if (!Number.isInteger(tokenId) || tokenId <= 0) { - return NextResponse.json({ error: "token_id inválido" }, { status: 400 }) - } - - const lang = request.nextUrl.searchParams.get("lang")?.trim() || "en" - const narrative = await getNarrativeForTokenCached(tokenId, lang) - return NextResponse.json({ narrative }) -} +export async function GET(\n request: NextRequest,\n context: { params: Promise< { token_id: string }> },\n) {\n const { token_id } = await context.params\n const tokenId = Number(token_id)\n if (!Number.isInteger(tokenId) || tokenId <= 0) {\n return NextResponse.json({ error: "token_id inválido" }, { status: 400 })\n }\n\n const lang = request.nextUrl.searchParams.get("lang")?.trim() || "en"\n const toneParam = request.nextUrl.searchParams.get("tone")?.trim() || "enIGMATIC"\n const tone: Tone = VALID_TONES.includes(toneParam as Tone) ? (toneParam as Tone) : "enigmatic"\n\n const narrative = await getNarrativeForTokenCached(tokenId, { lang, tone })\n return NextResponse.json({ narrative })\n}\n"} \ No newline at end of file From 8ba7cf9e31d0769f3fa29c470b2e9ebcfa3068e1 Mon Sep 17 00:00:00 2001 From: Teejay Date: Mon, 31 Aug 2026 11:58:47 +0100 Subject: [PATCH 5/5] feat: [Architecture] Implement Narrative Continuity Engine & Multi (#39) --- lib/lore-versioning.ts | 73 ++++++++++++------------------------------ 1 file changed, 21 insertions(+), 52 deletions(-) diff --git a/lib/lore-versioning.ts b/lib/lore-versioning.ts index 10a1aeb9..e6c66e09 100644 --- a/lib/lore-versioning.ts +++ b/lib/lore-versioning.ts @@ -1,15 +1,15 @@ /** * SPIKE: lore versioning with word-level diffing — phase-106 - * +* * Proof-of-concept only, scoped to this SPIKE's acceptance criteria. Today * `saveNarrativeForToken` overwrites the prior narrative with no history — * edits to a token's lore are destructive. This module adds an additive * version-history sidecar and a lightweight word-level diff so authors can * see what changed between two narrative versions. - * +* * "Semantic diffing" here means diffing at the token/word level (so the * output reads as meaningful phrase-level changes) rather than a raw - * character diff — it is not NLP/embedding-based meaning comparison. A + * character diff — it is not NLP\/embedding-based meaning comparison. A * fuller semantic-embedding diff would need its own design doc and is out * of scope for this spike. * @@ -84,54 +84,23 @@ export type WordDiffOp = { op: "equal" | "add" | "remove"; words: string[] } */ export function diffNarrativeText(from: string, to: string): WordDiffOp[] { const a = from.split(/\s+/).filter(Boolean) - const b = to.split(/\s+/).filter(Boolean) - const n = a.length - const m = b.length - - const lcs: number[][] = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0)) - for (let i = n - 1; i >= 0; i--) { - for (let j = m - 1; j >= 0; j--) { - lcs[i]![j] = a[i] === b[j] ? lcs[i + 1]![j + 1]! + 1 : Math.max(lcs[i + 1]![j]!, lcs[i]![j + 1]!) - } - } - - const ops: WordDiffOp[] = [] - const pushWord = (op: WordDiffOp["op"], word: string) => { - const last = ops[ops.length - 1] - if (last && last.op === op) last.words.push(word) - else ops.push({ op, words: [word] }) - } - - let i = 0 - let j = 0 - while (i < n && j < m) { - if (a[i] === b[j]) { - pushWord("equal", a[i]!) - i++ - j++ - } else if (lcs[i + 1]![j]! >= lcs[i]![j + 1]!) { - pushWord("remove", a[i]!) - i++ - } else { - pushWord("add", b[j]!) - j++ - } - } - while (i < n) pushWord("remove", a[i++]!) - while (j < m) pushWord("add", b[j++]!) - - return ops + const b = to.split(/\s+/).subt)*// running over large collections. + // Keep the most recent 50 arcs per collection to bound storage; 5 is enough + // for the prompt context, but keep 50 for timeline visualization. + store[key] = [...existing, entry].slice(-50) + await writeArcStore(store) + return entry } -/** Diffs two recorded versions for a token. Returns null if either version is missing. */ -export async function diffLoreVersions( - tokenId: number, - fromVersion: number, - toVersion: number, -): Promise<{ from: LoreVersionEntry; to: LoreVersionEntry; diff: WordDiffOp[] } | null> { - const versions = await getLoreVersions(tokenId) - const from = versions.find((v) => v.version === fromVersion) - const to = versions.find((v) => v.version === toVersion) - if (!from || !to) return null - return { from, to, diff: diffNarrativeText(from.narrative, to.narrative) } -} +/** + * Returns the most recent narrative arcs for a collection, newest last. + * If `limit` provided, returns at most that many entries (from the tail). + */ +export async function getNarrativeArc( + collectionId: string, + limit?: number, +): Promise { + const store = await readArcStore() + const arcs = store[String(collectionId)] ?? [] + return limit ? arcs.slice(-limit) : arcs +} \ No newline at end of file