diff --git a/CMakeLists.txt b/CMakeLists.txt index 250169b..2a1a384 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -302,6 +302,7 @@ set(WORKFLOW_SOURCES set(FRAME_WORKFLOW_SOURCES src/services/impl/workflow_frame_begin_step.cpp + src/services/impl/workflow_frame_bullet_physics_step.cpp src/services/impl/workflow_frame_physics_step.cpp src/services/impl/workflow_frame_scene_step.cpp src/services/impl/workflow_frame_render_step.cpp diff --git a/config/schema/workflow_v1.schema.json b/config/schema/workflow_v1.schema.json index 9fa2eb7..81d9536 100644 --- a/config/schema/workflow_v1.schema.json +++ b/config/schema/workflow_v1.schema.json @@ -2,44 +2,192 @@ "$schema": "http://json-schema.org/draft-07/schema#", "title": "SDL3CPP Workflow v1", "type": "object", - "properties": { - "template": { - "type": "string" + "oneOf": [ + { + "$ref": "#/definitions/legacyWorkflow" }, - "steps": { - "type": "array", - "items": { - "type": "object", - "required": [ - "id", - "plugin" - ], - "properties": { - "id": { + { + "$ref": "#/definitions/n8nWorkflow" + } + ], + "definitions": { + "legacyWorkflow": { + "type": "object", + "properties": { + "template": { + "type": "string" + }, + "steps": { + "type": "array", + "items": { + "$ref": "#/definitions/legacyStep" + } + } + }, + "required": [ + "steps" + ], + "additionalProperties": false + }, + "legacyStep": { + "type": "object", + "required": [ + "id", + "plugin" + ], + "properties": { + "id": { + "type": "string" + }, + "plugin": { + "type": "string" + }, + "inputs": { + "type": "object", + "additionalProperties": { "type": "string" - }, - "plugin": { + } + }, + "outputs": { + "type": "object", + "additionalProperties": { "type": "string" + } + } + }, + "additionalProperties": false + }, + "n8nWorkflow": { + "type": "object", + "properties": { + "template": { + "type": "string" + }, + "nodes": { + "type": "array", + "items": { + "$ref": "#/definitions/n8nNode" + } + }, + "connections": { + "$ref": "#/definitions/n8nConnections" + } + }, + "required": [ + "nodes" + ], + "additionalProperties": false + }, + "n8nNode": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "plugin": { + "type": "string" + }, + "type": { + "type": "string" + }, + "typeVersion": { + "type": "integer" + }, + "position": { + "type": "array", + "items": { + "type": "number" }, - "inputs": { - "type": "object", - "additionalProperties": { - "type": "string" + "minItems": 2, + "maxItems": 2 + }, + "parameters": { + "type": "object", + "additionalProperties": true + }, + "inputs": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "outputs": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "allOf": [ + { + "anyOf": [ + { + "required": [ + "id" + ] + }, + { + "required": [ + "name" + ] } - }, - "outputs": { - "type": "object", - "additionalProperties": { - "type": "string" + ] + }, + { + "anyOf": [ + { + "required": [ + "plugin" + ] + }, + { + "required": [ + "type" + ] + } + ] + } + ], + "additionalProperties": true + }, + "n8nConnections": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "main": { + "type": "array", + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/n8nConnectionEntry" + } } } }, - "additionalProperties": false + "additionalProperties": true } + }, + "n8nConnectionEntry": { + "type": "object", + "properties": { + "node": { + "type": "string" + }, + "type": { + "type": "string" + }, + "index": { + "type": "integer" + } + }, + "required": [ + "node" + ], + "additionalProperties": true } - }, - "required": [ - "steps" - ], - "additionalProperties": false + } } diff --git a/config/workflows/templates/boot_default.json b/config/workflows/templates/boot_default.json index 982e550..90e1246 100644 --- a/config/workflows/templates/boot_default.json +++ b/config/workflows/templates/boot_default.json @@ -1,9 +1,10 @@ { "template": "boot.default", - "steps": [ + "nodes": [ { "id": "load_config", "plugin": "config.load", + "position": [0, 0], "inputs": { "path": "config.path" }, @@ -14,6 +15,7 @@ { "id": "validate_version", "plugin": "config.version.validate", + "position": [260, 0], "inputs": { "document": "config.document", "path": "config.path" @@ -25,6 +27,7 @@ { "id": "migrate_version", "plugin": "config.migrate", + "position": [520, 0], "inputs": { "document": "config.document", "path": "config.path", @@ -38,6 +41,7 @@ { "id": "validate_schema", "plugin": "config.schema.validate", + "position": [780, 0], "inputs": { "document": "config.document", "path": "config.path" @@ -46,6 +50,7 @@ { "id": "build_runtime_config", "plugin": "runtime.config.build", + "position": [1040, 0], "inputs": { "document": "config.document", "path": "config.path" @@ -54,5 +59,35 @@ "runtime": "config.runtime" } } - ] + ], + "connections": { + "load_config": { + "main": [ + [ + { "node": "validate_version", "type": "main", "index": 0 } + ] + ] + }, + "validate_version": { + "main": [ + [ + { "node": "migrate_version", "type": "main", "index": 0 } + ] + ] + }, + "migrate_version": { + "main": [ + [ + { "node": "validate_schema", "type": "main", "index": 0 } + ] + ] + }, + "validate_schema": { + "main": [ + [ + { "node": "build_runtime_config", "type": "main", "index": 0 } + ] + ] + } + } } diff --git a/config/workflows/templates/frame_default.json b/config/workflows/templates/frame_default.json index 354a377..3126807 100644 --- a/config/workflows/templates/frame_default.json +++ b/config/workflows/templates/frame_default.json @@ -1,9 +1,10 @@ { "template": "frame.default", - "steps": [ + "nodes": [ { "id": "begin_frame", "plugin": "frame.begin", + "position": [0, 0], "inputs": { "delta": "frame.delta", "elapsed": "frame.elapsed" @@ -12,6 +13,7 @@ { "id": "step_physics", "plugin": "frame.physics", + "position": [260, 0], "inputs": { "delta": "frame.delta" } @@ -19,6 +21,7 @@ { "id": "update_scene", "plugin": "frame.scene", + "position": [520, 0], "inputs": { "delta": "frame.delta" } @@ -26,17 +29,51 @@ { "id": "render_frame", "plugin": "frame.render", + "position": [780, 0], "inputs": { "elapsed": "frame.elapsed" } }, { "id": "update_audio", - "plugin": "frame.audio" + "plugin": "frame.audio", + "position": [1040, -120] }, { "id": "dispatch_gui", - "plugin": "frame.gui" + "plugin": "frame.gui", + "position": [1040, 120] } - ] + ], + "connections": { + "begin_frame": { + "main": [ + [ + { "node": "step_physics", "type": "main", "index": 0 } + ] + ] + }, + "step_physics": { + "main": [ + [ + { "node": "update_scene", "type": "main", "index": 0 } + ] + ] + }, + "update_scene": { + "main": [ + [ + { "node": "render_frame", "type": "main", "index": 0 } + ] + ] + }, + "render_frame": { + "main": [ + [ + { "node": "update_audio", "type": "main", "index": 0 }, + { "node": "dispatch_gui", "type": "main", "index": 0 } + ] + ] + } + } } diff --git a/config/workflows/templates/n8n_skeleton.json b/config/workflows/templates/n8n_skeleton.json new file mode 100644 index 0000000..21f8296 --- /dev/null +++ b/config/workflows/templates/n8n_skeleton.json @@ -0,0 +1,34 @@ +{ + "template": "n8n.skeleton", + "nodes": [ + { + "id": "load_config", + "plugin": "config.load", + "position": [0, 0], + "inputs": { + "path": "config.path" + }, + "outputs": { + "document": "config.document" + } + }, + { + "id": "validate_schema", + "plugin": "config.schema.validate", + "position": [260, 0], + "inputs": { + "document": "config.document", + "path": "config.path" + } + } + ], + "connections": { + "load_config": { + "main": [ + [ + { "node": "validate_schema", "type": "main", "index": 0 } + ] + ] + } + } +} diff --git a/docs/N8N_WORKFLOWS.md b/docs/N8N_WORKFLOWS.md new file mode 100644 index 0000000..7706dcc --- /dev/null +++ b/docs/N8N_WORKFLOWS.md @@ -0,0 +1,31 @@ +N8N-Style Workflows + +Overview +- Workflows can be declared with `nodes` and `connections`, modeled after n8n. +- Each node maps to a C++ workflow plugin (via `plugin` or `type`). +- Connections define execution order. When connections are omitted, the node array order is used. + +Node Shape +- `id` or `name`: required. Used to identify the node in `connections`. +- `plugin` or `type`: required. The plugin string must match a registered step. +- `inputs` / `outputs`: optional string maps for workflow context keys. +- `position`: optional `[x, y]` array for layout parity with n8n. + +Connections Shape +```json +{ + "connections": { + "load_config": { + "main": [ + [ + { "node": "validate_schema", "type": "main", "index": 0 } + ] + ] + } + } +} +``` + +Templates +- `config/workflows/templates/boot_default.json` and `config/workflows/templates/frame_default.json` are n8n-style now. +- `config/workflows/templates/n8n_skeleton.json` is a minimal starting point. diff --git a/packages/engine_tester/package.json b/packages/engine_tester/package.json index 8bedb5f..82976bf 100644 --- a/packages/engine_tester/package.json +++ b/packages/engine_tester/package.json @@ -4,8 +4,7 @@ "description": "Validation tour package with teleport checkpoints, captures, and diagnostics.", "defaultWorkflow": "workflows/validation_tour.json", "workflows": [ - "workflows/validation_tour.json", - "workflows/stubs/validation_probe_placeholder.json" + "workflows/validation_tour.json" ], "assets": [ "assets/validation_checks.json" diff --git a/packages/engine_tester/workflows/stubs/validation_probe_placeholder.json b/packages/engine_tester/workflows/stubs/validation_probe_placeholder.json deleted file mode 100644 index b2abd82..0000000 --- a/packages/engine_tester/workflows/stubs/validation_probe_placeholder.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "id": "validation.tour.checkpoint", - "summary": "Validation probe step stub used to reserve workflow hooks before full diagnostics land.", - "stage": "planned", - "notes": [ - "Should sample engine framebuffers, analyze capture stats, and report mismatches.", - "Currently registered as part of packages so workflows referencing checkpoints keep parsing." - ] -} diff --git a/packages/engine_tester/workflows/validation_tour.json b/packages/engine_tester/workflows/validation_tour.json index f7570ef..779aefb 100644 --- a/packages/engine_tester/workflows/validation_tour.json +++ b/packages/engine_tester/workflows/validation_tour.json @@ -1,9 +1,10 @@ { "template": "boot.default", - "steps": [ + "nodes": [ { "id": "load_config", "plugin": "config.load", + "position": [0, 0], "inputs": { "path": "config.path" }, @@ -14,6 +15,7 @@ { "id": "validate_schema", "plugin": "config.schema.validate", + "position": [260, 0], "inputs": { "document": "config.document", "path": "config.path" @@ -22,6 +24,7 @@ { "id": "build_runtime", "plugin": "runtime.config.build", + "position": [520, 0], "inputs": { "document": "config.document", "path": "config.path" @@ -33,9 +36,33 @@ { "id": "validation_probe", "plugin": "validation.tour.checkpoint", + "position": [780, 0], "inputs": { "checkpoint": "packages.engine_tester" } } - ] + ], + "connections": { + "load_config": { + "main": [ + [ + { "node": "validate_schema", "type": "main", "index": 0 } + ] + ] + }, + "validate_schema": { + "main": [ + [ + { "node": "build_runtime", "type": "main", "index": 0 } + ] + ] + }, + "build_runtime": { + "main": [ + [ + { "node": "validation_probe", "type": "main", "index": 0 } + ] + ] + } + } } diff --git a/packages/gui/package.json b/packages/gui/package.json index 0f3c993..79d55de 100644 --- a/packages/gui/package.json +++ b/packages/gui/package.json @@ -4,8 +4,7 @@ "description": "Workflow package describing the GUI demo, focused on UI updates + frame capture validation.", "defaultWorkflow": "workflows/gui_frame.json", "workflows": [ - "workflows/gui_frame.json", - "workflows/stubs/gui_layout_placeholder.json" + "workflows/gui_frame.json" ], "assets": [ "assets/gui_widgets.json", diff --git a/packages/gui/workflows/gui_frame.json b/packages/gui/workflows/gui_frame.json index 14ab82b..37f46fc 100644 --- a/packages/gui/workflows/gui_frame.json +++ b/packages/gui/workflows/gui_frame.json @@ -1,9 +1,10 @@ { "template": "frame.default", - "steps": [ + "nodes": [ { "id": "gui_begin", "plugin": "frame.begin", + "position": [0, 0], "inputs": { "delta": "frame.delta", "elapsed": "frame.elapsed" @@ -12,6 +13,7 @@ { "id": "gui_layout", "plugin": "frame.gui", + "position": [260, 0], "inputs": { "elapsed": "frame.elapsed" } @@ -19,6 +21,7 @@ { "id": "render_ui", "plugin": "frame.render", + "position": [520, 0], "inputs": { "elapsed": "frame.elapsed" } @@ -26,9 +29,33 @@ { "id": "capture_ui", "plugin": "validation.tour.checkpoint", + "position": [780, 0], "inputs": { "checkpoint": "packages.gui_demo" } } - ] + ], + "connections": { + "gui_begin": { + "main": [ + [ + { "node": "gui_layout", "type": "main", "index": 0 } + ] + ] + }, + "gui_layout": { + "main": [ + [ + { "node": "render_ui", "type": "main", "index": 0 } + ] + ] + }, + "render_ui": { + "main": [ + [ + { "node": "capture_ui", "type": "main", "index": 0 } + ] + ] + } + } } diff --git a/packages/gui/workflows/stubs/gui_layout_placeholder.json b/packages/gui/workflows/stubs/gui_layout_placeholder.json deleted file mode 100644 index 3eb9590..0000000 --- a/packages/gui/workflows/stubs/gui_layout_placeholder.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "id": "frame.gui", - "summary": "Placeholder for GUI layout + input processing steps that are still being refactored.", - "stage": "planned", - "notes": [ - "Should resolve UI command bundles and update ImGui state.", - "Currently registered so workflows can declare GUI dependencies without crashing." - ] -} diff --git a/packages/quake3/package.json b/packages/quake3/package.json index faf931c..29dd3f2 100644 --- a/packages/quake3/package.json +++ b/packages/quake3/package.json @@ -4,8 +4,7 @@ "description": "Quake3-style example package bundling physics, scene, and map metadata.", "defaultWorkflow": "workflows/quake3_frame.json", "workflows": [ - "workflows/quake3_frame.json", - "workflows/stubs/quake3_physics_placeholder.json" + "workflows/quake3_frame.json" ], "assets": [ "assets/quake3_materials.json" diff --git a/packages/quake3/workflows/quake3_frame.json b/packages/quake3/workflows/quake3_frame.json index 407ad3b..2740d7e 100644 --- a/packages/quake3/workflows/quake3_frame.json +++ b/packages/quake3/workflows/quake3_frame.json @@ -1,9 +1,10 @@ { "template": "frame.default", - "steps": [ + "nodes": [ { "id": "quake_begin", "plugin": "frame.begin", + "position": [0, 0], "inputs": { "delta": "frame.delta" } @@ -11,6 +12,7 @@ { "id": "quake_physics", "plugin": "frame.bullet_physics", + "position": [260, 0], "inputs": { "delta": "frame.delta" } @@ -18,6 +20,7 @@ { "id": "quake_scene", "plugin": "frame.scene", + "position": [520, 0], "inputs": { "delta": "frame.delta" } @@ -25,6 +28,7 @@ { "id": "quake_render", "plugin": "frame.render", + "position": [780, 0], "inputs": { "elapsed": "frame.elapsed" } @@ -32,9 +36,40 @@ { "id": "quake_validation", "plugin": "validation.tour.checkpoint", + "position": [1040, 0], "inputs": { "checkpoint": "packages.quake3_map" } } - ] + ], + "connections": { + "quake_begin": { + "main": [ + [ + { "node": "quake_physics", "type": "main", "index": 0 } + ] + ] + }, + "quake_physics": { + "main": [ + [ + { "node": "quake_scene", "type": "main", "index": 0 } + ] + ] + }, + "quake_scene": { + "main": [ + [ + { "node": "quake_render", "type": "main", "index": 0 } + ] + ] + }, + "quake_render": { + "main": [ + [ + { "node": "quake_validation", "type": "main", "index": 0 } + ] + ] + } + } } diff --git a/packages/quake3/workflows/stubs/quake3_physics_placeholder.json b/packages/quake3/workflows/stubs/quake3_physics_placeholder.json deleted file mode 100644 index 4c5d48c..0000000 --- a/packages/quake3/workflows/stubs/quake3_physics_placeholder.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "id": "frame.bullet_physics", - "summary": "Quake3 physics stub to ensure workflows can declare bullet-dependent steps.", - "stage": "planned", - "notes": [ - "Should load BSP geometry, apply gravity, and resolve collisions.", - "Widgets will be active once the physics service is refactored accordingly." - ] -} diff --git a/packages/seed/package.json b/packages/seed/package.json index 67dded1..1f217ab 100644 --- a/packages/seed/package.json +++ b/packages/seed/package.json @@ -4,8 +4,7 @@ "description": "Template package describing a boot-to-frame workflow, assets, and validation presets for the demo cube.", "defaultWorkflow": "workflows/demo_gameplay.json", "workflows": [ - "workflows/demo_gameplay.json", - "workflows/stubs/bullet_physics_placeholder.json" + "workflows/demo_gameplay.json" ], "assets": [ "assets/cube_materials.json" diff --git a/packages/seed/workflows/demo_gameplay.json b/packages/seed/workflows/demo_gameplay.json index bd11c98..e8afd9c 100644 --- a/packages/seed/workflows/demo_gameplay.json +++ b/packages/seed/workflows/demo_gameplay.json @@ -1,9 +1,10 @@ { "template": "frame.default", - "steps": [ + "nodes": [ { "id": "begin_frame", "plugin": "frame.begin", + "position": [0, 0], "inputs": { "delta": "frame.delta", "elapsed": "frame.elapsed" @@ -12,6 +13,7 @@ { "id": "camera_control", "plugin": "frame.camera", + "position": [260, 0], "inputs": { "delta": "frame.delta" } @@ -19,6 +21,7 @@ { "id": "bullet_physics", "plugin": "frame.bullet_physics", + "position": [520, 0], "inputs": { "delta": "frame.delta" } @@ -26,6 +29,7 @@ { "id": "scene", "plugin": "frame.scene", + "position": [780, 0], "inputs": { "delta": "frame.delta" } @@ -33,6 +37,7 @@ { "id": "render", "plugin": "frame.render", + "position": [1040, 0], "inputs": { "elapsed": "frame.elapsed" } @@ -40,9 +45,47 @@ { "id": "validate_capture", "plugin": "validation.tour.checkpoint", + "position": [1300, 0], "inputs": { "checkpoint": "gameplay.startup_camera" } } - ] + ], + "connections": { + "begin_frame": { + "main": [ + [ + { "node": "camera_control", "type": "main", "index": 0 } + ] + ] + }, + "camera_control": { + "main": [ + [ + { "node": "bullet_physics", "type": "main", "index": 0 } + ] + ] + }, + "bullet_physics": { + "main": [ + [ + { "node": "scene", "type": "main", "index": 0 } + ] + ] + }, + "scene": { + "main": [ + [ + { "node": "render", "type": "main", "index": 0 } + ] + ] + }, + "render": { + "main": [ + [ + { "node": "validate_capture", "type": "main", "index": 0 } + ] + ] + } + } } diff --git a/packages/seed/workflows/stubs/bullet_physics_placeholder.json b/packages/seed/workflows/stubs/bullet_physics_placeholder.json deleted file mode 100644 index deaa7da..0000000 --- a/packages/seed/workflows/stubs/bullet_physics_placeholder.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "id": "frame.bullet_physics", - "summary": "Stub for bullet physics integration; wiring exists but needs a concrete implementation before it becomes active.", - "stage": "planned", - "notes": [ - "Dependencies: physics service + rigid body registry", - "Placeholder step ensures the workflow registry can parse missing plugins without crashing.", - "Once implemented, this step should apply gravity + constraints before the scene update step." - ] -} diff --git a/packages/soundboard/package.json b/packages/soundboard/package.json index a31e0be..38017ad 100644 --- a/packages/soundboard/package.json +++ b/packages/soundboard/package.json @@ -4,11 +4,7 @@ "description": "Workflow template for the soundboard experience (audio cues + GUI controls).", "defaultWorkflow": "workflows/soundboard_flow.json", "workflows": [ - "workflows/soundboard_flow.json", - "workflows/stubs/soundboard_catalog_scan_stub.json", - "workflows/stubs/soundboard_gui_stub.json", - "workflows/stubs/soundboard_audio_stub.json", - "workflows/stubs/audio_visualizer_placeholder.json" + "workflows/soundboard_flow.json" ], "assets": [ "assets/sound/sound_samples.json", diff --git a/packages/soundboard/workflows/soundboard_flow.json b/packages/soundboard/workflows/soundboard_flow.json index 8703e67..38d6912 100644 --- a/packages/soundboard/workflows/soundboard_flow.json +++ b/packages/soundboard/workflows/soundboard_flow.json @@ -1,9 +1,10 @@ { "template": "frame.default", - "steps": [ + "nodes": [ { "id": "begin_frame", "plugin": "frame.begin", + "position": [0, 0], "inputs": { "delta": "frame.delta", "elapsed": "frame.elapsed" @@ -12,6 +13,7 @@ { "id": "catalog_scan", "plugin": "soundboard.catalog.scan", + "position": [260, -120], "outputs": { "catalog": "soundboard.catalog" } @@ -19,6 +21,7 @@ { "id": "gui_render", "plugin": "soundboard.gui", + "position": [520, -120], "inputs": { "catalog": "soundboard.catalog", "layout": "soundboard.layout" @@ -30,6 +33,7 @@ { "id": "audio_dispatch", "plugin": "soundboard.audio", + "position": [780, -120], "inputs": { "catalog": "soundboard.catalog", "selection": "soundboard.selection" @@ -41,6 +45,7 @@ { "id": "render_frame", "plugin": "frame.render", + "position": [520, 120], "inputs": { "elapsed": "frame.elapsed" } @@ -48,9 +53,48 @@ { "id": "validation_capture", "plugin": "validation.tour.checkpoint", + "position": [780, 120], "inputs": { "checkpoint": "packages.soundboard" } } - ] + ], + "connections": { + "begin_frame": { + "main": [ + [ + { "node": "catalog_scan", "type": "main", "index": 0 }, + { "node": "render_frame", "type": "main", "index": 0 } + ] + ] + }, + "catalog_scan": { + "main": [ + [ + { "node": "gui_render", "type": "main", "index": 0 } + ] + ] + }, + "gui_render": { + "main": [ + [ + { "node": "audio_dispatch", "type": "main", "index": 0 } + ] + ] + }, + "audio_dispatch": { + "main": [ + [ + { "node": "validation_capture", "type": "main", "index": 0 } + ] + ] + }, + "render_frame": { + "main": [ + [ + { "node": "validation_capture", "type": "main", "index": 0 } + ] + ] + } + } } diff --git a/packages/soundboard/workflows/stubs/audio_visualizer_placeholder.json b/packages/soundboard/workflows/stubs/audio_visualizer_placeholder.json deleted file mode 100644 index 65e33a7..0000000 --- a/packages/soundboard/workflows/stubs/audio_visualizer_placeholder.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "id": "frame.audio", - "summary": "Audio dispatch stub for the soundboard; actual cue scheduling + mixing service is pending.", - "stage": "planned", - "notes": [ - "Should queue audio commands and report current sample index to UI.", - "Registered so workflows can mention the audio step without requiring implementation." - ] -} diff --git a/packages/soundboard/workflows/stubs/soundboard_audio_stub.json b/packages/soundboard/workflows/stubs/soundboard_audio_stub.json deleted file mode 100644 index 01433f5..0000000 --- a/packages/soundboard/workflows/stubs/soundboard_audio_stub.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "id": "soundboard.audio", - "summary": "Plays clips requested by the GUI and reports playback status.", - "stage": "planned", - "inputs": [ - { - "name": "catalog", - "type": "soundboard.catalog", - "description": "Catalog data to resolve clip paths." - }, - { - "name": "selection", - "type": "soundboard.selection", - "description": "Selected clip identifier emitted by the GUI step." - } - ], - "outputs": [ - { - "name": "status", - "type": "soundboard.status", - "description": "Playback status message (playing/failed/missing)." - } - ], - "notes": [ - "Should interact with the runtime audio command pipeline (`audio_play_sound` equivalent) to queue clips.", - "Status outputs let the GUI display feedback (success, failure, no clip available)." - ] -} diff --git a/packages/soundboard/workflows/stubs/soundboard_catalog_scan_stub.json b/packages/soundboard/workflows/stubs/soundboard_catalog_scan_stub.json deleted file mode 100644 index a7b4f08..0000000 --- a/packages/soundboard/workflows/stubs/soundboard_catalog_scan_stub.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "id": "soundboard.catalog.scan", - "summary": "Scans `assets/audio_catalog.json` plus audio folders to publish clip metadata.", - "stage": "planned", - "inputs": [ - { - "name": "catalog_descriptor", - "type": "json_path", - "description": "Path to `assets/audio_catalog.json` describing categories + relative folders." - } - ], - "outputs": [ - { - "name": "catalog", - "type": "soundboard.catalog", - "description": "Structured catalog with categories, clip names, absolute/audio-relative paths." - } - ], - "notes": [ - "This step should read the JSON descriptor, scan the referenced folders (`assets/audio/sfx`, `assets/audio/tts`), and sort clip names alphabetically per category.", - "The output catalog should include the category ID, display name, and clip list (filename + label + path)." - ] -} diff --git a/packages/soundboard/workflows/stubs/soundboard_gui_stub.json b/packages/soundboard/workflows/stubs/soundboard_gui_stub.json deleted file mode 100644 index d74350b..0000000 --- a/packages/soundboard/workflows/stubs/soundboard_gui_stub.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "id": "soundboard.gui", - "summary": "Renders buttons for every clip in the catalog and emits selection events.", - "stage": "planned", - "inputs": [ - { - "name": "catalog", - "type": "soundboard.catalog", - "description": "Catalog produced by `soundboard.catalog.scan` (categories + clip names/paths)." - }, - { - "name": "layout", - "type": "json_path", - "description": "GUI layout definition from `assets/soundboard_gui.json`." - } - ], - "outputs": [ - { - "name": "selection", - "type": "soundboard.selection", - "description": "Clip selection event including category, clip id, and display label." - } - ], - "notes": [ - "Should render a panel with column tabs per category, draw buttons for each clip, and feed button presses into the audio dispatcher.", - "Should also publish status messages (e.g., playback started/failed) for preview." - ] -} diff --git a/src/services/impl/frame_workflow_step_registrar.cpp b/src/services/impl/frame_workflow_step_registrar.cpp index cc9f389..3c6b8ec 100644 --- a/src/services/impl/frame_workflow_step_registrar.cpp +++ b/src/services/impl/frame_workflow_step_registrar.cpp @@ -2,6 +2,7 @@ #include "workflow_frame_audio_step.hpp" #include "workflow_frame_begin_step.hpp" +#include "workflow_frame_bullet_physics_step.hpp" #include "workflow_frame_gui_step.hpp" #include "workflow_frame_physics_step.hpp" #include "workflow_frame_render_step.hpp" @@ -43,6 +44,9 @@ void FrameWorkflowStepRegistrar::RegisterUsedSteps( if (plugins.contains("frame.physics")) { registry->RegisterStep(std::make_shared(physicsService_, logger_)); } + if (plugins.contains("frame.bullet_physics")) { + registry->RegisterStep(std::make_shared(physicsService_, logger_)); + } if (plugins.contains("frame.scene")) { registry->RegisterStep(std::make_shared(sceneService_, logger_)); } diff --git a/src/services/impl/workflow_definition_parser.cpp b/src/services/impl/workflow_definition_parser.cpp index 2d8b399..3e1928f 100644 --- a/src/services/impl/workflow_definition_parser.cpp +++ b/src/services/impl/workflow_definition_parser.cpp @@ -4,6 +4,7 @@ #include #include +#include #include #include #include @@ -36,34 +37,197 @@ std::unordered_map ReadStringMap(const rapidjson::Valu } return result; } + +std::string ReadNodeId(const rapidjson::Value& node, size_t index) { + if (node.HasMember("id") && node["id"].IsString()) { + return node["id"].GetString(); + } + if (node.HasMember("name") && node["name"].IsString()) { + return node["name"].GetString(); + } + throw std::runtime_error("Workflow node[" + std::to_string(index) + "] requires string id or name"); +} + +std::string ReadNodePlugin(const rapidjson::Value& node, const std::string& nodeId) { + if (node.HasMember("plugin") && node["plugin"].IsString()) { + return node["plugin"].GetString(); + } + if (node.HasMember("type") && node["type"].IsString()) { + return node["type"].GetString(); + } + throw std::runtime_error("Workflow node '" + nodeId + "' requires string plugin or type"); +} + +std::vector> ReadConnections(const rapidjson::Value& document) { + if (!document.HasMember("connections")) { + return {}; + } + const auto& connectionsValue = document["connections"]; + if (!connectionsValue.IsObject()) { + throw std::runtime_error("Workflow member 'connections' must be an object"); + } + std::vector> edges; + for (auto it = connectionsValue.MemberBegin(); it != connectionsValue.MemberEnd(); ++it) { + const std::string fromNode = it->name.GetString(); + if (!it->value.IsObject()) { + throw std::runtime_error("Workflow connections for '" + fromNode + "' must be an object"); + } + if (!it->value.HasMember("main")) { + continue; + } + const auto& mainValue = it->value["main"]; + if (!mainValue.IsArray()) { + throw std::runtime_error("Workflow connections.main for '" + fromNode + "' must be an array"); + } + for (const auto& branch : mainValue.GetArray()) { + if (!branch.IsArray()) { + throw std::runtime_error("Workflow connections.main entries for '" + fromNode + "' must be arrays"); + } + for (const auto& connection : branch.GetArray()) { + if (!connection.IsObject() || !connection.HasMember("node") || !connection["node"].IsString()) { + throw std::runtime_error("Workflow connection entries for '" + fromNode + "' require a node string"); + } + edges.emplace_back(fromNode, connection["node"].GetString()); + } + } + } + return edges; +} + +std::vector SortNodesByConnections( + const std::vector& nodeOrder, + const std::vector>& edges) { + std::unordered_map indexById; + std::unordered_map indegree; + std::unordered_map> adjacency; + indexById.reserve(nodeOrder.size()); + indegree.reserve(nodeOrder.size()); + adjacency.reserve(nodeOrder.size()); + for (size_t i = 0; i < nodeOrder.size(); ++i) { + indexById.emplace(nodeOrder[i], i); + indegree.emplace(nodeOrder[i], 0); + adjacency.emplace(nodeOrder[i], std::vector{}); + } + + for (const auto& edge : edges) { + const auto fromIt = indexById.find(edge.first); + if (fromIt == indexById.end()) { + throw std::runtime_error("Workflow connection references unknown node '" + edge.first + "'"); + } + const auto toIt = indexById.find(edge.second); + if (toIt == indexById.end()) { + throw std::runtime_error("Workflow connection references unknown node '" + edge.second + "'"); + } + adjacency[edge.first].push_back(edge.second); + ++indegree[edge.second]; + } + + std::set> ready; + for (const auto& nodeId : nodeOrder) { + if (indegree[nodeId] == 0u) { + ready.emplace(indexById[nodeId], nodeId); + } + } + + std::vector ordered; + ordered.reserve(nodeOrder.size()); + while (!ready.empty()) { + auto it = ready.begin(); + const std::string nodeId = it->second; + ready.erase(it); + ordered.push_back(nodeId); + for (const auto& next : adjacency[nodeId]) { + auto indegreeIt = indegree.find(next); + if (indegreeIt == indegree.end()) { + continue; + } + if (--indegreeIt->second == 0u) { + ready.emplace(indexById[next], next); + } + } + } + + if (ordered.size() != nodeOrder.size()) { + throw std::runtime_error("Workflow connections contain a cycle"); + } + + return ordered; +} } // namespace WorkflowDefinition WorkflowDefinitionParser::ParseFile(const std::filesystem::path& path) const { json_config::JsonConfigDocumentParser parser; rapidjson::Document document = parser.Parse(path, "workflow file"); - if (!document.HasMember("steps") || !document["steps"].IsArray()) { - throw std::runtime_error("Workflow must contain a 'steps' array"); + const bool hasSteps = document.HasMember("steps"); + const bool hasNodes = document.HasMember("nodes"); + if (hasSteps && hasNodes) { + throw std::runtime_error("Workflow cannot define both 'steps' and 'nodes'"); + } + if (!hasSteps && !hasNodes) { + throw std::runtime_error("Workflow must contain a 'steps' array or 'nodes' array"); } WorkflowDefinition workflow; if (document.HasMember("template")) { - if (!document["template"].IsString()) { - throw std::runtime_error("Workflow member 'template' must be a string"); - } - workflow.templateName = document["template"].GetString(); + workflow.templateName = ReadRequiredString(document, "template"); } - for (const auto& entry : document["steps"].GetArray()) { + if (hasSteps) { + if (!document["steps"].IsArray()) { + throw std::runtime_error("Workflow must contain a 'steps' array"); + } + for (const auto& entry : document["steps"].GetArray()) { + if (!entry.IsObject()) { + throw std::runtime_error("Workflow steps must be objects"); + } + WorkflowStepDefinition step; + step.id = ReadRequiredString(entry, "id"); + step.plugin = ReadRequiredString(entry, "plugin"); + step.inputs = ReadStringMap(entry, "inputs"); + step.outputs = ReadStringMap(entry, "outputs"); + workflow.steps.push_back(std::move(step)); + } + return workflow; + } + + if (!document["nodes"].IsArray()) { + throw std::runtime_error("Workflow must contain a 'nodes' array"); + } + + std::vector nodes; + std::vector nodeOrder; + for (rapidjson::SizeType i = 0; i < document["nodes"].Size(); ++i) { + const auto& entry = document["nodes"][i]; if (!entry.IsObject()) { - throw std::runtime_error("Workflow steps must be objects"); + throw std::runtime_error("Workflow nodes must be objects"); } WorkflowStepDefinition step; - step.id = ReadRequiredString(entry, "id"); - step.plugin = ReadRequiredString(entry, "plugin"); + step.id = ReadNodeId(entry, i); + step.plugin = ReadNodePlugin(entry, step.id); step.inputs = ReadStringMap(entry, "inputs"); step.outputs = ReadStringMap(entry, "outputs"); - workflow.steps.push_back(std::move(step)); + nodes.push_back(step); + nodeOrder.push_back(step.id); + } + + const auto edges = ReadConnections(document); + std::vector orderedIds = edges.empty() + ? nodeOrder + : SortNodesByConnections(nodeOrder, edges); + + std::unordered_map nodeMap; + nodeMap.reserve(nodes.size()); + for (const auto& node : nodes) { + nodeMap.emplace(node.id, node); + } + workflow.steps.reserve(nodes.size()); + for (const auto& nodeId : orderedIds) { + auto it = nodeMap.find(nodeId); + if (it == nodeMap.end()) { + throw std::runtime_error("Workflow nodes missing entry for '" + nodeId + "'"); + } + workflow.steps.push_back(it->second); } return workflow; diff --git a/src/services/impl/workflow_frame_bullet_physics_step.cpp b/src/services/impl/workflow_frame_bullet_physics_step.cpp new file mode 100644 index 0000000..833f9de --- /dev/null +++ b/src/services/impl/workflow_frame_bullet_physics_step.cpp @@ -0,0 +1,35 @@ +#include "workflow_frame_bullet_physics_step.hpp" +#include "workflow_step_io_resolver.hpp" + +#include + +namespace sdl3cpp::services::impl { + +WorkflowFrameBulletPhysicsStep::WorkflowFrameBulletPhysicsStep(std::shared_ptr physicsService, + std::shared_ptr logger) + : physicsService_(std::move(physicsService)), + logger_(std::move(logger)) {} + +std::string WorkflowFrameBulletPhysicsStep::GetPluginId() const { + return "frame.bullet_physics"; +} + +void WorkflowFrameBulletPhysicsStep::Execute(const WorkflowStepDefinition& step, WorkflowContext& context) { + if (!physicsService_) { + throw std::runtime_error("frame.bullet_physics requires an IPhysicsService"); + } + WorkflowStepIoResolver resolver; + const std::string deltaKey = resolver.GetRequiredInputKey(step, "delta"); + const auto* delta = context.TryGet(deltaKey); + if (!delta) { + throw std::runtime_error("frame.bullet_physics missing delta input"); + } + physicsService_->StepSimulation(static_cast(*delta)); + if (logger_) { + logger_->Trace("WorkflowFrameBulletPhysicsStep", "Execute", + "delta=" + std::to_string(*delta), + "Bullet physics step completed"); + } +} + +} // namespace sdl3cpp::services::impl diff --git a/src/services/impl/workflow_frame_bullet_physics_step.hpp b/src/services/impl/workflow_frame_bullet_physics_step.hpp new file mode 100644 index 0000000..b75207c --- /dev/null +++ b/src/services/impl/workflow_frame_bullet_physics_step.hpp @@ -0,0 +1,25 @@ +#pragma once + +#include "../interfaces/i_logger.hpp" +#include "../interfaces/i_physics_service.hpp" +#include "../interfaces/i_workflow_step.hpp" + +#include +#include + +namespace sdl3cpp::services::impl { + +class WorkflowFrameBulletPhysicsStep final : public IWorkflowStep { +public: + WorkflowFrameBulletPhysicsStep(std::shared_ptr physicsService, + std::shared_ptr logger); + + std::string GetPluginId() const override; + void Execute(const WorkflowStepDefinition& step, WorkflowContext& context) override; + +private: + std::shared_ptr physicsService_; + std::shared_ptr logger_; +}; + +} // namespace sdl3cpp::services::impl diff --git a/tests/workflow_definition_parser_test.cpp b/tests/workflow_definition_parser_test.cpp index 855348f..0540dad 100644 --- a/tests/workflow_definition_parser_test.cpp +++ b/tests/workflow_definition_parser_test.cpp @@ -80,3 +80,71 @@ TEST(WorkflowDefinitionParserTest, FailsWhenPluginIsMissing) { EXPECT_NE(message.find("plugin"), std::string::npos); } } + +TEST(WorkflowDefinitionParserTest, ParsesN8nNodesWithConnections) { + const std::string json = R"json({ + "template": "boot.default", + "nodes": [ + { + "id": "load_config", + "plugin": "config.load", + "inputs": { "path": "config.path" }, + "outputs": { "document": "config.document" } + }, + { + "name": "validate_schema", + "type": "config.schema.validate", + "inputs": { "document": "config.document", "path": "config.path" } + } + ], + "connections": { + "load_config": { + "main": [ + [ + { "node": "validate_schema", "type": "main", "index": 0 } + ] + ] + } + } +})json"; + + TempFile file(json); + sdl3cpp::services::impl::WorkflowDefinitionParser parser; + const auto workflow = parser.ParseFile(file.path); + + ASSERT_EQ(workflow.steps.size(), 2u); + EXPECT_EQ(workflow.steps[0].id, "load_config"); + EXPECT_EQ(workflow.steps[0].plugin, "config.load"); + EXPECT_EQ(workflow.steps[1].id, "validate_schema"); + EXPECT_EQ(workflow.steps[1].plugin, "config.schema.validate"); +} + +TEST(WorkflowDefinitionParserTest, RejectsUnknownNodeInConnections) { + const std::string json = R"json({ + "nodes": [ + { + "id": "start", + "plugin": "config.load" + } + ], + "connections": { + "start": { + "main": [ + [ + { "node": "missing", "type": "main", "index": 0 } + ] + ] + } + } +})json"; + + TempFile file(json); + sdl3cpp::services::impl::WorkflowDefinitionParser parser; + try { + parser.ParseFile(file.path); + FAIL() << "Expected workflow parser to reject unknown node connection"; + } catch (const std::runtime_error& error) { + const std::string message = error.what(); + EXPECT_NE(message.find("unknown node"), std::string::npos); + } +}