diff --git a/AGENTS.md b/AGENTS.md index c3d67ae..e6216c3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -98,6 +98,23 @@ Access control helpers live in `src/utils/access-control/` (isRole, hasRoles). R ### Non-structure events The `EventLedger` currently filters by `structure.id`. For event logs scoped to other entities (e.g., faction finance ledger), a new ledger variant would need to be created that queries by `targetCollection` + `targetId` instead. +## Narrative Events System + +`src/collections/world/NarrativeEvents.ts` — `narrative-events` collection. A flowchart-based narrative event editor using `@xyflow/react` (React Flow) in the Payload admin panel. + +- **Schema**: `name` (text, title), `summary` (textarea), `flowData` (JSON — React Flow nodes + edges), `triggers` (group with type select + optional condition JSON). +- **Access**: developer-only (create/update/delete/read). +- **Flow editor**: custom admin field component at `src/components/admin/narrative-flow/NarrativeFlowEditor.tsx`. Renders a React Flow canvas with a toolbar to add node types. +- **Node types** (custom React Flow nodes in `src/components/admin/narrative-flow/`): + - **NarrativeBeat** (`NarrativeBeatNode.tsx`) — blue, story exposition. One source handle (bottom) + one target handle (top). Fields: `title`, `text`. + - **Choice** (`ChoiceNode.tsx`) — amber, player decision point. One target handle (top), one source handle per option (right side). Fields: `question`, `options[]` (each with `id`, `label`). + - **Outcome** (`OutcomeNode.tsx`) — emerald, terminal result. Target handle only. Fields: `title`, `text`, `effects[]` (each with `type`, `value`). +- **Context menu**: right-click any node/edge to open a Radix context menu with a "Delete" action. Deleting a node also removes connected edges. +- **Data flow**: changes to nodes/edges sync to Payload's form state via `useField().setValue()`. Serialization uses `JSON.stringify` diffing to avoid loops. +- **Editing**: double-click or click the edit icon on a node to edit its properties via node edit dialog (modal with type-specific fields). +- **Dependencies**: `@xyflow/react` (v12), `radix-ui` (context-menu primitives), `lucide-react` (icons). +- To add a new node type, create the component + register it in the `nodeTypes` object in `NarrativeFlowEditor.tsx`. + ## Auth Username-based login (no email login). Users log in via Payload admin with `username` only. diff --git a/bun.lock b/bun.lock index af3dd54..954ecca 100644 --- a/bun.lock +++ b/bun.lock @@ -24,6 +24,7 @@ "@radix-ui/react-tooltip": "^1.2.8", "@tailwindcss/postcss": "^4.1.18", "@types/nodemailer": "^8.0.1", + "@xyflow/react": "^12.11.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cross-env": "^10.1.0", @@ -847,6 +848,18 @@ "@types/chai": ["@types/chai@5.2.2", "", { "dependencies": { "@types/deep-eql": "4.0.2" } }, "sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg=="], + "@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="], + + "@types/d3-drag": ["@types/d3-drag@3.0.7", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ=="], + + "@types/d3-interpolate": ["@types/d3-interpolate@3.0.4", "", { "dependencies": { "@types/d3-color": "*" } }, "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA=="], + + "@types/d3-selection": ["@types/d3-selection@3.0.11", "", {}, "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w=="], + + "@types/d3-transition": ["@types/d3-transition@3.0.9", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg=="], + + "@types/d3-zoom": ["@types/d3-zoom@3.0.8", "", { "dependencies": { "@types/d3-interpolate": "*", "@types/d3-selection": "*" } }, "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw=="], + "@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "2.1.0" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="], "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], @@ -959,6 +972,10 @@ "@vitest/utils": ["@vitest/utils@4.0.16", "", { "dependencies": { "@vitest/pretty-format": "4.0.16", "tinyrainbow": "3.0.3" } }, "sha512-h8z9yYhV3e1LEfaQ3zdypIrnAg/9hguReGZoS7Gl0aBG5xgA410zBqECqmaF/+RkTggRsfnzc1XaAHA6bmUufA=="], + "@xyflow/react": ["@xyflow/react@12.11.2", "", { "dependencies": { "@xyflow/system": "0.0.79", "classcat": "^5.0.3", "zustand": "^4.4.0" }, "peerDependencies": { "@types/react": ">=17", "@types/react-dom": ">=17", "react": ">=17", "react-dom": ">=17" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-eLAlDWJfWnQEhJwGMjlWdAXO9eYllKpliUmPQlAmOLxz6mExXuzMVDUKLMquixgkrtmMFFtug3jGKmYYld12cA=="], + + "@xyflow/system": ["@xyflow/system@0.0.79", "", { "dependencies": { "@types/d3-drag": "^3.0.7", "@types/d3-interpolate": "^3.0.4", "@types/d3-selection": "^3.0.10", "@types/d3-transition": "^3.0.8", "@types/d3-zoom": "^3.0.8", "d3-drag": "^3.0.0", "d3-interpolate": "^3.0.1", "d3-selection": "^3.0.0", "d3-zoom": "^3.0.0" } }, "sha512-czLyOh91NF0hIzbNzwi8I6GlqG23BHh2435OddfI6uiaLH3xdrdygO93gqgH1Bv9mhy8XPFQJOBn1FTq4LvEWA=="], + "acorn": ["acorn@8.12.1", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg=="], "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "8.15.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], @@ -1071,6 +1088,8 @@ "class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="], + "classcat": ["classcat@5.0.5", "", {}, "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w=="], + "client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="], "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], @@ -1107,6 +1126,24 @@ "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + "d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="], + + "d3-dispatch": ["d3-dispatch@3.0.1", "", {}, "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg=="], + + "d3-drag": ["d3-drag@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-selection": "3" } }, "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg=="], + + "d3-ease": ["d3-ease@3.0.1", "", {}, "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="], + + "d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="], + + "d3-selection": ["d3-selection@3.0.0", "", {}, "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ=="], + + "d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="], + + "d3-transition": ["d3-transition@3.0.1", "", { "dependencies": { "d3-color": "1 - 3", "d3-dispatch": "1 - 3", "d3-ease": "1 - 3", "d3-interpolate": "1 - 3", "d3-timer": "1 - 3" }, "peerDependencies": { "d3-selection": "2 - 3" } }, "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w=="], + + "d3-zoom": ["d3-zoom@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-drag": "2 - 3", "d3-interpolate": "1 - 3", "d3-selection": "2 - 3", "d3-transition": "2 - 3" } }, "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw=="], + "damerau-levenshtein": ["damerau-levenshtein@1.0.8", "", {}, "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA=="], "data-urls": ["data-urls@6.0.0", "", { "dependencies": { "whatwg-mimetype": "4.0.0", "whatwg-url": "15.1.0" } }, "sha512-BnBS08aLUM+DKamupXs3w2tJJoqU+AkaE/+6vQxi/G/DPmIZFJJp9Dkb1kM03AZx8ADehDUZgsNxju3mPXZYIA=="], @@ -2097,6 +2134,8 @@ "zod-validation-error": ["zod-validation-error@4.0.2", "", { "peerDependencies": { "zod": "4.2.1" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="], + "zustand": ["zustand@4.5.7", "", { "dependencies": { "use-sync-external-store": "^1.2.2" }, "peerDependencies": { "@types/react": ">=16.8", "immer": ">=9.0.6", "react": ">=16.8" }, "optionalPeers": ["@types/react", "immer", "react"] }, "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw=="], + "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], "@aws-crypto/sha1-browser/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "2.2.0", "tslib": "2.8.1" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="], diff --git a/package.json b/package.json index efca784..b566262 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "@radix-ui/react-tooltip": "^1.2.8", "@tailwindcss/postcss": "^4.1.18", "@types/nodemailer": "^8.0.1", + "@xyflow/react": "^12.11.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cross-env": "^10.1.0", diff --git a/src/app/(payload)/admin/importMap.js b/src/app/(payload)/admin/importMap.js index de71ba6..9327d80 100644 --- a/src/app/(payload)/admin/importMap.js +++ b/src/app/(payload)/admin/importMap.js @@ -23,6 +23,7 @@ import { BoldFeatureClient as BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 import { ItalicFeatureClient as ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' import { default as default_0edde40a6ccd30c893b1227360f69360 } from '@/components/static/TickDurationDescription' import { default as default_2d468cc5cf0b647ffca3201592c20825 } from '@/components/static/WarningMessage' +import { default as default_7fa52484aec46e988e9cfb5e86c0c759 } from '@/components/admin/narrative-flow/NarrativeFlowEditor' import { default as default_2f6c866ab4d4d51b3789f090b84b85c7 } from '@/components/static/VersionOverlay' import { default as default_508743824696caae2894ebf34c2e68bb } from '@/components/graphics/AppIcon' import { default as default_e752fee78378294ff03f91dba0a75a04 } from '@/components/graphics/AppLogo' @@ -54,6 +55,7 @@ export const importMap = { "@payloadcms/richtext-lexical/client#ItalicFeatureClient": ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, "@/components/static/TickDurationDescription#default": default_0edde40a6ccd30c893b1227360f69360, "@/components/static/WarningMessage#default": default_2d468cc5cf0b647ffca3201592c20825, + "@/components/admin/narrative-flow/NarrativeFlowEditor#default": default_7fa52484aec46e988e9cfb5e86c0c759, "@/components/static/VersionOverlay#default": default_2f6c866ab4d4d51b3789f090b84b85c7, "@/components/graphics/AppIcon#default": default_508743824696caae2894ebf34c2e68bb, "@/components/graphics/AppLogo#default": default_e752fee78378294ff03f91dba0a75a04, diff --git a/src/collections/world/NarrativeEvents.ts b/src/collections/world/NarrativeEvents.ts index 4baadef..28bc1ff 100644 --- a/src/collections/world/NarrativeEvents.ts +++ b/src/collections/world/NarrativeEvents.ts @@ -5,6 +5,7 @@ export const NarrativeEvents: CollectionConfig = { slug: "narrative-events", admin: { group: "World", + useAsTitle: "name", }, access: { create: isDeveloper, @@ -17,26 +18,70 @@ export const NarrativeEvents: CollectionConfig = { name: "name", type: "text", required: true, + admin: { + description: "The title of this narrative event chain.", + }, }, { name: "summary", type: "textarea", required: true, + admin: { + description: "A brief summary of what this event is about.", + }, }, { type: "tabs", tabs: [ { - label: "Event Setup", - description: - "Establish the basic requirements and conditions needed for this event to occur.", - fields: [], + label: "Flow Editor", + description: "Build the narrative flowchart by adding beats, choices, and outcomes.", + fields: [ + { + name: "flowData", + type: "json", + required: true, + defaultValue: () => ({ + nodes: [], + edges: [], + }), + admin: { + components: { + Field: "@/components/admin/narrative-flow/NarrativeFlowEditor", + }, + }, + }, + ], }, { - label: "Narrative Graph", - description: - "Write the event storyline here, including choices, conditions, and outcomes!", - fields: [], + label: "Event Configuration", + description: "Configure trigger conditions and other metadata.", + fields: [ + { + name: "triggers", + type: "group", + fields: [ + { + name: "type", + type: "select", + options: [ + { label: "Manual", value: "manual" }, + { label: "On Mission Complete", value: "mission_complete" }, + { label: "On Timer", value: "timer" }, + { label: "On Condition", value: "condition" }, + ], + defaultValue: "manual", + }, + { + name: "condition", + type: "json", + admin: { + description: "Optional JSON conditions for when this event can fire.", + }, + }, + ], + }, + ], }, ], }, diff --git a/src/components/admin/narrative-flow/ChoiceNode.tsx b/src/components/admin/narrative-flow/ChoiceNode.tsx new file mode 100644 index 0000000..8db2c66 --- /dev/null +++ b/src/components/admin/narrative-flow/ChoiceNode.tsx @@ -0,0 +1,44 @@ +"use client"; + +import { Handle, Position, type NodeProps } from "@xyflow/react"; +import { PencilIcon, WaypointsIcon } from "lucide-react"; + +export function ChoiceNode({ data }: NodeProps) { + const d = data as { question?: string; options?: Array<{ id: string; label: string }> }; + + const options = d.options ?? []; + + return ( +
Unknown node type.
; + } + }; + + const typeLabel = + nodeType === "narrativeBeat" + ? "Beat" + : nodeType === "choice" + ? "Choice" + : nodeType === "outcome" + ? "Outcome" + : "Node"; + + return ( + + ); +}; diff --git a/src/components/admin/narrative-flow/OutcomeNode.tsx b/src/components/admin/narrative-flow/OutcomeNode.tsx new file mode 100644 index 0000000..1ef4043 --- /dev/null +++ b/src/components/admin/narrative-flow/OutcomeNode.tsx @@ -0,0 +1,49 @@ +"use client"; + +import { Handle, Position, type NodeProps } from "@xyflow/react"; +import { FlagIcon, PencilIcon } from "lucide-react"; + +export function OutcomeNode({ data }: NodeProps) { + const d = data as { + title?: string; + text?: string; + effects?: Array<{ type: string; value: string }>; + }; + + const effects = d.effects ?? []; + + return ( +