Skip to main content

galeon_engine/
handler.rs

1// SPDX-License-Identifier: AGPL-3.0-only OR Commercial
2
3//! Handler registration seam — the boundary between generated adapters and
4//! game-owned domain logic.
5//!
6//! Galeon generates adapter glue. The game project implements handlers.
7//! Both local and remote adapters target the same [`HandlerRegistry`].
8//!
9//! # Architecture
10//!
11//! ```text
12//! Protocol definitions (game crate)
13//!         │
14//!         ▼
15//! ┌─────────────────┐
16//! │ HandlerRegistry │ ← game registers handlers here
17//! └────────┬────────┘
18//!          │
19//!     ┌────┴────┐
20//!     ▼         ▼
21//!   Local    Remote
22//!  Adapter   Adapter
23//! ```
24//!
25//! # Design Rules
26//!
27//! - One command type → one handler entry
28//! - One query type → one handler entry
29//! - Game project owns all handlers; Galeon does not generate domain logic
30//! - Local and remote adapters target the same boundary
31
32use std::any::{Any, TypeId, type_name};
33use std::collections::HashMap;
34
35use serde::{Deserialize, Serialize};
36
37use crate::protocol::{Command, ProtocolMeta, ProtocolQuery};
38
39// =============================================================================
40// Handler traits — game implements these
41// =============================================================================
42
43/// Handler for a command type. Game project implements this.
44///
45/// `C` is the command type (e.g., `SpawnUnit`).
46/// `R` is the response type (e.g., `()` or a result DTO).
47pub trait CommandHandler<C: Command, R: Serialize>: Send + Sync {
48    /// Execute the command and return a response.
49    fn handle(&self, cmd: C) -> Result<R, String>;
50}
51
52/// Handler for a query type. Game project implements this.
53///
54/// `Q` is the query type (e.g., `GetWorldSnapshot`).
55/// `R` is the response type (e.g., `WorldSnapshot` DTO).
56pub trait QueryHandler<Q: ProtocolQuery, R: Serialize>: Send + Sync {
57    /// Execute the query and return a response.
58    fn handle(&self, query: Q) -> Result<R, String>;
59}
60
61// =============================================================================
62// Type-erased handler wrappers (internal)
63// =============================================================================
64
65/// A type-erased command handler that works with JSON strings.
66///
67/// This is the boundary between typed game handlers and transport adapters.
68trait ErasedCommandHandler: Send + Sync {
69    /// Deserialize request JSON, call the typed handler, serialize response.
70    fn handle_json(&self, request: &str) -> Result<String, String>;
71
72    /// Call the typed handler with a boxed Any (for local adapter).
73    fn handle_any(&self, cmd: Box<dyn Any>) -> Result<Box<dyn Any>, String>;
74}
75
76/// A type-erased query handler.
77trait ErasedQueryHandler: Send + Sync {
78    fn handle_json(&self, request: &str) -> Result<String, String>;
79    fn handle_any(&self, query: Box<dyn Any>) -> Result<Box<dyn Any>, String>;
80}
81
82/// Wraps a typed CommandHandler into an erased one.
83struct CommandHandlerWrapper<C, R, H> {
84    handler: H,
85    _phantom: std::marker::PhantomData<(C, R)>,
86}
87
88impl<C, R, H> ErasedCommandHandler for CommandHandlerWrapper<C, R, H>
89where
90    C: Command,
91    R: Serialize + for<'de> Deserialize<'de> + Send + Sync + 'static,
92    H: CommandHandler<C, R> + Send + Sync,
93{
94    fn handle_json(&self, request: &str) -> Result<String, String> {
95        let cmd: C = serde_json::from_str(request).map_err(|e| e.to_string())?;
96        let response = self.handler.handle(cmd)?;
97        serde_json::to_string(&response).map_err(|e| e.to_string())
98    }
99
100    fn handle_any(&self, cmd: Box<dyn Any>) -> Result<Box<dyn Any>, String> {
101        let cmd = *cmd.downcast::<C>().map_err(|_| "type mismatch")?;
102        let response = self.handler.handle(cmd)?;
103        Ok(Box::new(response))
104    }
105}
106
107/// Wraps a typed QueryHandler into an erased one.
108struct QueryHandlerWrapper<Q, R, H> {
109    handler: H,
110    _phantom: std::marker::PhantomData<(Q, R)>,
111}
112
113impl<Q, R, H> ErasedQueryHandler for QueryHandlerWrapper<Q, R, H>
114where
115    Q: ProtocolQuery,
116    R: Serialize + for<'de> Deserialize<'de> + Send + Sync + 'static,
117    H: QueryHandler<Q, R> + Send + Sync,
118{
119    fn handle_json(&self, request: &str) -> Result<String, String> {
120        let query: Q = serde_json::from_str(request).map_err(|e| e.to_string())?;
121        let response = self.handler.handle(query)?;
122        serde_json::to_string(&response).map_err(|e| e.to_string())
123    }
124
125    fn handle_any(&self, query: Box<dyn Any>) -> Result<Box<dyn Any>, String> {
126        let query = *query.downcast::<Q>().map_err(|_| "type mismatch")?;
127        let response = self.handler.handle(query)?;
128        Ok(Box::new(response))
129    }
130}
131
132// =============================================================================
133// HandlerRegistry — the registration seam
134// =============================================================================
135
136/// A stored handler entry shared between TypeId and name indices.
137struct CommandEntry(std::sync::Arc<dyn ErasedCommandHandler>);
138struct QueryEntry(std::sync::Arc<dyn ErasedQueryHandler>);
139
140/// Registry of command and query handlers.
141///
142/// Game projects register handlers here. Both local and remote adapters
143/// dispatch through this registry. Local dispatch uses `TypeId` (zero-cost).
144/// Remote dispatch uses the stable protocol name from `ProtocolMeta::name()`.
145///
146/// # Surface independence
147///
148/// The registry is deliberately surface-unaware. Protocol surfaces partition
149/// *generated artifacts* (TypeScript modules, route descriptors) but not
150/// handler registration. A handler registered once serves every surface that
151/// includes its protocol item. Transport adapters (e.g., an axum router)
152/// filter descriptors by surface when mounting routes — the registry itself
153/// stays flat.
154pub struct HandlerRegistry {
155    /// TypeId → handler index (local adapter path).
156    commands_by_type: HashMap<TypeId, CommandEntry>,
157    /// Protocol name → handler index (remote adapter path).
158    commands_by_name: HashMap<String, CommandEntry>,
159    /// TypeId → handler index (local adapter path).
160    queries_by_type: HashMap<TypeId, QueryEntry>,
161    /// Protocol name → handler index (remote adapter path).
162    queries_by_name: HashMap<String, QueryEntry>,
163}
164
165impl HandlerRegistry {
166    /// Create an empty registry.
167    pub fn new() -> Self {
168        Self {
169            commands_by_type: HashMap::new(),
170            commands_by_name: HashMap::new(),
171            queries_by_type: HashMap::new(),
172            queries_by_name: HashMap::new(),
173        }
174    }
175
176    /// Register a command handler.
177    ///
178    /// Indexes by both `TypeId` (for local dispatch) and
179    /// `ProtocolMeta::name()` (for remote dispatch via stable protocol name).
180    ///
181    /// Panics if a handler for this command type or protocol name is already
182    /// registered. The name check catches collisions between different Rust
183    /// types that share the same `ProtocolMeta::name()`.
184    pub fn register_command<C, R, H>(&mut self, handler: H)
185    where
186        C: Command + ProtocolMeta,
187        R: Serialize + for<'de> Deserialize<'de> + Send + Sync + 'static,
188        H: CommandHandler<C, R> + 'static,
189    {
190        let type_id = TypeId::of::<C>();
191        let protocol_name = C::name().to_string();
192        assert!(
193            !self.commands_by_type.contains_key(&type_id),
194            "duplicate command handler for type {}",
195            protocol_name
196        );
197        assert!(
198            !self.commands_by_name.contains_key(&protocol_name),
199            "duplicate command handler for protocol name {:?} (different type, same name)",
200            protocol_name
201        );
202        let shared: std::sync::Arc<dyn ErasedCommandHandler> =
203            std::sync::Arc::new(CommandHandlerWrapper {
204                handler,
205                _phantom: std::marker::PhantomData::<(C, R)>,
206            });
207        self.commands_by_type
208            .insert(type_id, CommandEntry(shared.clone()));
209        self.commands_by_name
210            .insert(protocol_name, CommandEntry(shared));
211    }
212
213    /// Register a query handler.
214    ///
215    /// Indexes by both `TypeId` (for local dispatch) and
216    /// `ProtocolMeta::name()` (for remote dispatch via stable protocol name).
217    ///
218    /// Panics if a handler for this query type or protocol name is already
219    /// registered. The name check catches collisions between different Rust
220    /// types that share the same `ProtocolMeta::name()`.
221    pub fn register_query<Q, R, H>(&mut self, handler: H)
222    where
223        Q: ProtocolQuery + ProtocolMeta,
224        R: Serialize + for<'de> Deserialize<'de> + Send + Sync + 'static,
225        H: QueryHandler<Q, R> + 'static,
226    {
227        let type_id = TypeId::of::<Q>();
228        let protocol_name = Q::name().to_string();
229        assert!(
230            !self.queries_by_type.contains_key(&type_id),
231            "duplicate query handler for type {}",
232            protocol_name
233        );
234        assert!(
235            !self.queries_by_name.contains_key(&protocol_name),
236            "duplicate query handler for protocol name {:?} (different type, same name)",
237            protocol_name
238        );
239        let shared: std::sync::Arc<dyn ErasedQueryHandler> =
240            std::sync::Arc::new(QueryHandlerWrapper {
241                handler,
242                _phantom: std::marker::PhantomData::<(Q, R)>,
243            });
244        self.queries_by_type
245            .insert(type_id, QueryEntry(shared.clone()));
246        self.queries_by_name
247            .insert(protocol_name, QueryEntry(shared));
248    }
249
250    // -------------------------------------------------------------------------
251    // Local adapter interface (in-process, typed dispatch)
252    // -------------------------------------------------------------------------
253
254    /// Dispatch a command in-process (local adapter path).
255    pub fn dispatch_command<C: Command + 'static, R: 'static>(&self, cmd: C) -> Result<R, String> {
256        let entry = self
257            .commands_by_type
258            .get(&TypeId::of::<C>())
259            .ok_or_else(|| format!("no handler for command {}", type_name::<C>()))?;
260
261        let result = entry.0.handle_any(Box::new(cmd))?;
262        let boxed = result
263            .downcast::<R>()
264            .map_err(|_| "response type mismatch".to_string())?;
265        Ok(*boxed)
266    }
267
268    /// Dispatch a query in-process (local adapter path).
269    pub fn dispatch_query<Q: ProtocolQuery + 'static, R: 'static>(
270        &self,
271        query: Q,
272    ) -> Result<R, String> {
273        let entry = self
274            .queries_by_type
275            .get(&TypeId::of::<Q>())
276            .ok_or_else(|| format!("no handler for query {}", type_name::<Q>()))?;
277
278        let result = entry.0.handle_any(Box::new(query))?;
279        let boxed = result
280            .downcast::<R>()
281            .map_err(|_| "response type mismatch".to_string())?;
282        Ok(*boxed)
283    }
284
285    // -------------------------------------------------------------------------
286    // Remote adapter interface (JSON boundary, keyed by stable protocol name)
287    // -------------------------------------------------------------------------
288
289    /// Dispatch a command via JSON using the stable protocol name.
290    ///
291    /// `protocol_name` is the value from `ProtocolMeta::name()` (e.g.,
292    /// `"SpawnUnit"`) — the same name that appears in the manifest and
293    /// generated descriptors. This is the boundary-safe dispatch path.
294    pub fn dispatch_command_json(
295        &self,
296        protocol_name: &str,
297        request_json: &str,
298    ) -> Result<String, String> {
299        let entry = self
300            .commands_by_name
301            .get(protocol_name)
302            .ok_or_else(|| format!("unknown command: {}", protocol_name))?;
303        entry.0.handle_json(request_json)
304    }
305
306    /// Dispatch a query via JSON using the stable protocol name.
307    pub fn dispatch_query_json(
308        &self,
309        protocol_name: &str,
310        request_json: &str,
311    ) -> Result<String, String> {
312        let entry = self
313            .queries_by_name
314            .get(protocol_name)
315            .ok_or_else(|| format!("unknown query: {}", protocol_name))?;
316        entry.0.handle_json(request_json)
317    }
318
319    /// Returns the number of registered command handlers.
320    pub fn command_count(&self) -> usize {
321        self.commands_by_type.len()
322    }
323
324    /// Returns the number of registered query handlers.
325    pub fn query_count(&self) -> usize {
326        self.queries_by_type.len()
327    }
328}
329
330impl Default for HandlerRegistry {
331    fn default() -> Self {
332        Self::new()
333    }
334}
335
336// =============================================================================
337// Tests
338// =============================================================================
339
340#[cfg(test)]
341mod tests {
342    use super::*;
343
344    // -- Sample protocol items --
345
346    use crate::protocol::ProtocolKind;
347
348    #[derive(Debug, Serialize, Deserialize)]
349    struct SpawnUnit {
350        unit_id: u64,
351        location_id: u64,
352    }
353    impl Command for SpawnUnit {}
354    impl ProtocolMeta for SpawnUnit {
355        fn name() -> &'static str {
356            "SpawnUnit"
357        }
358        fn kind() -> ProtocolKind {
359            ProtocolKind::Command
360        }
361    }
362
363    #[derive(Debug, Serialize, Deserialize)]
364    struct GetWorldSnapshot;
365    impl ProtocolQuery for GetWorldSnapshot {}
366    impl ProtocolMeta for GetWorldSnapshot {
367        fn name() -> &'static str {
368            "GetWorldSnapshot"
369        }
370        fn kind() -> ProtocolKind {
371            ProtocolKind::Query
372        }
373    }
374
375    #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
376    struct WorldSnapshot {
377        units_active: u32,
378        units_idle: u32,
379    }
380
381    #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
382    struct DispatchResult {
383        ok: bool,
384    }
385
386    // -- Sample handlers --
387
388    struct UnitSpawner;
389
390    impl CommandHandler<SpawnUnit, DispatchResult> for UnitSpawner {
391        fn handle(&self, _cmd: SpawnUnit) -> Result<DispatchResult, String> {
392            Ok(DispatchResult { ok: true })
393        }
394    }
395
396    struct WorldQuerier;
397
398    impl QueryHandler<GetWorldSnapshot, WorldSnapshot> for WorldQuerier {
399        fn handle(&self, _query: GetWorldSnapshot) -> Result<WorldSnapshot, String> {
400            Ok(WorldSnapshot {
401                units_active: 2,
402                units_idle: 5,
403            })
404        }
405    }
406
407    // -- Registry tests --
408
409    #[test]
410    fn register_and_dispatch_command_local() {
411        let mut registry = HandlerRegistry::new();
412        registry.register_command::<SpawnUnit, DispatchResult, _>(UnitSpawner);
413
414        let result: DispatchResult = registry
415            .dispatch_command(SpawnUnit {
416                unit_id: 1,
417                location_id: 42,
418            })
419            .unwrap();
420
421        assert!(result.ok);
422    }
423
424    #[test]
425    fn register_and_dispatch_query_local() {
426        let mut registry = HandlerRegistry::new();
427        registry.register_query::<GetWorldSnapshot, WorldSnapshot, _>(WorldQuerier);
428
429        let snapshot: WorldSnapshot = registry.dispatch_query(GetWorldSnapshot).unwrap();
430
431        assert_eq!(snapshot.units_active, 2);
432        assert_eq!(snapshot.units_idle, 5);
433    }
434
435    #[test]
436    fn dispatch_command_json_by_protocol_name() {
437        let mut registry = HandlerRegistry::new();
438        registry.register_command::<SpawnUnit, DispatchResult, _>(UnitSpawner);
439
440        // Use stable protocol name — the same name in manifest/descriptors.
441        let response = registry
442            .dispatch_command_json("SpawnUnit", r#"{"unit_id":1,"location_id":42}"#)
443            .unwrap();
444
445        assert!(response.contains("true"));
446    }
447
448    #[test]
449    fn dispatch_query_json_by_protocol_name() {
450        let mut registry = HandlerRegistry::new();
451        registry.register_query::<GetWorldSnapshot, WorldSnapshot, _>(WorldQuerier);
452
453        let response = registry
454            .dispatch_query_json("GetWorldSnapshot", "null")
455            .unwrap();
456
457        let snapshot: WorldSnapshot = serde_json::from_str(&response).unwrap();
458        assert_eq!(snapshot.units_idle, 5);
459    }
460
461    #[test]
462    fn same_registry_serves_both_adapters() {
463        let mut registry = HandlerRegistry::new();
464        registry.register_command::<SpawnUnit, DispatchResult, _>(UnitSpawner);
465        registry.register_query::<GetWorldSnapshot, WorldSnapshot, _>(WorldQuerier);
466
467        // Local adapter (typed, in-process)
468        let local_result: DispatchResult = registry
469            .dispatch_command(SpawnUnit {
470                unit_id: 1,
471                location_id: 42,
472            })
473            .unwrap();
474        assert!(local_result.ok);
475
476        let local_snapshot: WorldSnapshot = registry.dispatch_query(GetWorldSnapshot).unwrap();
477        assert_eq!(local_snapshot.units_idle, 5);
478
479        // Remote adapter (JSON, keyed by stable protocol name)
480        let json_result = registry
481            .dispatch_command_json("SpawnUnit", r#"{"unit_id":2,"location_id":99}"#)
482            .unwrap();
483        assert!(json_result.contains("true"));
484
485        let json_snapshot = registry
486            .dispatch_query_json("GetWorldSnapshot", "null")
487            .unwrap();
488        let remote_snapshot: WorldSnapshot = serde_json::from_str(&json_snapshot).unwrap();
489        assert_eq!(remote_snapshot.units_idle, 5);
490    }
491
492    /// Drives remote dispatch from descriptor output — proves the
493    /// execution-portability claim: descriptor names resolve to handlers.
494    #[test]
495    fn descriptor_driven_remote_dispatch() {
496        use crate::codegen::generate_descriptors;
497        use crate::manifest::{ManifestEntry, ManifestField, ProtocolManifest};
498
499        // Build a manifest matching our test protocol items.
500        let manifest = ProtocolManifest {
501            manifest_version: "2".into(),
502            protocol_version: "test@0.1".into(),
503            default_surface: "default".into(),
504            surfaces: vec!["default".into()],
505            commands: vec![ManifestEntry {
506                name: "SpawnUnit".into(),
507                kind: ProtocolKind::Command,
508                fields: vec![
509                    ManifestField {
510                        name: "unit_id".into(),
511                        ty: "u64".into(),
512                    },
513                    ManifestField {
514                        name: "location_id".into(),
515                        ty: "u64".into(),
516                    },
517                ],
518                doc: "".into(),
519                surfaces: vec![],
520            }],
521            queries: vec![ManifestEntry {
522                name: "GetWorldSnapshot".into(),
523                kind: ProtocolKind::Query,
524                fields: vec![],
525                doc: "".into(),
526                surfaces: vec![],
527            }],
528            events: vec![],
529            dtos: vec![],
530        };
531
532        // Generate descriptors (simulating what codegen produces).
533        let desc_set = generate_descriptors(&manifest);
534
535        // Register handlers.
536        let mut registry = HandlerRegistry::new();
537        registry.register_command::<SpawnUnit, DispatchResult, _>(UnitSpawner);
538        registry.register_query::<GetWorldSnapshot, WorldSnapshot, _>(WorldQuerier);
539
540        // Dispatch using descriptor names — no TypeId, no Rust-only knowledge.
541        for surface in &desc_set.surfaces {
542            for desc in &surface.descriptors {
543                match desc.kind {
544                    ProtocolKind::Command => {
545                        let response = registry
546                            .dispatch_command_json(&desc.name, r#"{"unit_id":1,"location_id":42}"#)
547                            .unwrap();
548                        assert!(response.contains("true"));
549                    }
550                    ProtocolKind::Query => {
551                        let response = registry.dispatch_query_json(&desc.name, "null").unwrap();
552                        let snapshot: WorldSnapshot = serde_json::from_str(&response).unwrap();
553                        assert_eq!(snapshot.units_idle, 5);
554                    }
555                    _ => {}
556                }
557            }
558        }
559    }
560
561    #[test]
562    fn missing_handler_returns_error() {
563        let registry = HandlerRegistry::new();
564        let result = registry.dispatch_command::<SpawnUnit, DispatchResult>(SpawnUnit {
565            unit_id: 1,
566            location_id: 1,
567        });
568        assert!(result.is_err());
569        assert!(result.unwrap_err().contains("no handler"));
570    }
571
572    #[test]
573    #[should_panic(expected = "duplicate command handler")]
574    fn duplicate_handler_panics() {
575        let mut registry = HandlerRegistry::new();
576        registry.register_command::<SpawnUnit, DispatchResult, _>(UnitSpawner);
577        registry.register_command::<SpawnUnit, DispatchResult, _>(UnitSpawner);
578    }
579
580    #[test]
581    fn registry_counts() {
582        let mut registry = HandlerRegistry::new();
583        assert_eq!(registry.command_count(), 0);
584        assert_eq!(registry.query_count(), 0);
585
586        registry.register_command::<SpawnUnit, DispatchResult, _>(UnitSpawner);
587        registry.register_query::<GetWorldSnapshot, WorldSnapshot, _>(WorldQuerier);
588
589        assert_eq!(registry.command_count(), 1);
590        assert_eq!(registry.query_count(), 1);
591    }
592
593    /// Two different Rust types with the same ProtocolMeta::name() must
594    /// panic on registration — prevents silent handler replacement.
595    #[test]
596    #[should_panic(expected = "duplicate command handler for protocol name")]
597    fn name_collision_panics() {
598        // A second command type that shares the same protocol name.
599        #[derive(Debug, Serialize, Deserialize)]
600        struct SpawnUnitV2 {
601            unit_id: u64,
602        }
603        impl Command for SpawnUnitV2 {}
604        impl ProtocolMeta for SpawnUnitV2 {
605            fn name() -> &'static str {
606                "SpawnUnit" // same name as the other type
607            }
608            fn kind() -> ProtocolKind {
609                ProtocolKind::Command
610            }
611        }
612
613        struct V2Spawner;
614        impl CommandHandler<SpawnUnitV2, DispatchResult> for V2Spawner {
615            fn handle(&self, _cmd: SpawnUnitV2) -> Result<DispatchResult, String> {
616                Ok(DispatchResult { ok: false })
617            }
618        }
619
620        let mut registry = HandlerRegistry::new();
621        registry.register_command::<SpawnUnit, DispatchResult, _>(UnitSpawner);
622        // This must panic — same protocol name, different type.
623        registry.register_command::<SpawnUnitV2, DispatchResult, _>(V2Spawner);
624    }
625
626    /// One flat registry serves multiple surfaces — surface filtering happens
627    /// at the descriptor/routing layer, not the handler layer.
628    #[test]
629    fn single_registry_serves_multiple_surfaces() {
630        use crate::codegen::generate_descriptors;
631        use crate::manifest::{ManifestEntry, ManifestField, ProtocolManifest};
632
633        // Two-surface manifest: SpawnUnit on gameplay, AdminReset on authority.
634        // A real game registers handlers once; adapters mount per-surface routes.
635
636        #[derive(Debug, Serialize, Deserialize)]
637        struct AdminReset {
638            zone_id: u64,
639        }
640        impl Command for AdminReset {}
641        impl ProtocolMeta for AdminReset {
642            fn name() -> &'static str {
643                "AdminReset"
644            }
645            fn kind() -> ProtocolKind {
646                ProtocolKind::Command
647            }
648        }
649
650        struct ZoneResetter;
651        impl CommandHandler<AdminReset, DispatchResult> for ZoneResetter {
652            fn handle(&self, _cmd: AdminReset) -> Result<DispatchResult, String> {
653                Ok(DispatchResult { ok: true })
654            }
655        }
656
657        let manifest = ProtocolManifest {
658            manifest_version: "2".into(),
659            protocol_version: "test@0.1".into(),
660            default_surface: "gameplay".into(),
661            surfaces: vec!["authority".into(), "gameplay".into()],
662            commands: vec![
663                ManifestEntry {
664                    name: "SpawnUnit".into(),
665                    kind: ProtocolKind::Command,
666                    fields: vec![ManifestField {
667                        name: "unit_id".into(),
668                        ty: "u64".into(),
669                    }],
670                    doc: "".into(),
671                    surfaces: vec![],
672                },
673                ManifestEntry {
674                    name: "AdminReset".into(),
675                    kind: ProtocolKind::Command,
676                    fields: vec![ManifestField {
677                        name: "zone_id".into(),
678                        ty: "u64".into(),
679                    }],
680                    doc: "".into(),
681                    surfaces: vec!["authority".into()],
682                },
683            ],
684            queries: vec![],
685            events: vec![],
686            dtos: vec![],
687        };
688
689        // One registry, all handlers.
690        let mut registry = HandlerRegistry::new();
691        registry.register_command::<SpawnUnit, DispatchResult, _>(UnitSpawner);
692        registry.register_command::<AdminReset, DispatchResult, _>(ZoneResetter);
693
694        // Generate per-surface descriptors.
695        let descs = generate_descriptors(&manifest);
696
697        // Simulate per-surface routing: only dispatch commands whose descriptors
698        // appear in that surface's descriptor set.
699        for surface in &descs.surfaces {
700            for desc in &surface.descriptors {
701                if desc.kind == ProtocolKind::Command {
702                    let payload = match desc.name.as_str() {
703                        "SpawnUnit" => r#"{"unit_id":1,"location_id":42}"#,
704                        "AdminReset" => r#"{"zone_id":7}"#,
705                        other => panic!("unexpected descriptor: {other}"),
706                    };
707                    let response = registry.dispatch_command_json(&desc.name, payload).unwrap();
708                    assert!(response.contains("true"));
709                }
710            }
711        }
712
713        // Gameplay surface should only see SpawnUnit
714        let gameplay = descs
715            .surfaces
716            .iter()
717            .find(|s| s.name == "gameplay")
718            .unwrap();
719        assert_eq!(gameplay.descriptors.len(), 1);
720        assert_eq!(gameplay.descriptors[0].name, "SpawnUnit");
721
722        // Authority surface should only see AdminReset
723        let authority = descs
724            .surfaces
725            .iter()
726            .find(|s| s.name == "authority")
727            .unwrap();
728        assert_eq!(authority.descriptors.len(), 1);
729        assert_eq!(authority.descriptors[0].name, "AdminReset");
730    }
731}