// // $Id$ // // Vilya library - tools for developing networked games // Copyright (C) 2002-2007 Three Rings Design, Inc., All Rights Reserved // http://www.threerings.net/code/vilya/ // // This library is free software; you can redistribute it and/or modify it // under the terms of the GNU Lesser General Public License as published // by the Free Software Foundation; either version 2.1 of the License, or // (at your option) any later version. // // This library 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 // Lesser General Public License for more details. // // You should have received a copy of the GNU Lesser General Public // License along with this library; if not, write to the Free Software // Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA package com.threerings.parlor.server; import com.samskivert.util.IntMap; import com.samskivert.util.IntMaps; import com.samskivert.util.ListUtil; import com.threerings.util.Name; import com.threerings.presents.data.ClientObject; import com.threerings.presents.dobj.AttributeChangeListener; import com.threerings.presents.dobj.AttributeChangedEvent; import com.threerings.presents.dobj.ChangeListener; import com.threerings.presents.dobj.DObject; import com.threerings.presents.dobj.NamedEvent; import com.threerings.presents.dobj.ObjectAddedEvent; import com.threerings.presents.dobj.ObjectDeathListener; import com.threerings.presents.dobj.ObjectDestroyedEvent; import com.threerings.presents.dobj.ObjectRemovedEvent; import com.threerings.presents.dobj.OidListListener; import com.threerings.presents.dobj.RootDObjectManager; import com.threerings.presents.server.InvocationException; import com.threerings.presents.server.InvocationManager; import com.threerings.crowd.data.BodyObject; import com.threerings.crowd.data.OccupantInfo; import com.threerings.crowd.data.PlaceObject; import com.threerings.crowd.server.PlaceRegistry; import com.threerings.parlor.client.TableService; import com.threerings.parlor.data.ParlorCodes; import com.threerings.parlor.data.Table; import com.threerings.parlor.data.TableConfig; import com.threerings.parlor.data.TableLobbyObject; import com.threerings.parlor.game.data.GameConfig; import com.threerings.parlor.game.data.GameObject; import com.threerings.parlor.game.server.GameManager; import static com.threerings.parlor.Log.log; /** * A table manager can be used by a place manager (or other entity) to take care of the management * of a table matchmaking service on a particular distributed object. */ public class TableManager implements ParlorCodes, TableProvider { /** * Creates a table manager that will manage tables in the supplied distributed object (which * must implement {@link TableLobbyObject}. */ public TableManager (RootDObjectManager omgr, InvocationManager invmgr, PlaceRegistry plreg, DObject tableObject) { _omgr = omgr; _invmgr = invmgr; _plreg = plreg; // set up our object references _tlobj = (TableLobbyObject)tableObject; _tlobj.setTableService(invmgr.registerDispatcher(new TableDispatcher(this))); _dobj = tableObject; // if our table is in a "place" add ourselves as a listener so that we can tell if a user // leaves the place without leaving their table if (_dobj instanceof PlaceObject) { _dobj.addListener(_placeListener); } } /** * Returns the number of tables being managed currently. */ public int getTableCount () { return _tables.size(); } /** * This must be called when the table manager is no longer needed. */ public void shutdown () { if (_tlobj != null) { _invmgr.clearDispatcher(_tlobj.getTableService()); _tlobj.setTableService(null); } if (_dobj instanceof PlaceObject) { _dobj.removeListener(_placeListener); } _tlobj = null; _dobj = null; } /** * Set the subclass of Table that this instance should generate. */ public void setTableClass (Class tableClass) { _tableClass = tableClass; } /** * Allow a player in the first position of a table to boot others. */ public void setAllowBooting (boolean allowBooting) { _allowBooting = allowBooting; } /** * Creates a table for the specified creator and returns said table. * * @exception InvocationException thrown if the table could not be created for any reason. */ public Table createTable (BodyObject creator, TableConfig tableConfig, GameConfig config) throws InvocationException { // make sure the caller is not already in a table if (_boidMap.containsKey(creator.getOid())) { throw new InvocationException(ALREADY_AT_TABLE); } // create a brand spanking new table Table table; try { table = _tableClass.newInstance(); } catch (Exception e) { log.warning("Table.newInstance() failed [class=" + _tableClass + ", e=" + e + "]."); throw new InvocationException(INTERNAL_ERROR); } table.init(_dobj.getOid(), tableConfig, config); if (table.bodyOids != null && table.bodyOids.length > 0) { // stick the creator into the first non-AI position int cpos = (config.ais == null) ? 0 : config.ais.length; String error = table.setPlayer(cpos, creator); if (error != null) { log.warning("Unable to add creator to position zero of table!? [table=" + table + ", creator=" + creator + ", error=" + error + "]."); // bail out now and abort the table creation process throw new InvocationException(error); } // make a mapping from the creator to this table notePlayerAdded(table, creator); } // stick the table into our tables tables _tables.put(table.tableId, table); // if the table has only one seat, start the game immediately if (table.shouldBeStarted()) { createGame(table); } // wire our table into our bits tableCreated(table); return table; } // from interface TableProvider public void createTable (ClientObject caller, TableConfig tableConfig, GameConfig config, TableService.ResultListener listener) throws InvocationException { BodyObject creator = (BodyObject)caller; // if we're managing tables in a place, make sure the creator is an occupant of the place // in which they are requesting to create a table if (_dobj instanceof PlaceObject && !((PlaceObject)_dobj).occupants.contains(creator.getOid())) { log.warning("Requested to create a table in a place not occupied by the creator " + "[creator=" + creator + ", ploid=" + _dobj.getOid() + "]."); throw new InvocationException(INTERNAL_ERROR); } // if createTable() returns, we're good to go listener.requestProcessed(createTable(creator, tableConfig, config).tableId); } // from interface TableProvider public void joinTable (ClientObject caller, int tableId, int position, TableService.InvocationListener listener) throws InvocationException { BodyObject joiner = (BodyObject)caller; // make sure the caller is not already in a table if (_boidMap.containsKey(joiner.getOid())) { throw new InvocationException(ALREADY_AT_TABLE); } // look the table up Table table = _tables.get(tableId); if (table == null) { throw new InvocationException(NO_SUCH_TABLE); } // request that the user be added to the table at that position String error = table.setPlayer(position, joiner); // if that failed, report the error if (error != null) { throw new InvocationException(error); } // if the table is sufficiently full, start the game automatically if (table.shouldBeStarted()) { createGame(table); } else { // make a mapping from this player to this table notePlayerAdded(table, joiner); } // update the table in the lobby updateTable(table); // there is normally no success response. the client will see // themselves show up in the table that they joined } // from interface TableProvider public void leaveTable (ClientObject caller, int tableId, TableService.InvocationListener listener) throws InvocationException { BodyObject leaver = (BodyObject)caller; // look the table up Table table = _tables.get(tableId); if (table == null) { throw new InvocationException(NO_SUCH_TABLE); } // if the table is in play, the user is not allowed to leave (he must leave the game) if (table.inPlay()) { throw new InvocationException(GAME_ALREADY_STARTED); } // request that the user be removed from the table if (!table.clearPlayer(leaver.getVisibleName())) { throw new InvocationException(NOT_AT_TABLE); } // remove the mapping from this user to the table if (null == notePlayerRemoved(leaver.getOid(), leaver)) { log.warning("No body to table mapping to clear? [leaver=" + leaver.who() + ", table=" + table + "]."); } // either update or delete the table depending on whether or not we just removed the last // player if (table.isEmpty()) { purgeTable(table); } else { updateTable(table); } // there is normally no success response. the client will see // themselves removed from the table they just left } // from interface TableProvider public void startTableNow (ClientObject caller, int tableId, TableService.InvocationListener listener) throws InvocationException { BodyObject starter = (BodyObject)caller; Table table = _tables.get(tableId); if (table == null) { throw new InvocationException(NO_SUCH_TABLE); } else if (starter.getOid() != table.bodyOids[0]) { throw new InvocationException(MUST_BE_CREATOR); } else if (!table.mayBeStarted()) { throw new InvocationException(INTERNAL_ERROR); } createGame(table); } // from interface TableProvider public void bootPlayer (ClientObject caller, int tableId, Name target, TableService.InvocationListener listener) throws InvocationException { BodyObject booter = (BodyObject) caller; Table table = _tables.get(tableId); if (table == null) { throw new InvocationException(NO_SUCH_TABLE); } else if ( ! _allowBooting) { throw new InvocationException(INTERNAL_ERROR); } int position = ListUtil.indexOf(table.players, target); if (position < 0) { throw new InvocationException(NOT_AT_TABLE); } else if (booter.getOid() != table.bodyOids[0]) { throw new InvocationException(MUST_BE_CREATOR); } else if (booter.getOid() == table.bodyOids[position]) { throw new InvocationException(NO_SELF_BOOT); } // Remember to keep him banned table.addBannedUser(target); // Remove the player from the table if (notePlayerRemoved(table.bodyOids[position]) == null) { log.warning("No body to table mapping to clear? [position=" + position + ", table=" + table + "]."); } table.clearPlayerPos(position); updateTable(table); } /** * Publishes a newly created table to the lobby object. Can also be overridden by custom * managers that want to react to table creation. */ protected void tableCreated (Table table) { // publish the table in the lobby object if desired if (shouldPublish(table)) { _tlobj.addToTables(table); } } /** * Removes the table from all of our internal tables and from its lobby's distributed object. */ protected void purgeTable (Table table) { // remove the table from our tables table _tables.remove(table.tableId); // clear out all matching entries in the boid map if (table.bodyOids != null) { for (int bodyOid : table.bodyOids) { if (bodyOid != 0) { notePlayerRemoved(bodyOid); } } } // remove the mapping by gameOid Table removed = _goidMap.remove(table.gameOid); // no-op if gameOid == 0 // remove the table from the lobby object (the table may not have been published if a // derived class decided it was not worth publishing, see shouldPublish()) if (_tlobj.getTables().containsKey(table.tableId)) { _tlobj.removeFromTables(table.tableId); } // remove the listener too so we do not get a request later on to update the occupants or // unmap this table if (removed != null) { DObject gameObj = _omgr.getObject(table.gameOid); if (gameObj != null) { gameObj.removeListener(_gameListener); } } } /** * Called when a player is added to a table to set up our mappings. */ protected void notePlayerAdded (Table table, BodyObject body) { _boidMap.put(body.getOid(), table); body.addListener(_userListener); } /** * Called when a player leaves the room and we're not sure if the user is still online. */ final protected Table notePlayerRemoved (int playerOid) { return notePlayerRemoved(playerOid, (BodyObject)_omgr.getObject(playerOid)); } /** * Called when a player leaves a table to clear our mappings. * @param body will be non-null if the user is still online. */ protected Table notePlayerRemoved (int playerOid, BodyObject body) { if (body != null) { body.removeListener(_userListener); } return _boidMap.remove(playerOid); } /** * Called when we're ready to create a game (either an invitation has been accepted or a table * is ready to start. If there is a problem creating the game manager, it should be reported in * the logs. * * @return the oid of the newly-created game. */ protected int createGame (final Table table) throws InvocationException { try { GameManager gmgr = createGameManager(createConfig(table)); GameObject gobj = (GameObject)gmgr.getPlaceObject(); gameCreated(table, gobj, gmgr); return gobj.getOid(); } catch (Throwable t) { log.warning("Failed to create manager for game [config=" + table.config + "]: " + t); throw new InvocationException(INTERNAL_ERROR); } } /** * This method should validate that the (client provided) configuration in the supplied {@link * Table} object is valid and fill in any extra information that is the purview of the server. */ protected GameConfig createConfig (Table table) { // fill the players array into the game config table.config.players = table.getPlayers(); // we just trust the rest by default, yay! return table.config; } /** * Creates a {@link GameManager} using the supplied config. Used by {@link #createGame}, but * extracted into a method to allow customization of this process. */ protected GameManager createGameManager (GameConfig config) throws InstantiationException, InvocationException { return (GameManager)_plreg.createPlace(config); } /** * Called when our game has been created, we take this opportunity to clean up the table and * transition it to "in play" mode. */ protected void gameCreated (Table table, GameObject gameobj, GameManager gmgr) { // update the table with the newly created game object table.gameOid = gameobj.getOid(); // add it to the gameOid map _goidMap.put(table.gameOid, table); // configure the privacy of the game gameobj.setIsPrivate(table.tconfig.privateTable); if (table.bodyOids != null) { // clear the player to table mappings as this game is underway for (int bodyOid : table.bodyOids) { if (bodyOid != 0) { notePlayerRemoved(bodyOid); } } } // add an object death listener to unmap the table when the game finally goes away gameobj.addListener(_gameListener); // and then update the lobby object that contains the table if (shouldPublish(table)) { updateTable(table); } else if (_tlobj.getTables().containsKey(table.tableId)) { // or remove it if the table is no longer "interesting" _tlobj.removeFromTables(table.tableId); } } /** * Called when a game created from a table managed by this table manager was destroyed. We * remove the associated table. */ protected void unmapTable (int gameOid) { // if we've been shutdown, then we've got nothing to worry about if (_tlobj == null) { return; } Table table = _goidMap.get(gameOid); if (table != null) { purgeTable(table); } else { log.warning("Requested to unmap table that wasn't mapped [gameOid=" + gameOid + "]."); } } /** * Called when the occupants in a game change: publishes new info. */ protected void updateOccupants (int gameOid) { // if we've been shutdown, then we've got nothing to worry about if (_tlobj == null) { return; } Table table = _goidMap.get(gameOid); if (table == null) { log.warning("Unable to find table for running game [gameOid=" + gameOid + "]."); return; } // update this table's occupants information and update the table GameObject gameObj = (GameObject)_omgr.getObject(gameOid); table.updateOccupants(gameObj); updateTable(table); } /** * Called when a body is known to have left either the room that contains our tables or logged * off of the server. */ protected void bodyLeft (int bodyOid) { // look up the table to which this player is mapped Table pender = notePlayerRemoved(bodyOid); if (pender == null) { return; } // remove this player from the table if (!pender.clearPlayerByOid(bodyOid)) { log.warning("Attempt to remove body from mapped table failed [table=" + pender + ", bodyOid=" + bodyOid + "]."); return; } // update or delete the table depending on whether or not we just removed the last player if (pender.isEmpty()) { purgeTable(pender); } else { updateTable(pender); } } /** * Derived classes can override this method to filter certain tables from being published in * the lobby object. Such tables are probably unwatchable and unjoinable and thus just take up * space in the lobby object for no good reason. This will be checked when the table is first * created and when the table's game transitions to in-play. */ protected boolean shouldPublish (Table table) { return true; // we publish all tables by default } protected void updateTable (Table table) { // the table may not have been published, see shouldPublish() if (_tlobj.getTables().containsKey(table.tableId)) { _tlobj.updateTables(table); } } /** Listens to all games and updates the table objects as necessary. */ protected class GameListener implements ObjectDeathListener, OidListListener { // from ObjectDeathListener public void objectDestroyed (ObjectDestroyedEvent event) { unmapTable(event.getTargetOid()); } // from OidListListener public void objectAdded (ObjectAddedEvent event) { maybeCheckOccupants(event); } // from OidListListener public void objectRemoved (ObjectRemovedEvent event) { maybeCheckOccupants(event); } /** Check to see if the set event causes us to update the table. */ protected void maybeCheckOccupants (NamedEvent event) { if (GameObject.OCCUPANTS.equals(event.getName())) { updateOccupants(event.getTargetOid()); } } } // END: class GameDeathListener /** Listens to all users who have joined a table, takes care of removing them * as necessary. */ protected class UserListener implements AttributeChangeListener, ObjectDeathListener { // from AttributeChangeListener public void attributeChanged (AttributeChangedEvent event) { // Remove folks from tables when they disconnect if (BodyObject.STATUS.equals(event.getName())) { if (event.getByteValue() == OccupantInfo.DISCONNECTED) { bodyLeft(event.getTargetOid()); } } } // from ObjectDeathListener public void objectDestroyed (ObjectDestroyedEvent event) { // Remove folks from tables when they die bodyLeft(event.getTargetOid()); } } // END: class UserListener /** Listens for players leaving the place that contains our tables. */ protected ChangeListener _placeListener = new OidListListener() { public void objectAdded (ObjectAddedEvent event) { // nothing doing } public void objectRemoved (ObjectRemovedEvent event) { // if an occupant departed, see if they are in a pending table if (event.getName().equals(PlaceObject.OCCUPANTS)) { bodyLeft(event.getOid()); } } }; /** A reference to the distributed object in which we're managing tables. */ protected DObject _dobj; /** A reference to our distributed object casted to a table lobby object. */ protected TableLobbyObject _tlobj; /** The class of table we instantiate. */ protected Class _tableClass = Table.class; /** The table of pending tables. */ protected IntMap _tables = IntMaps.newHashIntMap(); /** A mapping from body oid to table. */ protected IntMap
_boidMap = IntMaps.newHashIntMap(); /** Once a game starts, a mapping from gameOid to table. */ protected IntMap
_goidMap = IntMaps.newHashIntMap(); /** A listener that prunes tables after the game dies. */ protected ChangeListener _gameListener = new GameListener(); /** A listener that removes users from tables when they're no longer able to play. */ protected ChangeListener _userListener = new UserListener(); /** Whether or not tables should support booting. */ protected boolean _allowBooting = false; protected RootDObjectManager _omgr; protected InvocationManager _invmgr; protected PlaceRegistry _plreg; }