@@ -15,6 +15,10 @@ use std::{future::Future, pin::Pin, sync::Arc};
1515pub type OpponentFuture < ' a > =
1616 Pin < Box < dyn Future < Output = Result < ActionChoice , ApiError > > + Send + ' a > > ;
1717
18+ /// Bounded recommendations returned by an analysis worker.
19+ pub type AnalysisFuture < ' a > =
20+ Pin < Box < dyn Future < Output = Result < Vec < ActionChoice > , ApiError > > + Send + ' a > > ;
21+
1822/// Select installed players without exposing live state or transport details.
1923pub trait OpponentFactory : Send + Sync {
2024 /// Stable catalog for this game.
@@ -33,6 +37,10 @@ pub trait OpponentFactory: Send + Sync {
3337pub trait OpponentExecutor : Send + Sync {
3438 /// Run outside the request executor, rejecting excess work instead of queueing without bounds.
3539 fn execute ( & self , job : OpponentJob ) -> OpponentFuture < ' _ > ;
40+ /// Analyze on the host's bounded executor. Legacy executors retain single-choice behavior.
41+ fn analyze ( & self , job : OpponentJob ) -> AnalysisFuture < ' _ > {
42+ Box :: pin ( async move { self . execute ( job) . await . map ( |choice| vec ! [ choice] ) } )
43+ }
3644}
3745
3846/// Owned planning input. No raw match state or match RNG is passed to the worker.
@@ -45,6 +53,21 @@ pub struct OpponentJob {
4553}
4654
4755impl OpponentJob {
56+ /// Request ranked analysis recommendations with the same observation-only input.
57+ pub fn analyze ( self , clock : & dyn Clock ) -> Result < Vec < ActionChoice > , ApiError > {
58+ self . opponent
59+ . analyze (
60+ & PlayerTurn {
61+ seat : self . seat ,
62+ observation : & self . observation ,
63+ legal_actions : & self . legal_actions ,
64+ } ,
65+ self . limits ,
66+ clock,
67+ )
68+ . map_err ( error:: opponent)
69+ }
70+
4871 /// Execute with an injected monotonic clock. The host supplies the runtime.
4972 pub fn run ( self , clock : & dyn Clock ) -> Result < ActionChoice , ApiError > {
5073 self . opponent
@@ -267,27 +290,42 @@ impl GameService {
267290 "Select the current player or an earlier turn." ,
268291 ) ) ;
269292 }
270- let choice = self
271- . choose_opponent (
272- planning. game ,
273- planning. config ,
274- & request. opponent ,
275- seed,
276- seat,
277- visible,
293+ let ( factory, executor) = self . opponents . as_ref ( ) . ok_or_else ( || {
294+ ApiError :: new (
295+ "ENGINE_UNAVAILABLE" ,
296+ "No opponent executor is installed" ,
297+ "Configure an opponent worker pool on the host." ,
278298 )
299+ } ) ?;
300+ let ( opponent, limits) =
301+ factory. create ( planning. game , planning. config , & request. opponent , seed) ?;
302+ let choices = executor
303+ . analyze ( OpponentJob {
304+ opponent,
305+ seat,
306+ observation : visible. observation ,
307+ legal_actions : visible. legal_actions . clone ( ) ,
308+ limits,
309+ } )
279310 . await ?;
311+ validate_analysis_choices ( & choices, & visible. legal_actions ) ?;
312+ let choice = choices. first ( ) . ok_or_else ( invalid_analysis) ?;
280313 Ok ( AnalysisResult {
281- advice : choice. info . advice ,
314+ advice : choice. info . advice . clone ( ) ,
282315 game_id : request. game_id ,
283316 opponent : request. opponent . id ,
284317 seed,
285- best_moves : vec ! [ choice. action] ,
318+ best_moves : choices . iter ( ) . map ( | choice| choice . action . clone ( ) ) . collect ( ) ,
286319 evaluation : choice. info . evaluation ,
287- principal_variation : choice. info . principal_variation ,
288- nodes : choice. info . nodes ,
320+ principal_variation : choice. info . principal_variation . clone ( ) ,
321+ nodes : choices
322+ . iter ( )
323+ . map ( |choice| choice. info . nodes )
324+ . max ( )
325+ . unwrap_or ( 0 ) ,
289326 depth : choice. info . depth ,
290- budget_exhausted : choice. info . budget_exhausted ,
327+ budget_exhausted : choices. iter ( ) . any ( |choice| choice. info . budget_exhausted ) ,
328+ variations : choices,
291329 } )
292330 }
293331}
@@ -339,3 +377,79 @@ mod failure_tests {
339377 Ok ( ( ) )
340378 }
341379}
380+
381+ fn invalid_analysis ( ) -> ApiError {
382+ ApiError :: new (
383+ "ENGINE_INVALID_RESPONSE" ,
384+ "Opponent returned invalid analysis recommendations" ,
385+ "Select another opponent and report the provider failure." ,
386+ )
387+ }
388+ fn validate_analysis_choices (
389+ choices : & [ ActionChoice ] ,
390+ legal : & [ LegalAction ] ,
391+ ) -> Result < ( ) , ApiError > {
392+ if choices. is_empty ( ) || choices. len ( ) > 16 {
393+ return Err ( invalid_analysis ( ) ) ;
394+ }
395+ let mut seen = std:: collections:: BTreeSet :: new ( ) ;
396+ for choice in choices {
397+ if !legal. contains ( & choice. action )
398+ || !seen. insert ( choice. action . index )
399+ || choice
400+ . info
401+ . evaluation
402+ . is_some_and ( |score| !score. is_finite ( ) )
403+ || choice. info . principal_variation . len ( ) > 128
404+ || choice
405+ . info
406+ . principal_variation
407+ . first ( )
408+ . is_some_and ( |first| first != & choice. action . string )
409+ {
410+ return Err ( invalid_analysis ( ) ) ;
411+ }
412+ }
413+ Ok ( ( ) )
414+ }
415+
416+ #[ cfg( test) ]
417+ mod analysis_validation_tests {
418+ use super :: * ;
419+ fn choice ( ) -> ActionChoice {
420+ ActionChoice {
421+ action : LegalAction {
422+ string : "a" . into ( ) ,
423+ json : serde_json:: json!( "a" ) ,
424+ index : 0 ,
425+ } ,
426+ info : gfa_core:: ChoiceInfo {
427+ advice : None ,
428+ algorithm : "test" . into ( ) ,
429+ nodes : 1 ,
430+ depth : 1 ,
431+ evaluation : Some ( 0.0 ) ,
432+ principal_variation : vec ! [ "a" . into( ) ] ,
433+ budget_exhausted : false ,
434+ } ,
435+ }
436+ }
437+ #[ test]
438+ fn rejects_empty_duplicate_nonfinite_illegal_and_mismatched_analysis ( ) {
439+ let first = choice ( ) ;
440+ let legal = vec ! [ first. action. clone( ) ] ;
441+ assert ! ( validate_analysis_choices( std:: slice:: from_ref( & first) , & legal) . is_ok( ) ) ;
442+ assert ! ( validate_analysis_choices( & [ ] , & legal) . is_err( ) ) ;
443+ assert ! ( validate_analysis_choices( & vec![ first. clone( ) ; 17 ] , & legal) . is_err( ) ) ;
444+ assert ! ( validate_analysis_choices( & [ first. clone( ) , first. clone( ) ] , & legal) . is_err( ) ) ;
445+ let mut bad = first. clone ( ) ;
446+ bad. info . evaluation = Some ( f64:: NAN ) ;
447+ assert ! ( validate_analysis_choices( & [ bad] , & legal) . is_err( ) ) ;
448+ let mut bad = first. clone ( ) ;
449+ bad. action . string = "b" . into ( ) ;
450+ assert ! ( validate_analysis_choices( & [ bad] , & legal) . is_err( ) ) ;
451+ let mut bad = first;
452+ bad. info . principal_variation [ 0 ] = "b" . into ( ) ;
453+ assert ! ( validate_analysis_choices( & [ bad] , & legal) . is_err( ) ) ;
454+ }
455+ }
0 commit comments