From 3bdc192ca819936ea5d8f2e707108e25a682b65c Mon Sep 17 00:00:00 2001
From: theEvilReaper
Date: Tue, 27 Oct 2026 11:34:51 +0100
Subject: [PATCH 01/12] feat(items): add the tutorial item and read access to
the role items
---
.../onelitefeather/cygnus/utils/Items.java | 106 ++++++++++++++++++
1 file changed, 106 insertions(+)
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..75fc10ef 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, left of the statistics item. */
+ public static final int TUTORIAL_SLOT = 2;
+
/** 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.KNOWLEDGE_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.
*
From 686a7b97c170b862d730fd535c717d1e382b6e01 Mon Sep 17 00:00:00 2001
From: theEvilReaper
Date: Tue, 27 Oct 2026 11:34:51 +0100
Subject: [PATCH 02/12] test(items): cover the tutorial item and the role item
getters
---
.../cygnus/utils/ItemsTest.java | 50 +++++++++++++++++++
1 file changed, 50 insertions(+)
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..2669029f 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,53 @@ 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(Material.KNOWLEDGE_BOOK, tutorial.material());
+ 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);
+ }
}
From ed4c33e2390abc33454bd88499f0fb57f495444a Mon Sep 17 00:00:00 2001
From: theEvilReaper
Date: Tue, 27 Oct 2026 11:37:02 +0100
Subject: [PATCH 03/12] feat(tutorial): build the tutorial pages
---
.../cygnus/tutorial/TutorialPages.java | 200 ++++++++++++++++++
.../cygnus/tutorial/package-info.java | 7 +
2 files changed, 207 insertions(+)
create mode 100644 game/src/main/java/net/onelitefeather/cygnus/tutorial/TutorialPages.java
create mode 100644 game/src/main/java/net/onelitefeather/cygnus/tutorial/package-info.java
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..16b07397
--- /dev/null
+++ b/game/src/main/java/net/onelitefeather/cygnus/tutorial/TutorialPages.java
@@ -0,0 +1,200 @@
+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 five pages of the lobby tutorial: the introduction, and for each role one page on how it
+ * plays and one 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.
+ *
+ * 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. Escape closes the tutorial, since
+ * nothing on it has to be read.
+ *
+ * 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 how they play. */
+ static final Key SURVIVOR_RULES_KEY = Key.key("cygnus", "tutorial/survivor/rules");
+ /** Opens the survivors' item page. */
+ static final Key SURVIVOR_ITEMS_KEY = Key.key("cygnus", "tutorial/survivor/items");
+ /** Opens Slenderman's page on how he plays. */
+ static final Key SLENDER_RULES_KEY = Key.key("cygnus", "tutorial/slender/rules");
+ /** 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 INTRO_TITLE = "How to play";
+ private static final List INTRO_BODY = List.of(
+ "One of you is Slenderman. Everyone else is a survivor.",
+ "The survivors search the map for pages. Find enough of them before the time runs out and "
+ + "you win. Slenderman wins if nobody is left to find them.",
+ "You are not alone with him: the creek, a second figure, walks the map as well, "
+ + "and it answers to nobody.",
+ "Pick a role to learn more.");
+
+ private static final String SURVIVOR_RULES_TITLE = "Survivor — How to survive";
+ private static final List SURVIVOR_RULES_BODY = List.of(
+ "Pages. Pages are spread across the map, but they do not stay forever. A page "
+ + "that is about to vanish starts to chime, faster the less time it has left.",
+ "Fear. Your level shows your sanity: 100 is calm, 0 is 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, and the creek goes for the most scared.",
+ "Sprint. The green bar 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.",
+ "Slenderman. Most of the time you cannot see him. When he shows himself he is "
+ + "close, and he hits whoever is near him. Your screen tears apart while he stands in your "
+ + "view. When he comes close, adrenaline lets you run faster for a moment.",
+ "The creek. If it catches you, it drags you to where Slenderman started.",
+ "Stay together. Not everything that looks like a teammate still is one.");
+
+ private static final String SURVIVOR_ITEMS_TITLE = "Survivor — Your items";
+
+ private static final String SLENDER_RULES_TITLE = "Slenderman — How to hunt";
+ private static final List SLENDER_RULES_BODY = List.of(
+ "Goal. Stop the survivors before they find enough pages.",
+ "Hidden and shown. While hidden, the survivors cannot see you. Show yourself to "
+ + "hit whoever is near you, but while you are shown you are slower, 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.",
+ "Traces. While hidden you now and then leave a footprint that only survivors "
+ + "can see.",
+ "Pages. Every page they take scrambles your own screen a bit more.",
+ "The creek. It hunts the survivors on its own. Use it: 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 five pages.
+ */
+ TutorialPages() {
+ this.pages = new HashMap<>();
+ this.pages.put(INTRO_KEY, page(INTRO_KEY, INTRO_TITLE, meta -> addParagraphs(meta, INTRO_BODY))
+ .columns(3)
+ .action(button("Survivor", SURVIVOR_RULES_KEY))
+ .action(button("Slenderman", SLENDER_RULES_KEY))
+ .action(button("Close", CLOSE_KEY))
+ .build());
+ this.pages.put(SURVIVOR_RULES_KEY, page(SURVIVOR_RULES_KEY, SURVIVOR_RULES_TITLE,
+ meta -> addParagraphs(meta, SURVIVOR_RULES_BODY))
+ .columns(2)
+ .action(button("Your items", SURVIVOR_ITEMS_KEY))
+ .action(button("Back", INTRO_KEY))
+ .build());
+ this.pages.put(SURVIVOR_ITEMS_KEY, page(SURVIVOR_ITEMS_KEY, SURVIVOR_ITEMS_TITLE, meta -> {
+ addItem(meta, Items.pageReveal(), PAGE_REVEAL_TEXT);
+ addItem(meta, Items.painkillers(), PAINKILLERS_TEXT);
+ })
+ .columns(2)
+ .action(button("How to survive", SURVIVOR_RULES_KEY))
+ .action(button("Back", INTRO_KEY))
+ .build());
+ this.pages.put(SLENDER_RULES_KEY, page(SLENDER_RULES_KEY, SLENDER_RULES_TITLE,
+ meta -> addParagraphs(meta, SLENDER_RULES_BODY))
+ .columns(2)
+ .action(button("Your items", SLENDER_ITEMS_KEY))
+ .action(button("Back", INTRO_KEY))
+ .build());
+ this.pages.put(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);
+ })
+ .columns(2)
+ .action(button("How to hunt", SLENDER_RULES_KEY))
+ .action(button("Back", INTRO_KEY))
+ .build());
+ }
+
+ /**
+ * 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);
+ }
+
+ 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);
+ });
+ }
+
+ private static Consumer button(String label, Key target) {
+ return button -> button.label(Messages.withMini(label)).action(new DialogAction.Custom(target, null));
+ }
+
+ 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)));
+ }
+}
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;
From d00f9d2ab6c48c65bf3b19de3fd37340ef964663 Mon Sep 17 00:00:00 2001
From: theEvilReaper
Date: Tue, 27 Oct 2026 11:37:02 +0100
Subject: [PATCH 04/12] test(tutorial): cover the tutorial pages
---
.../cygnus/tutorial/TutorialPagesTest.java | 143 ++++++++++++++++++
1 file changed, 143 insertions(+)
create mode 100644 game/src/test/java/net/onelitefeather/cygnus/tutorial/TutorialPagesTest.java
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..9c5dadc8
--- /dev/null
+++ b/game/src/test/java/net/onelitefeather/cygnus/tutorial/TutorialPagesTest.java
@@ -0,0 +1,143 @@
+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.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 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 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 five tutorial pages as the client receives them: which buttons each page carries,
+ * where they lead and which items the item pages show.
+ */
+@ExtendWith(MicrotusExtension.class)
+class TutorialPagesTest {
+
+ private static final List PAGE_KEYS = List.of(TutorialPages.INTRO_KEY,
+ TutorialPages.SURVIVOR_RULES_KEY, TutorialPages.SURVIVOR_ITEMS_KEY,
+ TutorialPages.SLENDER_RULES_KEY, TutorialPages.SLENDER_ITEMS_KEY);
+
+ 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 List buttonKeys(Dialog.MultiAction dialog) {
+ return dialog.actions().stream()
+ .map(button -> assertInstanceOf(DialogAction.Custom.class, button.action(),
+ "a button has to report back, otherwise the page never turns").key())
+ .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();
+ }
+
+ @Test
+ @DisplayName("The introduction offers both roles and a way out")
+ void introOffersBothRolesAndClose() {
+ Dialog.MultiAction intro = received(TutorialPages.INTRO_KEY);
+
+ assertEquals(List.of(TutorialPages.SURVIVOR_RULES_KEY, TutorialPages.SLENDER_RULES_KEY, TutorialPages.CLOSE_KEY),
+ buttonKeys(intro));
+ assertEquals(3, intro.columns(), "the three buttons belong in one row");
+ }
+
+ @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("The role pages lead to each other and back to the introduction")
+ void rolePagesLinkUp() {
+ assertEquals(List.of(TutorialPages.SURVIVOR_ITEMS_KEY, TutorialPages.INTRO_KEY),
+ buttonKeys(received(TutorialPages.SURVIVOR_RULES_KEY)));
+ assertEquals(List.of(TutorialPages.SURVIVOR_RULES_KEY, TutorialPages.INTRO_KEY),
+ buttonKeys(received(TutorialPages.SURVIVOR_ITEMS_KEY)));
+ assertEquals(List.of(TutorialPages.SLENDER_ITEMS_KEY, TutorialPages.INTRO_KEY),
+ buttonKeys(received(TutorialPages.SLENDER_RULES_KEY)));
+ assertEquals(List.of(TutorialPages.SLENDER_RULES_KEY, TutorialPages.INTRO_KEY),
+ buttonKeys(received(TutorialPages.SLENDER_ITEMS_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));
+ }
+}
From 5cd49d9c82c814dcf97a998b1b06c7945c72e0a4 Mon Sep 17 00:00:00 2001
From: theEvilReaper
Date: Tue, 27 Oct 2026 11:39:25 +0100
Subject: [PATCH 05/12] feat(tutorial): open the tutorial from a lobby item
---
.../net/onelitefeather/cygnus/Cygnus.java | 2 +
.../cygnus/tutorial/TutorialFeature.java | 112 ++++++++++++++++++
2 files changed, 114 insertions(+)
create mode 100644 game/src/main/java/net/onelitefeather/cygnus/tutorial/TutorialFeature.java
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..8a71cf54
--- /dev/null
+++ b/game/src/main/java/net/onelitefeather/cygnus/tutorial/TutorialFeature.java
@@ -0,0 +1,112 @@
+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.PlayerSpawnEvent;
+import net.minestom.server.event.player.PlayerUseItemEvent;
+import net.minestom.server.network.ConnectionManager;
+import net.onelitefeather.cygnus.GameFeature;
+import net.onelitefeather.cygnus.event.GameStartEvent;
+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.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}.
+ *
+ * When the round starts the item is taken away and an open tutorial is closed, so nobody walks
+ * into the round with a dialog in front of their eyes. A click that still arrives afterwards 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;
+
+ /**
+ * 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 when the round starts
+ */
+ public TutorialFeature(Supplier phase, ConnectionManager connections) {
+ this.phase = phase;
+ this.connections = connections;
+ this.pages = new TutorialPages();
+ 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(GameStartEvent.class, _ -> this.connections.getOnlinePlayers().forEach(player -> {
+ Items.removeTutorialItem(player);
+ player.closeDialog();
+ }));
+ }
+
+ 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 once the round runs, are left
+ * alone.
+ *
+ * @param event the click the client sent back
+ */
+ void handleClick(PlayerCustomClickEvent event) {
+ if (!inLobby()) {
+ return;
+ }
+ if (TutorialPages.CLOSE_KEY.equals(event.getKey())) {
+ 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) {
+ page.open(player);
+ }
+ }
+}
From 09374c8a5c0cb1b0d79db06a772973ea52b64a7a Mon Sep 17 00:00:00 2001
From: theEvilReaper
Date: Tue, 27 Oct 2026 11:39:25 +0100
Subject: [PATCH 06/12] test(tutorial): cover the tutorial item and the page
turns
---
.../cygnus/tutorial/TutorialFeatureTest.java | 235 ++++++++++++++++++
1 file changed, 235 insertions(+)
create mode 100644 game/src/test/java/net/onelitefeather/cygnus/tutorial/TutorialFeatureTest.java
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..f1f8c5f9
--- /dev/null
+++ b/game/src/test/java/net/onelitefeather/cygnus/tutorial/TutorialFeatureTest.java
@@ -0,0 +1,235 @@
+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.event.GameStartEvent;
+import net.onelitefeather.cygnus.jumpscare.JumpScareManager;
+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 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));
+ }
+
+ private void click(Player clicker, Key key) {
+ 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 dialog.actions().stream()
+ .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_RULES_KEY, TutorialPages.SLENDER_RULES_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() {
+ assertOpens(TutorialPages.SURVIVOR_RULES_KEY, List.of(TutorialPages.SURVIVOR_ITEMS_KEY, TutorialPages.INTRO_KEY));
+ assertOpens(TutorialPages.SURVIVOR_ITEMS_KEY, List.of(TutorialPages.SURVIVOR_RULES_KEY, TutorialPages.INTRO_KEY));
+ assertOpens(TutorialPages.SLENDER_RULES_KEY, List.of(TutorialPages.SLENDER_ITEMS_KEY, TutorialPages.INTRO_KEY));
+ assertOpens(TutorialPages.SLENDER_ITEMS_KEY, List.of(TutorialPages.SLENDER_RULES_KEY, TutorialPages.INTRO_KEY));
+ assertOpens(TutorialPages.INTRO_KEY,
+ List.of(TutorialPages.SURVIVOR_RULES_KEY, TutorialPages.SLENDER_RULES_KEY, TutorialPages.CLOSE_KEY));
+ }
+
+ 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_RULES_KEY);
+
+ dialogs.assertSingle();
+ assertEquals(List.of(), otherDialogs.collect(), "another reader must stay on their own page");
+ }
+
+ @Test
+ @DisplayName("The round start takes the item away and closes the tutorial")
+ void roundStartTakesTheItemAndClosesTheDialog() {
+ spawn();
+ this.player.getInventory().setItemStack(Items.HEAL_SLOT, ItemStack.of(Material.SUGAR));
+ Collector clears = this.connection.trackIncoming(ClearDialogPacket.class);
+
+ this.feature.node().call(new GameStartEvent());
+
+ assertFalse(Items.hasTutorialItem(this.player), "the item must be gone when the round starts");
+ assertEquals(Material.SUGAR, this.player.getInventory().getItemStack(Items.HEAL_SLOT).material(), "the painkillers stay");
+ clears.assertSingle();
+ }
+
+ @Test
+ @DisplayName("A late click after the round started opens nothing")
+ void clickAfterRoundStartOpensNothing() {
+ startRound();
+ Collector dialogs = this.connection.trackIncoming(ShowDialogPacket.class);
+
+ click(this.player, TutorialPages.SURVIVOR_RULES_KEY);
+
+ assertEquals(List.of(), dialogs.collect(), "a closed tutorial must not come back during the round");
+ }
+}
From f8eead9700138a543c062aa2aec9f2a1c19ecdca Mon Sep 17 00:00:00 2001
From: theEvilReaper
Date: Tue, 27 Oct 2026 11:41:28 +0100
Subject: [PATCH 07/12] feat(tutorial): close the tutorial right before the
teleport
---
.../cygnus/tutorial/TutorialFeature.java | 19 ++++++++++---------
1 file changed, 10 insertions(+), 9 deletions(-)
diff --git a/game/src/main/java/net/onelitefeather/cygnus/tutorial/TutorialFeature.java b/game/src/main/java/net/onelitefeather/cygnus/tutorial/TutorialFeature.java
index 8a71cf54..0a2305b9 100644
--- a/game/src/main/java/net/onelitefeather/cygnus/tutorial/TutorialFeature.java
+++ b/game/src/main/java/net/onelitefeather/cygnus/tutorial/TutorialFeature.java
@@ -9,7 +9,7 @@
import net.minestom.server.event.player.PlayerUseItemEvent;
import net.minestom.server.network.ConnectionManager;
import net.onelitefeather.cygnus.GameFeature;
-import net.onelitefeather.cygnus.event.GameStartEvent;
+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;
@@ -26,9 +26,10 @@
* 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}.
*
- * When the round starts the item is taken away and an open tutorial is closed, so nobody walks
- * into the round with a dialog in front of their eyes. A click that still arrives afterwards opens
- * nothing. Dropping or moving the item is already ruled out by the game's global cancels.
+ * 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. 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
@@ -45,7 +46,7 @@ public final class TutorialFeature implements GameFeature {
* 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 when the round starts
+ * @param connections finds the online players right before the teleport
*/
public TutorialFeature(Supplier phase, ConnectionManager connections) {
this.phase = phase;
@@ -67,7 +68,7 @@ private void registerListeners() {
}
});
this.node.addListener(PlayerCustomClickEvent.class, this::handleClick);
- this.node.addListener(GameStartEvent.class, _ -> this.connections.getOnlinePlayers().forEach(player -> {
+ this.node.addListener(GamePrepareEvent.class, _ -> this.connections.getOnlinePlayers().forEach(player -> {
Items.removeTutorialItem(player);
player.closeDialog();
}));
@@ -87,13 +88,13 @@ private void onSpawn(PlayerSpawnEvent event) {
/**
* 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 once the round runs, are left
- * alone.
+ * 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 (!inLobby()) {
+ if (!Items.hasTutorialItem(event.getPlayer())) {
return;
}
if (TutorialPages.CLOSE_KEY.equals(event.getKey())) {
From 7038ff20cd84d2b2e218fa4e8c9da5167f35761a Mon Sep 17 00:00:00 2001
From: theEvilReaper
Date: Tue, 27 Oct 2026 11:41:28 +0100
Subject: [PATCH 08/12] test(tutorial): expect the tutorial to close right
before the teleport
---
.../cygnus/tutorial/TutorialFeatureTest.java | 23 +++++++++++--------
1 file changed, 13 insertions(+), 10 deletions(-)
diff --git a/game/src/test/java/net/onelitefeather/cygnus/tutorial/TutorialFeatureTest.java b/game/src/test/java/net/onelitefeather/cygnus/tutorial/TutorialFeatureTest.java
index f1f8c5f9..6de6d134 100644
--- a/game/src/test/java/net/onelitefeather/cygnus/tutorial/TutorialFeatureTest.java
+++ b/game/src/test/java/net/onelitefeather/cygnus/tutorial/TutorialFeatureTest.java
@@ -21,8 +21,8 @@
import net.onelitefeather.cygnus.camera.WakeUpTransition;
import net.onelitefeather.cygnus.common.config.GameConfig;
import net.onelitefeather.cygnus.disclaimer.EpilepsyDisclaimer;
-import net.onelitefeather.cygnus.event.GameStartEvent;
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;
@@ -70,7 +70,9 @@ 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));
}
@@ -209,27 +211,28 @@ void clickOpensThePageOnlyForTheClicker() {
}
@Test
- @DisplayName("The round start takes the item away and closes the tutorial")
- void roundStartTakesTheItemAndClosesTheDialog() {
+ @DisplayName("Right before the teleport the item is taken away and the tutorial closed")
+ void teleportTakesTheItemAndClosesTheDialog() {
spawn();
this.player.getInventory().setItemStack(Items.HEAL_SLOT, ItemStack.of(Material.SUGAR));
Collector clears = this.connection.trackIncoming(ClearDialogPacket.class);
- this.feature.node().call(new GameStartEvent());
+ this.feature.node().call(new GamePrepareEvent());
- assertFalse(Items.hasTutorialItem(this.player), "the item must be gone when the round starts");
+ 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("A late click after the round started opens nothing")
- void clickAfterRoundStartOpensNothing() {
- startRound();
+ @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);
- click(this.player, TutorialPages.SURVIVOR_RULES_KEY);
+ this.feature.node().call(new PlayerCustomClickEvent(this.player, TutorialPages.SURVIVOR_RULES_KEY, null));
- assertEquals(List.of(), dialogs.collect(), "a closed tutorial must not come back during the round");
+ assertEquals(List.of(), dialogs.collect(), "a closed tutorial must not come back once the player left the lobby");
}
}
From 92e0d713c15aeb63e7a0797fbc2ee3223c2f521a Mon Sep 17 00:00:00 2001
From: theEvilReaper
Date: Tue, 27 Oct 2026 11:45:21 +0100
Subject: [PATCH 09/12] fix(tutorial): close only the tutorial readers' dialogs
before the teleport
---
.../cygnus/tutorial/TutorialFeature.java | 20 +++++++++++++++++--
1 file changed, 18 insertions(+), 2 deletions(-)
diff --git a/game/src/main/java/net/onelitefeather/cygnus/tutorial/TutorialFeature.java b/game/src/main/java/net/onelitefeather/cygnus/tutorial/TutorialFeature.java
index 0a2305b9..a28bf26a 100644
--- a/game/src/main/java/net/onelitefeather/cygnus/tutorial/TutorialFeature.java
+++ b/game/src/main/java/net/onelitefeather/cygnus/tutorial/TutorialFeature.java
@@ -5,6 +5,7 @@
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;
@@ -16,6 +17,9 @@
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;
/**
@@ -27,7 +31,12 @@
* 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. Buttons
+ * 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 closes the tutorial on the client alone, so such a reader is
+ * still sent the close; by then no other dialog can be open, since the warning has to be answered
+ * before the tutorial can be opened at all. 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.
*
@@ -41,6 +50,7 @@ public final class TutorialFeature implements GameFeature {
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.
@@ -52,6 +62,7 @@ public TutorialFeature(Supplier phase, ConnectionManager connections) {
this.phase = phase;
this.connections = connections;
this.pages = new TutorialPages();
+ this.readers = ConcurrentHashMap.newKeySet();
registerListeners();
}
@@ -70,8 +81,11 @@ private void registerListeners() {
this.node.addListener(PlayerCustomClickEvent.class, this::handleClick);
this.node.addListener(GamePrepareEvent.class, _ -> this.connections.getOnlinePlayers().forEach(player -> {
Items.removeTutorialItem(player);
- player.closeDialog();
+ if (this.readers.remove(player.getUuid())) {
+ player.closeDialog();
+ }
}));
+ this.node.addListener(PlayerDisconnectEvent.class, event -> this.readers.remove(event.getPlayer().getUuid()));
}
private boolean inLobby() {
@@ -98,6 +112,7 @@ void handleClick(PlayerCustomClickEvent event) {
return;
}
if (TutorialPages.CLOSE_KEY.equals(event.getKey())) {
+ this.readers.remove(event.getPlayer().getUuid());
event.getPlayer().closeDialog();
return;
}
@@ -107,6 +122,7 @@ void handleClick(PlayerCustomClickEvent event) {
private void open(Player player, Key key) {
DialogTemplate page = this.pages.page(key);
if (page != null) {
+ this.readers.add(player.getUuid());
page.open(player);
}
}
From 5d09d7432a2e7e72388f0f7f1dfb8e97cb27871c Mon Sep 17 00:00:00 2001
From: theEvilReaper
Date: Tue, 27 Oct 2026 11:45:21 +0100
Subject: [PATCH 10/12] test(tutorial): keep other dialogs open before the
teleport
---
.../cygnus/tutorial/TutorialFeatureTest.java | 26 +++++++++++++++++++
1 file changed, 26 insertions(+)
diff --git a/game/src/test/java/net/onelitefeather/cygnus/tutorial/TutorialFeatureTest.java b/game/src/test/java/net/onelitefeather/cygnus/tutorial/TutorialFeatureTest.java
index 6de6d134..65a04c2e 100644
--- a/game/src/test/java/net/onelitefeather/cygnus/tutorial/TutorialFeatureTest.java
+++ b/game/src/test/java/net/onelitefeather/cygnus/tutorial/TutorialFeatureTest.java
@@ -214,6 +214,7 @@ void clickOpensThePageOnlyForTheClicker() {
@DisplayName("Right before the teleport the item is taken away and the tutorial closed")
void teleportTakesTheItemAndClosesTheDialog() {
spawn();
+ click(this.player, TutorialPages.SURVIVOR_RULES_KEY);
this.player.getInventory().setItemStack(Items.HEAL_SLOT, ItemStack.of(Material.SUGAR));
Collector clears = this.connection.trackIncoming(ClearDialogPacket.class);
@@ -224,6 +225,31 @@ void teleportTakesTheItemAndClosesTheDialog() {
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_RULES_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() {
From 2484e21607e7698fd37a0e09d96b5f438a8ba1ef Mon Sep 17 00:00:00 2001
From: theEvilReaper
Date: Tue, 27 Oct 2026 17:24:04 +0100
Subject: [PATCH 11/12] feat(tutorial): turn the tutorial like a book with two
points per page
---
.../cygnus/tutorial/TutorialFeature.java | 5 +-
.../cygnus/tutorial/TutorialPages.java | 209 +++++++++++-------
.../onelitefeather/cygnus/utils/Items.java | 6 +-
3 files changed, 135 insertions(+), 85 deletions(-)
diff --git a/game/src/main/java/net/onelitefeather/cygnus/tutorial/TutorialFeature.java b/game/src/main/java/net/onelitefeather/cygnus/tutorial/TutorialFeature.java
index a28bf26a..342aa947 100644
--- a/game/src/main/java/net/onelitefeather/cygnus/tutorial/TutorialFeature.java
+++ b/game/src/main/java/net/onelitefeather/cygnus/tutorial/TutorialFeature.java
@@ -34,9 +34,8 @@
* 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 closes the tutorial on the client alone, so such a reader is
- * still sent the close; by then no other dialog can be open, since the warning has to be answered
- * before the tutorial can be opened at all. Buttons
+ * 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.
*
diff --git a/game/src/main/java/net/onelitefeather/cygnus/tutorial/TutorialPages.java b/game/src/main/java/net/onelitefeather/cygnus/tutorial/TutorialPages.java
index 16b07397..75ac9767 100644
--- a/game/src/main/java/net/onelitefeather/cygnus/tutorial/TutorialPages.java
+++ b/game/src/main/java/net/onelitefeather/cygnus/tutorial/TutorialPages.java
@@ -21,16 +21,22 @@
import java.util.function.Consumer;
/**
- * The five pages of the lobby tutorial: the introduction, and for each role one page on how it
- * plays and one on its items.
+ * 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. Escape closes the tutorial, since
- * nothing on it has to be read.
+ * 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.
@@ -43,112 +49,121 @@ final class TutorialPages {
/** Opens the introduction. */
static final Key INTRO_KEY = Key.key("cygnus", "tutorial/intro");
- /** Opens the survivors' page on how they play. */
- static final Key SURVIVOR_RULES_KEY = Key.key("cygnus", "tutorial/survivor/rules");
+ /** 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 how he plays. */
- static final Key SLENDER_RULES_KEY = Key.key("cygnus", "tutorial/slender/rules");
+ /** 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. Everyone else is a survivor.",
- "The survivors search the map for pages. Find enough of them before the time runs out and "
- + "you win. Slenderman wins if nobody is left to find them.",
- "You are not alone with him: the creek, a second figure, walks the map as well, "
- + "and it answers to nobody.",
- "Pick a role to learn more.");
-
- private static final String SURVIVOR_RULES_TITLE = "Survivor — How to survive";
- private static final List SURVIVOR_RULES_BODY = List.of(
- "Pages. Pages are spread across the map, but they do not stay forever. A page "
- + "that is about to vanish starts to chime, faster the less time it has left.",
- "Fear. Your level shows your sanity: 100 is calm, 0 is 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, and the creek goes for the most scared.",
- "Sprint. The green bar is your breath. Sprinting drains it, and it refills once "
+ "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.",
- "Slenderman. Most of the time you cannot see him. When he shows himself he is "
- + "close, and he hits whoever is near him. Your screen tears apart while he stands in your "
- + "view. When he comes close, adrenaline lets you run faster for a moment.",
- "The creek. If it catches you, it drags you to where Slenderman started.",
- "Stay together. Not everything that looks like a teammate still is one.");
+ + "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_RULES_TITLE = "Slenderman — How to hunt";
- private static final List SLENDER_RULES_BODY = List.of(
- "Goal. Stop the survivors before they find enough pages.",
- "Hidden and shown. While hidden, the survivors cannot see you. Show yourself to "
- + "hit whoever is near you, but while you are shown you are slower, 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.",
- "Traces. While hidden you now and then leave a footprint that only survivors "
- + "can see.",
- "Pages. Every page they take scrambles your own screen a bit more.",
- "The creek. It hunts the survivors on its own. Use it: look through its eyes to "
- + "find them.");
-
- private static final String SLENDER_ITEMS_TITLE = "Slenderman — 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 "
+ + "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, "
+ 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 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 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 five 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(3)
- .action(button("Survivor", SURVIVOR_RULES_KEY))
- .action(button("Slenderman", SLENDER_RULES_KEY))
- .action(button("Close", CLOSE_KEY))
- .build());
- this.pages.put(SURVIVOR_RULES_KEY, page(SURVIVOR_RULES_KEY, SURVIVOR_RULES_TITLE,
- meta -> addParagraphs(meta, SURVIVOR_RULES_BODY))
- .columns(2)
- .action(button("Your items", SURVIVOR_ITEMS_KEY))
- .action(button("Back", INTRO_KEY))
+ .columns(1)
+ .action(button("Survivor", SURVIVOR_PAGES_KEY))
+ .action(button("Slenderman", SLENDER_HUNT_KEY))
.build());
- this.pages.put(SURVIVOR_ITEMS_KEY, page(SURVIVOR_ITEMS_KEY, SURVIVOR_ITEMS_TITLE, meta -> {
+ 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);
- })
- .columns(2)
- .action(button("How to survive", SURVIVOR_RULES_KEY))
- .action(button("Back", INTRO_KEY))
- .build());
- this.pages.put(SLENDER_RULES_KEY, page(SLENDER_RULES_KEY, SLENDER_RULES_TITLE,
- meta -> addParagraphs(meta, SLENDER_RULES_BODY))
- .columns(2)
- .action(button("Your items", SLENDER_ITEMS_KEY))
- .action(button("Back", INTRO_KEY))
- .build());
- this.pages.put(SLENDER_ITEMS_KEY, page(SLENDER_ITEMS_KEY, SLENDER_ITEMS_TITLE, meta -> {
+ }))));
+ 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);
- })
- .columns(2)
- .action(button("How to hunt", SLENDER_RULES_KEY))
- .action(button("Back", INTRO_KEY))
- .build());
+ }))));
}
/**
@@ -161,6 +176,25 @@ final class TutorialPages {
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 -> {
@@ -169,13 +203,21 @@ private static MultiActionDialog page(Key key, String title, 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();
@@ -192,9 +234,18 @@ 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));
+ .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/utils/Items.java b/game/src/main/java/net/onelitefeather/cygnus/utils/Items.java
index 75fc10ef..42f0be0a 100644
--- a/game/src/main/java/net/onelitefeather/cygnus/utils/Items.java
+++ b/game/src/main/java/net/onelitefeather/cygnus/utils/Items.java
@@ -70,8 +70,8 @@ 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, left of the statistics item. */
- public static final int TUTORIAL_SLOT = 2;
+ /** 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;
@@ -126,7 +126,7 @@ public final class Items {
.set(Tags.ITEM_TAG, STATS_ITEM)
.build();
- private static final ItemStack tutorial = ItemStack.builder(Material.KNOWLEDGE_BOOK)
+ 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)
From 80e2b2a2e1566d2d558464df32471f416f2638d4 Mon Sep 17 00:00:00 2001
From: theEvilReaper
Date: Tue, 27 Oct 2026 17:24:04 +0100
Subject: [PATCH 12/12] test(tutorial): cover the book chapters and the
tutorial item
---
.../cygnus/tutorial/TutorialFeatureTest.java | 28 +++---
.../cygnus/tutorial/TutorialPagesTest.java | 86 +++++++++++++------
.../cygnus/utils/ItemsTest.java | 4 +-
3 files changed, 81 insertions(+), 37 deletions(-)
diff --git a/game/src/test/java/net/onelitefeather/cygnus/tutorial/TutorialFeatureTest.java b/game/src/test/java/net/onelitefeather/cygnus/tutorial/TutorialFeatureTest.java
index 65a04c2e..f0726ab3 100644
--- a/game/src/test/java/net/onelitefeather/cygnus/tutorial/TutorialFeatureTest.java
+++ b/game/src/test/java/net/onelitefeather/cygnus/tutorial/TutorialFeatureTest.java
@@ -36,6 +36,7 @@
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;
@@ -82,7 +83,7 @@ private void startRound() {
private static List buttonKeys(ShowDialogPacket packet) {
Dialog.MultiAction dialog = assertInstanceOf(Dialog.MultiAction.class, packet.dialog());
- return dialog.actions().stream()
+ return Stream.concat(dialog.actions().stream(), Stream.of(dialog.exitAction()))
.map(button -> assertInstanceOf(DialogAction.Custom.class, button.action()).key())
.toList();
}
@@ -136,7 +137,7 @@ void useOpensTheIntro() {
this.player.getInventory().getItemStack(Items.TUTORIAL_SLOT), 0L));
dialogs.assertSingle(packet -> assertEquals(
- List.of(TutorialPages.SURVIVOR_RULES_KEY, TutorialPages.SLENDER_RULES_KEY, TutorialPages.CLOSE_KEY),
+ List.of(TutorialPages.SURVIVOR_PAGES_KEY, TutorialPages.SLENDER_HUNT_KEY, TutorialPages.CLOSE_KEY),
buttonKeys(packet)));
}
@@ -156,12 +157,15 @@ void otherItemOpensNothing() {
@Test
@DisplayName("Each button opens the page it names")
void eachKeyOpensItsPage() {
- assertOpens(TutorialPages.SURVIVOR_RULES_KEY, List.of(TutorialPages.SURVIVOR_ITEMS_KEY, TutorialPages.INTRO_KEY));
- assertOpens(TutorialPages.SURVIVOR_ITEMS_KEY, List.of(TutorialPages.SURVIVOR_RULES_KEY, TutorialPages.INTRO_KEY));
- assertOpens(TutorialPages.SLENDER_RULES_KEY, List.of(TutorialPages.SLENDER_ITEMS_KEY, TutorialPages.INTRO_KEY));
- assertOpens(TutorialPages.SLENDER_ITEMS_KEY, List.of(TutorialPages.SLENDER_RULES_KEY, TutorialPages.INTRO_KEY));
- assertOpens(TutorialPages.INTRO_KEY,
- List.of(TutorialPages.SURVIVOR_RULES_KEY, TutorialPages.SLENDER_RULES_KEY, TutorialPages.CLOSE_KEY));
+ 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) {
@@ -204,7 +208,7 @@ void clickOpensThePageOnlyForTheClicker() {
Collector otherDialogs = otherConnection.trackIncoming(ShowDialogPacket.class);
Collector dialogs = this.connection.trackIncoming(ShowDialogPacket.class);
- click(this.player, TutorialPages.SURVIVOR_RULES_KEY);
+ click(this.player, TutorialPages.SURVIVOR_PAGES_KEY);
dialogs.assertSingle();
assertEquals(List.of(), otherDialogs.collect(), "another reader must stay on their own page");
@@ -214,7 +218,7 @@ void clickOpensThePageOnlyForTheClicker() {
@DisplayName("Right before the teleport the item is taken away and the tutorial closed")
void teleportTakesTheItemAndClosesTheDialog() {
spawn();
- click(this.player, TutorialPages.SURVIVOR_RULES_KEY);
+ 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);
@@ -241,7 +245,7 @@ void teleportLeavesOtherDialogsOpen() {
@DisplayName("Right before the teleport a player who already closed the tutorial keeps their dialog")
void teleportLeavesClosedReadersAlone() {
spawn();
- click(this.player, TutorialPages.SURVIVOR_RULES_KEY);
+ click(this.player, TutorialPages.SURVIVOR_PAGES_KEY);
click(this.player, TutorialPages.CLOSE_KEY);
Collector clears = this.connection.trackIncoming(ClearDialogPacket.class);
@@ -257,7 +261,7 @@ void clickAfterTeleportOpensNothing() {
this.feature.node().call(new GamePrepareEvent());
Collector dialogs = this.connection.trackIncoming(ShowDialogPacket.class);
- this.feature.node().call(new PlayerCustomClickEvent(this.player, TutorialPages.SURVIVOR_RULES_KEY, null));
+ 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
index 9c5dadc8..f43ba525 100644
--- a/game/src/test/java/net/onelitefeather/cygnus/tutorial/TutorialPagesTest.java
+++ b/game/src/test/java/net/onelitefeather/cygnus/tutorial/TutorialPagesTest.java
@@ -3,6 +3,7 @@
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;
@@ -15,12 +16,14 @@
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;
@@ -30,15 +33,21 @@
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
- * Covers the five tutorial pages as the client receives them: which buttons each page carries,
- * where they lead and which items the item pages show.
+ * 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 PAGE_KEYS = List.of(TutorialPages.INTRO_KEY,
- TutorialPages.SURVIVOR_RULES_KEY, TutorialPages.SURVIVOR_ITEMS_KEY,
- TutorialPages.SLENDER_RULES_KEY, TutorialPages.SLENDER_ITEMS_KEY);
+ 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;
@@ -64,11 +73,13 @@ private Dialog.MultiAction received(Key key) {
"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(button -> assertInstanceOf(DialogAction.Custom.class, button.action(),
- "a button has to report back, otherwise the page never turns").key())
- .toList();
+ return dialog.actions().stream().map(TutorialPagesTest::keyOf).toList();
}
private static List shownItems(Dialog.MultiAction dialog) {
@@ -78,14 +89,31 @@ private static List shownItems(Dialog.MultiAction dialog) {
.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 offers both roles and a way out")
- void introOffersBothRolesAndClose() {
+ @DisplayName("The introduction lists both roles one below the other")
+ void introOffersBothRoles() {
Dialog.MultiAction intro = received(TutorialPages.INTRO_KEY);
- assertEquals(List.of(TutorialPages.SURVIVOR_RULES_KEY, TutorialPages.SLENDER_RULES_KEY, TutorialPages.CLOSE_KEY),
- buttonKeys(intro));
- assertEquals(3, intro.columns(), "the three buttons belong in one row");
+ 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
@@ -102,16 +130,26 @@ void everyPageTurnsWithoutClosing() {
}
@Test
- @DisplayName("The role pages lead to each other and back to the introduction")
- void rolePagesLinkUp() {
- assertEquals(List.of(TutorialPages.SURVIVOR_ITEMS_KEY, TutorialPages.INTRO_KEY),
- buttonKeys(received(TutorialPages.SURVIVOR_RULES_KEY)));
- assertEquals(List.of(TutorialPages.SURVIVOR_RULES_KEY, TutorialPages.INTRO_KEY),
- buttonKeys(received(TutorialPages.SURVIVOR_ITEMS_KEY)));
- assertEquals(List.of(TutorialPages.SLENDER_ITEMS_KEY, TutorialPages.INTRO_KEY),
- buttonKeys(received(TutorialPages.SLENDER_RULES_KEY)));
- assertEquals(List.of(TutorialPages.SLENDER_RULES_KEY, TutorialPages.INTRO_KEY),
- buttonKeys(received(TutorialPages.SLENDER_ITEMS_KEY)));
+ @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
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 2669029f..1fac2b9f 100644
--- a/game/src/test/java/net/onelitefeather/cygnus/utils/ItemsTest.java
+++ b/game/src/test/java/net/onelitefeather/cygnus/utils/ItemsTest.java
@@ -67,7 +67,9 @@ void testTutorialItemSitsInItsSlot(@NotNull Env env) {
Items.setTutorialItem(player);
ItemStack tutorial = player.getInventory().getItemStack(Items.TUTORIAL_SLOT);
assertTrue(Items.isTutorialItem(tutorial), "the tutorial item belongs in its own slot");
- assertEquals(Material.KNOWLEDGE_BOOK, tutorial.material());
+ 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);