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);
+ }
}