Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions PROJECT_ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@ is a fixed-size digest of the canonical payload, keeping it under wallet

| Flag | Env | Purpose | Default | Rollback |
|------|-----|---------|---------|----------|
| `phase-105` | `NEXT_PUBLIC_FEATURE_PHASE_105` / `FEATURE_PHASE_105` | Co-authored world conflict detection: `POST /api/world` compares `expected_version` / `expected_vector_clock` (object, `Map` or `[node, counter]` entries — normalized before comparison) with the stored world and returns `409 WORLD_VERSION_CONFLICT` with `order` (`before`/`after`/`concurrent`) | off | Unset var, restart — saves overwrite unconditionally; stored `vector_clock` fields are ignored |
| `phase-109` | `NEXT_PUBLIC_FEATURE_PHASE_109` / `FEATURE_PHASE_109` | Collaborative world permissions: owner-enforced role assignment (`editor`/`viewer`) per wallet via `GET/POST /api/world/[collection_id]/roles`; POST requires `X-Wallet-Signature` | off | Unset var, restart — roles endpoint returns 404; existing role data on disk is unaffected |
| `phase-110` | `NEXT_PUBLIC_FEATURE_PHASE_110` / `FEATURE_PHASE_110` | Full-text narrative search across world collections: filter by entity ID, location, or free text via `GET /api/world/search` | off | Unset var, restart — search route returns 404 |
| `phase-112` | `NEXT_PUBLIC_FEATURE_PHASE_112` / `FEATURE_PHASE_112` | World export to portable formats (`json`, `markdown`) via `GET /api/world/[collection_id]/export?format=` with `Content-Disposition` download headers | off | Unset var, restart — export route returns 404 |
Expand Down
58 changes: 55 additions & 3 deletions app/api/world/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
type NarratorTone,
} from "@/lib/narrative-world-store"
import { checkAndUnlock } from "@/lib/achievement-store"
import { checkWorldConflict } from "@/lib/world-conflict"
import { checkWorldConflict, normalizeVectorClock, type VectorClock } from "@/lib/world-conflict"

export type WorldsListItem = {
collectionId: number
Expand All @@ -23,6 +23,8 @@ export type WorldsListItem = {
narrator_tone?: NarratorTone
/** Current revision — pass as `expected_version` on the next save (phase-105). */
version?: number
/** Per-author vector clock — pass as `expected_vector_clock` on the next save (phase-105). */
vector_clock?: VectorClock
}

export type WorldsGlobalStats = {
Expand All @@ -46,6 +48,7 @@ export async function GET() {
latestNarrative: narratives[0]?.narrative ?? null,
narrator_tone: data.narrator_tone,
version: data.version,
vector_clock: normalizeVectorClock(data.vector_clock) ?? undefined,
}
}),
)
Expand Down Expand Up @@ -80,6 +83,8 @@ type WorldSaveBody = {
creator_wallet?: unknown
/** Client's last-known world version — only checked when phase-105 is enabled. */
expected_version?: unknown
/** Client's last-known vector clock (object or `[node, counter]` entries) — phase-105. */
expected_vector_clock?: unknown
}

