@@ -463,3 +463,174 @@ async fn metadata_and_history_paginate_filter_and_survive_restart() -> TestResul
463463 app. store . close ( ) . await ;
464464 Ok ( ( ) )
465465}
466+
467+ #[ tokio:: test]
468+ async fn resignation_and_draw_agreements_replay_after_restart ( ) -> TestResult {
469+ let directory = tempfile:: tempdir ( ) ?;
470+ let settings = config ( & directory) ;
471+ let app = fixture ( & settings) . await ?;
472+ let ( _, initial) = call (
473+ & app. router ,
474+ "POST" ,
475+ "/v1/matches" ,
476+ json ! ( { "game_id" : "tictactoe" } ) ,
477+ None ,
478+ )
479+ . await ?;
480+ let id = match_id ( & initial) ?;
481+ let ( status, resigned) = call (
482+ & app. router ,
483+ "POST" ,
484+ & format ! ( "/v1/matches/{id}/resign" ) ,
485+ json ! ( { "seat" : 1 , "turn" : 0 } ) ,
486+ None ,
487+ )
488+ . await ?;
489+ assert_eq ! ( status, StatusCode :: OK ) ;
490+ assert_eq ! ( resigned[ "turn" ] , 0 ) ;
491+ assert_eq ! ( resigned[ "observation" ] , initial[ "observation" ] ) ;
492+ assert_eq ! ( resigned[ "returns" ] , json!( [ 1.0 , -1.0 ] ) ) ;
493+ assert_eq ! ( resigned[ "outcome" ] , json!( { "reason" : "resigned" , "seat" : 1 } ) ) ;
494+ assert_eq ! ( resigned[ "terminated" ] , true ) ;
495+ assert_eq ! ( resigned[ "legal_actions" ] , json!( [ ] ) ) ;
496+ let ( status, _) = call (
497+ & app. router ,
498+ "POST" ,
499+ & format ! ( "/v1/matches/{id}/actions" ) ,
500+ json ! ( { "seat" : 0 , "turn" : 0 , "action" : "r1c1" } ) ,
501+ None ,
502+ )
503+ . await ?;
504+ assert_eq ! ( status, StatusCode :: CONFLICT ) ;
505+ let ( _, created) = call (
506+ & app. router ,
507+ "POST" ,
508+ "/v1/matches" ,
509+ json ! ( { "game_id" : "connect4" } ) ,
510+ None ,
511+ )
512+ . await ?;
513+ let drawn_id = match_id ( & created) ?;
514+ let offer_url = format ! ( "/v1/matches/{drawn_id}/offer-draw" ) ;
515+ let ( _, offered) = call (
516+ & app. router ,
517+ "POST" ,
518+ & offer_url,
519+ json ! ( { "seat" : 0 , "turn" : 0 } ) ,
520+ None ,
521+ )
522+ . await ?;
523+ assert_eq ! ( offered[ "draw_offer" ] , 0 ) ;
524+ assert_eq ! ( offered[ "terminated" ] , false ) ;
525+ let ( _, duplicate) = call (
526+ & app. router ,
527+ "POST" ,
528+ & offer_url,
529+ json ! ( { "seat" : 0 , "turn" : 0 } ) ,
530+ None ,
531+ )
532+ . await ?;
533+ assert_eq ! ( offered, duplicate) ;
534+ let ( _, drawn) = call (
535+ & app. router ,
536+ "POST" ,
537+ & offer_url,
538+ json ! ( { "seat" : 1 , "turn" : 0 } ) ,
539+ None ,
540+ )
541+ . await ?;
542+ assert_eq ! ( drawn[ "outcome" ] , json!( { "reason" : "agreed_draw" } ) ) ;
543+ assert_eq ! ( drawn[ "returns" ] , json!( [ 0.0 , 0.0 ] ) ) ;
544+ assert ! ( drawn. get( "draw_offer" ) . is_none( ) ) ;
545+ let ( _, doc) = call ( & app. router , "GET" , "/v1/openapi.json" , Value :: Null , None ) . await ?;
546+ assert ! ( doc[ "components" ] [ "schemas" ] [ "MatchOutcome" ] . is_object( ) ) ;
547+ app. store . close ( ) . await ;
548+ let app = fixture ( & settings) . await ?;
549+ for ( id, expected) in [ ( id, resigned) , ( drawn_id, drawn) ] {
550+ let ( _, replay) = call (
551+ & app. router ,
552+ "GET" ,
553+ & format ! ( "/v1/matches/{id}/replay?seat=1" ) ,
554+ Value :: Null ,
555+ None ,
556+ )
557+ . await ?;
558+ assert_eq ! ( replay[ "states" ] , json!( [ expected] ) ) ;
559+ let ( _, metadata) = call (
560+ & app. router ,
561+ "GET" ,
562+ & format ! ( "/v1/matches/{id}" ) ,
563+ Value :: Null ,
564+ None ,
565+ )
566+ . await ?;
567+ assert_eq ! ( metadata[ "status" ] , "finished" ) ;
568+ assert_eq ! ( metadata[ "outcome" ] , expected[ "outcome" ] ) ;
569+ }
570+ app. store . close ( ) . await ;
571+ Ok ( ( ) )
572+ }
573+
574+ #[ tokio:: test]
575+ async fn controls_validate_turns_expire_offers_and_commit_once_under_races ( ) -> TestResult {
576+ use gfa_api_types:: { ControlRequest , MoveRequest } ;
577+ let directory = tempfile:: tempdir ( ) ?;
578+ let app = fixture ( & config ( & directory) ) . await ?;
579+ let ( _, initial) = call (
580+ & app. router ,
581+ "POST" ,
582+ "/v1/matches" ,
583+ json ! ( { "game_id" : "tictactoe" } ) ,
584+ None ,
585+ )
586+ . await ?;
587+ let id = match_id ( & initial) ?;
588+ for ( request, expected) in [
589+ ( json ! ( { "seat" : 0 , "turn" : 1 } ) , StatusCode :: CONFLICT ) ,
590+ ( json ! ( { "seat" : 9 , "turn" : 0 } ) , StatusCode :: UNPROCESSABLE_ENTITY ) ,
591+ ( json ! ( { "seat" : 0 } ) , StatusCode :: UNPROCESSABLE_ENTITY ) ,
592+ ] {
593+ let ( status, _) = call (
594+ & app. router ,
595+ "POST" ,
596+ & format ! ( "/v1/matches/{id}/resign" ) ,
597+ request,
598+ None ,
599+ )
600+ . await ?;
601+ assert_eq ! ( status, expected) ;
602+ }
603+ app. service
604+ . offer_draw ( & id, ControlRequest { seat : 0 , turn : 0 } )
605+ . await ?;
606+ app. service
607+ . make_move (
608+ & id,
609+ MoveRequest {
610+ seat : 0 ,
611+ turn : 0 ,
612+ action : json ! ( "r2c2" ) ,
613+ reasoning : None ,
614+ } ,
615+ None ,
616+ )
617+ . await ?;
618+ let offer = app
619+ . service
620+ . offer_draw ( & id, ControlRequest { seat : 1 , turn : 1 } )
621+ . await ?;
622+ assert ! ( !offer. terminated) ;
623+ assert_eq ! ( offer. draw_offer, Some ( 1 ) ) ;
624+ let ( a, b) = tokio:: join!(
625+ app. service. resign( & id, ControlRequest { seat: 0 , turn: 1 } ) ,
626+ app. service. resign( & id, ControlRequest { seat: 1 , turn: 1 } ) ,
627+ ) ;
628+ assert_eq ! ( usize :: from( a. is_ok( ) ) + usize :: from( b. is_ok( ) ) , 1 ) ;
629+ assert ! ( a
630+ . err( )
631+ . into_iter( )
632+ . chain( b. err( ) )
633+ . all( |error| error. code == "STALE_TURN" || error. code == "MATCH_FINISHED" ) ) ;
634+ app. store . close ( ) . await ;
635+ Ok ( ( ) )
636+ }
0 commit comments