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.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 Component 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("[" + getShortName() + "] ", 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; } } }