function isNonEmptyString(v: unknown): v is string {
Expand Down Expand Up @@ -129,7 +134,49 @@ export async function POST(request: NextRequest) {
? body.creator_wallet
: undefined

await saveWorldForCollection(collectionId, {
let expectedVersion: number | undefined
if (body.expected_version !== undefined) {
if (!Number.isSafeInteger(body.expected_version) || (body.expected_version as number) < 0) {
return NextResponse.json(
{ error: "expected_version debe ser un entero no negativo" },
{ status: 400 },
)
}
expectedVersion = body.expected_version as number
}

let expectedVectorClock: VectorClock | undefined
if (body.expected_vector_clock !== undefined) {
const normalized = normalizeVectorClock(body.expected_vector_clock)
if (!normalized) {
return NextResponse.json(
{ error: "expected_vector_clock inválido: se espera un objeto o una lista de pares [nodo, contador]" },
{ status: 400 },
)
}
expectedVectorClock = normalized
}

const conflict = checkWorldConflict(
await getWorldForCollection(collectionId),
expectedVersion,
expectedVectorClock,
)
if (conflict.conflict) {
return NextResponse.json(
{
error: "WORLD_VERSION_CONFLICT",
order: conflict.order,
server_version: conflict.serverVersion,
client_version: conflict.clientVersion,
server_vector_clock: conflict.serverVectorClock,
current: conflict.current,
},
{ status: 409 },
)
}

const saved = await saveWorldForCollection(collectionId, {
world_name: body.world_name.trim(),
world_prompt: body.world_prompt.trim(),
narrator_tone: isValidTone(body.narrator_tone) ? body.narrator_tone : undefined,
Expand All @@ -143,5 +190,10 @@ export async function POST(request: NextRequest) {
void checkAndUnlock(creatorWallet, { has_world: true }).catch(() => { /* silent */ })
}

return NextResponse.json({ ok: true, collection_id: collectionId })
return NextResponse.json({
ok: true,
collection_id: collectionId,
version: saved.version,
vector_clock: saved.vector_clock,
})
}
2 changes: 2 additions & 0 deletions docs/TECHNICAL.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ flowchart TB
| `lib/phase-copy.ts` | Centralized i18n dictionary (EN/ES) |
| `lib/server-data-paths.ts` | Writable data location abstraction |
| `lib/feature-flags.ts` | Flag registry (phase-107,111,113,114 + 116,117,119,120 + 121..124, env resolution, rollback notes) |
| `lib/world-conflict.ts` | World save conflict detection (phase-105): version check + per-author vector clock normalization (object / `Map` / entries array) and ordering |
| `lib/story-arc-continuity.ts` | AI story-arc continuity check against recent world narratives (phase-107) |
| `lib/narrative-world-store.ts` | World/narrative JSON store + localized per-(tokenId,lang) narrative cache (phase-111) + world export snapshot builder and markdown renderer (phase-112) + collaborative role store with ownership enforcement (phase-109) + lore link store with back-reference index (phase-115) |
| `lib/ipfs-upload-retry.ts` | IPFS upload retry w/ exponential backoff + sha256 checksum (phase-120) |
Expand Down Expand Up @@ -147,6 +148,7 @@ settlement verifier and must not accept unsigned base64 payloads.

| Flag | Route | Extension | Flag off |
|------|-------|-----------|----------|
| `phase-105` | `POST /api/world` | Accepts `expected_version` and `expected_vector_clock`; `409 WORLD_VERSION_CONFLICT` with `order`, `server_version`, `server_vector_clock`, `current` when the client's view is stale or concurrent. Success returns the new `version` and `vector_clock` | Unconditional overwrite |
| `phase-109` | `GET /api/world/[collection_id]/roles` | Returns current role map (`editor`/`viewer`) for the world collection | `404` disabled |
| `phase-109` | `POST /api/world/[collection_id]/roles` | Assigns a role to a target wallet; requires `X-Wallet-Signature` header and acting wallet must be the world owner (403 otherwise) | `404` disabled |
| `phase-110` | `GET /api/world/search` | Full-text narrative search across all world collections; supports `?entity=<id>`, `?location=<text>`, `?q=<text>` | `404` disabled |
Expand Down
77 changes: 76 additions & 1 deletion lib/__tests__/world-conflict.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { describe, it, before, after } from "node:test"
import * as assert from "node:assert/strict"
import { checkWorldConflict } from "@/lib/world-conflict"
import {
checkWorldConflict,
compareVectorClocks,
incrementVectorClock,
normalizeVectorClock,
} from "@/lib/world-conflict"
import type { WorldCollectionData } from "@/lib/narrative-world-store"

describe("phase-105 world conflict detection", () => {
Expand Down Expand Up @@ -49,4 +54,74 @@ describe("phase-105 world conflict detection", () => {
assert.equal(res.conflict, false)
process.env.FEATURE_PHASE_105 = "1"
})

const clocked: WorldCollectionData = { ...world, vector_clock: { alice: 2, bob: 1 } }

it("normalizes Map, object and entries-array clocks to the same shape", () => {
const fromObject = normalizeVectorClock({ alice: 2, bob: 1 })
const fromMap = normalizeVectorClock(new Map([["bob", 1], ["alice", 2]]))
const fromArray = normalizeVectorClock([["alice", 2], ["bob", 1]])
assert.deepEqual(fromMap, fromObject)
assert.deepEqual(fromArray, fromObject)
})

it("drops zero counters and keeps the max of duplicate entries", () => {
assert.deepEqual(normalizeVectorClock({ alice: 0 }), {})
assert.deepEqual(normalizeVectorClock([["alice", 1], ["alice", 3]]), { alice: 3 })
})

it("rejects malformed clocks", () => {
assert.equal(normalizeVectorClock(null), null)
assert.equal(normalizeVectorClock("alice:1"), null)
assert.equal(normalizeVectorClock({ alice: -1 }), null)
assert.equal(normalizeVectorClock({ alice: 1.5 }), null)
assert.equal(normalizeVectorClock([["alice"]]), null)
assert.equal(normalizeVectorClock([[1, 1]]), null)
})

it("orders vector clocks", () => {
assert.equal(compareVectorClocks({ a: 1 }, { a: 1 }), "equal")
assert.equal(compareVectorClocks({ a: 1 }, { a: 2 }), "before")
assert.equal(compareVectorClocks({ a: 2, b: 1 }, { a: 2 }), "after")
assert.equal(compareVectorClocks({ a: 2 }, { b: 1 }), "concurrent")
})

it("increments a clock stored as an entries array", () => {
assert.deepEqual(incrementVectorClock([["alice", 2]], "alice"), { alice: 3 })
assert.deepEqual(incrementVectorClock(undefined, "bob"), { bob: 1 })
})

it("no conflict when an entries-array clock matches a stored object clock", () => {
const expected = normalizeVectorClock([["bob", 1], ["alice", 2]])!
const res = checkWorldConflict(clocked, 3, expected)
assert.equal(res.conflict, false)
})

it("no conflict when a Map clock matches a stored entries-array clock", () => {
const stored = { ...world, vector_clock: [["alice", 2], ["bob", 1]] } as unknown as WorldCollectionData
const expected = normalizeVectorClock(new Map([["alice", 2], ["bob", 1]]))!
const res = checkWorldConflict(stored, undefined, expected)
assert.equal(res.conflict, false)
})

it("reports a stale clock as 'before'", () => {
const res = checkWorldConflict(clocked, undefined, { alice: 2 })
assert.equal(res.conflict, true)
if (res.conflict) {
assert.equal(res.order, "before")
assert.deepEqual(res.serverVectorClock, { alice: 2, bob: 1 })
}
})

it("reports a diverged clock as 'concurrent'", () => {
const res = checkWorldConflict(clocked, undefined, { alice: 3 })
assert.equal(res.conflict, true)
if (res.conflict) assert.equal(res.order, "concurrent")
})

it("still checks version for worlds saved before vector clocks existed", () => {
const res = checkWorldConflict(world, 2, {})
assert.equal(res.conflict, true)
if (res.conflict) assert.equal(res.order, "before")
})
})
9 changes: 7 additions & 2 deletions lib/narrative-world-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import { mkdir, readFile, writeFile } from "node:fs/promises"
import path from "node:path"
import { serverDataJsonPath } from "@/lib/server-data-paths"
import { incrementVectorClock, type VectorClock } from "@/lib/world-conflict"

export type NarratorTone = "enigmatic" | "epic" | "scientific" | "folkloric"

Expand All @@ -12,6 +13,7 @@ export type WorldCollectionData = {
narrator_tone?: NarratorTone
creator_wallet?: string
version?: number
vector_clock?: VectorClock
}

export type WorldNarrativeData = {
Expand Down Expand Up @@ -50,20 +52,23 @@ export async function saveWorldForCollection(
data: Pick<WorldCollectionData, "world_name" | "world_prompt" | "narrator_tone"> & {
creator_wallet?: string
},
): Promise<void> {
): Promise<WorldCollectionData> {
const filePath = serverDataJsonPath("worldCollections")
const store = await readJsonStore<WorldCollectionsStore>(filePath)
const existing = store[String(collectionId)]
store[String(collectionId)] = {
const saved: WorldCollectionData = {
...existing,
world_name: data.world_name,
world_prompt: data.world_prompt,
...(data.narrator_tone !== undefined ? { narrator_tone: data.narrator_tone } : {}),
...(data.creator_wallet !== undefined ? { creator_wallet: data.creator_wallet } : {}),
created_at: existing?.created_at ?? Date.now(),
version: (existing?.version ?? 0) + 1,
vector_clock: incrementVectorClock(existing?.vector_clock, data.creator_wallet ?? "anonymous"),
}
store[String(collectionId)] = saved
await writeJsonStore(filePath, store)
return saved
}

export async function getAllWorldCollections(): Promise<WorldCollectionsStore> {
Expand Down
100 changes: 92 additions & 8 deletions lib/world-conflict.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,14 @@
* author's changes are lost ("co-authored worlds overwrite each other
* blindly"). This module implements the scoped mechanism the issue
* describes: optimistic concurrency control keyed on a monotonically
* increasing `version` field on `WorldCollectionData`.
* increasing `version` field on `WorldCollectionData`, plus a per-author
* `vector_clock` that distinguishes stale writes from truly concurrent ones.
*
* Vector clocks reach this module in more than one shape: a plain JSON
* object (`{ "G...A": 2 }`), a `Map`, or an entries array
* (`[["G...A", 2]]`, which is what `JSON.stringify([...map])` produces).
* Comparing those shapes directly always reports divergence, so every clock
* is normalized to a canonical `Record<string, number>` before comparison.
*
* This is intentionally narrow — a "detect divergence, surface it to the
* client" strategy for a single shared resource (a world's name/prompt/tone)
Expand All @@ -19,43 +26,120 @@
import { isFeatureEnabled } from "@/lib/feature-flags"
import type { WorldCollectionData } from "@/lib/narrative-world-store"

export type VectorClock = Record<string, number>

export type VectorClockOrder = "equal" | "before" | "after" | "concurrent"

export type WorldConflictResult =
| { conflict: false }
| {
conflict: true
serverVersion: number
clientVersion: number
clientVersion: number | undefined
serverVectorClock: VectorClock
order: VectorClockOrder
current: Pick<WorldCollectionData, "world_name" | "world_prompt" | "narrator_tone">
}

export function isWorldConflictCheckEnabled(): boolean {
return isFeatureEnabled("phase-105")
}

function isCounter(v: unknown): v is number {
return typeof v === "number" && Number.isSafeInteger(v) && v >= 0
}

/**
* Detects a concurrent-edit conflict between the client's expected version
* and the currently stored version of a world.
* Canonicalizes a vector clock received as a `Map`, a plain object, or an
* array of `[node, counter]` entries. Zero counters are dropped so `{}` and
* `{ a: 0 }` compare equal; duplicate entries keep the highest counter.
* Returns `null` for anything malformed.
*/
export function normalizeVectorClock(input: unknown): VectorClock | null {
let entries: unknown[]
if (input instanceof Map) {
entries = [...input.entries()]
} else if (Array.isArray(input)) {
entries = input
} else if (input !== null && typeof input === "object") {
entries = Object.entries(input)
} else {
return null
}

const clock: VectorClock = {}
for (const entry of entries) {
if (!Array.isArray(entry) || entry.length !== 2) return null
const [node, counter] = entry
if (typeof node !== "string" || node.length === 0 || !isCounter(counter)) return null
if (counter === 0) continue
clock[node] = Math.max(clock[node] ?? 0, counter)
}
return clock
}

export function compareVectorClocks(a: VectorClock, b: VectorClock): VectorClockOrder {
let aAhead = false
let bAhead = false
for (const node of new Set([...Object.keys(a), ...Object.keys(b)])) {
const av = a[node] ?? 0
const bv = b[node] ?? 0
if (av > bv) aAhead = true
else if (bv > av) bAhead = true
}
if (aAhead && bAhead) return "concurrent"
if (aAhead) return "after"
if (bAhead) return "before"
return "equal"
}

export function incrementVectorClock(clock: unknown, node: string): VectorClock {
const next = normalizeVectorClock(clock) ?? {}
next[node] = (next[node] ?? 0) + 1
return next
}

/**
* Detects a concurrent-edit conflict between what the client last saw and
* the currently stored world.
*
* - Flag off → never reports a conflict (legacy overwrite behavior).
* - World doesn't exist yet → nothing to diverge from.
* - Client omitted `expectedVersion` → opts out of the check (keeps
* backward compatibility for older callers).
* - Client sent `expectedVectorClock` → conflict unless it equals the
* server's clock after normalization; `order` tells the client whether it
* is merely stale (`before`) or diverged (`concurrent`).
* - `expectedVersion` is still checked when the clocks agree (worlds saved
* before vector clocks existed); omitting both opts out of the check
* (keeps backward compatibility for older callers).
*/
export function checkWorldConflict(
existing: WorldCollectionData | null,
expectedVersion: number | undefined,
expectedVectorClock?: VectorClock,
): WorldConflictResult {
if (!isWorldConflictCheckEnabled()) return { conflict: false }
if (!existing) return { conflict: false }
if (expectedVersion === undefined) return { conflict: false }
if (expectedVersion === undefined && expectedVectorClock === undefined) return { conflict: false }

const serverVersion = existing.version ?? 0
if (expectedVersion === serverVersion) return { conflict: false }
const serverVectorClock = normalizeVectorClock(existing.vector_clock) ?? {}

let order: VectorClockOrder =
expectedVectorClock !== undefined
? compareVectorClocks(expectedVectorClock, serverVectorClock)
: "equal"
// Worlds saved before vector clocks existed only carry `version`.
if (order === "equal" && expectedVersion !== undefined && expectedVersion !== serverVersion) {
order = expectedVersion < serverVersion ? "before" : "after"
}
if (order === "equal") return { conflict: false }

return {
conflict: true,
serverVersion,
clientVersion: expectedVersion,
serverVectorClock,
order,
current: {
world_name: existing.world_name,
world_prompt: existing.world_prompt,
Expand Down