diff --git a/src/java/com/threerings/parlor/rating/server/Rating.java b/src/java/com/threerings/parlor/rating/server/Rating.java new file mode 100644 index 00000000..0845729b --- /dev/null +++ b/src/java/com/threerings/parlor/rating/server/Rating.java @@ -0,0 +1,165 @@ +// +// $Id$ +// +// Vilya library - tools for developing networked games +// Copyright (C) 2002-2010 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 com.samskivert.util.StringUtil; + +import com.threerings.media.util.MathUtil; + +import com.threerings.parlor.rating.data.RatingCodes; + +/** + * Encapsulates the rating/experience tuple representing a player's rating for a game, and logic + * for calculating rating changes. + */ +public class Rating + implements RatingCodes +{ + /** The player's rating for our game. */ + public int rating; + + /** The number of times the player's played our game. */ + public int experience; + + public Rating () + { + this.rating = DEFAULT_RATING; + this.experience = 0; + } + + /** + * 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. + * + * @param ratings the pre-match ratings of each of the opponents. + * @param pidx the index of the player whose rating is to be calculated. + * @param W the win value for the player whose rating is to be calculated, + * (1.0 means the player won, 0.5 means they drew, 0 means they lost). + * + * @return the player's updated rating or -1 if none of the opponents could + * be applicably rated against this player due to provisional/ + * non-provisional mismatch or lack of participation. + */ + public static int computeRating (Rating[] ratings, int pidx, float W) + { + float dR = 0; // the total delta rating + int opponents = 0; + + for (int ii = 0; ii < ratings.length; ii++) { + Rating orating = ratings[ii]; + // we don't care how we did against ourselves, or against guests... + if (pidx == ii || orating == null) { + 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].isProvisional() && ratings[ii].isProvisional()) { + opprat = Math.min(opprat, DEFAULT_RATING); + } + dR += computeAdjustment(W, opprat, ratings[pidx]); + opponents++; + } + + // if we have no valid opponents, we cannot compute a rating + if (opponents == 0) { + return -1; + } + + int nrat = Math.round(ratings[pidx].rating + dR/opponents); + return MathUtil.bound(MINIMUM_RATING, nrat, MAXIMUM_RATING); + } + + /** + * Computes a ratings adjustment for the given player, using a modified + * version of the FIDE Chess rating system as: + * + *
+     * adjustment = K(W - We)
+     *
+     * where:
+     *
+     * K = if (experience < 20) then 64
+     *     else if (rating < 2100 and experience >= 20) then 32
+     *     else if (rating >= 2100 and rating < 2400 and experience >= 20)
+     *          then 24
+     *     else 16
+     * W = score for the game just completed, as 1.0, 0.5, and 0.0 for a
+     * win, draw, or loss, respectively.
+     * dR = opponent's rating minus player's rating.
+     * We = expected score (win expectancy) as determined by:
+     *
+     *     We = 1 / (10^(dR/400) + 1)
+     * 
+ * + * @param W the win value the game in question (1.0 means the player won, + * 0.5 means they drew, 0 means they lost). + * @param opprat the opponent's current rating. + * @param rating the player's current rating. + * + * @return the adjustment to the player's rating. + */ + public static float computeAdjustment (float W, int opprat, Rating rating) + { + // calculate We, the win expectancy + float dR = opprat - rating.rating; + float We = 1.0f / (float)(Math.pow(10.0f, (dR / 400.0f)) + 1); + + // calculate K, the score multiplier constant + int K; + if (rating.experience < 20) { + K = 64; + } else if (rating.rating < 2100) { + K = 32; // experience >= 20 + } else if (rating.rating < 2400) { + K = 24; // experience >= 20 && rating >= 2100 + } else { + K = 16; // experience >= 20 && rating >= 2400 + } + + // compute and return the ratings adjustment + return K * (W - We); + } + + /** + * Returns true if this rating is provisional (experience < 20). + */ + public boolean isProvisional () + { + return (experience < 20); + } + + @Override + public String toString () + { + return StringUtil.fieldsToString(this); + } +} diff --git a/src/java/com/threerings/parlor/rating/server/RatingDelegate.java b/src/java/com/threerings/parlor/rating/server/RatingDelegate.java index 4a166984..7bf1f8f7 100644 --- a/src/java/com/threerings/parlor/rating/server/RatingDelegate.java +++ b/src/java/com/threerings/parlor/rating/server/RatingDelegate.java @@ -83,7 +83,7 @@ public abstract class RatingDelegate extends GameManagerDelegate // if the game is in play and this is a player, load their ratings BodyObject occupant = (BodyObject)_omgr.getObject(bodyOid); if (shouldRateGame() && _gobj.isInPlay() && isPlayer(occupant)) { - Rating rating = maybeCreateRating(occupant); + PlayerRating rating = maybeCreateRating(occupant); if (rating != null) { loadRatings(Collections.singleton(rating)); } @@ -96,7 +96,7 @@ public abstract class RatingDelegate extends GameManagerDelegate super.bodyLeft(bodyOid); // flush this player's rating if its modified - for (Rating rating : _ratings.values()) { + for (PlayerRating rating : _ratings.values()) { if (rating.playerOid == bodyOid && rating.modified) { saveRatings(Collections.singleton(rating.cloneForSave())); break; @@ -122,14 +122,14 @@ public abstract class RatingDelegate extends GameManagerDelegate // load up the ratings for all players in this game; also make a note of the persistent // player id of each player position for seated table games - List toLoad = Lists.newArrayList(); + List toLoad = Lists.newArrayList(); for (int ii = 0, ll = _gobj.occupants.size(); ii < ll; ii++) { BodyObject bobj = (BodyObject)_omgr.getObject(_gobj.occupants.get(ii)); int pidx = _gmgr.getPlayerIndex(bobj.getVisibleName()); if (pidx != -1) { _playerIds[pidx] = _gmgr.getPlayerPersistentId(bobj); } - Rating rating = maybeCreateRating(bobj); + PlayerRating rating = maybeCreateRating(bobj); if (rating != null) { toLoad.add(rating); } @@ -157,8 +157,8 @@ public abstract class RatingDelegate extends GameManagerDelegate updateRatings(); // any players who are no longer in the room need their ratings flushed immediately - List flushes = Lists.newArrayList(); - for (Rating rating : _ratings.values()) { + List flushes = Lists.newArrayList(); + for (PlayerRating rating : _ratings.values()) { if (rating.modified && !_gobj.occupants.contains(rating.playerOid)) { flushes.add(rating.cloneForSave()); } @@ -166,7 +166,7 @@ public abstract class RatingDelegate extends GameManagerDelegate saveRatings(flushes); } - protected Rating maybeCreateRating (BodyObject bobj) + protected PlayerRating maybeCreateRating (BodyObject bobj) { // if this occupant is not a player (or not ratable), skip 'em int playerId = _gmgr.getPlayerPersistentId(bobj); @@ -174,18 +174,18 @@ public abstract class RatingDelegate extends GameManagerDelegate return null; } // if this player's ratings are already loaded and their oids match, no need to reload - Rating orating = _ratings.get(playerId); + PlayerRating orating = _ratings.get(playerId); if (orating != null && orating.playerOid == bobj.getOid()) { return null; } - return new Rating(bobj, playerId); + return new PlayerRating(bobj, playerId); } /** * Loads up rating information for the specified set of player ids and stores them in the * {@link #_ratings} table. */ - protected void loadRatings (final Collection ratings) + protected void loadRatings (final Collection ratings) { if (ratings.size() == 0) { return; @@ -195,15 +195,15 @@ public abstract class RatingDelegate extends GameManagerDelegate _invoker.postUnit(new RepositoryUnit("loadRatings(" + gameId + ")") { @Override public void invokePersist () throws Exception { // map the records by player id so that we can correlate with the db results - IntMap map = IntMaps.newHashIntMap(); - for (Rating rating : ratings) { + IntMap map = IntMaps.newHashIntMap(); + for (PlayerRating rating : ratings) { map.put(rating.playerId, rating); } // load up the ratings data from the database, update the records Integer[] playerIds = map.keySet().toArray(new Integer[map.size()]); for (RatingRecord record : _repo.getRatings(gameId, playerIds)) { - Rating rating = map.get(record.playerId); + PlayerRating rating = map.get(record.playerId); if (rating != null) { rating.rating = record.rating; rating.experience = record.experience; @@ -213,14 +213,14 @@ public abstract class RatingDelegate extends GameManagerDelegate @Override public void handleSuccess () { // stuff our populated records into the _ratings mapping - for (Rating rating : ratings) { + for (PlayerRating rating : ratings) { _ratings.put(rating.playerId, rating); } } }); } - protected void saveRatings (final Collection ratings) + protected void saveRatings (final Collection ratings) { if (ratings.size() == 0) { return; @@ -229,14 +229,14 @@ public abstract class RatingDelegate extends GameManagerDelegate final int gameId = getGameId(); _invoker.postUnit(new RepositoryUnit("saveRatings(" + gameId + ")") { @Override public void invokePersist () throws Exception { - for (Rating rating : ratings) { + for (PlayerRating rating : ratings) { _repo.setRating(gameId, rating.playerId, rating.rating, rating.experience); } } @Override public void handleSuccess () { // let subclasses publish the new ratings if they so desire - for (Rating rating : ratings) { + for (PlayerRating rating : ratings) { updateRatingInMemory(gameId, rating); } } @@ -255,15 +255,21 @@ public abstract class RatingDelegate extends GameManagerDelegate return; } + PlayerRating[] ratings = new PlayerRating[_ratings.size()]; + for (int ii = 0; ii < ratings.length; ii++) { + ratings[ii] = _ratings.get(_playerIds[ii]); + } + // compute the update ratings for all players int[] nratings = new int[_playerIds.length]; - for (int ii = 0; ii < nratings.length; ii ++) { - nratings[ii] = computeRating(ii); + for (int ii = 0; ii < nratings.length; ii++) { + float W = _gobj.isDraw() ? 0.5f : _gobj.isWinner(ii) ? 1f : 0f; + nratings[ii] = Rating.computeRating(ratings, ii, W); } // and write them back to their rating records for (int ii = 0; ii < nratings.length; ii++) { - Rating rating = _ratings.get(_playerIds[ii]); + PlayerRating rating = ratings[ii]; if (rating != null && nratings[ii] > 0) { rating.rating = nratings[ii]; rating.experience++; @@ -272,80 +278,6 @@ public abstract class RatingDelegate extends GameManagerDelegate } } - /** - * 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 - * calculations for a non-provisional player. - * - * @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; - - Rating prating = _ratings.get(_playerIds[pidx]); - if (prating == null) { - // be robust - log.warning("Computing ratings for unknown player [where=" + where() + - ", pidx=" + pidx + "]."); - return -1; - } - - for (int ii = 0; ii < _playerIds.length; ii++) { - Rating orating = _ratings.get(_playerIds[ii]); - // we don't care how we did against ourselves, or against guests... - if (pidx == ii || orating == null) { - 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 = orating.rating; - if (prating.experience >= 20 && orating.experience < 20) { - opprat = Math.min(opprat, DEFAULT_RATING); - } - - // calculate K, the score multiplier constant - int K; - if (prating.experience < 20) { - K = 64; - } else if (prating.rating < 2100) { - K = 32; // experience >= 20 - } else if (prating.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 - prating.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(prating.rating + totDeltaR/opponents); - return MathUtil.bound(MINIMUM_RATING, nrat, MAXIMUM_RATING); - } - /** * Returns the game id to use when reading and writing ratings. */ @@ -372,12 +304,12 @@ public abstract class RatingDelegate extends GameManagerDelegate * * This method is called on the dobj thread. */ - protected abstract void updateRatingInMemory (int gameId, Rating rating); + protected abstract void updateRatingInMemory (int gameId, PlayerRating rating); /** * Encapsulates the rating/experience tuple representing a player's rating for a game. */ - protected static class Rating + protected static class PlayerRating extends Rating implements Cloneable { /** The oid of the rated player. */ @@ -389,19 +321,13 @@ public abstract class RatingDelegate extends GameManagerDelegate /** The id of the rated player. */ public int playerId; - /** The player's rating for our game. */ - public int rating; - - /** The number of times the player's played our game. */ - public int experience; - /** Whether or not this rating needs to be written back to the database. */ public boolean modified; /** - * Sets up a new {@link Rating} object with default values. + * Sets up a new {@link PlayerRating} object with default values. */ - public Rating (BodyObject player, int playerId) + public PlayerRating (BodyObject player, int playerId) { this.playerOid = player.getOid(); this.playerName = player.getVisibleName(); @@ -413,22 +339,16 @@ public abstract class RatingDelegate extends GameManagerDelegate /** * Duplicates this rating and clears its modified status. */ - public Rating cloneForSave () + public PlayerRating cloneForSave () { try { - Rating rating = (Rating)this.clone(); + PlayerRating rating = (PlayerRating)this.clone(); modified = false; return rating; } catch (CloneNotSupportedException cnse) { throw new AssertionError(cnse); } } - - @Override - public String toString () - { - return StringUtil.fieldsToString(this); - } } /** An appropriately casted reference to our GameManager. */ @@ -441,7 +361,7 @@ public abstract class RatingDelegate extends GameManagerDelegate protected int[] _playerIds; /** The ratings for each player as they were at the beginning of the game. */ - protected IntMap _ratings = IntMaps.newHashIntMap(); + protected IntMap _ratings = IntMaps.newHashIntMap(); /** A timestamp set at the beginning of the game, used to calculate its duration. */ protected long _startStamp;