// --------------------------------------------------------------------------------------------------
// Copyright (c) 2016 Microsoft Corporation
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
// associated documentation files (the "Software"), to deal in the Software without restriction,
// including without limitation the rights to use, copy, modify, merge, publish, distribute,
// sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all copies or
// substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT
// NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
// --------------------------------------------------------------------------------------------------
package com.microsoft.Malmo.Client;
import java.io.DataOutputStream;
import java.io.IOException;
import java.lang.reflect.Field;
import java.math.BigDecimal;
import java.net.UnknownHostException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.logging.Level;
import javax.xml.bind.JAXBException;
import javax.xml.stream.XMLStreamException;
import net.minecraft.client.Minecraft;
import net.minecraft.client.entity.EntityPlayerSP;
import net.minecraft.client.gui.GuiDisconnected;
import net.minecraft.client.gui.GuiIngameMenu;
import net.minecraft.client.gui.GuiMainMenu;
import net.minecraft.client.gui.GuiScreen;
import net.minecraft.client.multiplayer.WorldClient;
import net.minecraft.client.network.NetHandlerPlayClient;
import net.minecraft.client.settings.GameSettings;
import net.minecraft.entity.Entity;
import net.minecraft.entity.EntityLivingBase;
import net.minecraft.launchwrapper.Launch;
import net.minecraft.network.NetworkManager;
import net.minecraft.server.integrated.IntegratedServer;
import net.minecraft.util.math.MathHelper;
import net.minecraft.util.text.TextComponentString;
import net.minecraft.world.GameType;
import net.minecraft.world.World;
import net.minecraft.world.chunk.Chunk;
import net.minecraft.world.chunk.IChunkProvider;
import net.minecraftforge.common.MinecraftForge;
import net.minecraftforge.common.config.Configuration;
import net.minecraftforge.fml.client.event.ConfigChangedEvent.OnConfigChangedEvent;
import net.minecraftforge.fml.common.FMLCommonHandler;
import net.minecraftforge.fml.common.Loader;
import net.minecraftforge.fml.common.eventhandler.SubscribeEvent;
import net.minecraftforge.fml.common.gameevent.TickEvent;
import net.minecraftforge.fml.common.gameevent.TickEvent.ClientTickEvent;
import net.minecraftforge.fml.common.gameevent.TickEvent.Phase;
import net.minecraftforge.fml.common.gameevent.TickEvent.ServerTickEvent;
import org.xml.sax.SAXException;
import com.google.gson.JsonObject;
import com.microsoft.Malmo.IState;
import com.microsoft.Malmo.MalmoMod;
import com.microsoft.Malmo.MalmoMod.IMalmoMessageListener;
import com.microsoft.Malmo.MalmoMod.MalmoMessageType;
import com.microsoft.Malmo.StateEpisode;
import com.microsoft.Malmo.StateMachine;
import com.microsoft.Malmo.Client.MalmoModClient.InputType;
import com.microsoft.Malmo.MissionHandlerInterfaces.IVideoProducer;
import com.microsoft.Malmo.MissionHandlerInterfaces.IWantToQuit;
import com.microsoft.Malmo.MissionHandlers.MissionBehaviour;
import com.microsoft.Malmo.MissionHandlers.MultidimensionalReward;
import com.microsoft.Malmo.Schemas.AgentSection;
import com.microsoft.Malmo.Schemas.AgentStart;
import com.microsoft.Malmo.Schemas.ClientAgentConnection;
import com.microsoft.Malmo.Schemas.MinecraftServerConnection;
import com.microsoft.Malmo.Schemas.Mission;
import com.microsoft.Malmo.Schemas.MissionDiagnostics;
import com.microsoft.Malmo.Schemas.MissionEnded;
import com.microsoft.Malmo.Schemas.MissionInit;
import com.microsoft.Malmo.Schemas.MissionResult;
import com.microsoft.Malmo.Schemas.Reward;
import com.microsoft.Malmo.Schemas.ModSettings;
import com.microsoft.Malmo.Schemas.PosAndDirection;
import com.microsoft.Malmo.Utils.AddressHelper;
import com.microsoft.Malmo.Utils.AuthenticationHelper;
import com.microsoft.Malmo.Utils.SchemaHelper;
import com.microsoft.Malmo.Utils.ScreenHelper;
import com.microsoft.Malmo.Utils.SeedHelper;
import com.microsoft.Malmo.Utils.ScoreHelper;
import com.microsoft.Malmo.Utils.TextureHelper;
import com.microsoft.Malmo.Utils.ScreenHelper.TextCategory;
import com.microsoft.Malmo.Utils.TCPInputPoller;
import com.microsoft.Malmo.Utils.TCPInputPoller.CommandAndIPAddress;
import com.microsoft.Malmo.Utils.TimeHelper.SyncTickEvent;
import com.microsoft.Malmo.Utils.TCPSocketChannel;
import com.microsoft.Malmo.Utils.TCPUtils;
import com.microsoft.Malmo.Utils.TimeHelper;
import com.mojang.authlib.properties.Property;
/**
* Class designed to track and control the state of the mod, especially regarding mission launching/running.
* States are defined by the MissionState enum, and control is handled by
* MissionStateEpisode subclasses. The ability to set the state directly is
* restricted, but hooks such as onPlayerReadyForMission etc are exposed to
* allow subclasses to react to certain state changes.
* The ProjectMalmo mod app class inherits from this and uses these hooks to run missions.
*/
public class ClientStateMachine extends StateMachine implements IMalmoMessageListener
{
// AOG - Dropped from 2000 to 1000 to speed up detection of failed server restarts
private static final int WAIT_MAX_TICKS = 1000; // Over 1 minute and a half in client ticks.
private static final int VIDEO_MAX_WAIT = 90 * 1000; // Max wait for video in ms.
private static final String MISSING_MCP_PORT_ERROR = "no_mcp";
private static final String INFO_MCP_PORT = "info_mcp";
private static final String INFO_RESERVE_STATUS = "info_reservation";
private MissionInit currentMissionInit = null; // The MissionInit object for the mission currently being loaded/run.
private MissionBehaviour missionBehaviour = new MissionBehaviour();
private String missionQuitCode = ""; // The reason why this mission ended.
private MultidimensionalReward finalReward = new MultidimensionalReward(true); // The reward at the end of the mission, sent separately to ensure timely delivery.
private MissionDiagnostics missionEndedData = new MissionDiagnostics();
private ScreenHelper screenHelper = new ScreenHelper();
protected MalmoModClient inputController;
// Env service:
protected MalmoEnvServer envServer;
// Socket stuff:
protected TCPInputPoller missionPoller;
protected TCPInputPoller controlInputPoller;
protected int integratedServerPort;
String reservationID = ""; // empty if we are not reserved, otherwise "RESERVED" + the experiment ID we are reserved for.
long reservationExpirationTime = 0;
private TCPSocketChannel missionControlSocket;
private void reserveClient(String id)
{
synchronized(this.reservationID)
{
ClientStateMachine.this.getScreenHelper().clearFragment(INFO_RESERVE_STATUS);
// id is in the form :, where long is the length of time to keep the reservation for,
// and expID is the experimentationID used to ensure the client is reserved for the correct experiment.
int separator = id.indexOf(":");
if (separator == -1)
{
System.out.println("Error - malformed reservation request - client will not be reserved.");
this.reservationID = "";
}
else
{
long duration = Long.valueOf(id.substring(0, separator));
String expID = id.substring(separator + 1);
this.reservationExpirationTime = System.currentTimeMillis() + duration;
// We don't just use the id, in case users have supplied a blank string as their experiment ID.
this.reservationID = "RESERVED" + expID;
ClientStateMachine.this.getScreenHelper().addFragment("Reserved: " + expID, TextCategory.TXT_INFO, (int)duration);//INFO_RESERVE_STATUS);
}
}
}
private boolean isReserved()
{
synchronized(this.reservationID)
{
System.out.println("==== RES: " + this.reservationID + " - " + (this.reservationExpirationTime - System.currentTimeMillis()));
return !this.reservationID.isEmpty() && this.reservationExpirationTime > System.currentTimeMillis();
}
}
private boolean isAvailable(String id)
{
synchronized(this.reservationID)
{
return (this.reservationID.isEmpty() || this.reservationID.equals("RESERVED" + id) || System.currentTimeMillis() >= this.reservationExpirationTime);
}
}
private void cancelReservation()
{
synchronized(this.reservationID)
{
this.reservationID = "";
ClientStateMachine.this.getScreenHelper().clearFragment(INFO_RESERVE_STATUS);
}
}
protected TCPSocketChannel getMissionControlSocket() { return this.missionControlSocket; }
protected void createMissionControlSocket()
{
TCPUtils.LogSection ls = new TCPUtils.LogSection("Creating MissionControlSocket");
// Set up a TCP connection to the agent:
ClientAgentConnection cac = currentMissionInit().getClientAgentConnection();
if (this.missionControlSocket == null ||
this.missionControlSocket.getPort() != cac.getAgentMissionControlPort() ||
this.missionControlSocket.getAddress() == null ||
!this.missionControlSocket.isValid() ||
!this.missionControlSocket.isOpen() ||
!this.missionControlSocket.getAddress().equals(cac.getAgentIPAddress()))
{
if (this.missionControlSocket != null)
this.missionControlSocket.close();
this.missionControlSocket = new TCPSocketChannel(cac.getAgentIPAddress(), cac.getAgentMissionControlPort(), "mcp");
}
ls.close();
}
public ClientStateMachine(ClientState initialState, MalmoModClient inputController)
{
super(initialState);
this.inputController = inputController;
// Register ourself on the event busses, so we can harness the client tick:
MinecraftForge.EVENT_BUS.register(this);
MalmoMod.MalmoMessageHandler.registerForMessage(this, MalmoMessageType.SERVER_TEXT);
}
@Override
public void clearErrorDetails()
{
super.clearErrorDetails();
this.missionQuitCode = "";
}
@SubscribeEvent
public void onClientTick(TickEvent.ClientTickEvent ev)
{
// Use the client tick to ensure we regularly update our state (from the client thread)
updateState();
}
public ScreenHelper getScreenHelper()
{
return screenHelper;
}
@Override
public void onMessage(MalmoMessageType messageType, Map data)
{
if (messageType == MalmoMessageType.SERVER_TEXT)
{
String chat = data.get("chat");
if (chat != null)
Minecraft.getMinecraft().ingameGUI.getChatGUI().printChatMessageWithOptionalDeletion(new TextComponentString(chat), 1);
else
{
String text = data.get("text");
ScreenHelper.TextCategory category = ScreenHelper.TextCategory.valueOf(data.get("category"));
String strtime = data.get("displayTime");
Integer time = (strtime != null) ? Integer.valueOf(strtime) : null;
this.getScreenHelper().addFragment(text, category, time);
}
}
}
@Override
protected String getName()
{
return "CLIENT";
}
@Override
protected void onPreStateChange(IState toState)
{
this.getScreenHelper().addFragment("CLIENT: " + toState, ScreenHelper.TextCategory.TXT_CLIENT_STATE, "");
}
/**
* Create the episode object for the requested state.
*
* @param state the state the mod is entering
* @return a MissionStateEpisode that localises all the logic required to run this state
*/
@Override
protected StateEpisode getStateEpisodeForState(IState state)
{
if (!(state instanceof ClientState))
return null;
ClientState cs = (ClientState) state;
switch (cs) {
case WAITING_FOR_MOD_READY:
return new InitialiseClientModEpisode(this);
case DORMANT:
return new DormantEpisode(this);
case CREATING_HANDLERS:
return new CreateHandlersEpisode(this);
case EVALUATING_WORLD_REQUIREMENTS:
return new EvaluateWorldRequirementsEpisode(this);
case PAUSING_OLD_SERVER:
return new PauseOldServerEpisode(this);
case CLOSING_OLD_SERVER:
return new CloseOldServerEpisode(this);
case CREATING_NEW_WORLD:
return new CreateWorldEpisode(this);
case WAITING_FOR_SERVER_READY:
return new WaitingForServerEpisode(this);
case RUNNING:
return new MissionRunningEpisode(this);
case IDLING:
return new MissionIdlingEpisode(this);
case MISSION_ENDED:
return new MissionEndedEpisode(this, MissionResult.ENDED, false, false, true);
case ERROR_DUFF_HANDLERS:
return new MissionEndedEpisode(this, MissionResult.MOD_FAILED_TO_INSTANTIATE_HANDLERS, true, true, true);
case ERROR_INTEGRATED_SERVER_UNREACHABLE:
return new MissionEndedEpisode(this, MissionResult.MOD_SERVER_UNREACHABLE, true, true, true);
case ERROR_NO_WORLD:
return new MissionEndedEpisode(this, MissionResult.MOD_HAS_NO_WORLD_LOADED, true, true, true);
case ERROR_CANNOT_CREATE_WORLD:
return new MissionEndedEpisode(this, MissionResult.MOD_FAILED_TO_CREATE_WORLD, true, true, true);
case ERROR_CANNOT_START_AGENT: // run-ons deliberate
case ERROR_LOST_AGENT:
case ERROR_LOST_VIDEO:
return new MissionEndedEpisode(this, MissionResult.MOD_HAS_NO_AGENT_AVAILABLE, true, true, false);
case ERROR_LOST_NETWORK_CONNECTION: // run-on deliberate
case ERROR_CANNOT_CONNECT_TO_SERVER:
return new MissionEndedEpisode(this, MissionResult.MOD_CONNECTION_FAILED, true, false, true); // No point trying to inform the server - we can't reach it anyway!
case ERROR_TIMED_OUT_WAITING_FOR_EPISODE_START: // run-ons deliberate
case ERROR_TIMED_OUT_WAITING_FOR_EPISODE_PAUSE:
case ERROR_TIMED_OUT_WAITING_FOR_EPISODE_CLOSE:
case ERROR_TIMED_OUT_WAITING_FOR_MISSION_END:
case ERROR_TIMED_OUT_WAITING_FOR_WORLD_CREATE:
return new MissionEndedEpisode(this, MissionResult.MOD_CONNECTION_FAILED, true, true, true);
case MISSION_ABORTED:
return new MissionEndedEpisode(this, MissionResult.MOD_SERVER_ABORTED_MISSION, true, false, true); // Don't inform the server - it already knows (we're acting on its notification)
case WAITING_FOR_SERVER_MISSION_END:
return new WaitingForServerMissionEndEpisode(this);
default:
break;
}
return null;
}
protected MissionInit currentMissionInit()
{
return this.currentMissionInit;
}
protected MissionBehaviour currentMissionBehaviour()
{
return this.missionBehaviour;
}
protected class MissionInitResult
{
public MissionInit missionInit = null;
public boolean wasMissionInit = false;
public String error = null;
}
protected MissionInitResult decodeMissionInit(String command)
{
MissionInitResult result = new MissionInitResult();
if (command == null)
{
result.error = "Null command passed.";
return result;
}
String rootNodeName = SchemaHelper.getRootNodeName(command);
if (rootNodeName != null && rootNodeName.equals("MissionInit"))
{
result.wasMissionInit = true;
// Attempt to decode the MissionInit XML string.
try
{
result.missionInit = (MissionInit) SchemaHelper.deserialiseObject(command, "MissionInit.xsd", MissionInit.class);
}
catch (JAXBException e)
{
System.out.println("JAXB exception: " + e);
if (e.getMessage() != null)
result.error = e.getMessage();
else if (e.getLinkedException() != null && e.getLinkedException().getMessage() != null)
result.error = e.getLinkedException().getMessage();
else
result.error = "Unspecified problem parsing MissionInit - check your Mission xml.";
}
catch (SAXException e)
{
System.out.println("SAX exception: " + e);
result.error = e.getMessage();
}
catch (XMLStreamException e)
{
System.out.println("XMLStreamException: " + e);
result.error = e.getMessage();
}
}
return result;
}
protected boolean areMissionsEqual(Mission m1, Mission m2)
{
return true;
// FIX NEEDED - the following code fails because m1 may have been
// modified since loading - eg the MazeDecorator writes directly to the XML,
// and the use of some of the getters in the XSD-generated code can cause extra
// (empty) nodes to be added to the resulting XML.
// We need a more robust way of comparing two mission objects.
// For now, simply return true, since a false positive is less dangerous
// than a false negative.
/*
try {
String s1 = SchemaHelper.serialiseObject(m1, Mission.class);
String s2 = SchemaHelper.serialiseObject(m2, Mission.class);
return s1.compareTo(s2) == 0;
} catch( JAXBException e ) {
System.out.println("JAXB exception: " + e);
return false;
}*/
}
/**
* Set up the mission poller.
* This is called during the initialisation episode, but also needs to be
* available for other episodes in case the configuration changes, resulting
* in changes to the ports.
*
* @throws UnknownHostException
*/
protected void initialiseComms() throws UnknownHostException
{
// Start polling for missions:
if (this.missionPoller != null)
{
this.missionPoller.stopServer();
}
this.missionPoller = new TCPInputPoller(AddressHelper.getMissionControlPortOverride(), AddressHelper.MIN_MISSION_CONTROL_PORT, AddressHelper.MAX_FREE_PORT, true, "mcp")
{
@Override
public void onError(String error, DataOutputStream dos)
{
System.out.println("SENDING ERROR: " + error);
try
{
dos.writeInt(error.length());
dos.writeBytes(error);
dos.flush();
}
catch (IOException e)
{
}
}
private void reply(String reply, DataOutputStream dos)
{
System.out.println("REPLYING WITH: " + reply);
try
{
dos.writeInt(reply.length());
dos.writeBytes(reply);
dos.flush();
}
catch (IOException e)
{
System.out.println("Failed to reply to message!");
}
}
@Override
public boolean onCommand(String command, String ipFrom, DataOutputStream dos)
{
System.out.println("Received from " + ipFrom + ":" +
command.substring(0, Math.min(command.length(), 1024)));
boolean keepProcessing = false;
// Possible commands:
// 1: MALMO_REQUEST_CLIENT::
// 2: MALMO_CANCEL_REQUEST
// 3: MALMO_FIND_SERVER
// 4: MALMO_KILL_CLIENT
// 5: MissionInit
String reservePrefixGeneral = "MALMO_REQUEST_CLIENT:";
String reservePrefix = reservePrefixGeneral + Loader.instance().activeModContainer().getVersion() + ":";
String findServerPrefix = "MALMO_FIND_SERVER";
String cancelRequestCommand = "MALMO_CANCEL_REQUEST";
String killClientCommand = "MALMO_KILL_CLIENT";
if (command.startsWith(reservePrefix))
{
// Reservation request.
// We either reply with MALMOOK, if we are free, or MALMOBUSY if not.
IState currentState = getStableState();
if (currentState != null && currentState.equals(ClientState.DORMANT) && !isReserved())
{
reserveClient(command.substring(reservePrefix.length()));
reply("MALMOOK", dos);
}
else
{
// We're busy - we can't be reserved.
reply("MALMOBUSY", dos);
}
}
else if (command.startsWith(reservePrefixGeneral))
{
// Reservation request, but it didn't match the request we expect, above.
// This happens if the agent sending the request is running a different version of Malmo -
// a version mismatch error.
reply("MALMOERRORVERSIONMISMATCH in reservation string (Got " + command + ", expected " + reservePrefix + " - check your path for old versions of MalmoPython/MalmoJava/Malmo.lib etc)", dos);
}
else if (command.equals(cancelRequestCommand))
{
// If we've been reserved, cancel the reservation.
if (isReserved())
{
cancelReservation();
reply("MALMOOK", dos);
}
else
{
// We weren't reserved in the first place - something is odd.
reply("MALMOERRORAttempt to cancel a reservation that was never made.", dos);
}
}
else if (command.startsWith(findServerPrefix))
{
// Request to find the server for the given experiment ID.
String expID = command.substring(findServerPrefix.length());
if (currentMissionInit() != null && currentMissionInit().getExperimentUID().equals(expID))
{
// Our Experiment IDs match, so we are running the same experiment.
// Return the port and server IP address to the caller:
MinecraftServerConnection msc = currentMissionInit().getMinecraftServerConnection();
if (msc == null)
reply("MALMONOSERVERYET", dos); // Mission might be starting up.
else
reply("MALMOS" + msc.getAddress().trim() + ":" + msc.getPort(), dos);
}
else
{
// We don't have a MissionInit ourselves, or we're running a different experiment,
// so we can't help.
reply("MALMONOSERVER", dos);
}
}
else if (command.equals(killClientCommand))
{
// Kill switch provided in case AI takes over the world...
// Or, more likely, in case this Minecraft instance has become unreliable (eg if it's been running for several days)
// and needs to be replaced with a fresh instance.
// If we are currently running a mission, we gracefully decline, to prevent users from wiping out
// other users' experiments.
// We also decline unless we were launched in "replaceable" mode - a command-line switch that indicates we were
// launched by a script which is still running, and can therefore replace us when we terminate.
IState currentState = getStableState();
if (currentState != null && currentState.equals(ClientState.DORMANT) && !isReserved())
{
Configuration config = MalmoMod.instance.getModSessionConfigFile();
if (config.getBoolean("replaceable", "runtype", false, "Will be replaced if killed"))
{
reply("MALMOOK", dos);
missionPoller.stopServer();
exitJava();
}
else
{
reply("MALMOERRORNOTKILLABLE", dos);
}
}
else
{
// We're too busy and important to be killed.
reply("MALMOBUSY", dos);
}
}
else
{
// See if we've been sent a MissionInit message:
MissionInitResult missionInitResult = decodeMissionInit(command);
if (missionInitResult.wasMissionInit && missionInitResult.missionInit == null)
{
// Got sent a duff MissionInit xml - pass back the JAXB/SAXB errors.
reply("MALMOERROR" + missionInitResult.error, dos);
}
else if (missionInitResult.wasMissionInit && missionInitResult.missionInit != null)
{
MissionInit missionInit = missionInitResult.missionInit;
// We've been sent a MissionInit message.
// First, check the version number:
String platformVersion = missionInit.getPlatformVersion();
String ourVersion = Loader.instance().activeModContainer().getVersion();
if (platformVersion == null || !platformVersion.equals(ourVersion))
{
reply("MALMOERRORVERSIONMISMATCH (Got " + platformVersion + ", expected " + ourVersion + " - check your path for old versions of MalmoPython/MalmoJava/Malmo.lib etc)", dos);
}
else
{
// MissionInit passed to us - this is a request to launch this mission. Can we?
IState currentState = getStableState();
if (currentState != null && currentState.equals(ClientState.DORMANT) && isAvailable(missionInit.getExperimentUID()))
{
reply("MALMOOK", dos);
keepProcessing = true; // State machine will now process this MissionInit and start the mission.
}
else
{
// We're busy - we can't run this mission.
reply("MALMOBUSY", dos);
}
}
}
}
return keepProcessing;
}
};
int mcPort = 0;
if (MalmoEnvServer.isEnv()) {
// Start up new "Env" service instead of Malmo AgentHost api.
System.out.println("***** Start MalmoEnvServer on port " + AddressHelper.getMissionControlPortOverride());
this.envServer = new MalmoEnvServer(Loader.instance().activeModContainer().getVersion(), AddressHelper.getMissionControlPortOverride(), this.missionPoller);
Thread thread = new Thread("MalmoEnvServer") {
public void run() {
try {
envServer.serve();
} catch (IOException ioe) {
System.out.println("MalmoEnvServer exist on " + ioe);
}
}
};
thread.start();
} else {
// "Legacy" AgentHost api.
this.missionPoller.start();
mcPort = ClientStateMachine.this.missionPoller.getPortBlocking();
}
// Tell the address helper what the actual port is:
AddressHelper.setMissionControlPort(mcPort);
if (AddressHelper.getMissionControlPort() == -1)
{
// Failed to create a mission control port - nothing will work!
System.out.println("**** NO MISSION CONTROL SOCKET CREATED - WAS THE PORT IN USE? (Check Mod GUI options) ****");
ClientStateMachine.this.getScreenHelper().addFragment("ERROR: Could not open a Mission Control Port - check the Mod GUI options.", TextCategory.TXT_CLIENT_WARNING, MISSING_MCP_PORT_ERROR);
}
else
{
// Clear the error string, if there was one:
ClientStateMachine.this.getScreenHelper().clearFragment(MISSING_MCP_PORT_ERROR);
}
// Display the port number:
ClientStateMachine.this.getScreenHelper().clearFragment(INFO_MCP_PORT);
if (AddressHelper.getMissionControlPort() != -1)
ClientStateMachine.this.getScreenHelper().addFragment("MCP: " + AddressHelper.getMissionControlPort(), TextCategory.TXT_INFO, INFO_MCP_PORT);
}
public static void exitJava() {
// Give non-hard exit 10 seconds to complete and force a hard exit.
Thread deadMansHandle = new Thread(new Runnable() {
@Override
public void run() {
for (int i = 10; i > 0; i--) {
try {
Thread.sleep(1000);
System.out.println("Waiting to exit " + i + "...");
} catch (InterruptedException e) {
System.out.println("Interrupted " + i + "...");
}
}
// Kill it with fire!!!
System.out.println("Attempting hard exit");
FMLCommonHandler.instance().exitJava(0, true);
}
});
deadMansHandle.setDaemon(true);
deadMansHandle.start();
// Have to use FMLCommonHandler; direct calls to System.exit() are trapped and denied by the FML code.
FMLCommonHandler.instance().exitJava(0, false);
}
// ---------------------------------------------------------------------------------------------------------
// Episode helpers - each extends a MissionStateEpisode to encapsulate a certain state
// ---------------------------------------------------------------------------------------------------------
public abstract class ErrorAwareEpisode extends StateEpisode implements IMalmoMessageListener
{
protected Boolean errorFlag = false;
protected Map errorData = null;
public ErrorAwareEpisode(ClientStateMachine machine)
{
super(machine);
MalmoMod.MalmoMessageHandler.registerForMessage(this, MalmoMessageType.SERVER_ABORT);
}
protected boolean pingAgent(boolean abortIfFailed)
{
if (AddressHelper.getMissionControlPort() == 0) {
// MalmoEnvServer has no server to client ping.
return true;
}
boolean sentOkay = ClientStateMachine.this.getMissionControlSocket().sendTCPString("", 1);
if (!sentOkay)
{
// It's not available - bail.
ClientStateMachine.this.getScreenHelper().addFragment("ERROR: Lost contact with agent - aborting mission", TextCategory.TXT_CLIENT_WARNING, 10000);
if (abortIfFailed)
episodeHasCompletedWithErrors(ClientState.ERROR_LOST_AGENT, "Lost contact with the agent");
}
return sentOkay;
}
@Override
public void onMessage(MalmoMessageType messageType, Map data)
{
if (messageType == MalmoMod.MalmoMessageType.SERVER_ABORT)
{
synchronized (this.errorFlag)
{
this.errorFlag = true;
this.errorData = data;
// Save the error message, if there is one:
if (data != null)
{
String message = data.get("message");
String user = data.get("username");
String error = data.get("error");
String report = "";
if (user != null)
report += "From " + user + ": ";
if (error != null)
report += error;
if (message != null)
report += " (" + message + ")";
ClientStateMachine.this.saveErrorDetails(report);
}
onAbort(data);
}
}
}
@Override
public void cleanup()
{
super.cleanup();
MalmoMod.MalmoMessageHandler.deregisterForMessage(this, MalmoMessageType.SERVER_ABORT);
}
protected boolean inAbortState()
{
synchronized (this.errorFlag)
{
return this.errorFlag;
}
}
protected Map getErrorData()
{
synchronized (this.errorFlag)
{
return this.errorData;
}
}
protected void onAbort(Map errorData)
{
// Default does nothing, but can be overridden.
}
}
/**
* Helper base class that responds to the config change and updates our AddressHelper.
* This will also reset the mission poller. Depending on the state, more
* work may be needed (eg to recreate the command handler, etc) - it's up to
* the individual state episodes to do whatever else needs doing.
*/
abstract public class ConfigAwareStateEpisode extends ErrorAwareEpisode
{
ConfigAwareStateEpisode(ClientStateMachine machine)
{
super(machine);
}
@Override
public void onConfigChanged(OnConfigChangedEvent ev)
{
if (ev.getConfigID().equals(MalmoMod.SOCKET_CONFIGS))
{
AddressHelper.update(MalmoMod.instance.getModSessionConfigFile());
try
{
ClientStateMachine.this.initialiseComms();
}
catch (UnknownHostException e)
{
// TODO What to do here?
e.printStackTrace();
}
ScreenHelper.update(MalmoMod.instance.getModPermanentConfigFile());
TCPUtils.update(MalmoMod.instance.getModPermanentConfigFile());
}
}
}
/** Initial episode - perform client setup */
public class InitialiseClientModEpisode extends ConfigAwareStateEpisode
{
InitialiseClientModEpisode(ClientStateMachine machine)
{
super(machine);
}
@Override
protected void execute() throws Exception
{
ClientStateMachine.this.initialiseComms();
// This is necessary in order to allow user to exit the Minecraft window without halting the experiment:
GameSettings settings = Minecraft.getMinecraft().gameSettings;
settings.pauseOnLostFocus = false;
// And hook the screen helper into the ingame gui (which is responsible for overlaying chat, titles etc) -
// this has to be done after Minecraft.init(), so we do it here.
ScreenHelper.hookIntoInGameGui();
}
@Override
public void onRenderTick(TickEvent.RenderTickEvent ev)
{
// We wait until we start to get render ticks, at which point we assume Minecraft has finished starting up.
episodeHasCompleted(ClientState.DORMANT);
}
}
// ---------------------------------------------------------------------------------------------------------
/** Dormant state - receptive to new missions */
public class DormantEpisode extends ConfigAwareStateEpisode
{
private ClientStateMachine csMachine;
protected DormantEpisode(ClientStateMachine machine)
{
super(machine);
this.csMachine = machine;
}
@Override
protected void execute()
{
TextureHelper.init();
// Clear our current MissionInit state:
csMachine.currentMissionInit = null;
// Clear our current error state:
clearErrorDetails();
// And clear out any stale commands left over from recent missions:
if (ClientStateMachine.this.controlInputPoller != null)
ClientStateMachine.this.controlInputPoller.clearCommands();
// Finally, do some Java housekeeping:
System.gc();
}
@Override
public void onClientTick(TickEvent.ClientTickEvent ev) throws Exception
{
Minecraft.getMinecraft().mcProfiler.startSection("malmoHandleMissionCommands");
checkForMissionCommand();
Minecraft.getMinecraft().mcProfiler.endSection();
}
private void checkForMissionCommand() throws Exception
{
if (ClientStateMachine.this.missionPoller == null)
return;
CommandAndIPAddress comip = missionPoller.getCommandAndIPAddress();
if (comip == null)
return;
String missionMessage = comip.command;
if (missionMessage == null || missionMessage.length() == 0)
return;
Minecraft.getMinecraft().mcProfiler.endSection();
Minecraft.getMinecraft().mcProfiler.startSection("malmoDecodeMissionInit");
MissionInitResult missionInitResult = decodeMissionInit(missionMessage);
Minecraft.getMinecraft().mcProfiler.endSection();
MissionInit missionInit = missionInitResult.missionInit;
if (missionInit != null)
{
missionInit.getClientAgentConnection().setAgentIPAddress(comip.ipAddress);
System.out.println("Mission received: " + missionInit.getMission().getAbout().getSummary());
csMachine.currentMissionInit = missionInit;
TimeHelper.SyncManager.numTicks = 0;
ScoreHelper.logMissionInit(missionInit);
ClientStateMachine.this.createMissionControlSocket();
// Move on to next state:
episodeHasCompleted(ClientState.CREATING_HANDLERS);
}
else
{
throw new Exception("Failed to get valid MissionInit object from SchemaHelper.");
}
}
}
// ---------------------------------------------------------------------------------------------------------
/**
* Now the MissionInit XML has been decoded, the client needs to create the
* Mission Handlers.
*/
public class CreateHandlersEpisode extends ConfigAwareStateEpisode
{
protected CreateHandlersEpisode(ClientStateMachine machine)
{
super(machine);
}
@Override
protected void execute() throws Exception
{
// First, clear our reservation state, if we were reserved:
ClientStateMachine.this.cancelReservation();
// Now try creating the handlers:
try
{
if(envServer != null){
SeedHelper.advanceNextSeed(envServer.getSeed());
}
ClientStateMachine.this.missionBehaviour = MissionBehaviour.createAgentHandlersFromMissionInit(currentMissionInit());
if (envServer != null) {
ClientStateMachine.this.missionBehaviour.addQuitProducer(envServer);
}
}
catch (Exception e)
{
// TODO
System.err.println("ERROR: Exception caught making agent handlers" + e.toString());
e.printStackTrace();
}
// Set up our command input poller. This is only checked during the MissionRunning episode, but
// it needs to be started now, so we can report the port it's using back to the agent.
TCPUtils.LogSection ls = new TCPUtils.LogSection("Initialise Command Input Poller");
ClientAgentConnection cac = currentMissionInit().getClientAgentConnection();
int requestedPort = cac.getClientCommandsPort();
// If the requested port is 0, we dynamically allocate our own port, and feed that back to the agent.
// If the requested port is non-zero, we have to use it.
if (requestedPort != 0 && ClientStateMachine.this.controlInputPoller != null && ClientStateMachine.this.controlInputPoller.getPort() != requestedPort)
{
// A specific port has been requested, and it's not the one we are currently using,
// so we need to recreate our poller.
System.out.println("Requested command port is not the same as the input poller port; the port was not free. Stopping server.");
ClientStateMachine.this.controlInputPoller.stopServer();
ClientStateMachine.this.controlInputPoller = null;
}
if (ClientStateMachine.this.controlInputPoller == null)
{
if (requestedPort == 0)
ClientStateMachine.this.controlInputPoller = new TCPInputPoller(AddressHelper.MIN_FREE_PORT, AddressHelper.MAX_FREE_PORT, true, "com");
else
ClientStateMachine.this.controlInputPoller = new TCPInputPoller(requestedPort, "com");
System.out.println("Starting command server.");
ClientStateMachine.this.controlInputPoller.start();
}
// Make sure the cac is up-to-date:
cac.setClientCommandsPort(ClientStateMachine.this.controlInputPoller.getPortBlocking());
ls.close();
// Check to see whether anything has caused us to abort - if so, go to the abort state.
if (inAbortState())
episodeHasCompleted(ClientState.MISSION_ABORTED);
// Set the agent's name as the current username:
List agents = currentMissionInit().getMission().getAgentSection();
String agentName = agents.get(currentMissionInit().getClientRole()).getName();
AuthenticationHelper.setPlayerName(Minecraft.getMinecraft().getSession(), agentName);
// If the player's profile properties are empty, MC will keep pinging the Minecraft session service
// to fill them, resulting in multiple http requests and grumpy responses from the server
// (see https://github.com/Microsoft/malmo/issues/568).
// To prevent this, we add a dummy property.
Minecraft.getMinecraft().getProfileProperties().put("dummy", new Property("dummy", "property"));
// Handlers and poller created successfully; proceed to next stage of loading.
// We will either need to connect to an existing server, or to start
// a new integrated server ourselves, depending on our role.
// For now, assume that the mod with role 0 is responsible for the server.
if (currentMissionInit().getClientRole() == 0)
{
// We are responsible for the server - investigate what needs to happen next:
episodeHasCompleted(ClientState.EVALUATING_WORLD_REQUIREMENTS);
}
else
{
// We may need to connect to a server.
episodeHasCompleted(ClientState.WAITING_FOR_SERVER_READY);
}
}
}
// ---------------------------------------------------------------------------------------------------------
/**
* Attempt to connect to a server. Wait until connection is established.
*/
public class WaitingForServerEpisode extends ConfigAwareStateEpisode
{
String agentName;
int ticksUntilNextPing = 0;
int totalTicks = 0;
boolean waitingForChunk = false;
boolean waitingForPlayer = true;
protected WaitingForServerEpisode(ClientStateMachine machine)
{
super(machine);
MalmoMod.MalmoMessageHandler.registerForMessage(this, MalmoMessageType.SERVER_ALLPLAYERSJOINED);
}
private boolean isChunkReady()
{
// First, find the starting position we ought to have:
List agents = currentMissionInit().getMission().getAgentSection();
if (agents == null || agents.size() <= currentMissionInit().getClientRole())
return true; // This should never happen.
AgentSection as = agents.get(currentMissionInit().getClientRole());
if (as.getAgentStart() != null && as.getAgentStart().getPlacement() != null)
{
PosAndDirection pos = as.getAgentStart().getPlacement();
int x = MathHelper.floor(pos.getX().doubleValue()) >> 4;
int z = MathHelper.floor(pos.getZ().doubleValue()) >> 4;
// Now get the chunk we should be starting in:
IChunkProvider chunkprov = Minecraft.getMinecraft().world.getChunkProvider();
EntityPlayerSP player = Minecraft.getMinecraft().player;
if (player.addedToChunk)
{
// Our player is already added to a chunk - is it the right one?
Chunk actualChunk = chunkprov.provideChunk(player.chunkCoordX, player.chunkCoordZ);
Chunk requestedChunk = chunkprov.provideChunk(x, z);
if (actualChunk == requestedChunk && actualChunk != null && !actualChunk.isEmpty())
{
// We're in the right chunk, and it's not an empty chunk.
// We're ready to proceed, but first set our client positions to where we ought to be.
// The server should be doing this too, but there's no harm (probably) in doing it ourselves.
player.posX = pos.getX().doubleValue();
player.posY = pos.getY().doubleValue();
player.posZ = pos.getZ().doubleValue();
return true;
}
}
return false; // Our starting position has been specified, but it's not yet ready.
}
return true; // No starting position specified, so doesn't matter where we start.
}
@Override
protected void onClientTick(ClientTickEvent ev)
{
// Check to see whether anything has caused us to abort - if so, go to the abort state.
if (inAbortState())
episodeHasCompleted(ClientState.MISSION_ABORTED);
if (this.waitingForPlayer)
{
if (Minecraft.getMinecraft().player != null)
{
this.waitingForPlayer = false;
handleLan();
}
else
return;
}
totalTicks++;
if (ticksUntilNextPing == 0)
{
// Tell the server what our agent name is.
// We do this repeatedly, because the server might not yet be listening.
if (Minecraft.getMinecraft().player != null && !this.waitingForChunk)
{
HashMap map = new HashMap();
map.put("agentname", agentName);
map.put("username", Minecraft.getMinecraft().player.getName());
currentMissionBehaviour().appendExtraServerInformation(map);
System.out.println("***Telling server we are ready - " + agentName);
MalmoMod.network.sendToServer(new MalmoMod.MalmoMessage(MalmoMessageType.CLIENT_AGENTREADY, 0, map));
}
// We also ping our agent, just to check it is still available:
pingAgent(true); // Will abort to an error state if client unavailable.
ticksUntilNextPing = 10; // Try again in ten ticks.
}
else
{
ticksUntilNextPing--;
}
if (this.waitingForChunk)
{
// The server is ready, we're just waiting for our chunk to appear.
if (isChunkReady())
proceed();
}
List agents = currentMissionInit().getMission().getAgentSection();
boolean completedWithErrors = false;
if (agents.size() > 1 && currentMissionInit().getClientRole() != 0)
{
// We are waiting to join an out-of-process server. Need to pay attention to what happens -
// if we can't join, for any reason, we should abort the mission.
GuiScreen screen = Minecraft.getMinecraft().currentScreen;
if (screen != null && screen instanceof GuiDisconnected) {
// Disconnected screen appears when something has gone wrong.
// Would be nice to grab the reason from the screen, but it's a private member.
// (Can always use reflection, but it's so inelegant.)
String msg = "Unable to connect to Minecraft server in multi-agent mission.";
TCPUtils.Log(Level.SEVERE, msg);
episodeHasCompletedWithErrors(ClientState.ERROR_CANNOT_CONNECT_TO_SERVER, msg);
completedWithErrors = true;
}
}
if (!completedWithErrors && totalTicks > WAIT_MAX_TICKS)
{
String msg = "Too long waiting for server episode to start.";
TCPUtils.Log(Level.SEVERE, msg);
// AOG - If we have timed out waiting for the server to be ready, then the
// MalmoEnvServer is also likely stuck trying to handle a peek request from
// Python client. We need to signal the env server should abort the request
// so that the client detects the error and can retry.
if (envServer != null) {
envServer.abort();
}
episodeHasCompletedWithErrors(ClientState.ERROR_TIMED_OUT_WAITING_FOR_EPISODE_START, msg);
}
}
@Override
protected void execute() throws Exception
{
totalTicks = 0;
Minecraft.getMinecraft().displayGuiScreen(null); // Clear any menu screen that might confuse things.
// Get our name from the Mission:
List agents = currentMissionInit().getMission().getAgentSection();
//if (agents == null || agents.size() <= currentMissionInit().getClientRole())
// throw new Exception("No agent section for us!"); // TODO
this.agentName = agents.get(currentMissionInit().getClientRole()).getName();
if (agents.size() > 1 && currentMissionInit().getClientRole() != 0)
{
// Multi-agent mission, we should be joining a server.
// (Unless we are already on the correct server.)
String address = currentMissionInit().getMinecraftServerConnection().getAddress().trim();
int port = currentMissionInit().getMinecraftServerConnection().getPort();
String targetIP = address + ":" + port;
System.out.println("We should be joining " + targetIP);
EntityPlayerSP player = Minecraft.getMinecraft().player;
boolean namesMatch = (player == null) || Minecraft.getMinecraft().player.getName().equals(this.agentName);
if (!namesMatch)
{
// The name of our agent no longer matches the agent in our game profile -
// safest way to update is to log out and back in again.
// This hangs so just warn instead about the miss-match and proceed.
TCPUtils.Log(Level.WARNING,"Agent name does not match agent in game.");
// Minecraft.getMinecraft().world.sendQuittingDisconnectingPacket();
// Minecraft.getMinecraft().loadWorld((WorldClient)null);
}
if (Minecraft.getMinecraft().getCurrentServerData() == null || !Minecraft.getMinecraft().getCurrentServerData().serverIP.equals(targetIP))
{
net.minecraftforge.fml.client.FMLClientHandler.instance().connectToServerAtStartup(address, port);
}
this.waitingForPlayer = false;
}
}
protected void handleLan()
{
// Get our name from the Mission:
List agents = currentMissionInit().getMission().getAgentSection();
//if (agents == null || agents.size() <= currentMissionInit().getClientRole())
// throw new Exception("No agent section for us!"); // TODO
this.agentName = agents.get(currentMissionInit().getClientRole()).getName();
if (agents.size() > 1 && currentMissionInit().getClientRole() == 0) // Multi-agent mission - make sure the server is open to the LAN:
{
MinecraftServerConnection msc = new MinecraftServerConnection();
String address = currentMissionInit().getClientAgentConnection().getClientIPAddress();
// Do we need to open to LAN?
if (Minecraft.getMinecraft().isSingleplayer() && !Minecraft.getMinecraft().getIntegratedServer().getPublic())
{
String portstr = Minecraft.getMinecraft().getIntegratedServer().shareToLAN(GameType.SURVIVAL, true); // Set to true to stop spam kicks.
ClientStateMachine.this.integratedServerPort = Integer.valueOf(portstr);
}
TCPUtils.Log(Level.INFO,"Integrated server port: " + ClientStateMachine.this.integratedServerPort);
msc.setPort(ClientStateMachine.this.integratedServerPort);
msc.setAddress(address);
if (envServer != null) {
envServer.notifyIntegrationServerStarted(ClientStateMachine.this.integratedServerPort);
}
currentMissionInit().setMinecraftServerConnection(msc);
}
}
@Override
public void onMessage(MalmoMessageType messageType, Map data)
{
super.onMessage(messageType, data);
if (messageType != MalmoMessageType.SERVER_ALLPLAYERSJOINED)
return;
List