package com.boardgamegeek.tasks; import android.content.Context; import android.database.Cursor; import android.net.Uri; import android.os.AsyncTask; import android.support.annotation.NonNull; import android.support.annotation.Nullable; import android.support.annotation.StringRes; import android.text.TextUtils; import com.boardgamegeek.R; import com.boardgamegeek.events.ColorAssignmentCompleteEvent; import com.boardgamegeek.model.Play; import com.boardgamegeek.model.Player; import com.boardgamegeek.provider.BggContract.GameColors; import com.boardgamegeek.provider.BggContract.Games; import com.boardgamegeek.provider.BggContract.PlayerColors; import com.boardgamegeek.tasks.ColorAssignerTask.Results; import com.boardgamegeek.util.ResolverUtils; import org.greenrobot.eventbus.EventBus; import java.util.ArrayList; import java.util.List; import java.util.Random; import hugo.weaving.DebugLog; import timber.log.Timber; public class ColorAssignerTask extends AsyncTask<Void, Void, Results> { private static final int SUCCESS = 1; private static final int ERROR = -1; private static final int ERROR_NO_PLAYERS = -2; private static final int ERROR_MISSING_PLAYER_NAME = -3; private static final int ERROR_TOO_FEW_COLORS = -4; private static final int ERROR_DUPLICATE_PLAYER = -5; private static final int ERROR_SOMETHING_CHANGED = -99; private static final int TYPE_PLAYER_USER = 1; private static final int TYPE_PLAYER_NON_USER = 2; @NonNull private final Random random; private final Context context; private final Play play; private List<String> colorsAvailable; private List<PlayerColorChoices> playersNeedingColor; @NonNull private final Results results; private int round; @DebugLog public ColorAssignerTask(Context context, Play play) { this.context = context; this.play = play; results = new Results(); random = new Random(); } @DebugLog @NonNull @Override protected Results doInBackground(Void... params) { int result = populatePlayersNeedingColor(); if (result != SUCCESS) { results.resultCode = result; return results; } if (playersNeedingColor.size() == 0) { results.resultCode = ERROR_NO_PLAYERS; return results; } populateColorsAvailable(); if (colorsAvailable.size() < playersNeedingColor.size()) { results.resultCode = ERROR_TOO_FEW_COLORS; return results; } populatePlayerColorChoices(); round = 1; boolean shouldContinue = true; while (shouldContinue && playersNeedingColor.size() > 0) { while (shouldContinue && playersNeedingColor.size() > 0) { shouldContinue = assignTopChoice(); } shouldContinue = assignMostPreferredChoice(); round++; } // assign a random player a random color while (playersNeedingColor.size() > 0) { String color = colorsAvailable.get(random.nextInt(colorsAvailable.size())); PlayerColorChoices username = playersNeedingColor.get(random.nextInt(playersNeedingColor.size())); assignColorToPlayer(color, username, "random"); } results.resultCode = SUCCESS; return results; } @DebugLog @Override protected void onPostExecute(@Nullable Results results) { results = ensureResultsAreNotNull(results); if (results.resultCode == SUCCESS) { setPlayerColorsFromResults(results); } notifyCompletion(results, getMessageIdFromResults(results)); } @DebugLog @NonNull private Results ensureResultsAreNotNull(@Nullable Results results) { if (results == null) { results = new Results(); results.resultCode = ERROR; } return results; } @DebugLog private void setPlayerColorsFromResults(@NonNull Results results) { for (PlayerResult pr : this.results.results) { Player player = getPlayerFromResult(pr); if (player == null) { results.resultCode = ERROR_SOMETHING_CHANGED; break; } else { player.color = pr.color; } } } @DebugLog private int getMessageIdFromResults(@NonNull Results results) { @StringRes int messageId = R.string.msg_color_success; if (results.hasError()) { messageId = R.string.title_error; switch (results.resultCode) { case ERROR_NO_PLAYERS: messageId = R.string.msg_color_error_no_players; break; case ERROR_DUPLICATE_PLAYER: messageId = R.string.msg_color_error_duplicate_player; break; case ERROR_MISSING_PLAYER_NAME: messageId = R.string.msg_color_error_missing_player_name; break; case ERROR_SOMETHING_CHANGED: messageId = R.string.msg_color_error_something_changed; break; case ERROR_TOO_FEW_COLORS: messageId = R.string.msg_color_error_too_few_colors; break; } } return messageId; } @DebugLog private void notifyCompletion(@NonNull Results results, @StringRes int messageId) { EventBus.getDefault().postSticky(new ColorAssignmentCompleteEvent(results.resultCode == SUCCESS, messageId)); } /** * Assigns a player their top color choice if no one else has that top choice as well. * * @return <code>true</code> if a color was assigned, <code>false</code> if not. */ @DebugLog private boolean assignTopChoice() { for (String colorToAssign : colorsAvailable) { PlayerColorChoices playerWhoWantsThisColor = getLonePlayerWithTopChoice(colorToAssign); if (playerWhoWantsThisColor != null) { assignColorToPlayer(colorToAssign, playerWhoWantsThisColor, "top choice"); return true; } } Timber.i("No more players have a unique top choice in round %d", round); return false; } @DebugLog @Nullable private PlayerColorChoices getLonePlayerWithTopChoice(String colorToAssign) { List<PlayerColorChoices> players = getPlayersWithTopChoice(colorToAssign); if (players.size() == 0) { Timber.i("No players want %s as their top choice", colorToAssign); return null; } else if (players.size() > 1) { Timber.i("Multiple players want %s as their top choice", colorToAssign); return null; } return players.get(0); } @DebugLog @NonNull private List<PlayerColorChoices> getPlayersWithTopChoice(String colorToAssign) { List<PlayerColorChoices> players = new ArrayList<>(); for (PlayerColorChoices player : playersNeedingColor) { if (player.isTopChoice(colorToAssign)) { players.add(player); } } return players; } @DebugLog private boolean assignMostPreferredChoice() { List<PlayerColorChoices> players = new ArrayList<>(); double maxPreference = 0.0; for (String color : colorsAvailable) { List<PlayerColorChoices> playersWithTopChoice = getPlayersWithTopChoice(color); if (playersWithTopChoice.size() > 1) { for (PlayerColorChoices player : playersWithTopChoice) { double preference = player.calculateCurrentPreferenceFor(color); Timber.i("%s wants %s: %,.2f", player.name, color, preference); if (preference > maxPreference) { maxPreference = preference; players.clear(); players.add(player); } else if (preference == maxPreference) { players.add(player); } } } else { Timber.i("Not enough players want %s", color); } } if (players.size() == 0) { Timber.i("Nobody wants any color"); return false; } if (players.size() == 1) { PlayerColorChoices player = players.get(0); final ColorChoice topChoice = player.getTopChoice(); if (topChoice != null) { assignColorToPlayer(topChoice.color, player, String.format("most preferred (%,.2f)", maxPreference)); return true; } } else { int i = random.nextInt(players.size()); PlayerColorChoices player = players.get(i); final ColorChoice topChoice = player.getTopChoice(); if (topChoice != null) { assignColorToPlayer(player.getTopChoice().color, player, String.format("most preferred, but randomly chosen in a tie breaker (%,.2f)", maxPreference)); return true; } } Timber.i("Something went horribly wrong"); return false; } @DebugLog private int populatePlayersNeedingColor() { playersNeedingColor = new ArrayList<>(); List<String> users = new ArrayList<>(); List<String> nonusers = new ArrayList<>(); for (Player player : play.getPlayers()) { if (TextUtils.isEmpty(player.color)) { if (TextUtils.isEmpty(player.username)) { if (TextUtils.isEmpty(player.name)) { return ERROR_MISSING_PLAYER_NAME; } if (nonusers.contains(player.name)) { return ERROR_DUPLICATE_PLAYER; } nonusers.add(player.name); playersNeedingColor.add(new PlayerColorChoices(player.name, TYPE_PLAYER_NON_USER)); } else { if (users.contains(player.username)) { return ERROR_DUPLICATE_PLAYER; } users.add(player.username); playersNeedingColor.add(new PlayerColorChoices(player.username, TYPE_PLAYER_USER)); } } } return SUCCESS; } @DebugLog private void populateColorsAvailable() { colorsAvailable = ResolverUtils.queryStrings(context.getContentResolver(), Games.buildColorsUri(play.gameId), GameColors.COLOR); for (Player player : play.getPlayers()) { if (!TextUtils.isEmpty(player.color)) { colorsAvailable.remove(player.color); } } } /** * Populates the remaining players list with their color choices, limited to the colors remaining in the game. */ @DebugLog private void populatePlayerColorChoices() { for (PlayerColorChoices player : playersNeedingColor) { Cursor cursor = null; try { if (TextUtils.isEmpty(player.name)) continue; Uri uri = null; if (player.type == TYPE_PLAYER_USER) { uri = PlayerColors.buildUserUri(player.name); } else if (player.type == TYPE_PLAYER_NON_USER) { uri = PlayerColors.buildPlayerUri(player.name); } if (uri == null) continue; final String[] projection = { PlayerColors.PLAYER_COLOR, PlayerColors.PLAYER_COLOR_SORT_ORDER }; cursor = context.getContentResolver().query(uri, projection, null, null, null); while (cursor != null && cursor.moveToNext()) { String color = cursor.getString(0); int sortOrder = cursor.getInt(1); if (colorsAvailable.contains(color)) { player.colors.add(new ColorChoice(color, sortOrder)); } } } catch (Exception e) { Timber.w(e, "Couldn't get the colors for %s", player); } finally { if (cursor != null) cursor.close(); } } } /** * Gets the player from the play based on the player result. Returns null if the player couldn't be found or their color is already set. */ @DebugLog private Player getPlayerFromResult(@NonNull PlayerResult pr) { for (Player player : play.getPlayers()) { if ((pr.type == TYPE_PLAYER_USER && pr.name.equals(player.username)) || pr.type == TYPE_PLAYER_NON_USER && pr.name.equals(player.name) && TextUtils.isEmpty(player.username)) { if (TextUtils.isEmpty(player.color)) { return player; } } } return null; } /** * Assign a color to a player, and remove both from the list of remaining colors and players. This can't be called * from a for each loop without ending the iteration. */ @DebugLog private void assignColorToPlayer(@NonNull String color, @NonNull PlayerColorChoices player, String reason) { PlayerResult playerResult = new PlayerResult(player.name, player.type, color, reason); results.results.add(playerResult); Timber.i("Assigned %s", playerResult); colorsAvailable.remove(color); playersNeedingColor.remove(player); for (PlayerColorChoices playerColorChoices : playersNeedingColor) { playerColorChoices.removeChoice(color); } } public class Results { int resultCode; final List<PlayerResult> results = new ArrayList<>(); public boolean hasError() { return resultCode < 0; } @Override public String toString() { return String.format("%1$s - %2$s", resultCode, results.size()); } } public class PlayerResult { final String name; final int type; final String color; final String reason; public PlayerResult(String name, int type, String color, String reason) { this.name = name; this.type = type; this.color = color; this.reason = reason; } @NonNull @Override public String toString() { return String.format("%1$s - %3$s (%4$s in round %5$d)", name, type, color, reason, round); } } private class PlayerColorChoices { final String name; final int type; @NonNull final List<ColorChoice> colors; @DebugLog public PlayerColorChoices(String name, int type) { this.name = name; this.type = type; this.colors = new ArrayList<>(); } @DebugLog public boolean isTopChoice(String color) { return colors.size() > 0 && colors.get(0).color.equals(color); } /** * Gets the player's top remaining color choice, or <code>null</code> if they have no choices left. */ @DebugLog @Nullable public ColorChoice getTopChoice() { if (colors.size() > 0) { return colors.get(0); } return null; } @DebugLog public boolean removeChoice(@NonNull String color) { if (TextUtils.isEmpty(color)) { return false; } for (ColorChoice colorChoice : colors) { if (color.equals(colorChoice.color)) { colors.remove(colorChoice); return true; } } return false; } @DebugLog public double calculateCurrentPreferenceFor(@NonNull String color) { int MAX_PREFERENCE = 100; if (colors.size() == 0) { return 0; } else if (colors.size() == 1) { return MAX_PREFERENCE - colors.get(0).sortOrder; } int total = 0; int current = 0; for (ColorChoice colorChoice : colors) { total += colorChoice.sortOrder; if (color.equals(colorChoice.color)) { current = colorChoice.sortOrder; } } double expectedValue = ((double) total) / colors.size(); double expectedValueWithoutColor = ((double) (total - current)) / (colors.size() - 1); return expectedValueWithoutColor - expectedValue; } @Override public String toString() { String s = name + " (" + type + ")"; if (colors.size() > 0) { s += " [ "; boolean prependComma = false; for (ColorChoice color : colors) { if (prependComma) { s += ", "; } s += color; prependComma = true; } s += " ]"; } return s; } } private class ColorChoice { final String color; final int sortOrder; public ColorChoice(String color, int sortOrder) { this.color = color; this.sortOrder = sortOrder; } @NonNull @Override public String toString() { return "#" + sortOrder + ": " + color; } } }