590 lines
20 KiB
Java
590 lines
20 KiB
Java
package tc.oc.pgm.teams;
|
|
|
|
import java.util.Collections;
|
|
import java.util.Comparator;
|
|
import java.util.HashSet;
|
|
import java.util.Objects;
|
|
import java.util.Optional;
|
|
import java.util.Set;
|
|
import java.util.function.Supplier;
|
|
import java.util.function.ToIntFunction;
|
|
import javax.annotation.Nullable;
|
|
import javax.inject.Inject;
|
|
|
|
import com.google.common.collect.Sets;
|
|
import com.google.inject.assistedinject.Assisted;
|
|
import net.md_5.bungee.api.ChatColor;
|
|
import net.md_5.bungee.api.chat.BaseComponent;
|
|
import org.apache.commons.lang.math.Fraction;
|
|
import org.bukkit.command.CommandSender;
|
|
import java.time.Duration;
|
|
import tc.oc.api.docs.PlayerId;
|
|
import tc.oc.api.docs.virtual.MatchDoc;
|
|
import tc.oc.commons.bukkit.chat.NameStyle;
|
|
import tc.oc.commons.core.chat.BlankComponent;
|
|
import tc.oc.commons.core.chat.ChatUtils;
|
|
import tc.oc.commons.core.chat.Component;
|
|
import tc.oc.commons.core.util.Comparables;
|
|
import tc.oc.commons.core.util.Numbers;
|
|
import tc.oc.commons.core.util.PunchClock;
|
|
import tc.oc.pgm.events.PartyRenameEvent;
|
|
import tc.oc.pgm.features.SluggedFeature;
|
|
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.JoinResult;
|
|
import tc.oc.pgm.match.Competitor;
|
|
import tc.oc.pgm.match.Match;
|
|
import tc.oc.pgm.match.MatchPlayer;
|
|
import tc.oc.pgm.match.MultiPlayerParty;
|
|
import tc.oc.pgm.teams.events.TeamResizeEvent;
|
|
|
|
/** Mutable class to represent a team created from a TeamInfo instance that is
|
|
* tied to a specific match and will only live as long as the match lives.
|
|
* Teams support custom names and colors that differ from the defaults
|
|
* specified by the map creator.
|
|
*/
|
|
public class Team extends MultiPlayerParty implements Competitor, SluggedFeature<TeamFactory> {
|
|
|
|
// The maximum allowed ratio between the "fullness" of any two teams in a match,
|
|
// as measured by the Team.getFullness method. An imbalance of one player is
|
|
// always allowed, even if it exceeds this ratio.
|
|
public static final Fraction MAX_IMBALANCE = Fraction.getFraction(6, 5);
|
|
|
|
public interface Factory {
|
|
Team create(TeamFactory definition);
|
|
}
|
|
|
|
private final TeamFactory info;
|
|
private final JoinConfiguration joinConfiguration;
|
|
private final TeamConfiguration teamConfiguration;
|
|
|
|
private TeamMatchModule tmm;
|
|
protected @Nullable String name = null;
|
|
protected @Nullable Component componentName;
|
|
protected BaseComponent chatPrefix;
|
|
protected Optional<Integer> minPlayers = Optional.empty(),
|
|
maxPlayers = Optional.empty(),
|
|
maxOverfill = Optional.empty();
|
|
protected final Document document = new Document();
|
|
protected final Set<PlayerId> pastPlayers = new HashSet<>();
|
|
|
|
// Recorded in the match document, Tourney plugin sets this
|
|
protected @Nullable String leagueTeamId;
|
|
|
|
// Players who have ever been on this team and their participation/absence times
|
|
protected final PunchClock<PlayerId> participationClock = new PunchClock<>(getMatch()::runningTime);
|
|
|
|
/** Construct a Team instance with the necessary information.
|
|
* @param info Defaults to use for name and color.
|
|
* @param match Match this team is in.
|
|
* @param joinConfiguration
|
|
* @param teamConfiguration
|
|
*/
|
|
@Inject Team(@Assisted TeamFactory info, Match match, JoinConfiguration joinConfiguration, TeamConfiguration teamConfiguration) {
|
|
super(match);
|
|
this.info = info;
|
|
this.joinConfiguration = joinConfiguration;
|
|
this.teamConfiguration = teamConfiguration;
|
|
}
|
|
|
|
@Override
|
|
public String toString() {
|
|
return getClass().getSimpleName() + "{match=" + getMatch() + ", name=" + getName() + "}";
|
|
}
|
|
|
|
@Override
|
|
public boolean equals(Object that) {
|
|
return this == that || (that instanceof Team &&
|
|
getDefinition().equals(((Team) that).getDefinition()) &&
|
|
getMatch().equals(((Team) that).getMatch()));
|
|
}
|
|
|
|
@Override
|
|
public int hashCode() {
|
|
return Objects.hash(getDefinition(), getMatch());
|
|
}
|
|
|
|
protected TeamMatchModule module() {
|
|
if(tmm == null) {
|
|
tmm = getMatch().needMatchModule(TeamMatchModule.class);
|
|
}
|
|
return tmm;
|
|
}
|
|
|
|
@Override
|
|
public String getId() {
|
|
return slug();
|
|
}
|
|
|
|
@Override
|
|
public String slug() {
|
|
return match.featureDefinitions().slug(getDefinition());
|
|
}
|
|
|
|
/** Gets map specified information about this team.
|
|
* @return Map-specific information about the team.
|
|
*/
|
|
public TeamFactory getInfo() {
|
|
return this.info;
|
|
}
|
|
|
|
public @Nullable String getLeagueTeamId() {
|
|
return leagueTeamId;
|
|
}
|
|
|
|
public void setLeagueTeamId(@Nullable String leagueTeamId) {
|
|
this.leagueTeamId = leagueTeamId;
|
|
}
|
|
|
|
@Override
|
|
public MatchDoc.Team getDocument() {
|
|
return document;
|
|
}
|
|
|
|
@Override
|
|
public TeamFactory getDefinition() {
|
|
return this.info;
|
|
}
|
|
|
|
@Override
|
|
public Type getType() {
|
|
return Type.Participating;
|
|
}
|
|
|
|
@Override
|
|
public boolean isParticipatingType() {
|
|
return true;
|
|
}
|
|
|
|
@Override
|
|
public boolean isParticipating() {
|
|
return match.isRunning();
|
|
}
|
|
|
|
@Override
|
|
public boolean isObservingType() {
|
|
return false;
|
|
}
|
|
|
|
@Override
|
|
public boolean isObserving() {
|
|
return !match.isRunning();
|
|
}
|
|
|
|
@Override
|
|
public String getDefaultName() {
|
|
return info.getDefaultName();
|
|
}
|
|
|
|
/** Gets the name of this team that can be modified using setTeam. If no
|
|
* custom name is set then this will return the default team name as
|
|
* specified in the team info.
|
|
* @return Name of the team without colors.
|
|
*/
|
|
@Override
|
|
public String getName() {
|
|
return name != null ? name : getDefaultName();
|
|
}
|
|
|
|
public String getShortName() {
|
|
String lower = getName().toLowerCase();
|
|
if(lower.endsWith(" team")) {
|
|
return getName().substring(0, lower.length() - " team".length());
|
|
} else if(lower.startsWith("team ")) {
|
|
return getName().substring("team ".length());
|
|
} else {
|
|
return getName();
|
|
}
|
|
}
|
|
|
|
@Override
|
|
public String getName(@Nullable CommandSender viewer) {
|
|
return getName();
|
|
}
|
|
|
|
@Override
|
|
public boolean isNamePlural() {
|
|
// Assume custom names are singular
|
|
return this.name == null && this.info.isDefaultNamePlural();
|
|
}
|
|
|
|
/** Gets the combination of the team color with the team name.
|
|
* @return Colored version of the team name.
|
|
*/
|
|
@Override
|
|
public String getColoredName() {
|
|
return getColor() + getName();
|
|
}
|
|
|
|
@Override
|
|
public String getColoredName(@Nullable CommandSender viewer) {
|
|
return getColor() + getName(viewer);
|
|
}
|
|
|
|
/** Sets a custom name for this team that should be unique in the match.
|
|
* Note that setting the name to null will reset it to the default name as
|
|
* specified in the team info.
|
|
* @param newName New name for this team. Should not include colors.
|
|
*/
|
|
public void setName(@Nullable String newName) {
|
|
if(Objects.equals(this.name, newName) || this.getName().equals(newName)) return;
|
|
String oldName = this.getName();
|
|
this.name = newName;
|
|
this.componentName = null;
|
|
this.match.callEvent(new PartyRenameEvent(this, oldName, this.getName()));
|
|
}
|
|
|
|
@Override
|
|
public ChatColor getColor() {
|
|
return this.info.getDefaultColor();
|
|
}
|
|
|
|
@Override
|
|
public BaseComponent getComponentName() {
|
|
if(componentName == null) {
|
|
this.componentName = new Component(getName(), ChatUtils.convert(getColor()));
|
|
}
|
|
return componentName;
|
|
}
|
|
|
|
@Override
|
|
public BaseComponent getStyledName(NameStyle style) {
|
|
return getComponentName();
|
|
}
|
|
|
|
@Override
|
|
public BaseComponent getChatPrefix() {
|
|
if(chatPrefix == null) {
|
|
this.chatPrefix = new Component("(Team) ", ChatUtils.convert(getColor()));
|
|
}
|
|
return chatPrefix;
|
|
}
|
|
|
|
@Override
|
|
public org.bukkit.scoreboard.Team.OptionStatus getNameTagVisibility() {
|
|
return info.getNameTagVisibility();
|
|
}
|
|
|
|
public int getMinPlayers() {
|
|
return minPlayers.orElse(info.getMinPlayers().orElse(teamConfiguration.minimumPlayers()));
|
|
}
|
|
|
|
public int getMaxPlayers() {
|
|
return maxPlayers.orElse(info.getMaxPlayers());
|
|
}
|
|
|
|
public int getMaxOverfill() {
|
|
return maxOverfill.orElse(info.getMaxOverfill().orElse(joinConfiguration.overfillFromMax(getMaxPlayers())));
|
|
}
|
|
|
|
public void setMinSize(@Nullable Integer minPlayers) {
|
|
this.minPlayers = Optional.ofNullable(minPlayers);
|
|
|
|
if(getMaxPlayers() < getMinPlayers()) {
|
|
this.maxPlayers = Optional.of(getMinPlayers());
|
|
}
|
|
if(getMaxOverfill() < getMaxPlayers()) {
|
|
this.maxOverfill = Optional.of(getMaxPlayers());
|
|
}
|
|
|
|
getMatch().callEvent(new TeamResizeEvent(this));
|
|
module().updatePlayerLimits();
|
|
}
|
|
|
|
public void resetMinSize() {
|
|
setMinSize(null);
|
|
}
|
|
|
|
public void setMaxSize(@Nullable Integer maxPlayers, @Nullable Integer maxOverfill) {
|
|
this.maxPlayers = Optional.ofNullable(maxPlayers);
|
|
this.maxOverfill = Optional.ofNullable(maxOverfill);
|
|
|
|
if(getMinPlayers() > getMaxPlayers()) {
|
|
this.minPlayers = Optional.of(getMaxPlayers());
|
|
}
|
|
if(getMaxOverfill() < getMaxPlayers()) {
|
|
this.maxOverfill = Optional.of(getMaxPlayers());
|
|
}
|
|
|
|
getMatch().callEvent(new TeamResizeEvent(this));
|
|
module().updatePlayerLimits();
|
|
}
|
|
|
|
public void resetMaxSize() {
|
|
setMaxSize(null, null);
|
|
}
|
|
|
|
public PunchClock<PlayerId> getParticipationClock() {
|
|
return participationClock;
|
|
}
|
|
|
|
public Duration getCumulativeParticipation(PlayerId playerId) {
|
|
return getParticipationClock().getCumulativePresence(playerId);
|
|
}
|
|
|
|
@Override
|
|
public Set<PlayerId> getPastPlayers() {
|
|
return pastPlayers;
|
|
}
|
|
|
|
@Override
|
|
public boolean addPlayerInternal(MatchPlayer player) {
|
|
if(super.addPlayerInternal(player)) {
|
|
participationClock.punchIn(player.getPlayerId());
|
|
if(getMatch().isCommitted()) {
|
|
pastPlayers.add(player.getPlayerId());
|
|
}
|
|
return true;
|
|
} else {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
@Override
|
|
public boolean removePlayerInternal(MatchPlayer player) {
|
|
if(super.removePlayerInternal(player)) {
|
|
participationClock.punchOut(player.getPlayerId());
|
|
return true;
|
|
} else {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
@Override
|
|
public void commit() {
|
|
for(MatchPlayer player : getPlayers()) {
|
|
pastPlayers.add(player.getPlayerId());
|
|
}
|
|
}
|
|
|
|
@Override
|
|
public boolean isAutomatic() {
|
|
return false;
|
|
}
|
|
|
|
protected @Nullable MatchPlayer joiningPlayer() {
|
|
final Change change = CHANGE.get();
|
|
return change != null && equals(change.newTeam) ? change.player : null;
|
|
}
|
|
|
|
@Override
|
|
public Set<MatchPlayer> getPlayers() {
|
|
final Change change = CHANGE.get();
|
|
if(change != null) {
|
|
if(equals(change.oldTeam)) {
|
|
return Sets.difference(super.getPlayers(), Collections.singleton(change.player));
|
|
} else if(equals(change.newTeam)) {
|
|
return Sets.union(super.getPlayers(), Collections.singleton(change.player));
|
|
}
|
|
}
|
|
return super.getPlayers();
|
|
}
|
|
|
|
/**
|
|
* Return the number of players on this team.
|
|
* If priority is true, exclude players who can be bumped off the team.
|
|
*/
|
|
public int getSize() {
|
|
final int realSize = super.getPlayers().size();
|
|
final Change change = CHANGE.get();
|
|
if(change != null) {
|
|
if(equals(change.oldTeam)) {
|
|
return realSize - 1;
|
|
} else if(equals(change.newTeam)) {
|
|
return realSize + 1;
|
|
}
|
|
}
|
|
return realSize;
|
|
}
|
|
|
|
/**
|
|
* Get the "fullness" of this team, relative to some capacity returned by
|
|
* the given function. The return value is always in the range 0 to 1.
|
|
*/
|
|
public Fraction getFullness(ToIntFunction<? super Team> maxFunction) {
|
|
final int max = maxFunction.applyAsInt(this);
|
|
return max == 0 ? Fraction.ONE
|
|
: Fraction.getReducedFraction(getSize(), max);
|
|
}
|
|
|
|
/**
|
|
* Get the maximum number of players currently allowed on this team without
|
|
* exceeding any limits.
|
|
*/
|
|
public int getMaxBalancedSize() {
|
|
// Find the minimum fullness among other teams
|
|
final Fraction minFullness = (Fraction) module().getTeams()
|
|
.stream()
|
|
.filter(team -> !equals(team))
|
|
.map(team -> team.getFullness(Team::getMaxOverfill))
|
|
.min(Comparator.naturalOrder())
|
|
.orElse(Fraction.ONE);
|
|
|
|
// Calculate the dynamic limit to maintain balance with other teams (this can be zero)
|
|
int slots = Numbers.ceil(Comparables.min(Fraction.ONE, minFullness.multiplyBy(MAX_IMBALANCE))
|
|
.multiplyBy(Fraction.getFraction(getMaxOverfill(), 1)));
|
|
|
|
// Clamp to the static limit defined for this team (cannot be zero unless the static limit is zero)
|
|
return Math.min(getMaxOverfill(), Math.max(1, slots));
|
|
}
|
|
|
|
public boolean isStacked() {
|
|
return this.getSize() > this.getMaxBalancedSize();
|
|
}
|
|
|
|
public int getTotalSlots(MatchPlayer joining) {
|
|
return getMatch().needMatchModule(JoinMatchModule.class).canJoinFull(joining) ? getMaxOverfill() : getMaxPlayers();
|
|
}
|
|
|
|
/**
|
|
* Return the number of available slots for the given player. If priority is true,
|
|
* and the joining player has priority kick privileges, assume that non-privileged
|
|
* players can be kicked off the team to make room.
|
|
*/
|
|
public int getOpenSlots(MatchPlayer joining, boolean priorityKick) {
|
|
// Can always join obs
|
|
if(this.getType() == Type.Observing) return 1;
|
|
|
|
// Count existing team members with and without join privileges
|
|
int normal = 0, privileged = 0;
|
|
|
|
final JoinMatchModule jmm = getMatch().needMatchModule(JoinMatchModule.class);
|
|
|
|
for(MatchPlayer player : this.getPlayers()) {
|
|
if(jmm.canPriorityKick(player)) privileged++;
|
|
else normal++;
|
|
}
|
|
|
|
// Get the total slots available to the joining player
|
|
// Deduct slots in use by privileged players, who cannot be kicked
|
|
int slots = getTotalSlots(joining) - privileged;
|
|
|
|
// If normal players cannot be bumped, deduct them as well
|
|
if(!priorityKick || !jmm.canPriorityKick(joining)) {
|
|
slots -= normal;
|
|
}
|
|
|
|
return Math.max(0, slots);
|
|
}
|
|
|
|
/**
|
|
* @return if there is a free slot available for the given player to join this team.
|
|
* If the player is already on this team, the test behaves as if they are not.
|
|
*/
|
|
public boolean hasOpenSlots(MatchPlayer joining, boolean priorityKick) {
|
|
return this.getOpenSlots(joining, priorityKick) > 0;
|
|
}
|
|
|
|
/**
|
|
* Perform a join query specific to this team, similar to {@link JoinHandler#queryJoin}.
|
|
*
|
|
* @param joining Player who is joining
|
|
* @param priorityKick If false, priority kicking is not considered at all.
|
|
* If true, priority kicking is considered if the player has that privilege.
|
|
* @param rejoin If true, assume the player was previously on this team and is rejoining.
|
|
*
|
|
* @return The result of the hypothetical join
|
|
*/
|
|
public JoinResult queryJoin(MatchPlayer joining, boolean priorityKick, boolean rejoin) {
|
|
if(hasOpenSlots(joining, false)) {
|
|
return new JoinTeam(this, rejoin, false);
|
|
}
|
|
|
|
if(priorityKick && hasOpenSlots(joining, true)) {
|
|
return new JoinTeam(this, rejoin, true);
|
|
}
|
|
|
|
return JoinDenied.unavailable("command.gameplay.join.completelyFull", getComponentName());
|
|
}
|
|
|
|
// TODO: send an update to the API when any of these values change
|
|
class Document implements MatchDoc.Team {
|
|
@Override
|
|
public String _id() {
|
|
return slug();
|
|
}
|
|
|
|
@Override
|
|
public String name() {
|
|
return getName();
|
|
}
|
|
|
|
@Override
|
|
public @Nullable Integer min_players() {
|
|
return getMinPlayers();
|
|
}
|
|
|
|
@Override
|
|
public @Nullable Integer max_players() {
|
|
return getMaxPlayers();
|
|
}
|
|
|
|
@Override
|
|
public @Nullable net.md_5.bungee.api.ChatColor color() {
|
|
return ChatUtils.convert(getColor());
|
|
}
|
|
|
|
@Override
|
|
public @Nullable Integer size() {
|
|
return getParticipationClock().getAllWithPresence().size();
|
|
}
|
|
|
|
@Override
|
|
public String league_team_id() {
|
|
return leagueTeamId;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Run the given block with a temporary team change in effect for all team queries and calculations.
|
|
* All {@link Team}s will behave as if the given player has left their current team, if any, and
|
|
* joined the given team, if its non-null.
|
|
*
|
|
* If the given player is null, or the specified change is not actually a change, the block is
|
|
* run without altering any behavior. Whatever the block returns is returned by this method.
|
|
*
|
|
* This mechanism affects {@link #getPlayers()}, {@link #getSize()}, and any other method derived
|
|
* from those, which includes all the methods involved in joining and balancing teams. This is
|
|
* useful for performing calculations about some hypothetical state, without actually changing
|
|
* the current state.
|
|
*
|
|
* Internally, this works by storing the change in a static {@link ThreadLocal}, which is checked
|
|
* by various methods. If a change is present, they adjust their behavior to reflect the change.
|
|
* The effects of this method are only visible while it is executing, and only to the current thread.
|
|
*
|
|
* Only one change can be simulated at a time on any particular thread. Calling this method again
|
|
* from within the block will throw a {@link IllegalStateException}.
|
|
*/
|
|
public static <R> R withChange(@Nullable MatchPlayer player, @Nullable Team newTeam, Supplier<R> block) {
|
|
if(player == null || Objects.equals(player.partyMaybe().orElse(null), newTeam)) {
|
|
return block.get();
|
|
} else {
|
|
if(CHANGE.get() != null) {
|
|
throw new IllegalStateException("Nested call to Team.withChange");
|
|
}
|
|
try {
|
|
CHANGE.set(new Change(player, Teams.get(player), newTeam));
|
|
return block.get();
|
|
} finally {
|
|
CHANGE.remove();
|
|
}
|
|
}
|
|
}
|
|
|
|
private static final ThreadLocal<Change> CHANGE = new ThreadLocal<>();
|
|
|
|
private static class Change {
|
|
final MatchPlayer player;
|
|
final @Nullable Team oldTeam;
|
|
final @Nullable Team newTeam;
|
|
|
|
private Change(MatchPlayer player, Team oldTeam, Team newTeam) {
|
|
this.player = player;
|
|
this.oldTeam = oldTeam;
|
|
this.newTeam = newTeam;
|
|
}
|
|
}
|
|
}
|