566 lines
22 KiB
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();
|
|
}
|
|
}
|