Revamped the rating manager delegate to support party games and to only read

and write ratings once regardless of how many times the game is played.


git-svn-id: svn+ssh://src.earth.threerings.net/vilya/trunk@429 c613c5cb-e716-0410-b11b-feb51c14d237
This commit is contained in:
Michael Bayne
2007-09-11 20:56:45 +00:00
parent 9450dc5f2c
commit 8316c3f40c
@@ -23,6 +23,9 @@ package com.threerings.parlor.rating.server;
import static com.threerings.parlor.Log.log;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.logging.Level;
@@ -32,18 +35,23 @@ import com.samskivert.jdbc.RepositoryUnit;
import com.samskivert.util.ArrayIntSet;
import com.samskivert.util.ArrayUtil;
import com.samskivert.util.HashIntMap;
import com.samskivert.util.StringUtil;
import com.threerings.util.Name;
import com.threerings.media.util.MathUtil;
import com.threerings.crowd.data.BodyObject;
import com.threerings.crowd.data.PlaceObject;
import com.threerings.crowd.server.CrowdServer;
import com.threerings.media.util.MathUtil;
import com.threerings.parlor.game.data.GameConfig;
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.
@@ -76,6 +84,35 @@ public abstract class RatingManagerDelegate extends GameManagerDelegate
_gobj = (GameObject) plobj;
}
@Override // from PlaceManagerDelegate
public void bodyEntered (int bodyOid)
{
super.bodyEntered(bodyOid);
// if the game is in play and this is a player, load their ratings
BodyObject occupant = (BodyObject)CrowdServer.omgr.getObject(bodyOid);
if (_gobj.isInPlay() && isPlayer(occupant)) {
Rating rating = maybeCreateRating(occupant);
if (rating != null) {
loadRatings(Collections.singleton(rating));
}
}
}
@Override // from PlaceManagerDelegate
public void bodyLeft (int bodyOid)
{
super.bodyLeft(bodyOid);
// flush this players rating if its modified
for (Rating rating : _ratings.values()) {
if (rating.playerOid == bodyOid && rating.modified) {
saveRatings(Collections.singleton(rating));
break;
}
}
}
@Override // from GameManagerDelegate
public void gameWillStart ()
{
@@ -84,44 +121,125 @@ public abstract class RatingManagerDelegate extends GameManagerDelegate
// note the time at which we started
_startStamp = (int) (System.currentTimeMillis() / 1000);
final int gameId = _gmgr.getGameConfig().getGameId();
// this contains the persistent player id for each position in a seated table game
_playerIds = new int[_gmgr.getPlayerSlots()];
// enumerate our players
final Rating[] ratings = new Rating[_gmgr.getPlayerCount()];
ArrayIntSet ratedSet = new ArrayIntSet();
for (int ii = 0; ii < ratings.length; ii ++) {
ratings[ii] = new Rating(_gmgr.getPlayerPersistentId(_gmgr.getPlayer(ii)));
if (ratings[ii].playerId != 0) {
// this is a player with a persistent id, a player we can rate
ratedSet.add(ratings[ii].playerId);
// 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
ArrayList<Rating> toLoad = new ArrayList<Rating>();
for (int ii = 0, ll = _gobj.occupants.size(); ii < ll; ii++) {
BodyObject bobj = (BodyObject)CrowdServer.omgr.getObject(_gobj.occupants.get(ii));
int pidx = _gmgr.getPlayerIndex(bobj.getVisibleName());
if (pidx != -1) {
_playerIds[pidx] = _gmgr.getPlayerPersistentId(bobj);
}
Rating rating = maybeCreateRating(bobj);
if (rating != null) {
toLoad.add(rating);
}
}
final Integer[] ratedPlayers = ratedSet.toArray(new Integer[ratedSet.size()]);
loadRatings(toLoad);
}
// read ratings from persistent store
@Override // from GameManagerDelegate
public void gameDidEnd ()
{
super.gameDidEnd();
// don't update ratings if the game did not run long enough
int gameSecs = (int) (System.currentTimeMillis()/1000 - _startStamp);
if (gameSecs < minimumRatedDuration()) {
return;
}
// compute our updated ratings
updateRatings();
// any players who are no longer in the room need their ratings flushed immediately
ArrayList<Rating> flushes = new ArrayList<Rating>();
for (Rating rating : _ratings.values()) {
if (rating.modified && !_gobj.occupants.contains(rating.playerOid)) {
flushes.add(rating.cloneForSave());
}
}
saveRatings(flushes);
}
protected Rating maybeCreateRating (BodyObject bobj)
{
// if this occupant is not a player (or not ratable), skip 'em
int playerId = _gmgr.getPlayerPersistentId(bobj);
if (playerId == 0) {
return null;
}
// if this player's ratings are already loaded and their oids match, no need to reload
Rating orating = _ratings.get(playerId);
if (orating != null && orating.playerOid == bobj.getOid()) {
return null;
}
return new Rating(bobj, playerId);
}
/**
* Returns true if the supplied occupant is a player, false if not.
*/
protected boolean isPlayer (BodyObject occupant)
{
// avoid having to do this check all over the damned place
if (occupant == null) {
return false;
}
// if this is a seated table game, ask the game object
if (_gobj.getPlayerIndex(occupant.getVisibleName()) != -1) {
return true;
}
// if this is a party game, everyone's a player!
if (_gmgr.getGameConfig().getMatchType() == GameConfig.PARTY) {
return true;
}
// otherwise, sorry pardner
return false;
}
/**
* Loads up rating information for the specified set of player ids and stores them in the
* {@link #_ratings} table.
*/
protected void loadRatings (final Collection<Rating> ratings)
{
if (ratings.size() == 0) {
return;
}
log.info("Loading " + ratings + "...");
final int gameId = _gmgr.getGameConfig().getGameId();
CrowdServer.invoker.postUnit(new RepositoryUnit("loadRatings") {
public void invokePersist () throws Exception {
// fetch the previous ratings of our persistent users and map them by player id
HashIntMap<RatingRecord> map = new HashIntMap<RatingRecord>();
for (RatingRecord record : _repo.getRatings(gameId, ratedPlayers)) {
map.put(record.playerId, record);
// map the records by player id so that we can correlate with the db results
HashIntMap<Rating> map = new HashIntMap<Rating>();
for (Rating rating : ratings) {
map.put(rating.playerId, rating);
}
// now build the array we keep around until the end of the game
for (Rating rating : ratings) {
if (rating.playerId == 0) {
continue; // for guests we let the slot remain null
}
RatingRecord record = map.get(rating.playerId);
if (record != null) {
// 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);
if (rating != null) {
rating.rating = record.rating;
rating.experience = record.experience;
}
} // else { hell frozen over, pigs flying }
}
}
public void handleSuccess () {
_ratings = ratings; // stick our ratings into the manager
// stuff our populated records into the _ratings mapping
for (Rating rating : ratings) {
_ratings.put(rating.playerId, rating);
}
}
public void handleFailure (Exception e) {
@@ -131,59 +249,25 @@ public abstract class RatingManagerDelegate extends GameManagerDelegate
});
}
@Override // from GameManagerDelegate
public void gameDidEnd ()
protected void saveRatings (final Collection<Rating> ratings)
{
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() || _gobj.getWinnerCount() == 0) {
if (ratings.size() == 0) {
return;
}
// compute the updated ratings
int[] nratings = new int[_ratings.length];
for (int ii = 0; ii < _ratings.length; ii ++) {
nratings[ii] = _ratings[ii] != null ? computeRating(ii) : -1;
}
// and store them
boolean modified = false;
for (int ii = 0; ii < _ratings.length; ii++) {
// skip this rating if it's a guest slot or we weren't able to compute a value
if (nratings[ii] < 0) {
continue;
}
_ratings[ii].rating = nratings[ii];
_ratings[ii].experience ++;
modified = true;
}
// bail if nothing changed
if (!modified) {
return;
}
// else persist the result
log.info("Saving " + ratings + "...");
final int gameId = _gmgr.getGameConfig().getGameId();
CrowdServer.invoker.postUnit(new RepositoryUnit("updateRatings") {
CrowdServer.invoker.postUnit(new RepositoryUnit("saveRatings") {
public void invokePersist () throws Exception {
for (Rating rating : _ratings) {
if (rating != null) {
_repo.setRating(gameId, rating.playerId, rating.rating, rating.experience);
}
for (Rating rating : ratings) {
_repo.setRating(gameId, rating.playerId, rating.rating, rating.experience);
}
}
public void handleSuccess () {
for (int ii = 0; ii < _ratings.length; ii++) {
if (_ratings[ii] != null) {
// let subclasses publish the new ratings if they so desire
updateRatingInMemory(gameId, _gmgr.getPlayerName(ii), _ratings[ii]);
}
// let subclasses publish the new ratings if they so desire
for (Rating rating : ratings) {
updateRatingInMemory(gameId, rating);
}
}
@@ -194,46 +278,76 @@ public abstract class RatingManagerDelegate extends GameManagerDelegate
});
}
/**
* Computes updated ratings for the players of this game. The default implementation uses the
* {@link GameObject#winners} field to determine winners and losers and uses a FIDE/ELO
* algorithm to comptue updated ratings.
*/
protected void updateRatings ()
{
// if no one won (or we're not a seated game), no ratings need be computed
if (_gobj.getWinnerCount() == 0 || _playerIds.length == 0) {
return;
}
// 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);
}
// and write them back to their rating records
for (int ii = 0; ii < nratings.length; ii++) {
Rating rating = _ratings.get(_playerIds[ii]);
if (rating != null && nratings[ii] > 0) {
rating.rating = nratings[ii];
rating.experience++;
rating.modified = true;
}
}
}
/**
* 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. <em>Note:</em> 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.
* <p> 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. <em>Note:</em> 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.
*
* @return the player's updated rating or -1 if none of the opponents could be applicably
* rated against this 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]);
for (int ii = 0; ii < _ratings.length; ii++) {
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 || _ratings[ii] == null) {
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].experience >= 20 && _ratings[ii].experience < 20) {
// 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 (_ratings[pidx].experience < 20) {
if (prating.experience < 20) {
K = 64;
} else if (_ratings[pidx].rating < 2100) {
} else if (prating.rating < 2100) {
K = 32; // experience >= 20
} else if (_ratings[pidx].rating < 2400) {
} else if (prating.rating < 2400) {
K = 24; // experience >= 20 && rating >= 2100
} else {
K = 16; // experience >= 20 && rating >= 2400
@@ -242,7 +356,7 @@ public abstract class RatingManagerDelegate extends GameManagerDelegate
// 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 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
@@ -256,7 +370,7 @@ public abstract class RatingManagerDelegate extends GameManagerDelegate
}
// return the updated and clamped rating
int nrat = Math.round(_ratings[pidx].rating + totDeltaR/opponents);
int nrat = Math.round(prating.rating + totDeltaR/opponents);
return MathUtil.bound(MINIMUM_RATING, nrat, MAXIMUM_RATING);
}
@@ -275,13 +389,20 @@ public abstract class RatingManagerDelegate extends GameManagerDelegate
*
* This method is called on the dobj thread.
*/
protected abstract void updateRatingInMemory (int gameId, Name playerName, Rating rating);
protected abstract void updateRatingInMemory (int gameId, Rating rating);
/**
* Simply encapsulates the rating/experience tuple representing a player's rating for a game.
* Encapsulates the rating/experience tuple representing a player's rating for a game.
*/
protected static class Rating
implements Cloneable
{
/** The oid of the rated player. */
public int playerOid;
/** The name of the rated player. */
public Name playerName;
/** The id of the rated player. */
public int playerId;
@@ -291,15 +412,40 @@ public abstract class RatingManagerDelegate extends GameManagerDelegate
/** 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.
*/
public Rating (int playerId)
public Rating (BodyObject player, int playerId)
{
this.playerOid = player.getOid();
this.playerName = player.getVisibleName();
this.playerId = playerId;
this.rating = DEFAULT_RATING;
this.experience = 0;
}
/**
* Duplicates this rating and clears its modified status.
*/
public Rating cloneForSave ()
{
try {
Rating rating = (Rating)this.clone();
modified = false;
return rating;
} catch (CloneNotSupportedException cnse) {
throw new RuntimeException(cnse);
}
}
@Override // from Object
public String toString ()
{
return StringUtil.fieldsToString(this);
}
}
/** An appropriately casted reference to our GameManager. */
@@ -311,8 +457,11 @@ public abstract class RatingManagerDelegate extends GameManagerDelegate
/** The RatingRepository that holds our data. */
protected RatingRepository _repo;
/** Contains the persistent id of the players in this game. */
protected int[] _playerIds;
/** The ratings for each player as they were at the beginning of the game. */
protected Rating[] _ratings;
protected HashIntMap<Rating> _ratings = new HashIntMap<Rating>();
/** A timestamp set at the beginning of the game, used to calculate its duration. */
protected int _startStamp;