ProjectAres/PGM/src/main/java/tc/oc/pgm/teams/TeamMatchModule.java

566 lines
22 KiB
Java

package tc.oc.pgm.teams;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Stream;
import javax.annotation.Nullable;
import javax.inject.Inject;
import com.google.common.collect.Range;
import net.md_5.bungee.api.ChatColor;
import net.md_5.bungee.api.chat.BaseComponent;
import net.md_5.bungee.api.chat.TranslatableComponent;
import org.apache.commons.lang.math.Fraction;
import org.bukkit.Sound;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import tc.oc.api.docs.PlayerId;
import tc.oc.commons.core.chat.Component;
import tc.oc.commons.core.formatting.StringUtils;
import tc.oc.commons.core.stream.Collectors;
import tc.oc.commons.core.util.Comparators;
import tc.oc.commons.core.util.Optionals;
import tc.oc.commons.core.util.Streams;
import tc.oc.pgm.Config;
import tc.oc.commons.bukkit.chat.Links;
import tc.oc.pgm.events.ListenerScope;
import tc.oc.pgm.events.PlayerPartyChangeEvent;
import tc.oc.pgm.features.FeatureDefinitionContext;
import tc.oc.pgm.features.MatchFeatureContext;
import tc.oc.pgm.join.JoinAllowed;
import tc.oc.pgm.join.JoinConfiguration;
import tc.oc.pgm.join.JoinDenied;
import tc.oc.pgm.join.JoinHandler;
import tc.oc.pgm.join.JoinMatchModule;
import tc.oc.pgm.join.JoinMethod;
import tc.oc.pgm.join.JoinQueued;
import tc.oc.pgm.join.JoinRequest;
import tc.oc.pgm.join.JoinResult;
import tc.oc.pgm.join.QueuedParticipants;
import tc.oc.pgm.match.Competitor;
import tc.oc.pgm.match.Match;
import tc.oc.pgm.match.MatchModule;
import tc.oc.pgm.match.MatchPlayer;
import tc.oc.pgm.match.MatchScope;
import tc.oc.pgm.match.Party;
import tc.oc.pgm.start.StartMatchModule;
import tc.oc.pgm.start.UnreadyReason;
import tc.oc.pgm.teams.events.TeamResizeEvent;
import static com.google.common.base.Preconditions.checkArgument;
import static com.google.common.base.Preconditions.checkNotNull;
import static tc.oc.commons.core.util.Functions.memoize;
import static tc.oc.commons.core.util.Utils.*;
@ListenerScope(MatchScope.LOADED)
public class TeamMatchModule extends MatchModule implements Listener, JoinHandler {
private static final String CHOOSE_TEAM_PERMISSION = "pgm.join.choose.participating";
class NeedMorePlayers implements UnreadyReason {
final @Nullable Team team;
final int players;
NeedMorePlayers(@Nullable Team team, int players) {
this.team = team;
this.players = players;
}
@Override
public BaseComponent getReason() {
if(team != null) {
if(players == 1) {
return new TranslatableComponent("start.needMorePlayers.team.singular",
new Component(String.valueOf(players), ChatColor.AQUA),
team.getComponentName());
} else {
return new TranslatableComponent("start.needMorePlayers.team.plural",
new Component(String.valueOf(players), ChatColor.AQUA),
team.getComponentName());
}
} else {
if(players == 1) {
return new TranslatableComponent("start.needMorePlayers.ffa.singular",
new Component(String.valueOf(players), ChatColor.AQUA));
} else {
return new TranslatableComponent("start.needMorePlayers.ffa.plural",
new Component(String.valueOf(players), ChatColor.AQUA));
}
}
}
@Override
public boolean canForceStart() {
return true;
}
@Override
public String toString() {
return getClass().getSimpleName() + "{players=" + players + " team=" + team + "}";
}
};
@Inject private TeamConfiguration config;
@Inject private JoinConfiguration joinConfiguration;
@Inject private FeatureDefinitionContext definitions;
@Inject private MatchFeatureContext features;
@Inject private JoinMatchModule jmm;
@Inject private StartMatchModule smm;
private final Optional<Boolean> requireEven;
// Players who autojoined their current team
private final Set<MatchPlayer> autoJoins = new HashSet<>();
// Minimum at any time of the number of additional players needed to start the match
private int minPlayersNeeded = Integer.MAX_VALUE;
public TeamMatchModule(Match match, Optional<Boolean> requireEven) {
super(match);
this.requireEven = requireEven;
}
@Override
public void load() {
super.load();
jmm.registerHandler(this);
Streams.consume(teams());
updatePlayerLimits();
updateReadiness();
}
protected void updatePlayerLimits() {
int min = 0, max = 0;
for(Team team : getTeams()) {
min += team.getMinPlayers();
max += team.getMaxPlayers();
}
getMatch().setPlayerLimits(Range.closed(min, max));
}
protected void updateReadiness() {
if(getMatch().hasStarted()) return;
final int playersQueued = jmm.getQueuedParticipants().getPlayers().size();
final int playersJoined = getMatch().getParticipatingPlayers().size();
Team singleTeam = null;
int teamNeeded = 0;
for(Team t : getTeams()) {
int p = t.getMinPlayers() - t.getPlayers().size();
if(p > 0) {
singleTeam = teamNeeded == 0 ? t : null;
teamNeeded += p;
}
}
teamNeeded -= playersQueued;
int globalNeeded = Config.minimumPlayers() - playersJoined - playersQueued;
int playersNeeded;
if(globalNeeded > teamNeeded) {
playersNeeded = globalNeeded;
singleTeam = null;
} else {
playersNeeded = teamNeeded;
}
if(playersNeeded > 0) {
smm.addUnreadyReason(new NeedMorePlayers(singleTeam, playersNeeded));
// Whenever playersNeeded reaches a new minimum, reset the unready timeout
if(playersNeeded < minPlayersNeeded) {
minPlayersNeeded = playersNeeded;
smm.restartUnreadyTimeout();
}
} else {
smm.removeUnreadyReason(NeedMorePlayers.class);
}
}
public Stream<Team> teams() {
return definitions.all(TeamFactory.class)
.map(this::team);
}
public Set<Team> getTeams() {
return teams().collect(Collectors.toImmutableSet());
}
public Team team(TeamFactory def) {
return features.get(def);
}
public Stream<Team> shuffledTeams() {
final List<Team> list = new ArrayList<>(getTeams());
Collections.shuffle(list, match.getRandom());
return list.stream();
}
public @Nullable Team bestFuzzyMatch(String name) {
return bestFuzzyMatch(name, 0.9);
}
public @Nullable Team bestFuzzyMatch(String name, double threshold) {
Map<String, Team> byName = new HashMap<>();
for(Team team : getTeams()) byName.put(team.getName(), team);
return StringUtils.bestFuzzyMatch(name, byName, threshold);
}
public Optional<Team> fuzzyMatch(String name) {
return Optional.ofNullable(bestFuzzyMatch(name));
}
protected void setAutoJoin(MatchPlayer player, boolean autoJoined) {
if(autoJoined) {
autoJoins.add(player);
} else {
autoJoins.remove(player);
}
}
protected boolean isAutoJoin(MatchPlayer player) {
return autoJoins.contains(player);
}
private boolean canSwitchTeams(MatchPlayer joining) {
return config.allowSwitch() || !getMatch().hasStarted();
}
private boolean canChooseTeam(MatchPlayer joining) {
return config.allowChoose() && joining.getBukkit().hasPermission(CHOOSE_TEAM_PERMISSION);
}
public boolean forceJoin(MatchPlayer joining, @Nullable Competitor forcedParty) {
if(forcedParty instanceof Team) {
return forceJoin(joining, (Team) forcedParty, false);
} else if(forcedParty == null) {
final JoinResult result = queryAutoJoin(joining, false);
if(result.isAllowed() && result.competitor().isPresent() && result.competitor().get() instanceof Team) {
return forceJoin(joining, (Team) result.competitor().get(), true);
}
}
return false;
}
private boolean forceJoin(MatchPlayer player, Team newTeam, boolean autoJoin) {
checkNotNull(newTeam);
if(Optionals.equals(newTeam, player.partyMaybe())) return true;
if(getMatch().setPlayerParty(player, newTeam, true)) {
setAutoJoin(player, autoJoin);
return true;
} else {
return false;
}
}
private boolean requireEvenTeams() {
final boolean requireEven = this.requireEven.orElse(config.requireEven());
if(!requireEven) return false;
// If any teams are unequal in size, don't try to even the teams
// TODO: This could be done, it's just more complicated
int size = -1;
for(Team team : getTeams()) {
if(size == -1) {
size = team.getMaxOverfill();
} else if(size != team.getMaxOverfill()) {
return false;
}
}
return true;
}
/**
* Do all teams have equal fullness ratios?
*/
public boolean areTeamsEven() {
return Streams.isUniform(teams().map(team -> team.getFullness(Team::getMaxOverfill)));
}
/**
* Return the most full participating team
*/
public Team getFullestTeam() {
return (Team) shuffledTeams()
.max(Comparator.comparing(team -> team.getFullness(Team::getMaxOverfill)))
.get();
}
/**
* Return join query results for all teams, sorted by auto-join preference
* i.e. according to the following chain of criteria:
*
* - Successful joins before failed ones
* - Joins that do not require a priority kick before those that do
* - Ascending team fullness relative to min-players
* - Ascending team fullness relative to max-overfill
* - Random order
*
* It is assumed that the joining player will leave their current team before
* joining i.e. they are ignored for all calculations.
*/
private Stream<JoinResult> autoJoinResults(MatchPlayer joining, boolean priorityKick) {
return Team.withChange(joining, null, () -> {
final Function<Team, JoinResult> queryJoin = memoize(team -> team.queryJoin(joining, priorityKick, false));
return shuffledTeams()
.sorted(Comparator.<Team, JoinResult>comparing(queryJoin, Comparator.comparing(JoinResult::isAllowed, Comparators.firstIf())
.thenComparing(JoinResult::priorityKickRequired, Comparators.lastIf()))
.<Fraction>thenComparing((Team team) -> team.getFullness(Team::getMinPlayers))
.<Fraction>thenComparing((Team team) -> team.getFullness(Team::getMaxOverfill)))
.map(queryJoin);
});
}
/**
* Return the best team for the given player to join, as determined by {@link #autoJoinResults}.
* If no teams can be joined, the result will be the least bad option.
*/
private JoinResult queryAutoJoin(MatchPlayer joining, boolean priorityKick) {
return autoJoinResults(joining, priorityKick)
.filter(result -> !(result.competitor().isPresent() && joining.inParty(result.competitor().get())))
.findFirst()
.get();
}
/**
* Get the given player's last joined {@link Team} in this match,
* or empty if the player has never joined a team.
*/
public Optional<Team> lastTeam(PlayerId playerId) {
return getInstanceOf(getMatch().getLastCompetitor(playerId), Team.class);
}
/**
* What would happen if the given player tried to join the given team right now?
*/
@Override
public @Nullable JoinResult queryJoin(MatchPlayer joining, JoinRequest request) {
if(!request.competitor().isPresent() || request.competitor().get() instanceof Team) {
return queryJoin(joining, request, false);
}
return null;
}
private JoinResult queryJoin(MatchPlayer joining, JoinRequest request, boolean queued) {
final Optional<Team> lastTeam = lastTeam(joining.getPlayerId());
final Optional<Team> chosenTeam = getInstanceOf(request.competitor(), Team.class);
if(request.method() == JoinMethod.REMOTE) {
// If remote joining, force the player onto a team
return JoinAllowed.force(queryAutoJoin(joining, true));
} else if(!request.competitor().isPresent()) {
// If autojoining, and the player is already on a team, the request is satisfied
if(Optionals.isInstance(joining.partyMaybe(), Competitor.class)) {
return JoinDenied.error("command.gameplay.join.alreadyOnTeam", joining.getParty().getComponentName());
}
// If team choosing is disabled, and the match has not started yet, defer the join.
// Note that this can only happen with autojoin. Choosing a team always fails if
// the condition below is true.
if(!queued && !config.allowChoose() && !getMatch().hasStarted()) {
return new JoinQueued();
}
if(lastTeam.isPresent()) {
// If the player was previously on a team, try to join that team first
final JoinResult rejoin = lastTeam.get().queryJoin(joining, true, true);
if(rejoin.isAllowed() || !canSwitchTeams(joining)) return rejoin;
// If the join fails, and the player is allowed to switch teams, fall through to the auto-join
}
// Try to find a team for the player to join
final JoinResult auto = queryAutoJoin(joining, true);
if(auto.isAllowed()) return auto;
if(jmm.canJoinFull(joining) || !joinConfiguration.overfill()) {
return JoinDenied.unavailable("autoJoin.teamsFull");
} else {
// If the player is not premium, and overfill is enabled, plug the shop
return JoinDenied.unavailable("autoJoin.teamsFull")
.also(Links.shopPlug("shop.plug.joinFull"));
}
} else if(chosenTeam.isPresent()) {
// If the player is already on the chosen team, there is nothing to do
if(joining.hasParty() && contains(chosenTeam, joining.getParty())) {
return JoinDenied.error("command.gameplay.join.alreadyOnTeam", joining.getParty().getComponentName());
}
// If team switching is disabled and the player is choosing to re-join their
// last team, don't consider it a "choice" since that's the only team they can
// join anyway. In any other case, check that they are allowed to choose their team.
if(config.allowSwitch() || !chosenTeam.equals(lastTeam)) {
// Team choosing is disabled
if(!config.allowChoose()) {
return JoinDenied.error("command.gameplay.join.choiceDisabled");
}
// Player is not allowed to choose their team
if(!canChooseTeam(joining)) {
return JoinDenied.unavailable("command.gameplay.join.choiceDenied")
.also(Links.shopPlug("shop.plug.chooseTeam"));
}
}
// If team switching is disabled, check if the player is rejoining their former team
if(!canSwitchTeams(joining) && lastTeam.isPresent()) {
if(chosenTeam.equals(lastTeam)) {
return chosenTeam.get().queryJoin(joining, true, true);
} else {
return JoinDenied.error("command.gameplay.join.switchDisabled", lastTeam.get().getComponentName());
}
}
return chosenTeam.get().queryJoin(joining, true, false);
}
return null;
}
@Override
public boolean join(MatchPlayer joining, JoinRequest request, JoinResult result) {
if(result.isAllowed() && isInstanceOf(result.competitor(), Team.class)) {
final Optional<Team> lastTeam = lastTeam(joining.getPlayerId());
final Team newTeam = (Team) result.competitor().get();
// FIXME: When a player rejoins their last team, we lose their autojoin status
if(!forceJoin(joining, newTeam, !lastTeam.isPresent() && !request.competitor().isPresent())) {
return false;
}
if(result.priorityKickRequired()) {
logger.info("Bumping a player from " + newTeam.getColoredName() + " to make room for " + joining.getDisplayName());
kickPlayerOffTeam(newTeam, false);
}
return true;
}
return false;
}
@Override
public void queuedJoin(QueuedParticipants queue) {
final boolean even = requireEvenTeams();
final JoinRequest request = JoinRequest.user();
// First, eliminate any players who cannot join at all, so they do not influence the even teams logic
List<MatchPlayer> shortList = new ArrayList<>();
for(MatchPlayer player : queue.getOrderedPlayers()) {
JoinResult result = queryJoin(player, request, true);
if(result.isAllowed()) {
shortList.add(player);
} else {
// This will send a failure message
join(player, request, result);
}
}
for(int i = 0; i < shortList.size(); i++) {
MatchPlayer player = shortList.get(i);
if(even && areTeamsEven() && shortList.size() - i < getTeams().size()) {
// Prevent join if even teams are required, and there aren't enough remaining players to go around
player.sendWarning(new TranslatableComponent("command.gameplay.join.uneven"), false);
} else {
join(player, request, queryJoin(player, request, true));
}
}
}
/**
* Try to balance teams by bumping players to other teams
*/
public void balanceTeams() {
if(!config.autoBalance()) return;
logger.info("Auto-balancing teams");
for(;;) {
Team team = this.getFullestTeam();
if(team == null) break;
if(!team.isStacked()) break;
logger.info("Bumping a player from stacked team " + team.getColoredName());
if(!this.kickPlayerOffTeam(team, true)) break;
}
}
public boolean kickPlayerOffTeam(Team kickFrom, boolean forBalance) {
checkArgument(kickFrom.getMatch() == getMatch());
// Find all players who can be bumped
List<MatchPlayer> kickable = kickFrom.getPlayers().stream()
.filter(player -> !jmm.canPriorityKick(player) || (forBalance && isAutoJoin(player)))
.collect(Collectors.toImmutableList());
// Premium players can be auto-balanced if they auto-joined
if(kickable.isEmpty()) return false;
// Choose an unfortunate cheapskate
MatchPlayer kickMe = kickable.get(getMatch().getRandom().nextInt(kickable.size()));
// Try to put them on another team
final Party kickTo;
final JoinResult kickResult = queryAutoJoin(kickMe, false);
if(kickResult.isAllowed()) {
kickTo = kickResult.competitor().get();
} else {
// If no teams are available, kick them to observers, if necessary
if(forBalance) return false;
kickTo = getMatch().getDefaultParty();
}
// Give them the bad news
if(jmm.canPriorityKick(kickMe)) {
kickMe.sendMessage(new TranslatableComponent("gameplay.kickedForBalance", kickTo.getComponentName()));
kickMe.sendMessage(new TranslatableComponent("gameplay.autoJoinSwitch"));
} else {
kickMe.playSound(Sound.ENTITY_VILLAGER_HURT, kickMe.getBukkit().getLocation(), 1, 1);
if(forBalance) {
kickMe.sendWarning(new TranslatableComponent("gameplay.kickedForBalance", kickTo.getComponentName()), false);
kickMe.sendMessage(Links.shopPlug("shop.plug.neverSwitched"));
} else {
kickMe.sendWarning(new TranslatableComponent("gameplay.kickedForPremium", kickFrom.getComponentName()), false);
kickMe.sendMessage(Links.shopPlug("shop.plug.neverKicked"));
}
}
logger.info("Bumping " + kickMe.getDisplayName() + " to " + kickTo.getColoredName());
if(kickTo instanceof Team) {
return forceJoin(kickMe, (Team) kickTo);
} else {
return getMatch().setPlayerParty(kickMe, kickTo, false);
}
}
@EventHandler(priority = EventPriority.MONITOR)
public void onPartyChange(PlayerPartyChangeEvent event) {
if(event.getNewParty() instanceof Team) {
event.getPlayer().sendMessage(new TranslatableComponent("team.join", event.getNewParty().getComponentName()));
}
updateReadiness();
}
@EventHandler(priority = EventPriority.MONITOR)
public void onTeamResize(TeamResizeEvent event) {
updateReadiness();
}
}