diff --git a/src/main/java/net/tfminecraft/tfmccore/TFMCCore.java b/src/main/java/net/tfminecraft/tfmccore/TFMCCore.java index 7a84596..4f14e71 100644 --- a/src/main/java/net/tfminecraft/tfmccore/TFMCCore.java +++ b/src/main/java/net/tfminecraft/tfmccore/TFMCCore.java @@ -5,6 +5,9 @@ import java.util.Set; import java.util.Random; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.event.player.PlayerQuitEvent; import org.bukkit.plugin.java.JavaPlugin; import io.papermc.paper.plugin.lifecycle.event.types.LifecycleEvents; @@ -57,6 +60,7 @@ public class TFMCCore extends JavaPlugin{ private final SkillsStatConfig skillsStatConfig = new SkillsStatConfig(); private final FactionsStatConfig factionsStatConfig = new FactionsStatConfig(); private final TfmcConfig tfmcConfig = new TfmcConfig(); + private TfmcCommand tfmcCommand; private final CoreManager coreManager = new CoreManager(); private final StationManager stationManager = new StationManager(); @@ -140,14 +144,21 @@ private void registerTfmcCommand() { } cooldowns.save(); } - TfmcCommand tfmc = new TfmcCommand(tfmcConfig, cooldowns, new BukkitTfmcActions(this), + TfmcCommand tfmc = tfmcCommand = new TfmcCommand(tfmcConfig, cooldowns, new BukkitTfmcActions(this), Clock.systemDefaultZone(), new Random(), task -> getServer().getScheduler().runTask(this, task)); getLifecycleManager().registerEventHandler(LifecycleEvents.COMMANDS, event -> event.registrar().register(tfmc.build(), "TFMC player commands")); + getServer().getPluginManager().registerEvents(new Listener() { + @EventHandler + public void onQuit(PlayerQuitEvent event) { + tfmc.onQuit(event.getPlayer()); + } + }, this); } @Override public void onDisable() { + if (tfmcCommand != null) tfmcCommand.shutdown(); if (multipartPacks != null) multipartPacks.close(); if (stoneListener != null) { stoneListener.refundAll(); diff --git a/src/main/java/net/tfminecraft/tfmccore/tfmc/BukkitTfmcActions.java b/src/main/java/net/tfminecraft/tfmccore/tfmc/BukkitTfmcActions.java index 157fe20..2d18199 100644 --- a/src/main/java/net/tfminecraft/tfmccore/tfmc/BukkitTfmcActions.java +++ b/src/main/java/net/tfminecraft/tfmccore/tfmc/BukkitTfmcActions.java @@ -9,6 +9,9 @@ import org.bukkit.entity.Player; import org.bukkit.inventory.ItemStack; import org.bukkit.plugin.RegisteredServiceProvider; +import org.bukkit.potion.PotionEffect; +import org.bukkit.potion.PotionEffectType; +import org.bukkit.scheduler.BukkitTask; import net.kyori.adventure.text.Component; import net.luckperms.api.LuckPerms; @@ -106,6 +109,30 @@ public CompletableFuture> stepTrack(Player player, String track }); } + @Override + public FlightState startFlight(Player player, float speed) { + FlightState previous = new FlightState(player.getAllowFlight(), player.isFlying(), player.getFlySpeed()); + player.setAllowFlight(true); + player.setFlySpeed(Math.max(-1f, Math.min(1f, speed))); + return previous; + } + + @Override + public void endFlight(Player player, FlightState previous) { + player.setFlySpeed(previous.flySpeed()); + player.setAllowFlight(previous.allowFlight()); + player.setFlying(previous.allowFlight() && previous.flying()); + if (!player.getAllowFlight() && !player.isOnGround()) { + player.addPotionEffect(new PotionEffect(PotionEffectType.SLOW_FALLING, 100, 0, false, false, true)); + } + } + + @Override + public Runnable later(long ticks, Runnable task) { + BukkitTask scheduled = Bukkit.getScheduler().runTaskLater(plugin, task, ticks); + return scheduled::cancel; + } + private static CompletableFuture withUser(LuckPerms api, Player player) { User loaded = api.getUserManager().getUser(player.getUniqueId()); return loaded != null diff --git a/src/main/java/net/tfminecraft/tfmccore/tfmc/TfmcActions.java b/src/main/java/net/tfminecraft/tfmccore/tfmc/TfmcActions.java index ade2c32..7c2cca8 100644 --- a/src/main/java/net/tfminecraft/tfmccore/tfmc/TfmcActions.java +++ b/src/main/java/net/tfminecraft/tfmccore/tfmc/TfmcActions.java @@ -12,6 +12,9 @@ /** Side effects of the /tfmc subcommands, separated from the command tree for testing. */ public interface TfmcActions { + /** A player's flight settings before a temporary change, so they can be put back. */ + record FlightState(boolean allowFlight, boolean flying, float flySpeed) {} + void consoleCommand(String command); void playerCommand(Player player, String command); @@ -28,4 +31,13 @@ public interface TfmcActions { /** Moves the player one step along a LuckPerms track; completes with the new group. */ CompletableFuture> stepTrack(Player player, String track, boolean promote); + + /** Lets the player fly at {@code speed} and returns their previous flight settings. */ + FlightState startFlight(Player player, float speed); + + /** Restores flight settings, easing the player down if the change leaves them airborne. */ + void endFlight(Player player, FlightState previous); + + /** Runs {@code task} on the main thread after {@code ticks}; the returned action cancels it. */ + Runnable later(long ticks, Runnable task); } diff --git a/src/main/java/net/tfminecraft/tfmccore/tfmc/TfmcCommand.java b/src/main/java/net/tfminecraft/tfmccore/tfmc/TfmcCommand.java index 9e53616..24c2db7 100644 --- a/src/main/java/net/tfminecraft/tfmccore/tfmc/TfmcCommand.java +++ b/src/main/java/net/tfminecraft/tfmccore/tfmc/TfmcCommand.java @@ -5,8 +5,11 @@ import java.util.ArrayList; import java.util.List; import java.util.Locale; +import java.util.Map; import java.util.Optional; +import java.util.UUID; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.Executor; import java.util.function.Consumer; import java.util.function.Predicate; @@ -43,6 +46,8 @@ public final class TfmcCommand { public static final String NAME = "tfmc"; public static final String BOOSTER_COOLDOWN = "booster"; + private record ParrotFlight(TfmcActions.FlightState previous, Runnable cancelExpiry) {} + private static final Logger LOGGER = Logger.getLogger("TFMCCore"); private static final Pattern PLAYER_NAME = Pattern.compile("[A-Za-z0-9_]{1,16}"); private static final MiniMessage MINI = MiniMessage.miniMessage(); @@ -53,6 +58,7 @@ public final class TfmcCommand { private final Clock clock; private final RandomGenerator random; private final Executor mainThread; + private final Map parrots = new ConcurrentHashMap<>(); /** {@code mainThread} runs LuckPerms completions, which arrive on LuckPerms worker threads. */ public TfmcCommand(TfmcConfig config, TfmcCooldowns cooldowns, TfmcActions actions, Clock clock, @@ -106,6 +112,34 @@ public LiteralCommandNode build() { .executes(player(p -> step(p, name, false))))); } + // Content still being built on dev; each section stays hidden until tfmc.yml enables it + root.then(Commands.literal("tutorial").requires(enabled("tutorials")) + .then(Commands.argument("name", StringArgumentType.word()) + .suggests((context, builder) -> { + String typed = builder.getRemainingLowerCase(); + config.keys("tutorials.list").stream() + .filter(tutorial -> tutorial.toLowerCase(Locale.ROOT).startsWith(typed)) + .forEach(builder::suggest); + return builder.buildFuture(); + }) + .then(Commands.literal("clear").executes(context -> { + String name = StringArgumentType.getString(context, "name"); + return player(p -> clearTutorial(p, name)).run(context); + })))); + + root.then(Commands.literal("parrot") + .requires(enabled("parrot").and(anyPermission("parrot.permissions"))) + .executes(player(this::parrot))); + root.then(Commands.literal("unparrot").requires(enabled("parrot")) + .executes(player(p -> { + if (!endParrot(p, true)) { + send(p, "parrot.not-a-parrot"); + } + }))); + + root.then(Commands.literal("worldboss").requires(enabled("worldboss")) + .then(Commands.literal("info").executes(player(p -> send(p, "worldboss.info"))))); + root.then(Commands.literal("poster") .then(Commands.argument("name", StringArgumentType.word()) .suggests((context, builder) -> { @@ -244,6 +278,65 @@ private void step(Player player, String track, boolean promote) { }, mainThread); } + private void clearTutorial(Player player, String name) { + Optional tutorial = config.keys("tutorials.list").stream() + .filter(key -> key.equalsIgnoreCase(name)) + .findFirst(); + if (tutorial.isEmpty()) { + send(player, "tutorials.unknown", Placeholder.unparsed("name", name)); + return; + } + String base = "tutorials.list." + tutorial.get(); + if (onCooldown(player, "tutorial." + tutorial.get(), config.seconds("tutorials.cooldown-seconds"), "messages.cooldown")) { + return; + } + runConsole(player, config.lines(base + ".clear")); + send(player, "tutorials.cleared", Placeholder.unparsed("name", config.string(base + ".name", tutorial.get()))); + } + + private void parrot(Player player) { + if (parrots.containsKey(player.getUniqueId())) { + send(player, "parrot.already"); + return; + } + if (onCooldown(player, "parrot", config.seconds("parrot.cooldown-seconds"), "parrot.cooldown")) { + return; + } + long seconds = Math.max(1L, config.seconds("parrot.duration-seconds")); + actions.consoleCommand(config.string("parrot.disguise") + .replace("", player.getName()).replace("", Long.toString(seconds))); + TfmcActions.FlightState previous = actions.startFlight(player, (float) config.decimal("parrot.fly-speed", 0.01)); + Runnable cancel = actions.later(seconds * 20L, () -> endParrot(player, false)); + parrots.put(player.getUniqueId(), new ParrotFlight(previous, cancel)); + send(player, "parrot.started"); + } + + /** Ends a parrot flight early or on expiry; false when the player is not a parrot. */ + private boolean endParrot(Player player, boolean undisguise) { + ParrotFlight flight = parrots.remove(player.getUniqueId()); + if (flight == null) { + return false; + } + flight.cancelExpiry().run(); + if (undisguise) { + actions.consoleCommand(config.string("parrot.undisguise").replace("", player.getName())); + } + actions.endFlight(player, flight.previous()); + send(player, "parrot.ended"); + return true; + } + + /** Puts a leaving player's flight settings back so the temporary flight is not saved with them. */ + public void onQuit(Player player) { + endParrot(player, true); + } + + public void shutdown() { + for (Player player : Bukkit.getOnlinePlayers()) { + endParrot(player, true); + } + } + private void poster(Player player, String name) { config.entries("posters").entrySet().stream() .filter(poster -> poster.getKey().equalsIgnoreCase(name)) @@ -271,12 +364,16 @@ private void runConsole(Player player, List commands) { } private boolean onCooldown(Player player, String subcommand) { - long left = cooldowns.remaining(subcommand, player.getUniqueId(), config.seconds(subcommand + ".cooldown-seconds") * 1000L); + return onCooldown(player, subcommand, config.seconds(subcommand + ".cooldown-seconds"), "messages.cooldown"); + } + + private boolean onCooldown(Player player, String key, long seconds, String messagePath) { + long left = cooldowns.remaining(key, player.getUniqueId(), seconds * 1000L); if (left > 0) { - send(player, "messages.cooldown", Placeholder.unparsed("time", TfmcCooldowns.format(left))); + send(player, messagePath, Placeholder.unparsed("time", TfmcCooldowns.format(left))); return true; } - cooldowns.markUsed(subcommand, player.getUniqueId()); + cooldowns.markUsed(key, player.getUniqueId()); return false; } @@ -305,6 +402,18 @@ private String trackPermission(String track, boolean promote) { return promote ? steps.promotePermission() : steps.demotePermission(); } + private Predicate enabled(String section) { + return source -> config.enabled(section); + } + + /** Any of the listed permissions; an empty list means everyone. */ + private Predicate anyPermission(String path) { + return source -> { + List nodes = config.lines(path); + return nodes.isEmpty() || nodes.stream().anyMatch(source.getSender()::hasPermission); + }; + } + // Restricted nodes fail closed: a missing or blank permission hides the command from everyone private static Predicate permission(Supplier node) { return source -> { diff --git a/src/main/java/net/tfminecraft/tfmccore/tfmc/TfmcConfig.java b/src/main/java/net/tfminecraft/tfmccore/tfmc/TfmcConfig.java index efee100..dd8b476 100644 --- a/src/main/java/net/tfminecraft/tfmccore/tfmc/TfmcConfig.java +++ b/src/main/java/net/tfminecraft/tfmccore/tfmc/TfmcConfig.java @@ -51,6 +51,14 @@ public String string(String path, String fallback) { return value == null || value.isBlank() ? fallback : value; } + public boolean enabled(String section) { + return config.getBoolean(section + ".enabled", false); + } + + public double decimal(String path, double fallback) { + return config.getDouble(path, fallback); + } + public long seconds(String path) { return Math.max(0L, config.getLong(path, 0L)); } diff --git a/src/main/resources/tfmc.yml b/src/main/resources/tfmc.yml index 7eaf0cd..1b04a52 100644 --- a/src/main/resources/tfmc.yml +++ b/src/main/resources/tfmc.yml @@ -172,3 +172,135 @@ posters: Zerratoris6: "images create https://cdn.imgchest.com/files/yd5cemzr9v4.jpg 42.74553571428" Zerratoris7: "images create https://cdn.imgchest.com/files/7951abcc2f81.jpg 42.74553571428" Zerratoris8: "images create https://cdn.imgchest.com/files/c34edb81f614.jpg 42.74553571428" + +# --------------------------------------------------------------------------- +# Content still being built on dev. Each section is hidden until enabled: true. +# --------------------------------------------------------------------------- + +# /tfmc tutorial clear takes back the items a tutorial lantern handed out. +tutorials: + enabled: false + cooldown-seconds: 10 + cleared: "Cleared inventory of items from Tutorial" + unknown: "There is no tutorial called ." + list: + archaeology: + name: Archaeology + clear: + - "mi take TOOLS ARCHAEO_TRACKER 1" + - "mi take TOOLS ARCHAEO_PROSPECT 1" + - "mi take TOOLS ARCHAEO_ESTABLISH 1" + - "mi take TOOLS ARCHEOLOGY_HANDPICK 1" + - "mi take TOOLS ARCHAEO_PENCIL 1" + - "clear brush 1" + - "clear paper 1" + - "clear water_bucket 1" + - "clear bucket 1" + - "clear feather 1" + - "iaremove tfmc:archeology_cabinet 1 silent" + cooking: + name: Cooking + clear: + - "clear carrot 2" + - "iaremove tfmc_cooking:cutting_knife 1 silent" + - "iaremove tfmc_cooking:cutting_board 1 silent" + - "iaremove tfmc_cooking:frying_pan 1 silent" + - "iaremove tfmc_cooking:oven_bottom 1 silent" + - "clear oak_log 6" + - "clear flint_and_steel 1" + factions: + name: Factions + clear: + - "clear chest 1" + fishing: + name: Fishing + clear: + - "mi take FISHING_RODS FISHING_ROD 1" + - "clear shears 1" + - "clear paper 1" + guns: + name: Guns + clear: + - "clear iron_ingot 10" + - "mi take UTILS PEBBLESHOT 16" + instruments: + name: Instruments + clear: + - "mi take INSTRUMENTS LUTE 1" + - "mi take INSTRUMENTS FLUTE 1" + magic: + name: Magic + clear: + - "clear iron_ingot 4" + - "clear stick 2" + - "clear book 2" + - "mi take CURRENCY ENCHANTED_DUST 6" + - "mi take OSENI_RUNES RUNE_OF_FIRE_SHARD 1" + - "mi take MATERIALS ENCHANTED_CHARGE_1 1" + - "iaremove tfmc:pedestal 1 silent" + marketblock: + name: Market Block + clear: + - "iaremove tfmc:market_block 1 silent" + - "clear wheat 128" + nodes: + name: Resource Node + clear: + - "iaremove tfmc:general_node 1 silent" + - "clear barrel 1" + - "clear hopper 1" + - "clear cobblestone 16" + - "clear stick 16" + recycler: + name: Recycler + clear: + - "clear iron_sword 1" + - "clear iron_ingot 2" + sitting: + name: Sitting + clear: + - "clear oak_stairs 1" + - "clear white_carpet 1" + thievery: + name: Thievery + clear: + - "mi take LOCKPICKS BASIC_LOCKPICK 1" + - "mi take KEYS IRON_KEY 1" + - "clear chest 1" + - "rpcharacter removetrait thief" + woodworking: + name: Woodworking + clear: + - "mi take TOOLS SKIN_BRANDING_TOOL 1" + - "mi take TOOLS ENGRAVING_TOOL 1" + - "mi take MATERIALS SILK 2" + +# /tfmc parrot disguises the player as a parrot and lets them fly briefly; /tfmc unparrot ends it early. +parrot: + enabled: false + # Any of these; leave empty for everyone. + permissions: [] + cooldown-seconds: 300 + duration-seconds: 20 + # Bukkit fly speed (default 0.1); 0.01 matches the old "flyspeed fly 0.1". + fly-speed: 0.01 + disguise: "libsdisguises:disguiseplayer parrot setVariant GRAY setExpires s" + undisguise: "libsdisguises:undisguiseplayer " + started: "You are now a <#eeeeee>Parrot" + ended: "You are no longer a <#eeeeee>Parrot" + already: "You are already a parrot. Use <#eeeeee>/tfmc unparrot to stop." + not-a-parrot: "You are not a parrot." + cooldown: "You need to wait before turning into a <#eeeeee>Parrot again." + +worldboss: + enabled: false + info: + - "━━━━━━━━━━━━━━━" + - "TFMC Roleplay World Boss Info" + - "" + - "There are several world bosses in TFMC. These are hard fights that can grant powerful loot. To start a boss fight you will need the corresponding boss ticket. Each person that wants to take part in the fight will need to use one." + - "" + - "You can get the tickets in the Voting Crates. To use a ticket you will need to right click 2 times (2nd time to confirm). Once you are in the lobby before the fight, wait for everyone before pressing the soul lantern to start the fight." + - "" + - "Each boss can only be fought by one group at the same time, so if there is no lantern you will need to wait for the current group to finish. There is no cooldown for fighting the different bosses, but you can always run into the danger of dying. (You can always use an Insurance Ticket if you die)." + - "━━━━━━━━━━━━━━━" diff --git a/src/test/java/net/tfminecraft/tfmccore/tfmc/TfmcCommandTest.java b/src/test/java/net/tfminecraft/tfmccore/tfmc/TfmcCommandTest.java index 6810bb7..2f844a2 100644 --- a/src/test/java/net/tfminecraft/tfmccore/tfmc/TfmcCommandTest.java +++ b/src/test/java/net/tfminecraft/tfmccore/tfmc/TfmcCommandTest.java @@ -49,12 +49,17 @@ class TfmcCommandTest { private final List permissionChanges = new ArrayList<>(); private final List trackSteps = new ArrayList<>(); private final List given = new ArrayList<>(); + private final List flights = new ArrayList<>(); + private final List scheduledTicks = new ArrayList<>(); + private final List scheduled = new ArrayList<>(); + private int cancelled; private final List mainThread = new ArrayList<>(); private int packsSent; private long clock = NOW.toEpochMilli(); private CommandDispatcher dispatcher; private TfmcConfig config; + private TfmcCommand command; @BeforeEach void setUp() throws Exception { @@ -72,7 +77,7 @@ public int nextInt(int bound) { return 0; } }; - TfmcCommand command = new TfmcCommand(config, cooldowns, new RecordingActions(), + command = new TfmcCommand(config, cooldowns, new RecordingActions(), Clock.fixed(NOW, ZoneOffset.UTC), firstMask, mainThread::add); dispatcher = new CommandDispatcher<>(); dispatcher.getRoot().addChild(command.build()); @@ -240,6 +245,94 @@ void postersRunTheImagesCommandAsThePlayer() throws Exception { assertEquals(List.of("images create https://cdn.imgchest.com/files/739cxwpeqp7.jpg 42.74553571428"), playerCommands); } + @Test + void devContentIsHiddenUntilEnabled() throws Exception { + TestPlayer steve = player("Steve"); + assertFalse(visible(steve).contains("tutorial")); + assertFalse(visible(steve).contains("parrot")); + assertFalse(visible(steve).contains("worldboss")); + + enableDevContent(); + assertTrue(visible(steve).containsAll(List.of("tutorial", "parrot", "unparrot", "worldboss"))); + } + + @Test + void tutorialClearTakesBackTheTutorialItems() throws Exception { + enableDevContent(); + TestPlayer steve = player("Steve"); + + run(steve, "tfmc tutorial cooking clear"); + assertEquals("clear Steve carrot 2", console.get(0)); + assertEquals(7, console.size()); + assertEquals(List.of("Cleared inventory of items from Cooking Tutorial"), steve.messages); + + run(steve, "tfmc tutorial Cooking clear"); + assertEquals(7, console.size()); + assertEquals("You need to wait 10s before using this again.", steve.messages.get(0)); + + run(steve, "tfmc tutorial sitting clear"); + assertEquals(9, console.size()); + + run(steve, "tfmc tutorial bogus clear"); + assertEquals(List.of("There is no tutorial called bogus."), steve.messages); + } + + @Test + void parrotFliesBrieflyAndRestoresFlight() throws Exception { + enableDevContent(); + TestPlayer steve = player("Steve"); + + run(steve, "tfmc parrot"); + assertEquals(List.of("libsdisguises:disguiseplayer Steve parrot setVariant GRAY setExpires 20s"), console); + assertEquals(List.of("start Steve 0.01"), flights); + assertEquals(List.of(400L), scheduledTicks); + assertEquals(List.of("You are now a Parrot"), steve.messages); + + run(steve, "tfmc parrot"); + assertEquals("You are already a parrot. Use /tfmc unparrot to stop.", steve.messages.get(0)); + + steve.messages.clear(); + scheduled.remove(0).run(); + assertEquals(List.of("start Steve 0.01", "end Steve"), flights); + assertEquals(List.of("You are no longer a Parrot"), steve.messages); + assertEquals(1, console.size()); + + run(steve, "tfmc parrot"); + assertTrue(steve.messages.get(0).startsWith("You need to wait 5m")); + } + + @Test + void unparrotAndLeavingEndTheFlightEarly() throws Exception { + enableDevContent(); + TestPlayer steve = player("Steve"); + + run(steve, "tfmc unparrot"); + assertEquals(List.of("You are not a parrot."), steve.messages); + + run(steve, "tfmc parrot"); + run(steve, "tfmc unparrot"); + assertEquals("libsdisguises:undisguiseplayer Steve", console.get(1)); + assertEquals(List.of("start Steve 0.01", "end Steve"), flights); + assertEquals(1, cancelled); + + TestPlayer alex = player("Alex"); + run(alex, "tfmc parrot"); + command.onQuit(alex.player); + assertEquals("end Alex", flights.get(3)); + assertEquals(2, cancelled); + } + + @Test + void worldbossInfoExplainsTheBossFights() throws Exception { + enableDevContent(); + TestPlayer steve = player("Steve"); + + run(steve, "tfmc worldboss info"); + + assertEquals(9, steve.messages.size()); + assertEquals("TFMC Roleplay World Boss Info", steve.messages.get(1)); + } + @Test void consoleIsToldTheCommandIsForPlayers() throws Exception { ConsoleCommandSender sender = mock(ConsoleCommandSender.class); @@ -250,6 +343,11 @@ void consoleIsToldTheCommandIsForPlayers() throws Exception { assertEquals(List.of("Only players can use this command."), messages); } + private void enableDevContent() throws Exception { + config.loadFromString(Files.readString(Path.of("src/main/resources/tfmc.yml")) + .replace(" enabled: false", " enabled: true")); + } + private List visible(TestPlayer player, String... path) { CommandNode node = dispatcher.getRoot().getChild("tfmc"); for (String step : path) { @@ -331,6 +429,24 @@ public CompletableFuture setPermission(Player player, String permission, b return CompletableFuture.completedFuture(null); } + @Override + public FlightState startFlight(Player player, float speed) { + flights.add("start " + player.getName() + " " + speed); + return new FlightState(false, false, 0.1f); + } + + @Override + public void endFlight(Player player, FlightState previous) { + flights.add("end " + player.getName()); + } + + @Override + public Runnable later(long ticks, Runnable task) { + scheduledTicks.add(ticks); + scheduled.add(task); + return () -> cancelled++; + } + @Override public CompletableFuture> stepTrack(Player player, String track, boolean promote) { trackSteps.add(player.getName() + " " + track + " " + (promote ? "promote" : "demote"));