diff --git a/src/java/com/threerings/parlor/rating/server/RatingManagerDelegate.java b/src/java/com/threerings/parlor/rating/server/RatingManagerDelegate.java new file mode 100644 index 00000000..b192b21c --- /dev/null +++ b/src/java/com/threerings/parlor/rating/server/RatingManagerDelegate.java @@ -0,0 +1,334 @@ +// +// $Id: PuzzleManagerDelegate.java 209 2007-02-24 00:37:33Z mdb $ +// +// 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.rating.server; + +import static com.threerings.parlor.Log.log; + +import java.util.List; +import java.util.logging.Level; + +import com.samskivert.io.PersistenceException; + +import com.samskivert.util.HashIntMap; +import com.samskivert.util.IntMap; +import com.samskivert.util.Invoker; + +import com.threerings.crowd.data.PlaceObject; +import com.threerings.crowd.server.CrowdServer; + +import com.threerings.media.util.MathUtil; +import com.threerings.parlor.game.data.GameObject; +import com.threerings.parlor.game.server.GameManager; +import com.threerings.parlor.game.server.GameManagerDelegate; + +import com.threerings.parlor.rating.server.persist.RatingRecord; +import com.threerings.parlor.rating.server.persist.RatingRepository; +import com.threerings.util.Name; + +/** + * Rates players after each game and handles persisting the results. + */ +public abstract class RatingManagerDelegate + extends GameManagerDelegate +{ + /** + * The minimum rating value. + */ + public static final int MINIMUM_RATING = 1000; + + /** + * The default rating value. + */ + public static final int DEFAULT_RATING = 1200; + + /** + * The maximum rating value. + */ + public static final int MAXIMUM_RATING = 3000; + + /** + * Constructs a rating manager delegate. + */ + public RatingManagerDelegate (GameManager gmgr) + { + super(gmgr); + _gmgr = gmgr; + + _repo = getRatingRepository(); + } + + @Override + public void didStartup (PlaceObject plobj) + { + super.didStartup(plobj); + + _gobj = (GameObject) plobj; + } + + @Override + public void gameWillStart () + { + super.gameWillStart(); + + // note the time at which we started + _startStamp = (int) (System.currentTimeMillis() / 1000); + + // read ratings from persistent store + CrowdServer.invoker.postUnit(new Invoker.Unit() { + public boolean invoke () { + int gameId = _gmgr.getGameConfig().getGameId(); + + // enumerate our players + Name[] players = new Name[_gmgr.getPlayerCount()]; + for (int ii = 0; ii < players.length; ii ++) { + players[ii] = _gmgr.getPlayerName(ii); + } + + try { + // fetch their previous ratings for this game + List records = _repo.getRatings(gameId, players); + + // and make it easy to look them up by their id + IntMap map = new HashIntMap(); + for (RatingRecord record : records) { + map.put(record.playerId, record); + } + + // now build the array we keep around until the end of the game + _ratings = new Rating[_gmgr.getPlayerCount()]; + for (int ii = 0; ii < _ratings.length; ii ++) { + RatingRecord record = map.get(_repo.mapNameToId(_gmgr.getPlayerName(ii))); + // if the player had no previous record, initiate them at default values + _ratings[ii] = record != null ? + new Rating(record.rating, record.experience) : new Rating(); + } + + return true; + + } catch (PersistenceException pe) { + log.log(Level.WARNING, + "Failed to load ratings [where=" + where() + ", id=" + gameId + "].", pe); + return false; + } + }; + + public void handleResult () { + // nothing to do here? + } + }); + } + + @Override + public void gameDidEnd () + { + super.gameDidEnd(); + + // note the duration of the game (in seconds) + int gameSecs = (int) (System.currentTimeMillis()/1000 - _startStamp); + + // update ratings if the game ran long enough, and the persistence code finished reading + if (_ratings == null || gameSecs < minimumRatedDuration()) { + return; + } + + // compute the updated ratings + int[] nratings = new int[_ratings.length]; + for (int ii = 0; ii < _ratings.length; ii ++) { + nratings[ii] = computeRating(ii); + } + + // and store them + boolean modified = false; + for (int ii = 0; ii < _ratings.length; ii++) { + // skip this rating if we weren't able to compute a value + if (nratings[ii] < 0) { + continue; + } + _ratings[ii].rating = nratings[ii]; + _ratings[ii].experience ++; + modified = true; + } + + if (!modified) { + return; + } + + // finally persist the result if necessary + final int gameId = _gmgr.getGameConfig().getGameId(); + CrowdServer.invoker.postUnit(new Invoker.Unit() { + public boolean invoke () { + try { + for (int ii = 0; ii < _ratings.length; ii ++) { + // for each player, update or create the rating record + // TODO: reorganize things so this can be a single db request? + _repo.setRating( + gameId, _gmgr.getPlayerName(ii), + _ratings[ii].rating, _ratings[ii].experience); + } + return true; + + } catch (PersistenceException pe) { + log.log(Level.WARNING, + "Failed to load ratings [where=" + where() + ", id=" + gameId + "].", pe); + return false; + } + }; + + public void handleResult () { + // let subclasses store away the new ratings if they so desire + for (int ii = 0; ii < _ratings.length; ii ++) { + updateRatingInMemory(gameId, _gmgr.getPlayerName(ii), _ratings[ii]); + } + } + }); + } + + /** + * Return the minimum time (in seconds) a game must've lasted for it to count towards rating. + */ + protected abstract int minimumRatedDuration (); + + /** + * Return a reference to the {@link RatingRepository} instance we should use to persist. + */ + protected abstract RatingRepository getRatingRepository (); + + /** + * Optionally store update ratings in memory e.g. in the user object. + * + * This method is called on the dobj thread. + */ + protected abstract void updateRatingInMemory (int gameId, Name playerName, Rating rating); + + /** + * Computes a player's updated rating using a modified version of the FIDE/ELO system. + * The rating adjustment is computed for the player versus each opponent individually and + * these adjustments are summed and scaled by one over the number of opponents to create + * the final rating adjustment, which is then added to the player's previous rating and + * bounded to the rating range. Note: provisional players (those with experience of + * less than 20) will be treated as having, at most, the default rating when used as an + * opponent in calculatons for a non-provisional player. + * + * This method may be overriden to change the rating algorithm. + * + * @return the player's updated rating or -1 if none of the opponents could be applicably + * rated against this player. + */ + protected int computeRating (int pidx) + { + float totDeltaR = 0; // the total delta rating + int opponents = 0; + + for (int ii = 0; ii < _ratings.length; ii++) { + // we don't care how we did against ourselves... + if (pidx == ii) { + continue; + } + + // if we are non-provisional, and the opponent is provisional, we + // max the opponent out at the default rating to avoid potentially + // inflating a real rating with one that has a lot of uncertainty + int opprat = _ratings[ii].rating; + if (_ratings[pidx].experience >= 20 && _ratings[ii].experience < 20) { + opprat = Math.min(opprat, DEFAULT_RATING); + } + + // calculate K, the score multiplier constant + int K; + if (_ratings[pidx].experience < 20) { + K = 64; + } else if (_ratings[pidx].rating < 2100) { + K = 32; // experience >= 20 + } else if (_ratings[pidx].rating < 2400) { + K = 24; // experience >= 20 && rating >= 2100 + } else { + K = 16; // experience >= 20 && rating >= 2400 + } + + // calculate W, the win value representing the actual result of the game + float W = _gobj.isDraw() ? 0.5f : _gobj.isWinner(pidx) ? 1f : 0f; + // calculate We, the expected win value given the players' respective ratings + float dR = opprat - _ratings[pidx].rating; + float We = 1.0f / (float)(Math.pow(10.0f, (dR / 400.0f)) + 1); + + // update the total rating adjustment with the value for this opponent + totDeltaR += K * (W - We); + opponents++; + } + + // if we have no valid opponents, we cannot compute a rating; + if (opponents == 0) { + return -1; + } + + // return the updated and clamped rating + int nrat = Math.round(_ratings[pidx].rating + totDeltaR/opponents); + return MathUtil.bound(MINIMUM_RATING, nrat, MAXIMUM_RATING); + } + + /** + * Simply encapsulates the rating/experience tuple representing a player's rating for a game. + */ + protected static class Rating + { + /** A player's rating for a game. */ + public int rating; + + /** The number of times a player's played a game. */ + public int experience; + + /** + * Sets up a new {@link Rating} object with default values. + */ + public Rating () + { + super(); + this.rating = DEFAULT_RATING; + this.experience = 0; + } + + /** + * Sets up a new {@link Rating} object with the given values. + */ + public Rating (int rating, int experience) + { + super(); + this.rating = rating; + this.experience = experience; + } + } + + /** An appropriately casted reference to our GameManager. */ + protected GameManager _gmgr; + + /** An appropriately casted reference to our GameObject. */ + protected GameObject _gobj; + + /** The RatingRepository that holds our data. */ + protected RatingRepository _repo; + + /** The ratings for each player as they were at the beginning of the game. */ + protected Rating[] _ratings; + + /** A timestamp set at the beginning of the game, used to calculate its duration. */ + protected int _startStamp; +} diff --git a/src/java/com/threerings/parlor/rating/server/persist/RatingRecord.java b/src/java/com/threerings/parlor/rating/server/persist/RatingRecord.java new file mode 100644 index 00000000..47225841 --- /dev/null +++ b/src/java/com/threerings/parlor/rating/server/persist/RatingRecord.java @@ -0,0 +1,113 @@ +// +// $Id: PuzzleManagerDelegate.java 209 2007-02-24 00:37:33Z mdb $ +// +// 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.rating.server.persist; + +import com.samskivert.jdbc.depot.Key; +import com.samskivert.jdbc.depot.PersistentRecord; +import com.samskivert.jdbc.depot.annotation.Entity; +import com.samskivert.jdbc.depot.annotation.Id; +import com.samskivert.jdbc.depot.expression.ColumnExp; + +@Entity +public class RatingRecord extends PersistentRecord +{ + // AUTO-GENERATED: FIELDS START + /** The column identifier for the {@link #gameId} field. */ + public static final String GAME_ID = "gameId"; + + /** The qualified column identifier for the {@link #gameId} field. */ + public static final ColumnExp GAME_ID_C = + new ColumnExp(RatingRecord.class, GAME_ID); + + /** The column identifier for the {@link #playerId} field. */ + public static final String PLAYER_ID = "playerId"; + + /** The qualified column identifier for the {@link #playerId} field. */ + public static final ColumnExp PLAYER_ID_C = + new ColumnExp(RatingRecord.class, PLAYER_ID); + + /** The column identifier for the {@link #rating} field. */ + public static final String RATING = "rating"; + + /** The qualified column identifier for the {@link #rating} field. */ + public static final ColumnExp RATING_C = + new ColumnExp(RatingRecord.class, RATING); + + /** The column identifier for the {@link #experience} field. */ + public static final String EXPERIENCE = "experience"; + + /** The qualified column identifier for the {@link #experience} field. */ + public static final ColumnExp EXPERIENCE_C = + new ColumnExp(RatingRecord.class, EXPERIENCE); + // AUTO-GENERATED: FIELDS END + + public static final int SCHEMA_VERSION = 1; + + /** The identifier of the game we're rating for. */ + @Id + public int gameId; + + /** The identifier of the player we're rating. */ + @Id + public int playerId; + + /** The player's current rating. */ + public int rating; + + /** The number of times the player has played this game. */ + public int experience; + + /** + * An empty constructor for unmarshalling. + */ + public RatingRecord () + { + super(); + } + + /** + * A constructor that populates all our fields. + */ + public RatingRecord (int gameId, int playerId, int rating, int experience) + { + super(); + + this.gameId = gameId; + this.playerId = playerId; + this.rating = rating; + this.experience = experience; + } + + // AUTO-GENERATED: METHODS START + /** + * Create and return a primary {@link Key} to identify a {@link #RatingRecord} + * with the supplied key values. + */ + public static Key getKey (int gameId, int playerId) + { + return new Key( + RatingRecord.class, + new String[] { GAME_ID, PLAYER_ID }, + new Comparable[] { gameId, playerId }); + } + // AUTO-GENERATED: METHODS END +} diff --git a/src/java/com/threerings/parlor/rating/server/persist/RatingRepository.java b/src/java/com/threerings/parlor/rating/server/persist/RatingRepository.java new file mode 100644 index 00000000..88d983f9 --- /dev/null +++ b/src/java/com/threerings/parlor/rating/server/persist/RatingRepository.java @@ -0,0 +1,105 @@ +// +// $Id: PuzzleManagerDelegate.java 209 2007-02-24 00:37:33Z mdb $ +// +// 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.rating.server.persist; + +import java.util.List; + +import com.samskivert.io.PersistenceException; + +import com.samskivert.jdbc.ConnectionProvider; +import com.samskivert.jdbc.depot.DepotRepository; +import com.samskivert.jdbc.depot.clause.Where; +import com.samskivert.jdbc.depot.operator.Logic.*; +import com.samskivert.jdbc.depot.operator.Conditionals.*; + +import com.threerings.util.Name; + +/** + * Handles the persistent storage of per-user per-game ratings. + */ +public abstract class RatingRepository extends DepotRepository +{ + /** + * Users are indexed by integer. It is the responsibility of the subclasser to implement + * an injective mapping of {@link Name} instance to integer here. + */ + public abstract int mapNameToId (Name player); + + /** + * Loads the rating for the given player for the given game and returns it as a + * {@link RatingRecord} object, or null if the player has no previous rating for the game. + */ + public RatingRecord getRating (int gameId, Name player) + throws PersistenceException + { + return load( + RatingRecord.class, + RatingRecord.GAME_ID, gameId, + RatingRecord.PLAYER_ID, mapNameToId(player)); + } + + /** + * Fetch the ratings registered for any of the given players for the given game and return + * them as a list of {@link RatingRecord} objects. The size of this list is no less than zero + * and no greater than the number of given players. + */ + public List getRatings (int gameId, Name... players) + throws PersistenceException + { + Comparable[] idArr = new Comparable[players.length]; + for (int ii = 0; ii < idArr.length; ii ++) { + idArr[ii] = mapNameToId(players[ii]); + } + return findAll( + RatingRecord.class, + new Where(new And( + new Equals(RatingRecord.GAME_ID, gameId), + new In(RatingRecord.PLAYER_ID, idArr)))); + } + + /** + * Fetch and return all the registered {@link RatingRecord} rows for the given player. + */ + public List getRatings (Name player) + throws PersistenceException + { + return findAll(RatingRecord.class, new Where(RatingRecord.PLAYER_ID, mapNameToId(player))); + } + + /** + * Set the rating and experience for a given player and game. This method will either update + * or create a row. + */ + public void setRating (int gameId, Name player, int rating, int experience) + throws PersistenceException + { + store(new RatingRecord(gameId, mapNameToId(player), rating, experience)); + } + + /** + * Initialize the {@link RatingRepository}. + */ + protected RatingRepository(ConnectionProvider conprov) + { + super(conprov); + } +}