Mud Designer's Revised State Management
This morning I wired up a revised state management system in the Mud Designer. The system is a bit more flexible, as it make use of the input agnostic player objects. In the past, the States required the Player object as a parameter within its Render
method so that it could fetch input directly from the player object. This brought with it a tight coupling that I have been trying to get rid of. It also allows for the new state system to fit in to the Single Responsibility Principle, which was previously being violated. What essentially would happen, is that the states would take control of the players input and not return it to the rest of the engine until the user was completed with the state. The state system had become responsible for fetching input, parsing and rendering.
As an example, the following state in the original engine captured control within itself and would not let it go until the user had completed the login process.
private ServerDirector director;
private IPlayer connectedPlayer;
//Used to manage the state of the Login in a more readable manor
private enum CurrentState
{
EnteringName,
EnteringPassword,
CharacterSelection,
}
private CurrentState currentState;
public ClientLoginState(ServerDirector serverDirector)
{
director = serverDirector;
currentState = CurrentState.EnteringName;
}
public void Render(IPlayer player)
{
//Store a reference for the GetCommand method to use
connectedPlayer = player;
//Check which state we are in
switch(currentState)
{
//User is entering his/her character name
case CurrentState.EnteringName:
player.SendMessage("What is your username, adventurer? ", false);
break;
//User is entering his/her password
case CurrentState.EnteringPassword:
player.SendMessage("Please enter your password " + player.Name + ": ", false);
break;
}
}
public ICommand GetCommand()
{
//Check which state we are in
switch (currentState)
{
//User is entering his/her character name
case CurrentState.EnteringName:
{
GetUsername();
break;
}
//User is entering his/her password
case CurrentState.EnteringPassword:
{
IState startState = connectedPlayer.CurrentState;
if(GetUserPassword())
{
//Was originally LookCommand() but that was overriding the State specified in GetUserPassword.
return new NoOpCommand();
}
break;
}
}
return new NoOpCommand();
}
private void GetUsername()
{
//Recieve the user input
var input = connectedPlayer.ReceiveInput();
//First input received on connection, so clean it.
//Some telnet clients send header information with it.
input = System.Text.RegularExpressions.Regex.Match(input, @"\w+").ToString();
//Make sure the text entered is valid and not null, blank etc.
if (!ValidateInput(input))
{
connectedPlayer.SendMessage("You have entered an invalid name, please try again!");
return;
}
//Check if the character exists. If so, change the state of Login so s/he can enter the password
if (UserExists(input))
{
connectedPlayer.Name = input;
currentState = CurrentState.EnteringPassword;
}
else //No character by the supplied name found, so lets create a new one!
{
//New user messages are sent from within the NewCharacter state.
connectedPlayer.Name = input;
connectedPlayer.SwitchState(new CreationManager(director, CreationManager.CreationState.CharacterCreation));
}
}
private bool GetUserPassword()
{
//Recieve the user input
var input = connectedPlayer.ReceiveInput();
//Make sure the text entered is valid and not null, blank etc.
if (!ValidateInput(input))
{
connectedPlayer.SendMessage("Your password is invalid!");
return false;
}
var file = new FileIO();
IPlayer loadedplayer = (IPlayer)file.Load(
Path.Combine(
EngineSettings.Default.PlayerSavePath,
string.Format("{0}.char", connectedPlayer.Username)),
connectedPlayer.GetType());
if (loadedplayer != null && loadedplayer.CheckPassword(input))
{
/*Make sure we are disconnecting the user if they are connected already.
foreach (var connectedUser in director.ConnectedPlayers.Keys)
{
if (connectedUser.Name == loadedplayer.Name && connectedUser != loadedplayer)
connectedUser.Disconnect();
}
*/
connectedPlayer.SendMessage("Success!!");
//Can use inherited built-in CopyState method instead
//connectedPlayer.LoadPlayer(loadedplayer);
//Use IGameObject.CopyState to use a uniform method across the engine
//A little slower than the LoadPlayer method, but it can be revised to be quicker.
//Notes on revising the method are under GameObject.cs
IGameObject tmp = (IGameObject)loadedplayer;
connectedPlayer.CopyState(ref tmp); //Copies loadedPlayer state to connectedPlayer.
//Make sure the player is properly added to the world.
IWorld world = connectedPlayer.Director.Server.Game.World;
IRealm realm = world.GetRealm(connectedPlayer.Location.Zone.Realm.Name);
if (realm == null)
return false;
IZone zone = realm.GetZone(connectedPlayer.Location.Zone.Name);
if (zone == null)
return false;
IRoom room = zone.GetRoom(connectedPlayer.Location.Name);
if (room == null)
return false;
connectedPlayer.Move(room);
Log.Info(string.Format("{0} has just logged in.", connectedPlayer.Name));
connectedPlayer.SwitchState(new LoginCompleted());
return true;
}
else
{
Log.Info(string.Format("{0} has failed logged in at IP Address: {1}.", connectedPlayer.Name,
connectedPlayer.Connection.RemoteEndPoint));
return false;
}
}
As you can see, in the GetUserName
and GetUserPassword
methods, the methods invoke connectedPlayer.ReceiveInput()
which would not proceed any further until after the user has provided the required text.
Now, with the new ReceivedMessage
event that the player object has, we can register to receive a notification when the event is fired, without having to capture control and hold it hostage. This allows the state objects to do what they are designed to do, render state to the screen and then waits for additional input when ever it arrives before proceeding or altering its state.
private IPlayer connectedPlayer;
private enum CurrentState
{
FetchUserName,
FetchPassword,
InvalidUser,
}
private CurrentState currentState;
public void Render(IMob mob)
{
if (!(mob is IPlayer))
{
throw new NullReferenceException("ConnectState can only be used with a player object implementing IPlayer");
}
//Store a reference for the GetCommand() method to use.
this.connectedPlayer = mob as IPlayer;
var server = mob.Game as IServer;
this.connectedPlayer.ReceivedMessage += connectedPlayer_ReceivedMessage;
if (server == null)
{
throw new NullReferenceException("LoginState can only be set to a player object that is part of a server.");
}
this.currentState = CurrentState.FetchUserName;
switch(this.currentState)
{
case CurrentState.FetchUserName:
this.connectedPlayer.Send(new InputMessage("Please enter your user name"));
break;
case CurrentState.FetchPassword:
this.connectedPlayer.Send(new InputMessage("Please enter your password"));
break;
case CurrentState.InvalidUser:
this.connectedPlayer.Send(new InformationalMessage("Invalid username/password specified."));
this.currentState = CurrentState.FetchUserName;
this.connectedPlayer.Send(new InputMessage("Please enter your user name"));
break;
}
}
void connectedPlayer_ReceivedMessage(object sender, IMessage e)
{
// We have the text from the user now, create a command from it.
// ATM this does not do anything with the command.
ICommand command = this.GetCommand(e);
// Be good memory citizens and clean ourself up after receiving a message.
// Not doing this results in duplicate events being registered and memory leaks.
this.connectedPlayer.ReceivedMessage -= connectedPlayer_ReceivedMessage;
}
public Commands.ICommand GetCommand(IMessage command)
{
if (this.currentState == CurrentState.FetchUserName)
{
this.connectedPlayer.Name = command.Message;
this.currentState = CurrentState.FetchPassword;
}
else if (this.currentState == CurrentState.FetchPassword)
{
// find user
}
return new NoOpCommand();
}
Since the state is associated with the player object (via a StateManager class I will discuss in another post) we can register to receive an event notice when the user inputs text and then continue handling our state with the new data. This also allows other objects that might be registered to act on the users input. The benefit here is that a user can hold a conversation with another user, or be in the middle of interacting with a NPC or deep in a training course and still be able to execute other commands without messing up their current state.
The IGame
objects are responsible for specifying what the initial state of the game should be. Since the MultiplayerGame
object instances and invokes a Connect
method within the ServerPlayer
object (that wraps an IPlayer), I specify that the initial state should be a ConnectState
within the Connect method.
public virtual void Connect(System.Net.Sockets.Socket socket, IPlayer player)
{
this.Connection = socket;
this.Player = player;
// Set the users initial state.
this.Player.StateManager.SwitchState(new ConnectState());
this.OnConnect();
}
As soon as the SwitchState method is invoked on the StateManager, the players state is changed and immediately rendered to the user.