diff --git a/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java b/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java index 492cd8b4..5978961b 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java +++ b/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java @@ -126,6 +126,7 @@ import net.onelitefeather.cygnus.view.GameView; import net.onelitefeather.cygnus.view.GameViewImpl; +import net.onelitefeather.cygnus.tutorial.TutorialFeature; import net.onelitefeather.cygnus.telemetry.ActionTracer; import net.onelitefeather.cygnus.telemetry.CygnusAttributes; import net.onelitefeather.cygnus.telemetry.TraceCookie; @@ -329,6 +330,7 @@ sanityService, new HuntedTabWitness(new TracingCreekWitness(sanityService, this. new SlenderPassFeature(this.slenderPassService, MinecraftServer.getConnectionManager()), new LobbyHintsFeature(ServiceName.of(path), this.linearPhaseSeries::getCurrentPhase, MinecraftServer.getConnectionManager()), + new TutorialFeature(this.linearPhaseSeries::getCurrentPhase, MinecraftServer.getConnectionManager()), // Not part of the OverlayModule: the sound is the feedback a hit owes the player // either way, and it needs neither the resource pack nor the overlay gate to be heard. new MinimapPolicy(this.gameConfig.minimap()), diff --git a/game/src/main/java/net/onelitefeather/cygnus/tutorial/TutorialFeature.java b/game/src/main/java/net/onelitefeather/cygnus/tutorial/TutorialFeature.java new file mode 100644 index 00000000..342aa947 --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/tutorial/TutorialFeature.java @@ -0,0 +1,128 @@ +package net.onelitefeather.cygnus.tutorial; + +import net.kyori.adventure.key.Key; +import net.minestom.server.entity.Player; +import net.minestom.server.event.Event; +import net.minestom.server.event.EventNode; +import net.minestom.server.event.player.PlayerCustomClickEvent; +import net.minestom.server.event.player.PlayerDisconnectEvent; +import net.minestom.server.event.player.PlayerSpawnEvent; +import net.minestom.server.event.player.PlayerUseItemEvent; +import net.minestom.server.network.ConnectionManager; +import net.onelitefeather.cygnus.GameFeature; +import net.onelitefeather.cygnus.map.event.GamePrepareEvent; +import net.onelitefeather.cygnus.phase.LobbyPhase; +import net.onelitefeather.cygnus.phase.WaitingPhase; +import net.onelitefeather.cygnus.utils.Items; +import net.onelitefeather.pica.dialog.DialogTemplate; +import net.theevilreaper.xerus.api.phase.Phase; + +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Supplier; + +/** + * Lets every player in the lobby open a tutorial on both roles and their items. + * + *

The tutorial hangs on an item in the lobby hotbar, handed out on the first spawn while the + * lobby or the waiting phase runs, so it can be opened as often as a player likes and is never + * pushed on anyone. A right click opens the introduction, and each button there and on the + * following pages names the page it leads to, see {@link TutorialPages}.

+ * + *

Right before the players are teleported onto the map, the item is taken away and an open + * tutorial is closed, so nobody arrives in the round with a dialog in front of their eyes. Only the + * players who opened the tutorial and did not close it with its own button are sent the close: a + * player who never opened it may still have the epilepsy warning in front of them, and that one has + * to be answered, not swept away. Escape runs the close button in the footer of every page, so it + * reports back like a click on close. Buttons + * only answer a player who still holds the item, so a click that arrives after that opens nothing. + * Dropping or moving the item is already ruled out by the game's global cancels.

+ * + * @author theEvilReaper + * @version 1.0.0 + * @since 2.21.0 + */ +public final class TutorialFeature implements GameFeature { + + private final EventNode node = EventNode.all("tutorial"); + private final Supplier phase; + private final ConnectionManager connections; + private final TutorialPages pages; + private final Set readers; + + /** + * Creates the feature, builds the pages and puts its listeners on its node. + * + * @param phase reads the running phase + * @param connections finds the online players right before the teleport + */ + public TutorialFeature(Supplier phase, ConnectionManager connections) { + this.phase = phase; + this.connections = connections; + this.pages = new TutorialPages(); + this.readers = ConcurrentHashMap.newKeySet(); + registerListeners(); + } + + @Override + public EventNode node() { + return this.node; + } + + private void registerListeners() { + this.node.addListener(PlayerSpawnEvent.class, this::onSpawn); + this.node.addListener(PlayerUseItemEvent.class, event -> { + if (Items.isTutorialItem(event.getItemStack())) { + open(event.getPlayer(), TutorialPages.INTRO_KEY); + } + }); + this.node.addListener(PlayerCustomClickEvent.class, this::handleClick); + this.node.addListener(GamePrepareEvent.class, _ -> this.connections.getOnlinePlayers().forEach(player -> { + Items.removeTutorialItem(player); + if (this.readers.remove(player.getUuid())) { + player.closeDialog(); + } + })); + this.node.addListener(PlayerDisconnectEvent.class, event -> this.readers.remove(event.getPlayer().getUuid())); + } + + private boolean inLobby() { + Phase running = this.phase.get(); + return running instanceof LobbyPhase || running instanceof WaitingPhase; + } + + private void onSpawn(PlayerSpawnEvent event) { + if (!event.isFirstSpawn() || !inLobby()) { + return; + } + Items.setTutorialItem(event.getPlayer()); + } + + /** + * Answers a tutorial button: close shuts the dialog, every other tutorial key opens its page for + * the player who clicked. Clicks of other dialogs, and every click of a player who no longer holds + * the item, are left alone. + * + * @param event the click the client sent back + */ + void handleClick(PlayerCustomClickEvent event) { + if (!Items.hasTutorialItem(event.getPlayer())) { + return; + } + if (TutorialPages.CLOSE_KEY.equals(event.getKey())) { + this.readers.remove(event.getPlayer().getUuid()); + event.getPlayer().closeDialog(); + return; + } + open(event.getPlayer(), event.getKey()); + } + + private void open(Player player, Key key) { + DialogTemplate page = this.pages.page(key); + if (page != null) { + this.readers.add(player.getUuid()); + page.open(player); + } + } +} diff --git a/game/src/main/java/net/onelitefeather/cygnus/tutorial/TutorialPages.java b/game/src/main/java/net/onelitefeather/cygnus/tutorial/TutorialPages.java new file mode 100644 index 00000000..75ac9767 --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/tutorial/TutorialPages.java @@ -0,0 +1,251 @@ +package net.onelitefeather.cygnus.tutorial; + +import net.kyori.adventure.key.Key; +import net.kyori.adventure.text.Component; +import net.minestom.server.component.DataComponents; +import net.minestom.server.dialog.DialogAction; +import net.minestom.server.dialog.DialogAfterAction; +import net.minestom.server.item.ItemStack; +import net.onelitefeather.cygnus.common.Messages; +import net.onelitefeather.cygnus.utils.Items; +import net.onelitefeather.pica.dialog.DialogTemplate; +import net.onelitefeather.pica.dialog.action.ActionButton; +import net.onelitefeather.pica.dialog.meta.DialogMeta; +import net.onelitefeather.pica.dialog.type.DialogType; +import net.onelitefeather.pica.dialog.type.MultiActionDialog; +import org.jetbrains.annotations.Nullable; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; + +/** + * The pages of the lobby tutorial: the introduction, and for each role a chapter of rule pages + * followed by one page on its items. + * + *

Every button reports a key of its own, and every key but {@link #CLOSE_KEY} names the page it + * leads to, so turning a page needs nothing but the key the client sends back. The pages are built + * once and shared by every player.

+ * + *

A chapter turns like a book: the left button goes back one page, the right one goes on, and on + * the last page of a chapter it leads back to the introduction. A rule page explains two points, so + * it never has to scroll and its buttons stay in sight.

+ * + *

Close sits in the footer of every page, where the client keeps it at the bottom. The footer + * button is also what escape runs, so escape closes the tutorial through the same key.

+ * + *

All pages stay open after a click. The next page then replaces the current one directly; + * closing first would show the world for a moment in between.

+ * + *

The item pages show the very stacks the round hands out, so the icon and the coloured name a + * player learns here are the ones they find in their hotbar later.

+ * + * @author theEvilReaper + * @version 1.0.0 + * @since 2.21.0 + */ +final class TutorialPages { + + /** Opens the introduction. */ + static final Key INTRO_KEY = Key.key("cygnus", "tutorial/intro"); + /** Opens the survivors' page on pages and sprinting. */ + static final Key SURVIVOR_PAGES_KEY = Key.key("cygnus", "tutorial/survivor/pages"); + /** Opens the survivors' page on Slenderman and adrenaline. */ + static final Key SURVIVOR_DANGER_KEY = Key.key("cygnus", "tutorial/survivor/danger"); + /** Opens the survivors' page on fear and the creek. */ + static final Key SURVIVOR_FEAR_KEY = Key.key("cygnus", "tutorial/survivor/fear"); + /** Opens the survivors' item page. */ + static final Key SURVIVOR_ITEMS_KEY = Key.key("cygnus", "tutorial/survivor/items"); + /** Opens Slenderman's page on hiding and traces. */ + static final Key SLENDER_HUNT_KEY = Key.key("cygnus", "tutorial/slender/hunt"); + /** Opens Slenderman's page on pages and the creek. */ + static final Key SLENDER_CREEK_KEY = Key.key("cygnus", "tutorial/slender/creek"); + /** Opens Slenderman's item page. */ + static final Key SLENDER_ITEMS_KEY = Key.key("cygnus", "tutorial/slender/items"); + /** Closes the tutorial. It has no page of its own. */ + static final Key CLOSE_KEY = Key.key("cygnus", "tutorial/close"); + + private static final String BACK_LABEL = "← Back"; + private static final String NEXT_LABEL = "Next →"; + private static final String START_LABEL = "To the start"; + private static final String CLOSE_LABEL = "Close"; + + private static final String INTRO_TITLE = "How to play"; + private static final List INTRO_BODY = List.of( + "One of you is Slenderman and everyone else is a survivor.", + "Survivors search the map for pages and win if they find enough of them " + + "before the time runs out.", + "Slenderman wins if nobody is left to find them.", + "The creek walks the map as well. It is a second figure and answers to " + + "nobody.", + "Pick a role below to learn more."); + + private static final String SURVIVOR_PAGES_TITLE = "Survivor — Find the pages"; + private static final List SURVIVOR_PAGES_BODY = List.of( + point("Pages", "Pages are spread across the map but they do not stay forever. A page that is about " + + "to vanish starts to chime and the chime gets faster the less time it has left."), + point("Sprint", "The bar above your hotbar is your breath. Sprinting drains it and it refills once " + + "you stop. Run it empty and you cannot sprint again until it has filled up a good part of " + + "the way.")); + + private static final String SURVIVOR_DANGER_TITLE = "Survivor — Stay alive"; + private static final List SURVIVOR_DANGER_BODY = List.of( + point("Slenderman", "Most of the time you cannot see him. When he shows himself he is close and " + + "hits whoever is near him. Your screen tears apart while he stands in your view."), + point("Adrenaline", "When he comes close adrenaline lets you run faster for a moment.")); + + private static final String SURVIVOR_FEAR_TITLE = "Survivor — Fear"; + private static final List SURVIVOR_FEAR_BODY = List.of( + point("Fear", "Your level shows your sanity from 100 for calm to 0 for terrified. Pages, the creek " + + "and the death of a teammate scare you. Fear wears off again but less and less as the " + + "round goes on."), + point("The creek", "It goes for the most scared. Whoever it catches is dragged to where " + + "Slenderman started.")); + + private static final String SURVIVOR_ITEMS_TITLE = "Survivor — Your items"; + + private static final String SLENDER_HUNT_TITLE = "Slenderman — How to hunt"; + private static final List SLENDER_HUNT_BODY = List.of( + point("Hidden and shown", "While hidden the survivors cannot see you. Show yourself to hit whoever " + + "is near you. While shown you are slower and half blind and cannot sprint. When your bar " + + "runs out you vanish again and need time to recover before you can show yourself once " + + "more."), + point("Traces", "While hidden you now and then leave a footprint that only survivors can see.")); + + private static final String SLENDER_CREEK_TITLE = "Slenderman — Pages and the creek"; + private static final List SLENDER_CREEK_BODY = List.of( + point("Pages", "Every page the survivors take scrambles your screen a bit more."), + point("The creek", "It hunts the survivors on its own. Look through its eyes to find them.")); + + private static final String SLENDER_ITEMS_TITLE = "Slenderman — Your items"; + + private static final String PAGE_REVEAL_TEXT = "Shows the pages around you on your locator bar for a few " + + "seconds. It only points roughly to them so the last steps are up to you. Does not work while " + + "sprinting."; + private static final String PAINKILLERS_TEXT = "Win back some health over a few seconds. Only once per round " + + "so save them for when you need them. Fear makes them work slower."; + private static final String SLENDER_EYE_TEXT = "Hide yourself or show yourself to the survivors."; + private static final String TRACKER_TEXT = "Reveals where survivors walked around you a little while ago. " + + "Only you can see the tracks. Locked for a while at the start of a round."; + private static final String POSSESSION_TEXT = "While hidden look through the eyes of the creek. Survivors " + + "close to it show on your locator bar. Your body stays behind frozen and visible. Sneak to return."; + + private final Map pages; + + /** + * Builds all pages. + */ + TutorialPages() { + this.pages = new HashMap<>(); + this.pages.put(INTRO_KEY, page(INTRO_KEY, INTRO_TITLE, meta -> addParagraphs(meta, INTRO_BODY)) + .columns(1) + .action(button("Survivor", SURVIVOR_PAGES_KEY)) + .action(button("Slenderman", SLENDER_HUNT_KEY)) + .build()); + addChapter(List.of( + new Page(SURVIVOR_PAGES_KEY, page(SURVIVOR_PAGES_KEY, SURVIVOR_PAGES_TITLE, + meta -> addParagraphs(meta, SURVIVOR_PAGES_BODY))), + new Page(SURVIVOR_DANGER_KEY, page(SURVIVOR_DANGER_KEY, SURVIVOR_DANGER_TITLE, + meta -> addParagraphs(meta, SURVIVOR_DANGER_BODY))), + new Page(SURVIVOR_FEAR_KEY, page(SURVIVOR_FEAR_KEY, SURVIVOR_FEAR_TITLE, + meta -> addParagraphs(meta, SURVIVOR_FEAR_BODY))), + new Page(SURVIVOR_ITEMS_KEY, page(SURVIVOR_ITEMS_KEY, SURVIVOR_ITEMS_TITLE, meta -> { + addItem(meta, Items.pageReveal(), PAGE_REVEAL_TEXT); + addItem(meta, Items.painkillers(), PAINKILLERS_TEXT); + })))); + addChapter(List.of( + new Page(SLENDER_HUNT_KEY, page(SLENDER_HUNT_KEY, SLENDER_HUNT_TITLE, + meta -> addParagraphs(meta, SLENDER_HUNT_BODY))), + new Page(SLENDER_CREEK_KEY, page(SLENDER_CREEK_KEY, SLENDER_CREEK_TITLE, + meta -> addParagraphs(meta, SLENDER_CREEK_BODY))), + new Page(SLENDER_ITEMS_KEY, page(SLENDER_ITEMS_KEY, SLENDER_ITEMS_TITLE, meta -> { + addItem(meta, Items.slenderEye(), SLENDER_EYE_TEXT); + addItem(meta, Items.tracker(), TRACKER_TEXT); + addItem(meta, Items.possession(), POSSESSION_TEXT); + })))); + } + + /** + * Returns the page a key leads to. + * + * @param key the key a button sent back + * @return the page, or {@code null} for {@link #CLOSE_KEY} and for keys of other dialogs + */ + @Nullable DialogTemplate page(Key key) { + return this.pages.get(key); + } + + /** + * Gives the pages of one chapter their back and next buttons and stores them. The first page + * goes back to the introduction, and the last one leads there instead of on. + */ + private void addChapter(List chapter) { + for (int i = 0; i < chapter.size(); i++) { + Page page = chapter.get(i); + Key back = i == 0 ? INTRO_KEY : chapter.get(i - 1).key(); + Consumer forward = i == chapter.size() - 1 + ? button(START_LABEL, INTRO_KEY) + : button(NEXT_LABEL, chapter.get(i + 1).key()); + this.pages.put(page.key(), page.dialog() + .columns(2) + .action(button(BACK_LABEL, back)) + .action(forward) + .build()); + } + } + + private static MultiActionDialog page(Key key, String title, Consumer body) { + return DialogType.multiAction(Key.key("cygnus", "dialog/" + key.value())) + .meta(meta -> { + meta.title(Messages.withMini(title)) + .closeWithEscape(true) + .pause(false) + .afterAction(DialogAfterAction.NONE); + body.accept(meta); + }) + .exitAction(button(CLOSE_LABEL, CLOSE_KEY)); + } + + private static Consumer button(String label, Key target) { + return button -> button.label(Messages.withMini(label)).action(new DialogAction.Custom(target, null)); + } + + /** + * Formats one point of a rule page as its name, an arrow and the explanation. + */ + private static String point(String name, String text) { + return "" + name + " → " + text; + } + + private static void addParagraphs(DialogMeta meta, List paragraphs) { + for (int i = 0; i < paragraphs.size(); i++) { + if (i > 0) meta.emptyMessage(); + Component paragraph = Messages.withMini(paragraphs.get(i)); + meta.messageBody(body -> body.contents(paragraph)); + } + } + + /** + * Adds one item with its coloured name in front of its description, so the name a player reads + * here is the one their hotbar shows. + */ + private static void addItem(DialogMeta meta, ItemStack item, String text) { + Component name = item.get(DataComponents.CUSTOM_NAME, Component.empty()); + Component description = Component.empty() + .append(name) + .append(Messages.withMini(" → " + text)); + meta.itemBody(template -> template.item(item) + .showToolTip(true) + .description(body -> body.contents(description))); + } + + /** + * One page of a chapter before its buttons are added. + * + * @param key the key that opens the page + * @param dialog the page so far + */ + private record Page(Key key, MultiActionDialog dialog) { + } +} diff --git a/game/src/main/java/net/onelitefeather/cygnus/tutorial/package-info.java b/game/src/main/java/net/onelitefeather/cygnus/tutorial/package-info.java new file mode 100644 index 00000000..7d366e2b --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/tutorial/package-info.java @@ -0,0 +1,7 @@ +/** + * The lobby tutorial: the dialog pages that explain both roles and their items. + */ +@NotNullByDefault +package net.onelitefeather.cygnus.tutorial; + +import org.jetbrains.annotations.NotNullByDefault; diff --git a/game/src/main/java/net/onelitefeather/cygnus/utils/Items.java b/game/src/main/java/net/onelitefeather/cygnus/utils/Items.java index b5e19113..42f0be0a 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/utils/Items.java +++ b/game/src/main/java/net/onelitefeather/cygnus/utils/Items.java @@ -35,6 +35,7 @@ public final class Items { public static final byte LOBBY_ITEM = (byte) 0x08; public static final byte PAGE_REVEAL_ITEM = (byte) 0x09; public static final byte PASS_ITEM = (byte) 0x0A; + public static final byte TUTORIAL_ITEM = (byte) 0x0B; /** The hotbar slot of the lobby's slender pass, the third one, between the server item and the statistics. */ public static final int PASS_SLOT = 2; @@ -69,6 +70,9 @@ public final class Items { /** The hotbar slot of the lobby's statistics item, the middle one. */ public static final int STATS_SLOT = 4; + /** The hotbar slot of the lobby's tutorial item, right next to the server item. */ + public static final int TUTORIAL_SLOT = 1; + /** The hotbar slot of the lobby's server item, the first one. */ public static final int SERVER_SLOT = 0; @@ -122,6 +126,12 @@ public final class Items { .set(Tags.ITEM_TAG, STATS_ITEM) .build(); + private static final ItemStack tutorial = ItemStack.builder(Material.ENCHANTED_BOOK) + .customName(Component.text("How to play", NamedTextColor.AQUA).decoration(TextDecoration.ITALIC, false)) + .lore(lore("Right click to learn how", "each role plays")) + .set(Tags.ITEM_TAG, TUTORIAL_ITEM) + .build(); + private static final HotBarLayout SPECTATOR_LAYOUT; static { @@ -274,6 +284,102 @@ public static boolean isPassItem(ItemStack stack) { return tag != null && tag == PASS_ITEM; } + /** + * Gives the player the lobby's item that opens the tutorial. + * + * @param player who should receive the item + */ + public static void setTutorialItem(Player player) { + player.getInventory().setItemStack(TUTORIAL_SLOT, tutorial); + } + + /** + * Takes the tutorial item out of the player's inventory, wherever it ended up. Everything else + * stays. + * + * @param player who should lose the item + */ + public static void removeTutorialItem(Player player) { + var inventory = player.getInventory(); + for (int slot = 0; slot < inventory.getSize(); slot++) { + if (isTutorialItem(inventory.getItemStack(slot))) { + inventory.setItemStack(slot, ItemStack.AIR); + } + } + } + + /** + * Tells whether the stack is the tutorial item. + * + * @param stack the stack to look at + * @return {@code true} if the stack carries the tutorial tag + */ + public static boolean isTutorialItem(ItemStack stack) { + Byte tag = stack.getTag(Tags.ITEM_TAG); + return tag != null && tag == TUTORIAL_ITEM; + } + + /** + * Tells whether the player carries the tutorial item anywhere in their inventory. + * + * @param player the player to look at + * @return {@code true} if one slot holds it + */ + public static boolean hasTutorialItem(Player player) { + var inventory = player.getInventory(); + for (int slot = 0; slot < inventory.getSize(); slot++) { + if (isTutorialItem(inventory.getItemStack(slot))) { + return true; + } + } + return false; + } + + /** + * Returns the slender's SlenderEye, the same stack the round hands out. + * + * @return the SlenderEye + */ + public static ItemStack slenderEye() { + return slenderEye; + } + + /** + * Returns the slender's tracker, the same stack the round hands out. + * + * @return the tracker + */ + public static ItemStack tracker() { + return tracker; + } + + /** + * Returns the slender's item to look through the creek's eyes, the same stack the round hands out. + * + * @return the possession item + */ + public static ItemStack possession() { + return possession; + } + + /** + * Returns the survivors' painkillers, the same stack the round hands out. + * + * @return the painkillers + */ + public static ItemStack painkillers() { + return painkillers; + } + + /** + * Returns the survivors' page reveal, the same stack the round hands out. + * + * @return the page reveal + */ + public static ItemStack pageReveal() { + return pageReveal; + } + /** * Gives the player the lobby's item that shows the server they are on. * diff --git a/game/src/test/java/net/onelitefeather/cygnus/tutorial/TutorialFeatureTest.java b/game/src/test/java/net/onelitefeather/cygnus/tutorial/TutorialFeatureTest.java new file mode 100644 index 00000000..f0726ab3 --- /dev/null +++ b/game/src/test/java/net/onelitefeather/cygnus/tutorial/TutorialFeatureTest.java @@ -0,0 +1,268 @@ +package net.onelitefeather.cygnus.tutorial; + +import net.kyori.adventure.key.Key; +import net.minestom.server.coordinate.Pos; +import net.minestom.server.dialog.Dialog; +import net.minestom.server.dialog.DialogAction; +import net.minestom.server.entity.Player; +import net.minestom.server.entity.PlayerHand; +import net.minestom.server.event.player.PlayerCustomClickEvent; +import net.minestom.server.event.player.PlayerSpawnEvent; +import net.minestom.server.event.player.PlayerUseItemEvent; +import net.minestom.server.instance.Instance; +import net.minestom.server.item.ItemStack; +import net.minestom.server.item.Material; +import net.minestom.server.network.packet.server.common.ClearDialogPacket; +import net.minestom.server.network.packet.server.common.ShowDialogPacket; +import net.minestom.testing.Collector; +import net.minestom.testing.Env; +import net.minestom.testing.TestConnection; +import net.minestom.testing.extension.MicrotusExtension; +import net.onelitefeather.cygnus.camera.WakeUpTransition; +import net.onelitefeather.cygnus.common.config.GameConfig; +import net.onelitefeather.cygnus.disclaimer.EpilepsyDisclaimer; +import net.onelitefeather.cygnus.jumpscare.JumpScareManager; +import net.onelitefeather.cygnus.map.event.GamePrepareEvent; +import net.onelitefeather.cygnus.phase.GamePhase; +import net.onelitefeather.cygnus.phase.LobbyPhase; +import net.onelitefeather.cygnus.phase.WaitingPhase; +import net.onelitefeather.cygnus.telemetry.TickSections; +import net.onelitefeather.cygnus.utils.Items; +import net.onelitefeather.cygnus.view.GameViewImpl; +import net.theevilreaper.xerus.api.phase.Phase; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import java.util.List; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Covers the tutorial item and the page turns on the feature's own event node. The events are + * dispatched to the node directly, the way {@code GameFeatures} would hang it into the game. + */ +@ExtendWith(MicrotusExtension.class) +class TutorialFeatureTest { + + private Env env; + private Instance instance; + private TestConnection connection; + private Player player; + private Phase phase; + private TutorialFeature feature; + + @BeforeEach + void setUp(Env env) { + this.env = env; + this.instance = env.createFlatInstance(); + this.connection = env.createConnection(); + this.player = this.connection.connect(this.instance, new Pos(0, 40, 0)); + this.phase = new LobbyPhase(new GameConfig.Round(2, 10, 30, 600)); + this.feature = new TutorialFeature(() -> this.phase, env.process().connection()); + } + + private void spawn() { + this.feature.node().call(new PlayerSpawnEvent(this.player, this.instance, true)); + } + + /** Clicks a button the way a player reading the tutorial does, with the item still in the hotbar. */ + private void click(Player clicker, Key key) { + Items.setTutorialItem(clicker); + this.feature.node().call(new PlayerCustomClickEvent(clicker, key, null)); + } + + private void startRound() { + this.phase = new GamePhase(new GameViewImpl(), 600, new JumpScareManager(TickSections.NONE), (_, _) -> 0.0D); + } + + private static List buttonKeys(ShowDialogPacket packet) { + Dialog.MultiAction dialog = assertInstanceOf(Dialog.MultiAction.class, packet.dialog()); + return Stream.concat(dialog.actions().stream(), Stream.of(dialog.exitAction())) + .map(button -> assertInstanceOf(DialogAction.Custom.class, button.action()).key()) + .toList(); + } + + @Test + @DisplayName("The first spawn in the lobby hands out the tutorial item") + void lobbySpawnGivesTheItem() { + spawn(); + + assertTrue(Items.isTutorialItem(this.player.getInventory().getItemStack(Items.TUTORIAL_SLOT)), + "the item belongs in its slot"); + } + + @Test + @DisplayName("The waiting phase still hands out the item") + void waitingPhaseGivesTheItem() { + this.phase = new WaitingPhase(new GameViewImpl(), () -> { + }, () -> { + }, () -> this.instance, new WakeUpTransition()); + + spawn(); + + assertTrue(Items.hasTutorialItem(this.player)); + } + + @Test + @DisplayName("During the round nobody gets the item") + void gamePhaseGivesNoItem() { + startRound(); + + spawn(); + + assertFalse(Items.hasTutorialItem(this.player), "a late joiner must not carry the lobby item into the round"); + } + + @Test + @DisplayName("A spawn that is not the first one hands out nothing") + void laterSpawnGivesNoItem() { + this.feature.node().call(new PlayerSpawnEvent(this.player, this.instance, false)); + + assertFalse(Items.hasTutorialItem(this.player)); + } + + @Test + @DisplayName("Right click with the item opens the introduction") + void useOpensTheIntro() { + spawn(); + Collector dialogs = this.connection.trackIncoming(ShowDialogPacket.class); + + this.feature.node().call(new PlayerUseItemEvent(this.player, PlayerHand.MAIN, + this.player.getInventory().getItemStack(Items.TUTORIAL_SLOT), 0L)); + + dialogs.assertSingle(packet -> assertEquals( + List.of(TutorialPages.SURVIVOR_PAGES_KEY, TutorialPages.SLENDER_HUNT_KEY, TutorialPages.CLOSE_KEY), + buttonKeys(packet))); + } + + @Test + @DisplayName("Using any other item opens nothing") + void otherItemOpensNothing() { + Items.setStatsItem(this.player); + Collector dialogs = this.connection.trackIncoming(ShowDialogPacket.class); + + this.feature.node().call(new PlayerUseItemEvent(this.player, PlayerHand.MAIN, + this.player.getInventory().getItemStack(Items.STATS_SLOT), 0L)); + this.feature.node().call(new PlayerUseItemEvent(this.player, PlayerHand.MAIN, ItemStack.of(Material.COMPASS), 0L)); + + assertEquals(List.of(), dialogs.collect()); + } + + @Test + @DisplayName("Each button opens the page it names") + void eachKeyOpensItsPage() { + Key close = TutorialPages.CLOSE_KEY; + assertOpens(TutorialPages.SURVIVOR_PAGES_KEY, List.of(TutorialPages.INTRO_KEY, TutorialPages.SURVIVOR_DANGER_KEY, close)); + assertOpens(TutorialPages.SURVIVOR_DANGER_KEY, List.of(TutorialPages.SURVIVOR_PAGES_KEY, TutorialPages.SURVIVOR_FEAR_KEY, close)); + assertOpens(TutorialPages.SURVIVOR_FEAR_KEY, List.of(TutorialPages.SURVIVOR_DANGER_KEY, TutorialPages.SURVIVOR_ITEMS_KEY, close)); + assertOpens(TutorialPages.SURVIVOR_ITEMS_KEY, List.of(TutorialPages.SURVIVOR_FEAR_KEY, TutorialPages.INTRO_KEY, close)); + assertOpens(TutorialPages.SLENDER_HUNT_KEY, List.of(TutorialPages.INTRO_KEY, TutorialPages.SLENDER_CREEK_KEY, close)); + assertOpens(TutorialPages.SLENDER_CREEK_KEY, List.of(TutorialPages.SLENDER_HUNT_KEY, TutorialPages.SLENDER_ITEMS_KEY, close)); + assertOpens(TutorialPages.SLENDER_ITEMS_KEY, List.of(TutorialPages.SLENDER_CREEK_KEY, TutorialPages.INTRO_KEY, close)); + assertOpens(TutorialPages.INTRO_KEY, List.of(TutorialPages.SURVIVOR_PAGES_KEY, TutorialPages.SLENDER_HUNT_KEY, close)); + } + + private void assertOpens(Key key, List expectedButtons) { + Collector dialogs = this.connection.trackIncoming(ShowDialogPacket.class); + + click(this.player, key); + + dialogs.assertSingle(packet -> assertEquals(expectedButtons, buttonKeys(packet), "wrong page for " + key)); + } + + @Test + @DisplayName("Close closes the tutorial instead of opening a page") + void closeClosesTheDialog() { + Collector dialogs = this.connection.trackIncoming(ShowDialogPacket.class); + Collector clears = this.connection.trackIncoming(ClearDialogPacket.class); + + click(this.player, TutorialPages.CLOSE_KEY); + + clears.assertSingle(); + assertEquals(List.of(), dialogs.collect()); + } + + @Test + @DisplayName("A button of another dialog is left alone") + void foreignKeyOpensNothing() { + Collector dialogs = this.connection.trackIncoming(ShowDialogPacket.class); + Collector clears = this.connection.trackIncoming(ClearDialogPacket.class); + + click(this.player, EpilepsyDisclaimer.ACKNOWLEDGE_KEY); + + assertEquals(List.of(), dialogs.collect(), "the disclaimer's button must not open a tutorial page"); + assertEquals(List.of(), clears.collect(), "the disclaimer's button must not close anything here"); + } + + @Test + @DisplayName("A click turns the page only for the player who clicked") + void clickOpensThePageOnlyForTheClicker() { + TestConnection otherConnection = this.env.createConnection(); + otherConnection.connect(this.instance, new Pos(0, 40, 0)); + Collector otherDialogs = otherConnection.trackIncoming(ShowDialogPacket.class); + Collector dialogs = this.connection.trackIncoming(ShowDialogPacket.class); + + click(this.player, TutorialPages.SURVIVOR_PAGES_KEY); + + dialogs.assertSingle(); + assertEquals(List.of(), otherDialogs.collect(), "another reader must stay on their own page"); + } + + @Test + @DisplayName("Right before the teleport the item is taken away and the tutorial closed") + void teleportTakesTheItemAndClosesTheDialog() { + spawn(); + click(this.player, TutorialPages.SURVIVOR_PAGES_KEY); + this.player.getInventory().setItemStack(Items.HEAL_SLOT, ItemStack.of(Material.SUGAR)); + Collector clears = this.connection.trackIncoming(ClearDialogPacket.class); + + this.feature.node().call(new GamePrepareEvent()); + + assertFalse(Items.hasTutorialItem(this.player), "the item must be gone before the teleport"); + assertEquals(Material.SUGAR, this.player.getInventory().getItemStack(Items.HEAL_SLOT).material(), "the painkillers stay"); + clears.assertSingle(); + } + + @Test + @DisplayName("Right before the teleport a player who never opened the tutorial keeps their dialog") + void teleportLeavesOtherDialogsOpen() { + spawn(); + Collector clears = this.connection.trackIncoming(ClearDialogPacket.class); + + this.feature.node().call(new GamePrepareEvent()); + + assertEquals(List.of(), clears.collect(), + "the epilepsy warning may still be open and has to be answered, not swept away"); + } + + @Test + @DisplayName("Right before the teleport a player who already closed the tutorial keeps their dialog") + void teleportLeavesClosedReadersAlone() { + spawn(); + click(this.player, TutorialPages.SURVIVOR_PAGES_KEY); + click(this.player, TutorialPages.CLOSE_KEY); + Collector clears = this.connection.trackIncoming(ClearDialogPacket.class); + + this.feature.node().call(new GamePrepareEvent()); + + assertEquals(List.of(), clears.collect(), "a closed tutorial has nothing left to close"); + } + + @Test + @DisplayName("A late click after the teleport opens nothing") + void clickAfterTeleportOpensNothing() { + spawn(); + this.feature.node().call(new GamePrepareEvent()); + Collector dialogs = this.connection.trackIncoming(ShowDialogPacket.class); + + this.feature.node().call(new PlayerCustomClickEvent(this.player, TutorialPages.SURVIVOR_PAGES_KEY, null)); + + assertEquals(List.of(), dialogs.collect(), "a closed tutorial must not come back once the player left the lobby"); + } +} diff --git a/game/src/test/java/net/onelitefeather/cygnus/tutorial/TutorialPagesTest.java b/game/src/test/java/net/onelitefeather/cygnus/tutorial/TutorialPagesTest.java new file mode 100644 index 00000000..f43ba525 --- /dev/null +++ b/game/src/test/java/net/onelitefeather/cygnus/tutorial/TutorialPagesTest.java @@ -0,0 +1,181 @@ +package net.onelitefeather.cygnus.tutorial; + +import net.kyori.adventure.key.Key; +import net.minestom.server.dialog.Dialog; +import net.minestom.server.dialog.DialogAction; +import net.minestom.server.dialog.DialogActionButton; +import net.minestom.server.dialog.DialogAfterAction; +import net.minestom.server.dialog.DialogBody; +import net.minestom.server.entity.Player; +import net.minestom.server.instance.Instance; +import net.minestom.server.item.ItemStack; +import net.minestom.server.network.packet.server.common.ShowDialogPacket; +import net.minestom.testing.Collector; +import net.minestom.testing.Env; +import net.minestom.testing.TestConnection; +import net.minestom.testing.extension.MicrotusExtension; +import net.onelitefeather.cygnus.disclaimer.EpilepsyDisclaimer; +import net.onelitefeather.cygnus.utils.Items; +import net.onelitefeather.pica.dialog.meta.DialogMeta; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import java.util.List; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Covers the tutorial pages as the client receives them: which buttons each page carries, where + * they lead, how much text a page holds and which items the item pages show. + */ +@ExtendWith(MicrotusExtension.class) +class TutorialPagesTest { + + private static final List SURVIVOR_CHAPTER = List.of(TutorialPages.SURVIVOR_PAGES_KEY, + TutorialPages.SURVIVOR_DANGER_KEY, TutorialPages.SURVIVOR_FEAR_KEY, TutorialPages.SURVIVOR_ITEMS_KEY); + private static final List SLENDER_CHAPTER = List.of(TutorialPages.SLENDER_HUNT_KEY, + TutorialPages.SLENDER_CREEK_KEY, TutorialPages.SLENDER_ITEMS_KEY); + private static final List RULE_PAGES = List.of(TutorialPages.SURVIVOR_PAGES_KEY, + TutorialPages.SURVIVOR_DANGER_KEY, TutorialPages.SURVIVOR_FEAR_KEY, + TutorialPages.SLENDER_HUNT_KEY, TutorialPages.SLENDER_CREEK_KEY); + private static final List PAGE_KEYS = Stream.of(List.of(TutorialPages.INTRO_KEY), SURVIVOR_CHAPTER, + SLENDER_CHAPTER).flatMap(List::stream).toList(); + + private TutorialPages pages; + private Env env; + private Instance instance; + + @BeforeEach + void setUp(Env env) { + this.env = env; + this.instance = env.createFlatInstance(); + this.pages = new TutorialPages(); + } + + private Dialog.MultiAction received(Key key) { + TestConnection connection = this.env.createConnection(); + Player player = connection.connect(this.instance); + Collector dialogs = connection.trackIncoming(ShowDialogPacket.class); + var page = this.pages.page(key); + assertNotNull(page, "every page key has a page"); + page.open(player); + List packets = dialogs.collect(); + assertEquals(1, packets.size(), "opening a page sends exactly one dialog"); + return assertInstanceOf(Dialog.MultiAction.class, packets.getFirst().dialog(), + "only a multi-action dialog carries buttons of its own"); + } + + private static Key keyOf(DialogActionButton button) { + return assertInstanceOf(DialogAction.Custom.class, button.action(), + "a button has to report back, otherwise the page never turns").key(); + } + + private static List buttonKeys(Dialog.MultiAction dialog) { + return dialog.actions().stream().map(TutorialPagesTest::keyOf).toList(); + } + + private static List shownItems(Dialog.MultiAction dialog) { + return dialog.metadata().body().stream() + .filter(DialogBody.Item.class::isInstance) + .map(body -> ((DialogBody.Item) body).itemStack()) + .toList(); + } + + private static long paragraphs(Dialog.MultiAction dialog) { + return dialog.metadata().body().stream() + .filter(DialogBody.PlainMessage.class::isInstance) + .filter(body -> !body.equals(DialogMeta.EMPTY)) + .count(); + } + + @Test + @DisplayName("The introduction lists both roles one below the other") + void introOffersBothRoles() { + Dialog.MultiAction intro = received(TutorialPages.INTRO_KEY); + + assertEquals(List.of(TutorialPages.SURVIVOR_PAGES_KEY, TutorialPages.SLENDER_HUNT_KEY), buttonKeys(intro)); + assertEquals(1, intro.columns(), "the menu lists its buttons one below the other"); + } + + @Test + @DisplayName("Every page carries close in its footer, so escape closes the tutorial as well") + void everyPageClosesFromTheFooter() { + for (Key key : PAGE_KEYS) { + Dialog.MultiAction page = received(key); + + assertNotNull(page.exitAction(), "the footer holds the way out: " + key); + assertEquals(TutorialPages.CLOSE_KEY, keyOf(page.exitAction()), "escape runs the footer button: " + key); + } + } + + @Test + @DisplayName("Every page turns without closing and can be left with escape") + void everyPageTurnsWithoutClosing() { + for (Key key : PAGE_KEYS) { + Dialog.MultiAction page = received(key); + + assertEquals(DialogAfterAction.NONE, page.metadata().afterAction(), + "closing first would flash the world before the next page: " + key); + assertTrue(page.metadata().canCloseWithEscape(), "nothing here has to be read: " + key); + assertFalse(page.metadata().pause(), "the lobby keeps running behind the tutorial: " + key); + } + } + + @Test + @DisplayName("Each chapter turns like a book: back to the page before, on to the next, and from the last to the start") + void chaptersTurnLikeABook() { + for (List chapter : List.of(SURVIVOR_CHAPTER, SLENDER_CHAPTER)) { + for (int i = 0; i < chapter.size(); i++) { + Key back = i == 0 ? TutorialPages.INTRO_KEY : chapter.get(i - 1); + Key next = i == chapter.size() - 1 ? TutorialPages.INTRO_KEY : chapter.get(i + 1); + Dialog.MultiAction page = received(chapter.get(i)); + + assertEquals(List.of(back, next), buttonKeys(page), "wrong buttons on " + chapter.get(i)); + assertEquals(2, page.columns(), "back and next belong side by side: " + chapter.get(i)); + } + } + } + + @Test + @DisplayName("A rule page explains two points, so it never has to scroll") + void rulePagesHoldTwoPoints() { + for (Key key : RULE_PAGES) { + assertEquals(2, paragraphs(received(key)), "wrong number of points on " + key); + } + } + + @Test + @DisplayName("Every button leads to a page or closes the tutorial") + void everyButtonLeadsToAPageOrCloses() { + for (Key key : PAGE_KEYS) { + for (Key target : buttonKeys(received(key))) { + assertTrue(target.equals(TutorialPages.CLOSE_KEY) || this.pages.page(target) != null, + "a button on " + key + " leads nowhere: " + target); + } + } + } + + @Test + @DisplayName("The item pages show the very items the round hands out") + void itemPagesShowTheRoundItems() { + assertEquals(List.of(Items.pageReveal(), Items.painkillers()), + shownItems(received(TutorialPages.SURVIVOR_ITEMS_KEY))); + assertEquals(List.of(Items.slenderEye(), Items.tracker(), Items.possession()), + shownItems(received(TutorialPages.SLENDER_ITEMS_KEY))); + } + + @Test + @DisplayName("Close and keys of other dialogs have no page") + void unknownKeysHaveNoPage() { + assertNull(this.pages.page(TutorialPages.CLOSE_KEY)); + assertNull(this.pages.page(EpilepsyDisclaimer.ACKNOWLEDGE_KEY)); + } +} diff --git a/game/src/test/java/net/onelitefeather/cygnus/utils/ItemsTest.java b/game/src/test/java/net/onelitefeather/cygnus/utils/ItemsTest.java index 540bb011..1fac2b9f 100644 --- a/game/src/test/java/net/onelitefeather/cygnus/utils/ItemsTest.java +++ b/game/src/test/java/net/onelitefeather/cygnus/utils/ItemsTest.java @@ -18,6 +18,7 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; @ExtendWith(MicrotusExtension.class) class ItemsTest { @@ -57,4 +58,55 @@ void testPossessionItemSet(@NotNull Env env) { env.destroyInstance(instance, true); } + + @Test + void testTutorialItemSitsInItsSlot(@NotNull Env env) { + Instance instance = env.createFlatInstance(); + Player player = env.createPlayer(instance); + + Items.setTutorialItem(player); + ItemStack tutorial = player.getInventory().getItemStack(Items.TUTORIAL_SLOT); + assertTrue(Items.isTutorialItem(tutorial), "the tutorial item belongs in its own slot"); + assertEquals(1, Items.TUTORIAL_SLOT, "the tutorial item sits right next to the server item"); + assertEquals(Material.ENCHANTED_BOOK, tutorial.material(), + "a knowledge book is used up by the client on a right click, and a plain book is the statistics"); + assertFalse(tutorial.get(DataComponents.LORE, List.of()).isEmpty()); + + env.destroyInstance(instance, true); + } + + @Test + void testRemoveTutorialItemLeavesTheOthers(@NotNull Env env) { + Instance instance = env.createFlatInstance(); + Player player = env.createPlayer(instance); + Items.setTutorialItem(player); + Items.setStatsItem(player); + + Items.removeTutorialItem(player); + + assertFalse(Items.hasTutorialItem(player), "the tutorial item must be gone"); + assertTrue(Items.hasStatsItem(player), "the statistics item is not the tutorial's to take"); + + env.destroyInstance(instance, true); + } + + @Test + void testRoleItemGettersReturnTheRoundItems(@NotNull Env env) { + Instance instance = env.createFlatInstance(); + Player slender = env.createPlayer(instance); + Player survivor = env.createPlayer(instance); + + Items.setSlenderItems(slender); + Items.setPossessionItem(slender); + Items.setHealItem(survivor); + Items.setPageRevealItem(survivor); + + assertEquals(Items.slenderEye(), slender.getInventory().getItemStack(0)); + assertEquals(Items.tracker(), slender.getInventory().getItemStack(Items.TRACKING_SLOT)); + assertEquals(Items.possession(), slender.getInventory().getItemStack(Items.POSSESSION_SLOT)); + assertEquals(Items.painkillers(), survivor.getInventory().getItemStack(Items.HEAL_SLOT)); + assertEquals(Items.pageReveal(), survivor.getInventory().getItemStack(Items.PAGE_REVEAL_SLOT)); + + env.destroyInstance(instance, true); + } }