Skip to main content

nightshade_api/editor/
protocol.rs

1//! The editor's wire format: page to worker messages, worker to page state
2//! mirrors, and the external agent request and response types.
3
4#[cfg(feature = "protocol-agent")]
5mod agent;
6#[cfg(feature = "protocol-agent")]
7pub use agent::*;
8
9use crate::command::Command;
10use nightshade::ecs::camera::components::Camera;
11use nightshade::ecs::decal::components::Decal;
12use nightshade::ecs::event::Event;
13use nightshade::ecs::light::components::Light;
14use nightshade::ecs::scene::{SceneChunkConfig, SceneLayerConfig};
15use nightshade::ecs::text::components::TextProperties;
16use nightshade::ecs::vfx::components::{Beam, LightningBolt, Trail, VfxAnimator};
17use nightshade::ecs::water::components::Water;
18use nightshade::plugins::audio::components::AudioSource;
19use nightshade::plugins::gizmos::GizmoMode;
20pub use nightshade::prelude::{
21    Atmosphere, CharacterControllerComponent, ColliderComponent, NavMeshAgent, RigidBodyComponent,
22};
23use nightshade::render::material::Material;
24use nightshade::render::particles::ParticleEmitter;
25use serde::{Deserialize, Serialize};
26
27pub use nightshade::ecs::camera::components::{
28    Camera as CameraComponent, OrthographicCamera, PerspectiveCamera, Projection,
29};
30pub use nightshade::ecs::decal::components::Decal as DecalComponent;
31pub use nightshade::ecs::light::components::{AreaLightShape, Light as LightComponent, LightType};
32pub use nightshade::ecs::primitives::{CameraCullingMask, CullingMask};
33pub use nightshade::ecs::text::components::TextProperties as TextPropertiesComponent;
34pub use nightshade::ecs::vfx::components::{
35    Beam as BeamComponent, LightningBolt as LightningBoltComponent, Trail as TrailComponent,
36    VfxAnimator as VfxAnimatorComponent,
37};
38pub use nightshade::ecs::water::components::Water as WaterComponent;
39pub use nightshade::plugins::audio::components::AudioSource as AudioSourceComponent;
40pub use nightshade::plugins::gizmos::GizmoMode as GizmoModeKind;
41pub use nightshade::plugins::physics::components::ColliderShape;
42pub use nightshade::plugins::physics::types::RigidBodyType;
43pub use nightshade::prelude::{Atmosphere as AtmosphereKind, Vec2, Vec3, Vec4};
44pub use nightshade::render::material::AlphaMode;
45pub use nightshade::render::material::Material as MaterialComponent;
46pub use nightshade::render::particles::ParticleEmitter as ParticleEmitterComponent;
47pub use nightshade::render::render_layer::RenderLayer;
48
49/// A parametric primitive shape used for spawning and block placement.
50#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
51#[cfg_attr(
52    feature = "protocol-agent",
53    derive(enum2schema::Schema),
54    schema(string_enum)
55)]
56pub enum ShapeKind {
57    Cube,
58    Plane,
59    Cylinder,
60    Cone,
61    Sphere,
62    Torus,
63}
64
65/// A light kind for the Add menu.
66#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
67#[cfg_attr(
68    feature = "protocol-agent",
69    derive(enum2schema::Schema),
70    schema(string_enum)
71)]
72pub enum LightKind {
73    Point,
74    Spot,
75    Directional,
76}
77
78/// A procedural greybox generator.
79#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
80#[cfg_attr(
81    feature = "protocol-agent",
82    derive(enum2schema::Schema),
83    schema(string_enum)
84)]
85pub enum GeneratorKind {
86    Building,
87    Room,
88    Tower,
89    Stairs,
90    Columns,
91    Perimeter,
92    CityBlock,
93    Courtyard,
94    Wfc,
95    WfcCity,
96}
97
98/// Asset browser category for Polyhaven.
99#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
100#[cfg_attr(
101    feature = "protocol-agent",
102    derive(enum2schema::Schema),
103    schema(string_enum)
104)]
105pub enum PolyhavenCategory {
106    Hdris,
107    Models,
108}
109
110pub use crate::reflect::{ComponentKind, ComponentPatch};
111
112/// Animation playback control for the selected entity's player.
113#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
114#[cfg_attr(feature = "protocol-agent", derive(enum2schema::Schema))]
115pub enum AnimationCommand {
116    Play { index: u32 },
117    PlayAll,
118    Pause,
119    Resume,
120    Stop,
121    Seek { time: f32 },
122    SetSpeed { speed: f32 },
123    SetLooping { looping: bool },
124}
125
126/// Procedural generation settings, mirrored from the worker's resource so the
127/// panel edits round-trip.
128#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
129#[cfg_attr(feature = "protocol-agent", derive(enum2schema::Schema))]
130pub struct GenerationSettings {
131    pub footprint_x: f32,
132    pub footprint_z: f32,
133    pub stories: u32,
134    pub story_height: f32,
135    pub wall_thickness: f32,
136    pub floor_thickness: f32,
137    pub window_spacing: f32,
138    pub seed: u32,
139    pub variation: f32,
140}
141
142/// Every world-level rendering setting the inspector's world sections edit,
143/// synced both ways as one block.
144#[derive(Clone, Copy, PartialEq, Serialize, Deserialize)]
145pub struct RenderSettingsState {
146    pub ambient_light: [f32; 4],
147    pub bloom_enabled: bool,
148    pub bloom_intensity: f32,
149    pub bloom_threshold: f32,
150    pub bloom_knee: f32,
151    pub bloom_filter_radius: f32,
152    pub ssao_enabled: bool,
153    pub ssao_radius: f32,
154    pub ssao_intensity: f32,
155    pub ssao_bias: f32,
156    pub ssao_sample_count: u32,
157    pub ssao_visualization: bool,
158    pub ssgi_enabled: bool,
159    pub ssgi_radius: f32,
160    pub ssgi_intensity: f32,
161    pub ssgi_max_steps: u32,
162    pub ssr_enabled: bool,
163    pub ssr_max_steps: u32,
164    pub ssr_thickness: f32,
165    pub ssr_max_distance: f32,
166    pub ssr_stride: f32,
167    pub ssr_fade_start: f32,
168    pub ssr_fade_end: f32,
169    pub ssr_intensity: f32,
170    pub fog_enabled: bool,
171    pub fog_color: [f32; 3],
172    pub fog_start: f32,
173    pub fog_end: f32,
174    pub dof_enabled: bool,
175    pub dof_focus_distance: f32,
176    pub dof_focus_range: f32,
177    pub dof_max_blur_radius: f32,
178    pub dof_bokeh_threshold: f32,
179    pub dof_bokeh_intensity: f32,
180    pub dof_quality: u32,
181    pub taa_enabled: bool,
182    pub render_scale: f32,
183    pub ibl_blend_factor: f32,
184    pub pbr_debug_index: u32,
185    pub unlit_mode: bool,
186    pub selection_outline_enabled: bool,
187    pub selection_outline_color: [f32; 4],
188    pub exposure: f32,
189    pub saturation: f32,
190    pub contrast: f32,
191    pub brightness: f32,
192    pub gamma: f32,
193    pub show_grid: bool,
194    pub show_sky: bool,
195    pub show_normals: bool,
196    pub navmesh_debug: bool,
197    pub atmosphere: Atmosphere,
198}
199
200/// Editor-side state the page mirrors for toolbar and panel highlights.
201#[derive(Clone, PartialEq, Serialize, Deserialize)]
202pub struct EditorFlags {
203    pub gizmo_mode: GizmoMode,
204    pub snap_enabled: bool,
205    pub snap_translation_step: f32,
206    pub snap_rotation_step_degrees: f32,
207    pub snap_scale_step: f32,
208    pub greybox_enabled: bool,
209    pub greybox_show_final: bool,
210    pub placement_active: bool,
211    pub placement_shape: ShapeKind,
212    pub placement_size: [f32; 3],
213    pub placement_orient: bool,
214    pub placement_status: String,
215    pub push_pull_active: bool,
216    pub scripting_active: bool,
217    pub playground_active: bool,
218    pub fly_mode: bool,
219    pub viewer_mode: bool,
220    pub frame_on_load: bool,
221    pub day_night_hour: f32,
222    pub day_night_auto: bool,
223    pub skeleton_view: bool,
224    pub rotation_speed: f32,
225    pub active_palette: usize,
226    pub edit_mode_target: Option<u32>,
227    pub generation: GenerationSettings,
228    pub global_scripts: Vec<nightshade::ecs::script::components::GlobalScript>,
229}
230
231/// An ordered edit to the scene's global script list.
232#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
233#[cfg_attr(feature = "protocol-agent", derive(enum2schema::Schema))]
234pub enum GlobalScriptCommand {
235    Add,
236    AddTemplate { name: String, source: String },
237    Remove(u32),
238    MoveUp(u32),
239    MoveDown(u32),
240    SetEnabled { index: u32, enabled: bool },
241    SetSource { index: u32, source: String },
242    Rename { index: u32, name: String },
243}
244
245/// One row of the flattened scene tree, in depth-first order.
246#[derive(Clone, PartialEq, Serialize, Deserialize)]
247pub struct TreeRow {
248    pub id: u32,
249    pub name: String,
250    pub depth: u32,
251    pub has_children: bool,
252    pub camera: bool,
253    pub light: bool,
254    pub mesh: bool,
255}
256
257/// A greybox material palette swatch.
258#[derive(Clone, PartialEq, Serialize, Deserialize)]
259pub struct PaletteEntry {
260    pub label: String,
261    pub color: [f32; 3],
262}
263
264/// Prefab link shown in the inspector for instanced prefab roots.
265#[derive(Clone, PartialEq, Serialize, Deserialize)]
266pub struct PrefabLink {
267    pub name: String,
268    pub source_path: Option<String>,
269}
270
271/// One mesh's morph targets for the scene-wide morph panel: the entity id, a
272/// display name, and the current weight per target.
273#[derive(Clone, PartialEq, Serialize, Deserialize)]
274pub struct MorphMeshInfo {
275    pub id: u32,
276    pub name: String,
277    pub weights: Vec<f32>,
278}
279
280/// Animation state of the selected entity, for the inspector's player section.
281#[derive(Clone, PartialEq, Serialize, Deserialize)]
282pub struct AnimationInfo {
283    pub clips: Vec<String>,
284    pub current: Option<u32>,
285    pub playing: bool,
286    pub time: f32,
287    pub duration: f32,
288    pub speed: f32,
289    pub looping: bool,
290}
291
292/// The selected entity's full editable detail. Rotation is Euler degrees.
293#[derive(Clone, Serialize, Deserialize)]
294pub struct SelectedEntity {
295    pub id: u32,
296    /// Bumped whenever a committed edit or undo changed this entity, so the
297    /// page re-seeds its local editor copies exactly then.
298    pub revision: u32,
299    pub name: String,
300    pub translation: [f32; 3],
301    pub rotation: [f32; 3],
302    pub scale: [f32; 3],
303    pub mesh: Option<String>,
304    pub material_name: Option<String>,
305    pub tags: Vec<String>,
306    pub layer: Option<u32>,
307    pub chunk: Option<u32>,
308    pub prefab: Option<PrefabLink>,
309    pub animation: Option<AnimationInfo>,
310    pub morph_weights: Option<Vec<f32>>,
311    pub selected_count: u32,
312    pub visibility: Option<bool>,
313    pub casts_shadow: bool,
314    pub light: Option<Light>,
315    pub camera: Option<Camera>,
316    pub is_active_camera: bool,
317    pub rigid_body: Option<RigidBodyComponent>,
318    pub collider: Option<ColliderComponent>,
319    pub character_controller: Option<CharacterControllerComponent>,
320    pub navmesh_agent: Option<NavMeshAgent>,
321    pub particle_emitter: Option<Box<ParticleEmitter>>,
322    pub decal: Option<Decal>,
323    pub water: Option<Water>,
324    pub audio_source: Option<AudioSource>,
325    pub render_layer: Option<RenderLayer>,
326    pub culling_mask: Option<CullingMask>,
327    pub camera_culling_mask: Option<CameraCullingMask>,
328    pub ignore_parent_scale: bool,
329    pub text: Option<(String, TextProperties)>,
330    pub script: Option<String>,
331    pub beam: Option<Box<Beam>>,
332    pub lightning_bolt: Option<Box<LightningBolt>>,
333    pub trail: Option<Box<Trail>>,
334    pub vfx_animator: Option<Box<VfxAnimator>>,
335    pub present: Vec<ComponentKind>,
336    pub addable: Vec<ComponentKind>,
337}
338
339/// A material registry entry for the materials panel.
340#[derive(Clone, Serialize, Deserialize)]
341pub struct MaterialEntry {
342    pub name: String,
343    pub material: Material,
344}
345
346/// A Khronos sample-asset entry for the browser grid.
347#[derive(Clone, PartialEq, Serialize, Deserialize)]
348pub struct KhronosEntry {
349    pub label: String,
350    pub thumbnail: Option<String>,
351}
352
353/// A Polyhaven entry for the browser grid.
354#[derive(Clone, PartialEq, Serialize, Deserialize)]
355pub struct PolyhavenEntry {
356    pub slug: String,
357    pub name: String,
358    pub thumbnail: String,
359}
360
361/// Scene layer and chunk authoring data.
362#[derive(Clone, PartialEq, Serialize, Deserialize)]
363pub struct SceneStructure {
364    pub layers: Vec<SceneLayerConfig>,
365    pub chunks: Vec<SceneChunkConfig>,
366}
367
368/// Editor commands that need no payload beyond what they carry. Entity
369/// references are raw entity ids resolved by the worker.
370#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
371#[cfg_attr(feature = "protocol-agent", derive(enum2schema::Schema))]
372pub enum EditorAction {
373    ToggleGroundGrid,
374    ToggleSky,
375    ToggleLitUnlit,
376    ToggleViewerMode,
377    SetViewerMode(bool),
378    FrameScene,
379    ToggleFrameOnLoad,
380    ClearScene,
381    SnapSelectionToFloor,
382    BakeNavmesh,
383    ToggleNavmeshDebug,
384    SetAtmosphere(Atmosphere),
385    SetGizmoMode(GizmoMode),
386    RandomizeBoth,
387    SelectEntity(u32),
388    ToggleSelectEntity(u32),
389    ClearSelection,
390    AddEntityChild(u32),
391    ViewCamera(u32),
392    ToggleSnap,
393    ToggleSkeletonView,
394    SetMaterialVariant(Option<String>),
395    ToggleCameraGizmos,
396    ToggleShowNormals,
397    ToggleDayNightCycle,
398    SetDayNightHour(f32),
399    LoadKhronosByIndex(usize),
400    LoadPolyhavenByIndex {
401        category: PolyhavenCategory,
402        index: usize,
403    },
404    SetPolyhavenResolution(u32),
405    RefreshBrowsers,
406    DeepLink(String),
407    SpawnLines,
408    SpawnMeshes,
409    Spawn3DText,
410    SpawnTextLattice,
411    ClothSponza,
412    NewProject,
413    LoadExampleProject,
414    SaveProject,
415    ExportGlb,
416    SaveSelectedAsPrefab,
417    RefreshSelectedPrefab,
418    BreakSelectedPrefabLink,
419    SpawnPrefab(String),
420    Undo,
421    Redo,
422    DeleteEntity(u32),
423    DeleteSelectedEntity,
424    DuplicateEntity(u32),
425    DuplicateSelectedEntity,
426    AddShape(ShapeKind),
427    AddInstancedShape(ShapeKind),
428    ConvertSimilarToInstanced,
429    AddEmpty,
430    AddCamera,
431    AddPlayerSpawn,
432    AddSavePoint,
433    AddPatrolPoint,
434    AddTriggerVolume,
435    StampDecalAtCursor,
436    AddLight(LightKind),
437    AddTagToSelected(String),
438    RemoveTagFromSelected(String),
439    ToggleGreyboxMode,
440    ApplyGreyboxToSelection,
441    SnapSelectionToGrid,
442    MarkSelectionAsBrush,
443    ToggleShowFinal,
444    ToggleScripting,
445    /// Start or stop scripting mode (run the scene's scripts) explicitly,
446    /// unlike `ToggleScripting`.
447    SetScripting(bool),
448    /// Enter or leave the playground: snapshot the authored scene and clear to a
449    /// clean scratch scene that runs a single lesson script, or restore the scene
450    /// on the way out.
451    SetPlayground(bool),
452    /// Replace the playground's lesson script and reset its scratch scene.
453    LoadLesson(String),
454    GlobalScript(GlobalScriptCommand),
455    TogglePushPull,
456    SetPlacementActive(bool),
457    SetPlacementShape(ShapeKind),
458    SetPlacementSize([f32; 3]),
459    SetOrientToNormal(bool),
460    RunGenerator(GeneratorKind),
461    SetGenerationSettings(GenerationSettings),
462    SetPaletteIndex(usize),
463    SetSnapEnabled(bool),
464    SetSnapSteps {
465        translation: f32,
466        rotation_degrees: f32,
467        scale: f32,
468    },
469    SetRotationSpeed(f32),
470    SetEntityLayer {
471        id: u32,
472        layer: Option<u32>,
473    },
474    SetEntityChunk {
475        id: u32,
476        chunk: Option<u32>,
477    },
478    AddSceneLayer,
479    RemoveSceneLayer(u32),
480    UpdateSceneLayer(SceneLayerConfig),
481    AddSceneChunk,
482    RemoveSceneChunk(u32),
483    UpdateSceneChunk(SceneChunkConfig),
484}
485
486/// A camera entity available for multi-view tiling.
487#[derive(Clone, Serialize, Deserialize)]
488pub struct CameraEntry {
489    pub id: u32,
490    pub name: String,
491}
492
493/// One camera's tile in the multi-view layout, in physical canvas pixels with
494/// the origin at the canvas top-left.
495#[derive(Clone, Copy, Serialize, Deserialize)]
496pub struct ViewportTile {
497    pub camera: u32,
498    pub x: f32,
499    pub y: f32,
500    pub width: f32,
501    pub height: f32,
502}
503
504/// Page to worker, over the app envelope field. Transport (init, resize,
505/// pointer, wheel, touch, keyboard) is the worker host's control plane and
506/// is not part of this protocol.
507#[derive(Clone, Serialize, Deserialize)]
508pub enum ClientMessage {
509    /// Replaces the camera tile layout. An empty list returns to the single
510    /// full-canvas view.
511    SetViewportTiles(Vec<ViewportTile>),
512    Action(Box<EditorAction>),
513    SetTransform {
514        id: u32,
515        translation: [f32; 3],
516        rotation: [f32; 3],
517        scale: [f32; 3],
518        committed: bool,
519    },
520    SetEntityName {
521        id: u32,
522        name: String,
523    },
524    SetComponent {
525        id: u32,
526        payload: Box<ComponentPatch>,
527        committed: bool,
528    },
529    AddComponent {
530        id: u32,
531        kind: ComponentKind,
532    },
533    RemoveComponent {
534        id: u32,
535        kind: ComponentKind,
536    },
537    Animation {
538        id: u32,
539        command: AnimationCommand,
540    },
541    UpdateMaterial {
542        name: String,
543        material: Box<Material>,
544        committed: bool,
545    },
546    SetRenderSettings(Box<RenderSettingsState>),
547    /// A dropped or picked file's bytes ride the attachments under `name`.
548    /// Routed by extension: .glb/.gltf import, .hdr skybox, .nsmap/.json map,
549    /// .nsprefab prefab.
550    DropAsset {
551        name: String,
552    },
553    /// A multi-file glTF: the attachments carry the document bytes under
554    /// `name` plus every buffer and image under its relative path.
555    LoadGltfBundle {
556        name: String,
557    },
558    /// Replace the selected greybox brush's art with the glTF riding the
559    /// attachments under `name`.
560    UpgradeBrush {
561        name: String,
562    },
563    /// Submit a command from the console builder. `command` is the json of a
564    /// [`Command`](crate::Command), the one command layer. The worker
565    /// deserializes it and pushes it onto the participant command buffer, the
566    /// same path a script takes, applied on the next dispatch.
567    SubmitCommand {
568        command: String,
569    },
570    /// Fetch an asset by url and register its bytes under `name` in the
571    /// script runtime's asset map, so an embedded lesson reaches a glb or hdr
572    /// through `assets.<name>` without carrying the bytes. The worker fetches
573    /// asynchronously and acks with [`WorkerMessage::ScriptAssetLoaded`] or
574    /// [`WorkerMessage::ScriptAssetFailed`].
575    LoadScriptAsset {
576        name: String,
577        url: String,
578    },
579    /// External agent traffic (Claude Code via the MCP bridge), forwarded over
580    /// the page's WebSocket relay onto this same postMessage path.
581    #[cfg(feature = "protocol-agent")]
582    Agent(Box<AgentRequest>),
583}
584
585/// Worker to page, over the app envelope field. Ready and stats reporting
586/// belong to the worker host's control plane.
587#[derive(Clone, Serialize, Deserialize)]
588pub enum WorkerMessage {
589    /// The active camera's world-space basis, for the page's nav gizmo.
590    Camera {
591        right: [f32; 3],
592        up: [f32; 3],
593        forward: [f32; 3],
594    },
595    /// A [`ClientMessage::LoadScriptAsset`] finished and its bytes are in the
596    /// script asset map under `name`.
597    ScriptAssetLoaded {
598        name: String,
599    },
600    /// A [`ClientMessage::LoadScriptAsset`] failed to fetch.
601    ScriptAssetFailed {
602        name: String,
603        error: String,
604    },
605    Scene {
606        rows: Vec<TreeRow>,
607    },
608    Selected {
609        detail: Option<Box<SelectedEntity>>,
610    },
611    /// Every camera entity in the world, independent of scene tree filtering,
612    /// so multi-view tiling sees cameras inside collapsed prefab instances.
613    Cameras {
614        entries: Vec<CameraEntry>,
615    },
616    /// The selected entity's animation playhead, streamed while it changes.
617    Animation {
618        time: f32,
619        duration: f32,
620        playing: bool,
621        clip: Option<u32>,
622    },
623    /// One mesh's morph weights, streamed while they change (animation-driven
624    /// or edited), so the inspector and scene panel sliders follow.
625    MorphWeights {
626        id: u32,
627        weights: Vec<f32>,
628    },
629    /// Every mesh in the scene that carries morph targets, synced when the set
630    /// changes. Weight values stream separately via `MorphWeights`.
631    MorphMeshes {
632        entries: Vec<MorphMeshInfo>,
633    },
634    /// Every material variant name in the scene plus the active one, synced
635    /// whenever either changes.
636    MaterialVariants {
637        names: Vec<String>,
638        active: Option<String>,
639    },
640    RenderSettings(Box<RenderSettingsState>),
641    Flags(Box<EditorFlags>),
642    Materials {
643        entries: Vec<MaterialEntry>,
644    },
645    Tags {
646        entries: Vec<(String, u32)>,
647    },
648    Structure(SceneStructure),
649    Palette {
650        entries: Vec<PaletteEntry>,
651    },
652    KhronosList {
653        entries: Vec<KhronosEntry>,
654    },
655    PolyhavenList {
656        category: PolyhavenCategory,
657        entries: Vec<PolyhavenEntry>,
658    },
659    Loading {
660        active: bool,
661        label: String,
662    },
663    /// Project status for the title bar.
664    Project {
665        name: String,
666        modified: bool,
667        model: Option<String>,
668        mode: String,
669    },
670    /// Serialized file bytes for the page to save, riding the attachments
671    /// under `name`.
672    SaveFile {
673        name: String,
674    },
675    /// Ask the page to acquire or release pointer lock on the canvas.
676    PointerLock {
677        locked: bool,
678    },
679    /// Scroll the tree to this entity after a viewport pick.
680    FocusEntity {
681        id: u32,
682    },
683    UndoState {
684        can_undo: bool,
685        can_redo: bool,
686    },
687    Toast {
688        message: String,
689        error: bool,
690    },
691    /// New command and event traffic from the participant cycle, streamed each
692    /// frame the journal has anything, for the command and event log panel.
693    CommandLog {
694        entries: Vec<CommandLogEntry>,
695    },
696    /// External agent responses and delta batches, relayed back to the MCP
697    /// bridge.
698    #[cfg(feature = "protocol-agent")]
699    Agent(Box<AgentResponse>),
700}
701
702/// Whether a [`CommandLogEntry`] is a command that was applied or an event that
703/// was published.
704#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
705pub enum CommandLogKind {
706    Command,
707    Event,
708    Error,
709}
710
711/// One line in the command and event log: the cycle it happened in, whether it
712/// was a command or an event, a short label, and the human readable detail.
713#[derive(Clone, Debug, Serialize, Deserialize)]
714pub struct CommandLogEntry {
715    pub cycle: u64,
716    pub kind: CommandLogKind,
717    pub label: String,
718    pub detail: String,
719    /// The entities this entry names, by id, so the log row can select and
720    /// highlight them in the viewport.
721    pub entities: Vec<u32>,
722}
723
724impl CommandLogEntry {
725    /// Builds a log row from a published event: its cycle, a label and detail
726    /// read off the typed value, and the entities it names.
727    pub fn from_event(cycle: u64, event: &Event) -> Self {
728        Self {
729            cycle,
730            kind: CommandLogKind::Event,
731            label: event_label(event).to_string(),
732            detail: event_detail(event),
733            entities: event_entities(event),
734        }
735    }
736
737    /// Builds a log row from a command the script runtime or the console
738    /// produced. The variant name is the label, its serialized arguments are the
739    /// detail, and the entity ids are read off the value, so a tool surfaces the
740    /// traffic without parsing formatted strings back apart.
741    pub fn from_command(cycle: u64, command: &Command) -> Self {
742        use enum2schema::serde_json::Value;
743        let value: Value = enum2schema::serde_json::to_value(command).unwrap_or(Value::Null);
744        let detail = value
745            .as_object()
746            .and_then(|object| object.values().next())
747            .map(|inner| inner.to_string())
748            .unwrap_or_else(|| value.to_string());
749        let mut entities = Vec::new();
750        collect_entity_ids(&value, &mut entities);
751        Self {
752            cycle,
753            kind: CommandLogKind::Command,
754            label: command.name().to_string(),
755            detail,
756            entities,
757        }
758    }
759
760    /// Builds a log row from a script error, so a failed compile or tick is
761    /// visible in the log and the message carries its `(line N, ...)` for the
762    /// editor to underline.
763    pub fn from_error(cycle: u64, message: String) -> Self {
764        Self {
765            cycle,
766            kind: CommandLogKind::Error,
767            label: "error".to_string(),
768            detail: message,
769            entities: Vec::new(),
770        }
771    }
772}
773
774fn event_label(event: &Event) -> &'static str {
775    match event {
776        Event::Collision { .. } => "Collision",
777        Event::Despawned { .. } => "Despawned",
778        Event::AnimationFinished { .. } => "AnimationFinished",
779        Event::AnimationEvent { .. } => "AnimationEvent",
780        Event::NavigationArrived { .. } => "NavigationArrived",
781    }
782}
783
784fn event_detail(event: &Event) -> String {
785    match event {
786        Event::Collision {
787            a,
788            b,
789            sensor,
790            started,
791        } => format!(
792            "#{} and #{}, {}{}",
793            a.id,
794            b.id,
795            if *started { "started" } else { "ended" },
796            if *sensor { ", sensor" } else { "" }
797        ),
798        Event::AnimationEvent { entity, name } => format!("#{} {}", entity.id, name),
799        Event::Despawned { entity }
800        | Event::AnimationFinished { entity }
801        | Event::NavigationArrived { entity } => format!("#{}", entity.id),
802    }
803}
804
805fn event_entities(event: &Event) -> Vec<u32> {
806    match event {
807        Event::Collision { a, b, .. } => vec![a.id, b.id],
808        Event::AnimationEvent { entity, .. } => vec![entity.id],
809        Event::Despawned { entity }
810        | Event::AnimationFinished { entity }
811        | Event::NavigationArrived { entity } => vec![entity.id],
812    }
813}
814
815/// Walks a serialized command for the entity ids it names, reading the
816/// `{ "Existing": id }` and `{ "Entity": { "id": id } }` reference forms.
817fn collect_entity_ids(value: &enum2schema::serde_json::Value, ids: &mut Vec<u32>) {
818    use enum2schema::serde_json::Value;
819    match value {
820        Value::Object(map) => {
821            if let Some(id) = map.get("Existing").and_then(Value::as_u64) {
822                ids.push(id as u32);
823            }
824            if let Some(id) = map
825                .get("Entity")
826                .and_then(Value::as_object)
827                .and_then(|entity| entity.get("id"))
828                .and_then(Value::as_u64)
829            {
830                ids.push(id as u32);
831            }
832            for nested in map.values() {
833                collect_entity_ids(nested, ids);
834            }
835        }
836        Value::Array(array) => {
837            for nested in array {
838                collect_entity_ids(nested, ids);
839            }
840        }
841        _ => {}
842    }
843}
844
845#[cfg(all(test, feature = "protocol-agent"))]
846mod schema_tests {
847    use super::EditorAction;
848    use enum2schema::Schema;
849    use enum2schema::serde_json::Value;
850
851    /// JSON Schema draft 2020-12 forbids an array-valued `items` (the old
852    /// draft-07 tuple form). Positional tuples must use `prefixItems`. The
853    /// Anthropic tool API validates against 2020-12 and rejects the whole tool
854    /// set otherwise, so the generated action schema must stay clean.
855    fn assert_no_array_items(value: &Value) {
856        match value {
857            Value::Object(object) => {
858                if let Some(items) = object.get("items") {
859                    assert!(
860                        !items.is_array(),
861                        "array-valued `items` is invalid in draft 2020-12, use `prefixItems`: {object:?}"
862                    );
863                }
864                for nested in object.values() {
865                    assert_no_array_items(nested);
866                }
867            }
868            Value::Array(array) => {
869                for nested in array {
870                    assert_no_array_items(nested);
871                }
872            }
873            _ => {}
874        }
875    }
876
877    #[test]
878    fn editor_action_schema_is_draft_2020_12() {
879        assert_no_array_items(&EditorAction::schema());
880    }
881}