);
}
diff --git a/src/components/admin/narrative-flow/NarrativeBeatNode.tsx b/src/components/admin/narrative-flow/NarrativeBeatNode.tsx
index 91ce415..50d6bfd 100644
--- a/src/components/admin/narrative-flow/NarrativeBeatNode.tsx
+++ b/src/components/admin/narrative-flow/NarrativeBeatNode.tsx
@@ -1,14 +1,15 @@
"use client";
-import { Handle, Position, type NodeProps } from "@xyflow/react";
+import { type NodeProps } from "@xyflow/react";
import { BookOpenTextIcon, PencilIcon } from "lucide-react";
+import { NodeHandles } from "./NodeHandles";
export function NarrativeBeatNode({ data }: NodeProps) {
const d = data as { title?: string; text?: string };
return (
-
+
@@ -19,7 +20,6 @@ export function NarrativeBeatNode({ data }: NodeProps) {
{d.text || "No description."}
-
);
}
diff --git a/src/components/admin/narrative-flow/NarrativeFlowEditor.tsx b/src/components/admin/narrative-flow/NarrativeFlowEditor.tsx
index c61c6f7..98caa53 100644
--- a/src/components/admin/narrative-flow/NarrativeFlowEditor.tsx
+++ b/src/components/admin/narrative-flow/NarrativeFlowEditor.tsx
@@ -29,6 +29,7 @@ import { RandomNode } from "./RandomNode";
import { BooleanNode } from "./BooleanNode";
import { DateNode } from "./DateNode";
import { NodeEditDialog } from "./NodeEditDialog";
+import { NODE_SOCKETS, type SocketType } from "./types";
const nodeTypes = {
narrativeBeat: NarrativeBeatNode,
@@ -85,6 +86,29 @@ export function NarrativeFlowEditor() {
[setEdges],
);
+ const isValidConnection = useCallback(
+ (connection: Edge | Connection) => {
+ if (!connection.source || !connection.target || !connection.sourceHandle || !connection.targetHandle) {
+ return false;
+ }
+ const sourceNode = nodes.find((n) => n.id === connection.source);
+ const targetNode = nodes.find((n) => n.id === connection.target);
+ if (!sourceNode || !targetNode) return false;
+
+ const sourceSockets = NODE_SOCKETS[sourceNode.type as string];
+ const targetSockets = NODE_SOCKETS[targetNode.type as string];
+ if (!sourceSockets || !targetSockets) return false;
+
+ const sourceSocket = sourceSockets.outputs.find((s) => s.id === connection.sourceHandle);
+ const targetSocket = targetSockets.inputs.find((s) => s.id === connection.targetHandle);
+ if (!sourceSocket || !targetSocket) return false;
+
+ if (sourceSocket.type === "any" || targetSocket.type === "any") return true;
+ return sourceSocket.type === targetSocket.type;
+ },
+ [nodes],
+ );
+
const onNodeClick = useCallback((_e: React.MouseEvent, node: Node) => {
setEditingNodeId(node.id);
}, []);
@@ -253,6 +277,7 @@ export function NarrativeFlowEditor() {
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
+ isValidConnection={isValidConnection}
onNodeClick={onNodeClick}
onNodeContextMenu={onNodeContextMenu}
onEdgeContextMenu={onEdgeContextMenu}
diff --git a/src/components/admin/narrative-flow/NodeHandles.tsx b/src/components/admin/narrative-flow/NodeHandles.tsx
new file mode 100644
index 0000000..8fe699e
--- /dev/null
+++ b/src/components/admin/narrative-flow/NodeHandles.tsx
@@ -0,0 +1,52 @@
+"use client";
+
+import { TypedHandle } from "./TypedHandle";
+import { NODE_SOCKETS } from "./types";
+
+interface NodeHandlesProps {
+ nodeType: string;
+ dynamicOutputs?: Array<{ id: string; label: string }>;
+}
+
+export function NodeHandles({ nodeType, dynamicOutputs }: NodeHandlesProps) {
+ const config = NODE_SOCKETS[nodeType];
+ if (!config) return null;
+
+ const inputs = config.inputs;
+ const outputs = dynamicOutputs
+ ? dynamicOutputs.map((o) => ({ ...o, type: "flow" as const }))
+ : config.outputs;
+
+ const maxSlots = Math.max(inputs.length, outputs.length, 1);
+
+ return (
+ <>
+ {inputs.map((socket, i) => {
+ const topPercent = maxSlots === 1 ? 50 : (i / (maxSlots - 1)) * 80 + 10;
+ return (
+
+ );
+ })}
+ {outputs.map((socket, i) => {
+ const topPercent = maxSlots === 1 ? 50 : (i / (maxSlots - 1)) * 80 + 10;
+ return (
+
+ );
+ })}
+ >
+ );
+}
diff --git a/src/components/admin/narrative-flow/NumberNode.tsx b/src/components/admin/narrative-flow/NumberNode.tsx
index 6247638..219bda2 100644
--- a/src/components/admin/narrative-flow/NumberNode.tsx
+++ b/src/components/admin/narrative-flow/NumberNode.tsx
@@ -1,14 +1,15 @@
"use client";
-import { Handle, Position, type NodeProps } from "@xyflow/react";
+import { type NodeProps } from "@xyflow/react";
import { HashIcon } from "lucide-react";
+import { NodeHandles } from "./NodeHandles";
export function NumberNode({ data }: NodeProps) {
const d = data as { value?: number; description?: string };
return (
-
+
@@ -18,7 +19,6 @@ export function NumberNode({ data }: NodeProps) {
{d.description || "Numeric value"}
-
);
}
diff --git a/src/components/admin/narrative-flow/OutcomeNode.tsx b/src/components/admin/narrative-flow/OutcomeNode.tsx
index a5b8cd1..6a13baa 100644
--- a/src/components/admin/narrative-flow/OutcomeNode.tsx
+++ b/src/components/admin/narrative-flow/OutcomeNode.tsx
@@ -1,7 +1,8 @@
"use client";
-import { Handle, Position, type NodeProps } from "@xyflow/react";
+import { type NodeProps } from "@xyflow/react";
import { FlagIcon, PencilIcon } from "lucide-react";
+import { NodeHandles } from "./NodeHandles";
export function OutcomeNode({ data }: NodeProps) {
const d = data as {
@@ -9,12 +10,11 @@ export function OutcomeNode({ data }: NodeProps) {
text?: string;
effects?: Array<{ type: string; value: string }>;
};
-
const effects = d.effects ?? [];
return (
-
+
@@ -33,10 +33,7 @@ export function OutcomeNode({ data }: NodeProps) {
None
) : (
effects.map((eff, i) => (
-
+
{eff.type}
:
{eff.value}
diff --git a/src/components/admin/narrative-flow/RandomNode.tsx b/src/components/admin/narrative-flow/RandomNode.tsx
index b560456..5e37c0b 100644
--- a/src/components/admin/narrative-flow/RandomNode.tsx
+++ b/src/components/admin/narrative-flow/RandomNode.tsx
@@ -1,14 +1,15 @@
"use client";
-import { Handle, Position, type NodeProps } from "@xyflow/react";
+import { type NodeProps } from "@xyflow/react";
import { ShuffleIcon } from "lucide-react";
+import { NodeHandles } from "./NodeHandles";
export function RandomNode({ data }: NodeProps) {
const d = data as { min?: number; max?: number; description?: string };
return (
-
+
@@ -18,7 +19,6 @@ export function RandomNode({ data }: NodeProps) {
{d.description || "Random number generator"}
-
);
}
diff --git a/src/components/admin/narrative-flow/StringNode.tsx b/src/components/admin/narrative-flow/StringNode.tsx
index c906479..aa4d6d8 100644
--- a/src/components/admin/narrative-flow/StringNode.tsx
+++ b/src/components/admin/narrative-flow/StringNode.tsx
@@ -1,14 +1,15 @@
"use client";
-import { Handle, Position, type NodeProps } from "@xyflow/react";
+import { type NodeProps } from "@xyflow/react";
import { TypeIcon } from "lucide-react";
+import { NodeHandles } from "./NodeHandles";
export function StringNode({ data }: NodeProps) {
const d = data as { value?: string; description?: string };
return (
-
+
@@ -18,7 +19,6 @@ export function StringNode({ data }: NodeProps) {
{d.description || "String value"}
-
);
}
diff --git a/src/components/admin/narrative-flow/TypedHandle.tsx b/src/components/admin/narrative-flow/TypedHandle.tsx
new file mode 100644
index 0000000..8a3d602
--- /dev/null
+++ b/src/components/admin/narrative-flow/TypedHandle.tsx
@@ -0,0 +1,59 @@
+"use client";
+
+import { Handle, Position, type HandleProps } from "@xyflow/react";
+import { SOCKET_COLORS, type SocketType } from "./types";
+
+interface TypedHandleProps {
+ id: string;
+ socketType: SocketType;
+ label?: string;
+ isInput: boolean;
+ style?: React.CSSProperties;
+}
+
+export function TypedHandle({
+ id,
+ socketType,
+ label,
+ isInput,
+ style,
+}: TypedHandleProps) {
+ const color = SOCKET_COLORS[socketType];
+ const isFlow = socketType === "flow";
+
+ return (
+
+
+ {label && (
+
+ {label}
+
+ )}
+
+ );
+}
diff --git a/src/components/admin/narrative-flow/WaitNode.tsx b/src/components/admin/narrative-flow/WaitNode.tsx
index 0fa546c..869d48b 100644
--- a/src/components/admin/narrative-flow/WaitNode.tsx
+++ b/src/components/admin/narrative-flow/WaitNode.tsx
@@ -1,14 +1,15 @@
"use client";
-import { Handle, Position, type NodeProps } from "@xyflow/react";
+import { type NodeProps } from "@xyflow/react";
import { TimerIcon } from "lucide-react";
+import { NodeHandles } from "./NodeHandles";
export function WaitNode({ data }: NodeProps) {
const d = data as { ticks?: number; input?: unknown };
return (
-
+
@@ -16,9 +17,10 @@ export function WaitNode({ data }: NodeProps) {
- {d.input !== undefined ? `Input: ${String(d.input).substring(0, 50)}${String(d.input).length > 50 ? "..." : ""}` : "No input"}
+ {d.input !== undefined
+ ? `Input: ${String(d.input).substring(0, 50)}${String(d.input).length > 50 ? "..." : ""}`
+ : "No input"}
-
);
}
diff --git a/src/components/admin/narrative-flow/types.ts b/src/components/admin/narrative-flow/types.ts
new file mode 100644
index 0000000..c085399
--- /dev/null
+++ b/src/components/admin/narrative-flow/types.ts
@@ -0,0 +1,143 @@
+export type SocketType = "flow" | "number" | "string" | "boolean" | "any";
+
+export interface SocketDef {
+ id: string;
+ type: SocketType;
+ label: string;
+}
+
+export const SOCKET_COLORS: Record
= {
+ flow: "#e4e4e7",
+ number: "#a855f7",
+ string: "#14b8a6",
+ boolean: "#22c55e",
+ any: "#6366f1",
+};
+
+export interface NodeSocketConfig {
+ inputs: SocketDef[];
+ outputs: SocketDef[];
+}
+
+export const NODE_SOCKETS: Record = {
+ narrativeBeat: {
+ inputs: [
+ { id: "flow-in", type: "flow", label: "" },
+ { id: "text-var", type: "any", label: "text" },
+ ],
+ outputs: [{ id: "flow-out", type: "flow", label: "" }],
+ },
+ choice: {
+ inputs: [
+ { id: "flow-in", type: "flow", label: "" },
+ { id: "question-var", type: "string", label: "question" },
+ ],
+ outputs: [{ id: "flow-out", type: "flow", label: "" }],
+ },
+ outcome: {
+ inputs: [
+ { id: "flow-in", type: "flow", label: "" },
+ { id: "trigger", type: "boolean", label: "trigger" },
+ ],
+ outputs: [],
+ },
+ wait: {
+ inputs: [
+ { id: "flow-in", type: "flow", label: "" },
+ { id: "ticks", type: "number", label: "ticks" },
+ ],
+ outputs: [{ id: "flow-out", type: "flow", label: "" }],
+ },
+ number: {
+ inputs: [],
+ outputs: [{ id: "value", type: "number", label: "out" }],
+ },
+ string: {
+ inputs: [],
+ outputs: [{ id: "value", type: "string", label: "out" }],
+ },
+ random: {
+ inputs: [
+ { id: "min", type: "number", label: "min" },
+ { id: "max", type: "number", label: "max" },
+ ],
+ outputs: [{ id: "value", type: "number", label: "out" }],
+ },
+ boolean: {
+ inputs: [],
+ outputs: [{ id: "value", type: "boolean", label: "out" }],
+ },
+ date: {
+ inputs: [],
+ outputs: [{ id: "value", type: "string", label: "out" }],
+ },
+ gameData: {
+ inputs: [],
+ outputs: [
+ { id: "number-out", type: "number", label: "num" },
+ { id: "string-out", type: "string", label: "str" },
+ { id: "boolean-out", type: "boolean", label: "bool" },
+ ],
+ },
+ flag: {
+ inputs: [
+ { id: "flow-in", type: "flow", label: "" },
+ { id: "set-value", type: "boolean", label: "set" },
+ ],
+ outputs: [
+ { id: "flow-out", type: "flow", label: "" },
+ { id: "value", type: "boolean", label: "out" },
+ ],
+ },
+ compare: {
+ inputs: [
+ { id: "a", type: "number", label: "A" },
+ { id: "b", type: "number", label: "B" },
+ ],
+ outputs: [{ id: "result", type: "boolean", label: "out" }],
+ },
+ math: {
+ inputs: [
+ { id: "a", type: "number", label: "A" },
+ { id: "b", type: "number", label: "B" },
+ ],
+ outputs: [{ id: "result", type: "number", label: "out" }],
+ },
+ format: {
+ inputs: [
+ { id: "template", type: "string", label: "tmpl" },
+ { id: "arg-0", type: "any", label: "{0}" },
+ { id: "arg-1", type: "any", label: "{1}" },
+ ],
+ outputs: [{ id: "result", type: "string", label: "out" }],
+ },
+ logic: {
+ inputs: [
+ { id: "a", type: "boolean", label: "A" },
+ { id: "b", type: "boolean", label: "B" },
+ ],
+ outputs: [{ id: "result", type: "boolean", label: "out" }],
+ },
+ not: {
+ inputs: [{ id: "value", type: "boolean", label: "in" }],
+ outputs: [{ id: "result", type: "boolean", label: "out" }],
+ },
+ branch: {
+ inputs: [
+ { id: "flow-in", type: "flow", label: "" },
+ { id: "condition", type: "boolean", label: "cond" },
+ ],
+ outputs: [
+ { id: "flow-true", type: "flow", label: "true" },
+ { id: "flow-false", type: "flow", label: "false" },
+ ],
+ },
+ sequence: {
+ inputs: [{ id: "flow-in", type: "flow", label: "" }],
+ outputs: [
+ { id: "flow-1", type: "flow", label: "1" },
+ { id: "flow-2", type: "flow", label: "2" },
+ { id: "flow-3", type: "flow", label: "3" },
+ ],
+ },
+};