/** * Copyright 2010 The University of Nottingham * * This file is part of lobbyservice. * * lobbyservice is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * lobbyservice is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with lobbyservice. If not, see <http://www.gnu.org/licenses/>. * */ package uk.ac.horizon.ug.lobby.server; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; import java.io.OutputStreamWriter; import java.net.HttpURLConnection; import java.net.MalformedURLException; import java.net.URL; import java.net.URLConnection; import java.net.URLEncoder; import java.util.Date; import java.util.HashMap; import java.util.UUID; import java.util.logging.Level; import java.util.logging.Logger; import javax.persistence.EntityManager; import javax.persistence.EntityTransaction; import javax.servlet.http.HttpServletResponse; import javax.xml.parsers.DocumentBuilder; import javax.xml.parsers.DocumentBuilderFactory; import javax.xml.parsers.ParserConfigurationException; import javax.xml.transform.Result; import javax.xml.transform.Source; import javax.xml.transform.Transformer; import javax.xml.transform.TransformerConfigurationException; import javax.xml.transform.TransformerException; import javax.xml.transform.TransformerFactory; import javax.xml.transform.TransformerFactoryConfigurationError; import javax.xml.transform.dom.DOMSource; import javax.xml.transform.stream.StreamResult; import org.json.JSONException; import org.json.JSONObject; import org.w3c.dom.Document; import org.w3c.dom.Element; import org.w3c.dom.Node; import org.w3c.dom.NodeList; import org.xml.sax.SAXException; import uk.ac.horizon.ug.lobby.browser.JoinGameInstanceServlet; import uk.ac.horizon.ug.lobby.browser.JoinUtils; import uk.ac.horizon.ug.lobby.model.Account; import uk.ac.horizon.ug.lobby.model.EMF; import uk.ac.horizon.ug.lobby.model.GameClient; import uk.ac.horizon.ug.lobby.model.GameClientKnownType; import uk.ac.horizon.ug.lobby.model.GameInstance; import uk.ac.horizon.ug.lobby.model.GameInstanceFactory; import uk.ac.horizon.ug.lobby.model.GameInstanceSlot; import uk.ac.horizon.ug.lobby.model.GameServer; import uk.ac.horizon.ug.lobby.protocol.GameJoinRequest; import uk.ac.horizon.ug.lobby.protocol.GameJoinResponse; import uk.ac.horizon.ug.lobby.protocol.GameJoinResponseStatus; /** * @author cmg * */ public class ExplodingPlacesServerProtocol implements ServerProtocol { static Logger logger = Logger.getLogger(ExplodingPlacesServerProtocol.class.getName()); static final int DEFAULT_TIMEOUT_MS = 30000; /** do post of xml, return Connection */ public static Document doPost(String surl, Document doc) throws IOException { return doPost(surl, doc, true); } /** do post of xml, return Connection */ public static Document doPost(String surl, Document doc, boolean readResponse) throws IOException { try { DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance(); dbf.setNamespaceAware(false); DocumentBuilder db = dbf.newDocumentBuilder(); URL url = new URL(surl); HttpURLConnection conn = (HttpURLConnection) url.openConnection(); if (doc!=null) conn.setDoOutput(true); conn.setDoInput(true); conn.setUseCaches(false); conn.setRequestMethod("POST"); conn.setConnectTimeout(DEFAULT_TIMEOUT_MS); conn.setReadTimeout(DEFAULT_TIMEOUT_MS); conn.setRequestProperty("Content-Type", "text/xml;charset=UTF-8"); logger.info("Send request "+doc+" to "+url); if (doc!=null) { OutputStreamWriter osw = new OutputStreamWriter(conn.getOutputStream(), "UTF-8"); // TODO (standard) Xstream doesn't work on GAE :-( Transformer dt = TransformerFactory.newInstance().newTransformer(); dt.transform(new DOMSource(doc), new StreamResult(osw)); osw.close(); } int status = conn.getResponseCode(); if (status!=HttpServletResponse.SC_OK) throw new IOException("HTTP response "+status+": "+conn.getResponseMessage()); if (readResponse) { InputStream is = conn.getInputStream(); doc = db.parse(is); is.close(); logger.info("Received response "+doc+" from "+url); } else { InputStream is = conn.getInputStream(); is.close(); doc = null; } return doc; } catch (IOException e) { throw e; } catch (TransformerConfigurationException e) { logger.warning("doPost: "+e); throw new IOException(e.toString()); } catch (TransformerFactoryConfigurationError e) { logger.warning("doPost: "+e); throw new IOException(e.toString()); } catch (TransformerException e) { logger.warning("doPost: "+e); throw new IOException(e.toString()); } catch (SAXException e) { logger.warning("doPost: "+e); throw new IOException(e.toString()); } catch (ParserConfigurationException e) { logger.warning("doPost: "+e); throw new IOException(e.toString()); } } @Override public void handlePlayRequest(GameJoinRequest gjreq, GameJoinResponse gjresp, GameInstance gi, GameInstanceSlot gs, GameServer server, GameClient gc, Account account) { // Register client with the server. // Post // <login> // <clientId>...</clientId> // <conversationId>...</conversationId> // <playerName>...</playerName> // <clientVersion>1</clientVersion> // <clientType>AndroidDevclient</clientType> // <gameTag>...</gameTag> // </login> // to baseUrl/rpc/login. if (gjresp.getPlayData()==null) gjresp.setPlayData(new HashMap<String,Object>()); try { DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance(); dbf.setNamespaceAware(false); DocumentBuilder db = dbf.newDocumentBuilder(); Document doc = db.newDocument(); doc.appendChild(doc.createElement("login")); // clientId is meant to be durable across retries/restarts - our clientId is probably OK addElement(doc, "clientId", gc.getId()); // conversationId is limited to 20 chars. // each log should be a new conversationId. gs.setClientSharedSecret(JoinUtils.createClientSharedSecret(20*4)); addElement(doc, "conversationId", gs.getClientSharedSecret()); gjresp.getPlayData().put("conversationId", gs.getClientSharedSecret()); // TODO client role? addElement(doc, "clientType", "AndroidDevclient"); addElement(doc, "clientVersion", "1"); String nickname = gs.getNickname(); if (nickname==null) nickname = "Anonymous"; gjresp.setNickname(nickname); addElement(doc, "playerName", nickname); addElement(doc, "gameTag", getGameTag(gi)); if (gi.getBaseUrl()!=null && !gi.getBaseUrl().equals(server.getBaseUrl())) logger.warning("GameInstance baseUrl does not match server baseUrl ("+gi.getBaseUrl()+" vs "+server.getBaseUrl()+") for "+gi); String url = server.getBaseUrl()+"/rpc/login"; doc = doPost(url, doc); String replyStatus = getElement(doc, "status"); if (!"OK".equals(replyStatus)) { if ("FAILED".equals(replyStatus) || "GAME_NOT_FOUND".equals(replyStatus)) { logger.warning("Retryable error logging into "+url+": reply"); // worth retrying JoinUtils.setTryLater(gjresp); return; } logger.warning("Terminal error logging into "+url+": reply"); gjresp.setStatus(GameJoinResponseStatus.ERROR_INTERNAL); gjresp.setMessage("Unable to join game ("+getElement(doc, "message")+")"); return; } gjresp.getPlayData().put("gameId", getElement(doc, "gameId")); gjresp.getPlayData().put("gameStatus", getElement(doc, "gameStatus")); } catch (IOException e) { logger.log(Level.WARNING, "Problem doing login with URL based on "+server.getBaseUrl(), e); JoinUtils.setTryLater(gjresp); return; } catch (ParserConfigurationException e) { logger.log(Level.WARNING, "Problem with XML parser", e); JoinUtils.setError(gjresp, GameJoinResponseStatus.ERROR_INTERNAL, "Problem with ExplodingPlaces protocol"); return; } // generate client play URL. (Note conversationId is a required parameter) // baseUrl/messages gjresp.setPlayUrl(server.getBaseUrl()+"/rpc/"); gjresp.setStatus(GameJoinResponseStatus.OK); } private String getElement(Document doc, String tag) { NodeList els = doc.getDocumentElement().getElementsByTagName(tag); if (els.getLength()==0) return null; return ((Element)els.item(0)).getTextContent(); } private void addElement(Document doc, String tag, String value) { Element el = doc.createElement(tag); doc.getDocumentElement().appendChild(el); el.appendChild(doc.createTextNode(value)); } /* (non-Javadoc) * @see uk.ac.horizon.ug.lobby.server.ServerProtocol#handleGameInstanceActiveFromReady(uk.ac.horizon.ug.lobby.model.GameInstance, uk.ac.horizon.ug.lobby.model.GameInstanceFactory, uk.ac.horizon.ug.lobby.model.GameServer, javax.persistence.EntityManager) */ @Override public void handleGameInstanceActiveFromReady(GameInstance gi, GameInstanceFactory factory, GameServer server) throws ConfigurationException, IOException { // TODO audit doPost(server.getBaseUrl()+"/orchestration/play.html?gameID="+getGameId(gi), null, false); } /* (non-Javadoc) * @see uk.ac.horizon.ug.lobby.server.ServerProtocol#handleGameInstanceEnd(uk.ac.horizon.ug.lobby.model.GameInstance, uk.ac.horizon.ug.lobby.model.GameInstanceFactory, uk.ac.horizon.ug.lobby.model.GameServer, javax.persistence.EntityManager) */ @Override public void handleGameInstanceEnd(GameInstance gi, GameInstanceFactory factory, GameServer server) throws ConfigurationException, IOException { // TODO audit doPost(server.getBaseUrl()+"/orchestration/stop.html?gameID="+getGameId(gi), null, false); } /* (non-Javadoc) * @see uk.ac.horizon.ug.lobby.server.ServerProtocol#handleGameInstanceEndingFromActive(uk.ac.horizon.ug.lobby.model.GameInstance, uk.ac.horizon.ug.lobby.model.GameInstanceFactory, uk.ac.horizon.ug.lobby.model.GameServer, javax.persistence.EntityManager) */ @Override public void handleGameInstanceEndingFromActive(GameInstance gi, GameInstanceFactory factory, GameServer server) throws ConfigurationException, IOException { // TODO audit doPost(server.getBaseUrl()+"/orchestration/finish.html?gameID="+getGameId(gi), null, false); } /* (non-Javadoc) * @see uk.ac.horizon.ug.lobby.server.ServerProtocol#validate(uk.ac.horizon.ug.lobby.model.GameInstanceFactory, uk.ac.horizon.ug.lobby.model.GameServer) */ @Override public void validate(GameInstanceFactory factory, GameServer server) throws ConfigurationException { getServerConfig(factory); } public static final String CONTENT_GROUP = "contentGroup"; private JSONObject getServerConfig(GameInstanceFactory factory) throws ConfigurationException { if (factory.getServerConfigJson()==null) { throw new ConfigurationException("serverConfigJson undefined"); } try { JSONObject o = new JSONObject(factory.getServerConfigJson()); if (!o.has(CONTENT_GROUP)) throw new ConfigurationException("Config must include "+CONTENT_GROUP+" property"); JSONObject contentGroup = o.getJSONObject(CONTENT_GROUP); return o; } catch (JSONException e) { throw new ConfigurationException(e.toString()+" for "+factory.getServerConfigJson()); } } private JSONObject getContentGroupConfig(JSONObject config) throws ConfigurationException { try { return config.getJSONObject(CONTENT_GROUP); } catch (JSONException e) { throw new ConfigurationException("Config must include "+CONTENT_GROUP+" property with object value"); } } /* (non-Javadoc) * @see uk.ac.horizon.ug.lobby.server.ServerProtocol#handleGameInstancePreparingFromPlanned(uk.ac.horizon.ug.lobby.model.GameInstance, uk.ac.horizon.ug.lobby.model.GameInstanceFactory, uk.ac.horizon.ug.lobby.model.GameServer, javax.persistence.EntityManager) */ @Override public void handleGameInstancePreparingFromPlanned(GameInstance gi, GameInstanceFactory factory, GameServer server) throws ConfigurationException, IOException { // identify appropriate ContentGroup // serverConfigJson must include 'contentGroup':{...} // where inner properties filter ContentGroups, e.g. name, location, version JSONObject contentGroupConfig = getContentGroupConfig(getServerConfig(factory)); String getContentGroupsUrl = server.getBaseUrl()+"/orchestration/content_group_list"; // e.g. // <array size="2" elementjavatype="java.lang.Object"> // <item> // <ContentGroup package="uk.ac.horizon.ug.exploding.db"> // <ID>CG500</ID> // <name>gameState.xml</name> // <version>1.0</version> // <location>Woolwich</location> // <startYear>1900</startYear> // <endYear>2020</endYear> // </ContentGroup> // </item> // ... Document doc = doPost(getContentGroupsUrl, null); String contentGroupId = null; Element rootEl = doc.getDocumentElement(); NodeList items= rootEl.getElementsByTagName("item"); nextitem: for (int ii=0; ii<items.getLength(); ii++) { Element itemEl = (Element)items.item(ii); NodeList cgs = rootEl.getElementsByTagName("ContentGroup"); for (int cgi=0; cgi<cgs.getLength(); cgi++) { Element cgEl = (Element)cgs.item(cgi); NodeList cns = cgEl.getChildNodes(); String id = null; for (int cni=0; cni<cns.getLength(); cni++) { Node cn = cns.item(cni); if (cn instanceof Element) { Element cnEl = (Element)cn; String name = cnEl.getTagName(); String value = cnEl.getTextContent(); if (contentGroupConfig.has(name)) { try { if (!value.equals(contentGroupConfig.get(name))) // mis-match continue nextitem; } catch (JSONException je) {/*shouldn't happen*/} } if (name.equals("ID")) id = value; } } // satisfied any constraints if (id!=null) { contentGroupId = id; break nextitem; } } } if (contentGroupId==null) throw new ConfigurationException("Server has no ContentGroup matching "+contentGroupConfig.toString()); // generate and store Game Tag (for use with login) String name = gi.getTitle()+"/"+(new Date(gi.getStartTime())); String gameTag = gi.getTitle()+"/"+(new Date(gi.getStartTime()))+"/"+UUID.randomUUID().toString(); // create game using orchestration form // POST with url-encoded contentGroupID, name and tag to orchestration/create.html String createUrl = server.getBaseUrl()+"/orchestration/lobby_create?"+ "contentGroupID="+URLEncoder.encode(contentGroupId, "UTF-8")+ "&name="+URLEncoder.encode(name, "UTF-8")+ "&tag="+URLEncoder.encode(gameTag, "UTF-8"); // TODO audit doc = doPost(createUrl, null); // returns something like: // <?xml version="1.0"?> // <Game package="uk.ac.horizon.ug.exploding.db"> // <ID>GA514</ID> // <contentGroupID>CG500</contentGroupID> // <name>name</name> // <tag>tag</tag> // <timeCreated>1283957181008</timeCreated> // <gameTimeID>GT514</gameTimeID> // <state>NOT_STARTED</state> // </Game> String gameId = getElement(doc, "ID"); if (gameId==null) throw new IOException("No ID in return from lobby_create"); // store generated Game ID EntityManager em = EMF.get().createEntityManager(); EntityTransaction et = em.getTransaction(); et.begin(); try { JSONObject config = new JSONObject(); config.put(GAME_ID, gameId); config.put(GAME_TAG, gameTag); GameInstance ngi = em.find(GameInstance.class, gi.getKey()); ngi.setServerConfigJson(config.toString()); em.merge(ngi); et.commit(); // don't fiddle the cached value } catch (Exception e) { throw new IOException("Problem saving gameId ("+gameId+"): "+e); } finally { if (et.isActive()) et.rollback(); em.close(); } } public static final String GAME_ID = "gameId"; public static final String GAME_TAG = "gameTag"; private String getGameId(GameInstance gi) throws IOException { if (gi.getServerConfigJson()==null) { throw new IOException("instance serverConfigJson undefined"); } try { JSONObject o = new JSONObject(gi.getServerConfigJson()); return o.getString(GAME_ID); } catch (JSONException e) { throw new IOException(e.toString()+" for "+gi.getServerConfigJson()); } } private String getGameTag(GameInstance gi) throws IOException { if (gi.getServerConfigJson()==null) { throw new IOException("instance serverConfigJson undefined"); } try { JSONObject o = new JSONObject(gi.getServerConfigJson()); return o.getString(GAME_TAG); } catch (JSONException e) { throw new IOException(e.toString()+" for "+gi.getServerConfigJson()); } } /* (non-Javadoc) * @see uk.ac.horizon.ug.lobby.server.ServerProtocol#handleGameInstanceReadyFromPreparing(uk.ac.horizon.ug.lobby.model.GameInstance, uk.ac.horizon.ug.lobby.model.GameInstanceFactory, uk.ac.horizon.ug.lobby.model.GameServer, javax.persistence.EntityManager) */ @Override public void handleGameInstanceReadyFromPreparing(GameInstance gi, GameInstanceFactory factory, GameServer server) { // no-op } }