Rock Paper and Scissors in Python

I wonder if you’d have any advice, I spent some time at it, but am quite new to Python (not to programming.)

Maybe any styles or pattern that you find uncommon or not optimal, or styling suggestions.

import random
import sys
from typing import TypedDict


def request_valid_input(msg:str, allowed_values:list[str], max_tries:int)->str:
    """
    Gives the user a certain number of tries in case it types the wrong letter.
    returns: valid user input
    """
    user_input = input(msg).strip()
    while user_input not in allowed_values:
        max_tries-=1
        if max_tries <=0:
            raise Exception(f"Max tries reached. Input must be one of {allowed_values.join(',')}")
        user_input = input(msg).strip()
    return user_input


class Score(TypedDict):
    # total user score
    user:int
    # computer/python score
    py:int

class LastRound(TypedDict):
    """
    a summary of what happened in the last played game.
    """
    user_score:int
    # python/computer score.
    py_score:int
    user_letter:str
    # python/computer letter.
    py_letter:str

class RPS():
    """
    Represents a simple Rock Paper Scissors game
    """
    def __init__(self):
        """
        Configuration and state of the game
        """
        self.total_score:Score = {"user":0, "py":0}
        self.last_round:LastRound|None = None
        self.welcome = """
                                Welcome to the ultimate, deadly, emboldened Rock, Paper and Scissors game.
                                Possible values: r, p, s, q.
                                Enter r for ROCK, p for PAPER, s for SCISSORS, q to quit the game.

                      """
        self.max_tries=3 # when user tries an invalid letter or input.
        self.letter_mapping = {"r":"ROCK", "s":"SCISSORS", "p":"PAPER"} # for informing user
        self.allowed_values=set('r', 's', 'p', 'q')

    def random_letter(self)->str:
        """
        Generates r, s or p used as the computer's input value / choice.
        """
        rps_letter=self.allowed_values[0,-1]
        our_val = random.randint(0,2)
        return rps_letter[our_val]

    def report_result(self)->None:
        """
        Print a formatted string telling who wins.
        """
        user_selection  = self.letter_mapping.get(self.last_round.get("user_letter"))
        py_selection  = self.letter_mapping.get(self.last_round.get("py_letter"))

        print(f"You chose {user_selection}, Python chose {py_selection}")
        if self.last_round.get("user_score") > self.last_round.get("py_score"):
            print("User wins.")
        elif self.last_round.get("py_score") > self.last_round.get("user_score"):
            print("Python wins.")
        else:
            print("Ties.")


    def play(self, user_input):
        user_score=0
        py_score=0
        if user_input == "q":
            sys.exit("Exiting game.")
        user_val = user_input
        our_val = self.random_letter()

        # test tie separately.
        if user_val==our_val:
            pass

        # case 1: user chooses ROCK
        elif user_val=="r":
            if our_val=="s":
                user_score=1

            else:
                py_score=1
        # case 2 user chooses PAPER
        elif user_val=="p":
            if our_val=="r":
                user_score=1
            else:
                py_score=1
        # case 3 user chooses SCISSORS
        else:
            if our_val=="r":
                py_score=1
            else :
                user_score=1

        # put the results in the state of the class
        self.last_round:LastRound = {"user_score":user_score, "py_score":py_score, "user_letter":user_val, "py_letter":our_val}
        self.total_score["user"]+=user_score
        self.total_score["py"]+=py_score

def run_game(subsequent_message="remember: r for ROCK, s for SCISSORS, p for PAPER"):
    game = RPS()
    keep_going='y'
    while keep_going=='y':
        input = request_valid_input(msg=game.welcome, max_tries=game.max_tries, allowed_values=game.allowed_values)
        game.play(input)
        game.report_result()
        keep_going = request_valid_input(msg="Would you like to play again? Type y for yes, n for no.", allowed_values=['n', 'y'], max_tries=2)
        # if there are second tries it will use this message.
        msg = "Type your new choice (r, p, s or q): "
    print(f"\n\nEnd of the game. \n\nUser Score: {game.total_score.get('user')},\nPython Score: {game.total_score.get('py')}")

run_game()

PS i just realised the code in play can be shrunk quite a bit !

    def play(self, user_input):
        user_score=0
        py_score=0
        if user_input == "q":
            sys.exit("Exiting game.")
        user_val = user_input
        our_val = self.random_letter()

        # test tie separately.
        if user_val==our_val:
            pass
        # 3 cases where the user wins
        elif user_val=="r" and our_val=="s":
                user_score=1
        elif user_val=="p" and our_val=='r':
            user_score=1
        # case 3 user chooses SCISSORS
        elif user_val=="s" and our_val=='p':
            user_score=1
        else:
            py_score=1
        # put the results in the state of the class
        self.last_round:LastRound = {"user_score":user_score, "py_score":py_score, "user_letter":user_val, "py_letter":our_val}
        self.total_score["user"]+=user_score
        self.total_score["py"]+=py_score