From 43ff38dfe783fd2669fd2ebe33116a0ac12ea847 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Wed, 2 Sep 2026 14:42:02 -0700 Subject: [PATCH 1/3] Fix hint arrow packet writing fewer bytes than the client reads The client frames opcode 69 as a fixed 12 bytes, but arrowHint only wrote the body when a sprite was given. clearHint passes the default sprite of -1, so clearing an arrow sent a 2 byte packet, and the client consumed the following 10 bytes as part of it. That desynchronised the stream and dropped the connection on the next opcode. Every branch now writes the full body, padding the clear case the client skips over. Covered for npc, player, tile and both clear forms. --- .../login/protocol/encode/HintEncoder.kt | 23 ++++++---- .../network/login/protocol/HintEncoderTest.kt | 45 +++++++++++++++++++ 2 files changed, 60 insertions(+), 8 deletions(-) create mode 100644 network/src/test/kotlin/world/gregs/voidps/network/login/protocol/HintEncoderTest.kt diff --git a/network/src/main/kotlin/world/gregs/voidps/network/login/protocol/encode/HintEncoder.kt b/network/src/main/kotlin/world/gregs/voidps/network/login/protocol/encode/HintEncoder.kt index f3382c997e..5032f6e102 100644 --- a/network/src/main/kotlin/world/gregs/voidps/network/login/protocol/encode/HintEncoder.kt +++ b/network/src/main/kotlin/world/gregs/voidps/network/login/protocol/encode/HintEncoder.kt @@ -44,20 +44,27 @@ fun Client.arrowHint( radius: Int = 0, model: Int = 65535, ) = send(Protocol.HINT_ARROW) { + // The client frames this packet as a fixed 12 bytes, so every branch must write all of + // them even when it ignores the contents - a short packet eats the next one off the wire. writeByte((arrowIndex shl 5) or type) writeByte(sprite) - if (sprite >= 0) { - if (type == 1 || type == 10) { + when { + type == 1 || type == 10 -> { writeShort(entityIndex) writeInt(0) writeShort(0) - } else if (type in 2..6) { - writeByte(level) // level - writeShort(x) // x - writeShort(y) // y - writeByte(z) // z? + } + type in 2..6 -> { + writeByte(level) + writeShort(x) + writeShort(y) + writeByte(z) writeShort(radius) } - writeShort(model) + else -> { // Clearing an arrow; the client stops reading after the type but still expects the body. + writeInt(0) + writeInt(0) + } } + writeShort(model) } \ No newline at end of file diff --git a/network/src/test/kotlin/world/gregs/voidps/network/login/protocol/HintEncoderTest.kt b/network/src/test/kotlin/world/gregs/voidps/network/login/protocol/HintEncoderTest.kt new file mode 100644 index 0000000000..d2265d5170 --- /dev/null +++ b/network/src/test/kotlin/world/gregs/voidps/network/login/protocol/HintEncoderTest.kt @@ -0,0 +1,45 @@ +package world.gregs.voidps.network.login.protocol + +import io.ktor.utils.io.ByteChannel +import io.ktor.utils.io.availableForRead +import io.ktor.utils.io.readAvailable +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.DynamicTest.dynamicTest +import org.junit.jupiter.api.TestFactory +import world.gregs.voidps.network.client.Client +import world.gregs.voidps.network.client.IsaacCipher +import world.gregs.voidps.network.login.protocol.encode.arrowHint + +class HintEncoderTest { + + /** + * The client reads this packet as a fixed 12 bytes, so a shorter one swallows whatever + * follows it on the wire and desynchronises the stream. + */ + @TestFactory + fun `Every hint arrow is the fixed packet length`() = listOf Unit>>( + "npc" to { arrowHint(type = 1, arrowIndex = 0, sprite = 0, entityIndex = 1234) }, + "player" to { arrowHint(type = 10, arrowIndex = 3, sprite = 0, entityIndex = 1234) }, + "tile" to { arrowHint(type = 2, arrowIndex = 1, sprite = 3, x = 100, y = 123, level = 2, z = 50, radius = 2) }, + "clear" to { arrowHint(type = 0, arrowIndex = 5) }, + "clear all slots" to { arrowHint(type = 0, arrowIndex = 7, sprite = -1) }, + ).map { (name, packet) -> + dynamicTest("Test $name hint arrow length") { + val channel = ByteChannel(true) + val client = Client(channel, IsaacCipher(IntArray(4)), IsaacCipher(IntArray(4)), "") + + packet.invoke(client) + + val actual = ByteArray(channel.availableForRead) + runTest { + channel.readAvailable(actual) + } + assertEquals(PACKET_LENGTH + 1, actual.size, "$name hint arrow was ${actual.size - 1} bytes of payload") + } + } + + companion object { + private const val PACKET_LENGTH = 12 + } +} From 00172d5ddd1a81f7e6fc5657761ab868bb49eb38 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Wed, 2 Sep 2026 14:42:22 -0700 Subject: [PATCH 2/3] Add Tutorial Island New accounts start on Tutorial Island and walk the 68 stage sequence through all nine instructors, ending with the Magic Instructor teleporting them to Lumbridge with the usual starter kit. Gated behind world.start.tutorial, default on; with it off nothing changes. The stage counter is a persisted int seeded by AccountManager.create, so only accounts made while the setting is on ever enter, and existing saves are untouched. Stage data lives in tutorial_island.tables.toml: text, hint target, tab to flash and component to reveal, one row per stage. Locked tabs are derived from the stage rather than stored separately, so relogging restores everything from the one value. The instruction box (interface 372) gets its own interface type rather than reusing dialogue_box. Queued actions only run while Player.dialogue is null, so an always-on interface in a dialogue slot freezes every delayed action, smelting included. It also stands aside while an NPC is talking, since both share the chat box slot on the client. The progress bar (371) sits in a new above_chat_box slot, and its varp is the segment count plus one because the client script lights segment n only when the varp exceeds n. Fixes found along the way: - Introduction read world.setup.gear, but the property is world.start.gear, so the toggle never did anything. It also granted "shrimp", which is not an item id. - fishing_spot_tutorial_island had an empty net list and caught nothing. - Two Tutorial Island ladders (3029, 3030) were filed under wizards tower with wrong region comments. Object teleports are keyed by tile and option alone, so they collided with the correctly named entries. - The dungeon doorways are named Gate in the cache but swing as doors, so they take the existing gate = false override. - The run orb showed a stale mode when revealed mid-session, since varp 173 is never sent while it holds its default. --- .../tutorial_island.areas.toml | 5 + .../tutorial_island.ifaces.toml | 33 ++ .../tutorial_island/tutorial_island.objs.toml | 24 + .../tutorial_island.tables.toml | 447 ++++++++++++++++++ .../tutorial_island.teles.toml | 14 +- .../tutorial_island.varps.toml | 5 + .../tutorial_island/tutorial_island.vars.toml | 15 + .../wizards_tower/wizards_tower.teles.toml | 11 - data/entity/obj/gates.objs.toml | 9 + .../player/dialogue/dialogue.ifaces.toml | 4 - data/entity/player/modal/interface_types.toml | 14 + data/entity/player/modal/tab/tab.varbits.toml | 6 + data/skill/fishing/fishing_spots.tables.toml | 2 +- .../voidps/engine/data/AccountManager.kt | 11 +- .../misthalin/tutorial_island/BrotherBrace.kt | 32 ++ .../tutorial_island/CombatInstructor.kt | 52 ++ .../tutorial_island/FinancialAdvisor.kt | 23 + .../tutorial_island/MagicInstructor.kt | 89 ++++ .../misthalin/tutorial_island/MasterChef.kt | 45 ++ .../tutorial_island/MiningInstructor.kt | 48 ++ .../misthalin/tutorial_island/QuestGuide.kt | 27 ++ .../tutorial_island/RunescapeGuide.kt | 34 ++ .../tutorial_island/SurvivalExpert.kt | 42 ++ .../tutorial_island/TutorialBanker.kt | 17 + .../tutorial_island/TutorialCommands.kt | 23 + .../tutorial_island/TutorialIsland.kt | 238 ++++++++++ .../tutorial_island/TutorialObjects.kt | 85 ++++ .../tutorial_island/TutorialProgress.kt | 63 +++ .../tutorial_island/TutorialRestrictions.kt | 60 +++ .../tutorial_island/TutorialSkills.kt | 77 +++ .../misthalin/tutorial_island/TutorialTabs.kt | 43 ++ .../content/entity/player/Introduction.kt | 58 ++- .../entity/player/effect/energy/Running.kt | 3 + .../content/entity/player/modal/GameFrame.kt | 68 +-- game/src/main/resources/game.properties | 8 + .../tutorial_island/TutorialDepartureTest.kt | 59 +++ .../tutorial_island/TutorialDialogueTest.kt | 62 +++ .../tutorial_island/TutorialGatesTest.kt | 50 ++ .../tutorial_island/TutorialIslandTest.kt | 197 ++++++++ .../tutorial_island/TutorialLaddersTest.kt | 56 +++ .../tutorial_island/TutorialRunOrbTest.kt | 35 ++ .../tutorial_island/TutorialSmeltingTest.kt | 55 +++ 42 files changed, 2179 insertions(+), 70 deletions(-) create mode 100644 data/area/misthalin/tutorial_island/tutorial_island.ifaces.toml create mode 100644 data/area/misthalin/tutorial_island/tutorial_island.tables.toml create mode 100644 data/area/misthalin/tutorial_island/tutorial_island.varps.toml create mode 100644 data/area/misthalin/tutorial_island/tutorial_island.vars.toml create mode 100644 data/entity/player/modal/tab/tab.varbits.toml create mode 100644 game/src/main/kotlin/content/area/misthalin/tutorial_island/BrotherBrace.kt create mode 100644 game/src/main/kotlin/content/area/misthalin/tutorial_island/CombatInstructor.kt create mode 100644 game/src/main/kotlin/content/area/misthalin/tutorial_island/FinancialAdvisor.kt create mode 100644 game/src/main/kotlin/content/area/misthalin/tutorial_island/MagicInstructor.kt create mode 100644 game/src/main/kotlin/content/area/misthalin/tutorial_island/MasterChef.kt create mode 100644 game/src/main/kotlin/content/area/misthalin/tutorial_island/MiningInstructor.kt create mode 100644 game/src/main/kotlin/content/area/misthalin/tutorial_island/QuestGuide.kt create mode 100644 game/src/main/kotlin/content/area/misthalin/tutorial_island/RunescapeGuide.kt create mode 100644 game/src/main/kotlin/content/area/misthalin/tutorial_island/SurvivalExpert.kt create mode 100644 game/src/main/kotlin/content/area/misthalin/tutorial_island/TutorialBanker.kt create mode 100644 game/src/main/kotlin/content/area/misthalin/tutorial_island/TutorialCommands.kt create mode 100644 game/src/main/kotlin/content/area/misthalin/tutorial_island/TutorialIsland.kt create mode 100644 game/src/main/kotlin/content/area/misthalin/tutorial_island/TutorialObjects.kt create mode 100644 game/src/main/kotlin/content/area/misthalin/tutorial_island/TutorialProgress.kt create mode 100644 game/src/main/kotlin/content/area/misthalin/tutorial_island/TutorialRestrictions.kt create mode 100644 game/src/main/kotlin/content/area/misthalin/tutorial_island/TutorialSkills.kt create mode 100644 game/src/main/kotlin/content/area/misthalin/tutorial_island/TutorialTabs.kt create mode 100644 game/src/test/kotlin/content/area/misthalin/tutorial_island/TutorialDepartureTest.kt create mode 100644 game/src/test/kotlin/content/area/misthalin/tutorial_island/TutorialDialogueTest.kt create mode 100644 game/src/test/kotlin/content/area/misthalin/tutorial_island/TutorialGatesTest.kt create mode 100644 game/src/test/kotlin/content/area/misthalin/tutorial_island/TutorialIslandTest.kt create mode 100644 game/src/test/kotlin/content/area/misthalin/tutorial_island/TutorialLaddersTest.kt create mode 100644 game/src/test/kotlin/content/area/misthalin/tutorial_island/TutorialRunOrbTest.kt create mode 100644 game/src/test/kotlin/content/area/misthalin/tutorial_island/TutorialSmeltingTest.kt diff --git a/data/area/misthalin/tutorial_island/tutorial_island.areas.toml b/data/area/misthalin/tutorial_island/tutorial_island.areas.toml index c6f0c7a53d..defdc4b676 100644 --- a/data/area/misthalin/tutorial_island/tutorial_island.areas.toml +++ b/data/area/misthalin/tutorial_island/tutorial_island.areas.toml @@ -2,3 +2,8 @@ x = [3040, 3160] y = [3040, 3136] tags = ["no_random_events"] + +[tutorial_island_cave] +x = [3072, 3135] +y = [9472, 9535] +tags = ["no_random_events"] diff --git a/data/area/misthalin/tutorial_island/tutorial_island.ifaces.toml b/data/area/misthalin/tutorial_island/tutorial_island.ifaces.toml new file mode 100644 index 0000000000..3118785a32 --- /dev/null +++ b/data/area/misthalin/tutorial_island/tutorial_island.ifaces.toml @@ -0,0 +1,33 @@ +# The progress bar sits in the strip above the chat box, not over the game screen. +# Component 0 is bound to varp 406 and carries the client script that lights the segments, +# so the server only keeps `tutorial_progress` current. +[tutorial_overlay] +id = 371 +type = "above_chat_box" + +[.percent] +id = 1 + +[.title] +id = 2 + +# Layer holding the oversized arrow model and welcome blurb. It's sized for the full game +# screen, so it has to stay hidden while the bar sits in the strip above the chat box. +[.welcome] +id = 4 + +[.instructions] +id = 27 + +# The instruction box sits in the chat box. Deliberately not named `dialogue_*` so that +# re-sending it when a dialogue closes can't retrigger itself, and typed `tutorial_box` so it +# doesn't count as an open dialogue. +[tutorial_text] +id = 372 +type = "tutorial_box" + +[.title] +id = 0 + +[.line1] +id = "1-6" diff --git a/data/area/misthalin/tutorial_island/tutorial_island.objs.toml b/data/area/misthalin/tutorial_island/tutorial_island.objs.toml index 571fb80796..3bd4810521 100644 --- a/data/area/misthalin/tutorial_island/tutorial_island.objs.toml +++ b/data/area/misthalin/tutorial_island/tutorial_island.objs.toml @@ -1,3 +1,27 @@ [bank_booth_tutorial_island] id = 3045 examine = "The bank teller will serve you from here." + +[cooking_range_tutorial_island] +id = 3039 +examine = "A hot flame cooks the food." + +[furnace_tutorial_island] +id = 3044 +examine = "Hot!" + +[ladder_tutorial_island_cave_down] +id = 3029 +examine = "I can climb down this." + +[ladder_tutorial_island_cave_up] +id = 3028 +examine = "I can climb up this." + +[ladder_tutorial_island_rat_pit_down] +id = 3031 +examine = "I can climb down this." + +[ladder_tutorial_island_rat_pit_up] +id = 3030 +examine = "I can climb up this." diff --git a/data/area/misthalin/tutorial_island/tutorial_island.tables.toml b/data/area/misthalin/tutorial_island/tutorial_island.tables.toml new file mode 100644 index 0000000000..60847d2abc --- /dev/null +++ b/data/area/misthalin/tutorial_island/tutorial_island.tables.toml @@ -0,0 +1,447 @@ +# Tutorial Island stage table. One row per stage, in order; `TutorialIsland` reads +# `tutorial_island.stage_` and a stage only ever advances to the next index. +# `title`/`lines` are written to the instruction box (interface 372, 1 title + 6 lines). +# `%server%` is replaced with the `server.name` setting. +# `hint_npc`/`hint_tile` place the yellow arrow, `flash` blinks a sidebar tab and +# `unlock` reveals a game frame component from that stage onwards. + +[tutorial_island] +title = "string" +lines = "list" +hint_npc = "string" +hint_tile = "tile" +hint_height = "int" +flash = "string" +unlock = "string" + +# TALK_TO_GUIDE +[.stage_0] +title = "Getting started" +lines = ["To start the tutorial use your left mouse button to click on the", "%server% Guide in this room. He is indicated by a flashing", "yellow arrow above his head. If you can't see him, use your", "keyboard's arrow keys to rotate the view."] +hint_npc = "runescape_guide" + +# OPEN_SETTINGS +[.stage_1] +title = "Player controls" +lines = ["Please click on the flashing spanner icon found at the bottom", "right of your screen. This will display your player controls."] +flash = "Options" +unlock = "options" + +# TALK_TO_GUIDE_2 +[.stage_2] +title = "Player controls" +lines = ["On the side panel you can now see a variety of options, from", "changing the screen brightness and the volume of the music, to", "selecting whether your player should accept help from other", "players. Don't worry about these too much for now. Talk to the", "RuneScape Guide to continue."] +hint_npc = "runescape_guide" + +# LEAVE_GUIDE_ROOM +[.stage_3] +title = "Interacting with scenery" +lines = ["You can interact with many items of scenery by simply clicking", "on them. Right clicking will also give more options. Feel free to", "try it with the things in this room, then click the door", "indicated with the yellow arrow to go through to the next", "instructor."] +hint_tile = { x = 3097, y = 3107 } +hint_height = 125 + +# TALK_TO_SURVIVAL_EXPERT +[.stage_4] +title = "Moving around" +lines = ["Follow the path to find the next instructor. Clicking on the", "ground will walk you to that point. Talk to the Survival Expert by", "the pond to continue the tutorial. Remember that you can rotate", "the view by pressing the arrow keys."] +hint_npc = "survival_expert" + +# OPEN_INVENTORY +[.stage_5] +title = "Viewing the items that you were given." +lines = ["Click on the flashing backpack icon to the right hand side of", "the main window to view your inventory. Your inventory is a list", "of everything you have in your backpack."] +flash = "Inventory" +unlock = "inventory" + +# CHOP_TREE +[.stage_6] +title = "Cut down a tree" +lines = ["You can click on the backpack icon at any time to view the", "items that you currently have in your inventory. You will see", "that you now have an axe in your inventory. Use this to get", "some logs by clicking on one of the trees in the area."] +hint_tile = { x = 3099, y = 3095 } +hint_height = 150 + +# MAKE_A_FIRE +[.stage_7] +title = "Making a fire" +lines = ["Well done! You managed to cut some logs from the tree! Next,", "use the tinderbox in your inventory to light the logs.", "First click on the tinderbox to 'use' it.", "Then click on the logs in your inventory to light them."] + +# OPEN_SKILLS +[.stage_8] +title = "" +lines = ["You gained some experience.", "Click on the flashing bar graph icon near the inventory button", "to see your skill stats."] +flash = "Stats" +unlock = "stats" + +# TALK_TO_SURVIVAL_EXPERT_2 +[.stage_9] +title = "Your skill stats." +lines = ["Here you will see how good your skills are. As you move your", "mouse over any of the icons in this panel, the small yellow", "popup box will show you the exact amount of experience you", "have and how much is needed to get to the next level. Speak to", "the Survival Expert to continue."] +hint_npc = "survival_expert" + +# CATCH_SHRIMP +[.stage_10] +title = "Catch some Shrimp" +lines = ["Click on the sparkling fishing spot, indicated by the flashing", "arrow. Remember, you can check your inventory by clicking the", "backpack icon."] +hint_npc = "fishing_spot_tutorial_island" + +# BURN_SHRIMP +[.stage_11] +title = "Cooking your shrimp." +lines = ["Now you have caught some shrimp, let's cook it. First light a", "fire: chop down a tree and then use the tinderbox on the logs.", "If you've lost your axe or tinderbox Brynna will give you", "another."] + +# COOK_SHRIMP +[.stage_12] +title = "Burning your shrimp." +lines = ["You have just burnt your first shrimp. This is normal. As you", "get more experience in Cooking, you will burn stuff less often.", "Let's try cooking without burning it this time. First catch some", "more shrimp, then use them on a fire."] + +# LEAVE_SURVIVAL_EXPERT +[.stage_13] +title = "Well done, you've just cooked your first %server% meal." +lines = ["If you'd like a recap on anything you've learnt so far, speak to", "the Survival Expert. You can now move on to the next instructor.", "Click on the gate shown and follow the path.", "Remember, you can move the camera with the arrow keys."] +hint_tile = { x = 3089, y = 3092 } +hint_height = 120 + +# ENTER_CHEF_HOUSE +[.stage_14] +title = "Find your next instructor." +lines = ["Follow the path until you get to the door with the yellow arrow", "above it. Click on the door to open it. Notice the mini-map in", "the top right; this shows a top down view of the area around", "you. This can also be used for navigation."] +hint_tile = { x = 3078, y = 3084 } +hint_height = 150 + +# TALK_TO_CHEF +[.stage_15] +title = "Find your next instructor." +lines = ["Talk to the chef indicated. He will teach you the more advanced", "aspects of Cooking such as combining ingredients. He will also", "teach you about your music player menu as well."] +hint_npc = "master_chef" + +# MAKE_DOUGH +[.stage_16] +title = "Making dough." +lines = ["This is the base for many of the meals. To make dough we must", "mix flour and water. First, right click the bucket of water and", "select use, then left click on the pot of flour."] + +# COOK_DOUGH +[.stage_17] +title = "Cooking dough." +lines = ["Now you have made dough, you can cook it. To cook the dough,", "use it with the range shown by the arrow. If you lose your", "dough, talk to Lev - he will give you more ingredients."] +hint_tile = { x = 3075, y = 3081 } +hint_height = 125 + +# OPEN_MUSIC +[.stage_18] +title = "Cooking dough" +lines = ["Well done! Your first loaf of bread. As you gain experience in", "Cooking, you will be able to make other things like pies, cakes", "and even kebabs. Now you've got the hang of cooking, let's", "move on. Click on the flashing icon in the bottom right to see", "the jukebox."] +flash = "MusicPlayer" +unlock = "music_player" + +# LEAVE_CHEF_HOUSE +[.stage_19] +title = "The music player." +lines = ["From this interface you can control the music that is played.", "As you explore the world, more of the tunes will become", "unlocked. Once you've examined this menu use the next door", "to continue. If you need a recap on anything covered here,", "talk to Lev."] +hint_tile = { x = 3072, y = 3090 } +hint_height = 125 + +# OPEN_EMOTES +[.stage_20] +title = "Emotes." +lines = ["", "Now, how about showing some feelings? You will see a flashing", "icon in the shape of a person. Click on that to access your", "emotes."] +flash = "Emotes" +unlock = "emotes" + +# USE_EMOTE +[.stage_21] +title = "Emotes." +lines = ["For those situations where words don't quite describe how you", "feel, try an emote. Go ahead, try one out! You might notice that", "some of the emotes are greyed out and cannot be used yet.", "Don't worry! As you progress further into the game you'll gain", "access to all sorts of things, including more fun emotes."] + +# RUN +[.stage_22] +title = "Running." +lines = ["It's only a short distance to the next guide.", "Why not try running there? You can run by clicking", "on the boot icon next to your minimap or by holding", "down your control key while clicking your destination."] +flash = "RunOrb" +unlock = "energy_orb" + +# ENTER_QUEST_GUIDE_HOUSE +[.stage_23] +title = "Run to the next guide." +lines = ["Now that you have the run button turned on, follow the path", "until you come to the end. You may notice that the number on", "the button goes down. This is your run energy. If your run", "energy reaches zero, you'll stop running. Click on the door", "to pass through it."] +hint_tile = { x = 3086, y = 3126 } +hint_height = 125 + +# TALK_TO_QUEST_GUIDE +[.stage_24] +title = "" +lines = ["Talk with the Quest Guide.", "", "He will tell you all about quests.", ""] +hint_npc = "quest_guide" + +# OPEN_QUEST_TAB +[.stage_25] +title = "" +lines = ["Open the Quest Journal.", "", "Click on the flashing icon next to your inventory.", ""] +flash = "QuestJournals" +unlock = "quest_journals" + +# TALK_TO_QUEST_GUIDE_2 +[.stage_26] +title = "Your Quest Journal" +lines = ["", "This is your Quest Journal, a list of all the quests in the game.", "Talk to the Quest Guide again for an explanation.", ""] +hint_npc = "quest_guide" + +# LEAVE_QUEST_GUIDE_HOUSE +[.stage_27] +title = "" +lines = ["Moving on.", "It's time to enter some caves. Click on the ladder to go down to", "the next area.", ""] +hint_tile = { x = 3088, y = 3119 } +hint_height = 50 + +# TALK_TO_MINING_GUIDE +[.stage_28] +title = "Mining and Smithing." +lines = ["Next let's get you a weapon, or more to the point, you can", "make your first weapon yourself. Don't panic, the Mining", "Instructor will help you. Talk to him and he'll tell you all about it.", ""] +hint_npc = "mining_instructor" + +# PROSPECTING_TIN +[.stage_29] +title = "Prospecting." +lines = ["To prospect a mineable rock, just right click it and select the", "'prospect rock' option. This will tell you the type of ore you can", "mine from it. Try it now on one of the rocks indicated.", ""] +hint_tile = { x = 3076, y = 9504 } +hint_height = 45 + +# PROSPECTING_COPPER +[.stage_30] +title = "It's tin." +lines = ["", "So now you know there's tin in the grey rocks, try prospecting", "the brown ones next.", ""] +hint_tile = { x = 3086, y = 9501 } +hint_height = 45 + +# TALK_TO_MINING_GUIDE_2 +[.stage_31] +title = "It's copper." +lines = ["Talk to the Mining Instructor to find out about these types of", "ore and how you can mind them. He'll even give you the", "required tools.", ""] +hint_npc = "mining_instructor" + +# MINING_TIN +[.stage_32] +title = "Mining." +lines = ["It's quite simple really. All you need to do is right click on the", "rock and select 'mine'. You can only mine when you have a", "pickaxe. So give it a try: first mine one tin ore.", ""] +hint_tile = { x = 3076, y = 9504 } +hint_height = 45 + +# MINING_COPPER +[.stage_33] +title = "Mining." +lines = ["Now you have some tin ore you just need some copper ore,", "then you'll have all you need to create a bronze bar. As you", "did before right click on the copper rock and select 'mine'.", ""] +hint_tile = { x = 3086, y = 9501 } +hint_height = 45 + +# SMELTING +[.stage_34] +title = "Smelting." +lines = ["You should now have both some copper and tin ore. So let's", "smelt them to make a bronze bar. To do this, right click on", "either tin or copper ore and select use then left click on the", "furnace. Try it now."] +hint_tile = { x = 3079, y = 9496 } +hint_height = 125 + +# TALK_TO_MINING_GUIDE_3 +[.stage_35] +title = "You've made a bronze bar!" +lines = ["", "Speak to the Mining Instructor and he'll show you how to make", "it into a weapon.", ""] +hint_npc = "mining_instructor" + +# CLICK_ANVIL +[.stage_36] +title = "Smithing a dagger." +lines = ["To smith you'll need a hammer and enough metal bars to make", "the desired item, as well as a handy anvil. To start the", "process, click on the anvil, or alternatively use the bar on it.", ""] +hint_tile = { x = 3083, y = 9499 } +hint_height = 35 + +# SMITH_DAGGER +[.stage_37] +title = "Smithing a dagger." +lines = ["Now you have the Smithing menu open, you will see a list of all", "the things you can make. Only the dagger can be made at your", "skill level; this is shown by the white text under it. You'll need", "to select the dagger to continue."] + +# LEAVE_MINING_AREA +[.stage_38] +title = "You've finished in this area." +lines = ["So let's move on. Go through the gates shown by the arrow.", "Remember, you may need to move the camera to see your", "surroundings. Speak to the guide for a recap at any time.", ""] +hint_tile = { x = 3094, y = 9502 } +hint_height = 125 + +# TALK_TO_COMBAT_INSTRUCTOR +[.stage_39] +title = "Combat." +lines = ["", "In this area you will find out about combat with swords and", "bows. Speak to the guide and he will tell you all about it.", ""] +hint_npc = "combat_instructor" + +# OPEN_EQUIPMENT_TAB +[.stage_40] +title = "Wielding weapons." +lines = ["", "You now have access to a new interface. Click on the flashing", "icon of a man, the one to the right of your backpack icon.", ""] +flash = "WornEquipment" +unlock = "worn_equipment" + +# OPEN_EQUIPMENT_SCREEN +[.stage_41] +title = "This is your worn inventory." +lines = ["From here you can see what items you have equipped. You will", "notice the button 'View equipment stats'. Click on this now to", "display the details of what you have equipped.", ""] + +# WIELD_DAGGER +[.stage_42] +title = "Worn interface" +lines = ["You can see what items you are wearing in the worn inventory", "to the left of the screen, with their combined statistics on the", "right. Let's add something. Left click your dagger to 'wield' it.", ""] + +# TALK_TO_COMBAT_INSTRUCTOR_2 +[.stage_43] +title = "You're now holding your dagger." +lines = ["Clothes, armour, weapons and many other items are equipped", "like this. You can unequip items by clicking on the item in the", "worn inventory. You can close this window by clicking on the", "small 'x' in the top right hand corner. Speak to the Combat", "Instructor to continue."] +hint_npc = "combat_instructor" + +# EQUIP_SWORD_AND_SHIELD +[.stage_44] +title = "Unequipping items." +lines = ["In your worn inventory panel, right click on the dagger and", "select the remove option from the drop down list. After you've", "unequipped the dagger, wield the sword and shield. As you", "pass the mouse over an item, you will see its name appear at", "the top left of the screen."] + +# OPEN_COMBAT_TAB +[.stage_45] +title = "Combat interface." +lines = ["", "Click on the flashing crossed swords icon to see the combat", "interface.", ""] +flash = "CombatStyles" +unlock = "combat_styles" + +# ENTER_RAT_CAGE +[.stage_46] +title = "This is your combat interface." +lines = ["From this interface you can select the type of attack your", "character will use. Different monsters have different weaknesses.", "Hover your mouse over the buttons to see the type of XP you", "receive from each attack. Now you have the tools for battle, why", "not slay some rats? Click on the gates indicated to continue."] +hint_tile = { x = 3111, y = 9518 } +hint_height = 125 + +# ATTACK_RAT_MELEE +[.stage_47] +title = "Attacking." +lines = ["", "To attack the rat, right click it and select the attack option. You", "will then walk over to it and start hitting it.", ""] +hint_npc = "giant_rat_tutorial_island" + +# KILL_RAT_MELEE +[.stage_48] +title = "Sit back and watch." +lines = ["While you are fighting you will see a bar over your head. The", "bar shows how much health you have left. Your opponent will", "have one too. You will continue to attack the rat until it's dead", "or you do something else."] + +# TALK_TO_COMBAT_INSTRUCTOR_3 +[.stage_49] +title = "Well done, you've made your first kill!" +lines = ["", "Pass through the gate and talk to the Combat Instructor; he", "will give you your next task.", ""] +hint_npc = "combat_instructor" + +# KILL_RAT_RANGE +[.stage_50] +title = "Rat ranging." +lines = ["Now you have a bow and some arrows. Before you can use", "them you'll need to equip them. Once equipped with the", "ranging gear, try killing another rat. Remember: to attack, right", "click on the monster and select attack."] +hint_npc = "giant_rat_tutorial_island" + +# LEAVE_COMBAT_AREA +[.stage_51] +title = "Moving on." +lines = ["You have completed the tasks here. To move on, click on the", "ladder shown. If you need to go over any of what you learnt", "here, just talk to the Combat Instructor and he'll tell you what", "he can."] +hint_tile = { x = 3111, y = 9526 } +hint_height = 125 + +# OPEN_BANK +[.stage_52] +title = "Banking." +lines = ["Follow the path and you will come to the front of the building.", "This is the Bank of %server%, where you can store all you", "most valued items. To open your bank box just right click on an", "open booth indicated and select 'use'."] +hint_tile = { x = 3122, y = 3124 } +hint_height = 125 + +# ENTER_FINANCIAL_ROOM +[.stage_53] +title = "This is your bank box." +lines = ["You can store stuff here for safekeeping. If you die, anything", "in your bank will be saved. To deposit something, right click it", "and select 'store'. Once you've had a good look, close the", "window and move through the door indicated."] +hint_tile = { x = 3124, y = 3124 } +hint_height = 125 + +# TALK_TO_FINANCIAL_ADVISOR +[.stage_54] +title = "Financial advice." +lines = ["", "The guide here will tell you all about making cash. Just click on", "him to hear what he's got to say.", ""] +hint_npc = "financial_advisor" + +# LEAVE_FINANCIAL_ADVISOR_ROOM +[.stage_55] +title = "" +lines = ["", "Continue through the next door.", "", ""] +hint_tile = { x = 3129, y = 3124 } +hint_height = 125 + +# TALK_TO_BROTHER_BRACE +[.stage_56] +title = "Prayer." +lines = ["Follow the path to the chapel and enter it.", "Once inside talk to the monk. He'll tell you all about the Prayer", "skill.", ""] +hint_npc = "brother_brace" + +# OPEN_PRAYER_TAB +[.stage_57] +title = "Your Prayer menu." +lines = ["", "Click on the flashing icon to open the Prayer menu.", "", ""] +flash = "PrayerList" +unlock = "prayer_list" + +# TALK_TO_BROTHER_BRACE_2 +[.stage_58] +title = "" +lines = ["Your Prayer Menu.", "", "Talk with Brother Brace and he'll tell you about prayers.", ""] +hint_npc = "brother_brace" + +# OPEN_FRIENDS_TAB +[.stage_59] +title = "" +lines = ["Friends list.", "You should now see another new icon. Click on the flashing", "smiling face to open your friends list.", ""] +flash = "FriendsList" +unlock = "friends_list" + +# OPEN_IGNORE_LIST +[.stage_60] +title = "This is your friends list." +lines = ["", "This will be explained by Brother Brace shortly, but first click", "on the red dot button on the bottom right of the friends list.", ""] +flash = "IgnoreList" +unlock = "ignore_list" + +# TALK_TO_BROTHER_BRACE_3 +[.stage_61] +title = "This is your ignore list." +lines = ["The two lists - friends and ignore - can be very helpful for", "keeping track of when your friends are online or for blocking", "messages from people you simply don't like. Speak with", "Brother Brace and he will tell you more."] +hint_npc = "brother_brace" + +# LEAVE_CHURCH_AREA +[.stage_62] +title = "" +lines = ["Your final instructor!", "You're almost finished on tutorial island. Pass through the", "door to find the path leading to your final instructor.", ""] +hint_tile = { x = 3122, y = 3102 } +hint_height = 125 + +# TALK_TO_MAGIC_INSTRUCTOR +[.stage_63] +title = "Your final instructor!" +lines = ["Just follow the path to the Wizard's house, where you will be", "shown how to cast spells. Just talk with the mage indicated to", "find out more.", ""] +hint_npc = "magic_instructor" + +# OPEN_MAGIC_TAB +[.stage_64] +title = "Open up your final menu." +lines = ["", "Open up the Magic menu by clicking on the flashing icon next", "to the Prayer button you just learned about.", ""] +flash = "MagicSpellbook" +unlock = "modern_spellbook" + +# TALK_TO_MAGIC_INSTRUCTOR_2 +[.stage_65] +title = "" +lines = ["This is your spells list.", "", "Ask the mage about it.", ""] +hint_npc = "magic_instructor" + +# CAST_WIND_STRIKE +[.stage_66] +title = "Cast Wind Strike at a chicken." +lines = ["Now you have runes you should see the Wind Strike icon at the", "top left corner of the Magic interface - third in from the", "left. Walk over to the caged chickens, click the Wind Strike icon", "and then select one of the chickens to cast it on. It may take", "several tries. If you need more runes ask Terrova."] +hint_npc = "chicken_tutorial_island" + +# TALK_TO_MAGIC_INSTRUCTOR_3 +[.stage_67] +title = "You have almost completed the tutorial!" +lines = ["", "All you need to do now is move on to the mainland. Just speak", "with Terrova and he'll teleport you to Lumbridge Castle.", ""] +hint_npc = "magic_instructor" diff --git a/data/area/misthalin/tutorial_island/tutorial_island.teles.toml b/data/area/misthalin/tutorial_island/tutorial_island.teles.toml index 71644fafb2..8070b1ad49 100644 --- a/data/area/misthalin/tutorial_island/tutorial_island.teles.toml +++ b/data/area/misthalin/tutorial_island/tutorial_island.teles.toml @@ -34,7 +34,12 @@ option = "Climb-up" tile = { x = 3116, y = 3126 } delta = { level = 1 } -[3031] +[ladder_tutorial_island_cave_down] +option = "Climb-down" +tile = { x = 3088, y = 3119 } +delta = { y = 6400 } + +[ladder_tutorial_island_rat_pit_down] option = "Climb-down" tile = { x = 3111, y = 3126 } delta = { y = 6400 } @@ -61,11 +66,16 @@ delta = { level = -1 } # 12436 tutorial_island -[3028] +[ladder_tutorial_island_cave_up] option = "Climb-up" tile = { x = 3088, y = 9519 } delta = { y = -6400 } +[ladder_tutorial_island_rat_pit_up] +option = "Climb-up" +tile = { x = 3111, y = 9526 } +delta = { y = -6400 } + # 12592 tutorial_island [29355] option = "Climb-up" diff --git a/data/area/misthalin/tutorial_island/tutorial_island.varps.toml b/data/area/misthalin/tutorial_island/tutorial_island.varps.toml new file mode 100644 index 0000000000..dd5b0c89de --- /dev/null +++ b/data/area/misthalin/tutorial_island/tutorial_island.varps.toml @@ -0,0 +1,5 @@ +# Drives the interface 371 progress bar. Client script 1437 lights segment n (component +# 371:4+n) only when this is greater than n, for n in 1..20 - so 1 is empty and 21 is full. +[tutorial_progress] +id = 406 +format = "int" diff --git a/data/area/misthalin/tutorial_island/tutorial_island.vars.toml b/data/area/misthalin/tutorial_island/tutorial_island.vars.toml new file mode 100644 index 0000000000..391cda33ab --- /dev/null +++ b/data/area/misthalin/tutorial_island/tutorial_island.vars.toml @@ -0,0 +1,15 @@ +# -1 means the player never entered Tutorial Island, so every existing save and every +# account created while `world.start.tutorial` is off is exempt. `AccountManager.create` +# seeds 0 for new accounts when the tutorial is enabled. +[tutorial_stage] +format = "int" +persist = true +default = -1 + +[tutorial_complete] +format = "boolean" +persist = true + +[tutorial_designed] +format = "boolean" +persist = true diff --git a/data/area/misthalin/wizards_tower/wizards_tower.teles.toml b/data/area/misthalin/wizards_tower/wizards_tower.teles.toml index 8d806bcab1..f0e6aa3c06 100644 --- a/data/area/misthalin/wizards_tower/wizards_tower.teles.toml +++ b/data/area/misthalin/wizards_tower/wizards_tower.teles.toml @@ -1,9 +1,4 @@ # 12337 wizards_tower -[3029] -option = "Climb-down" -tile = { x = 3088, y = 3119 } -delta = { y = 6400 } - [2147] option = "Climb-down" tile = { x = 3104, y = 3162 } @@ -24,12 +19,6 @@ option = "Climb-up" tile = { x = 3103, y = 3159, level = 1 } delta = { level = 1 } -# 12437 wizards_tower -[3030] -option = "Climb-up" -tile = { x = 3111, y = 9526 } -delta = { y = -6400 } - [portal_runecrafting_guild] option = "Enter" tile = { x = 1696, y = 5460, level = 2 } diff --git a/data/entity/obj/gates.objs.toml b/data/entity/obj/gates.objs.toml index 5edd8fc65c..d8acc98000 100644 --- a/data/entity/obj/gates.objs.toml +++ b/data/entity/obj/gates.objs.toml @@ -537,32 +537,41 @@ examine = "A wooden gate." id = 3016 examine = "A wooden gate." +# The Tutorial Island dungeon doorways are named "Gate" in the cache but swing as doors. [gate_74_opened] id = 26922 +gate = false [gate_74_closed] id = 3020 +gate = false examine = "A wrought iron gate." [gate_75_opened] id = 3269 +gate = false [gate_75_closed] id = 3021 +gate = false examine = "A wrought iron gate." [gate_76_opened] id = 26922 +gate = false [gate_76_closed] id = 3022 +gate = false examine = "A wrought iron gate." [gate_77_opened] id = 3269 +gate = false [gate_77_closed] id = 3023 +gate = false examine = "A wrought iron gate." [gate_79_opened] diff --git a/data/entity/player/dialogue/dialogue.ifaces.toml b/data/entity/player/dialogue/dialogue.ifaces.toml index 020d4ca93f..4a6d945a10 100644 --- a/data/entity/player/dialogue/dialogue.ifaces.toml +++ b/data/entity/player/dialogue/dialogue.ifaces.toml @@ -945,10 +945,6 @@ type = "dialogue_box" [chat3_models] id = 314 -[dialogue_tutorial_text] -id = 372 -type = "dialogue_box" - [dialogue_poh_hangman] id = 393 type = "dialogue_box" diff --git a/data/entity/player/modal/interface_types.toml b/data/entity/player/modal/interface_types.toml index 8c11732e71..5c7cb4242e 100644 --- a/data/entity/player/modal/interface_types.toml +++ b/data/entity/player/modal/interface_types.toml @@ -19,6 +19,12 @@ resizeIndex = 15 fixedIndex = 16 resizeIndex = 69 +# The 512x69 strip sitting directly above the chat box, bottom anchored in both frames. +# Pairs with private_chat, which occupies the matching slot alongside it. +[above_chat_box] +fixedIndex = 17 +resizeIndex = 74 + # Mini-map [energy_orb] fixedIndex = 185 @@ -119,6 +125,14 @@ parent = "chat_box" index = 13 permanent = false +# The same chat box slot as dialogue_box, under its own name. Queued actions only tick while +# `Player.dialogue` is null, and that reads the dialogue_box slots by name - so an interface +# that simply lives in the chat box must not claim one, or it freezes every delayed action. +[tutorial_box] +parent = "chat_box" +index = 13 +permanent = false + [dialogue_box_small] parent = "chat_box" index = 12 diff --git a/data/entity/player/modal/tab/tab.varbits.toml b/data/entity/player/modal/tab/tab.varbits.toml new file mode 100644 index 0000000000..dc6ac1911f --- /dev/null +++ b/data/entity/player/modal/tab/tab.varbits.toml @@ -0,0 +1,6 @@ +# Blinks a sidebar tab icon. Mirrors the `tab` varc list offset by one, so "None" clears it. +[tab_flash] +id = 3756 +format = "list" +default = "None" +values = [ "None", "CombatStyles", "TaskSystem", "Stats", "QuestJournals", "Inventory", "WornEquipment", "PrayerList", "MagicSpellbook", "Objectives", "FriendsList", "IgnoreList", "ClanChat", "Options", "Emotes", "MusicPlayer", "Notes", "RunOrb" ] diff --git a/data/skill/fishing/fishing_spots.tables.toml b/data/skill/fishing/fishing_spots.tables.toml index cdc311d024..2b56263927 100644 --- a/data/skill/fishing/fishing_spots.tables.toml +++ b/data/skill/fishing/fishing_spots.tables.toml @@ -182,7 +182,7 @@ bait = ["raw_sardine", "raw_herring"] net = ["raw_shrimps", "raw_karambwanji"] [.fishing_spot_tutorial_island] -net = [] +net = ["raw_shrimps"] [.fishing_spot_wilderness_bandit_camp] net = ["raw_shrimps", "raw_anchovies"] diff --git a/engine/src/main/kotlin/world/gregs/voidps/engine/data/AccountManager.kt b/engine/src/main/kotlin/world/gregs/voidps/engine/data/AccountManager.kt index 70135ccf73..4fb550251d 100644 --- a/engine/src/main/kotlin/world/gregs/voidps/engine/data/AccountManager.kt +++ b/engine/src/main/kotlin/world/gregs/voidps/engine/data/AccountManager.kt @@ -40,8 +40,17 @@ class AccountManager( private val homeTile: Tile get() = Tile(Settings["world.home.x", 0], Settings["world.home.y", 0], Settings["world.home.level", 0]) - fun create(name: String, passwordHash: String): Player = Player(tile = homeTile, accountName = name, passwordHash = passwordHash).apply { + private val tutorialTile: Tile + get() = Tile(Settings["world.start.tutorial.x", 0], Settings["world.start.tutorial.y", 0], Settings["world.start.tutorial.level", 0]) + + private val startTile: Tile + get() = if (Settings["world.start.tutorial", false]) tutorialTile else homeTile + + fun create(name: String, passwordHash: String): Player = Player(tile = startTile, accountName = name, passwordHash = passwordHash).apply { this["new_player"] = true + if (Settings["world.start.tutorial", false]) { + this["tutorial_stage"] = 0 + } } fun setup(player: Player, client: Client?, displayMode: Int, viewport: Boolean = true): Boolean { diff --git a/game/src/main/kotlin/content/area/misthalin/tutorial_island/BrotherBrace.kt b/game/src/main/kotlin/content/area/misthalin/tutorial_island/BrotherBrace.kt new file mode 100644 index 0000000000..f5cae5455b --- /dev/null +++ b/game/src/main/kotlin/content/area/misthalin/tutorial_island/BrotherBrace.kt @@ -0,0 +1,32 @@ +package content.area.misthalin.tutorial_island + +import content.entity.player.dialogue.Happy +import content.entity.player.dialogue.Neutral +import content.entity.player.dialogue.type.npc +import world.gregs.voidps.engine.Script + +class BrotherBrace : Script { + + init { + npcOperate("Talk-to", "brother_brace") { + when (tutorialStage) { + 56 -> { + npc("Hello there, and welcome to the church. I'm here to tell you about Prayer.") + npc("Open your prayer list and take a look at what's available to you.") + advanceTutorial(56) + } + 58 -> { + npc("Prayers drain your prayer points while they're active. Bury bones or pray at an altar to restore them.") + npc("Now let me show you the friends list. Open it and you'll see who's online.") + advanceTutorial(58) + } + 61 -> { + npc("Your ignore list works the same way, but for people you'd rather not hear from.") + npc("That's me done. Head out of the church and speak to the Magic Instructor.") + advanceTutorial(61) + } + else -> npc("May Saradomin watch over you.") + } + } + } +} diff --git a/game/src/main/kotlin/content/area/misthalin/tutorial_island/CombatInstructor.kt b/game/src/main/kotlin/content/area/misthalin/tutorial_island/CombatInstructor.kt new file mode 100644 index 0000000000..479365ba8d --- /dev/null +++ b/game/src/main/kotlin/content/area/misthalin/tutorial_island/CombatInstructor.kt @@ -0,0 +1,52 @@ +package content.area.misthalin.tutorial_island + +import content.entity.player.dialogue.Happy +import content.entity.player.dialogue.Neutral +import content.entity.player.dialogue.type.item +import content.entity.player.dialogue.type.npc +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.inv.add +import world.gregs.voidps.engine.inv.inventory + +class CombatInstructor : Script { + + init { + npcOperate("Talk-to", "combat_instructor") { + when (tutorialStage) { + 39 -> { + npc("Hello there! Ready to learn how to fight?") + npc("First you'll need to wield a weapon. Open your worn equipment tab and take a look at what you're carrying.") + advanceTutorial(39) + } + 43 -> { + npc("Good. A dagger is quick, but a sword hits harder and a shield keeps you alive. Take these and equip them both.") + inventory.add("bronze_sword") + inventory.add("wooden_shield") + item("bronze_sword", "The Combat Instructor gives you a bronze sword and a wooden shield.") + advanceTutorial(43) + } + 49 -> { + npc("Nicely done. Not every fight should be up close, though.") + npc("Take this shortbow and these arrows, then kill another rat from a distance.") + inventory.add("shortbow") + inventory.add("bronze_arrow", 50) + item("shortbow", "The Combat Instructor gives you a shortbow and some arrows.") + advanceTutorial(49) + } + else -> { + val replaced = when { + tutorialStage >= 49 -> resupply("shortbow") or resupply("bronze_arrow", 50) + tutorialStage >= 43 -> resupply("bronze_sword", "wooden_shield") + tutorialStage >= 41 -> resupply("bronze_dagger") + else -> false + } + if (replaced) { + npc("You'll not get far unarmed. Take these.") + return@npcOperate + } + npc("Keep practising. The rats in the cage won't hurt you.") + } + } + } + } +} diff --git a/game/src/main/kotlin/content/area/misthalin/tutorial_island/FinancialAdvisor.kt b/game/src/main/kotlin/content/area/misthalin/tutorial_island/FinancialAdvisor.kt new file mode 100644 index 0000000000..01196cb971 --- /dev/null +++ b/game/src/main/kotlin/content/area/misthalin/tutorial_island/FinancialAdvisor.kt @@ -0,0 +1,23 @@ +package content.area.misthalin.tutorial_island + +import content.entity.player.dialogue.Happy +import content.entity.player.dialogue.Neutral +import content.entity.player.dialogue.type.npc +import world.gregs.voidps.engine.Script + +class FinancialAdvisor : Script { + + init { + npcOperate("Talk-to", "financial_advisor") { + when (tutorialStage) { + 54 -> { + npc("Hello, and welcome to the bank of the future!") + npc("Your money pouch carries your coins for you, so you'll never need to make room for them in your backpack.") + npc("Head through the next door to meet Brother Brace.") + advanceTutorial(54) + } + else -> npc("Look after your coins and they'll look after you.") + } + } + } +} diff --git a/game/src/main/kotlin/content/area/misthalin/tutorial_island/MagicInstructor.kt b/game/src/main/kotlin/content/area/misthalin/tutorial_island/MagicInstructor.kt new file mode 100644 index 0000000000..6be0c4fee7 --- /dev/null +++ b/game/src/main/kotlin/content/area/misthalin/tutorial_island/MagicInstructor.kt @@ -0,0 +1,89 @@ +package content.area.misthalin.tutorial_island + +import content.entity.player.bank.bank +import content.entity.player.dialogue.Happy +import content.entity.player.dialogue.Neutral +import content.entity.player.dialogue.type.choice +import content.entity.player.dialogue.type.item +import content.entity.player.dialogue.type.npc +import content.entity.player.dialogue.type.statement +import content.entity.player.modal.gameFrameComponents +import content.entity.player.starterKit +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.client.clearMinimap +import world.gregs.voidps.engine.client.ui.open +import world.gregs.voidps.engine.data.Settings +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.entity.character.player.Teleport +import world.gregs.voidps.engine.inv.add +import world.gregs.voidps.engine.inv.clear +import world.gregs.voidps.engine.inv.equipment +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.engine.queue.queue +import world.gregs.voidps.type.Tile + +class MagicInstructor : Script { + + init { + npcOperate("Talk-to", "magic_instructor") { + when (tutorialStage) { + 63 -> { + npc("Greetings! I am here to teach you the ways of magic.") + npc("Open your spellbook to see the spells you can cast.") + advanceTutorial(63) + } + 65 -> { + npc("Every spell needs runes. Take these air and mind runes, and cast Wind Strike on one of those chickens.") + inventory.add("air_rune", 5) + inventory.add("mind_rune", 5) + item("air_rune", "The Magic Instructor gives you some air and mind runes.") + advanceTutorial(65) + } + 67 -> finish() + else -> { + // Every rune is spent on a cast, so running out would otherwise strand the stage. + if (tutorialStage == 66 && (resupply("air_rune", 5) or resupply("mind_rune", 5))) { + npc("Out of runes? Take some more.") + return@npcOperate + } + npc("Cast Wind Strike on a chicken to finish your training.") + } + } + } + } + + private suspend fun Player.finish() { + npc("Well done, you've completed the tutorial!") + npc("You're ready to enter the world proper. Would you like me to send you to Lumbridge now?") + choice { + option("Yes, please.") { + leave() + } + option("Not yet.") { + npc("That's fine. Talk to me again whenever you're ready.") + } + } + } + + private suspend fun Player.leave() { + leaveTutorial() + clearMinimap() + TutorialRestrictions.restore(this) + // Everyone leaves the island with the same kit, whatever they gathered on it. + inventory.clear() + equipment.clear() + bank.clear() + starterKit(this) + for (component in gameFrameComponents) { + open(component) + } + Teleport.teleport(this, homeTile(), "modern") + // Teleporting is a strong queue, so this has to wait its turn rather than run inline - + // an open message would otherwise block the teleport until the player dismissed it. + queue("welcome") { + statement("Welcome to Lumbridge! To get more help, simply click on the Lumbridge Guide or one of the Tutors - these can be found by looking for the question mark icon on your minimap.") + } + } + + private fun homeTile() = Tile(Settings["world.home.x", 0], Settings["world.home.y", 0], Settings["world.home.level", 0]) +} diff --git a/game/src/main/kotlin/content/area/misthalin/tutorial_island/MasterChef.kt b/game/src/main/kotlin/content/area/misthalin/tutorial_island/MasterChef.kt new file mode 100644 index 0000000000..f185af6689 --- /dev/null +++ b/game/src/main/kotlin/content/area/misthalin/tutorial_island/MasterChef.kt @@ -0,0 +1,45 @@ +package content.area.misthalin.tutorial_island + +import content.entity.player.dialogue.Happy +import content.entity.player.dialogue.Neutral +import content.entity.player.dialogue.type.item +import content.entity.player.dialogue.type.npc +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.inv.carriesItem + +class MasterChef : Script { + + init { + npcOperate("Talk-to", "master_chef") { + when (tutorialStage) { + 15 -> { + npc("Hello there! I'm the cook. There's more to cooking than throwing a fish on a fire, you know.") + npc("Take this pot of flour and bucket of water. Use them together to make dough, then cook the dough on my range.") + giveIngredients() + advanceTutorial(15) + } + 16, 17 -> replaceIngredients() + else -> npc("Use the flour with the water to make dough, then cook it on my range.") + } + } + } + + /** + * Burning the bread consumes the dough, so the chef has to hand out more - the stage text + * tells the player he will. + */ + private suspend fun Player.replaceIngredients() { + if (carriesItem("bread_dough")) { + npc("You've got your dough. Cook it on the range over there.") + return + } + npc("Lost your ingredients? Not to worry, here's some more.") + giveIngredients() + } + + private suspend fun Player.giveIngredients() { + resupply("pot_of_flour", "bucket_of_water") + item("pot_of_flour", "The cook gives you a pot of flour and a bucket of water.") + } +} diff --git a/game/src/main/kotlin/content/area/misthalin/tutorial_island/MiningInstructor.kt b/game/src/main/kotlin/content/area/misthalin/tutorial_island/MiningInstructor.kt new file mode 100644 index 0000000000..520b7c6d0c --- /dev/null +++ b/game/src/main/kotlin/content/area/misthalin/tutorial_island/MiningInstructor.kt @@ -0,0 +1,48 @@ +package content.area.misthalin.tutorial_island + +import content.entity.player.dialogue.Happy +import content.entity.player.dialogue.Neutral +import content.entity.player.dialogue.type.item +import content.entity.player.dialogue.type.npc +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.inv.add +import world.gregs.voidps.engine.inv.inventory + +class MiningInstructor : Script { + + init { + npcOperate("Talk-to", "mining_instructor") { + when (tutorialStage) { + 28 -> { + npc("Hello there. This is where we teach Mining and Smithing.") + npc("Prospect those rocks to find out what ore they hold. Start with the tin, then the copper.") + advanceTutorial(28) + } + 31 -> { + npc("Now you know what's in them, you'll want to get it out. Take this pickaxe and mine some tin and some copper.") + inventory.add("bronze_pickaxe") + item("bronze_pickaxe", "The Mining Instructor gives you a bronze pickaxe.") + advanceTutorial(31) + } + 35 -> { + npc("A fine bronze bar. Now take this hammer and use the bar on the anvil to smith a dagger.") + inventory.add("hammer") + item("hammer", "The Mining Instructor gives you a hammer.") + advanceTutorial(35) + } + else -> { + val replaced = when { + tutorialStage >= 36 -> resupply("bronze_pickaxe", "hammer") + tutorialStage >= 32 -> resupply("bronze_pickaxe") + else -> false + } + if (replaced) { + npc("Lost your tools? Here's a replacement.") + return@npcOperate + } + npc("Keep at it. Mining and Smithing are the backbone of any adventurer's kit.") + } + } + } + } +} diff --git a/game/src/main/kotlin/content/area/misthalin/tutorial_island/QuestGuide.kt b/game/src/main/kotlin/content/area/misthalin/tutorial_island/QuestGuide.kt new file mode 100644 index 0000000000..2f665d5247 --- /dev/null +++ b/game/src/main/kotlin/content/area/misthalin/tutorial_island/QuestGuide.kt @@ -0,0 +1,27 @@ +package content.area.misthalin.tutorial_island + +import content.entity.player.dialogue.Happy +import content.entity.player.dialogue.Neutral +import content.entity.player.dialogue.type.npc +import world.gregs.voidps.engine.Script + +class QuestGuide : Script { + + init { + npcOperate("Talk-to", "quest_guide") { + when (tutorialStage) { + 24 -> { + npc("Greetings, traveller. I am the Quest Guide, and I'm here to tell you about quests.") + npc("Click on the flashing question mark icon to open your quest journal. It lists every quest, and how far through each one you are.") + advanceTutorial(24) + } + 26 -> { + npc("Quests are set by people all over the world. Completing them earns you rewards and quest points.") + npc("That's everything from me. Climb down the ladder to meet the Mining Instructor.") + advanceTutorial(26) + } + else -> npc("Check your quest journal whenever you want to know what to do next.") + } + } + } +} diff --git a/game/src/main/kotlin/content/area/misthalin/tutorial_island/RunescapeGuide.kt b/game/src/main/kotlin/content/area/misthalin/tutorial_island/RunescapeGuide.kt new file mode 100644 index 0000000000..953c97d19a --- /dev/null +++ b/game/src/main/kotlin/content/area/misthalin/tutorial_island/RunescapeGuide.kt @@ -0,0 +1,34 @@ +package content.area.misthalin.tutorial_island + +import content.entity.player.dialogue.Happy +import content.entity.player.dialogue.Neutral +import content.entity.player.dialogue.type.npc +import content.entity.player.dialogue.type.player +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.data.Settings + +class RunescapeGuide : Script { + + init { + npcOperate("Talk-to", "runescape_guide") { + when (tutorialStage) { + 0 -> { + npc("Greetings! I see you are a new arrival to this land. My job is to teach you a few basic skills and functions.") + npc("First we shall go through some of the game's control panels, which you can find at the bottom right of your screen.") + npc("Click on the flashing spanner icon to open your game options.") + advanceTutorial(0) + } + 2 -> { + npc("The options panel lets you change the screen brightness, the volume of the music and sound effects, and whether other players may offer you help.") + npc("That's all I have to teach you. Go through that door and the Survival Expert will show you how to look after yourself.") + player("Thanks, I'll do that.") + advanceTutorial(2) + } + else -> { + npc("You've learnt all I have to teach. Follow the arrow to your next instructor.") + npc("Welcome to ${Settings["server.name"]}.") + } + } + } + } +} diff --git a/game/src/main/kotlin/content/area/misthalin/tutorial_island/SurvivalExpert.kt b/game/src/main/kotlin/content/area/misthalin/tutorial_island/SurvivalExpert.kt new file mode 100644 index 0000000000..52a93619f4 --- /dev/null +++ b/game/src/main/kotlin/content/area/misthalin/tutorial_island/SurvivalExpert.kt @@ -0,0 +1,42 @@ +package content.area.misthalin.tutorial_island + +import content.entity.player.dialogue.Happy +import content.entity.player.dialogue.Neutral +import content.entity.player.dialogue.type.item +import content.entity.player.dialogue.type.npc +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.inv.add +import world.gregs.voidps.engine.inv.inventory + +class SurvivalExpert : Script { + + init { + npcOperate("Talk-to", "survival_expert") { + when (tutorialStage) { + 4 -> { + npc("Hello there! I'm here to teach you how to survive out in the wilds.") + npc("Take this hatchet and tinderbox. Chop down one of these trees, then use the tinderbox on the logs to light a fire.") + inventory.add("bronze_hatchet") + inventory.add("tinderbox") + item("bronze_hatchet", "The Survival Expert gives you a bronze hatchet and a tinderbox.") + advanceTutorial(4) + } + 9 -> { + npc("Well done! Now let's try some fishing.") + npc("Take this small fishing net and use it on the fishing spots in the pond. Then cook what you catch on your fire.") + inventory.add("small_fishing_net") + item("small_fishing_net", "The Survival Expert gives you a small fishing net.") + advanceTutorial(9) + } + else -> { + val replaced = if (tutorialStage < 9) resupply("bronze_hatchet", "tinderbox") else resupply("small_fishing_net") + if (replaced) { + npc("Lost your equipment? Here, take another.") + return@npcOperate + } + npc("Keep going, you're doing well. Just follow the instructions on your screen.") + } + } + } + } +} diff --git a/game/src/main/kotlin/content/area/misthalin/tutorial_island/TutorialBanker.kt b/game/src/main/kotlin/content/area/misthalin/tutorial_island/TutorialBanker.kt new file mode 100644 index 0000000000..ac25b9015b --- /dev/null +++ b/game/src/main/kotlin/content/area/misthalin/tutorial_island/TutorialBanker.kt @@ -0,0 +1,17 @@ +package content.area.misthalin.tutorial_island + +import world.gregs.voidps.engine.Script + +/** + * Only advances the stage. The banker's own conversation and bank menu come from + * [content.entity.npc.Banker], which registers `npcApproach` - matching that here means both + * run, rather than an `npcOperate` handler pre-empting it whenever the player stands adjacent. + */ +class TutorialBanker : Script { + + init { + npcApproach("Talk-to", "banker_tutorial_island") { + advanceTutorial(52) + } + } +} diff --git a/game/src/main/kotlin/content/area/misthalin/tutorial_island/TutorialCommands.kt b/game/src/main/kotlin/content/area/misthalin/tutorial_island/TutorialCommands.kt new file mode 100644 index 0000000000..8614b13a85 --- /dev/null +++ b/game/src/main/kotlin/content/area/misthalin/tutorial_island/TutorialCommands.kt @@ -0,0 +1,23 @@ +package content.area.misthalin.tutorial_island + +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.client.command.adminCommand +import world.gregs.voidps.engine.client.command.intArg +import world.gregs.voidps.engine.client.message + +class TutorialCommands : Script { + + init { + adminCommand("tutorial", intArg("stage", optional = true), desc = "Jump to a Tutorial Island stage, or restart it") { args -> + val stage = args.getOrNull(0)?.toIntOrNull() ?: 0 + if (stage !in 0 until TutorialIsland.stages) { + message("Stage must be between 0 and ${TutorialIsland.stages - 1}.") + return@adminCommand + } + set("tutorial_stage", stage) + set("tutorial_designed", true) + renderTutorial() + message("Tutorial Island stage set to $stage.") + } + } +} diff --git a/game/src/main/kotlin/content/area/misthalin/tutorial_island/TutorialIsland.kt b/game/src/main/kotlin/content/area/misthalin/tutorial_island/TutorialIsland.kt new file mode 100644 index 0000000000..025ac38f54 --- /dev/null +++ b/game/src/main/kotlin/content/area/misthalin/tutorial_island/TutorialIsland.kt @@ -0,0 +1,238 @@ +package content.area.misthalin.tutorial_island + +import content.entity.player.modal.tabComponent +import world.gregs.voidps.engine.client.clearHint +import world.gregs.voidps.engine.client.hint +import world.gregs.voidps.engine.client.ui.close +import world.gregs.voidps.engine.client.ui.dialogue +import world.gregs.voidps.engine.client.ui.hasOpen +import world.gregs.voidps.engine.client.ui.hasTypeOpen +import world.gregs.voidps.engine.client.ui.open +import world.gregs.voidps.engine.data.Settings +import world.gregs.voidps.engine.data.config.RowDefinition +import world.gregs.voidps.engine.data.definition.Rows +import world.gregs.voidps.engine.data.definition.Tables +import world.gregs.voidps.engine.entity.character.npc.NPCs +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.inv.add +import world.gregs.voidps.engine.inv.carriesItem +import world.gregs.voidps.engine.inv.inventory +import kotlin.math.roundToInt + +private const val NO_FLASH = "None" + +/** Text lines interface 372 lays out below its title. */ +const val TUTORIAL_TEXT_LINES = 6 + +/** + * Segments in the interface 371 bar. Client script 1437 lights segment n only when varp 406 is + * greater than n, so the varp is the segment count plus one and 1 means empty. + */ +private const val PROGRESS_SEGMENTS = 20 + +/** + * Tutorial Island runs off a single persistent stage counter. Every row of the + * `tutorial_island` table is one stage, in order, and a stage only ever advances to the + * next index, so an interaction fired from anywhere else is a no-op. + */ +object TutorialIsland { + + const val TABLE: String = "tutorial_island" + + val stages: Int + get() = Tables.get(TABLE).rows().size + + fun row(stage: Int): RowDefinition? = Rows.getOrNull("$TABLE.stage_$stage") + + /** Whether [component] has been revealed on or before [stage]. */ + fun unlocked(stage: Int, component: String): Boolean { + for (index in 0..stage) { + if (row(index)?.stringOrNull("unlock") == component) { + return true + } + } + return false + } +} + +val Player.tutorialStage: Int + get() = get("tutorial_stage", -1) + +val Player.inTutorial: Boolean + get() = tutorialStage >= 0 + +/** + * Game frame components that aren't sidebar tabs, and so stay visible for the whole + * tutorial. + */ +private val alwaysOpen = setOf( + "chat_box", + "chat_background", + "filter_buttons", + "private_chat", + "health_orb", + "prayer_orb", + "summoning_orb", + "task_popup", + "area_status_icon", +) + +fun Player.tutorialUnlocked(component: String): Boolean { + if (!inTutorial) { + return true + } + if (alwaysOpen.contains(component)) { + return true + } + return TutorialIsland.unlocked(tutorialStage, component) +} + +/** + * Rewrites every piece of client state the current stage owns. Safe to call repeatedly; + * login restore and stage advancement both go through here. + */ +fun Player.renderTutorial() { + val row = TutorialIsland.row(tutorialStage) ?: return + renderTutorialProgress() + renderTutorialUnlock(row) + // After the unlock, so the client is told to flash something it already has. + set("tab_flash", row.stringOrNull("flash") ?: NO_FLASH) + renderTutorialHint(row) + renderTutorialText() +} + +/** + * Text may only be sent once the client has the interface loaded. `sendText` resolves the + * component against the server's own definitions, so it can't tell; the client applies the + * packet a tick later and dies on a missing component, taking the whole client with it. + * + * `open` returns false both when the interface can't be opened and when it already is, so the + * check has to be `hasOpen` or the bar would stop updating after the first stage. + */ +private fun Player.renderTutorialProgress() { + if (!ensureOpen("tutorial_overlay", "above_chat_box")) { + return + } + interfaces.sendVisibility("tutorial_overlay", "welcome", false) + val percent = tutorialStage.toDouble() / TutorialIsland.stages + set("tutorial_progress", (percent * PROGRESS_SEGMENTS).roundToInt() + 1) + interfaces.sendText("tutorial_overlay", "percent", "${(percent * 100).roundToInt()}% Done") +} + +/** + * Redraws the instruction box, unless a conversation is using the chat box. + * + * The box shares the chat box slot with dialogue on the client but has its own type, so opening + * a dialogue doesn't evict it server-side. Re-asserting it while one is up would paint the + * instructions straight over the NPC's words. Whatever closes that dialogue schedules another + * redraw, so the box comes back on its own. + * + * @return whether the box was drawn + */ +fun Player.renderTutorialText(): Boolean { + val row = TutorialIsland.row(tutorialStage) ?: return false + if (dialogue != null) { + return false + } + if (hasOpen("tutorial_text")) { + // A dialogue may have taken the chat box slot on the client. Re-assert the box without + // closing anything, because closing clears the player's weak queue. + interfaces.refresh("tutorial_text") + } else if (!open("tutorial_text")) { + return false + } + interfaces.sendText("tutorial_text", "title", serverName(row.string("title"))) + val lines = row.stringListOrNull("lines") ?: emptyList() + for (index in 1..TUTORIAL_TEXT_LINES) { + interfaces.sendText("tutorial_text", "line$index", serverName(lines.getOrElse(index - 1) { "" })) + } + return true +} + +/** + * Opens [id] only when it isn't already open and nothing else holds its slot. + * + * `Player.open` closes whatever currently occupies the slot first - including [id] itself on a + * repeat call - and `Interfaces.remove` clears the player's weak queue. Re-opening blindly would + * therefore cancel any delayed action in progress; smelting, for one, defers its transaction by + * four ticks, so the animation would play and no bar would ever appear. + */ +private fun Player.ensureOpen(id: String, type: String): Boolean { + if (hasOpen(id)) { + return true + } + if (hasTypeOpen(type)) { + return false + } + return open(id) +} + +/** Reveals the component this stage unlocks; earlier ones are already open. */ +private fun Player.renderTutorialUnlock(row: RowDefinition) { + val unlock = row.stringOrNull("unlock") ?: return + interfaces.sendVisibility(interfaces.gameFrame, tabComponent(unlock), true) + open(unlock) +} + +private fun Player.renderTutorialHint(row: RowDefinition) { + clearHint() + val npcId = row.stringOrNull("hint_npc") + if (npcId != null) { + val npc = NPCs.findOrNull(tile.regionLevel, npcId) ?: return + hint(npc) + return + } + val target = row.tileOrNull("hint_tile") ?: return + hint(target, radius = 2, height = row.intOrNull("hint_height") ?: 0) +} + +private fun serverName(text: String): String = text.replace("%server%", Settings["server.name"]) + +/** + * Advances only when the player is on [from], so a handler can be registered without + * re-checking the stage itself. + */ +fun Player.advanceTutorial(from: Int) { + if (tutorialStage != from) { + return + } + set("tutorial_stage", from + 1) + renderTutorial() +} + +/** + * Hands back any of [items] the player no longer has. Every instructor offers this, so a lost, + * dropped or ruined attempt can never strand a stage that's waiting on one of them. + */ +fun Player.resupply(vararg items: String): Boolean { + var missing = false + for (item in items) { + if (carriesItem(item)) { + continue + } + inventory.add(item) + missing = true + } + return missing +} + +fun Player.resupply(item: String, amount: Int): Boolean { + if (carriesItem(item)) { + return false + } + inventory.add(item, amount) + return true +} + +fun Player.leaveTutorial() { + set("tutorial_stage", -1) + set("tutorial_complete", true) + // Marks the introduction as done so `Introduction` never re-runs character creation + // or hands out a second starter kit. + this["creation"] = System.currentTimeMillis() + clear("tab_flash") + clear("tutorial_progress") + clearHint() + close("tutorial_text") + close("tutorial_overlay") +} diff --git a/game/src/main/kotlin/content/area/misthalin/tutorial_island/TutorialObjects.kt b/game/src/main/kotlin/content/area/misthalin/tutorial_island/TutorialObjects.kt new file mode 100644 index 0000000000..5a85c22727 --- /dev/null +++ b/game/src/main/kotlin/content/area/misthalin/tutorial_island/TutorialObjects.kt @@ -0,0 +1,85 @@ +package content.area.misthalin.tutorial_island + +import content.entity.obj.ObjectTeleports +import content.entity.obj.door.openDoor +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.client.message +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.entity.obj.GameObject + +/** + * Doors, gates and ladders that only open once the tutorial has reached the stage they + * lead on from. A handler registered for a concrete object id replaces the wildcard + * handlers in [content.entity.obj.door.Doors] and + * [content.entity.obj.ObjectTeleporting] for that object, so both are re-invoked here + * after the stage check passes. + */ +class TutorialObjects(val teleports: ObjectTeleports) : Script { + + init { + gatedDoor("door_87_closed", 3) + gatedDoor("gate_72_closed,gate_73_closed", 13) + gatedDoor("door_88_closed", 14) + gatedDoor("door_89_closed", 19) + gatedDoor("door_90_closed", 23) + gatedDoor("gate_74_closed,gate_75_closed", 38) + gatedDoor("gate_76_closed,gate_77_closed", 46) + gatedDoor("door_91_closed", 53) + gatedDoor("door_92_closed", 55) + gatedDoor("door_93_closed", 62) + + gatedLadder("ladder_tutorial_island_cave_down", "Climb-down", 27) + gatedLadder("ladder_tutorial_island_rat_pit_up", "Climb-up", 51) + + // Registered on the wildcard so the Mining script's own prospect handler still runs; + // a handler bound to the concrete id would replace it instead. + objectApproach("Prospect") { (target) -> + when (target.id) { + "tin_rocks_tutorial_island_1" -> advanceTutorial(29) + "copper_rocks_tutorial_island_1" -> advanceTutorial(30) + } + } + + itemOnObjectOperate("bronze_bar", "anvil") { + advanceTutorial(36) + } + + objectOperate("Use", "bank_booth_tutorial_island") { + advanceTutorial(52) + } + } + + /** + * Registers [stage] as the gate an object guards; the player passes through only once + * they've been told to. + */ + private fun gatedDoor(ids: String, stage: Int) { + objectOperate("Open", ids) { (target) -> + if (!allowed(stage, target)) { + return@objectOperate + } + // Advance first: opening a double door replaces both halves, and despawning the + // object being interacted with cancels this coroutine before it could resume. + advanceTutorial(stage) + openDoor(target) + } + } + + private fun gatedLadder(id: String, option: String, stage: Int) { + objectOperate(option, id) { (target) -> + if (!allowed(stage, target)) { + return@objectOperate + } + advanceTutorial(stage) + teleports.teleport(this, target, option) + } + } + + private fun Player.allowed(stage: Int, target: GameObject): Boolean { + if (!inTutorial || tutorialStage >= stage) { + return true + } + message("You should talk to your instructor before going through the ${target.def.name.lowercase()}.") + return false + } +} diff --git a/game/src/main/kotlin/content/area/misthalin/tutorial_island/TutorialProgress.kt b/game/src/main/kotlin/content/area/misthalin/tutorial_island/TutorialProgress.kt new file mode 100644 index 0000000000..45eee72eb5 --- /dev/null +++ b/game/src/main/kotlin/content/area/misthalin/tutorial_island/TutorialProgress.kt @@ -0,0 +1,63 @@ +package content.area.misthalin.tutorial_island + +import content.bot.isBot +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.client.ui.open +import world.gregs.voidps.engine.client.variable.stop +import world.gregs.voidps.engine.data.Settings +import world.gregs.voidps.engine.entity.World +import world.gregs.voidps.engine.entity.character.player.flagAppearance +import world.gregs.voidps.engine.entity.character.player.name +import world.gregs.voidps.engine.timer.Timer + +/** + * Restores the tutorial on login and chains character creation into the first stage. + */ +class TutorialProgress : Script { + + init { + playerSpawn { + if (!inTutorial) { + return@playerSpawn + } + if (Settings["world.start.creation", true] && !isBot && !get("tutorial_designed", false)) { + sendVariable("movement") + this["delay"] = -1 + World.queue("tutorial_creation_$name", 1) { + open("character_creation") + } + return@playerSpawn + } + renderTutorial() + } + + interfaceClosed("character_creation") { + if (!inTutorial) { + return@interfaceClosed + } + set("tutorial_designed", true) + flagAppearance() + stop("delay") + renderTutorial() + } + + // Dialogue shares the chat box slot with the instruction box. Wait a tick before + // putting the instructions back so a multi-step conversation isn't interrupted + // between two of its own boxes. + interfaceClosed("dialogue_*") { + if (!inTutorial) { + return@interfaceClosed + } + softTimers.start("tutorial_instructions") + } + + timerStart("tutorial_instructions") { 1 } + + timerTick("tutorial_instructions") { + if (inTutorial) { + renderTutorialText() + } + Timer.CANCEL + } + } +} diff --git a/game/src/main/kotlin/content/area/misthalin/tutorial_island/TutorialRestrictions.kt b/game/src/main/kotlin/content/area/misthalin/tutorial_island/TutorialRestrictions.kt new file mode 100644 index 0000000000..4dd09e286f --- /dev/null +++ b/game/src/main/kotlin/content/area/misthalin/tutorial_island/TutorialRestrictions.kt @@ -0,0 +1,60 @@ +package content.area.misthalin.tutorial_island + +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.entity.character.player.skill.Skill +import world.gregs.voidps.engine.entity.character.player.skill.level.Level + +/** + * Nothing on the island is meant to outlive it: skills stay at the levels the + * instructors hand out, and the player can't teleport away or be traded with. + */ +class TutorialRestrictions : Script { + + /** Trade and assist slots in [world.gregs.voidps.engine.entity.character.player.PlayerOptions]. */ + private val tradeSlot = 4 + private val assistSlot = 7 + + init { + playerSpawn { + if (!inTutorial) { + return@playerSpawn + } + options.remove("Trade with") + options.remove("Req Assist") + } + + // The instructors only ever hand out a level or two, so clamp rather than block + // experience; blocking would divert it to the assist handler instead. + maxLevelChanged { skill, _, to -> + if (!inTutorial) { + return@maxLevelChanged + } + val cap = cap(skill) + if (to <= cap) { + return@maxLevelChanged + } + experience.set(skill, Level.experience(skill, cap)) + } + + for (type in listOf("modern", "ancient", "lunar", "tablet", "scroll", "jewellery")) { + teleportTakeOff(type) { !inTutorial } + } + + playerDeath { + if (!inTutorial) { + return@playerDeath + } + it.dropItems = false + } + } + + private fun cap(skill: Skill): Int = if (skill == Skill.Constitution) 10 else 3 + + companion object { + fun restore(player: Player) { + player.options.set(4, "Trade with") + player.options.set(7, "Req Assist") + } + } +} diff --git a/game/src/main/kotlin/content/area/misthalin/tutorial_island/TutorialSkills.kt b/game/src/main/kotlin/content/area/misthalin/tutorial_island/TutorialSkills.kt new file mode 100644 index 0000000000..71dae36133 --- /dev/null +++ b/game/src/main/kotlin/content/area/misthalin/tutorial_island/TutorialSkills.kt @@ -0,0 +1,77 @@ +package content.area.misthalin.tutorial_island + +import content.entity.combat.killer +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.entity.character.player.skill.Skill +import world.gregs.voidps.engine.inv.equipment + +/** + * Stages completed by producing something rather than by clicking a thing. + */ +class TutorialSkills : Script { + + init { + produced("logs", 6) + produced("raw_shrimps", 10) + produced("burnt_shrimp", 11) + produced("bread_dough", 16) + produced("bread", 17) + produced("burnt_bread", 17) + produced("tin_ore", 32) + produced("copper_ore", 33) + produced("bronze_bar", 34) + produced("bronze_dagger", 37) + + // Cooking the shrimp properly also clears the "you burnt it" stage, so a lucky + // first attempt can't leave the tutorial stuck. + itemAdded("shrimps", "inventory") { + advanceTutorial(11) + advanceTutorial(12) + } + + itemAdded("bronze_dagger", "worn_equipment") { + advanceTutorial(42) + } + + itemAdded("wooden_shield", "worn_equipment") { + equippedSwordAndShield() + } + + itemAdded("bronze_sword", "worn_equipment") { + equippedSwordAndShield() + } + + experience { skill, _, _ -> + when (skill) { + Skill.Firemaking -> advanceTutorial(7) + Skill.Attack, Skill.Strength, Skill.Defence -> advanceTutorial(47) + Skill.Magic -> advanceTutorial(66) + else -> {} + } + } + + npcDeath("giant_rat_tutorial_island") { + val killer = killer + if (killer !is Player) { + return@npcDeath + } + killer.advanceTutorial(48) + killer.advanceTutorial(50) + } + } + + /** Advances [stage] the first time [item] lands in the player's inventory. */ + private fun produced(item: String, stage: Int) { + itemAdded(item, "inventory") { + advanceTutorial(stage) + } + } + + private fun Player.equippedSwordAndShield() { + if (!equipment.contains("bronze_sword") || !equipment.contains("wooden_shield")) { + return + } + advanceTutorial(44) + } +} diff --git a/game/src/main/kotlin/content/area/misthalin/tutorial_island/TutorialTabs.kt b/game/src/main/kotlin/content/area/misthalin/tutorial_island/TutorialTabs.kt new file mode 100644 index 0000000000..53cf151dcd --- /dev/null +++ b/game/src/main/kotlin/content/area/misthalin/tutorial_island/TutorialTabs.kt @@ -0,0 +1,43 @@ +package content.area.misthalin.tutorial_island + +import world.gregs.voidps.engine.Script + +/** + * Stages that ask the player to open part of the interface. Each handler mirrors the + * registration in [content.entity.player.modal.GameFrame] so both run. + */ +class TutorialTabs : Script { + + init { + tab("Options", "options", 1) + tab("Inventory", "inventory", 5) + tab("Stats", "stats", 8) + tab("Music Player", "music_player", 18) + tab("Emotes", "emotes", 20) + tab("Quest Journals", "quest_journals", 25) + tab("Worn Equipment", "worn_equipment", 40) + tab("Combat Styles", "combat_styles", 45) + tab("Prayer List", "prayer_list", 57) + tab("Friends List", "friends_list", 59) + tab("Ignore List", "ignore_list", 60) + tab("Magic Spellbook", "magic_spellbook", 64) + + interfaceOption(id = "emotes:*") { + advanceTutorial(21) + } + + interfaceOption("Turn Run mode on", "energy_orb:*") { + advanceTutorial(22) + } + + interfaceOpened("equipment_bonuses") { + advanceTutorial(41) + } + } + + private fun tab(option: String, component: String, stage: Int) { + interfaceOption(option, "toplevel*:$component") { + advanceTutorial(stage) + } + } +} diff --git a/game/src/main/kotlin/content/entity/player/Introduction.kt b/game/src/main/kotlin/content/entity/player/Introduction.kt index 915d27cd1c..24afc4f22a 100644 --- a/game/src/main/kotlin/content/entity/player/Introduction.kt +++ b/game/src/main/kotlin/content/entity/player/Introduction.kt @@ -1,5 +1,6 @@ package content.entity.player +import content.area.misthalin.tutorial_island.inTutorial import content.bot.isBot import content.entity.player.bank.bank import content.entity.player.dialogue.type.statement @@ -24,6 +25,9 @@ class Introduction : Script { if (player.contains("creation")) { return } + if (player.inTutorial) { + return // Tutorial Island owns character creation, the welcome and the starter kit + } if (Settings["world.start.creation", true] && !player.isBot) { player.sendVariable("movement") player["delay"] = -1 @@ -40,6 +44,9 @@ class Introduction : Script { playerSpawn(::welcome) interfaceClosed("character_creation") { + if (inTutorial) { + return@interfaceClosed + } flagAppearance() setup(this) } @@ -51,30 +58,33 @@ class Introduction : Script { } player.stop("delay") player["creation"] = System.currentTimeMillis() + starterKit(player) + } +} - if (!Settings["world.setup.gear", true]) { - return - } - player.bank.add("coins", 25) - player.inventory.apply { - add("bronze_hatchet") - add("tinderbox") - add("small_fishing_net") - add("shrimp") - add("bucket") - add("empty_pot") - add("bread") - add("bronze_pickaxe") - add("bronze_dagger") - add("bronze_sword") - add("wooden_shield") - add("shortbow") - add("bronze_arrow", 25) - add("air_rune", 25) - add("mind_rune", 15) - add("water_rune", 6) - add("earth_rune", 4) - add("body_rune", 2) - } +fun starterKit(player: Player) { + if (!Settings["world.start.gear", true]) { + return + } + player.bank.add("coins", 25) + player.inventory.apply { + add("bronze_hatchet") + add("tinderbox") + add("small_fishing_net") + add("shrimps") + add("bucket") + add("empty_pot") + add("bread") + add("bronze_pickaxe") + add("bronze_dagger") + add("bronze_sword") + add("wooden_shield") + add("shortbow") + add("bronze_arrow", 25) + add("air_rune", 25) + add("mind_rune", 15) + add("water_rune", 6) + add("earth_rune", 4) + add("body_rune", 2) } } diff --git a/game/src/main/kotlin/content/entity/player/effect/energy/Running.kt b/game/src/main/kotlin/content/entity/player/effect/energy/Running.kt index 8052d6fe84..d297d86bf5 100644 --- a/game/src/main/kotlin/content/entity/player/effect/energy/Running.kt +++ b/game/src/main/kotlin/content/entity/player/effect/energy/Running.kt @@ -14,6 +14,9 @@ class Running : Script { init { interfaceOpened("energy_orb") { sendRunEnergy(energyPercent()) + // The orb's option depends on varp 173, which isn't sent while it holds its default, + // so an orb revealed later would otherwise show a stale run mode. + sendVariable("movement") } interfaceOption(option = "Turn Run mode on", id = "energy_orb:*") { diff --git a/game/src/main/kotlin/content/entity/player/modal/GameFrame.kt b/game/src/main/kotlin/content/entity/player/modal/GameFrame.kt index 7575ab5558..474b348cc5 100644 --- a/game/src/main/kotlin/content/entity/player/modal/GameFrame.kt +++ b/game/src/main/kotlin/content/entity/player/modal/GameFrame.kt @@ -1,5 +1,6 @@ package content.entity.player.modal +import content.area.misthalin.tutorial_island.tutorialUnlocked import net.pearx.kasechange.toSnakeCase import net.pearx.kasechange.toTitleCase import world.gregs.voidps.engine.Script @@ -12,33 +13,7 @@ import world.gregs.voidps.network.client.instruction.ChangeDisplayMode class GameFrame : Script { - val list = listOf( - "chat_box", - "chat_background", - "filter_buttons", - "private_chat", - "health_orb", - "prayer_orb", - "energy_orb", - "summoning_orb", - "combat_styles", - "task_system", - "task_popup", - "stats", - "quest_journals", - "inventory", - "worn_equipment", - "prayer_list", - "modern_spellbook", - "friends_list", - "ignore_list", - "clan_chat", - "options", - "emotes", - "music_player", - "notes", - "area_status_icon", - ) + val list = gameFrameComponents init { Tab.entries.forEach { tab -> @@ -85,6 +60,10 @@ class GameFrame : Script { fun GameFrame.openGamframe(player: Player) { for (name in list) { + if (!player.tutorialUnlocked(if (name.endsWith("_spellbook")) "modern_spellbook" else name)) { + player.interfaces.sendVisibility(player.interfaces.gameFrame, tabComponent(name), false) + continue + } if (name.endsWith("_spellbook")) { val book = player["spellbook_config", 0] and 0x3 player.open( @@ -101,3 +80,38 @@ class GameFrame : Script { } } } + +/** + * The toplevel component holding a game frame entry's tab button. Every spellbook opens into + * the one `magic_spellbook` button. + */ +fun tabComponent(name: String): String = if (name.endsWith("_spellbook")) "magic_spellbook" else name + +/** Every game frame component opened on login, in order. */ +val gameFrameComponents = listOf( + "chat_box", + "chat_background", + "filter_buttons", + "private_chat", + "health_orb", + "prayer_orb", + "energy_orb", + "summoning_orb", + "combat_styles", + "task_system", + "task_popup", + "stats", + "quest_journals", + "inventory", + "worn_equipment", + "prayer_list", + "modern_spellbook", + "friends_list", + "ignore_list", + "clan_chat", + "options", + "emotes", + "music_player", + "notes", + "area_status_icon", +) diff --git a/game/src/main/resources/game.properties b/game/src/main/resources/game.properties index af6181f426..57e2cbaaf4 100644 --- a/game/src/main/resources/game.properties +++ b/game/src/main/resources/game.properties @@ -100,6 +100,14 @@ world.start.gear=true # Clan chat channel for new accounts to automatically join (the channel owner's display name; blank to disable) world.start.clanChat= +# Send new accounts through Tutorial Island +world.start.tutorial=true + +# The tile new accounts start on when Tutorial Island is enabled (the RuneScape Guide's room) +world.start.tutorial.x=3094 +world.start.tutorial.y=3107 +world.start.tutorial.level=0 + #------- NPC Rules ------- # Whether NPCs can be collided with diff --git a/game/src/test/kotlin/content/area/misthalin/tutorial_island/TutorialDepartureTest.kt b/game/src/test/kotlin/content/area/misthalin/tutorial_island/TutorialDepartureTest.kt new file mode 100644 index 0000000000..39b4b9ea9c --- /dev/null +++ b/game/src/test/kotlin/content/area/misthalin/tutorial_island/TutorialDepartureTest.kt @@ -0,0 +1,59 @@ +package content.area.misthalin.tutorial_island + +import WorldTest +import dialogueOption +import npcOption +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Test +import skipDialogues +import world.gregs.voidps.engine.client.ui.dialogue +import world.gregs.voidps.engine.data.Settings +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.type.Tile + +class TutorialDepartureTest : WorldTest() { + + private val home get() = Tile(Settings["world.home.x", 0], Settings["world.home.y", 0], Settings["world.home.level", 0]) + + @Test + fun `Welcome message waits until the player has landed in Lumbridge`() { + val player = createPlayer(Tile(3142, 3088)) { it.startTutorial(67) } + val instructor = createNPC("magic_instructor", Tile(3141, 3088)) + + player.npcOption(instructor, "Talk-to") + tick(3) + player.skipDialogues() + player.dialogueOption(1) // "Yes, please." + player.skipDialogues() // the player's own line before the instructor acts + + var welcomeTile: Tile? = null + for (tick in 0 until 20) { + tick() + // `statement` opens dialogue_message*; the instructor's own chat is dialogue_npc_chat*. + if (welcomeTile == null && player.dialogue?.startsWith("dialogue_message") == true) { + welcomeTile = player.tile + } + } + + assertNotNull(welcomeTile, "the welcome message never appeared") + assertEquals(home, welcomeTile, "the welcome message appeared before the player landed") + assertEquals(home, player.tile) + } + + @Test + fun `Leaving grants the starter kit exactly once`() { + val player = createPlayer(Tile(3142, 3088)) { it.startTutorial(67) } + + player.leaveTutorial() + content.entity.player.starterKit(player) + + assertEquals(1, player.inventory.count("bronze_hatchet")) + } + + private fun Player.startTutorial(stage: Int) { + set("tutorial_stage", stage) + set("tutorial_designed", true) + } +} diff --git a/game/src/test/kotlin/content/area/misthalin/tutorial_island/TutorialDialogueTest.kt b/game/src/test/kotlin/content/area/misthalin/tutorial_island/TutorialDialogueTest.kt new file mode 100644 index 0000000000..bda42ebe43 --- /dev/null +++ b/game/src/test/kotlin/content/area/misthalin/tutorial_island/TutorialDialogueTest.kt @@ -0,0 +1,62 @@ +package content.area.misthalin.tutorial_island + +import WorldTest +import npcOption +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import skipDialogues +import world.gregs.voidps.engine.client.ui.dialogue +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.type.Tile + +/** + * The instruction box shares the chat box slot with dialogue on the client, so it must stand + * aside while an instructor is talking rather than painting over their words. + */ +class TutorialDialogueTest : WorldTest() { + + @Test + fun `Instruction box does not paint over an instructor`() { + val player = createPlayer(Tile(3128, 3124)) { it.startTutorial(54) } + val advisor = createNPC("financial_advisor", Tile(3127, 3124)) + + player.npcOption(advisor, "Talk-to") + tick(3) + + assertNotNull(player.dialogue) + assertFalse(player.renderTutorialText(), "instruction box drew over the conversation") + } + + @Test + fun `Instruction box returns once the conversation ends`() { + val player = createPlayer(Tile(3128, 3124)) { it.startTutorial(54) } + val advisor = createNPC("financial_advisor", Tile(3127, 3124)) + + player.npcOption(advisor, "Talk-to") + tick(3) + player.skipDialogues() + tick(3) + + assertTrue(player.renderTutorialText()) + } + + @Test + fun `Talking to the banker advances the stage and keeps their own conversation`() { + val player = createPlayer(Tile(3121, 3124)) { it.startTutorial(52) } + val banker = createNPC("banker_tutorial_island", Tile(3120, 3125)) + + player.npcOption(banker, "Talk-to") + tick(3) + + assertEquals(53, player.tutorialStage) + assertNotNull(player.dialogue, "the banker's own conversation should still open") + } + + private fun Player.startTutorial(stage: Int) { + set("tutorial_stage", stage) + set("tutorial_designed", true) + } +} diff --git a/game/src/test/kotlin/content/area/misthalin/tutorial_island/TutorialGatesTest.kt b/game/src/test/kotlin/content/area/misthalin/tutorial_island/TutorialGatesTest.kt new file mode 100644 index 0000000000..bd714949ee --- /dev/null +++ b/game/src/test/kotlin/content/area/misthalin/tutorial_island/TutorialGatesTest.kt @@ -0,0 +1,50 @@ +package content.area.misthalin.tutorial_island + +import WorldTest +import objectOption +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotEquals +import org.junit.jupiter.api.Test +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.entity.obj.ObjectShape +import world.gregs.voidps.type.Tile + +/** + * Both halves of each dungeon doorway exist on the map, so they have to be tested as the pair + * they are - a single half takes a different code path in [content.entity.obj.door.DoubleDoor]. + */ +class TutorialGatesTest : WorldTest() { + + @Test + fun `Mining area doorway opens and lets the player through`() { + val start = Tile(3093, 9503) + val player = createPlayer(start) { it.startTutorial(38) } + val door = createObject("gate_74_closed", Tile(3094, 9503), ObjectShape.WALL_STRAIGHT, rotation = 2) + createObject("gate_75_closed", Tile(3094, 9502), ObjectShape.WALL_STRAIGHT, rotation = 2) + + player.objectOption(door, "Open") + tick(6) + + assertEquals(39, player.tutorialStage) + assertNotEquals(start, player.tile) + } + + @Test + fun `Rat pit doorway opens and lets the player through`() { + val start = Tile(3109, 9519) + val player = createPlayer(start) { it.startTutorial(46) } + val door = createObject("gate_76_closed", Tile(3110, 9519), ObjectShape.WALL_STRAIGHT, rotation = 2) + createObject("gate_77_closed", Tile(3110, 9518), ObjectShape.WALL_STRAIGHT, rotation = 2) + + player.objectOption(door, "Open") + tick(6) + + assertEquals(47, player.tutorialStage) + assertNotEquals(start, player.tile) + } + + private fun Player.startTutorial(stage: Int) { + set("tutorial_stage", stage) + set("tutorial_designed", true) + } +} diff --git a/game/src/test/kotlin/content/area/misthalin/tutorial_island/TutorialIslandTest.kt b/game/src/test/kotlin/content/area/misthalin/tutorial_island/TutorialIslandTest.kt new file mode 100644 index 0000000000..0ef32f9f05 --- /dev/null +++ b/game/src/test/kotlin/content/area/misthalin/tutorial_island/TutorialIslandTest.kt @@ -0,0 +1,197 @@ +package content.area.misthalin.tutorial_island + +import WorldTest +import content.entity.player.modal.Tab +import content.entity.player.modal.gameFrameComponents +import npcOption +import objectOption +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.DynamicTest +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestFactory +import skipDialogues +import world.gregs.voidps.engine.client.ui.hasOpen +import world.gregs.voidps.engine.data.definition.NPCDefinitions +import world.gregs.voidps.engine.data.definition.Tables +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.inv.add +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.type.Tile + +class TutorialIslandTest : WorldTest() { + + private val guideRoom = Tile(3096, 3107) + + /** Skips the character design step so tests start on the island itself. */ + private fun Player.startTutorial(stage: Int) { + set("tutorial_stage", stage) + set("tutorial_designed", true) + } + + @Test + fun `Players outside the tutorial are unaffected`() { + val player = createPlayer(guideRoom) + + assertFalse(player.inTutorial) + assertEquals(-1, player.tutorialStage) + for (component in gameFrameComponents) { + assertTrue(player.tutorialUnlocked(component), "$component should be unlocked") + } + } + + @Test + fun `Stage only advances from the stage before it`() { + val player = createPlayer(guideRoom) { it.startTutorial(5) } + + player.advanceTutorial(3) + assertEquals(5, player.tutorialStage) + + player.advanceTutorial(5) + assertEquals(6, player.tutorialStage) + } + + @Test + fun `Tabs unlock as the stage advances`() { + val player = createPlayer(guideRoom) { it.startTutorial(0) } + + assertFalse(player.tutorialUnlocked("options")) + assertFalse(player.tutorialUnlocked("inventory")) + assertTrue(player.tutorialUnlocked("chat_box")) + + player["tutorial_stage"] = 5 + assertTrue(player.tutorialUnlocked("options")) + assertTrue(player.tutorialUnlocked("inventory")) + assertFalse(player.tutorialUnlocked("prayer_list")) + } + + @Test + fun `Talking to the guide advances the first stage`() { + val player = createPlayer(Tile(3095, 3107)) { it.startTutorial(0) } + val guide = createNPC("runescape_guide", Tile(3094, 3107)) + + player.npcOption(guide, "Talk-to") + tick(3) + player.skipDialogues() + + assertEquals(1, player.tutorialStage) + } + + @Test + fun `Guide room door stays shut until the guide says so`() { + val player = createPlayer(Tile(3097, 3107)) { it.startTutorial(0) } + val door = createObject("door_87_closed", Tile(3098, 3107)) + + player.objectOption(door, "Open") + tick() + assertEquals(0, player.tutorialStage) + + player["tutorial_stage"] = 3 + player.objectOption(door, "Open") + tick() + assertEquals(4, player.tutorialStage) + } + + @Test + fun `Survival expert hands over the woodcutting kit once`() { + val player = createPlayer(Tile(3104, 3095)) { it.startTutorial(4) } + val expert = createNPC("survival_expert", Tile(3103, 3095)) + + player.npcOption(expert, "Talk-to") + tick(3) + player.skipDialogues() + + assertEquals(5, player.tutorialStage) + assertTrue(player.inventory.contains("bronze_hatchet")) + assertTrue(player.inventory.contains("tinderbox")) + } + + @Test + fun `Leaving the island grants the starter kit and clears tutorial state`() { + val player = createPlayer(Tile(3141, 3088)) { it.startTutorial(67) } + + player.leaveTutorial() + + assertFalse(player.inTutorial) + assertTrue(player["tutorial_complete", false]) + // `Introduction` keys off `creation`, so leaving must stamp it or the starter kit + // would be handed out a second time on the next login. + assertTrue(player["creation", 0L] > 0L) + for (component in gameFrameComponents) { + assertTrue(player.tutorialUnlocked(component), "$component should be unlocked") + } + } + + @Test + fun `Progress keeps updating after the overlay is already open`() { + val player = createPlayer(guideRoom) { it.startTutorial(0) } + + player.renderTutorial() + assertTrue(player.hasOpen("tutorial_overlay")) + assertTrue(player.hasOpen("tutorial_text")) + + // `open` returns false for an already-open interface, so a render that guards on it + // would silently stop updating here. + player["tutorial_stage"] = 40 + player.renderTutorial() + + // round(40 / 68 * 20) = 12 segments, and the varp is one more than the segment count. + assertEquals(13, player["tutorial_progress", -1]) + } + + @Test + fun `Burning the bread still finishes the cooking stage`() { + val player = createPlayer(Tile(3076, 3081)) { it.startTutorial(17) } + + player.inventory.add("burnt_bread") + tick() + + assertEquals(18, player.tutorialStage) + } + + @Test + fun `The chef replaces ingredients lost to a burnt loaf`() { + val player = createPlayer(Tile(3075, 3086)) { it.startTutorial(17) } + val chef = createNPC("master_chef", Tile(3075, 3085)) + + player.npcOption(chef, "Talk-to") + tick(3) + player.skipDialogues() + + assertTrue(player.inventory.contains("pot_of_flour")) + assertTrue(player.inventory.contains("bucket_of_water")) + assertEquals(17, player.tutorialStage) + } + + @TestFactory + fun `Every stage row is renderable`(): List = (0 until TutorialIsland.stages).map { stage -> + DynamicTest.dynamicTest("stage $stage") { + val row = TutorialIsland.row(stage) + assertTrue(row != null, "missing row for stage $stage") + val lines = row!!.stringListOrNull("lines") ?: emptyList() + assertTrue(lines.size <= TUTORIAL_TEXT_LINES, "stage $stage has ${lines.size} lines, the box fits $TUTORIAL_TEXT_LINES") + val flash = row.stringOrNull("flash") + if (flash != null) { + assertTrue(flash == "RunOrb" || Tab.entries.any { it.name == flash }, "unknown flash target $flash") + } + val unlock = row.stringOrNull("unlock") + if (unlock != null) { + assertTrue(gameFrameComponents.contains(unlock), "unknown unlock target $unlock") + } + val npc = row.stringOrNull("hint_npc") + if (npc != null) { + assertTrue(NPCDefinitions.ids.containsKey(npc), "unknown hint npc $npc") + } + } + } + + @Test + fun `Stage table covers every stage index`() { + val rows = Tables.get(TutorialIsland.TABLE).rows() + assertEquals(rows.size, TutorialIsland.stages) + for (stage in 0 until TutorialIsland.stages) { + assertTrue(TutorialIsland.row(stage) != null, "missing stage $stage") + } + } +} diff --git a/game/src/test/kotlin/content/area/misthalin/tutorial_island/TutorialLaddersTest.kt b/game/src/test/kotlin/content/area/misthalin/tutorial_island/TutorialLaddersTest.kt new file mode 100644 index 0000000000..ba57e5d177 --- /dev/null +++ b/game/src/test/kotlin/content/area/misthalin/tutorial_island/TutorialLaddersTest.kt @@ -0,0 +1,56 @@ +package content.area.misthalin.tutorial_island + +import WorldTest +import objectOption +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import world.gregs.voidps.type.Tile + +/** + * The ladders are the only way between the island's surface and its cave, so a missing or + * duplicated teleport definition strands the player mid-tutorial. + */ +class TutorialLaddersTest : WorldTest() { + + @Test + fun `Quest guide ladder climbs down to the mining area`() { + val player = createPlayer(Tile(3089, 3119)) { it.startTutorial(27) } + val ladder = createObject("ladder_tutorial_island_cave_down", Tile(3088, 3119)) + + player.objectOption(ladder, "Climb-down") + tick(6) + + assertEquals(28, player.tutorialStage) + assertEquals(Tile(3089, 9519), player.tile) + } + + @Test + fun `Rat pit ladder climbs back up to the surface`() { + val player = createPlayer(Tile(3112, 9526)) { it.startTutorial(51) } + val ladder = createObject("ladder_tutorial_island_rat_pit_up", Tile(3111, 9526)) + + player.objectOption(ladder, "Climb-up") + tick(6) + + assertEquals(52, player.tutorialStage) + assertEquals(Tile(3112, 3126), player.tile) + } + + @Test + fun `Ladder stays put before its stage`() { + val start = Tile(3089, 3119) + val player = createPlayer(start) { it.startTutorial(20) } + val ladder = createObject("ladder_tutorial_island_cave_down", Tile(3088, 3119)) + + player.objectOption(ladder, "Climb-down") + tick(6) + + assertEquals(20, player.tutorialStage) + assertEquals(start, player.tile) + } + + private fun world.gregs.voidps.engine.entity.character.player.Player.startTutorial(stage: Int) { + set("tutorial_stage", stage) + set("tutorial_designed", true) + } +} diff --git a/game/src/test/kotlin/content/area/misthalin/tutorial_island/TutorialRunOrbTest.kt b/game/src/test/kotlin/content/area/misthalin/tutorial_island/TutorialRunOrbTest.kt new file mode 100644 index 0000000000..203612256d --- /dev/null +++ b/game/src/test/kotlin/content/area/misthalin/tutorial_island/TutorialRunOrbTest.kt @@ -0,0 +1,35 @@ +package content.area.misthalin.tutorial_island + +import WorldTest +import interfaceOption +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import world.gregs.voidps.engine.client.ui.hasOpen +import world.gregs.voidps.engine.entity.character.move.running +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.type.Tile + +class TutorialRunOrbTest : WorldTest() { + + @Test + fun `First click on the newly revealed run orb turns running on`() { + val player = createPlayer(Tile(3088, 3126)) { it.startTutorial(21) } + player.advanceTutorial(21) // entering stage 22 reveals the orb + tick() + + assertTrue(player.hasOpen("energy_orb"), "the orb was never opened") + assertEquals(false, player.running) + + player.interfaceOption("energy_orb", "run_background", "Turn Run mode on") + tick() + + assertEquals(23, player.tutorialStage) + assertEquals(true, player.running, "the first click was swallowed") + } + + private fun Player.startTutorial(stage: Int) { + set("tutorial_stage", stage) + set("tutorial_designed", true) + } +} diff --git a/game/src/test/kotlin/content/area/misthalin/tutorial_island/TutorialSmeltingTest.kt b/game/src/test/kotlin/content/area/misthalin/tutorial_island/TutorialSmeltingTest.kt new file mode 100644 index 0000000000..f6bb02a971 --- /dev/null +++ b/game/src/test/kotlin/content/area/misthalin/tutorial_island/TutorialSmeltingTest.kt @@ -0,0 +1,55 @@ +package content.area.misthalin.tutorial_island + +import WorldTest +import itemOnObject +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import skillCreation +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.inv.add +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.type.Tile + +/** + * Smelting defers its transaction in a weak queue, and closing an interface clears weak + * queues - so the instruction box must never take the chat box off a live dialogue. + */ +class TutorialSmeltingTest : WorldTest() { + + @Test + fun `Smelting on the island produces a bronze bar`() { + val player = createPlayer(Tile(3079, 9496)) { it.startTutorial(34) } + player.inventory.add("tin_ore") + player.inventory.add("copper_ore") + val furnace = createObject("furnace_tutorial_island", Tile(3078, 9495)) + + player.itemOnObject(furnace, 0) + tick(2) + player.skillCreation("Bronze bar", 1) + tick(10) + + assertTrue(player.inventory.contains("bronze_bar"), "no bar: ${player.inventory.items.toList().filter { it.isNotEmpty() }}") + assertEquals(35, player.tutorialStage) + } + + @Test + fun `Smelting works for a player outside the tutorial`() { + val player = createPlayer(Tile(3079, 9496)) + player.inventory.add("tin_ore") + player.inventory.add("copper_ore") + val furnace = createObject("furnace_tutorial_island", Tile(3078, 9495)) + + player.itemOnObject(furnace, 0) + tick(2) + player.skillCreation("Bronze bar", 1) + tick(10) + + assertTrue(player.inventory.contains("bronze_bar"), "control: no bar either") + } + + private fun Player.startTutorial(stage: Int) { + set("tutorial_stage", stage) + set("tutorial_designed", true) + } +} From fd29c9284ebec0171fcfd7e7876fa595a83e1f66 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Wed, 2 Sep 2026 15:22:56 -0700 Subject: [PATCH 3/3] Send players to 3236, 3219 when leaving Tutorial Island The exit followed world.home, which is the respawn point rather than where the tutorial drops you. Split it into its own world.start.tutorial.exit setting so the two can differ. --- .../area/misthalin/tutorial_island/MagicInstructor.kt | 4 ++-- game/src/main/resources/game.properties | 6 ++++++ .../misthalin/tutorial_island/TutorialDepartureTest.kt | 7 ++++--- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/game/src/main/kotlin/content/area/misthalin/tutorial_island/MagicInstructor.kt b/game/src/main/kotlin/content/area/misthalin/tutorial_island/MagicInstructor.kt index 6be0c4fee7..a0b6cc3bfe 100644 --- a/game/src/main/kotlin/content/area/misthalin/tutorial_island/MagicInstructor.kt +++ b/game/src/main/kotlin/content/area/misthalin/tutorial_island/MagicInstructor.kt @@ -77,7 +77,7 @@ class MagicInstructor : Script { for (component in gameFrameComponents) { open(component) } - Teleport.teleport(this, homeTile(), "modern") + Teleport.teleport(this, exitTile(), "modern") // Teleporting is a strong queue, so this has to wait its turn rather than run inline - // an open message would otherwise block the teleport until the player dismissed it. queue("welcome") { @@ -85,5 +85,5 @@ class MagicInstructor : Script { } } - private fun homeTile() = Tile(Settings["world.home.x", 0], Settings["world.home.y", 0], Settings["world.home.level", 0]) + private fun exitTile() = Tile(Settings["world.start.tutorial.exit.x", 0], Settings["world.start.tutorial.exit.y", 0], Settings["world.start.tutorial.exit.level", 0]) } diff --git a/game/src/main/resources/game.properties b/game/src/main/resources/game.properties index 57e2cbaaf4..352e965443 100644 --- a/game/src/main/resources/game.properties +++ b/game/src/main/resources/game.properties @@ -108,6 +108,12 @@ world.start.tutorial.x=3094 world.start.tutorial.y=3107 world.start.tutorial.level=0 +# The tile the Magic Instructor teleports players to when they finish. Kept separate from +# world.home, which is the respawn point. +world.start.tutorial.exit.x=3236 +world.start.tutorial.exit.y=3219 +world.start.tutorial.exit.level=0 + #------- NPC Rules ------- # Whether NPCs can be collided with diff --git a/game/src/test/kotlin/content/area/misthalin/tutorial_island/TutorialDepartureTest.kt b/game/src/test/kotlin/content/area/misthalin/tutorial_island/TutorialDepartureTest.kt index 39b4b9ea9c..1d077b3828 100644 --- a/game/src/test/kotlin/content/area/misthalin/tutorial_island/TutorialDepartureTest.kt +++ b/game/src/test/kotlin/content/area/misthalin/tutorial_island/TutorialDepartureTest.kt @@ -15,10 +15,11 @@ import world.gregs.voidps.type.Tile class TutorialDepartureTest : WorldTest() { - private val home get() = Tile(Settings["world.home.x", 0], Settings["world.home.y", 0], Settings["world.home.level", 0]) + private val exit get() = Tile(Settings["world.start.tutorial.exit.x", 0], Settings["world.start.tutorial.exit.y", 0], Settings["world.start.tutorial.exit.level", 0]) @Test fun `Welcome message waits until the player has landed in Lumbridge`() { + assertEquals(Tile(3236, 3219), exit, "unexpected tutorial exit tile") val player = createPlayer(Tile(3142, 3088)) { it.startTutorial(67) } val instructor = createNPC("magic_instructor", Tile(3141, 3088)) @@ -38,8 +39,8 @@ class TutorialDepartureTest : WorldTest() { } assertNotNull(welcomeTile, "the welcome message never appeared") - assertEquals(home, welcomeTile, "the welcome message appeared before the player landed") - assertEquals(home, player.tile) + assertEquals(exit, welcomeTile, "the welcome message appeared before the player landed") + assertEquals(exit, player.tile) } @Test