From 5d5e6cd7e7682b6d4405b5749736da63c53d3717 Mon Sep 17 00:00:00 2001 From: Alexander Date: Sun, 30 Aug 2026 17:48:12 -0400 Subject: [PATCH 01/10] Add Rum Deal quest Braindeath Island content and the full quest script: Captain Braindeath, Captain Donnie, Luke, Davey, the brewers, zombie swabs and protesters, fever spiders and the Evil Spirit, plus Pirate Pete starting the quest in Port Phasmatys. Supporting changes: - Blindweed farming patch - varbit, produce entry, patch registration and its objs config. Patches with no Rake option can now be raked by using a rake on them, a crop planted into an already clear patch starts the growth timer, and fully overgrown weeds stop at stage 3 instead of wrapping back to clear. - Leash movement mode - follows a target only while both stay within range of the npc's spawn tile and loses interest otherwise. Follow teleports an npc that falls behind or changes level, which let protesters trail a player across the world. - zombie_swab hunt mode so swab aggression can be judged per player. - questprep/questreset admin commands and the req_item_ids quest param they read. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01K22iXYZjnQnTr3ZUtbuGBG --- .../gregs/voidps/cache/definition/Params.kt | 5 + .../braindeath_island.anims.toml | 26 +- .../braindeath_island.combat.toml | 12 + .../braindeath_island.npcs.toml | 22 +- .../braindeath_island.objs.toml | 39 + .../braindeath_island.sounds.toml | 29 + data/entity/npc/hunt_modes.toml | 9 + data/entity/player/player.sounds.toml | 3 + data/quest/members/rum_deal/rum_deal.gfx.toml | 3 + .../members/rum_deal/rum_deal.ifaces.toml | 7 + .../members/rum_deal/rum_deal.items.toml | 5 + .../members/rum_deal/rum_deal.jingles.toml | 5 + .../quest/members/rum_deal/rum_deal.npcs.toml | 2 +- .../members/rum_deal/rum_deal.varbits.toml | 47 + .../members/rum_deal/rum_deal.varps.toml | 2 +- data/quest/quests.toml | 3 + data/skill/farming/farming.varbits.toml | 7 + data/skill/farming/farming_produce.toml | 3 + .../farming/patch/blindweed_patch.objs.toml | 31 + .../engine/entity/character/mode/Leash.kt | 104 ++ .../morytania/braindeath_island/Brewer.kt | 318 +++++-- .../braindeath_island/CaptainBraindeath.kt | 896 ++++++++++++++++++ .../braindeath_island/CaptainDonnie.kt | 162 +++- .../area/morytania/braindeath_island/Davey.kt | 223 +++++ .../braindeath_island/FeverSpider.kt | 5 +- .../area/morytania/braindeath_island/Luke.kt | 144 +++ .../braindeath_island/ZombieProtester.kt | 60 +- .../morytania/braindeath_island/ZombieSwab.kt | 144 +++ .../morytania/port_phasmatys/PiratePete.kt | 261 ++++- game/src/main/kotlin/content/quest/Quest.kt | 1 + .../kotlin/content/quest/QuestCommands.kt | 113 +++ .../content/quest/member/rum_deal/RumDeal.kt | 827 ++++++++++++++++ .../kotlin/content/skill/farming/Farming.kt | 25 +- .../skill/farming/FarmingPatchPlant.kt | 11 +- .../skill/farming/FarmingPatchTreat.kt | 29 +- .../content/skill/farming/FarmingPatches.kt | 1 + .../braindeath_island/FeverSpiderTest.kt | 36 + .../braindeath_island/ZombieProtesterTest.kt | 66 ++ .../braindeath_island/ZombieSwabTest.kt | 45 + .../kotlin/content/quest/QuestCommandsTest.kt | 94 ++ .../member/rum_deal/BraindeathSpawnTest.kt | 47 + .../member/rum_deal/IntroCutsceneTest.kt | 54 ++ .../quest/member/rum_deal/RumDealTest.kt | 587 ++++++++++++ .../member/rum_deal/ZombieDialogueTest.kt | 27 + .../content/skill/farming/FarmingTest.kt | 2 +- 45 files changed, 4406 insertions(+), 136 deletions(-) create mode 100644 data/quest/members/rum_deal/rum_deal.ifaces.toml create mode 100644 data/quest/members/rum_deal/rum_deal.jingles.toml create mode 100644 data/quest/members/rum_deal/rum_deal.varbits.toml create mode 100644 data/skill/farming/patch/blindweed_patch.objs.toml create mode 100644 engine/src/main/kotlin/world/gregs/voidps/engine/entity/character/mode/Leash.kt create mode 100644 game/src/main/kotlin/content/area/morytania/braindeath_island/CaptainBraindeath.kt create mode 100644 game/src/main/kotlin/content/area/morytania/braindeath_island/Davey.kt create mode 100644 game/src/main/kotlin/content/area/morytania/braindeath_island/Luke.kt create mode 100644 game/src/main/kotlin/content/area/morytania/braindeath_island/ZombieSwab.kt create mode 100644 game/src/main/kotlin/content/quest/QuestCommands.kt create mode 100644 game/src/main/kotlin/content/quest/member/rum_deal/RumDeal.kt create mode 100644 game/src/test/kotlin/content/area/morytania/braindeath_island/FeverSpiderTest.kt create mode 100644 game/src/test/kotlin/content/area/morytania/braindeath_island/ZombieProtesterTest.kt create mode 100644 game/src/test/kotlin/content/area/morytania/braindeath_island/ZombieSwabTest.kt create mode 100644 game/src/test/kotlin/content/quest/QuestCommandsTest.kt create mode 100644 game/src/test/kotlin/content/quest/member/rum_deal/BraindeathSpawnTest.kt create mode 100644 game/src/test/kotlin/content/quest/member/rum_deal/IntroCutsceneTest.kt create mode 100644 game/src/test/kotlin/content/quest/member/rum_deal/RumDealTest.kt create mode 100644 game/src/test/kotlin/content/quest/member/rum_deal/ZombieDialogueTest.kt diff --git a/cache/src/main/kotlin/world/gregs/voidps/cache/definition/Params.kt b/cache/src/main/kotlin/world/gregs/voidps/cache/definition/Params.kt index d899e60979..c27b9b4aad 100644 --- a/cache/src/main/kotlin/world/gregs/voidps/cache/definition/Params.kt +++ b/cache/src/main/kotlin/world/gregs/voidps/cache/definition/Params.kt @@ -137,6 +137,10 @@ object Params { const val REGION = 5136 const val REQ_COMBAT = 5137 const val REQ_ITEMS = 5138 + + /** Item ids a quest expects the player to bring; used by the questprep command. */ + const val REQ_ITEM_IDS = 5267 + const val REQ_QUEST_POINTS = 5139 const val REQ_QUESTS = 5140 const val REQ_SKILLS = 5141 @@ -472,6 +476,7 @@ object Params { "regen_rate_ticks" -> REGEN_RATE_TICKS "region" -> REGION "req_combat" -> REQ_COMBAT + "req_item_ids" -> REQ_ITEM_IDS "req_items" -> REQ_ITEMS "req_quest_points" -> REQ_QUEST_POINTS "req_quests" -> REQ_QUESTS diff --git a/data/area/morytania/braindeath_island/braindeath_island.anims.toml b/data/area/morytania/braindeath_island/braindeath_island.anims.toml index 2a619f7fdb..cce4e03f7c 100644 --- a/data/area/morytania/braindeath_island/braindeath_island.anims.toml +++ b/data/area/morytania/braindeath_island/braindeath_island.anims.toml @@ -14,4 +14,28 @@ id = 5653 id = 5651 [zombie_pirate_death] -id = 5654 \ No newline at end of file +id = 5654 + +[deal_press_press] +id = 2808 + +[deal_smite_control] +id = 2810 + +[deal_wrench_flourish] +id = 2811 + +[deal_bowl_fish] +id = 2813 + +[spirit_attack] +id = 2804 + +[spirit_death] +id = 2805 + +[spirit_parry] +id = 2806 + +[swab_panic] +id = 9560 diff --git a/data/area/morytania/braindeath_island/braindeath_island.combat.toml b/data/area/morytania/braindeath_island/braindeath_island.combat.toml index 37b7449706..9649013d51 100644 --- a/data/area/morytania/braindeath_island/braindeath_island.combat.toml +++ b/data/area/morytania/braindeath_island/braindeath_island.combat.toml @@ -30,3 +30,15 @@ range = 1 anim = "zombie_pirate_attack" target_sound = "zombie_pirate_attack" target_hit = { offense = "crush", max = 60 } + +[evil_spirit] +defend_anim = "spirit_parry" +defend_sound = "deal_spirit_attack" +death_anim = "spirit_death" +death_sound = "rumdeal_spirit_depart" + +[evil_spirit.melee] +range = 1 +anim = "spirit_attack" +target_sound = "deal_spirit_hit" +target_hit = { offense = "slash", max = 280 } diff --git a/data/area/morytania/braindeath_island/braindeath_island.npcs.toml b/data/area/morytania/braindeath_island/braindeath_island.npcs.toml index cd7f78a027..2327b955d2 100644 --- a/data/area/morytania/braindeath_island/braindeath_island.npcs.toml +++ b/data/area/morytania/braindeath_island/braindeath_island.npcs.toml @@ -7,6 +7,7 @@ examine = "A shabby-looking leader." [50_luke] id = 2828 +dialogue = "old" [davey] id = 2829 @@ -14,33 +15,41 @@ examine = "Looks like he's taking a break." [captain_donnie] id = 2830 +dialogue = "old" [zombie_protester] id = 2831 +dialogue = "old" hunt_mode = "spotted" [zombie_protester_2] id = 2832 +dialogue = "old" hunt_mode = "spotted" [zombie_protester_3] id = 2833 +dialogue = "old" hunt_mode = "spotted" [zombie_protester_4] id = 2834 +dialogue = "old" hunt_mode = "spotted" [zombie_protester_5] id = 2835 +dialogue = "old" hunt_mode = "spotted" [zombie_protester_6] id = 2836 +dialogue = "old" hunt_mode = "spotted" [zombie_pirate] id = 2837 +dialogue = "old" hitpoints = 520 att = 49 str = 50 @@ -75,6 +84,7 @@ clone = "zombie_pirate" [zombie_swab] id = 2843 +dialogue = "old" hitpoints = 500 att = 45 str = 50 @@ -85,32 +95,32 @@ categories = ["zombies"] respawn_delay = 37 drop_table = "pirate" examine = "He talks a good fight." -hunt_mode = "cowardly" +hunt_mode = "zombie_swab" [zombie_swab_2] id = 2844 clone = "zombie_swab" -hunt_mode = "cowardly" +hunt_mode = "zombie_swab" [zombie_swab_3] id = 2845 clone = "zombie_swab" -hunt_mode = "cowardly" +hunt_mode = "zombie_swab" [zombie_swab_4] id = 2846 clone = "zombie_swab" -hunt_mode = "cowardly" +hunt_mode = "zombie_swab" [zombie_swab_5] id = 2847 clone = "zombie_swab" -hunt_mode = "cowardly" +hunt_mode = "zombie_swab" [zombie_swab_6] id = 2848 clone = "zombie_swab" -hunt_mode = "cowardly" +hunt_mode = "zombie_swab" [fever_spider] id = 2850 diff --git a/data/area/morytania/braindeath_island/braindeath_island.objs.toml b/data/area/morytania/braindeath_island/braindeath_island.objs.toml index b8b6063350..7f9e91bea3 100644 --- a/data/area/morytania/braindeath_island/braindeath_island.objs.toml +++ b/data/area/morytania/braindeath_island/braindeath_island.objs.toml @@ -12,4 +12,43 @@ examine = "It leads up." [braindeath_island_ladder_down] id = 10168 + +[braindeath_island_stagnant_lake] +id = 10105 + +[braindeath_island_brewing_controls_multi] +id = 10104 + +[braindeath_island_brewing_controls] +id = 10142 + +[braindeath_island_brewing_controls_possessed] +id = 10143 + +[braindeath_island_brewing_controls_working] +id = 10144 + +[braindeath_island_output_tap] +id = 10148 + +[braindeath_island_tool_cupboard] +id = 10162 + +[braindeath_island_tool_cupboard_open] +id = 10163 + +[braindeath_island_pressure_lever_multi] +id = 10164 + +[braindeath_island_pressure_lever_up] +id = 10165 + +[braindeath_island_pressure_lever_down] +id = 10166 + +[braindeath_island_intake_hopper] +id = 10170 + +[braindeath_island_pressure_barrel] +id = 10171 examine = "I can climb down this." diff --git a/data/area/morytania/braindeath_island/braindeath_island.sounds.toml b/data/area/morytania/braindeath_island/braindeath_island.sounds.toml index 0ea7fe50cc..ffd8e24958 100644 --- a/data/area/morytania/braindeath_island/braindeath_island.sounds.toml +++ b/data/area/morytania/braindeath_island/braindeath_island.sounds.toml @@ -6,3 +6,32 @@ id = 924 [zombie_pirate_death] id = 925 + + +[rumdeal_hopper_1] +id = 1762 + +[rumdeal_pressurize] +id = 1763 + +[rumdeal_stuff_other] +id = 1764 + +[rumdeal_stuff_slugling] +id = 1765 + +[wrench_crush] +id = 1766 + +# Not present in the 474 synth list; the reference plays it at the fishing spot. +[rumdeal_bowl_fish] +id = 2600 + +[deal_spirit_attack] +id = 1756 + +[deal_spirit_hit] +id = 1757 + +[rumdeal_spirit_depart] +id = 1755 diff --git a/data/entity/npc/hunt_modes.toml b/data/entity/npc/hunt_modes.toml index 2e5d655560..a313141cac 100644 --- a/data/entity/npc/hunt_modes.toml +++ b/data/entity/npc/hunt_modes.toml @@ -44,6 +44,15 @@ check_not_combat_self = true check_not_too_strong = true check_not_busy = true +# Rum Deal zombie swabs; they lose interest once the quest is over, filtered in ZombieSwab +[zombie_swab] +type = "player" +check_visual = "line_of_sight" +check_not_combat = true +check_not_combat_self = true +check_not_too_strong = true +check_not_busy = true + # Attacking tzhaar crafters [tzhaar_hunter] type = "player" diff --git a/data/entity/player/player.sounds.toml b/data/entity/player/player.sounds.toml index 241885bf0f..9f3c98844d 100644 --- a/data/entity/player/player.sounds.toml +++ b/data/entity/player/player.sounds.toml @@ -39,3 +39,6 @@ id = 761 [unarmed_kick] id = 2565 + +[pour_tea] +id = 2134 diff --git a/data/quest/members/rum_deal/rum_deal.gfx.toml b/data/quest/members/rum_deal/rum_deal.gfx.toml index 5f46016a96..1a1485e6f9 100644 --- a/data/quest/members/rum_deal/rum_deal.gfx.toml +++ b/data/quest/members/rum_deal/rum_deal.gfx.toml @@ -1,2 +1,5 @@ [karamthulhu] id = 627 + +[deal_spotanim] +id = 465 diff --git a/data/quest/members/rum_deal/rum_deal.ifaces.toml b/data/quest/members/rum_deal/rum_deal.ifaces.toml new file mode 100644 index 0000000000..5695b9c58c --- /dev/null +++ b/data/quest/members/rum_deal/rum_deal.ifaces.toml @@ -0,0 +1,7 @@ +[rum_deal_censor] +id = 294 +type = "main_screen" + +[rum_deal_title] +id = 295 +type = "main_screen" diff --git a/data/quest/members/rum_deal/rum_deal.items.toml b/data/quest/members/rum_deal/rum_deal.items.toml index c501425214..aecd87d583 100644 --- a/data/quest/members/rum_deal/rum_deal.items.toml +++ b/data/quest/members/rum_deal/rum_deal.items.toml @@ -2,6 +2,10 @@ [blindweed_seed] id = 6710 tradeable = false +farming_level = 40 +farming_crop = "blindweed" +farming_xp = 32.0 +farming_patch = "blindweed" examine = "A Blindweed seed - plant in a Blindweed patch." kept = "Wilderness" @@ -9,6 +13,7 @@ kept = "Wilderness" id = 6711 tradeable = false weight = 0.007 +farming_xp = 45.0 examine = "An inedible, foul smelling herb." kept = "Wilderness" diff --git a/data/quest/members/rum_deal/rum_deal.jingles.toml b/data/quest/members/rum_deal/rum_deal.jingles.toml new file mode 100644 index 0000000000..2c4b9b3468 --- /dev/null +++ b/data/quest/members/rum_deal/rum_deal.jingles.toml @@ -0,0 +1,5 @@ +[zombie_pirates] +id = 209 + +[captain_braindeath] +id = 79 diff --git a/data/quest/members/rum_deal/rum_deal.npcs.toml b/data/quest/members/rum_deal/rum_deal.npcs.toml index 0ac7608cbd..b4e220e67b 100644 --- a/data/quest/members/rum_deal/rum_deal.npcs.toml +++ b/data/quest/members/rum_deal/rum_deal.npcs.toml @@ -4,7 +4,7 @@ hitpoints = 900 att = 170 str = 146 def = 100 -style = "crush" +style = "slash" max_hit_melee = 280 hunt_mode = "cowardly" drop_table = "ashes" diff --git a/data/quest/members/rum_deal/rum_deal.varbits.toml b/data/quest/members/rum_deal/rum_deal.varbits.toml new file mode 100644 index 0000000000..c3a1973fd4 --- /dev/null +++ b/data/quest/members/rum_deal/rum_deal.varbits.toml @@ -0,0 +1,47 @@ +[rum_deal_pressure_count] +id = 1354 +persist = true + +[rum_deal_brewing_control] +id = 1355 +persist = true + +[rum_deal_squid_spot] +id = 1356 +persist = true + +[rum_deal_slugling_count] +id = 1357 +persist = true + +[rum_deal_karamthulhu_count] +id = 1358 +persist = true + +[rum_deal_swab_a] +id = 1359 +persist = true + +[rum_deal_swab_b] +id = 1360 +persist = true + +[rum_deal_swab_c] +id = 1361 +persist = true + +[rum_deal_swab_d] +id = 1362 +persist = true + +[rum_deal_swab_e] +id = 1363 +persist = true + +[rum_deal_swab_f] +id = 1364 +persist = true + +[rum_deal_diversion] +id = 1365 +persist = true diff --git a/data/quest/members/rum_deal/rum_deal.varps.toml b/data/quest/members/rum_deal/rum_deal.varps.toml index b6be5efc10..1dbb27a5f6 100644 --- a/data/quest/members/rum_deal/rum_deal.varps.toml +++ b/data/quest/members/rum_deal/rum_deal.varps.toml @@ -3,4 +3,4 @@ id = 600 persist = true format = "map" default = "unstarted" -values = { unstarted = 0, started = 1 } +values = { unstarted = 0, agreed_to_help = 1, slay_barrelor = 2, met_braindeath = 3, given_seeds = 4, grown_blindweed = 5, show_blindweed = 6, blindweed_added = 7, fetch_water = 8, collected_water = 9, water_added = 10, catch_creatures = 11, pressurised = 12, bless_wrench = 13, spirit_banished = 14, kill_spider = 15, spider_added = 16, collect_swill = 17, told_donnie = 18, completed = 19 } diff --git a/data/quest/quests.toml b/data/quest/quests.toml index c802a88f6c..16934a04f6 100644 --- a/data/quest/quests.toml +++ b/data/quest/quests.toml @@ -3216,7 +3216,10 @@ req_skills = { Crafting = 42, Slayer = 42 } +req_quests = ["zogre_flesh_eaters"] req_items = "Combat equipment and food, Slayer gloves." +# Everything else is handed out by Captain Braindeath or the basement tool cupboard. +req_item_ids = ["slayer_gloves", "spade"] req_combat = "You will need to defeat a level 56 melee enemy and a level 70 ranged enemy." points = 2 reward = "Holy wrench
Access to Braindeath Island
Tangled fishbowl and net" diff --git a/data/skill/farming/farming.varbits.toml b/data/skill/farming/farming.varbits.toml index a7ca74b4f8..778fb737d6 100644 --- a/data/skill/farming/farming.varbits.toml +++ b/data/skill/farming/farming.varbits.toml @@ -40,6 +40,13 @@ persist = true default = "weeds_3" values = { weeds_3 = 0, weeds_2 = 1, weeds_1 = 2, weeds_0 = 3, belladonna_0 = 4, belladonna_1 = 5, belladonna_2 = 6, belladonna_3 = 7, belladonna_life1 = 8, belladonna_diseased_1 = 9, belladonna_diseased_2 = 10, belladonna_diseased_3 = 11, belladonna_dead_1 = 12, belladonna_dead_2 = 13, belladonna_dead_3 = 14 } +[farming_blindweed_patch_braindeath_island] +id = 1366 +format = "map" +persist = true +default = "weeds_3" +values = { weeds_3 = 0, weeds_2 = 1, weeds_1 = 2, weeds_0 = 3, blindweed_0 = 4, blindweed_life1 = 5 } + [farming_evil_turnip_patch_draynor] id = 4291 format = "map" diff --git a/data/skill/farming/farming_produce.toml b/data/skill/farming/farming_produce.toml index df0ec0e27f..0cad2a567e 100644 --- a/data/skill/farming/farming_produce.toml +++ b/data/skill/farming/farming_produce.toml @@ -192,3 +192,6 @@ disease_chance = 30 [potato] disease_chance = 30 + +[blindweed] +disease_chance = 0 diff --git a/data/skill/farming/patch/blindweed_patch.objs.toml b/data/skill/farming/patch/blindweed_patch.objs.toml new file mode 100644 index 0000000000..c63891f3f1 --- /dev/null +++ b/data/skill/farming/patch/blindweed_patch.objs.toml @@ -0,0 +1,31 @@ +[blindweed_patch_weeds_3] +id = 10097 +examine = "Weedy." + +[blindweed_patch_weeds_2] +id = 10098 +examine = "Less weedy." + +[blindweed_patch_weeds_1] +id = 10099 +examine = "Almost clear." + +[blindweed_patch_weeded] +id = 10100 +examine = "Free from weeds." + +[blindweed_seedling] +id = 10101 +examine = "Something is growing here." + +[blindweed_fullygrown] +id = 10102 +harvest = "blindweed" +examine = "Fully grown Blindweed." + +[blindweed_patch_trashed] +id = 10103 +examine = "Weedy, wrecked and infertile." + +[farming_blindweed_patch_braindeath_island] +id = 10096 diff --git a/engine/src/main/kotlin/world/gregs/voidps/engine/entity/character/mode/Leash.kt b/engine/src/main/kotlin/world/gregs/voidps/engine/entity/character/mode/Leash.kt new file mode 100644 index 0000000000..e22c39bb09 --- /dev/null +++ b/engine/src/main/kotlin/world/gregs/voidps/engine/entity/character/mode/Leash.kt @@ -0,0 +1,104 @@ +package world.gregs.voidps.engine.entity.character.mode + +import world.gregs.voidps.engine.entity.character.Character +import world.gregs.voidps.engine.entity.character.mode.move.Movement +import world.gregs.voidps.engine.entity.character.mode.move.Step +import world.gregs.voidps.engine.entity.character.mode.move.target.FollowTargetStrategy +import world.gregs.voidps.engine.entity.character.mode.move.target.TargetStrategy +import world.gregs.voidps.engine.entity.character.npc.NPC +import world.gregs.voidps.engine.entity.distanceTo +import world.gregs.voidps.engine.map.Overlap +import world.gregs.voidps.type.Direction +import world.gregs.voidps.type.Tile +import world.gregs.voidps.type.random + +/** + * Follows [target] on a leash; the npc trails them for as long as both stay within [range] of + * [spawn] and loses interest the moment either doesn't. + * + * Unlike [Follow] - which is written for pets and teleports an npc that falls more than 15 tiles + * behind or ends up on another level - a leashed npc never teleports. It can only ever be [range] + * tiles from [spawn], and a target that outruns, teleports or climbs away from it is simply + * forgotten. + */ +class Leash( + private val npc: NPC, + val target: Character, + private val range: Int, + private val spawn: Tile = npc["spawn_tile"]!!, + private val strategy: TargetStrategy = FollowTargetStrategy(target), +) : Movement(npc, strategy) { + + override fun start() { + npc.watch(target) + } + + override fun tick() { + // within also requires matching levels, so climbing away loses interest too + if (!npc.tile.within(spawn, range) || !target.tile.within(spawn, range)) { + npc.mode = EmptyMode + return + } + if (!npc.watching(target)) { + npc.watch(target) + } + npc.walkTrigger() + // An npc already beside its target just faces it rather than shuffling onto the follow + // tile. A target standing on top of the npc falls through so [stepOut] can move it to a + // free adjacent tile. + if (!underTarget() && target.tile.distanceTo(npc) <= 1) { + npc.steps.clearDestination() + return + } + // Npcs single-step rather than pathfind, so the destination is recalculated every tick + npc.steps.clearDestination() + super.tick() + } + + override fun recalculate(): Boolean { + if (target.tile.distanceTo(npc) <= 1) { + return false + } + if (equals(strategy.tile, npc.steps.destination)) { + return false + } + npc.steps.queueStep(strategy.tile, noRun = strategy.forceWalk(npc)) + return true + } + + override fun getTarget(): Step? { + val step = npc.steps.peek() + if (step == null) { + recalculate() + return npc.steps.peek() + } + return super.getTarget() + } + + /** + * The target standing on top of the npc forces it out to a free adjacent tile, the same way + * combat movement steps npcs out from under their opponent. + */ + override fun stepOut(): Boolean { + if (npc.def["allowed_under", false] || !underTarget()) { + return false + } + clearSteps() + for (direction in Direction.cardinal.shuffled(random)) { + if (canStep(direction.delta.x, direction.delta.y)) { + npc.steps.queueStep(npc.tile.add(direction), noRun = strategy.forceWalk(npc)) + break + } + } + return true + } + + private fun underTarget(): Boolean = Overlap.isUnder(npc.tile, npc.size, npc.size, target.tile, target.size, target.size) + + override fun onCompletion() { + } + + override fun stop(replacement: Mode) { + npc.clearWatch() + } +} diff --git a/game/src/main/kotlin/content/area/morytania/braindeath_island/Brewer.kt b/game/src/main/kotlin/content/area/morytania/braindeath_island/Brewer.kt index b71c3e7505..ac68a32173 100644 --- a/game/src/main/kotlin/content/area/morytania/braindeath_island/Brewer.kt +++ b/game/src/main/kotlin/content/area/morytania/braindeath_island/Brewer.kt @@ -6,107 +6,271 @@ import content.entity.player.dialogue.Happy import content.entity.player.dialogue.Laugh import content.entity.player.dialogue.Neutral import content.entity.player.dialogue.Quiz +import content.entity.player.dialogue.Sad import content.entity.player.dialogue.Scared import content.entity.player.dialogue.Shifty import content.entity.player.dialogue.Shock import content.entity.player.dialogue.type.npc import content.entity.player.dialogue.type.player -import content.quest.questCompleted +import content.quest.questStage import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.entity.character.npc.NPC +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.inv.inventory +/** + * Brewers on Braindeath Island. Eight unique NPCs all share this dialogue + * handler with personality-specific responses at each branch point. + * + * NPC IDs (Java) -> roles: + * 2851 - "one of THEM" / paranoid + * 2852 - "Game over, man!" / panicked + * 2853 - calm but secretly crying + * 2854 - angry war veteran + * 2855 - opportunistic traitor / Hungry Frank + * 2856 - sleep-deprived / drunk + * 2857 - paranoid about disguised zombies + * 2858 - resigned to dying + */ class Brewer : Script { init { - npcOperate("Talk-to", "brewer") { - if (!questCompleted("rum_deal")) { - return@npcOperate + npcOperate("Talk-to", "brewer*") { (target) -> + val brewer = target.number() + when (questStage("rum_deal")) { + 13 -> wrenchSearch(brewer) + 19 -> postQuestGreeting(brewer) + else -> initialGreeting(brewer) } - player("Hello there!") - npc("I don't know what your game is, but I know you're one of THEM!") - player("But I just saved you!") - npc("The voices tell me different. It's all part of a plot! Confess!") } + } - npcOperate("Talk-to", "brewer_2") { - if (!questCompleted("rum_deal")) { - return@npcOperate - } - player("Hello there!") - npc("Have they gone yet?") - player("Well, no, but they are a lot calmer now.") - npc("What are we gonna do now, huh? What are we gunna do now?") - player("In your case I would say relax.") - } + // ===== Default greeting (during the quest, before the wrench step) ===== - npcOperate("Talk-to", "brewer_3") { - if (!questCompleted("rum_deal")) { - return@npcOperate + private suspend fun Player.initialGreeting(brewer: Int) { + player("So... how are you holding up?") + when (brewer) { + 1 -> { + npc("Who are you?") + npc("You're one of THEM arent you!") + player( + "O...kay...walking away now, smiling and not making eye contact.", + ) } - player("Hello there!") - npc("Hello yourself!") - player("How's things?") - npc("Fine...") - player("Excellent! Since I get the feeling I don't want to know why you said that so oddly I'll just go over here!") - npc("I think that would be for the best!") - } - - npcOperate("Talk-to", "brewer_4") { - if (!questCompleted("rum_deal")) { - return@npcOperate + 2 -> { + npc("Game over, man! Game over!") + player("Errr...keep up the good work.") } - player("Hello there!") - npc("Hello yerself Landlubber!") - player("Everything ok with you now?") - npc("Hmmm...Overall everything is good!") - player("Great!") - } - - npcOperate("Talk-to", "brewer_5") { - if (!questCompleted("rum_deal")) { - return@npcOperate + 3 -> { + npc("Very well, considering.") + player("Considering what?") + npc( + "That I've been crawling into the corner to cry whenever nobody is looking.", + ) + player("Wow... I always thought pirates were tough...") + } + 4 -> { + npc("Don't sneak up on me like that!") + npc("Don't you know there's a war on?") + player("I didn't think this counted as a war.") + npc("That's because you're a landlubber!") + npc( + "I bet ye've never had to beat a dozen zombies to death with a blunt " + + "toothpick!", + ) + player("That's right. Have you?") + npc( + "No, I've spent most of my time in the toilet to be honest.", + ) + } + 5 -> { + npc( + "Don't talk to me! If I play this right, I can sell you lot out and make " + + "it to the mainland!", + ) + } + 6 -> { + npc("No school today mother, my brain hurts real baaaad...") + player("What?") + npc("I no sleep in many, many, many days.") + } + 7 -> { + npc("How do I know you're not one of them?") + player( + "Well, I don't know... the way I'm breathing could give it away.", + ) + npc("That means nothing! NOTHING!") + } + 8 -> { + npc("Very well.") + player("You sure?") + npc("Oh Guthix, who am I kidding?") + npc("We're all gonna die!") } - player("Hello there!") - npc("Hello there, brave hero, in whom I had total confidence!") - player("Total confidence?") - npc("Yes! I was so confident that I would never, ever have sold your soft, edible body to the pirates outside!") - player("Well, great...") - npc("On a completely unrelated note, I would steer clear of Hungry Frank for a while.") - npc("He's a filthy liar. And a forger. It wouldn't surprise me if he has written out a note detailing the terms of our surrender and your dismemberment and cooking in MY handwriting.") - npc("Imagine that, the fiend.") } + } - npcOperate("Talk-to", "brewer_6") { - if (!questCompleted("rum_deal")) { - return@npcOperate + // ===== Progress 13: Looking for a priest and a wrench ===== + + private suspend fun Player.wrenchSearch(brewer: Int) { + if (inventory.contains("holy_wrench")) { + // Show off the blessed wrench + player("Bask in the glory of my Holy Wrench!") + when (brewer) { + 1 -> npc("Oooh...It's so shiny...") + 2 -> npc("(Sob) It's so beautiful...") + 3 -> npc("I am not worthy to gaze upon it!") + 4 -> npc( + "I can feel the aura of Wrenchly Power radiating from it!", + ) + 5 -> npc( + "Take it away, I am not worthy to be in its Wrenchly presence.", + ) + 6 -> npc("Wow...I don't feel tired any more...") + 7 -> npc("Such power!") + 8 -> npc("We're saved!") + } + } else { + // Asking around for help + player("I'm looking for a priest. And a wrench.") + when (brewer) { + 1 -> { + npc("What possible plan could involve a priest and a wrench?") + } + 2 -> { + npc( + "What? Are you gonna build some strange mechanical priest to save us?", + ) + npc("What if it runs riot? We'll all be killed!") + } + 3 -> { + npc( + "Well good luck. If you find any spare trousers please pass them my way.", + ) + } + 4 -> { + npc( + "Well the usual combo is an old priest and a young priest, but " + + "whatever floats your boat lad.", + ) + } + 5 -> { + npc( + "What are you going to do to that priest? No, wait, I don't want to know.", + ) + } + 6 -> { + npc("Try Davey, he might have seen it.") + player("Thanks!") + npc( + "His giant, green, inflatable dwarf might be able to tell you where " + + "one is as well.", + ) + } + 7 -> { + npc( + "If this is some sort of secret zombie code then I don't get it.", + ) + } + 8 -> { + npc("Well I can't help you there.") + player("Oh, sorry to have bothered you.") + npc("No problem.") + npc("It's not like I'm going to live through this or anything.") + npc("What if they get me while I sleep?") + npc("I don't wanna die!") + } } - player("So...got any sleep yet?") - npc("My brain is no longer capable of sleep.") - player("So...what are you going to do now?") - npc("I was gonna try and will myself dead.") - player("Right...good luck with that.") } + } - npcOperate("Talk-to", "brewer_7") { - if (!questCompleted("rum_deal")) { - return@npcOperate - } + // ===== Progress 19: Post-quest greeting ===== + + private suspend fun Player.postQuestGreeting(brewer: Int) { + // The sleep-deprived brewer gets a different opener + if (brewer == 6) { + player("So...got any sleep yet?") + } else { player("Hello there!") - npc("Well you proved that you're probably not a zombie.") - npc("So what are you then? A ghoul? A vampyre?") - player("I'm not any form of undead!") - npc("Oh...oh god I'm sorry, I didn't realise it was natural...") - player("What?") - npc("Nothing...") } - - npcOperate("Talk-to", "brewer_8") { - if (!questCompleted("rum_deal")) { - return@npcOperate + when (brewer) { + 1 -> { + npc( + "I don't know what your game is, but I know you're one of THEM!", + ) + player("But I just saved you!") + npc( + "The voices tell me different. It's all part of a plot! Confess!", + ) + } + 2 -> { + npc("Have they gone yet?") + player("Well, no, but they are a lot calmer now.") + npc("What are we gonna do now, huh? What are we gunna do now?") + player("In your case I would say relax.") + } + 3 -> { + npc("Hello yourself!") + player("How's things?") + npc("Fine...") + player( + "Excellent! Since I get the feeling I don't want to know why you said " + + "that so oddly I'll just go over here!", + ) + npc("I think that would be for the best!") + } + 4 -> { + npc("Hello yerself Landlubber!") + player("Everything ok with you now?") + npc("Hmmm...Overall everything is good!") + player("Great!") + } + 5 -> { + npc( + "Hello there, brave hero, in whom I had total confidence!", + ) + player("Total confidence?") + npc( + "Yes! I was so confident that I would never, ever have sold your soft, " + + "edible body to the pirates outside!", + ) + player("Well, great...") + npc( + "On a completely unrelated note, I would steer clear of Hungry Frank for " + + "a while.", + ) + npc( + "He's a filthy liar. And a forger. It wouldn't surprise me if he has " + + "written out a note detailing the terms of our surrender and your " + + "dismemberment and cooking in MY handwriting.", + ) + npc("Imagine that, the fiend.") + } + 6 -> { + npc("My brain is no longer capable of sleep.") + player("So...what are you going to do now?") + npc("I was gonna try and will myself dead.") + player("Right...good luck with that.") + } + 7 -> { + npc("Well you proved that you're probably not a zombie.") + npc("So what are you then? A ghoul? A vampire?") + player("I'm not any form of undead!") + player("What?") + npc("Nothing...") + } + 8 -> { + npc("You saved us! Huzzah!") + player("All in a day's work, think nothing of it.") + npc( + "I don't have anything to reward you with except my collection of bleak, " + + "gothic poetry I wrote when I assumed we were all done for. Do you " + + "want it?", + ) + player("I may come for it later, you hang on to it for now.") } - player("Hello there!") - npc("You saved us! Huzzah!") - player("All in a day's work, think nothing of it.") - npc("I don't have anything to reward you with except my collection of bleak, gothic poetry I wrote when I assumed we were all done for. Do you want it?") - player("I may come for it later, you hang on to it for now.") } } + + /** Brewers are named `brewer` then `brewer_2` through `brewer_8`. */ + private fun NPC.number(): Int = id.substringAfterLast('_').toIntOrNull() ?: 1 } diff --git a/game/src/main/kotlin/content/area/morytania/braindeath_island/CaptainBraindeath.kt b/game/src/main/kotlin/content/area/morytania/braindeath_island/CaptainBraindeath.kt new file mode 100644 index 0000000000..c08455735d --- /dev/null +++ b/game/src/main/kotlin/content/area/morytania/braindeath_island/CaptainBraindeath.kt @@ -0,0 +1,896 @@ +package content.area.morytania.braindeath_island + +import content.entity.effect.clearTransform +import content.entity.effect.transform +import content.entity.player.dialogue.Angry +import content.entity.player.dialogue.Drunk +import content.entity.player.dialogue.Happy +import content.entity.player.dialogue.Laugh +import content.entity.player.dialogue.Neutral +import content.entity.player.dialogue.Quiz +import content.entity.player.dialogue.Sad +import content.entity.player.dialogue.Shifty +import content.entity.player.dialogue.Shock +import content.entity.player.dialogue.type.ChoiceOption +import content.entity.player.dialogue.type.choice +import content.entity.player.dialogue.type.npc +import content.entity.player.dialogue.type.player +import content.entity.player.dialogue.type.statement +import content.entity.player.inv.item.addOrDrop +import content.quest.questComplete +import content.quest.questStage +import content.quest.refreshQuestJournal +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.client.clearCamera +import world.gregs.voidps.engine.client.moveCamera +import world.gregs.voidps.engine.client.turnCamera +import world.gregs.voidps.engine.client.ui.close +import world.gregs.voidps.engine.client.ui.open +import world.gregs.voidps.engine.entity.character.jingle +import world.gregs.voidps.engine.entity.character.move.tele +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.entity.character.player.equip.equipped +import world.gregs.voidps.engine.entity.character.player.male +import world.gregs.voidps.engine.entity.character.player.skill.Skill +import world.gregs.voidps.engine.entity.character.player.skill.exp.exp +import world.gregs.voidps.engine.event.AuditLog +import world.gregs.voidps.engine.inv.add +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.network.login.protocol.visual.update.player.EquipSlot +import world.gregs.voidps.type.Tile + +class CaptainBraindeath : Script { + init { + npcOperate("Talk-to", "captain_braindeath") { (target) -> + when (questStage("rum_deal")) { + 2 -> introCutsceneIntro() + 3 -> handoutBlindweedSeed() + 5 -> deliverBlindweed() + 7 -> nextStagnantWater() + 10 -> nextSluglings() + 12 -> slugShockSnap() + 14 -> finalIngredient() + 16 -> spiderInHopper() + 17 -> spokeToCaptainNo() + 18 -> spokeToCaptainYes() + 19 -> offerRum() + else -> defaultGreeting() + } + } + } + + // ===== Progress 2: Wake-up + intro cutscene ===== + + private suspend fun Player.introCutsceneIntro() { + npc("Are ye alright, ${ladOrLass()}?") + player("Ohhhh... My head...") + player("It feels like someone has smacked me one with a bottle...") + npc("Arr... Those devils gave ye a nasty knock when ye came to aid us.") + npc( + "But now yer here we'll run those evil brain-eatin' dogs off the island fer good!", + ) + player("What? What is going on here?") + player("I can't seem to remember anything beyond chatting to a man at the docks.") + npc("Arr. Well, ${ladOrLass()}, that would be Pete, one of my men.") + npc("He's been out lookin' fer heroes like yerself to aid us in our peril.") + npc( + "When ye arrived ye took a nasty knock to the head, so ye probably don't remember " + + "agreein' to help us out. But I swear to ye that ye did.", + ) + player("Okay... I'll buy that. It sounds like something I would do.") + player("So where am I, and what is going on?") + npc("Yer on Braindeath Island!") + npc( + "Where it lies is a secret, because ye are standin' in the brewery of Cap'n " + + "Braindeath, purveyor of the most vitriolic alcoholic beverages in the world!", + ) + player("Wow!") + npc( + "I am the notorious alchemist Cap'n Braindeath, and this whole operation be my idea!", + ) + npc( + "With my crew of sturdy, upright pirate brewers, we sail the seven seas, " + + "distributing cheap 'alcohol' to all and sundry.", + ) + npc("Well, fer a price, at any rate.") + player("Oooh!") + npc( + "These be dark times, though, ${ladOrLass()}. See, a week ago we awoke to find " + + "ourselves beseiged.", + ) + player( + "The lads and I have held them off so far, but 'tis only a matter of time before " + + "they sweep through the buildin' and put us all to the sword.", + ) + player("Who?") + npc("Them!") + statement("The Captain points out of the window...", clickToContinue = false) + playProtestCutscene() + player("...protesting?") + npc("Arr, ${ladOrLass()}! That they are!") + npc( + "Day and night they seek to break our will with their chantin', and their singin' " + + "and their passive resistance!", + ) + npc( + "Seems they lost their fightin' spirit after the first few days. Now most of them " + + "just protest all the time.", + ) + player("So, what do you want me to do?") + npc( + "Well, me and the lads got our heads together and decided that if we can get their " + + "Cap'n drunk enough, perhaps they'll stop protestin'.", + ) + npc( + "If that happens, we'll slip out the back and set up shop somewhere else.", + ) + player("Well, how can I help?") + npc( + "Well, first of all, we need someone to go out the front and grow us some Blindweed.", + ) + npc("'Tis one of the ingredients of our 'rum'.") + npc( + "The only problem is that those rottin' fiends have torn up and destroyed all but " + + "one of the Blindweed Patches.", + ) + if (hasSpace()) { + addOrDrop("blindweed_seed") + set("rum_deal", "given_seeds") + npc( + "Here, ${ladOrLass()}. I'll give ye the seed you'll need fer growin' the herb. " + + "Help yerself to the gardenin' equipment in the basement.", + ) + blindweedWarnings() + } else { + set("rum_deal", "met_braindeath") + npc( + "Well, I have some Blindweed seeds fer ye. When ye have some free space fer " + + "them, come and talk to me.", + ) + } + } + + private suspend fun Player.blindweedWarnings() { + npc("I'll warn ye again that those devils are sat right on top of the patch.") + npc( + "Try hecklin' 'em from a distance. Those Swabs may talk a good fight, but if ye " + + "can put a scare in 'em they'll keep out of yer way!", + ) + } + + // ===== Progress 3: Came back with space, hand out the seed ===== + + private suspend fun Player.handoutBlindweedSeed() { + if (hasSpace()) { + addOrDrop("blindweed_seed") + set("rum_deal", "given_seeds") + npc( + "Finally, ${ladOrLass()}! Here, take this seed and grow us some Blindweed.", + ) + npc( + "Ye can help yerself to the gardenin' equipment in the basement, just be quick " + + "about it!", + ) + blindweedWarnings() + } else { + defaultGreeting() + } + } + + // ===== Progress 5: Player either has Blindweed or lost it ===== + + private suspend fun Player.deliverBlindweed() { + if (inventory.contains("blindweed")) { + player("Here! I have your Blindweed!") + npc("Splendid, ${ladOrLass()}! Go shove it in the Intake Hopper upstairs.") + set("rum_deal", "show_blindweed") + npc("We'll beat them zombies yet! Arr!") + } else { + player("Captain... I grew some blindweed but then I lost it...") + npc("There there, ${ladOrLass()}. 'Tis all right.") + if (hasSpace()) { + npc("Here, I've been savin' some fer an emergency.") + set("rum_deal", "show_blindweed") + npc("Go shove that in the Intake Hopper, and try not ter lose this lot!") + addOrDrop("blindweed") + } else { + npc( + "When ye've quite finished blubbin', come see me and I'll give ye some Blindweed.", + ) + npc( + "Clear a little space from yer pack first though, ${ladOrLass()}, or ye'll " + + "be hard pressed to hold it.", + ) + } + } + } + + // ===== Progress 7: Stagnant water step ===== + + private suspend fun Player.nextStagnantWater() { + player("Well, that takes care of the Blindweed. What now?") + npc( + "Well, now ye've shoved the Blindweed into the mix, what we need is a bucket of " + + "stagnant water.", + ) + player("Where can I get some of that?") + npc( + "Ye won't have to go far, ${ladOrLass()}, we have a pool of the stuff here!", + ) + player("Here in the brewery?") + npc("No, ${ladOrLass()}, that would be a strange thing to have in a brewery!") + npc("It's up the mountain to the north.") + player("Up a mountain?") + npc("Well, 'tis technically a volcano, but ye get the general idea.") + player("And I assume the place is crawling with these zombies?") + npc("No ${ladOrLass()}, there be not a zombie in sight.") + player("Oh, good!") + npc( + "All ye have to do is get past the keen-eyed lookout that's been spottin' my men " + + "when I send 'em.", + ) + npc("I'll tell ye that it won't be easy!") + player("When is it ever...") + npc("Don't ferget yer bucket!") + if (!inventory.contains("bucket") && hasSpace()) { + addOrDrop("bucket") + } + set("rum_deal", "fetch_water") + } + + // ===== Progress 10: Sluglings step ===== + + private suspend fun Player.nextSluglings() { + player("So is that everything?") + npc( + "No, ${ladOrLass()}, next ye'll need to go outside and catch five loads of " + + "Sluglings fer the brew.", + ) + player("What? Sluglings? That's disgusting!") + npc("Arr, ${ladOrLass()}, that it be.") + npc("To a weak-stomached, knock-kneed landlubber...") + player("Why Sluglings?") + npc("'Cos, ${ladOrLass()} they're one of our super-secret ingredients!") + npc( + "Yer not too susceptible to mind control are ye ${ladOrLass()}?", + ) + player("Why?") + npc( + "Because they have been known te, well, influence people every now and again.", + ) + if (isSeaSlugComplete()) { + player("They aren't related to those Seaslugs are they?") + npc( + "Aye they're related! Tis a good job they'll starve if they tried te eat yer " + + "brains!", + ) + } else { + npc("I'm sure ye've got nothing te worry about!") + } + player("So what should I do with these Sluglings anyway?") + npc("Well, ye shove them in the Pressure Barrel in the attic.") + npc("And then ye...") + player("And then I...") + npc("And then ye...") + npc("Pressurise 'em.") + player("Pressurise them?!") + npc("Look, we don't have all day, get movin'.") + if (hasSpace()) { + set("rum_deal", "catch_creatures") + if (inventory.contains("fishbowl_and_net")) { + npc("Arr! I see ye've already got a fishbowl in a net.") + } else { + addOrDrop("fishbowl_and_net") + npc("Here. Ye'll need this to catch them.") + } + player("What should I do with it?") + npc( + "Just dunk it in the water. I'm sure a clever ${ladOrLass()} like yerself will " + + "have no problem.", + ) + npc( + "Oh, and if ye haul up some squiddy-looking things, don't tate to shove 'em in " + + "the barrel too.", + ) + npc("They add a special, fishy texture to the drink.") + } else { + npc( + "Ye'll be needin' a fishbowl in a big net to help ye catch them slimy little " + + "blighters.", + ) + set("rum_deal", "catch_creatures") + npc("Pity ye ain't got any free space to carry one.") + } + } + + // ===== Progress 12: Slug-shock recovery ===== + + private suspend fun Player.slugShockSnap() { + player("How could I kill my sluggy brethren...?") + npc("Snap out of it, ${ladOrLass()}! Yer in slug-shock!") + player("What? Who?") + player("Gah! Sorry about that.") + npc( + "No problem. Well, now ye have just one more ingredient to grab, and then we can " + + "get this 'rum' flowin!", + ) + player("Well, how far away will I have to go to grab it?") + npc("Not far at all, ${ladOrLass()}. Ye've just got to get it from the basement!") + player("Great! What is it?") + npc( + "Hold yer horses, ${ladOrLass()}! While ye was off gallivantin' with yer slimy " + + "aquatic playmates, the 'rum' achieved spiritual critical mass.", + ) + npc("To put it in terms ye'll understand: the brewin' equipment is possessed.") + player("Possessed!") + npc("Don't ye worry yerself about it! This happens all the time.") + npc( + "Well, to tell the truth, my lads are a little quicker off the mark, so it only " + + "happens occasionally.", + ) + npc("Not that I'm criticisin' yer performance, ${ladOrLass()}.") + if (hasSpace()) { + inventory.add("wrench") + set("rum_deal", "bless_wrench") + npc("Give the controls a couple of belts with this wrench.") + npc( + "One of the lads did a little priestin' on the side before he came here. Get " + + "him to bless it and ye'll do fine.", + ) + } else { + set("rum_deal", "bless_wrench") + npc( + "We've got a special item fer dealin' with this problem. Dump some of that " + + "stuff yer carryin' and I'll hand it over!", + ) + } + } + + // ===== Progress 14: Final ingredient (fever spider body) ===== + + private suspend fun Player.finalIngredient() { + npc( + "Well, now that ye've got that spirit out of there ye can dump in the final " + + "ingredient.", + ) + player("And that is?") + npc("We need the body of a diseased Fever Spider!") + player("Remind me never to drink anything you have ever made. Or touched.") + npc( + "When yer quite done flappin' yer lips, go down into the basement and whack " + + "spiders until ye find a fever spider body.", + ) + npc("Shove it in the hopper, and then we're in business.") + when { + wearingSlayerGloves() -> { + npc( + "I see yer already wearin' some Slayer Gloves. That'll keep the Fever " + + "Spiders from gnawin' yer hands off!", + ) + } + inventory.contains("slayer_gloves") -> { + npc( + "Ye'll be wanting to put them Slayer Gloves of yours on before ye head off " + + "${ladOrLass()}, as those Fever Spiders carry a nasty disease.", + ) + npc( + "They'll give it to ye if ye aren't wearin' somethin' too thick fer them " + + "to bite through.", + ) + } + else -> { + npc("Ye may want to hop back to the mainland and see a Slayer Master.") + npc( + "These Fever Spiders carry a nasty disease, and ye'll need a pair of " + + "Slayer Gloves on to not catch it!", + ) + } + } + set("rum_deal", "kill_spider") + } + + // ===== Progress 16: Spider in hopper, brew sequence ===== + + private suspend fun Player.spiderInHopper() { + player("Well, I stuck your spider in the hopper, what now?") + npc("Now ye stand well back and watch the glory of brewin' at its best!") + playBrewCutscene() + player("Is that it?") + npc("Aye ${ladOrLass()}! Now get outside and feed that stuff to the pirates.") + npc( + "Try givin' it to the Captain, he's in charge. Get him bladdered and the rest " + + "will fall!", + ) + when { + inventory.contains("bucket") -> { + npc( + "Ye'll need to use that bucket of yours. Most stuff can't stand bein' in " + + "contact with our 'rum' fer too long.", + ) + npc("Took us a lot of dissolvin' to work that one out.") + set("rum_deal", "collect_swill") + } + hasSpace() -> { + inventory.add("bucket") + npc( + "Here, ye'll need to use one of these. The rum tends to eat through " + + "almost everything else.", + ) + set("rum_deal", "collect_swill") + } + else -> { + npc( + "Oh, and don't try using anything but a bucket. Our 'rum' tends to eat " + + "through stuff.", + ) + } + } + } + + // ===== Progress 17/18: Reporting back from the Captain ===== + + private suspend fun Player.spokeToCaptainNo() { + npc("So, what did he say?") + player("Who?") + npc("The Cap'n!") + player("Oh! I haven't spoken to him yet!") + npc("Well, get a move on!") + if (!inventory.contains("unsanitary_swill")) { + npc("And don't ferget the 'rum'!") + } + } + + private suspend fun Player.spokeToCaptainYes() { + npc("So, what did he say?") + player("Not much that was coherent.") + player("Who is Rabid Jack?") + npc("Rabid Jack!") + npc("THE Rabid Jack!") + npc("Egad...I haven't heard that name...") + npc("...before.") + player("So, who is he?") + npc("Dunno ${ladOrLass()}.") + npc("Almost as if I aren't changing subjects, well done!") + npc( + "With those rottin' dogs legless they'll never keep fighting us now, so we've " + + "decided to stay here and keep the 'rum' flowin!", + ) + npc("Thanks, ${ladOrLass()}. We'd never have managed without ye!") + completeRumDeal() + } + + // ===== Progress 19: Post-quest "want some rum?" ===== + + private suspend fun Player.offerRum() { + npc( + "Fancy some 'rum', ${ladOrLass()}? It's still fresh. Well, fresh-ish...", + ) + player("No... I think I'll pass.") + } + + // ===== Default greeting / generic options menu ===== + + private suspend fun Player.defaultGreeting() { + player("So...") + defaultOptionsChoice() + } + + suspend fun Player.defaultOptionsChoice() { + choice { + whatDoYouWantMeToDo() + whyTalkLikePirate() + whatDoYouMakeHere() + } + } + + fun ChoiceOption.whatDoYouWantMeToDo(): Unit = option( + "What exactly do you want me to do?", + ) { + when (questStage("rum_deal")) { + 3 -> { + npc( + "Well, I need ye to go grow me some Blindweed, but ye are carryin' too " + + "much stuff fer me to give ye the seed.", + ) + } + 4 -> growBlindweedReminder() + 6 -> putBlindweedInHopper() + 8 -> goGetStagnantWater() + 9 -> pourBucketInHopper() + 11 -> catchFiveSeaCreatures() + 13 -> clearEvilSpirit() + 15 -> jamBodyInHopper() + } + } + + fun ChoiceOption.whyTalkLikePirate(): Unit = option("Why do you talk like a pirate?") { + player( + "Why do you talk like a pirate? Didn't you tell me you were an alchemist?", + ) + npc("Arr, ${ladOrLass()}, 'tis true.") + npc( + "However, 'tis also true that I stumbled across the basic recipe fer my most " + + "potent of brews in a terrible alchemical accident.", + ) + npc( + "See, 'twas a dark and stormy night, and the wind was howlin' around the trees as " + + "I worked late into the night.", + ) + npc( + "Steppin' too close to a candle with my flask in my hand, I was suddenly swept up " + + "in a terrible, yet potently alcoholic, explosion.", + ) + player("And?") + npc( + "Well' ${ladOrLass()}, it seems the fumes from that first batch of me 'rum' did " + + "strange things to me brain.", + ) + npc( + "I don't remember the exact words the healers used, but apparently the stuff " + + "burned out the tiny, specialised part of me brain that tells me not to talk " + + "like a pirate.", + ) + player("That... that... that sounds utterly impossible!") + npc("Arr! That be what I told them!") + } + + fun ChoiceOption.whatDoYouMakeHere(): Unit = option("So what do you make here anyway?") { + npc("'Rum', ${ladOrLass()}!") + npc( + "The finest, most potent, most flammable and most debilitatin' 'rum' in the whole " + + "of RuneScape!", + ) + player("Rum, eh?") + npc("No, ${ladOrLass()}, 'rum'!") + player("What's the difference?") + npc("Well, see, it's like this.") + npc( + "If we called the stuff we make 'rum' without makin' the little quote gestures " + + "every time, then the Cookin' Guild has promised to do entertaininly painful " + + "things to us with whisks.", + ) + npc("See, technically -") + npc( + "And by that I mean technically according to the Disposal of Hazardous Waste Act " + + "and the Health and Safety Laws -", + ) + npc( + "technically, what we're brewin' here is Artificially Produced Hyper Condensed " + + "Sweetened 'Rum' Flavour Distillate.", + ) + player("Riiiiiiiight...") + npc( + "So ye see, ${ladOrLass()}, we just call it 'rum' because the real name be a bit " + + "of a mouthful.", + ) + npc("Want a drop?") + player("No thanks... I think I'll skip it for now.") + } + + // ===== Per-progress reminder branches from the options menu ===== + + private suspend fun Player.growBlindweedReminder() { + npc("Arr, well I want ye to get outside and grow some Blindweed.") + npc( + "Best be careful, ${ladOrLass()}, fer them pirates will skin ye alive if they " + + "catch ye.", + ) + when { + get("farming_blindweed_patch_braindeath_island", 0) >= 4 -> { + npc("Ye'll need ter keep an eye on yer plants, ${ladOrLass()}.") + npc("Wouldn't want them pirates to trample all over 'em, would ye?") + } + inventory.contains("blindweed_seed") -> { + // Player already has the seed — nothing more to say + } + else -> { + player("And what happens if I lose the seed?") + npc( + "Then I'll have ye flogged, hung, slapped with a haddock and sent back " + + "out there to fight the zombie hordes with little more than harsh language!", + ) + if (hasSpace()) { + npc("Arr... Here, ${ladOrLass()}. Just take this and get goin'!") + inventory.add("blindweed_seed") + } else { + npc( + "Come back and see me when ye ain't loaded down with stuff, and I'll " + + "give ye another.", + ) + } + } + } + } + + private suspend fun Player.putBlindweedInHopper() { + npc( + "I want ye to go put yer Blindweed in the intake hopper, and to be sharp about it!", + ) + if (inventory.contains("blindweed")) return + player( + "And what happens if I lose the Blindweed between here and the intake hopper?", + ) + player( + "Theoretically, of course, as it would be almost impossible for me to simply lose " + + "the stuff in the time it will take me.", + ) + npc("Gah!") + npc( + "Yer nothin' better than a sea-slug wearin', lilly-legged, bandy-livered " + + "landlubber!", + ) + if (hasSpace()) { + npc( + "Here, I was savin' a little fer an emergency, so take this and shove it in " + + "the hopper before I bung ye in there and turn the handle!", + ) + addOrDrop("blindweed") + player("You had some all the time?") + player( + "Why was I risking my neck killing pirates to grow some if you had some all " + + "along?", + ) + npc("Less talk, ${ladOrLass()}, haven't ye got a hopper to be fillin'?") + } else { + npc( + "I've got a little put aside fer an emergency. Drop some of that junk yer " + + "carryin', and come see me to get it.", + ) + } + } + + private suspend fun Player.goGetStagnantWater() { + npc("To get off yer lazy behind and head out to the stagnant lake on top of the volcano.") + npc("Chop chop, ${ladOrLass()}.") + if (!inventory.contains("bucket") && hasSpace()) { + inventory.add("bucket") + } + npc("Don't ferget yer bucket!") + } + + private suspend fun Player.pourBucketInHopper() { + npc("I want ye to go pour yer bucket into yonder hopper.") + npc("Take yer time, ${ladOrLass()}, we're only bein' besieged by zombies.") + if (inventory.contains("bucket_of_water_stagnant")) return + // Stolen-bucket scene + player("Err... Captain...") + npc("Aye, what is it, ${ladOrLass()}?") + player( + "Well, on my way over to the hopper some big boy stole the bucket and ran away...", + ) + npc("Give me strength!") + npc("If ye weren't so good at distractin' the zombies I'd skin ye alive!") + if (hasSpace()) { + inventory.add("bucket_of_water_stagnant") + npc( + "Take this bucket and pour it into the hopper. If ye don't get it right this " + + "time I'll have yer guts fer garters!", + ) + } else { + npc( + "Well, 'tis a good job I saved a little, isn't it? Now get rid of something " + + "so I can give it to ye.", + ) + } + } + + private suspend fun Player.catchFiveSeaCreatures() { + npc( + "I want ye to go outside and catch five sea creatures from the squid fishin' spot.", + ) + npc( + "When ye've grabbed them, jam 'em in the barrel in the attic and pressurise 'em.", + ) + if (inventory.contains("fishbowl_and_net")) return + player("How am I suppost to catch these things?") + npc( + "Ye'll be needin' to use a fishbowl in a big net to snare these little devils.", + ) + player("And if I don't have one?") + when { + !inventory.contains("fishbowl") && inventory.contains("big_fishing_net") -> { + if (hasSpace()) { + inventory.add("fishbowl") + npc( + "Yer lucky all my precious fish died last week, ${ladOrLass()}. Take this and " + + "wrap it in that net, then go grab some Sluglings!", + ) + } else { + npc( + "Well, I'll give ye a fishbowl fer that net of yours if ye'll drop " + + "some of that clutter in yer pack.", + ) + } + } + !inventory.contains("big_fishing_net") && inventory.contains("fishbowl") -> { + npc("Gah! I don't believe it!") + if (hasSpace()) { + inventory.add("big_fishing_net") + npc( + "Here, wrap this around yer fishbowl and get back out there!", + ) + } else { + npc( + "Well, I'll give ye a net fer that fishbowl of yours if ye'll drop " + + "some of that clutter in yer pack.", + ) + } + } + else -> { + npc("Why, I oughta...") + if (hasSpace()) { + npc( + "Here! Try not ter lose this one! These things don't grow on trees, " + + "ye know.", + ) + inventory.add("fishbowl_and_net") + } else { + npc( + "Come see me when yer not so laden down. I've got another one fer ye " + + "if ye need it.", + ) + } + } + } + } + + private suspend fun Player.clearEvilSpirit() { + npc( + "I want ye to go clear the Evil Spirit out of the brewin' controls. Use the " + + "wrench I gave ye, but get it blessed first.", + ) + when { + inventory.contains("holy_wrench") -> { + npc( + "I see ye've got the wrench good and blessed, so go whack the controls " + + "till the Evil Spirit pops out.", + ) + } + inventory.contains("wrench") -> { + // Player has the unblessed wrench — nothing more to say + } + else -> { + player("Mumblemumblewhatwrenchmumble...") + npc("What were that?") + player("I said, what if I've lost the wrench?") + if (hasSpace()) { + inventory.add("wrench") + npc("Arr! I'd get more work out of a minty flavoured brick!") + npc( + "If ye lose this one I'll have ye turned inside out, covered with " + + "spiders and turned rightside in so they'll eat ye alive!", + ) + player("Steady on!") + } else { + npc("Arr, ye landlubber!") + npc("Calm down, remember yer ulcer...") + npc( + "Look, I've got another wrench, but ye don't have any free space fer " + + "it. Come back when ye do.", + ) + } + } + } + } + + private suspend fun Player.jamBodyInHopper() { + when { + wearingSlayerGloves() -> { + npc( + "I see yer already wearin' some Slayer Gloves. That'll keep the Fever " + + "Spiders from gnawin' yer hands off!", + ) + } + inventory.contains("fever_spider_body") -> { + npc("I want ye to jam that body into the hopper and come see me.") + npc("Arr! Yer not as slow as I took ye fer!") + } + else -> { + npc("I want ye to go kill a Fever Spider and jam its body in the hopper.") + npc("Whenever yer ready, ${ladOrLass()}.") + } + } + } + + // ===== Helpers ===== + + private fun Player.ladOrLass(): String = if (male) "lad" else "lass" + + private fun Player.hasSpace(): Boolean = inventory.spaces > 0 + + private fun Player.wearingSlayerGloves(): Boolean = equipped(EquipSlot.Hands).id == "slayer_gloves" + + private fun Player.isSeaSlugComplete(): Boolean = get("sea_slug", "") == "completed" + + private suspend fun Player.playProtestCutscene() { + open("fade_out") + delay(3) + // 1957 is an unnamed, model-less npc; transforming into it hides the player while + // the camera pans over the protest + transform("rupert_the_beard_3") + + jingle("zombie_pirates") + tele(2152, 5098, 0) + moveCamera( + tile = Tile(2145, 5088), + height = 310, + acceleration = 100, + ) + turnCamera( + tile = Tile(2145, 5066), + height = 300, + acceleration = 100, + ) + delay(1) + + moveCamera( + tile = Tile(2156, 5088), + height = 310, + speed = 2, + acceleration = 0, + ) + delay(2) + + close("fade_out") + delay(2) + + open("rum_deal_title") + delay(17) + + open("fade_out") + delay(2) + + close("rum_deal_title") + clearTransform() + tele(2144, 5108, 1) + delay(2) + + clearCamera() + open("fade_in") + player("Are they...") + } + + private suspend fun Player.playBrewCutscene() { + moveCamera( + tile = Tile(2144, 5106), + height = 250, + ) + turnCamera( + tile = Tile(2144, 5099), + height = 100, + ) + delay(3) + set("rum_deal_brewing_control", 2) + delay(3) + clearCamera() + } + + private fun Player.completeRumDeal() { + jingle("quest_complete_1") + exp(Skill.Prayer, 7000.0) + exp(Skill.Fishing, 7000.0) + exp(Skill.Farming, 7000.0) + addOrDrop("holy_wrench") + inc("quest_points", 2) + AuditLog.event(this, "quest_completed", "rum_deal") + set("rum_deal", "completed") + set("rum_deal_pressure_count", 0) + set("rum_deal_brewing_control", 2) + set("rum_deal_slugling_count", 0) + set("rum_deal_karamthulhu_count", 0) + for (swab in 'a'..'f') { + set("rum_deal_swab_$swab", 1) + } + set("farming_blindweed_patch_braindeath_island", "weeds_0") + refreshQuestJournal() + questComplete( + "Rum Deal", + "2 Quest Points", + "7,000 Prayer XP", + "7,000 Fishing XP", + "7,000 Farming XP", + "A Holy Wrench", + item = "holy_wrench", + ) + } +} diff --git a/game/src/main/kotlin/content/area/morytania/braindeath_island/CaptainDonnie.kt b/game/src/main/kotlin/content/area/morytania/braindeath_island/CaptainDonnie.kt index c758f36efd..754f0dfed3 100644 --- a/game/src/main/kotlin/content/area/morytania/braindeath_island/CaptainDonnie.kt +++ b/game/src/main/kotlin/content/area/morytania/braindeath_island/CaptainDonnie.kt @@ -1,30 +1,162 @@ package content.area.morytania.braindeath_island +import content.entity.player.dialogue.Angry import content.entity.player.dialogue.Drunk +import content.entity.player.dialogue.Happy +import content.entity.player.dialogue.Neutral import content.entity.player.dialogue.Quiz +import content.entity.player.dialogue.Sad import content.entity.player.dialogue.Shifty +import content.entity.player.dialogue.Shock +import content.entity.player.dialogue.type.ChoiceOption +import content.entity.player.dialogue.type.choice import content.entity.player.dialogue.type.npc import content.entity.player.dialogue.type.player -import content.quest.questCompleted +import content.entity.player.dialogue.type.statement +import content.quest.questStage import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.engine.inv.remove class CaptainDonnie : Script { init { - npcOperate("Talk-to", "captain_donnie") { - if (!questCompleted("rum_deal")) { - return@npcOperate + npcOperate("Talk-to", "captain_donnie") { (target) -> + talkWithDonnie() + } + + itemOnNPCOperate("unsanitary_swill", "captain_donnie") { + talkWithDonnie() + } + } + + // ===== Default greeting (no swill, or wrong quest progress) ===== + + private suspend fun Player.talkWithDonnie() { + when { + inventory.contains("unsanitary_swill") && questStage("rum_deal") == 17 -> { + deliverSwillFirstTime() + } + inventory.contains("unsanitary_swill") -> { + deliverSwillRepeat() } - npc("Hey! You!") - player("Who? Me?") - npc("Aye! Ye!") - npc("Got any more rum?") - player("What happens if I don't?") - npc("Then I'll clap ye in Runes!") - player("Don't you mean clap me in irons?") - npc("No lad, not irons, Runes!") - npc("We upgraded last week.") - player("Ok, well it's a good job that I have some over here isn't it?") - npc("Arr, yer a good lad...") + else -> initialGreeting() + } + } + + private suspend fun Player.initialGreeting() { + npc("Arr! What be ye wantin?") + player("I, err, came to...") + approachChoice() + } + + suspend fun Player.approachChoice() { + choice { + tellYouToLeave() + askWhatYouWanted() + joinYourCrew() + } + } + + fun ChoiceOption.tellYouToLeave(): Unit = option("Tell you to leave.") { + player("I have come to tell you to leave.") + npc("Ye have?") + player("Yes.") + npc("Hahahahahahahahahahahahaha!") + npc("Bwahahahahahahahahahahahaha!") + npc("Wheeze...wheeze...") + npc( + "Gadzooks lad, that be the funniest thing I've heard all day! Say it again!", + ) + player("I have come to tell you to leave.") + npc("Stop lad! I'll shatter me ribs!") + } + + fun ChoiceOption.askWhatYouWanted(): Unit = option("Ask what you wanted.") { + player("I've come to ask you what you want.") + npc("Whadda we want? 'Rum'! When do we want it? Now!") + player("So...if I were to give you 'rum' you would leave?") + npc( + "Not really lad. If ye were to give us 'rum' we'd kill ye quickly, as opposed to " + + "over a few weeks.", + ) + player("Oh...") + } + + fun ChoiceOption.joinYourCrew(): Unit = option("Join your crew.") { + player("I've come to join your crew!") + player("Err, I mean...") + player( + "Arr! Shiver me mainbraces and make them landlubbers walk the scurvy plank, Cap'n! " + + "I've come to join yer cut-throat, bilge swillin' crew! Also, arr!", + ) + npc("Are ye quite done, lad?") + player("Yes, for the time being anyway.") + npc( + "Well, ye'll be glad to know that after that little performance I'd be glad to " + + "have ye on me crew!", + ) + player("Huzzah!") + npc("Course, I'll have te kill ye first.") + player("Oh...") + npc( + "Don't ye worry, lad. After we take the island I'll have the boss haul yer body " + + "to the temple and...", + ) + npc("Err, never mind.") + player("Never mind what?") + npc("Ferget I said anything.") + } + + // ===== Progress 17: First swill delivery (the trick) ===== + + private suspend fun Player.deliverSwillFirstTime() { + npc("Be that the finest, most abrasive 'rum' I've ever smelled?") + player("Yes! That it be!") + npc("Hand it over or I'll run ye through!") + inventory.remove("unsanitary_swill") + set("rum_deal", "told_donnie") + advanceSwabVarbits() + statement("The Captain drinks the 'rum' as quickly as possible.") + npc("Arr.") + npc("Ye be a good lad, fer a filthy livin' landlubber.") + player("So... I take it your boss will be pleased?") + npc("Arr, that he will. I'll tell...") + npc("Wait a minute...") + npc("Arr, ye tricky dog!") + npc("Ye tried to trick old Donnie!") + player("Oh well, I guess I'll have to try again.") + npc( + "Arr, lad, you tried to trick me, but I was too clever for ye!", + ) + npc( + "Besides, Rabid Jack would have my hide if I told ye it were him that sent me!", + ) + player("I'm sure he would. Good job you caught me out, eh!") + npc("Aye! Now get ye gone, and don't return without more 'rum'!") + } + + // ===== Repeat swill delivery (later attempts) ===== + + private suspend fun Player.deliverSwillRepeat() { + npc("Hey you! I can smell the 'rum' on ye!") + npc( + "If it isn't in my belly by the time I cout ter three, I'll have ye flogged!", + ) + inventory.remove("unsanitary_swill") + statement("Donnie drinks the 'rum' like it is water.") + npc("Arrr...that hits the spot...") + } + + // ===== Helpers ===== + + /** + * Once Donnie has drunk the swill the whole crew joins him, so every zombie pirate + * outside the compound switches to its drunk variant. + */ + private fun Player.advanceSwabVarbits() { + for (swab in 'a'..'f') { + set("rum_deal_swab_$swab", 1) } } } diff --git a/game/src/main/kotlin/content/area/morytania/braindeath_island/Davey.kt b/game/src/main/kotlin/content/area/morytania/braindeath_island/Davey.kt new file mode 100644 index 0000000000..ecddfbbe0a --- /dev/null +++ b/game/src/main/kotlin/content/area/morytania/braindeath_island/Davey.kt @@ -0,0 +1,223 @@ +package content.area.morytania.braindeath_island + +import content.entity.player.dialogue.Happy +import content.entity.player.dialogue.Neutral +import content.entity.player.dialogue.Quiz +import content.entity.player.dialogue.Shifty +import content.entity.player.dialogue.type.npc +import content.entity.player.dialogue.type.player +import content.quest.questStage +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.client.clearCamera +import world.gregs.voidps.engine.client.moveCamera +import world.gregs.voidps.engine.client.turnCamera +import world.gregs.voidps.engine.entity.character.npc.NPC +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.add +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.engine.inv.remove +import world.gregs.voidps.type.Tile + +class Davey : Script { + init { + npcOperate("Talk-to", "davey") { (target) -> + walkToDelay(tile = Tile(2132, 5099, 1), forceWalk = true) + when (questStage("rum_deal")) { + 13 -> rumDealHelpRequest(target) + 19 -> postQuestGreeting() + else -> defaultGreeting() + } + } + } + + // ===== Default greeting (player hasn't reached the wrench step yet) ===== + + private suspend fun Player.defaultGreeting() { + player("Hello!") + npc("'Ello guv.") + player("Why are you not helping out the others?") + npc( + "Well you see, guv, I've got these unshakable religious convictions.", + ) + player("And those are?") + npc( + "Well, it's like this, see. I used to do a bit of the old priestin' on the side.", + ) + npc( + "You know, collectin' for repairs to the church roof, passing the collection " + + "plate, jumble sales, nicking the lead off the temple roof so they needed " + + "repairing again.", + ) + npc("Real holy stuff, you know.") + player("That doesn't sound all that holy to me.") + npc("Well, that's because you're not a man of the cloth are you?") + npc( + "You are, in fact, what we refer to in layman's terms as a punter.", + ) + npc( + "But anyway, all that priestin' left me with an unshakable faith in three things.", + ) + npc("The power of good over evil.") + npc("The true glory of the human spirit.") + npc( + "And that I ain't goin out there with those things runnin' around.", + ) + } + + // ===== Progress 13: Wrench blessing request ===== + + private suspend fun Player.rumDealHelpRequest(dave: NPC) { + player("You used to be a priest, right?") + npc( + "I didn't nick anything, guv. I've got twenty people who'll swear blind I was...", + ) + player("What?") + player( + "Never mind. I need some help with the spirit in the brewing equipment.", + ) + when { + inventory.contains("holy_wrench") -> { + npc( + "You mean more help? I've already blessed one wrench for you, that should " + + "be enough.", + ) + } + !inventory.contains("wrench") -> { + npc( + "Well, I'll bless something for you if you need it, but just don't spread " + + "it around that I'm giving out freebies.", + ) + } + levels.get(Skill.Prayer) >= 47 -> { + npc( + "Trust me, guv, lots of people need help after coming into contact with " + + "the spirits we produce here.", + ) + askToBlessWrench(dave) + } + else -> { + askToBlessWrench(dave) + } + } + } + + private suspend fun Player.askToBlessWrench(dave: NPC) { + player("Could you bless this wrench for me?") + if (levels.get(Skill.Prayer) >= 47) { + blessTheWrench(dave) + } else { + npc("Nope.") + player("Nope?") + npc("Nope. You don't strike me as being... devout, you know?") + player("Come again?") + npc( + "Your holy aura, it's a little shoddy, if you catch my drift.", + ) + player("What?") + npc("You need a Prayer of 47.") + player("Oh!") + player("Well why didn't you just say so?") + npc( + "I might have had a bit of an overestimation problem guv. I'm sure it won't " + + "happen again.", + ) + } + } + + private suspend fun Player.blessTheWrench(dave: NPC) { + npc("I might well do. Remember, only the first one's free.") + npc("Dominoes Ad Nauseum, Romanes Eunt Domus.") + npc("Sorted.") + player("Is that it?") + npc("Oh, you want the full package deal.") + npc("All right. Brace yourself.") + playBlessingCutscene(dave) + player("Thanks!") + npc("No problem, guv. Good luck with your little problem.") + npc( + "You might find that little wrench worth hanging on to after you're done with the " + + "Spirit.", + ) + player( + "Really? I mean, it's holy and everything, but I don't think it looks all that " + + "useful.", + ) + npc( + "Well, it may not look much, but you'll find that you might need a few less " + + "prayer potions if you have it in your pack, if you know what I mean.", + ) + player( + "Well, no. I don't know what you mean, but I'm sure I'll find out!", + ) + } + + // ===== Progress 19: Post-quest greeting ===== + + private suspend fun Player.postQuestGreeting() { + if (inventory.contains("holy_wrench")) { + npc("Ello again guv. How's things?") + player("Good! Everything seems to have worked out ok.") + npc( + "I'm sure it'll stay that way so long as you don't know it's missing.", + ) + player("What?") + npc("Nothin' guv, you just have a nice day.") + } else { + npc("Ello again guv. I take it you're here about the wrench?") + player("What? The Holy Wrench? But I lost it somewhere...") + npc("Well guv, maybe that's what I want you to think.") + if (inventory.spaces > 0) { + inventory.add("holy_wrench") + npc("There you go. I've kept it nice and holy for you.") + player("Thanks...") + } else { + npc( + "As soon as you've got somewhere to put it I'll hand it over. I'll keep it " + + "nice and holy until then.", + ) + } + } + } + + // ===== Helpers ===== + + private suspend fun Player.playBlessingCutscene(davey: NPC) { + davey.anim("emote_cheer") + inventory.remove("wrench") + inventory.add("holy_wrench") + delay(4) + moveCamera( + tile = Tile(2134, 5099), + height = 180, + speed = 10, + acceleration = 70, + ) + turnCamera( + tile = Tile(2130, 5099), + height = 100, + speed = 10, + acceleration = 70, + ) + delay(2) + moveCamera( + tile = Tile(2135, 5099), + height = 275, + speed = 1, + acceleration = 70, + ) + turnCamera( + tile = Tile(2132, 5099), + height = 200, + speed = 10, + acceleration = 70, + ) + delay(3) + gfx("deal_spotanim") + anim("deal_wrench_flourish") + delay(3) + say("Groovy.") + delay(3) + clearCamera() + } +} diff --git a/game/src/main/kotlin/content/area/morytania/braindeath_island/FeverSpider.kt b/game/src/main/kotlin/content/area/morytania/braindeath_island/FeverSpider.kt index 985dc276b8..c379ea1527 100644 --- a/game/src/main/kotlin/content/area/morytania/braindeath_island/FeverSpider.kt +++ b/game/src/main/kotlin/content/area/morytania/braindeath_island/FeverSpider.kt @@ -1,6 +1,7 @@ package content.area.morytania.braindeath_island import content.entity.combat.hit.damage +import content.skill.prayer.protectMelee import world.gregs.voidps.engine.Script import world.gregs.voidps.engine.entity.character.player.Player import world.gregs.voidps.engine.entity.character.player.equip.equipped @@ -12,7 +13,9 @@ class FeverSpider : Script { npcCondition("slayer_gloves") { it is Player && it.equipped(EquipSlot.Hands).id == "slayer_gloves" } npcCondition("no_slayer_gloves") { it is Player && it.equipped(EquipSlot.Hands).id != "slayer_gloves" } npcAttack("fever_spider", "disease") { - val damage = (it.levels.get(Skill.Constitution) / 100.0) * 12.5 + // Protecting from melee blocks the bite itself but not the disease, which the + // combat definition applies regardless. + val damage = if (it.protectMelee()) 0.0 else (it.levels.get(Skill.Constitution) / 100.0) * 12.5 it.damage(damage.toInt()) } } diff --git a/game/src/main/kotlin/content/area/morytania/braindeath_island/Luke.kt b/game/src/main/kotlin/content/area/morytania/braindeath_island/Luke.kt new file mode 100644 index 0000000000..3fc8fba46e --- /dev/null +++ b/game/src/main/kotlin/content/area/morytania/braindeath_island/Luke.kt @@ -0,0 +1,144 @@ +package content.area.morytania.braindeath_island + +import content.entity.player.dialogue.Angry +import content.entity.player.dialogue.Happy +import content.entity.player.dialogue.Neutral +import content.entity.player.dialogue.Quiz +import content.entity.player.dialogue.Sad +import content.entity.player.dialogue.Shifty +import content.entity.player.dialogue.Shock +import content.entity.player.dialogue.type.ChoiceOption +import content.entity.player.dialogue.type.choice +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.entity.character.player.Player +import world.gregs.voidps.engine.entity.character.player.male + +class Luke : Script { + init { + npcOperate("Talk-to", "50_luke") { (target) -> + player("Are you all right?") + npc("Who goes there?") + npc("Arr! A landlubber!") + npc( + "Begone afore I take my cutlass to ye! I've been charged with guardin' this " + + "gate and no noodle-armed landlubbers will make it past me alive!", + ) + player("That's not a cutlass.") + player("I think it's a twig.") + npc("Ye cheeky begger! I was wavin' my finger at ye!") + player("All right...") + npc( + "Arrr... just 'cos me body happens to be 50% wood does not mean I'm heartless.", + ) + npc("I got a bag of 'em here. Wanna see?") + player("I think I'll pass.") + mainQuestionsChoice() + } + } + + suspend fun Player.mainQuestionsChoice() { + choice { + whatHappened() + howFlammable() + whatsGoingOn() + } + } + + fun ChoiceOption.whatHappened(): Unit = option("What happened to you?") { + npc("Well, it all starts with this albatross...") + npc("Wait, never mind, I'll skip forward a bit.") + player("Are you sure?") + npc( + "I have to lad, Cap'n Donnie will flay what little is left of me if I told ye.", + ) + npc( + "But anyway, I got recruited to the zombie pirates along with the rest of the " + + "crew in an unspecified incident involvin this albatross.", + ) + npc( + "We was sailin' along happily, and I was partakin' of a little 'rum' in the crows " + + "nest.", + ) + npc("Well, we hit either a really rough wave or some rocks.") + npc( + "Twas kind of hard for me to tell which, as I was well out of it by then!", + ) + npc("Regardless I toppled from the crows nest into the water.") + player("Is that how you got so badly injured?") + npc("No ${ladOrLass()}!") + npc( + "What happened next was that I discovered a new, previously uncharted reef of " + + "hard, spiky coral.", + ) + npc( + "I made a mental note of its location, and to this day it is still marked on our " + + "fleet's charts as Lukes Reef.", + ) + npc( + "I managed to grab a hold of our ship, the Inebriated, as it passed overhead.", + ) + npc( + "And then I discovered another, taller, spikier reef of even sharper and more " + + "painful coral.", + ) + npc( + "To this day it is still marked on our fleet's charts as The Other 50% Reef.", + ) + player("Owwwwwwwww...") + npc("It gets worse...") + npc( + "When they hauled what was left of me on deck, they dropped me onto the floor " + + "while they decided what to do with me.", + ) + npc( + "Bear in mind this would be on a ghost ship, the planks of which sweat a thick " + + "mixture of stagnant water...and pure salt crystals.", + ) + player("Oh...my...god...") + npc( + "But on the good side, all my thrashin' and pained squealin' settled the matter in " + + "the Captain's mind, and he had the shipwright carve me half a body out of his " + + "Witchwood Planks.", + ) + player("Witchwood? What's that?") + npc("Tis a special, magical wood from a now extinct tree.") + npc("Once they nailed it all in place the stuff moves like it is part of me body.") + npc("The stuff will also grow back if it breaks, which is dead handy!") + player("Wow, that stuff must be very valuable!") + npc("Arr! That it be!") + npc("So, that be the tale of how I managed to lose precisely 50% of my body.") + player("There there.") + } + + fun ChoiceOption.howFlammable(): Unit = option("How flammable are you?") { + player("So how flammable are you?") + npc("What kind of a question is that?") + player("A rather rude, personal one?") + npc("Ye've got that right!") + } + + fun ChoiceOption.whatsGoingOn(): Unit = option("So what is going on here anyway?") { + npc("Ye expect me to talk?") + player("No Mr. Luke, I expect you to die!") + npc("Hah! I'm one step ahead of ye!") + player("Egad, outsmarted by the man with the wooden brain.") + player("But seriously, what is going on here?") + npc("I can't tell ye lad.") + npc("The Cap'n would have me whittled down to toothpicks if I did.") + player( + "Well if you can't tell me, perhaps you could show me through the medium of " + + "Interpretive Dance?", + ) + npc("No. Just...no.") + player("Mime?") + npc( + "Look, lad I'm not tellin' ye a thing! So clear out while ye still can!", + ) + } + + // ===== Helpers ===== + + private fun Player.ladOrLass(): String = if (male) "lad" else "lass" +} diff --git a/game/src/main/kotlin/content/area/morytania/braindeath_island/ZombieProtester.kt b/game/src/main/kotlin/content/area/morytania/braindeath_island/ZombieProtester.kt index a816d03a72..10cb8b4467 100644 --- a/game/src/main/kotlin/content/area/morytania/braindeath_island/ZombieProtester.kt +++ b/game/src/main/kotlin/content/area/morytania/braindeath_island/ZombieProtester.kt @@ -1,14 +1,19 @@ package content.area.morytania.braindeath_island +import content.entity.player.dialogue.Angry +import content.entity.player.dialogue.Bored import content.entity.player.dialogue.Drunk +import content.entity.player.dialogue.Quiz import content.entity.player.dialogue.Shifty import content.entity.player.dialogue.type.npc import content.entity.player.dialogue.type.player -import content.quest.questCompleted +import content.quest.questStage import world.gregs.voidps.engine.Script -import world.gregs.voidps.engine.entity.character.mode.Follow +import world.gregs.voidps.engine.entity.character.mode.Leash +import world.gregs.voidps.engine.entity.character.player.Player import world.gregs.voidps.engine.timer.Timer import world.gregs.voidps.engine.timer.toTicks +import world.gregs.voidps.type.Tile import world.gregs.voidps.type.random import java.util.concurrent.TimeUnit @@ -16,20 +21,23 @@ class ZombieProtester : Script { init { huntPlayer("zombie_protester*", "spotted") { - if (!tile.within(it.tile, 4)) { + val post: Tile = get("spawn_tile") ?: return@huntPlayer + if (!tile.within(it.tile, 4) || !it.tile.within(post, LEASH_RANGE)) { return@huntPlayer } - softTimers.start("protesting") - mode = Follow(this, it) + softTimers.startIfAbsent("protesting") + // Leash rather than Follow; a protester that chased with Follow would teleport after + // the player the moment they outran it or changed level, and Hunting only re-runs for + // an idle, wandering or patrolling npc so nothing would ever call it back. + mode = Leash(this, it, LEASH_RANGE, post) } npcOperate("Talk-to", "zombie_protester*") { - if (!questCompleted("rum_deal")) { - return@npcOperate + if (questStage("rum_deal") >= 18) { + drunkProtester() + } else { + soberProtester() } - npc("Arrrr! Tis yerself! Have a drink!") - player("Errr...Arrr! I will in a sec, I've just go to, err, plunder some landlubbers...") - npc("Good huntin'!") } npcTimerStart("protesting") { TimeUnit.SECONDS.toTicks(30) } @@ -48,11 +56,33 @@ class ZombieProtester : Script { else -> "Rum, rum, we want rum!" }, ) - if (mode is Follow) { - Timer.CONTINUE - } else { - Timer.CANCEL - } + Timer.CONTINUE } } + + // ===== Pre-Donnie-drinking-swill: Just yelling "Arr!" ===== + + private suspend fun Player.soberProtester() { + player("Excuse me, but...") + npc("Arr!") + player("Is there any way you could...") + npc("ARRR!") + player("Is there anyone else I could...") + npc("Arrrrrrrrrr!!!") + player("Fiiine...") + } + + // ===== Post-Donnie-drinking-swill: They mistake the player for a pirate ===== + + private suspend fun Player.drunkProtester() { + npc("Arrrr! Tis yerself! Have a drink!") + player( + "Errr...Arrr! I will in a sec, I've just go to, err, plunder some landlubbers...", + ) + npc("Good huntin'!") + } + companion object { + /** How far a protester will chase from its post before losing interest. */ + private const val LEASH_RANGE = 10 + } } diff --git a/game/src/main/kotlin/content/area/morytania/braindeath_island/ZombieSwab.kt b/game/src/main/kotlin/content/area/morytania/braindeath_island/ZombieSwab.kt new file mode 100644 index 0000000000..5b897ee3cc --- /dev/null +++ b/game/src/main/kotlin/content/area/morytania/braindeath_island/ZombieSwab.kt @@ -0,0 +1,144 @@ +package content.area.morytania.braindeath_island + +import content.entity.player.dialogue.Angry +import content.entity.player.dialogue.Laugh +import content.entity.player.dialogue.Scared +import content.entity.player.dialogue.type.npc +import content.entity.player.dialogue.type.player +import content.entity.player.dialogue.type.statement +import content.quest.questCompleted +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.client.instruction.handle.interactPlayer +import world.gregs.voidps.engine.client.message +import world.gregs.voidps.engine.client.ui.close +import world.gregs.voidps.engine.client.ui.open +import world.gregs.voidps.engine.data.Settings +import world.gregs.voidps.engine.entity.character.npc.NPC +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.entity.character.player.male + +/** + * The six zombie swabs (2843-2848) guarding the blindweed patch outside the brewery. + * + * Each swab has its own varbit tracking whether the player has intimidated it. Until then + * "Intimidate" runs the heckling routine and the swab attacks on sight; afterwards the swab + * only cowers when talked to. + */ +class ZombieSwab : Script { + + init { + // Swabs use their own hunt mode rather than "cowardly" so aggression can be judged per + // player - the generic handler attacks regardless of how far along the quest is. + huntPlayer("zombie_swab*", "zombie_swab") { target -> + if (!Settings["world.npcs.aggression", true]) { + return@huntPlayer + } + // A swab that has been put in its place won't start anything, and there's nothing + // left to guard once the rum is brewed + if (target.intimidated(this) || target.questCompleted("rum_deal")) { + return@huntPlayer + } + interactPlayer(target, "Attack") + } + + npcOperate("Talk-to", "zombie_swab*") { (target) -> + if (!intimidated(target)) { + return@npcOperate message("I don't think he wants to talk to you.") + } + cower(target) + } + + npcOperate("Intimidate", "zombie_swab*") { (target) -> + if (intimidated(target)) { + return@npcOperate message("I don't think he will be causing any more trouble.") + } + heckle(target) + } + } + + private suspend fun Player.cower(swab: NPC) { + when (swab.number()) { + 1 -> npc("Arr! Do what ye like, just don't hurt my face!") + 2 -> npc("(Gulp) Be there anything ye want, ${sirOrMiss()}?") + 3 -> npc("Be there anything I can say to get out of here undead?") + 4 -> npc("Arrgh! It's ${himOrHer()}! Run fer it lads!") + 5 -> npc("No! Not the flyswatter! I'll be good!") + 6 -> npc("Egad! 'Tis ${himOrHer()}!") + } + } + + private suspend fun Player.heckle(swab: NPC) { + val number = swab.number() + player("Hey you!") + when (number) { + 1 -> npc("Arr! Prepare to die!") + 2 -> npc("Yer a deadman, sez I!") + 3 -> npc("Yer brave, but stupid!") + 4 -> npc("Hahahaha!") + 5 -> npc("Who? Me?") + 6 -> npc("Hey yerself, landlubber!") + } + if (number == 4) { + npc("Yer goin ter die!") + } + statement( + "We interrupt this insult to bring you this soothing picture of a kitten. " + + "Normal service will be resumed shortly. Thank you for your patience.", + clickToContinue = false, + ) + open("rum_deal_censor") + open("total_blackness") + delay(8) + close("rum_deal_censor") + close("total_blackness") + set(swab.varbit(), 1) + anim("emote_angry") + when (number) { + 1 -> { + player( + "...meaning you'll need a dozen Ogres and a team of highly skilled surgeons " + + "to remove them!", + ) + backDown(swab) + } + 2 -> { + player("...leading to the eventual, violent explosion of those organs!") + backDown(swab) + } + 3 -> { + player( + "...and then I'll use a wrench to remove the handles, so you'll see it " + + "every time you cough!", + ) + backDown(swab) + } + 4, 5 -> { + message("He seems to have vanished...you must have scared him off.") + swab.respawn(swab.def["respawn_delay", 50]) + } + 6 -> { + player( + "...so that you will know exactly what time it is due to the horrible pain " + + "in your earlobes!", + ) + backDown(swab) + } + } + } + + private suspend fun Player.backDown(swab: NPC) { + npc("That be the most intimidatin' thing I've ever heard! I'm not messin' with you!") + swab.anim("swab_panic") + } + + private fun Player.intimidated(swab: NPC): Boolean = get(swab.varbit(), 0) == 1 + + /** Swabs are named `zombie_swab` then `zombie_swab_2` through `zombie_swab_6`. */ + private fun NPC.number(): Int = id.substringAfterLast('_').toIntOrNull() ?: 1 + + private fun NPC.varbit(): String = "rum_deal_swab_${'a' + number() - 1}" + + private fun Player.sirOrMiss(): String = if (male) "sir" else "miss" + + private fun Player.himOrHer(): String = if (male) "him" else "her" +} diff --git a/game/src/main/kotlin/content/area/morytania/port_phasmatys/PiratePete.kt b/game/src/main/kotlin/content/area/morytania/port_phasmatys/PiratePete.kt index 33c0ff80a1..7d6dcc6f87 100644 --- a/game/src/main/kotlin/content/area/morytania/port_phasmatys/PiratePete.kt +++ b/game/src/main/kotlin/content/area/morytania/port_phasmatys/PiratePete.kt @@ -12,25 +12,51 @@ import content.entity.player.dialogue.type.choice import content.entity.player.dialogue.type.npc import content.entity.player.dialogue.type.player import content.quest.questCompleted +import content.quest.questStage +import content.quest.startCutscene import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.client.clearCamera +import world.gregs.voidps.engine.client.instruction.handle.interactNpc +import world.gregs.voidps.engine.client.moveCamera +import world.gregs.voidps.engine.client.turnCamera +import world.gregs.voidps.engine.client.ui.dialogue.talkWith import world.gregs.voidps.engine.client.ui.open +import world.gregs.voidps.engine.entity.character.jingle import world.gregs.voidps.engine.entity.character.move.tele import world.gregs.voidps.engine.entity.character.npc.NPC +import world.gregs.voidps.engine.entity.character.npc.NPCs import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.entity.character.player.male +import world.gregs.voidps.engine.entity.character.player.skill.Skill +import world.gregs.voidps.engine.entity.character.player.skill.level.Level.hasMax import world.gregs.voidps.engine.entity.character.sound import world.gregs.voidps.type.Direction +import world.gregs.voidps.type.Region import world.gregs.voidps.type.Tile import world.gregs.voidps.type.random class PiratePete : Script { init { npcOperate("Talk-to", "pirate_pete*") { (target) -> - if (!questCompleted("rum_deal")) { - player("Hello there!") - npc("Mornin'.") - player("Got any quests?") - npc("I may have a quest, but you don't look like you'd be able handle the kind of monsters I have problems with.") - return@npcOperate + when (questStage("rum_deal")) { + 0 -> { + player("Hello there!") + npc("Mornin'.") + if (!questCompleted("zogre_flesh_eaters") || !hasMax(Skill.Slayer, 42)) { + player("Got any quests?") + npc("I may have a quest, but you don't look like you'd be able handle the kind of monsters I have problems with.") + return@npcOperate + } + return@npcOperate offerQuest(target) + } + 1 -> { + player("Had any luck finding someone to slay the demon?") + return@npcOperate askAgain(target) + } + 2 -> { + player("Hello again!") + return@npcOperate greetAgain(target) + } } player("Do I know you?") npc("Yes, you owe me some money.") @@ -49,7 +75,7 @@ class PiratePete : Script { travel(target, Tile(2162, 5114, 1)) } } - option("Not now") { + option("Not now") { player("I'm getting an awful headache talking to you. Any idea why?") npc("No idea whatsoever.") } @@ -81,6 +107,227 @@ class PiratePete : Script { } } + // ===== Progress 0: The sob story ===== + + private suspend fun Player.offerQuest(pete: NPC) { + npc("Hey...you're an adventurer, right?") + player("Yes I am!") + player("Got any quests for me?") + npc("Yeah, I do, as a matter of fact!") + npc("(Ahem.)") + npc("I am a poor, dispossessed nobleman, forced by circumstance to lurk in the middle of nowhere, soliciting help from passers-by.") + npc("You see, my fiendish half-brother has seized my estates and forced me into exile.") + npc("The simple lemon farmers suffer under his tyrannous yoke, and only a brave adventurer can lift his iron boot from the neck of the poor.") + npc("To reclaim my lands I will need to have my family sword returned to me so that I may present it as proof of my rulership.") + npc("Will you help me find my family sword?") + choice { + option("Yes!") { + player("Yes! Your uncorroborated sob story has touched my heart. When do we set off?") + set("rum_deal", "agreed_to_help") + describeBarrelor(pete) + } + option("No.") { + player("No, I don't think I'll help you out this time.") + waitForSomeoneHeroic() + } + } + } + + private suspend fun Player.describeBarrelor(pete: NPC) { + npc("You'll help! Wonderful!") + npc("But, alas, my half brother has a powerful ally, the mighty demon...") + npc("Err...") + npc("Err...Barrelor!") + npc("Yes, the mighty, fearsome, tall, deadly, oaken, round demon Barrelor the Destroyer.") + player("Barrelor?") + npc("That's what I said!") + npc("Barrelor is an awesome opponent, and to reclaim my family sword you will need to defeat him, for he guards it within the deadly Trapped Pit of Barrelor.") + npc("Wanna give it a shot?") + choice { + option("Of course, I fear no demon!") { + player("Of course, I fear no demon!") + set("rum_deal", "slay_barrelor") + offerPayment(pete) + } + option("Not a chance, this sounds too dangerous.") { + player("Not a chance, this sounds too dangerous.") + waitForSomeoneHeroic() + } + } + } + + private suspend fun Player.offerPayment(pete: NPC) { + npc("Atta ${boyOrGirl()}!") + npc("When I am reinstated in my rightful place, I will not be a very wealthy man, as my half-brother has squandered my family fortune.") + npc("However, I will gladly give you every bent penny of what is left, and starve in the gutter with my many adorable children if you say you will help me.") + choice { + option("Nonsense! Keep the money!") { + player("Nonsense! Keep the money! I will dispose of this evil half-brother of yours and leave you what little money is left to feed your family.") + setOff(pete) + } + option("Great, I'll take the cash in used coins please.") { + player("Great, I'll take the cash in used coins please.") + npc("Er...") + waitForSomeoneHeroic() + } + } + } + + // ===== Progress 1: Turned him down, but came back ===== + + private suspend fun Player.askAgain(pete: NPC) { + npc("What?") + npc("Oh! Yes! The demon, with the oppression and stuff!") + npc("No, not yet. Why?") + choice { + option("Because I came to offer to help.") { + player("Because I came to offer to help.") + set("rum_deal", "slay_barrelor") + offerPayment(pete) + } + option("No reason.") { + player("No reason. I was just in the area and I thought I would ask.") + npc("I see...") + waitForSomeoneHeroic() + } + } + } + + // ===== Progress 2: Agreed to fight, hasn't set off yet ===== + + private suspend fun Player.greetAgain(pete: NPC) { + npc("Oh, it's you again.") + npc("So, what brings you down here today?") + choice { + option("I've decided to help you for free.") { + player("I've decided to help you for free! I will dispose of this evil half-brother of yours and leave you what little money is left to feed your family.") + setOff(pete) + } + option("No reason.") { + player("No reason. I was just in the area and I thought I would ask.") + npc("I see...") + waitForSomeoneHeroic() + } + } + } + + private suspend fun Player.waitForSomeoneHeroic() { + npc("Look, I think I'll wait here for someone a little more... you know...") + npc("...heroic.") + } + + private suspend fun Player.setOff(pete: NPC) { + npc("Wonderful! Just pick up your diversion and we'll leave!") + player("What diversion?") + face(Direction.SOUTH) + introCutscene(pete) + } + + /** + * Pete knocks the player out and rows them to Braindeath Island; they come round in the + * brewery just as Pete is reporting back to Captain Braindeath. + */ + private suspend fun Player.introCutscene(pete: NPC) { + delay(2) + say("Ow!") + pete.anim("mace_pummel") + gfx("stun_long", delay = 20) + sound("cudgel", delay = 15) + open("fade_out") + delay(4) + val cutscene = startCutscene("rum_deal_intro", Region(8527)) + cutscene.onEnd { + open("fade_out") + delay(3) + clearCamera() + tele(2144, 5108, 1) + face(Direction.NORTH) + gfx("stun_long") + sound("stunned") + } + tele(cutscene.tile(2142, 5105, 1), clearInterfaces = false) + val braindeath = NPCs.add("captain_braindeath", cutscene.tile(2142, 5111, 1), Direction.NORTH) + val peteCopy = NPCs.add("pirate_pete", cutscene.tile(2148, 5108, 1), Direction.NORTH) + moveCamera(cutscene.tile(2143, 5107), height = 305) + turnCamera(cutscene.tile(2143, 5111), height = 100) + delay(2) + open("fade_in") + jingle("captain_braindeath") + npc("captain_braindeath", "Arrr... 'Tis lookin' bleak...", clickToContinue = false) + overhead(braindeath, "Arrr... 'Tis lookin' bleak...") + + npc("pirate_pete", "Cap'n!", clickToContinue = false) + overhead(peteCopy, "Cap'n!") + + peteCopy.walkTo(cutscene.tile(2144, 5111, 1)) + npc("pirate_pete", "Good news Cap'n!", clickToContinue = false) + overhead(peteCopy, "Good news Cap'n!") + + npc("pirate_pete", "I found us a hero down by the docks!", clickToContinue = false) + overhead(peteCopy, "I found us a hero down", "by the docks!") + + peteCopy.face(braindeath) + braindeath.face(peteCopy) + npc("captain_braindeath", "Be they heroic, brave and true?", clickToContinue = false) + overhead(braindeath, "Be they heroic, brave and", "true?") + + npc("pirate_pete", "Aye! They also be gullible, tied up and unconscious!", clickToContinue = false) + overhead(peteCopy, "Aye! They also be gullible,", "tied up and unconscious!") + + npc( + "pirate_pete", + "They were willing to help out some random stranger with a good enough sob story, " + + "so I smacked them with a bottle and rowed them over.", + clickToContinue = false, + ) + overhead( + peteCopy, + "They were willing to help", + "out some random stranger", + "with a good enough sob", + "story, so I smacked them", + "with a bottle and rowed", + "them over.", + ) + + npc("captain_braindeath", "Brilliant! The island's location will remain a secret!", clickToContinue = false) + overhead(braindeath, "Brilliant! The island's", "location will remain a", "secret!") + + npc("captain_braindeath", "Bring 'em here and wake 'em up.", clickToContinue = false) + overhead(braindeath, "Bring 'em here and wake", "'em up.") + + npc("captain_braindeath", "We may make it through this yet...", clickToContinue = false) + overhead(braindeath, "We may make it through", "this yet...") + + cutscene.end() + + // The original hands straight over to Braindeath once the player comes round, rather than + // leaving them stood in the brewery. onEnd drops them directly south of his spawn. + delay(2) + val captain = NPCs.findOrNull(tile.regionLevel, "captain_braindeath") + if (captain != null) { + talkWith(captain) + interactNpc(captain, "Talk-to") + } + } + + private fun Player.boyOrGirl(): String = if (male) "boy" else "girl" + + /** + * Cutscene speech. The chatbox line carries no continue arrow because the scene is timed + * rather than clicked through, and the speaker repeats it overhead in the same short chunks + * the original splits it into - overhead text is too narrow for a whole line. + */ + private suspend fun Player.overhead(speaker: NPC, vararg chunks: String) { + for ((index, chunk) in chunks.withIndex()) { + if (index > 0) { + delay(3) + } + speaker.say(chunk) + } + delay(3) + } + private suspend fun Player.travel(target: NPC, tile: Tile) { if (random.nextBoolean()) { npc("Err... sure...") diff --git a/game/src/main/kotlin/content/quest/Quest.kt b/game/src/main/kotlin/content/quest/Quest.kt index 297abf5d61..de2a239de8 100644 --- a/game/src/main/kotlin/content/quest/Quest.kt +++ b/game/src/main/kotlin/content/quest/Quest.kt @@ -29,6 +29,7 @@ val quests = setOf( "nature_spirit", "plague_city", "priest_in_peril", + "rum_deal", "lost_city", "tears_of_guthix", "the_golem", diff --git a/game/src/main/kotlin/content/quest/QuestCommands.kt b/game/src/main/kotlin/content/quest/QuestCommands.kt new file mode 100644 index 0000000000..c7566c438f --- /dev/null +++ b/game/src/main/kotlin/content/quest/QuestCommands.kt @@ -0,0 +1,113 @@ +package content.quest + +import content.entity.player.inv.item.addOrDrop +import content.quest.refreshQuestJournal +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.client.command.adminCommand +import world.gregs.voidps.engine.client.command.stringArg +import world.gregs.voidps.engine.client.message +import world.gregs.voidps.engine.data.definition.ItemDefinitions +import world.gregs.voidps.engine.data.definition.QuestDefinitions +import world.gregs.voidps.engine.data.definition.VariableDefinitions +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 +import world.gregs.voidps.engine.get +import world.gregs.voidps.engine.inv.inventory + +/** + * Sets a player up to attempt a quest: skills raised to the requirements listed in `quests.toml`, + * prerequisite quests marked complete, and any items the quest expects the player to bring handed + * over. Requirements come from the quest definition, so this works for every quest that declares + * them; supply-your-own items are read from the optional `req_item_ids` key. + */ +class QuestCommands : Script { + + init { + adminCommand( + "questreset", + stringArg("quest-id", "the quest to reset", autofill = { get().ids.keys }), + desc = "Reset a quest back to unstarted, clearing its variables", + handler = ::reset, + ) + + adminCommand( + "questprep", + stringArg("quest-id", "the quest to prepare for", autofill = { get().ids.keys }), + desc = "Meet a quest's skill, quest and item requirements", + handler = ::prepare, + ) + } + + /** + * Puts a quest back to unstarted. Along with the quest variable itself this clears every + * variable named after it, which is how quest progress flags are named - Rum Deal's counters + * and swab flags are all `rum_deal_*`. Variables a quest shares with a skill (the blindweed + * farming patch, say) aren't named after the quest and are left alone. + */ + private fun reset(player: Player, args: List) { + val questId = args.getOrNull(0) ?: return + val questDefinitions: QuestDefinitions = get() + val quest = questDefinitions.getOrNull(questId) + if (quest == null) { + player.message("No quest found with id '$questId'.") + return + } + + player.clear(questId) + var cleared = 0 + for (variable in VariableDefinitions.definitions.keys) { + if (variable.startsWith("${questId}_") && player.variables.contains(variable)) { + player.clear(variable) + cleared++ + } + } + player.refreshQuestJournal() + player.message("Reset ${quest["name", questId]} to unstarted, clearing $cleared ${if (cleared == 1) "variable" else "variables"}.") + } + + private fun prepare(player: Player, args: List) { + val questId = args.getOrNull(0) ?: return + val questDefinitions: QuestDefinitions = get() + val quest = questDefinitions.getOrNull(questId) + if (quest == null) { + player.message("No quest found with id '$questId'.") + return + } + + val skills = quest["req_skills", emptyMap()] + for ((name, level) in skills) { + val skill = Skill.entries.firstOrNull { it.name == name } + if (skill == null) { + player.message("Unknown skill '$name' in ${quest.stringId} requirements.") + continue + } + if (player.levels.getMax(skill) < level) { + player.experience.set(skill, Level.experience(level)) + } + } + + val quests = quest["req_quests", emptyList()] + for (required in quests) { + player[required] = "completed" + } + + val items = quest["req_item_ids", emptyList()] + var given = 0 + for (item in items) { + if (ItemDefinitions.getOrNull(item) == null) { + player.message("Unknown item '$item' in ${quest.stringId} requirements.") + continue + } + if (!player.inventory.contains(item)) { + player.addOrDrop(item) + given++ + } + } + + player.message( + "Prepared for ${quest["name", questId]}: ${skills.size} skills, " + + "${quests.size} ${if (quests.size == 1) "quest" else "quests"}, $given items.", + ) + } +} diff --git a/game/src/main/kotlin/content/quest/member/rum_deal/RumDeal.kt b/game/src/main/kotlin/content/quest/member/rum_deal/RumDeal.kt new file mode 100644 index 0000000000..ac3aa7e6a3 --- /dev/null +++ b/game/src/main/kotlin/content/quest/member/rum_deal/RumDeal.kt @@ -0,0 +1,827 @@ +package content.quest.member.rum_deal + +import content.entity.combat.killer +import content.entity.player.dialogue.Angry +import content.entity.player.dialogue.Confused +import content.entity.player.dialogue.Mad +import content.entity.player.dialogue.Neutral +import content.entity.player.dialogue.Quiz +import content.entity.player.dialogue.Shifty +import content.entity.player.dialogue.Shock +import content.entity.player.dialogue.type.choice +import content.entity.player.dialogue.type.npc +import content.entity.player.dialogue.type.player +import content.entity.player.dialogue.type.statement +import content.quest.questJournal +import content.quest.questStage +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.client.clearHint +import world.gregs.voidps.engine.client.hint +import world.gregs.voidps.engine.client.instruction.handle.interactPlayer +import world.gregs.voidps.engine.client.message +import world.gregs.voidps.engine.client.ui.dialogue.talkWith +import world.gregs.voidps.engine.entity.character.npc.NPC +import world.gregs.voidps.engine.entity.character.npc.NPCs +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.has +import world.gregs.voidps.engine.entity.character.sound +import world.gregs.voidps.engine.entity.obj.GameObject +import world.gregs.voidps.engine.entity.obj.GameObjects +import world.gregs.voidps.engine.entity.obj.ObjectLayer +import world.gregs.voidps.engine.entity.obj.ObjectShape +import world.gregs.voidps.engine.entity.obj.replace +import world.gregs.voidps.engine.inv.add +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.engine.inv.remove +import world.gregs.voidps.engine.timer.toTicks +import world.gregs.voidps.type.Direction +import world.gregs.voidps.type.Tile +import world.gregs.voidps.type.area.Cuboid +import java.util.concurrent.TimeUnit + +class RumDeal : Script { + + init { + + val zombieSwabs = listOf( + "a" to 2843, + "b" to 2844, + "c" to 2845, + "d" to 2846, + "e" to 2847, + "f" to 2848, + ) + + questJournalOpen("rum_deal") { + val lines = when (val stage = questStage("rum_deal")) { + 0 -> notStartedJournal() + 19 -> completedJournal() + else -> startedJournal(stage) + } + questJournal("Rum Deal", lines) + } + + // ---------- Item-on-NPC: braindeath reactions to weird items ---------- + + itemOnNPCOperate("sluglings", "captain_braindeath") { + player("The slugs are talking to me... They tell me to do bad things...") + npc("Gimme that!") + statement("The Captain takes the Slugling and stamps on it until it stops squirming, then hands it back.") + player("Egad... what just happened?") + npc("What happened is ye were messin' about with Sluglings!") + npc("Don't ye have anything better to do?") + } + + itemOnNPCOperate("karamthulhu", "captain_braindeath") { + npc("Gah! Get that slimy thing away from me!") + } + + itemOnNPCOperate("fever_spider_body", "captain_braindeath") { + npc("What are ye doin', lass? I want ye to shove that in the hopper, not in me face!") + npc("Ye just can't get good help these days...") + } + + // ---------------------------------------------------------------- // + // Luke's gate (10172) — stand-and-distract + // ---------------------------------------------------------------- // + + // The zombies wrecked every patch but one. FarmingPatchInspect only handles `farming_*` + // ids, so these would otherwise fall through to "nothing interesting happens". + objectOperate("Inspect", "blindweed_patch_trashed") { + message("The soil here is too poor to farm on.") + } + + // Nothing advances the quest when the blindweed finishes growing - the original + // leaves progress 4 falling through to Braindeath's generic "So..." reply forever, + // so harvesting the patch is what moves it on. + // Neither the original nor void tells you a crop has finished growing, which leaves the + // player waiting at the patch with no signal. The quest needs the blindweed, so say so. + variableSet("farming_blindweed_patch_braindeath_island") { _, _, to -> + if (to == "blindweed_life1" && questStage("rum_deal") == 4) { + message("I wonder how my Blindweed is coming along...") + } + } + + itemAdded("blindweed", "inventory") { + if (questStage("rum_deal") == 4) { + set("rum_deal", "grown_blindweed") + } + } + + objectOperate("Open", "gate_104_closed") { (target) -> + // Luke wanders his post, so he is looked up by region rather than by tile. + val luke = NPCs.findOrNull(tile.regionLevel, "50_luke") ?: return@objectOperate + + if (tile.y > 5098) { + // Outside the compound - Luke marches the player straight back in + talkWith(luke) + npc("Hey! What are you doing out there?") + player("Nothing.") + npc("Well Cap'n Donnie said no livin' landlubbers were allowed out of the compound.") + npc("So get yerself back in here, or yer for it!") + useGate(target, luke, north = false) + return@objectOperate + } + + // Trying to sneak out + talkWith(luke) + npc("Arr! Tryin' ter get away eh? Well ye'll never sneak past me, I'm the best lookout this crew has ever seen!") + + if (questStage("rum_deal") < 8) { + player("Err...look...an astonishing...thing. There. Behind you.") + npc("Ye'll have te do better than that, landlubber!") + return@objectOperate + } + + // Any of the six diversions works once Braindeath has sent you for water + when ((0..5).random()) { + 0 -> player("Oh my! Is that a genuine 3rd Age Diversion?") + 1 -> player("Who's that making faces behind you?") + 2 -> player("Is that your distraction?") + 3 -> player("Who is that behind you?") + 4 -> player("Hey you! Look over there!") + 5 -> player("That is the most amazing thing I have ever seen!") + } + useGate(target, luke, north = true) + } + + objectOperate("Open", "braindeath_island_tool_cupboard") { (target) -> + anim("human_opencupboard") + sound("cupboard_open") + target.replace("braindeath_island_tool_cupboard_open", ticks = 500) + } + + objectOperate("Shut", "braindeath_island_tool_cupboard_open") { (target) -> + anim("human_opencupboard") + sound("cupboard_close") + target.replace("braindeath_island_tool_cupboard") + } + + objectOperate("Search", "braindeath_island_tool_cupboard_open") { + statement("There is a tangle of rusty old farming equipment in here.") + statement("What would you like to take?") + choice("What would you like to take?") { + option("A rake") { + takeFromCupboard("rake", "rake", "You can't find another rake. Good job you already have one.") + } + option("A seed dibber") { + takeFromCupboard( + "seed_dibber", + "seed dibber", + "The only dibber you find has a bent dibbing bit. You should use the one you have.", + ) + } + option("A watering can") { + takeFromCupboard( + "watering_can", + "watering can", + "You can't find any watering cans without holes in them, so you decide to use your own.", + ) + } + option("All of the above!") { + if (inventory.contains("watering_can") && + inventory.contains("rake") && + inventory.contains("seed_dibber") + ) { + statement("The remaining stuff seems fused together in a lump of spiky, tetanus-inducing rust. Best leave it alone for now.") + } else if (inventory.spaces > 0) { + for (item in listOf("watering_can", "rake", "seed_dibber")) { + if (!inventory.contains(item) && inventory.spaces > 0) { + inventory.add(item) + } + } + statement("You help yourself to what you need.") + } else { + statement("You do not have any free space for all of these items.") + } + } + } + } + + // ---------------------------------------------------------------- // + // Intake hopper (10170) — accepts blindweed / stagnant water / spider + // ---------------------------------------------------------------- // + + itemOnObjectOperate("blindweed,bucket_of_water_stagnant,fever_spider_body", "braindeath_island_intake_hopper") { interaction -> + val progress = questStage("rum_deal") + val id = interaction.item.id + + val matches = (id == "blindweed" && progress == 6) || + (id == "bucket_of_water_stagnant" && progress == 9) || + (id == "fever_spider_body" && progress == 15) + + if (!matches) { + return@itemOnObjectOperate message("Nothing interesting happens.") + } + + when (id) { + "blindweed" -> { + set("rum_deal", "blindweed_added") + delay(1) + message("You stuff the Blindweed into the Hopper.") + anim("net_catch") + inventory.remove("blindweed") + sound("rumdeal_stuff_other") + delay(2) + } + "bucket_of_water_stagnant" -> { + set("rum_deal", "water_added") + delay(1) + message("You dump the water into the hopper.") + anim("farming_pour_water") + inventory.remove("bucket_of_water_stagnant") + inventory.add("bucket") + sound("pour_tea") + delay(5) + } + "fever_spider_body" -> { + set("rum_deal", "spider_added") + delay(1) + message("You cram the diseased Fever Spider body into the hopper.") + anim("net_catch") + inventory.remove("fever_spider_body") + sound("rumdeal_stuff_other") + delay(2) + } + } + } + + // ---------------------------------------------------------------- // + // Pressure barrel (10171) — accepts sluglings / karamthulhu + // ---------------------------------------------------------------- // + + itemOnObjectOperate("sluglings,karamthulhu", "braindeath_island_pressure_barrel") { interaction -> + if (questStage("rum_deal") != 11) { + return@itemOnObjectOperate message("Nothing Interesting Happens") + } + + val slug = interaction.item.id == "sluglings" + val pressureCount = get("rum_deal_pressure_count", 0) + + if (pressureCount == 5) { + val name = if (slug) "Sluglings" else "Karamthulhu" + return@itemOnObjectOperate message("You don't think you can stuff any more $name in there.") + } + + set("rum_deal_pressure_count", pressureCount + 1) + if (slug) { + set("rum_deal_slugling_count", get("rum_deal_slugling_count", 0) + 1) + } else { + set("rum_deal_karamthulhu_count", get("rum_deal_karamthulhu_count", 0) + 1) + } + + val name = if (slug) "Sluglings" else "Karamthulhu" + message("You stuff the squirming $name into the barrel.") + anim("human_pickuptable") + inventory.remove(interaction.item.id) + sound("rumdeal_stuff_slugling") + delay(2) + } + + objectOperate("Count", "braindeath_island_pressure_barrel") { + val slugs = get("rum_deal_slugling_count", 0) + val karam = get("rum_deal_karamthulhu_count", 0) + message( + when { + slugs == 0 && karam == 0 -> "This barrel is empty and smells nasty." + slugs == 1 && karam == 0 -> "There are some Sluglings in this barrel." + slugs == 0 && karam == 1 -> "There is a Karamthulhu in this barrel." + slugs == 1 && karam == 1 -> "There are some Sluglings and a Karamthulhu in this barrel." + else -> "There are $slugs loads of Sluglings and $karam Karamthulhu in this barrel." + }, + ) + } + + // ---------- Pressure lever (10165 / 10166) ---------- + + objectOperate("Pull", "braindeath_island_pressure_lever_up,braindeath_island_pressure_lever_down") { + if (get("rum_deal_pressure_count", 0) != 5) { + return@objectOperate message("You do not yet have five sea creatures in the barrel!") + } + + set("rum_deal_pressure_count", 0) + set("rum_deal_slugling_count", 0) + set("rum_deal_karamthulhu_count", 0) + message("You pressurise the assorted sea creatures.") + sound("rumdeal_pressurize") + delay(2) + set("rum_deal", "pressurised") + set("rum_deal_brewing_control", 1) + GameObjects.findLayerOrNull(Tile(2141, 5102, 2), ObjectLayer.GROUND, "braindeath_island_pressure_barrel") + ?.anim("deal_press_press") + delay(2) + } + + // ---------------------------------------------------------------- // + // Stagnant water collection (10105) + // ---------------------------------------------------------------- // + + itemOnObjectOperate("bucket", "braindeath_island_stagnant_lake") { + if (questStage("rum_deal") == 8) { + set("rum_deal", "collected_water") + } + message("You scoop up a bucket of the stagnant water.") + anim("fill_bucket_slime") // 4471 + inventory.remove("bucket") + inventory.add("bucket_of_water_stagnant") + delay(3) + } + + // ---------------------------------------------------------------- // + // Fishbowl-net combine / uncombine + // ---------------------------------------------------------------- // + + itemOption("Unwrap", "fishbowl_and_net") { + if (inventory.spaces < 1) { + return@itemOption message("You do not have enough space to unwrap the net from the fishbowl.") + } + message("You unwrap the net from the fishbowl.") + inventory.remove("fishbowl_and_net") + inventory.add("fishbowl") + inventory.add("big_fishing_net") + } + + itemOnItem("fishbowl", "big_fishing_net") { _, _ -> + message("You wrap the net around the empty bowl.") + inventory.remove("fishbowl") + inventory.remove("big_fishing_net") + inventory.add("fishbowl_and_net") + } + + // ---------------------------------------------------------------- // + // Sea creature fishing spot (NPC 2859) + // ---------------------------------------------------------------- // + + npcOperate("Fish", "fishing_spot_braindeath_island") { + arriveDelay() + if (questStage("rum_deal") != 11) { + return@npcOperate message("You don't think you have time for fishing.") + } + + if (!has(Skill.Fishing, 50)) { + return@npcOperate message("You cannot fish here, you need a Fishing level of at least 50.") + } + + if (!inventory.contains("fishbowl_and_net")) { + return@npcOperate message("You do not have the correct equipment to fish here.") + } + + if (inventory.spaces < 1) { + return@npcOperate message("You do not have any free space for anything that you will catch!") + } + + message("You dunk the bowl in the water...") + anim("deal_bowl_fish") + sound("rumdeal_bowl_fish", delay = 20) + delay(4) + + if ((0..2).random() == 0) { + message("...and you catch a Karamthulhu!") + inventory.add("karamthulhu") + } else { + message("...and you catch some Sluglings!") + inventory.add("sluglings") + } + delay(1) + } + + // ---------------------------------------------------------------- // + // Output tap (10148) — fill bucket with swill + // ---------------------------------------------------------------- // + + objectOperate("Turn", "braindeath_island_output_tap") { + handleSwillFill(emptyVatMessage = "Nothing interesting happens.") + } + + itemOnObjectOperate("bucket", "braindeath_island_output_tap") { + handleSwillFill(emptyVatMessage = "The vat is empty.") + } + + // ---------------------------------------------------------------- // + // Brewing controls (10143) — smite with holy wrench + // ---------------------------------------------------------------- // + + itemOnObjectOperate("holy_wrench", "braindeath_island_brewing_controls_possessed") { + if (questStage("rum_deal") != 13) { + return@itemOnObjectOperate message("Nothing interesting happens.") + } + + if (evilSpirit() != null) { + return@itemOnObjectOperate message("The Evil Spirit has already manifested!") + } + + message("You raise your wrench on high and smite the controls mightily!") + sound("wrench_crush") + anim("deal_smite_control") + say("The power of Guthix compels you!") + delay(2) + message("The Evil Spirit is forced from the controls!") + sound("rumdeal_hopper_1") + val spirit = NPCs.addRandom( + "evil_spirit", + Cuboid(Tile(2143, 5098, 1), width = 4, height = 4, levels = 1), + ticks = TimeUnit.MINUTES.toTicks(8), + owner = this, + ) + if (spirit != null) { + spirit["hint_index"] = hint(spirit) + spirit.interactPlayer(this, "Attack") + } + } + + npcDeath("evil_spirit") { + val killer = killer as? Player ?: return@npcDeath + killer["rum_deal_brewing_control"] = 0 + if (killer.questStage("rum_deal") == 13) { + killer["rum_deal"] = "spirit_banished" + } + // Keep the arrow's slot on the spirit; clearHint() with no index wipes every + // hint the player has, including arrows belonging to other content. + val arrow: Int = get("hint_index", -1) + if (arrow != -1) { + killer.clearHint(arrow) + } + killer.message("You have banished the Evil Spirit!") + } + } + + // ---------------------------------------------------------------------- + // Helpers + // ---------------------------------------------------------------------- + + /** Slips the player through Luke's gate, [north] out of the compound or south back into it. */ + private suspend fun Player.useGate( + target: GameObject, + luke: NPC, + north: Boolean, + ) { + if (north) { + luke.face(Direction.SOUTH) + luke.say("Where?") + } + sound("barrows_door_open") + target.replace( + "inviswall", + shape = ObjectShape.WALL_STRAIGHT, + rotation = target.rotation, + ticks = 2, + ) + // The open gate is a wall piece; without the shape it defaults to a centrepiece, + // lands on the wrong layer and renders nothing. + GameObjects.add( + id = "gate_104_opened", + tile = Tile(2120, 5099, 0), + shape = ObjectShape.WALL_STRAIGHT, + rotation = 0, + ticks = 2, + ) + walkTo( + target = Tile(this.tile.x, this.tile.y + if (north) 1 else -1, this.tile.level), + forceWalk = true, + noCollision = true, + ) + delay(2) + } + + /** [name] is the readable name; the item id would otherwise be shown to the player verbatim. */ + private suspend fun Player.takeFromCupboard(item: String, name: String, alreadyHave: String) { + if (inventory.contains(item)) { + statement(alreadyHave) + return + } + if (inventory.spaces < 1) { + statement("You do not have any free space for the $name.") + return + } + inventory.add(item) + statement("You take an old $name.") + } + + private fun Player.evilSpirit(): NPC? = NPCs.at(tile.regionLevel).firstOrNull { it.id == "evil_spirit" && it["owner", ""] == accountName } + + private fun Player.handleSwillFill(emptyVatMessage: String) { + val progress = questStage("rum_deal") + + if (progress >= 18) { + return message("You thankfully have no need to do that any more.") + } + if (progress != 17) { + return message(emptyVatMessage) + } + if (!inventory.contains("bucket")) { + return message("Nothing interesting happens.") + } + + inventory.remove("bucket") + inventory.add("unsanitary_swill") + message("You carefully fill the bucket with the foul-smelling swill.") + } + + // ---------------------------------------------------------------------- + // Journal + // ---------------------------------------------------------------------- + + /** The patch variable only names a `blindweed_*` state once the seeds are in the ground. */ + private fun Player.blindweedPlanted(): Boolean = get("farming_blindweed_patch_braindeath_island", "weeds_0").startsWith("blindweed") + + private fun Player.notStartedJournal(): List { + // Java: checks Farming/Fishing/Prayer/Crafting/Slayer levels and Zogre Flesh Eaters completion. + val farming = levels.getMax(Skill.Farming) >= 40 + val fishing = levels.getMax(Skill.Fishing) >= 50 + val prayer = levels.getMax(Skill.Prayer) >= 47 + val crafting = levels.getMax(Skill.Crafting) >= 42 + val slayer = levels.getMax(Skill.Slayer) >= 42 + val zogre = get("zogre_flesh_eaters", "") == "completed" + + fun line(done: Boolean, text: String) = if (done) "$text" else "$text" + + return listOf( + "I need to speak to Pirate Pete in Port Phasmatys.", + "", + "To complete this quest I need:", + line(farming, "40 Farming"), + line(fishing, "50 Fishing"), + line(prayer, "47 Prayer"), + line(crafting, "42 Crafting"), + line(slayer, "42 Slayer"), + line(zogre, "I must have completed Zogre Flesh Eaters"), + "To be able to defeat a level 150 Monster", + ) + } + + /** + * The original's journal is cumulative - completing the quest strikes through everything that + * came before and appends the closing lines, rather than replacing it with a summary. + */ + private fun Player.completedJournal(): List = startedJournal(19) + listOf( + "", + "I have spoken to Captain Braindeath, who has decided to", + "stay on the island and keep the Zombie Pirates drunk!", + "Despite claiming he has never heard of Rabid Jack he was", + "more than happy with my performance, and rewarded me", + "with Farming, Fishing and Prayer experience, as well as the", + "Blessed Wrench.", + "", + "QUEST COMPLETE!", + ) + + private fun Player.startedJournal(stage: Int): List { + val list = mutableListOf() + + // Stage 1+ + if (stage < 2) { + list += "I have spoken to Pirate Pete, and I have agreed to help him" + list += "find his Family Sword." + } else { + list += "I have spoken to Pirate Pete, and have agreed to help him" + list += "find his Family Sword." + } + + // Stage 2+ + if (stage in 2..2) { + list += "" + list += "I have agreed to help Pirate Pete slay Barrelor the" + list += "Destroyer so that he can reclaim his lands." + } else if (stage > 2) { + list += "I have agreed to help Pirate Pete slay Barrelor the" + list += "Destroyer so that he can reclaim his lands." + } + + // Stage 3+ + if (stage in 3..3) { + list += "" + list += "I have a splitting headache. Captain Braindeath has" + list += "explained the situation to me, and I have agreed to help" + list += "brew up a batch of 'rum'." + } else if (stage > 3) { + list += "I have a splitting headache. Captain Braindeath has" + list += "explained the situation to me, and I have agreed to help" + list += "brew up a batch of 'rum'." + } + + // Stage 4+ + if (stage in 4..4) { + list += "" + list += "I have been given some Blindweed Seeds, and told to grow" + list += "them in the Herb Patch outside. Captain Braindeath" + list += "recommended that I try and intimidate the Swabs guarding" + list += "the Herb Patch if I want them to stop attacking me." + + if (blindweedPlanted()) { + list += "" + list += "I have planted my Blindweed Seeds, and should wait for" + list += "them to grow." + } + } else if (stage > 4) { + list += "I have been given some Blindweed Seeds, and told to grow" + list += "them in the Herb Patch outside. Captain Braindeath" + list += "recommended that I try and intimidate the Swabs guarding" + list += "the Herb Patch if I want them to stop attacking me." + list += "I have planted my Blindweed Seeds, and should wait for" + list += "them to grow." + } + + // Stage 5 + if (stage == 5) { + list += "" + list += "I have grown some Blindweed, I should show it to Captain" + list += "Braindeath so that he can tell me what to do with it." + + if (!inventory.contains("blindweed")) { + list += "" + list += "Having lost my Blindweed I should go and see Captain" + list += "Braindeath, he may have some kept to one side for use in" + list += "an emergency." + } + } else if (stage > 5) { + list += "I have grown some Blindweed, I should show it to Captain" + list += "Braindeath so that he can tell me what to do with it." + } + + // Stage 6 + if (stage in 6..6) { + list += "" + list += "I have shown my Blindweed to Captain Braindeath. He told" + list += "me to put it into the Intake Hopper." + } else if (stage > 6) { + list += "I have shown my Blindweed to Captain Braindeath. He told" + list += "me to put it into the Intake Hopper." + } + + // Stage 7 + if (stage in 7..7) { + list += "" + list += "I have added the Blindweed to the Intake Hopper. I should" + list += "ask Captain Braindeath what he wants me to do next." + } else if (stage > 7) { + list += "I have added the Blindweed to the hopper. I should ask" + list += "Captain Braindeath what he wants me to do next." + } + + // Stage 8 + if (stage in 8..8) { + list += "" + list += "Captain Braindeath has told me to fetch a bucket of" + list += "Stagnant Water from the Stagnant Lake near the top of" + list += "the Volcano to the North." + } else if (stage > 8) { + list += "Captain Braindeath has told me to fetch a bucket of" + list += "Stagnant Water from the Stagnant Lake near the top of" + list += "the Volcano to the North." + } + + // Stage 9 + if (stage in 9..9) { + list += "" + list += "Using superior cunning and stealth I have managed to exit" + list += "the Brewery compound and have collected a bucket of" + list += "Stagnant Water." + + if (!inventory.contains("bucket_of_water_stagnant")) { + list += "" + list += "Due to my inferior ability to hold objects I have lost my" + list += "Bucket of Stagnant Water. I should go and see Captain" + list += "Braindeath to see what he wants me to do about this" + list += "situation." + } + } else if (stage > 9) { + list += "Using superior cunning and stealth I have managed to exit" + list += "the Brewery compound and have collected a bucket of" + list += "Stagnant Water." + } + + // Stage 10 + if (stage in 10..10) { + list += "" + list += "I have poured the Stagnant Water into the Intake Hopper. I" + list += "should speak to Captain Braindeath again to see what else" + list += "he needs." + } else if (stage > 10) { + list += "I have poured the Stagnant Water into the Intake Hopper. I" + list += "should speak to Captain Braindeath again to see what else" + list += "he needs." + } + + // Stage 11 + if (stage in 11..11) { + list += "" + list += "Captain Braindeath has told me to fish with a Fishbowl and" + list += "Big Net in the Squid Fishing Spot. Whatever 5 Sea" + list += "Creatures I dredge up are to be put into a barrel and" + list += "pressurised." + } else if (stage > 11) { + list += "Captain Braindeath has told me to fish with a Fishbowl and" + list += "Big Net in the Squid Fishing Spot. Whatever 5 Sea" + list += "Creatures I dredge up are to be put into a barrel and" + list += "pressurised." + } + + // Stage 12 + if (stage in 12..12) { + list += "" + list += "I have pressurised the Assorted Sea Creatures in the" + list += "Pressure Barrel. I should see Captain Braindeath to check" + list += "if this is all he needs me to do." + } else if (stage > 12) { + list += "I have pressurised the Assorted Sea Creatures in the" + list += "Pressure Barrel. I should see Captain Braindeath to check" + list += "if this is all he needs me to do." + } + + // Stage 13 + if (stage in 13..13) { + list += "" + list += "Apparently the Brewing Equipment has become possessed." + list += "Captain Braindeath has given me a Wrench and told me to" + list += "sort it out. I should check amongst the Brewers to see" + list += "which of them can bless it for me." + list += "" + + if (inventory.contains("holy_wrench")) { + list += "I am now the proud owner of a Holy Wrench. I will take up" + list += "this Holy Wrench and strike the Brewing Controls until they" + list += "are very, very sorry." + } else if (!inventory.contains("wrench")) { + list += "I have just realised that I will have a hard time getting my" + list += "Wrench blessed now that I have thrown it away. I think I" + list += "should see Captain Braindeath to get a replacement" + list += "before the situation deteriorates further." + } + } else if (stage > 13) { + list += "Apparently the Brewing Equipment has become possessed." + list += "Captain Braindeath has given me a Wrench and told me to" + list += "sort it out. I should check amongst the Brewers to see" + list += "which of them can bless it for me." + list += "I am now the proud owner of a Holy Wrench. I will take up" + list += "this Holy Wrench and strike the Brewing Controls until they" + list += "are very, very sorry." + } + + // Stage 14 + if (stage in 14..14) { + list += "" + list += "I have banished the Evil Spirit. Captain Braindeath told me" + list += "there is one more ingredient left. I should ask him what" + list += "this is." + } else if (stage > 14) { + list += "I have banished the Evil Spirit. Captain Braindeath told me" + list += "there is one more ingredient left. I should ask him what" + list += "this is." + } + + // Stage 15 + if (stage in 15..15) { + list += "" + list += "Captain Braindeath wants me to kill a Fever Spider, and put" + list += "its Body into the Intake Hopper. He told me to wear Slayer" + list += "Gloves (Available from the Slayer Masters apparently)" + list += "while I did so, as they carry a nasty disease. Mental Note;" + list += "do not drink whatever comes out of that machine." + } else if (stage > 15) { + list += "Captain Braindeath wants me to kill a Fever Spider, and put" + list += "its Body into the Intake Hopper. He told me to wear Slayer" + list += "Gloves (Available from the Slayer Masters apparently)" + list += "while I did so, as they carry a nasty disease. Mental Note;" + list += "do not drink whatever comes out of that machine." + } + + // Stage 16 + if (stage in 16..16) { + list += "" + list += "I have placed the Fever Spider Body in the Hopper. Captain" + list += "Braindeath will be able to produce some 'rum' when I tell" + list += "him." + } else if (stage > 16) { + list += "I have placed the Fever Spider Body in the Hopper. Captain" + list += "Braindeath will be able to produce some 'rum' when I tell" + list += "him." + } + + // Stage 17 + if (stage in 17..17) { + list += "" + list += "Captain Braindeath has filled the Output Vat with 'rum'. I" + list += "should take a bucket of the stuff to Captain Donnie and" + list += "give it to him." + } else if (stage > 17) { + list += "Captain Braindeath has filled the Output Vat with 'rum'. I" + list += "should take a bucket of the stuff to Captain Donnie and" + list += "give it to him." + } + + // Stage 18 + if (stage in 18..18) { + list += "" + list += "I have spoken to Captain Donnie, and have found out the" + list += "name of his boss, Rabid Jack. I should tell Captain" + list += "Braindeath about this." + } else if (stage > 18) { + list += "I have spoken to Captain Donnie, and have found out the" + list += "name of his boss, Rabid Jack. I should tell Captain" + list += "Braindeath about this." + } + + return list + } +} diff --git a/game/src/main/kotlin/content/skill/farming/Farming.kt b/game/src/main/kotlin/content/skill/farming/Farming.kt index f78c00b20d..1a69238de3 100644 --- a/game/src/main/kotlin/content/skill/farming/Farming.kt +++ b/game/src/main/kotlin/content/skill/farming/Farming.kt @@ -40,7 +40,7 @@ class Farming( if (!contains("farming_offset_mins")) { set("farming_offset_mins", random.nextInt(0, 30)) } - if (contains("last_growth_cycle")) { + if (contains("last_growth_cycle") || hasCrop()) { timers.start("farming_tick", true) } } @@ -61,6 +61,23 @@ class Farming( } } + /** + * A patch with something already in the ground needs the growth timer even when it has never + * run. Only raking and composting start it, so a crop planted into an already clear patch would + * otherwise sit unfinished forever. + */ + private fun Player.hasCrop(): Boolean { + for (patches in FarmingPatches.patches.values) { + for (variable in patches) { + val value: String = this[variable] ?: continue + if (!value.startsWith("weeds")) { + return true + } + } + } + return false + } + fun grow(player: Player, minute: Int) { if (minute.rem(Settings["farming.decompose.mins", 2]) == 0) { growCompost(player) @@ -113,7 +130,11 @@ class Farming( continue } val stage = type.toInt() - val next = (stage + 1).rem(4) + // Stage 3 is fully overgrown - weeds stop there rather than wrapping back to clear + if (stage >= 3) { + continue + } + val next = stage + 1 player[variable] = when (next) { 3 -> if (variable.contains("farming_veg_")) { "weeds_${ diff --git a/game/src/main/kotlin/content/skill/farming/FarmingPatchPlant.kt b/game/src/main/kotlin/content/skill/farming/FarmingPatchPlant.kt index cb0ec39c37..50846a037d 100644 --- a/game/src/main/kotlin/content/skill/farming/FarmingPatchPlant.kt +++ b/game/src/main/kotlin/content/skill/farming/FarmingPatchPlant.kt @@ -80,15 +80,15 @@ class FarmingPatchPlant : Script { player.message("You need a seed dibber to plant the seed in the dirt.") // TODO proper message return } - if (!player.inventory.remove(item.id, amount)) { - player.message("You need $amount ${item.def.name.plural(amount)} to grow those.") - return - } val level = item.def["farming_level", 1] if (!player.has(Skill.Farming, level)) { player.statement("You need to be a level $level to plant that.") return } + if (!player.inventory.remove(item.id, amount)) { + player.message("You need $amount ${item.def.name.plural(amount)} to grow those.") + return + } if (patchName.contains("tree")) { player.anim("human_dig") player.sound("dig_spade") @@ -100,6 +100,9 @@ class FarmingPatchPlant : Script { player.message("You plant ${if (amount == 1) "a" else amount} ${item.def.name.lowercase().plural(amount)} in the $patchName.", type = ChatType.Filter) val crop: String = item.def.getOrNull("farming_crop") ?: return player[variable] = "${crop}_0" + // Growth is otherwise only kicked off by raking or composting, which leaves a crop + // planted into an already clear patch never growing at all. + player.timers.startIfAbsent("farming_tick") player.exp(Skill.Farming, item.def["farming_xp", 0.0]) } diff --git a/game/src/main/kotlin/content/skill/farming/FarmingPatchTreat.kt b/game/src/main/kotlin/content/skill/farming/FarmingPatchTreat.kt index af7f0e2df9..e99d6a3b09 100644 --- a/game/src/main/kotlin/content/skill/farming/FarmingPatchTreat.kt +++ b/game/src/main/kotlin/content/skill/farming/FarmingPatchTreat.kt @@ -11,6 +11,7 @@ import world.gregs.voidps.engine.entity.character.player.skill.Skill import world.gregs.voidps.engine.entity.character.player.skill.exp.exp import world.gregs.voidps.engine.entity.character.player.skill.level.Level import world.gregs.voidps.engine.entity.character.sound +import world.gregs.voidps.engine.entity.obj.GameObject import world.gregs.voidps.engine.inv.* import world.gregs.voidps.engine.queue.weakQueue @@ -18,6 +19,7 @@ class FarmingPatchTreat : Script { init { objectOperate("Rake", "*_patch_weeds_*", handler = ::rake) + itemOnObjectOperate("rake", "*", handler = ::rakeWith) itemOnObjectOperate("compost,supercompost", "*", handler = ::compost) itemOnObjectOperate("watering_can_*", "*", handler = ::water) } @@ -84,7 +86,22 @@ class FarmingPatchTreat : Script { player[target.id] = value.replaceFirst("_", "_watered_") } - private fun rake(player: Player, interact: PlayerOnObjectInteract, count: Int = 3) { + private fun rake(player: Player, interact: PlayerOnObjectInteract) = rake(player, interact.target) + + /** + * Not every patch carries a Rake option - the Rum Deal blindweed patch has none - so the rake + * can be used on the patch directly instead. + */ + private fun rakeWith(player: Player, interact: ItemOnObjectInteract) { + val target = interact.target + if (!target.id.startsWith("farming_")) { + player.noInterest() + return + } + rake(player, target) + } + + private fun rake(player: Player, obj: GameObject, count: Int = 3) { if (count <= 0) { return } @@ -92,8 +109,8 @@ class FarmingPatchTreat : Script { player.message("You need a rake to weed a farming patch") return } - val obj = interact.target - if (player[obj.id, "weeds_life3"] == "weeds_0") { + val value = player[obj.id, "weeds_life3"] + if (!value.startsWith("weeds") || value == "weeds_0") { player.message("This ${obj.patchName()} doesn't need weeding right now.") return } @@ -104,6 +121,8 @@ class FarmingPatchTreat : Script { "weeds_life3" -> "weeds_2" "weeds_life2" -> "weeds_1" "weeds_life1" -> "weeds_0" + // Allotments use weeds_life* for a fully overgrown patch, everything else weeds_3 + "weeds_3" -> "weeds_2" "weeds_2" -> "weeds_1" "weeds_1" -> "weeds_0" else -> return@weakQueue @@ -113,9 +132,9 @@ class FarmingPatchTreat : Script { player.addOrDrop("weeds") player.timers.startIfAbsent("farming_tick") player.exp(Skill.Farming, 8.0) - rake(player, interact, count - 1) + rake(player, obj, count - 1) } else { - rake(player, interact, count) + rake(player, obj, count) } } } diff --git a/game/src/main/kotlin/content/skill/farming/FarmingPatches.kt b/game/src/main/kotlin/content/skill/farming/FarmingPatches.kt index df7328cbe1..51cb4738f5 100644 --- a/game/src/main/kotlin/content/skill/farming/FarmingPatches.kt +++ b/game/src/main/kotlin/content/skill/farming/FarmingPatches.kt @@ -20,6 +20,7 @@ object FarmingPatches { "farming_flower_patch_ardougne", "patch_herblore_habitat_vine_flower", "farming_flower_patch_morytania", + "farming_blindweed_patch_braindeath_island", ), 2 to listOf( // allotments diff --git a/game/src/test/kotlin/content/area/morytania/braindeath_island/FeverSpiderTest.kt b/game/src/test/kotlin/content/area/morytania/braindeath_island/FeverSpiderTest.kt new file mode 100644 index 0000000000..77483a10c7 --- /dev/null +++ b/game/src/test/kotlin/content/area/morytania/braindeath_island/FeverSpiderTest.kt @@ -0,0 +1,36 @@ +package content.area.morytania.braindeath_island + +import WorldTest +import content.skill.prayer.PrayerConfigs +import content.skill.prayer.protectMelee +import org.junit.jupiter.api.Test +import world.gregs.voidps.type.Tile +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * Fever spiders bite through bare hands for heavy damage and always inflict disease. Protecting + * from melee stops the bite but not the disease, matching the original's FeverSpiderScript. + */ +class FeverSpiderTest : WorldTest() { + + @Test + fun `Protecting from melee stops the fever spider's bite`() { + val player = createPlayer(Tile(2140, 5100)) + + assertFalse(player.protectMelee(), "no protection prayer by default") + + player.addVarbit(PrayerConfigs.ACTIVE_PRAYERS, "protect_from_melee") + + assertTrue(player.protectMelee(), "protect from melee should register") + } + + @Test + fun `Deflect melee also counts as melee protection`() { + val player = createPlayer(Tile(2140, 5100)) + player[PrayerConfigs.PRAYERS] = "curses" + player.addVarbit(PrayerConfigs.ACTIVE_CURSES, "deflect_melee") + + assertTrue(player.protectMelee(), "deflect melee should register") + } +} diff --git a/game/src/test/kotlin/content/area/morytania/braindeath_island/ZombieProtesterTest.kt b/game/src/test/kotlin/content/area/morytania/braindeath_island/ZombieProtesterTest.kt new file mode 100644 index 0000000000..66b6cf599c --- /dev/null +++ b/game/src/test/kotlin/content/area/morytania/braindeath_island/ZombieProtesterTest.kt @@ -0,0 +1,66 @@ +package content.area.morytania.braindeath_island + +import WorldTest +import org.junit.jupiter.api.Test +import world.gregs.voidps.engine.entity.character.mode.Leash +import world.gregs.voidps.engine.entity.character.move.tele +import world.gregs.voidps.type.Tile +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * Protesters heckle and chase whoever comes near, but only as far as their post. Follow has no + * range limit of its own - it teleports after a target that outruns it or changes level - and + * Hunting won't reconsider an npc that is already following, so protesters chase on a [Leash] + * instead and lose interest the moment the player leaves their patch of the island. + */ +class ZombieProtesterTest : WorldTest() { + + @Test + fun `Zombie protesters chase a nearby player`() { + val player = createPlayer(Tile(2145, 5088)) + val protester = createNPC("zombie_protester", Tile(2145, 5087)) + + tickIf(limit = 20) { protester.mode !is Leash } + + assertTrue(protester.mode is Leash, "a protester should chase a player who walks up to it") + } + + @Test + fun `Zombie protesters give up once dragged away from their post`() { + val player = createPlayer(Tile(2145, 5088)) + val protester = createNPC("zombie_protester", Tile(2145, 5087)) + val post = protester.tile + + tickIf(limit = 20) { protester.mode !is Leash } + assertTrue(protester.mode is Leash, "the protester should be chasing to begin with") + + // Lead it away across the island; past the leash it should break off + player.tele(2160, 5070) + tickIf(limit = 100) { protester.mode is Leash } + + assertFalse(protester.mode is Leash, "the protester should give up the chase") + assertTrue(protester.tile.within(post, 10), "and stay on its own patch rather than teleporting after the player") + + // and then make its own way back to the protest + tickIf(limit = 400) { !protester.tile.within(post, 8) } + assertTrue(protester.tile.within(post, 8), "and return to its post") + } + + @Test + fun `Zombie protesters never leave their post`() { + val player = createPlayer(Tile(2145, 5088)) + val protester = createNPC("zombie_protester", Tile(2145, 5087)) + val post = protester.tile + + tickIf(limit = 20) { protester.mode !is Leash } + + // Walk the length of the island a step at a time; the protester tails then drops off + repeat(40) { + player.tele(player.tile.addY(1)) + tick() + assertTrue(protester.tile.within(post, 10), "a protester should never chase beyond its post") + } + assertFalse(protester.mode is Leash, "and should have lost interest long before the end") + } +} diff --git a/game/src/test/kotlin/content/area/morytania/braindeath_island/ZombieSwabTest.kt b/game/src/test/kotlin/content/area/morytania/braindeath_island/ZombieSwabTest.kt new file mode 100644 index 0000000000..6ca8bcc990 --- /dev/null +++ b/game/src/test/kotlin/content/area/morytania/braindeath_island/ZombieSwabTest.kt @@ -0,0 +1,45 @@ +package content.area.morytania.braindeath_island + +import WorldTest +import org.junit.jupiter.api.Test +import world.gregs.voidps.engine.entity.character.mode.combat.CombatMovement +import world.gregs.voidps.type.Tile +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * Swabs guard the blindweed patch and attack anyone who comes near, until they are either + * intimidated into backing off or the quest is finished and there is nothing left to guard. + */ +class ZombieSwabTest : WorldTest() { + + @Test + fun `Zombie swabs attack a nearby player during the quest`() { + createPlayer(Tile(2152, 5074)) + val swab = createNPC("zombie_swab", Tile(2152, 5073)) + + tickIf(limit = 20) { swab.mode !is CombatMovement } + + assertTrue(swab.mode is CombatMovement, "a swab should attack a player who walks up to it") + } + + @Test + fun `An intimidated zombie swab stops attacking`() { + createPlayer(Tile(2152, 5074)) { it["rum_deal_swab_a"] = 1 } + val swab = createNPC("zombie_swab", Tile(2152, 5073)) + + tick(20) + + assertFalse(swab.mode is CombatMovement, "a swab that has been heckled should back off") + } + + @Test + fun `Zombie swabs ignore a player who has completed Rum Deal`() { + createPlayer(Tile(2152, 5074)) { it["rum_deal"] = "completed" } + val swab = createNPC("zombie_swab", Tile(2152, 5073)) + + tick(20) + + assertFalse(swab.mode is CombatMovement, "a swab should leave a player who finished the quest alone") + } +} diff --git a/game/src/test/kotlin/content/quest/QuestCommandsTest.kt b/game/src/test/kotlin/content/quest/QuestCommandsTest.kt new file mode 100644 index 0000000000..8d9b180e9a --- /dev/null +++ b/game/src/test/kotlin/content/quest/QuestCommandsTest.kt @@ -0,0 +1,94 @@ +package content.quest + +import WorldTest +import containsMessage +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test +import world.gregs.voidps.engine.client.command.Commands +import world.gregs.voidps.engine.entity.character.player.PlayerRights +import world.gregs.voidps.engine.entity.character.player.rights +import world.gregs.voidps.engine.entity.character.player.skill.Skill +import world.gregs.voidps.engine.entity.character.player.skill.level.Level +import world.gregs.voidps.engine.inv.inventory +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class QuestCommandsTest : WorldTest() { + + @Test + fun `Questprep meets a quest's skill, quest and item requirements`() { + val admin = createPlayer(name = "prep admin") + admin.rights = PlayerRights.Admin + + runTest { Commands.call(admin, "questprep rum_deal") } + tick() + + // req_skills from quests.toml + assertEquals(40, admin.levels.getMax(Skill.Farming)) + assertEquals(50, admin.levels.getMax(Skill.Fishing)) + assertEquals(47, admin.levels.getMax(Skill.Prayer)) + assertEquals(42, admin.levels.getMax(Skill.Crafting)) + assertEquals(42, admin.levels.getMax(Skill.Slayer)) + + // req_quests + assertTrue(admin.questCompleted("zogre_flesh_eaters")) + + // req_item_ids - everything else is handed out during the quest + assertTrue(admin.inventory.contains("slayer_gloves")) + assertTrue(admin.inventory.contains("spade")) + } + + @Test + fun `Questprep never lowers a skill the player has already trained`() { + val admin = createPlayer(name = "prep admin2") + admin.rights = PlayerRights.Admin + admin.experience.set(Skill.Fishing, Level.experience(80)) + + runTest { Commands.call(admin, "questprep rum_deal") } + tick() + + assertEquals(80, admin.levels.getMax(Skill.Fishing)) + } + + @Test + fun `Questprep reports an unknown quest`() { + val admin = createPlayer(name = "prep admin3") + admin.rights = PlayerRights.Admin + + runTest { Commands.call(admin, "questprep not_a_quest") } + tick() + + assertTrue(admin.containsMessage("No quest found with id 'not_a_quest'.")) + } + + @Test + fun `Questreset puts the quest back to unstarted and clears its variables`() { + val admin = createPlayer(name = "reset admin") + admin.rights = PlayerRights.Admin + admin["rum_deal"] = "collect_swill" + admin["rum_deal_pressure_count"] = 5 + admin["rum_deal_slugling_count"] = 3 + admin["rum_deal_swab_a"] = 1 + admin["rum_deal_brewing_control"] = 2 + + runTest { Commands.call(admin, "questreset rum_deal") } + tick() + + assertEquals("unstarted", admin["rum_deal", "unstarted"]) + assertEquals(0, admin["rum_deal_pressure_count", 0]) + assertEquals(0, admin["rum_deal_slugling_count", 0]) + assertEquals(0, admin["rum_deal_swab_a", 0]) + assertEquals(0, admin["rum_deal_brewing_control", 0]) + } + + @Test + fun `Questreset reports an unknown quest`() { + val admin = createPlayer(name = "reset admin2") + admin.rights = PlayerRights.Admin + + runTest { Commands.call(admin, "questreset not_a_quest") } + tick() + + assertTrue(admin.containsMessage("No quest found with id 'not_a_quest'.")) + } +} diff --git a/game/src/test/kotlin/content/quest/member/rum_deal/BraindeathSpawnTest.kt b/game/src/test/kotlin/content/quest/member/rum_deal/BraindeathSpawnTest.kt new file mode 100644 index 0000000000..7c47fa57a2 --- /dev/null +++ b/game/src/test/kotlin/content/quest/member/rum_deal/BraindeathSpawnTest.kt @@ -0,0 +1,47 @@ +package content.quest.member.rum_deal + +import WorldTest +import dialogueContinue +import npcOption +import org.junit.jupiter.api.Test +import world.gregs.voidps.engine.client.ui.dialogue +import world.gregs.voidps.engine.entity.character.npc.NPCs +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.type.Tile +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** + * Talks to the Captain Braindeath that spawns from braindeath_island.npc-spawns.toml, rather than + * one created by the test, so the real definition and spawn tile are exercised. + */ +class BraindeathSpawnTest : WorldTest() { + + override var loadNpcs: Boolean = true + + @Test + fun `The spawned Captain Braindeath talks at the stagnant water stage`() { + val player = createPlayer(Tile(2144, 5108, 1)) + player["rum_deal"] = "blindweed_added" + tick(2) + + val braindeath = NPCs.findOrNull(Tile(2144, 5109, 1), "captain_braindeath") + assertNotNull(braindeath, "Captain Braindeath should be spawned at 2144, 5109, 1") + + player.npcOption(braindeath, "Talk-to") + tick(3) + + assertNotNull(player.dialogue, "talking to the spawned Braindeath should open a dialogue") + + var steps = 0 + while (player.dialogue != null && steps < 40) { + player.dialogueContinue() + steps++ + tick() + } + + assertEquals("fetch_water", player["rum_deal", "unstarted"], "the conversation should send the player for stagnant water") + assertTrue(player.inventory.contains("bucket"), "Braindeath hands over a bucket") + } +} diff --git a/game/src/test/kotlin/content/quest/member/rum_deal/IntroCutsceneTest.kt b/game/src/test/kotlin/content/quest/member/rum_deal/IntroCutsceneTest.kt new file mode 100644 index 0000000000..eb942c59dc --- /dev/null +++ b/game/src/test/kotlin/content/quest/member/rum_deal/IntroCutsceneTest.kt @@ -0,0 +1,54 @@ +package content.quest.member.rum_deal + +import WorldTest +import dialogueContinue +import dialogueOption +import npcOption +import org.junit.jupiter.api.Test +import skipDialogues +import world.gregs.voidps.engine.client.ui.dialogue +import world.gregs.voidps.engine.entity.character.player.skill.Skill +import world.gregs.voidps.engine.entity.character.player.skill.level.Level +import world.gregs.voidps.type.Tile +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + +/** + * Pirate Pete's intro knocks the player out and drops them in the brewery, where Captain + * Braindeath's conversation should pick up on its own. + */ +class IntroCutsceneTest : WorldTest() { + + override var loadNpcs: Boolean = true + + @Test + fun `The intro cutscene hands over to Captain Braindeath`() { + val player = createPlayer(Tile(3672, 3538)) + player["zogre_flesh_eaters"] = "completed" + player.experience.set(Skill.Slayer, Level.experience(42)) + player["rum_deal"] = "slay_barrelor" + val pete = createNPC("pirate_pete", Tile(3673, 3538)) + tick(2) + + player.npcOption(pete, "Talk-to") + tick(2) + player.skipDialogues() + player.dialogueOption("line1") // I've decided to help you for free. + + // Ride out the knockout, the cutscene and the hand-over + var steps = 0 + while (steps < 200) { + tick() + steps++ + if (player.dialogue != null) { + player.dialogueContinue() + } + if (player["rum_deal", ""] != "slay_barrelor") { + break + } + } + + assertEquals(Tile(2144, 5108, 1), player.tile, "the player should wake in the brewery") + assertNotNull(player.dialogue, "Braindeath's conversation should start on its own") + } +} diff --git a/game/src/test/kotlin/content/quest/member/rum_deal/RumDealTest.kt b/game/src/test/kotlin/content/quest/member/rum_deal/RumDealTest.kt new file mode 100644 index 0000000000..02fe62d40b --- /dev/null +++ b/game/src/test/kotlin/content/quest/member/rum_deal/RumDealTest.kt @@ -0,0 +1,587 @@ +package content.quest.member.rum_deal + +import WorldTest +import containsMessage +import content.entity.combat.hit.damage +import content.skill.farming.Farming +import dialogueContinue +import dialogueOption +import itemOnObject +import npcOption +import objectOption +import org.junit.jupiter.api.Test +import skipDialogues +import world.gregs.voidps.engine.client.ui.dialogue +import world.gregs.voidps.engine.data.definition.CombatDefinitions +import world.gregs.voidps.engine.entity.character.npc.NPCs +import world.gregs.voidps.engine.entity.character.player.skill.Skill +import world.gregs.voidps.engine.entity.character.player.skill.level.Level +import world.gregs.voidps.engine.entity.obj.GameObjects +import world.gregs.voidps.engine.entity.obj.ObjectLayer +import world.gregs.voidps.engine.get +import world.gregs.voidps.engine.inv.add +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.type.Tile +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Braindeath Island object locations: + * blindweed patch (2162, 5069, 0) tool cupboard (2157, 5093, 0) + * gate (2120, 5098, 0) stagnant lake (2134, 5162, 0) + * output tap (2142, 5093, 1) brewing control (2143, 5100, 1) + * intake hopper (2142, 5102, 2) pressure barrel (2141, 5102, 2) + * pressure lever (2141, 5103, 2) + */ +class RumDealTest : WorldTest() { + + @Test + fun `Pirate Pete talks the player into hunting Barrelor the Destroyer`() { + val player = createPlayer(Tile(3672, 3538)) + player["zogre_flesh_eaters"] = "completed" + player.experience.set(Skill.Slayer, Level.experience(42)) + val pete = createNPC("pirate_pete", Tile(3673, 3538)) + + player.npcOption(pete, "Talk-to") + tick(2) + player.skipDialogues() // greeting through "Will you help me find my family sword?" + player.dialogueOption("line1") // Yes! + player.dialogueContinue() + assertEquals("agreed_to_help", player["rum_deal", "unstarted"]) + + player.skipDialogues() // the Barrelor build-up + player.dialogueOption("line1") // Of course, I fear no demon! + player.dialogueContinue() + assertEquals("slay_barrelor", player["rum_deal", "unstarted"]) + } + + @Test + fun `Pirate Pete turns the player away without Zogre Flesh Eaters`() { + val player = createPlayer(Tile(3672, 3538)) + val pete = createNPC("pirate_pete", Tile(3673, 3538)) + + player.npcOption(pete, "Talk-to") + tick(2) + player.skipDialogues() + assertEquals("unstarted", player["rum_deal", "unstarted"]) + } + + @Test + fun `The tool cupboard hands out farming equipment`() { + val player = createPlayer(Tile(2159, 5092)) + val cupboard = GameObjects.find(Tile(2157, 5093), "braindeath_island_tool_cupboard") + tick(2) + + player.objectOption(cupboard, "Open") + tick(4) + val open = GameObjects.find(Tile(2157, 5093), "braindeath_island_tool_cupboard_open") + player.objectOption(open, "Search") + tick(2) + player.skipDialogues() + player.dialogueOption("line4") // All of the above! + tick(2) + + assertTrue(player.inventory.contains("rake")) + assertTrue(player.inventory.contains("seed_dibber")) + assertTrue(player.inventory.contains("watering_can")) + } + + @Test + fun `Blindweed goes into the intake hopper`() { + val player = createPlayer(Tile(2142, 5103, 2)) + player["rum_deal"] = "show_blindweed" + player.inventory.add("blindweed") + val hopper = GameObjects.find(Tile(2142, 5102, 2), "braindeath_island_intake_hopper") + + player.itemOnObject(hopper, player.inventory.indexOf("blindweed")) + tick(4) + + assertEquals("blindweed_added", player["rum_deal", "unstarted"]) + assertTrue(player.containsMessage("You stuff the Blindweed into the Hopper.")) + assertEquals(0, player.inventory.count("blindweed")) + } + + @Test + fun `Stagnant water is scooped into an empty bucket`() { + val player = createPlayer(Tile(2134, 5161)) + player["rum_deal"] = "fetch_water" + player.inventory.add("bucket") + val lake = GameObjects.find(Tile(2134, 5162), "braindeath_island_stagnant_lake") + + player.itemOnObject(lake, player.inventory.indexOf("bucket")) + tick(5) + + assertEquals("collected_water", player["rum_deal", "unstarted"]) + assertTrue(player.inventory.contains("bucket_of_water_stagnant")) + } + + @Test + fun `Sea creatures are stuffed into the barrel and pressurised`() { + val player = createPlayer(Tile(2140, 5102, 2)) + player["rum_deal"] = "catch_creatures" + player.inventory.add("sluglings", 3) + player.inventory.add("karamthulhu", 2) + val barrel = GameObjects.find(Tile(2141, 5102, 2), "braindeath_island_pressure_barrel") + tick(2) + + repeat(3) { + player.itemOnObject(barrel, player.inventory.indexOf("sluglings")) + tick(5) + } + repeat(2) { + player.itemOnObject(barrel, player.inventory.indexOf("karamthulhu")) + tick(5) + } + assertEquals(5, player["rum_deal_pressure_count", 0]) + + player.objectOption(barrel, "Count") + tick(2) + assertTrue(player.containsMessage("There are 3 loads of Sluglings and 2 Karamthulhu in this barrel.")) + + // Count 5 transforms the lever to its ready state + val lever = GameObjects.find(Tile(2141, 5103, 2), "braindeath_island_pressure_lever_multi") + player.objectOption(lever, "Pull") + tick(6) + + assertEquals("pressurised", player["rum_deal", "unstarted"]) + assertEquals(0, player["rum_deal_pressure_count", 0]) + assertEquals(1, player["rum_deal_brewing_control", 0]) + } + + @Test + fun `The lever refuses to pressurise fewer than five creatures`() { + val player = createPlayer(Tile(2142, 5103, 2)) + player["rum_deal"] = "catch_creatures" + player["rum_deal_pressure_count"] = 3 + val lever = GameObjects.find(Tile(2141, 5103, 2), "braindeath_island_pressure_lever_multi") + + player.objectOption(lever, "Pull") + tick(2) + + assertTrue(player.containsMessage("You do not yet have five sea creatures in the barrel!")) + assertEquals("catch_creatures", player["rum_deal", "unstarted"]) + } + + @Test + fun `Netting the fishing spot needs level 50 and the tangled bowl`() { + val player = createPlayer(Tile(2160, 5100)) + player["rum_deal"] = "catch_creatures" + val spot = createNPC("fishing_spot_braindeath_island", Tile(2161, 5100)) + + player.npcOption(spot, "Fish") + tick(2) + assertTrue(player.containsMessage("You cannot fish here, you need a Fishing level of at least 50.")) + + player.experience.set(Skill.Fishing, Level.experience(50)) + player.npcOption(spot, "Fish") + tick(2) + assertTrue(player.containsMessage("You do not have the correct equipment to fish here.")) + + player.inventory.add("fishbowl_and_net") + player.npcOption(spot, "Fish") + tick(8) + assertTrue(player.inventory.contains("sluglings") || player.inventory.contains("karamthulhu")) + } + + @Test + fun `Smiting the brewing controls forces out the Evil Spirit`() { + val player = createPlayer(Tile(2143, 5101, 1)) + player["rum_deal"] = "bless_wrench" + player["rum_deal_brewing_control"] = 1 + player.inventory.add("holy_wrench") + val controls = GameObjects.find(Tile(2143, 5100, 1), "braindeath_island_brewing_controls_multi") + + player.itemOnObject(controls, player.inventory.indexOf("holy_wrench")) + tick(8) + + assertTrue(player.containsMessage("The Evil Spirit is forced from the controls!")) + val spirit = NPCs.at(player.tile.regionLevel).firstOrNull { it.id == "evil_spirit" } + assertNotNull(spirit, "the Evil Spirit should have manifested") + + // Same rules as the Ghosts Ahoy lobster: owned, temporary, hint-marked and attacking + assertEquals(player.accountName, spirit["owner", ""], "the spirit belongs to its summoner") + assertTrue(player.viewport!!.hints.any { it != 0 }, "a hint arrow should mark the spirit") + } + + @Test + fun `The output tap only pours once the vat is full`() { + val player = createPlayer(Tile(2142, 5092, 1)) + player["rum_deal"] = "spider_added" + player.inventory.add("bucket") + val tap = GameObjects.find(Tile(2142, 5093, 1), "braindeath_island_output_tap") + + player.itemOnObject(tap, player.inventory.indexOf("bucket")) + tick(4) + assertTrue(player.containsMessage("The vat is empty.")) + assertNull(player.inventory.indexOf("unsanitary_swill").takeIf { it >= 0 }) + + player["rum_deal"] = "collect_swill" + player.itemOnObject(tap, player.inventory.indexOf("bucket")) + tick(2) + assertTrue(player.inventory.contains("unsanitary_swill")) + } + + @Test + fun `A zombie swab can only be talked to once intimidated`() { + val player = createPlayer(Tile(2160, 5070)) + val swab = createNPC("zombie_swab", Tile(2161, 5070)) + + player.npcOption(swab, "Talk-to") + tick(2) + assertTrue(player.containsMessage("I don't think he wants to talk to you.")) + + player["rum_deal_swab_a"] = 1 + player.npcOption(swab, "Talk-to") + tick(2) + player.dialogueContinue() + + player.npcOption(swab, "Intimidate") + tick(2) + assertTrue(player.containsMessage("I don't think he will be causing any more trouble.")) + } + + @Test + fun `Each brewer has their own dialogue`() { + val player = createPlayer(Tile(2150, 5093, 1)) + val first = createNPC("brewer", Tile(2149, 5093, 1)) + val fourth = createNPC("brewer_4", Tile(2151, 5093, 1)) + tick(2) + + // A brewer whose id did not match would end the dialogue right after the opener + player.npcOption(first, "Talk-to") + tick(2) + player.dialogueContinue() // "So... how are you holding up?" + assertNotNull(player.dialogue, "brewer should have its own reply") + player.skipDialogues() + + player.npcOption(fourth, "Talk-to") + tick(2) + player.dialogueContinue() + assertNotNull(player.dialogue, "brewer_4 should have its own reply") + } + + @Test + fun `Luke sees through a diversion before Braindeath sends you for water`() { + val player = createPlayer(Tile(2120, 5098)) + createNPC("50_luke", Tile(2120, 5096)) + player["rum_deal"] = "blindweed_added" // stage 7, one short of the water errand + val gate = GameObjects.find(Tile(2120, 5098), "gate_104_closed") + tick(2) + + player.objectOption(gate, "Open") + tick(2) + player.skipDialogues() + tick(4) + + assertEquals(Tile(2120, 5098), player.tile, "Luke should not let the player through") + } + + @Test + fun `A diversion gets the player out through Luke's gate`() { + val player = createPlayer(Tile(2120, 5098)) + createNPC("50_luke", Tile(2120, 5096)) + player["rum_deal"] = "fetch_water" // stage 8 unlocks the diversions + val gate = GameObjects.find(Tile(2120, 5098), "gate_104_closed") + tick(2) + + player.objectOption(gate, "Open") + tick(2) + player.skipDialogues() + tick(6) + + assertEquals(Tile(2120, 5099), player.tile, "the player should slip north past Luke") + } + + @Test + fun `Luke marches the player back into the compound`() { + val player = createPlayer(Tile(2120, 5099)) + createNPC("50_luke", Tile(2120, 5096)) + player["rum_deal"] = "fetch_water" + val gate = GameObjects.find(Tile(2120, 5098), "gate_104_closed") + tick(2) + + player.objectOption(gate, "Open") + tick(2) + player.skipDialogues() + tick(6) + + assertEquals(Tile(2120, 5098), player.tile, "the player should be sent back south") + } + + @Test + fun `Luke tells the tale of his missing half`() { + val player = createPlayer(Tile(2120, 5097)) + val luke = createNPC("50_luke", Tile(2120, 5096)) + tick(2) + + player.npcOption(luke, "Talk-to") + tick(2) + player.skipDialogues() + player.dialogueOption("line1") // What happened to you? + player.dialogueContinue() + assertNotNull(player.dialogue, "the albatross story should follow") + player.skipDialogues() + } + + @Test + fun `The Evil Spirit fights with its own animations and sounds`() { + val definition = get().getOrNull("evil_spirit") + assertNotNull(definition, "the Evil Spirit needs a combat definition to fight back") + + assertEquals("spirit_parry", definition.defendAnim) + assertEquals("spirit_death", definition.deathAnim) + assertEquals("deal_spirit_attack", definition.defendSound?.id) + assertEquals("rumdeal_spirit_depart", definition.deathSound?.id) + + val melee = definition.attacks["melee"] + assertNotNull(melee, "the Evil Spirit needs a melee attack") + assertEquals("spirit_attack", melee.anim) + val hit = melee.targetHits.first() + assertEquals("slash", hit.offense) + assertEquals(280, hit.max) + } + + @Test + fun `Blindweed seeds can be planted and harvested in the Braindeath patch`() { + val player = createPlayer(Tile(2161, 5069)) + player.experience.set(Skill.Farming, Level.experience(40)) + player.inventory.add("seed_dibber") + player.inventory.add("spade") + player.inventory.add("blindweed_seed") + val patch = GameObjects.find(Tile(2162, 5069), "farming_blindweed_patch_braindeath_island") + tick(2) + + player["farming_blindweed_patch_braindeath_island"] = "weeds_0" + + player.itemOnObject(patch, player.inventory.indexOf("blindweed_seed")) + tick(6) + assertEquals( + "blindweed_0", + player["farming_blindweed_patch_braindeath_island", ""], + "the seed should be in the ground", + ) + assertEquals(0, player.inventory.count("blindweed_seed")) + assertTrue( + player.timers.contains("farming_tick"), + "planting must start the growth timer for a patch that was already clear", + ) + + // Skip the growth tick + player["farming_blindweed_patch_braindeath_island"] = "blindweed_life1" + player.objectOption(patch, "Pick") + tick(6) + + assertEquals(1, player.inventory.count("blindweed"), "picking should yield one blindweed") + } + + @Test + fun `Blindweed reaches full growth in a single farming cycle`() { + val player = createPlayer(Tile(2161, 5069)) + val farming = scripts.filterIsInstance().first() + player["farming_blindweed_patch_braindeath_island"] = "blindweed_0" + + // Blindweed is a 2-stage crop on a 5 minute tick, so one cycle finishes it + farming.grow(player, 5) + assertEquals("blindweed_life1", player["farming_blindweed_patch_braindeath_island", ""]) + + // life1 is the last stage; further cycles must not advance or kill it + farming.grow(player, 5) + farming.grow(player, 10) + assertEquals("blindweed_life1", player["farming_blindweed_patch_braindeath_island", ""]) + } + + @Test + fun `Planting below the farming level keeps the seed`() { + val player = createPlayer(Tile(2161, 5069)) + player.experience.set(Skill.Farming, Level.experience(39)) + player.inventory.add("seed_dibber") + player.inventory.add("blindweed_seed") + player["farming_blindweed_patch_braindeath_island"] = "weeds_0" + val patch = GameObjects.find(Tile(2162, 5069), "farming_blindweed_patch_braindeath_island") + tick(2) + + player.itemOnObject(patch, player.inventory.indexOf("blindweed_seed")) + tick(4) + + assertEquals(1, player.inventory.count("blindweed_seed"), "the seed should survive a failed level check") + assertEquals("weeds_0", player["farming_blindweed_patch_braindeath_island", ""]) + } + + @Test + fun `Harvesting the blindweed advances the quest`() { + val player = createPlayer(Tile(2161, 5069)) + player.experience.set(Skill.Farming, Level.experience(40)) + player.inventory.add("spade") + player["rum_deal"] = "given_seeds" + player["farming_blindweed_patch_braindeath_island"] = "blindweed_life1" + val patch = GameObjects.find(Tile(2162, 5069), "farming_blindweed_patch_braindeath_island") + tick(2) + + player.objectOption(patch, "Pick") + tick(6) + + assertTrue(player.inventory.contains("blindweed")) + assertEquals( + "grown_blindweed", + player["rum_deal", "unstarted"], + "picking the blindweed is what moves the quest off given_seeds", + ) + } + + @Test + fun `A crop planted before the timer fix resumes growing on login`() { + // Simulates a save from before planting started the timer: seed in the ground, + // no last_growth_cycle because the growth tick had never run. + val player = createPlayer(Tile(2161, 5069)) { + it["farming_blindweed_patch_braindeath_island"] = "blindweed_0" + } + + assertTrue( + player.timers.contains("farming_tick"), + "a crop already in the ground must resume growing on login", + ) + } + + @Test + fun `Login does not start the growth timer for a player with nothing planted`() { + val player = createPlayer(Tile(2161, 5069)) + + assertTrue( + !player.timers.contains("farming_tick"), + "players who have never farmed should not tick every minute", + ) + } + + @Test + fun `The player is told when the blindweed finishes growing`() { + val player = createPlayer(Tile(2161, 5069)) + player["rum_deal"] = "given_seeds" + player["farming_blindweed_patch_braindeath_island"] = "blindweed_0" + val farming = scripts.filterIsInstance().first() + + farming.grow(player, 5) + + assertEquals("blindweed_life1", player["farming_blindweed_patch_braindeath_island", ""]) + assertTrue(player.containsMessage("I wonder how my Blindweed is coming along...")) + } + + @Test + fun `Opening Luke's gate swaps in the open gate model`() { + val player = createPlayer(Tile(2120, 5098)) + createNPC("50_luke", Tile(2120, 5096)) + player["rum_deal"] = "fetch_water" + val gate = GameObjects.find(Tile(2120, 5098), "gate_104_closed") + tick(2) + + player.objectOption(gate, "Open") + tick(2) + player.skipDialogues() + tick() + + // The closed gate is swapped for an invisible wall and the open gate appears alongside it, + // both on the wall layer - a centrepiece here would render nothing. + assertNotNull( + GameObjects.findLayerOrNull(Tile(2120, 5098), ObjectLayer.WALL, "inviswall"), + "the closed gate should be replaced by an invisible wall", + ) + assertNotNull( + GameObjects.findLayerOrNull(Tile(2120, 5099), ObjectLayer.WALL, "gate_104_opened"), + "the open gate should render on the wall layer", + ) + } + + @Test + fun `Weeds regrow on the blindweed patch and stop when fully overgrown`() { + val player = createPlayer(Tile(2161, 5069)) + val farming = scripts.filterIsInstance().first() + player["farming_blindweed_patch_braindeath_island"] = "weeds_0" + + // weeds_3 is the varbit default, so a fully overgrown patch stores no value of its own + for ((cycle, expected) in listOf("weeds_1", "weeds_2", "weeds_3").withIndex()) { + farming.grow(player, 5 * (cycle + 1)) + assertEquals(expected, player["farming_blindweed_patch_braindeath_island", "weeds_3"]) + } + + // weeds_3 is fully overgrown - further cycles must not wrap it back around to clear + farming.grow(player, 20) + farming.grow(player, 25) + assertEquals("weeds_3", player["farming_blindweed_patch_braindeath_island", "weeds_3"]) + } + + @Test + fun `Blindweed patch can be raked clear with a rake`() { + val player = createPlayer(Tile(2161, 5069)) + player.inventory.add("rake") + player["farming_blindweed_patch_braindeath_island"] = "weeds_3" + val patch = GameObjects.find(Tile(2162, 5069), "farming_blindweed_patch_braindeath_island") + tick(2) + + // These weed states have no Rake option, so the rake is used on the patch instead + player.itemOnObject(patch, player.inventory.indexOf("rake")) + tick(20) + + assertEquals("weeds_0", player["farming_blindweed_patch_braindeath_island", ""]) + assertEquals(3, player.inventory.count("weeds")) + } + + @Test + fun `Blindweed yields a single harvest and empties the patch`() { + val player = createPlayer(Tile(2161, 5069)) + player.experience.set(Skill.Farming, Level.experience(40)) + player.inventory.add("spade") + player["farming_blindweed_patch_braindeath_island"] = "blindweed_life1" + val patch = GameObjects.find(Tile(2162, 5069), "farming_blindweed_patch_braindeath_island") + tick(2) + + player.objectOption(patch, "Pick") + tick(20) + + // blindweed_life1 is the only life state, so picking it exhausts the patch + assertEquals(1, player.inventory.count("blindweed"), "blindweed has a single life") + assertEquals("weeds_0", player["farming_blindweed_patch_braindeath_island", ""]) + } + + @Test + fun `Banishing the Evil Spirit clears only its own hint arrow`() { + val player = createPlayer(Tile(2143, 5101, 1)) + player["rum_deal"] = "bless_wrench" + player["rum_deal_brewing_control"] = 1 + player.inventory.add("holy_wrench") + + // An arrow belonging to some other content, which must survive. Written straight into + // the viewport because Player.hint always allocates slot 0 - see the note below. + val otherArrow = 3 + player.viewport!!.hints[otherArrow] = 1 + + val controls = GameObjects.find(Tile(2143, 5100, 1), "braindeath_island_brewing_controls_multi") + player.itemOnObject(controls, player.inventory.indexOf("holy_wrench")) + tick(6) + + val spirit = NPCs.at(player.tile.regionLevel).first { it.id == "evil_spirit" } + val spiritArrow: Int = spirit["hint_index", -1] + assertTrue(spiritArrow != -1, "the spirit should record its arrow slot") + assertTrue(player.viewport!!.hints[spiritArrow] != 0) + + spirit.damage(5000, source = player) + tick(6) + + assertEquals(0, player.viewport!!.hints[spiritArrow], "the spirit's arrow should be gone") + assertTrue(player.viewport!!.hints[otherArrow] != 0, "other content's arrow must survive") + // Note: Player.hint uses firstOrNull instead of indexOfFirst, so it always returns slot 0 + // and cannot currently allocate a second arrow. Clearing by index is still correct. + } + + @Test + fun `Inspecting a trashed patch says the soil is ruined`() { + val player = createPlayer(Tile(2142, 5070)) + val trashed = GameObjects.find(Tile(2142, 5069), "blindweed_patch_trashed") + tick(2) + + player.objectOption(trashed, "Inspect") + tick(2) + + assertTrue(player.containsMessage("The soil here is too poor to farm on.")) + } +} diff --git a/game/src/test/kotlin/content/quest/member/rum_deal/ZombieDialogueTest.kt b/game/src/test/kotlin/content/quest/member/rum_deal/ZombieDialogueTest.kt new file mode 100644 index 0000000000..99c12e1739 --- /dev/null +++ b/game/src/test/kotlin/content/quest/member/rum_deal/ZombieDialogueTest.kt @@ -0,0 +1,27 @@ +package content.quest.member.rum_deal + +import WorldTest +import org.junit.jupiter.api.Test +import world.gregs.voidps.engine.data.definition.NPCDefinitions +import kotlin.test.assertEquals + +/** + * Luke, the zombie crew and Captain Donnie use the old-man chathead set, matching the OLD_* emotes the + * original uses for them. The `_2`..`_6` variants inherit it through `clone`. + */ +class ZombieDialogueTest : WorldTest() { + + @Test + fun `Luke, the zombies and Captain Donnie use the old chathead set`() { + val expected = buildList { + add("captain_donnie") + add("50_luke") + for (i in 1..6) add(if (i == 1) "zombie_protester" else "zombie_protester_$i") + for (i in 1..6) add(if (i == 1) "zombie_swab" else "zombie_swab_$i") + for (i in 1..6) add(if (i == 1) "zombie_pirate" else "zombie_pirate_$i") + } + for (id in expected) { + assertEquals("old", NPCDefinitions.get(id)["dialogue", ""], "$id should use the old chathead set") + } + } +} diff --git a/game/src/test/kotlin/content/skill/farming/FarmingTest.kt b/game/src/test/kotlin/content/skill/farming/FarmingTest.kt index 38f4d0b40b..2b9f1aeb85 100644 --- a/game/src/test/kotlin/content/skill/farming/FarmingTest.kt +++ b/game/src/test/kotlin/content/skill/farming/FarmingTest.kt @@ -94,7 +94,7 @@ class FarmingTest : KoinMock() { setRandom(object : FakeRandom() { override fun nextInt(until: Int): Int = 0 }) - player["farming_veg_patch_falador_nw"] = "weeds_3" + player["farming_veg_patch_falador_nw"] = "weeds_0" player["farming_offset_mins"] = 0 // simulate several growth cycles From 7ba79ccab3b1e282dba397f95c47a91a0e2e4e8f Mon Sep 17 00:00:00 2001 From: Alexander Date: Sun, 30 Aug 2026 18:31:58 -0400 Subject: [PATCH 02/10] Fix hint arrow packet and slot allocation Clearing a hint arrow could take the client down. HINT_ARROW is a fixed twelve byte packet, but the encoder wrote the payload only when a sprite was given, so a clear sent two bytes and the client read the packets behind it as the rest of that one. Every branch now fills the packet, matching what the client expects for a type 0 hint. Player.hint picked its slot with firstOrNull { it == 0 }, which returns the matched element rather than its position - so every arrow went into slot zero, the other seven were never used, and clearing by index cleared whatever happened to be there. It now uses indexOfFirst, and reuses the first slot rather than dropping the arrow when all eight are taken. Slots are only handed back explicitly and most content never did, so an arrow on an npc that died or timed out cost that player a slot for the rest of the session. NPC.markHint records the slot on the npc and NPCHints releases it when the npc despawns, which covers dying; barrows brothers, the Ghosts Ahoy lobster, dungeoneering marks and the Rum Deal Evil Spirit all mark through it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01K22iXYZjnQnTr3ZUtbuGBG --- .../voidps/engine/client/EncodeExtensions.kt | 18 +++++- .../kotlin/content/entity/npc/NPCHints.kt | 53 +++++++++++++++++ .../content/minigame/barrows/BarrowsCrypts.kt | 3 +- .../quest/member/ghosts_ahoy/GhostsAhoy.kt | 4 +- .../content/quest/member/rum_deal/RumDeal.kt | 14 ++--- .../skill/dungeoneering/DungeonMark.kt | 5 +- .../kotlin/content/entity/npc/NPCHintsTest.kt | 58 +++++++++++++++++++ .../quest/member/rum_deal/RumDealTest.kt | 18 +++--- .../login/protocol/encode/HintEncoder.kt | 26 +++++++-- .../network/login/protocol/EncodersTest.kt | 3 + .../src/test/resources/encoder-packets.csv | 1 + 11 files changed, 170 insertions(+), 33 deletions(-) create mode 100644 game/src/main/kotlin/content/entity/npc/NPCHints.kt create mode 100644 game/src/test/kotlin/content/entity/npc/NPCHintsTest.kt diff --git a/engine/src/main/kotlin/world/gregs/voidps/engine/client/EncodeExtensions.kt b/engine/src/main/kotlin/world/gregs/voidps/engine/client/EncodeExtensions.kt index 9b1c282650..ccd11814e6 100644 --- a/engine/src/main/kotlin/world/gregs/voidps/engine/client/EncodeExtensions.kt +++ b/engine/src/main/kotlin/world/gregs/voidps/engine/client/EncodeExtensions.kt @@ -1,6 +1,7 @@ package world.gregs.voidps.engine.client import world.gregs.voidps.engine.client.ui.chat.Colours +import world.gregs.voidps.engine.client.update.view.Viewport import world.gregs.voidps.engine.data.definition.ClientScriptDefinitions import world.gregs.voidps.engine.data.definition.FontDefinitions import world.gregs.voidps.engine.entity.character.Character @@ -211,12 +212,25 @@ fun Player.minimap(vararg states: Minimap) { fun Player.clearMinimap() = client?.sendMinimapState(0) ?: Unit +/** + * The slot the next hint arrow goes in. Slots are only freed by [clearHint], so content that + * forgets to clear one leaks it; rather than dropping the arrow entirely once all eight are taken, + * the first slot is reused. + */ +private fun Viewport.freeHint(): Int { + val index = hints.indexOfFirst { it == 0 } + if (index == -1) { + return 0 + } + return index +} + /** * Add an [arrow] hint to a [tile] with [radius] */ fun Player.hint(tile: Tile, radius: Int = 1, arrow: Int = HintArrow.FILLED, direction: Direction = Direction.NONE, height: Int = 0): Int { val viewport = viewport ?: return -1 - val index = viewport.hints.firstOrNull { it == 0 } ?: return -1 + val index = viewport.freeHint() val type = when (direction) { Direction.WEST -> 3 Direction.EAST -> 4 @@ -234,7 +248,7 @@ fun Player.hint(tile: Tile, radius: Int = 1, arrow: Int = HintArrow.FILLED, dire */ fun Player.hint(character: Character, arrow: Int = HintArrow.FILLED): Int { val viewport = viewport ?: return -1 - val index = viewport.hints.firstOrNull { it == 0 } ?: return -1 + val index = viewport.freeHint() val type = when (character) { is Player -> 10 is NPC -> 1 diff --git a/game/src/main/kotlin/content/entity/npc/NPCHints.kt b/game/src/main/kotlin/content/entity/npc/NPCHints.kt new file mode 100644 index 0000000000..ada57c445e --- /dev/null +++ b/game/src/main/kotlin/content/entity/npc/NPCHints.kt @@ -0,0 +1,53 @@ +package content.entity.npc + +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.client.clearHint +import world.gregs.voidps.engine.client.hint +import world.gregs.voidps.engine.entity.character.npc.NPC +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.entity.character.player.Players +import world.gregs.voidps.network.login.protocol.encode.HintArrow + +/** + * Hint arrows sit in one of eight per-player viewport slots and are only handed back by + * [clearHint], so an arrow left on an npc that dies or times out costs that player a slot for the + * rest of their session. Marking through [markHint] records the slot on the npc so it can be + * released when the npc goes away. + */ +class NPCHints : Script { + init { + npcDespawn { + clearHints() + } + } +} + +/** + * Points a hint arrow at this npc for [player], releasing it when the npc despawns - which covers + * dying, since a spawned npc with nowhere to respawn to is removed once its death finishes. + */ +fun NPC.markHint(player: Player, arrow: Int = HintArrow.FILLED) { + val slots: MutableMap = getOrPut("hint_slots") { mutableMapOf() } + // Free the old slot first so re-marking reuses it rather than taking a second + val previous = slots.remove(player.accountName) + if (previous != null) { + player.clearHint(previous) + } + val slot = player.hint(this, arrow) + if (slot == -1) { + return + } + slots[player.accountName] = slot +} + +/** + * Hands back every arrow slot [markHint] took for this npc. Safe to call more than once - the + * second call finds nothing rather than clearing a slot something else has since taken. + */ +fun NPC.clearHints() { + val slots: MutableMap = get("hint_slots") ?: return + clear("hint_slots") + for ((account, slot) in slots) { + Players.findByAccount(account)?.clearHint(slot) + } +} diff --git a/game/src/main/kotlin/content/minigame/barrows/BarrowsCrypts.kt b/game/src/main/kotlin/content/minigame/barrows/BarrowsCrypts.kt index 7f0f58685b..8028b669cc 100644 --- a/game/src/main/kotlin/content/minigame/barrows/BarrowsCrypts.kt +++ b/game/src/main/kotlin/content/minigame/barrows/BarrowsCrypts.kt @@ -1,6 +1,7 @@ package content.minigame.barrows import content.entity.combat.killer +import content.entity.npc.markHint import content.entity.obj.door.enterDoor import content.entity.player.dialogue.type.choice import content.entity.player.dialogue.type.statement @@ -235,7 +236,7 @@ class BarrowsCrypts : Script { npc.say(if (npc.tile.level == 3) "You dare disturb my rest!" else "You dare steal from us!") npc.interactPlayer(player, "Attack") player["${brother}_spawn"] = npc.index - player.hint(npc) + npc.markHint(player) } } } diff --git a/game/src/main/kotlin/content/quest/member/ghosts_ahoy/GhostsAhoy.kt b/game/src/main/kotlin/content/quest/member/ghosts_ahoy/GhostsAhoy.kt index 1953633755..9cd4773803 100644 --- a/game/src/main/kotlin/content/quest/member/ghosts_ahoy/GhostsAhoy.kt +++ b/game/src/main/kotlin/content/quest/member/ghosts_ahoy/GhostsAhoy.kt @@ -1,6 +1,7 @@ package content.quest.member.ghosts_ahoy import content.entity.effect.transform +import content.entity.npc.markHint import content.entity.player.bank.ownsItem import content.entity.player.dialogue.Neutral import content.entity.player.dialogue.type.item @@ -14,7 +15,6 @@ import content.quest.questJournal import content.quest.questStage import content.quest.refreshQuestJournal import world.gregs.voidps.engine.Script -import world.gregs.voidps.engine.client.hint import world.gregs.voidps.engine.client.instruction.handle.interactPlayer import world.gregs.voidps.engine.client.message import world.gregs.voidps.engine.entity.character.jingle @@ -280,7 +280,7 @@ class GhostsAhoy : Script { } else if (lobster == null) { message("You are attacked by a giant lobster!!") val lobster = NPCs.add("giant_lobster_ghosts_ahoy", Tile(3616, 3543, 0), ticks = TimeUnit.MINUTES.toTicks(8), owner = this) - hint(lobster) + lobster.markHint(this) lobster.interactPlayer(this, "Attack") } return@objectOperate diff --git a/game/src/main/kotlin/content/quest/member/rum_deal/RumDeal.kt b/game/src/main/kotlin/content/quest/member/rum_deal/RumDeal.kt index ac3aa7e6a3..49490c442b 100644 --- a/game/src/main/kotlin/content/quest/member/rum_deal/RumDeal.kt +++ b/game/src/main/kotlin/content/quest/member/rum_deal/RumDeal.kt @@ -1,6 +1,8 @@ package content.quest.member.rum_deal import content.entity.combat.killer +import content.entity.npc.clearHints +import content.entity.npc.markHint import content.entity.player.dialogue.Angry import content.entity.player.dialogue.Confused import content.entity.player.dialogue.Mad @@ -15,8 +17,6 @@ import content.entity.player.dialogue.type.statement import content.quest.questJournal import content.quest.questStage import world.gregs.voidps.engine.Script -import world.gregs.voidps.engine.client.clearHint -import world.gregs.voidps.engine.client.hint import world.gregs.voidps.engine.client.instruction.handle.interactPlayer import world.gregs.voidps.engine.client.message import world.gregs.voidps.engine.client.ui.dialogue.talkWith @@ -425,7 +425,7 @@ class RumDeal : Script { owner = this, ) if (spirit != null) { - spirit["hint_index"] = hint(spirit) + spirit.markHint(this) spirit.interactPlayer(this, "Attack") } } @@ -436,12 +436,8 @@ class RumDeal : Script { if (killer.questStage("rum_deal") == 13) { killer["rum_deal"] = "spirit_banished" } - // Keep the arrow's slot on the spirit; clearHint() with no index wipes every - // hint the player has, including arrows belonging to other content. - val arrow: Int = get("hint_index", -1) - if (arrow != -1) { - killer.clearHint(arrow) - } + // Takes the arrow away as the spirit dies rather than waiting for it to despawn + clearHints() killer.message("You have banished the Evil Spirit!") } } diff --git a/game/src/main/kotlin/content/skill/dungeoneering/DungeonMark.kt b/game/src/main/kotlin/content/skill/dungeoneering/DungeonMark.kt index 8841ad2f16..061ebfbaac 100644 --- a/game/src/main/kotlin/content/skill/dungeoneering/DungeonMark.kt +++ b/game/src/main/kotlin/content/skill/dungeoneering/DungeonMark.kt @@ -2,8 +2,8 @@ package content.skill.dungeoneering import content.area.wilderness.daemonheim.DungeoneeringParty.Companion.dungeonLeader import content.area.wilderness.daemonheim.DungeoneeringParty.Companion.dungeonMembers +import content.entity.npc.markHint import world.gregs.voidps.engine.Script -import world.gregs.voidps.engine.client.hint import world.gregs.voidps.engine.client.message import world.gregs.voidps.engine.entity.Approachable import world.gregs.voidps.engine.entity.character.npc.NPC @@ -22,8 +22,7 @@ class DungeonMark : Script { return } for (member in dungeonMembers) { - val index = member.hint(target) - member["dungeon_hint_${target.index}"] = index + target.markHint(member) } } } diff --git a/game/src/test/kotlin/content/entity/npc/NPCHintsTest.kt b/game/src/test/kotlin/content/entity/npc/NPCHintsTest.kt new file mode 100644 index 0000000000..5be9eba640 --- /dev/null +++ b/game/src/test/kotlin/content/entity/npc/NPCHintsTest.kt @@ -0,0 +1,58 @@ +package content.entity.npc + +import WorldTest +import org.junit.jupiter.api.Test +import world.gregs.voidps.type.Tile +import kotlin.test.assertEquals + +/** + * Arrow slots are only handed back explicitly, so an npc that is marked and then goes away without + * anyone clearing it would cost the player one of their eight slots for the rest of the session. + */ +class NPCHintsTest : WorldTest() { + + @Test + fun `Marked npc hands its arrow slot back when it despawns`() { + val player = createPlayer(Tile(3200, 3200)) + val npc = createNPC("man", Tile(3201, 3200)) + val hints = player.viewport!!.hints + + npc.markHint(player) + assertEquals(1, hints.count { it != 0 }, "marking should take a slot") + + npc.despawn() + tick(2) + + assertEquals(0, hints.count { it != 0 }, "despawning should give the slot back") + } + + @Test + fun `Re-marking the same npc reuses its slot`() { + val player = createPlayer(Tile(3200, 3200)) + val npc = createNPC("man", Tile(3201, 3200)) + val hints = player.viewport!!.hints + + npc.markHint(player) + npc.markHint(player) + npc.markHint(player) + + assertEquals(1, hints.count { it != 0 }, "a second mark should replace the first") + } + + @Test + fun `Separate npcs get separate slots`() { + val player = createPlayer(Tile(3200, 3200)) + val first = createNPC("man", Tile(3201, 3200)) + val second = createNPC("man", Tile(3202, 3200)) + val hints = player.viewport!!.hints + + first.markHint(player) + second.markHint(player) + assertEquals(2, hints.count { it != 0 }, "each npc should get its own slot") + + first.despawn() + tick(2) + + assertEquals(1, hints.count { it != 0 }, "only the despawned npc's slot should be freed") + } +} diff --git a/game/src/test/kotlin/content/quest/member/rum_deal/RumDealTest.kt b/game/src/test/kotlin/content/quest/member/rum_deal/RumDealTest.kt index 02fe62d40b..a68383d036 100644 --- a/game/src/test/kotlin/content/quest/member/rum_deal/RumDealTest.kt +++ b/game/src/test/kotlin/content/quest/member/rum_deal/RumDealTest.kt @@ -11,6 +11,7 @@ import npcOption import objectOption import org.junit.jupiter.api.Test import skipDialogues +import world.gregs.voidps.engine.client.hint import world.gregs.voidps.engine.client.ui.dialogue import world.gregs.voidps.engine.data.definition.CombatDefinitions import world.gregs.voidps.engine.entity.character.npc.NPCs @@ -550,27 +551,22 @@ class RumDealTest : WorldTest() { player["rum_deal_brewing_control"] = 1 player.inventory.add("holy_wrench") - // An arrow belonging to some other content, which must survive. Written straight into - // the viewport because Player.hint always allocates slot 0 - see the note below. - val otherArrow = 3 - player.viewport!!.hints[otherArrow] = 1 + // An arrow belonging to some other content, which must survive + val otherArrow = player.hint(Tile(2143, 5101, 1)) val controls = GameObjects.find(Tile(2143, 5100, 1), "braindeath_island_brewing_controls_multi") player.itemOnObject(controls, player.inventory.indexOf("holy_wrench")) tick(6) val spirit = NPCs.at(player.tile.regionLevel).first { it.id == "evil_spirit" } - val spiritArrow: Int = spirit["hint_index", -1] - assertTrue(spiritArrow != -1, "the spirit should record its arrow slot") - assertTrue(player.viewport!!.hints[spiritArrow] != 0) + val hints = player.viewport!!.hints + assertEquals(2, hints.count { it != 0 }, "the spirit's arrow should take a slot of its own") spirit.damage(5000, source = player) tick(6) - assertEquals(0, player.viewport!!.hints[spiritArrow], "the spirit's arrow should be gone") - assertTrue(player.viewport!!.hints[otherArrow] != 0, "other content's arrow must survive") - // Note: Player.hint uses firstOrNull instead of indexOfFirst, so it always returns slot 0 - // and cannot currently allocate a second arrow. Clearing by index is still correct. + assertEquals(1, hints.count { it != 0 }, "the spirit's arrow should be gone") + assertTrue(hints[otherArrow] != 0, "other content's arrow must survive") } @Test 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..9a8581f35b 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 @@ -45,19 +45,35 @@ fun Client.arrowHint( model: Int = 65535, ) = send(Protocol.HINT_ARROW) { writeByte((arrowIndex shl 5) or type) + // The packet is a fixed twelve bytes, so every branch - clearing included - has to write all + // of it. Stopping short leaves the client reading the packets behind this one as the rest of + // it, which desyncs the stream and takes the client down. + if (type == 0) { + repeat(PAYLOAD - 1) { + writeByte(0) + } + return@send + } writeByte(sprite) - if (sprite >= 0) { - if (type == 1 || type == 10) { + when (type) { + 1, 10 -> { writeShort(entityIndex) writeInt(0) writeShort(0) - } else if (type in 2..6) { + } + in 2..6 -> { writeByte(level) // level writeShort(x) // x writeShort(y) // y writeByte(z) // z? writeShort(radius) } - writeShort(model) + else -> repeat(8) { + writeByte(0) + } } -} \ No newline at end of file + writeShort(model) +} + +/** Bytes the client reads for a hint arrow, not counting the opcode. */ +private const val PAYLOAD = 12 \ No newline at end of file diff --git a/network/src/test/kotlin/world/gregs/voidps/network/login/protocol/EncodersTest.kt b/network/src/test/kotlin/world/gregs/voidps/network/login/protocol/EncodersTest.kt index 2ba288dee4..92ceb35137 100644 --- a/network/src/test/kotlin/world/gregs/voidps/network/login/protocol/EncodersTest.kt +++ b/network/src/test/kotlin/world/gregs/voidps/network/login/protocol/EncodersTest.kt @@ -123,6 +123,9 @@ class EncodersTest { { clearZone(123, 234, 12) }, { updateZone(123, 234, 12) }, { arrowHint(2, 1, 3, -1, 100, 123, 2, 50, 2, 1234) }, + // Clearing writes the same twelve bytes as any other hint; a shorter packet + // desyncs the client + { arrowHint(0, 1) }, ).mapIndexed { index, packet -> random = Random(0) val (name, expected) = packets[index] diff --git a/network/src/test/resources/encoder-packets.csv b/network/src/test/resources/encoder-packets.csv index ee6a6ffbea..d250472e95 100644 --- a/network/src/test/resources/encoder-packets.csv +++ b/network/src/test/resources/encoder-packets.csv @@ -66,3 +66,4 @@ weight, 78, 48, 57 clear zone, 20, -116, 22, -123 update zone, 28, 22, -116, -5 arrow hint, 56, 34, 3, 2, 0, 100, 0, 123, 50, 0, 2, 4, -46 +clear hint, 56, 32, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 From 77012e2ab6bc674f2c3037156e16df14dffea956 Mon Sep 17 00:00:00 2001 From: GregHib Date: Wed, 2 Sep 2026 13:54:57 +0100 Subject: [PATCH 03/10] Fix few broken dialogues --- .../area/morytania/braindeath_island/CaptainBraindeath.kt | 2 +- .../kotlin/content/area/morytania/braindeath_island/Davey.kt | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/game/src/main/kotlin/content/area/morytania/braindeath_island/CaptainBraindeath.kt b/game/src/main/kotlin/content/area/morytania/braindeath_island/CaptainBraindeath.kt index c08455735d..702c71bb2b 100644 --- a/game/src/main/kotlin/content/area/morytania/braindeath_island/CaptainBraindeath.kt +++ b/game/src/main/kotlin/content/area/morytania/braindeath_island/CaptainBraindeath.kt @@ -98,7 +98,7 @@ class CaptainBraindeath : Script { "These be dark times, though, ${ladOrLass()}. See, a week ago we awoke to find " + "ourselves beseiged.", ) - player( + npc( "The lads and I have held them off so far, but 'tis only a matter of time before " + "they sweep through the buildin' and put us all to the sword.", ) diff --git a/game/src/main/kotlin/content/area/morytania/braindeath_island/Davey.kt b/game/src/main/kotlin/content/area/morytania/braindeath_island/Davey.kt index ecddfbbe0a..420256462a 100644 --- a/game/src/main/kotlin/content/area/morytania/braindeath_island/Davey.kt +++ b/game/src/main/kotlin/content/area/morytania/braindeath_island/Davey.kt @@ -126,7 +126,7 @@ class Davey : Script { } private suspend fun Player.blessTheWrench(dave: NPC) { - npc("I might well do. Remember, only the first one's free.") + npc("I might well do. Remember, only the first one's free.") npc("Dominoes Ad Nauseum, Romanes Eunt Domus.") npc("Sorted.") player("Is that it?") @@ -148,7 +148,7 @@ class Davey : Script { "prayer potions if you have it in your pack, if you know what I mean.", ) player( - "Well, no. I don't know what you mean, but I'm sure I'll find out!", + "Well, no. I don't know what you mean, but I'm sure I'll find out!", ) } From 6485af81f59dae03bcefd540b3cf512df574ae02 Mon Sep 17 00:00:00 2001 From: GregHib Date: Wed, 2 Sep 2026 15:54:09 +0100 Subject: [PATCH 04/10] Formatting --- .../morytania/braindeath_island/Brewer.kt | 113 +---- .../braindeath_island/CaptainBraindeath.kt | 438 ++++-------------- .../braindeath_island/CaptainDonnie.kt | 52 +-- .../area/morytania/braindeath_island/Davey.kt | 109 +---- .../braindeath_island/FeverSpider.kt | 2 + .../area/morytania/braindeath_island/Luke.kt | 77 +-- .../braindeath_island/ZombieProtester.kt | 9 +- .../morytania/braindeath_island/ZombieSwab.kt | 18 +- 8 files changed, 176 insertions(+), 642 deletions(-) diff --git a/game/src/main/kotlin/content/area/morytania/braindeath_island/Brewer.kt b/game/src/main/kotlin/content/area/morytania/braindeath_island/Brewer.kt index ac68a32173..1bc7418d4f 100644 --- a/game/src/main/kotlin/content/area/morytania/braindeath_island/Brewer.kt +++ b/game/src/main/kotlin/content/area/morytania/braindeath_island/Brewer.kt @@ -52,9 +52,7 @@ class Brewer : Script { 1 -> { npc("Who are you?") npc("You're one of THEM arent you!") - player( - "O...kay...walking away now, smiling and not making eye contact.", - ) + player("O...kay...walking away now, smiling and not making eye contact.") } 2 -> { npc("Game over, man! Game over!") @@ -63,9 +61,7 @@ class Brewer : Script { 3 -> { npc("Very well, considering.") player("Considering what?") - npc( - "That I've been crawling into the corner to cry whenever nobody is looking.", - ) + npc("That I've been crawling into the corner to cry whenever nobody is looking.") player("Wow... I always thought pirates were tough...") } 4 -> { @@ -73,21 +69,11 @@ class Brewer : Script { npc("Don't you know there's a war on?") player("I didn't think this counted as a war.") npc("That's because you're a landlubber!") - npc( - "I bet ye've never had to beat a dozen zombies to death with a blunt " + - "toothpick!", - ) + npc("I bet ye've never had to beat a dozen zombies to death with a blunt toothpick!") player("That's right. Have you?") - npc( - "No, I've spent most of my time in the toilet to be honest.", - ) - } - 5 -> { - npc( - "Don't talk to me! If I play this right, I can sell you lot out and make " + - "it to the mainland!", - ) + npc("No, I've spent most of my time in the toilet to be honest.") } + 5 -> npc("Don't talk to me! If I play this right, I can sell you lot out and make it to the mainland!") 6 -> { npc("No school today mother, my brain hurts real baaaad...") player("What?") @@ -95,9 +81,7 @@ class Brewer : Script { } 7 -> { npc("How do I know you're not one of them?") - player( - "Well, I don't know... the way I'm breathing could give it away.", - ) + player("Well, I don't know... the way I'm breathing could give it away.") npc("That means nothing! NOTHING!") } 8 -> { @@ -119,12 +103,8 @@ class Brewer : Script { 1 -> npc("Oooh...It's so shiny...") 2 -> npc("(Sob) It's so beautiful...") 3 -> npc("I am not worthy to gaze upon it!") - 4 -> npc( - "I can feel the aura of Wrenchly Power radiating from it!", - ) - 5 -> npc( - "Take it away, I am not worthy to be in its Wrenchly presence.", - ) + 4 -> npc("I can feel the aura of Wrenchly Power radiating from it!") + 5 -> npc("Take it away, I am not worthy to be in its Wrenchly presence.") 6 -> npc("Wow...I don't feel tired any more...") 7 -> npc("Such power!") 8 -> npc("We're saved!") @@ -133,44 +113,20 @@ class Brewer : Script { // Asking around for help player("I'm looking for a priest. And a wrench.") when (brewer) { - 1 -> { - npc("What possible plan could involve a priest and a wrench?") - } + 1 -> npc("What possible plan could involve a priest and a wrench?") 2 -> { - npc( - "What? Are you gonna build some strange mechanical priest to save us?", - ) + npc("What? Are you gonna build some strange mechanical priest to save us?") npc("What if it runs riot? We'll all be killed!") } - 3 -> { - npc( - "Well good luck. If you find any spare trousers please pass them my way.", - ) - } - 4 -> { - npc( - "Well the usual combo is an old priest and a young priest, but " + - "whatever floats your boat lad.", - ) - } - 5 -> { - npc( - "What are you going to do to that priest? No, wait, I don't want to know.", - ) - } + 3 -> npc("Well good luck. If you find any spare trousers please pass them my way.") + 4 -> npc("Well the usual combo is an old priest and a young priest, but whatever floats your boat lad.") + 5 -> npc("What are you going to do to that priest? No, wait, I don't want to know.") 6 -> { npc("Try Davey, he might have seen it.") player("Thanks!") - npc( - "His giant, green, inflatable dwarf might be able to tell you where " + - "one is as well.", - ) - } - 7 -> { - npc( - "If this is some sort of secret zombie code then I don't get it.", - ) + npc("His giant, green, inflatable dwarf might be able to tell you where one is as well.") } + 7 -> npc("If this is some sort of secret zombie code then I don't get it.") 8 -> { npc("Well I can't help you there.") player("Oh, sorry to have bothered you.") @@ -194,13 +150,9 @@ class Brewer : Script { } when (brewer) { 1 -> { - npc( - "I don't know what your game is, but I know you're one of THEM!", - ) + npc("I don't know what your game is, but I know you're one of THEM!") player("But I just saved you!") - npc( - "The voices tell me different. It's all part of a plot! Confess!", - ) + npc("The voices tell me different. It's all part of a plot! Confess!") } 2 -> { npc("Have they gone yet?") @@ -212,10 +164,7 @@ class Brewer : Script { npc("Hello yourself!") player("How's things?") npc("Fine...") - player( - "Excellent! Since I get the feeling I don't want to know why you said " + - "that so oddly I'll just go over here!", - ) + player("Excellent! Since I get the feeling I don't want to know why you said that so oddly I'll just go over here!") npc("I think that would be for the best!") } 4 -> { @@ -225,24 +174,12 @@ class Brewer : Script { player("Great!") } 5 -> { - npc( - "Hello there, brave hero, in whom I had total confidence!", - ) + npc("Hello there, brave hero, in whom I had total confidence!") player("Total confidence?") - npc( - "Yes! I was so confident that I would never, ever have sold your soft, " + - "edible body to the pirates outside!", - ) + npc("Yes! I was so confident that I would never, ever have sold your soft, edible body to the pirates outside!") player("Well, great...") - npc( - "On a completely unrelated note, I would steer clear of Hungry Frank for " + - "a while.", - ) - npc( - "He's a filthy liar. And a forger. It wouldn't surprise me if he has " + - "written out a note detailing the terms of our surrender and your " + - "dismemberment and cooking in MY handwriting.", - ) + npc("On a completely unrelated note, I would steer clear of Hungry Frank for a while.") + npc("He's a filthy liar. And a forger. It wouldn't surprise me if he has written out a note detailing the terms of our surrender and your dismemberment and cooking in MY handwriting.") npc("Imagine that, the fiend.") } 6 -> { @@ -261,11 +198,7 @@ class Brewer : Script { 8 -> { npc("You saved us! Huzzah!") player("All in a day's work, think nothing of it.") - npc( - "I don't have anything to reward you with except my collection of bleak, " + - "gothic poetry I wrote when I assumed we were all done for. Do you " + - "want it?", - ) + npc("I don't have anything to reward you with except my collection of bleak, gothic poetry I wrote when I assumed we were all done for. Do you want it?") player("I may come for it later, you hang on to it for now.") } } diff --git a/game/src/main/kotlin/content/area/morytania/braindeath_island/CaptainBraindeath.kt b/game/src/main/kotlin/content/area/morytania/braindeath_island/CaptainBraindeath.kt index 702c71bb2b..3a80fb095e 100644 --- a/game/src/main/kotlin/content/area/morytania/braindeath_island/CaptainBraindeath.kt +++ b/game/src/main/kotlin/content/area/morytania/braindeath_island/CaptainBraindeath.kt @@ -66,96 +66,52 @@ class CaptainBraindeath : Script { player("Ohhhh... My head...") player("It feels like someone has smacked me one with a bottle...") npc("Arr... Those devils gave ye a nasty knock when ye came to aid us.") - npc( - "But now yer here we'll run those evil brain-eatin' dogs off the island fer good!", - ) + npc("But now yer here we'll run those evil brain-eatin' dogs off the island fer good!") player("What? What is going on here?") player("I can't seem to remember anything beyond chatting to a man at the docks.") npc("Arr. Well, ${ladOrLass()}, that would be Pete, one of my men.") npc("He's been out lookin' fer heroes like yerself to aid us in our peril.") - npc( - "When ye arrived ye took a nasty knock to the head, so ye probably don't remember " + - "agreein' to help us out. But I swear to ye that ye did.", - ) + npc("When ye arrived ye took a nasty knock to the head, so ye probably don't remember agreein' to help us out. But I swear to ye that ye did.") player("Okay... I'll buy that. It sounds like something I would do.") player("So where am I, and what is going on?") npc("Yer on Braindeath Island!") - npc( - "Where it lies is a secret, because ye are standin' in the brewery of Cap'n " + - "Braindeath, purveyor of the most vitriolic alcoholic beverages in the world!", - ) + npc("Where it lies is a secret, because ye are standin' in the brewery of Cap'n Braindeath, purveyor of the most vitriolic alcoholic beverages in the world!") player("Wow!") - npc( - "I am the notorious alchemist Cap'n Braindeath, and this whole operation be my idea!", - ) - npc( - "With my crew of sturdy, upright pirate brewers, we sail the seven seas, " + - "distributing cheap 'alcohol' to all and sundry.", - ) + npc("I am the notorious alchemist Cap'n Braindeath, and this whole operation be my idea!") + npc("With my crew of sturdy, upright pirate brewers, we sail the seven seas, distributing cheap 'alcohol' to all and sundry.") npc("Well, fer a price, at any rate.") player("Oooh!") - npc( - "These be dark times, though, ${ladOrLass()}. See, a week ago we awoke to find " + - "ourselves beseiged.", - ) - npc( - "The lads and I have held them off so far, but 'tis only a matter of time before " + - "they sweep through the buildin' and put us all to the sword.", - ) + npc("These be dark times, though, ${ladOrLass()}. See, a week ago we awoke to find ourselves beseiged.") + npc("The lads and I have held them off so far, but 'tis only a matter of time before they sweep through the buildin' and put us all to the sword.") player("Who?") npc("Them!") statement("The Captain points out of the window...", clickToContinue = false) playProtestCutscene() player("...protesting?") npc("Arr, ${ladOrLass()}! That they are!") - npc( - "Day and night they seek to break our will with their chantin', and their singin' " + - "and their passive resistance!", - ) - npc( - "Seems they lost their fightin' spirit after the first few days. Now most of them " + - "just protest all the time.", - ) + npc("Day and night they seek to break our will with their chantin', and their singin' and their passive resistance!") + npc("Seems they lost their fightin' spirit after the first few days. Now most of them just protest all the time.") player("So, what do you want me to do?") - npc( - "Well, me and the lads got our heads together and decided that if we can get their " + - "Cap'n drunk enough, perhaps they'll stop protestin'.", - ) - npc( - "If that happens, we'll slip out the back and set up shop somewhere else.", - ) + npc("Well, me and the lads got our heads together and decided that if we can get their Cap'n drunk enough, perhaps they'll stop protestin'.") + npc("If that happens, we'll slip out the back and set up shop somewhere else.") player("Well, how can I help?") - npc( - "Well, first of all, we need someone to go out the front and grow us some Blindweed.", - ) + npc("Well, first of all, we need someone to go out the front and grow us some Blindweed.") npc("'Tis one of the ingredients of our 'rum'.") - npc( - "The only problem is that those rottin' fiends have torn up and destroyed all but " + - "one of the Blindweed Patches.", - ) + npc("The only problem is that those rottin' fiends have torn up and destroyed all but one of the Blindweed Patches.") if (hasSpace()) { addOrDrop("blindweed_seed") set("rum_deal", "given_seeds") - npc( - "Here, ${ladOrLass()}. I'll give ye the seed you'll need fer growin' the herb. " + - "Help yerself to the gardenin' equipment in the basement.", - ) + npc("Here, ${ladOrLass()}. I'll give ye the seed you'll need fer growin' the herb. Help yerself to the gardenin' equipment in the basement.") blindweedWarnings() } else { set("rum_deal", "met_braindeath") - npc( - "Well, I have some Blindweed seeds fer ye. When ye have some free space fer " + - "them, come and talk to me.", - ) + npc("Well, I have some Blindweed seeds fer ye. When ye have some free space fer them, come and talk to me.") } } private suspend fun Player.blindweedWarnings() { npc("I'll warn ye again that those devils are sat right on top of the patch.") - npc( - "Try hecklin' 'em from a distance. Those Swabs may talk a good fight, but if ye " + - "can put a scare in 'em they'll keep out of yer way!", - ) + npc("Try hecklin' 'em from a distance. Those Swabs may talk a good fight, but if ye can put a scare in 'em they'll keep out of yer way!") } // ===== Progress 3: Came back with space, hand out the seed ===== @@ -164,13 +120,8 @@ class CaptainBraindeath : Script { if (hasSpace()) { addOrDrop("blindweed_seed") set("rum_deal", "given_seeds") - npc( - "Finally, ${ladOrLass()}! Here, take this seed and grow us some Blindweed.", - ) - npc( - "Ye can help yerself to the gardenin' equipment in the basement, just be quick " + - "about it!", - ) + npc("Finally, ${ladOrLass()}! Here, take this seed and grow us some Blindweed.") + npc("Ye can help yerself to the gardenin' equipment in the basement, just be quick about it!") blindweedWarnings() } else { defaultGreeting() @@ -194,13 +145,8 @@ class CaptainBraindeath : Script { npc("Go shove that in the Intake Hopper, and try not ter lose this lot!") addOrDrop("blindweed") } else { - npc( - "When ye've quite finished blubbin', come see me and I'll give ye some Blindweed.", - ) - npc( - "Clear a little space from yer pack first though, ${ladOrLass()}, or ye'll " + - "be hard pressed to hold it.", - ) + npc("When ye've quite finished blubbin', come see me and I'll give ye some Blindweed.") + npc("Clear a little space from yer pack first though, ${ladOrLass()}, or ye'll be hard pressed to hold it.") } } } @@ -209,14 +155,9 @@ class CaptainBraindeath : Script { private suspend fun Player.nextStagnantWater() { player("Well, that takes care of the Blindweed. What now?") - npc( - "Well, now ye've shoved the Blindweed into the mix, what we need is a bucket of " + - "stagnant water.", - ) + npc("Well, now ye've shoved the Blindweed into the mix, what we need is a bucket of stagnant water.") player("Where can I get some of that?") - npc( - "Ye won't have to go far, ${ladOrLass()}, we have a pool of the stuff here!", - ) + npc("Ye won't have to go far, ${ladOrLass()}, we have a pool of the stuff here!") player("Here in the brewery?") npc("No, ${ladOrLass()}, that would be a strange thing to have in a brewery!") npc("It's up the mountain to the north.") @@ -225,10 +166,7 @@ class CaptainBraindeath : Script { player("And I assume the place is crawling with these zombies?") npc("No ${ladOrLass()}, there be not a zombie in sight.") player("Oh, good!") - npc( - "All ye have to do is get past the keen-eyed lookout that's been spottin' my men " + - "when I send 'em.", - ) + npc("All ye have to do is get past the keen-eyed lookout that's been spottin' my men when I send 'em.") npc("I'll tell ye that it won't be easy!") player("When is it ever...") npc("Don't ferget yer bucket!") @@ -242,28 +180,18 @@ class CaptainBraindeath : Script { private suspend fun Player.nextSluglings() { player("So is that everything?") - npc( - "No, ${ladOrLass()}, next ye'll need to go outside and catch five loads of " + - "Sluglings fer the brew.", - ) + npc("No, ${ladOrLass()}, next ye'll need to go outside and catch five loads of Sluglings fer the brew.") player("What? Sluglings? That's disgusting!") npc("Arr, ${ladOrLass()}, that it be.") npc("To a weak-stomached, knock-kneed landlubber...") player("Why Sluglings?") npc("'Cos, ${ladOrLass()} they're one of our super-secret ingredients!") - npc( - "Yer not too susceptible to mind control are ye ${ladOrLass()}?", - ) + npc("Yer not too susceptible to mind control are ye ${ladOrLass()}?") player("Why?") - npc( - "Because they have been known te, well, influence people every now and again.", - ) + npc("Because they have been known te, well, influence people every now and again.") if (isSeaSlugComplete()) { player("They aren't related to those Seaslugs are they?") - npc( - "Aye they're related! Tis a good job they'll starve if they tried te eat yer " + - "brains!", - ) + npc("Aye they're related! Tis a good job they'll starve if they tried te eat yer brains!") } else { npc("I'm sure ye've got nothing te worry about!") } @@ -284,20 +212,11 @@ class CaptainBraindeath : Script { npc("Here. Ye'll need this to catch them.") } player("What should I do with it?") - npc( - "Just dunk it in the water. I'm sure a clever ${ladOrLass()} like yerself will " + - "have no problem.", - ) - npc( - "Oh, and if ye haul up some squiddy-looking things, don't tate to shove 'em in " + - "the barrel too.", - ) + npc("Just dunk it in the water. I'm sure a clever ${ladOrLass()} like yerself will have no problem.") + npc("Oh, and if ye haul up some squiddy-looking things, don't tate to shove 'em in the barrel too.") npc("They add a special, fishy texture to the drink.") } else { - npc( - "Ye'll be needin' a fishbowl in a big net to help ye catch them slimy little " + - "blighters.", - ) + npc("Ye'll be needin' a fishbowl in a big net to help ye catch them slimy little blighters.") set("rum_deal", "catch_creatures") npc("Pity ye ain't got any free space to carry one.") } @@ -310,80 +229,45 @@ class CaptainBraindeath : Script { npc("Snap out of it, ${ladOrLass()}! Yer in slug-shock!") player("What? Who?") player("Gah! Sorry about that.") - npc( - "No problem. Well, now ye have just one more ingredient to grab, and then we can " + - "get this 'rum' flowin!", - ) + npc("No problem. Well, now ye have just one more ingredient to grab, and then we can get this 'rum' flowin!") player("Well, how far away will I have to go to grab it?") npc("Not far at all, ${ladOrLass()}. Ye've just got to get it from the basement!") player("Great! What is it?") - npc( - "Hold yer horses, ${ladOrLass()}! While ye was off gallivantin' with yer slimy " + - "aquatic playmates, the 'rum' achieved spiritual critical mass.", - ) + npc("Hold yer horses, ${ladOrLass()}! While ye was off gallivantin' with yer slimy aquatic playmates, the 'rum' achieved spiritual critical mass.") npc("To put it in terms ye'll understand: the brewin' equipment is possessed.") player("Possessed!") npc("Don't ye worry yerself about it! This happens all the time.") - npc( - "Well, to tell the truth, my lads are a little quicker off the mark, so it only " + - "happens occasionally.", - ) + npc("Well, to tell the truth, my lads are a little quicker off the mark, so it only happens occasionally.") npc("Not that I'm criticisin' yer performance, ${ladOrLass()}.") if (hasSpace()) { inventory.add("wrench") set("rum_deal", "bless_wrench") npc("Give the controls a couple of belts with this wrench.") - npc( - "One of the lads did a little priestin' on the side before he came here. Get " + - "him to bless it and ye'll do fine.", - ) + npc("One of the lads did a little priestin' on the side before he came here. Get him to bless it and ye'll do fine.") } else { set("rum_deal", "bless_wrench") - npc( - "We've got a special item fer dealin' with this problem. Dump some of that " + - "stuff yer carryin' and I'll hand it over!", - ) + npc("We've got a special item fer dealin' with this problem. Dump some of that stuff yer carryin' and I'll hand it over!") } } // ===== Progress 14: Final ingredient (fever spider body) ===== private suspend fun Player.finalIngredient() { - npc( - "Well, now that ye've got that spirit out of there ye can dump in the final " + - "ingredient.", - ) + npc("Well, now that ye've got that spirit out of there ye can dump in the final ingredient.") player("And that is?") npc("We need the body of a diseased Fever Spider!") player("Remind me never to drink anything you have ever made. Or touched.") - npc( - "When yer quite done flappin' yer lips, go down into the basement and whack " + - "spiders until ye find a fever spider body.", - ) + npc("When yer quite done flappin' yer lips, go down into the basement and whack spiders until ye find a fever spider body.") npc("Shove it in the hopper, and then we're in business.") when { - wearingSlayerGloves() -> { - npc( - "I see yer already wearin' some Slayer Gloves. That'll keep the Fever " + - "Spiders from gnawin' yer hands off!", - ) - } + wearingSlayerGloves() -> npc("I see yer already wearin' some Slayer Gloves. That'll keep the Fever Spiders from gnawin' yer hands off!") inventory.contains("slayer_gloves") -> { - npc( - "Ye'll be wanting to put them Slayer Gloves of yours on before ye head off " + - "${ladOrLass()}, as those Fever Spiders carry a nasty disease.", - ) - npc( - "They'll give it to ye if ye aren't wearin' somethin' too thick fer them " + - "to bite through.", - ) + npc("Ye'll be wanting to put them Slayer Gloves of yours on before ye head off ${ladOrLass()}, as those Fever Spiders carry a nasty disease.") + npc("They'll give it to ye if ye aren't wearin' somethin' too thick fer them to bite through.") } else -> { npc("Ye may want to hop back to the mainland and see a Slayer Master.") - npc( - "These Fever Spiders carry a nasty disease, and ye'll need a pair of " + - "Slayer Gloves on to not catch it!", - ) + npc("These Fever Spiders carry a nasty disease, and ye'll need a pair of Slayer Gloves on to not catch it!") } } set("rum_deal", "kill_spider") @@ -397,32 +281,20 @@ class CaptainBraindeath : Script { playBrewCutscene() player("Is that it?") npc("Aye ${ladOrLass()}! Now get outside and feed that stuff to the pirates.") - npc( - "Try givin' it to the Captain, he's in charge. Get him bladdered and the rest " + - "will fall!", - ) + npc("Try givin' it to the Captain, he's in charge. Get him bladdered and the rest will fall!") when { inventory.contains("bucket") -> { - npc( - "Ye'll need to use that bucket of yours. Most stuff can't stand bein' in " + - "contact with our 'rum' fer too long.", - ) + npc("Ye'll need to use that bucket of yours. Most stuff can't stand bein' in contact with our 'rum' fer too long.") npc("Took us a lot of dissolvin' to work that one out.") set("rum_deal", "collect_swill") } hasSpace() -> { inventory.add("bucket") - npc( - "Here, ye'll need to use one of these. The rum tends to eat through " + - "almost everything else.", - ) + npc("Here, ye'll need to use one of these. The rum tends to eat through almost everything else.") set("rum_deal", "collect_swill") } else -> { - npc( - "Oh, and don't try using anything but a bucket. Our 'rum' tends to eat " + - "through stuff.", - ) + npc("Oh, and don't try using anything but a bucket. Our 'rum' tends to eat through stuff.") } } } @@ -451,10 +323,7 @@ class CaptainBraindeath : Script { player("So, who is he?") npc("Dunno ${ladOrLass()}.") npc("Almost as if I aren't changing subjects, well done!") - npc( - "With those rottin' dogs legless they'll never keep fighting us now, so we've " + - "decided to stay here and keep the 'rum' flowin!", - ) + npc("With those rottin' dogs legless they'll never keep fighting us now, so we've decided to stay here and keep the 'rum' flowin!") npc("Thanks, ${ladOrLass()}. We'd never have managed without ye!") completeRumDeal() } @@ -462,9 +331,7 @@ class CaptainBraindeath : Script { // ===== Progress 19: Post-quest "want some rum?" ===== private suspend fun Player.offerRum() { - npc( - "Fancy some 'rum', ${ladOrLass()}? It's still fresh. Well, fresh-ish...", - ) + npc("Fancy some 'rum', ${ladOrLass()}? It's still fresh. Well, fresh-ish...") player("No... I think I'll pass.") } @@ -483,16 +350,9 @@ class CaptainBraindeath : Script { } } - fun ChoiceOption.whatDoYouWantMeToDo(): Unit = option( - "What exactly do you want me to do?", - ) { + fun ChoiceOption.whatDoYouWantMeToDo(): Unit = option("What exactly do you want me to do?") { when (questStage("rum_deal")) { - 3 -> { - npc( - "Well, I need ye to go grow me some Blindweed, but ye are carryin' too " + - "much stuff fer me to give ye the seed.", - ) - } + 3 -> npc("Well, I need ye to go grow me some Blindweed, but ye are carryin' too much stuff fer me to give ye the seed.") 4 -> growBlindweedReminder() 6 -> putBlindweedInHopper() 8 -> goGetStagnantWater() @@ -504,65 +364,31 @@ class CaptainBraindeath : Script { } fun ChoiceOption.whyTalkLikePirate(): Unit = option("Why do you talk like a pirate?") { - player( - "Why do you talk like a pirate? Didn't you tell me you were an alchemist?", - ) + player("Why do you talk like a pirate? Didn't you tell me you were an alchemist?") npc("Arr, ${ladOrLass()}, 'tis true.") - npc( - "However, 'tis also true that I stumbled across the basic recipe fer my most " + - "potent of brews in a terrible alchemical accident.", - ) - npc( - "See, 'twas a dark and stormy night, and the wind was howlin' around the trees as " + - "I worked late into the night.", - ) - npc( - "Steppin' too close to a candle with my flask in my hand, I was suddenly swept up " + - "in a terrible, yet potently alcoholic, explosion.", - ) + npc("However, 'tis also true that I stumbled across the basic recipe fer my most potent of brews in a terrible alchemical accident.") + npc("See, 'twas a dark and stormy night, and the wind was howlin' around the trees as I worked late into the night.") + npc("Steppin' too close to a candle with my flask in my hand, I was suddenly swept up in a terrible, yet potently alcoholic, explosion.") player("And?") - npc( - "Well' ${ladOrLass()}, it seems the fumes from that first batch of me 'rum' did " + - "strange things to me brain.", - ) - npc( - "I don't remember the exact words the healers used, but apparently the stuff " + - "burned out the tiny, specialised part of me brain that tells me not to talk " + - "like a pirate.", - ) + npc("Well' ${ladOrLass()}, it seems the fumes from that first batch of me 'rum' did strange things to me brain.") + npc("I don't remember the exact words the healers used, but apparently the stuff burned out the tiny, specialised part of me brain that tells me not to talk like a pirate.") player("That... that... that sounds utterly impossible!") npc("Arr! That be what I told them!") } fun ChoiceOption.whatDoYouMakeHere(): Unit = option("So what do you make here anyway?") { npc("'Rum', ${ladOrLass()}!") - npc( - "The finest, most potent, most flammable and most debilitatin' 'rum' in the whole " + - "of RuneScape!", - ) + npc("The finest, most potent, most flammable and most debilitatin' 'rum' in the whole of RuneScape!") player("Rum, eh?") npc("No, ${ladOrLass()}, 'rum'!") player("What's the difference?") npc("Well, see, it's like this.") - npc( - "If we called the stuff we make 'rum' without makin' the little quote gestures " + - "every time, then the Cookin' Guild has promised to do entertaininly painful " + - "things to us with whisks.", - ) + npc("If we called the stuff we make 'rum' without makin' the little quote gestures every time, then the Cookin' Guild has promised to do entertaininly painful things to us with whisks.") npc("See, technically -") - npc( - "And by that I mean technically according to the Disposal of Hazardous Waste Act " + - "and the Health and Safety Laws -", - ) - npc( - "technically, what we're brewin' here is Artificially Produced Hyper Condensed " + - "Sweetened 'Rum' Flavour Distillate.", - ) + npc("And by that I mean technically according to the Disposal of Hazardous Waste Act and the Health and Safety Laws -") + npc("technically, what we're brewin' here is Artificially Produced Hyper Condensed Sweetened 'Rum' Flavour Distillate.") player("Riiiiiiiight...") - npc( - "So ye see, ${ladOrLass()}, we just call it 'rum' because the real name be a bit " + - "of a mouthful.", - ) + npc("So ye see, ${ladOrLass()}, we just call it 'rum' because the real name be a bit of a mouthful.") npc("Want a drop?") player("No thanks... I think I'll skip it for now.") } @@ -571,10 +397,7 @@ class CaptainBraindeath : Script { private suspend fun Player.growBlindweedReminder() { npc("Arr, well I want ye to get outside and grow some Blindweed.") - npc( - "Best be careful, ${ladOrLass()}, fer them pirates will skin ye alive if they " + - "catch ye.", - ) + npc("Best be careful, ${ladOrLass()}, fer them pirates will skin ye alive if they catch ye.") when { get("farming_blindweed_patch_braindeath_island", 0) >= 4 -> { npc("Ye'll need ter keep an eye on yer plants, ${ladOrLass()}.") @@ -585,57 +408,34 @@ class CaptainBraindeath : Script { } else -> { player("And what happens if I lose the seed?") - npc( - "Then I'll have ye flogged, hung, slapped with a haddock and sent back " + - "out there to fight the zombie hordes with little more than harsh language!", - ) + npc("Then I'll have ye flogged, hung, slapped with a haddock and sent back out there to fight the zombie hordes with little more than harsh language!") if (hasSpace()) { npc("Arr... Here, ${ladOrLass()}. Just take this and get goin'!") inventory.add("blindweed_seed") } else { - npc( - "Come back and see me when ye ain't loaded down with stuff, and I'll " + - "give ye another.", - ) + npc("Come back and see me when ye ain't loaded down with stuff, and I'll give ye another.") } } } } private suspend fun Player.putBlindweedInHopper() { - npc( - "I want ye to go put yer Blindweed in the intake hopper, and to be sharp about it!", - ) - if (inventory.contains("blindweed")) return - player( - "And what happens if I lose the Blindweed between here and the intake hopper?", - ) - player( - "Theoretically, of course, as it would be almost impossible for me to simply lose " + - "the stuff in the time it will take me.", - ) + npc("I want ye to go put yer Blindweed in the intake hopper, and to be sharp about it!") + if (inventory.contains("blindweed")) { + return + } + player("And what happens if I lose the Blindweed between here and the intake hopper?") + player("Theoretically, of course, as it would be almost impossible for me to simply lose the stuff in the time it will take me.") npc("Gah!") - npc( - "Yer nothin' better than a sea-slug wearin', lilly-legged, bandy-livered " + - "landlubber!", - ) + npc("Yer nothin' better than a sea-slug wearin', lilly-legged, bandy-livered landlubber!") if (hasSpace()) { - npc( - "Here, I was savin' a little fer an emergency, so take this and shove it in " + - "the hopper before I bung ye in there and turn the handle!", - ) + npc("Here, I was savin' a little fer an emergency, so take this and shove it in the hopper before I bung ye in there and turn the handle!") addOrDrop("blindweed") player("You had some all the time?") - player( - "Why was I risking my neck killing pirates to grow some if you had some all " + - "along?", - ) + player("Why was I risking my neck killing pirates to grow some if you had some all along?") npc("Less talk, ${ladOrLass()}, haven't ye got a hopper to be fillin'?") } else { - npc( - "I've got a little put aside fer an emergency. Drop some of that junk yer " + - "carryin', and come see me to get it.", - ) + npc("I've got a little put aside fer an emergency. Drop some of that junk yer carryin', and come see me to get it.") } } @@ -651,101 +451,66 @@ class CaptainBraindeath : Script { private suspend fun Player.pourBucketInHopper() { npc("I want ye to go pour yer bucket into yonder hopper.") npc("Take yer time, ${ladOrLass()}, we're only bein' besieged by zombies.") - if (inventory.contains("bucket_of_water_stagnant")) return + if (inventory.contains("bucket_of_water_stagnant")) { + return + } // Stolen-bucket scene player("Err... Captain...") npc("Aye, what is it, ${ladOrLass()}?") - player( - "Well, on my way over to the hopper some big boy stole the bucket and ran away...", - ) + player("Well, on my way over to the hopper some big boy stole the bucket and ran away...") npc("Give me strength!") npc("If ye weren't so good at distractin' the zombies I'd skin ye alive!") if (hasSpace()) { inventory.add("bucket_of_water_stagnant") - npc( - "Take this bucket and pour it into the hopper. If ye don't get it right this " + - "time I'll have yer guts fer garters!", - ) + npc("Take this bucket and pour it into the hopper. If ye don't get it right this time I'll have yer guts fer garters!") } else { - npc( - "Well, 'tis a good job I saved a little, isn't it? Now get rid of something " + - "so I can give it to ye.", - ) + npc("Well, 'tis a good job I saved a little, isn't it? Now get rid of something so I can give it to ye.") } } private suspend fun Player.catchFiveSeaCreatures() { - npc( - "I want ye to go outside and catch five sea creatures from the squid fishin' spot.", - ) - npc( - "When ye've grabbed them, jam 'em in the barrel in the attic and pressurise 'em.", - ) - if (inventory.contains("fishbowl_and_net")) return + npc("I want ye to go outside and catch five sea creatures from the squid fishin' spot.") + npc("When ye've grabbed them, jam 'em in the barrel in the attic and pressurise 'em.") + if (inventory.contains("fishbowl_and_net")) { + return + } player("How am I suppost to catch these things?") - npc( - "Ye'll be needin' to use a fishbowl in a big net to snare these little devils.", - ) + npc("Ye'll be needin' to use a fishbowl in a big net to snare these little devils.") player("And if I don't have one?") when { !inventory.contains("fishbowl") && inventory.contains("big_fishing_net") -> { if (hasSpace()) { inventory.add("fishbowl") - npc( - "Yer lucky all my precious fish died last week, ${ladOrLass()}. Take this and " + - "wrap it in that net, then go grab some Sluglings!", - ) + npc("Yer lucky all my precious fish died last week, ${ladOrLass()}. Take this and wrap it in that net, then go grab some Sluglings!") } else { - npc( - "Well, I'll give ye a fishbowl fer that net of yours if ye'll drop " + - "some of that clutter in yer pack.", - ) + npc("Well, I'll give ye a fishbowl fer that net of yours if ye'll drop some of that clutter in yer pack.") } } !inventory.contains("big_fishing_net") && inventory.contains("fishbowl") -> { npc("Gah! I don't believe it!") if (hasSpace()) { inventory.add("big_fishing_net") - npc( - "Here, wrap this around yer fishbowl and get back out there!", - ) + npc("Here, wrap this around yer fishbowl and get back out there!") } else { - npc( - "Well, I'll give ye a net fer that fishbowl of yours if ye'll drop " + - "some of that clutter in yer pack.", - ) + npc("Well, I'll give ye a net fer that fishbowl of yours if ye'll drop some of that clutter in yer pack.") } } else -> { npc("Why, I oughta...") if (hasSpace()) { - npc( - "Here! Try not ter lose this one! These things don't grow on trees, " + - "ye know.", - ) + npc("Here! Try not ter lose this one! These things don't grow on trees, ye know.") inventory.add("fishbowl_and_net") } else { - npc( - "Come see me when yer not so laden down. I've got another one fer ye " + - "if ye need it.", - ) + npc("Come see me when yer not so laden down. I've got another one fer ye if ye need it.") } } } } private suspend fun Player.clearEvilSpirit() { - npc( - "I want ye to go clear the Evil Spirit out of the brewin' controls. Use the " + - "wrench I gave ye, but get it blessed first.", - ) + npc("I want ye to go clear the Evil Spirit out of the brewin' controls. Use the wrench I gave ye, but get it blessed first.") when { - inventory.contains("holy_wrench") -> { - npc( - "I see ye've got the wrench good and blessed, so go whack the controls " + - "till the Evil Spirit pops out.", - ) - } + inventory.contains("holy_wrench") -> npc("I see ye've got the wrench good and blessed, so go whack the controls till the Evil Spirit pops out.") inventory.contains("wrench") -> { // Player has the unblessed wrench — nothing more to say } @@ -756,18 +521,12 @@ class CaptainBraindeath : Script { if (hasSpace()) { inventory.add("wrench") npc("Arr! I'd get more work out of a minty flavoured brick!") - npc( - "If ye lose this one I'll have ye turned inside out, covered with " + - "spiders and turned rightside in so they'll eat ye alive!", - ) + npc("If ye lose this one I'll have ye turned inside out, covered with spiders and turned rightside in so they'll eat ye alive!") player("Steady on!") } else { npc("Arr, ye landlubber!") npc("Calm down, remember yer ulcer...") - npc( - "Look, I've got another wrench, but ye don't have any free space fer " + - "it. Come back when ye do.", - ) + npc("Look, I've got another wrench, but ye don't have any free space fer it. Come back when ye do.") } } } @@ -775,12 +534,7 @@ class CaptainBraindeath : Script { private suspend fun Player.jamBodyInHopper() { when { - wearingSlayerGloves() -> { - npc( - "I see yer already wearin' some Slayer Gloves. That'll keep the Fever " + - "Spiders from gnawin' yer hands off!", - ) - } + wearingSlayerGloves() -> npc("I see yer already wearin' some Slayer Gloves. That'll keep the Fever Spiders from gnawin' yer hands off!") inventory.contains("fever_spider_body") -> { npc("I want ye to jam that body into the hopper and come see me.") npc("Arr! Yer not as slow as I took ye fer!") diff --git a/game/src/main/kotlin/content/area/morytania/braindeath_island/CaptainDonnie.kt b/game/src/main/kotlin/content/area/morytania/braindeath_island/CaptainDonnie.kt index 754f0dfed3..09965d0147 100644 --- a/game/src/main/kotlin/content/area/morytania/braindeath_island/CaptainDonnie.kt +++ b/game/src/main/kotlin/content/area/morytania/braindeath_island/CaptainDonnie.kt @@ -30,16 +30,10 @@ class CaptainDonnie : Script { } } - // ===== Default greeting (no swill, or wrong quest progress) ===== - private suspend fun Player.talkWithDonnie() { when { - inventory.contains("unsanitary_swill") && questStage("rum_deal") == 17 -> { - deliverSwillFirstTime() - } - inventory.contains("unsanitary_swill") -> { - deliverSwillRepeat() - } + inventory.contains("unsanitary_swill") && questStage("rum_deal") == 17 -> deliverSwillFirstTime() + inventory.contains("unsanitary_swill") -> deliverSwillRepeat() else -> initialGreeting() } } @@ -65,9 +59,7 @@ class CaptainDonnie : Script { npc("Hahahahahahahahahahahahaha!") npc("Bwahahahahahahahahahahahaha!") npc("Wheeze...wheeze...") - npc( - "Gadzooks lad, that be the funniest thing I've heard all day! Say it again!", - ) + npc("Gadzooks lad, that be the funniest thing I've heard all day! Say it again!") player("I have come to tell you to leave.") npc("Stop lad! I'll shatter me ribs!") } @@ -76,40 +68,26 @@ class CaptainDonnie : Script { player("I've come to ask you what you want.") npc("Whadda we want? 'Rum'! When do we want it? Now!") player("So...if I were to give you 'rum' you would leave?") - npc( - "Not really lad. If ye were to give us 'rum' we'd kill ye quickly, as opposed to " + - "over a few weeks.", - ) + npc("Not really lad. If ye were to give us 'rum' we'd kill ye quickly, as opposed to over a few weeks.") player("Oh...") } fun ChoiceOption.joinYourCrew(): Unit = option("Join your crew.") { player("I've come to join your crew!") player("Err, I mean...") - player( - "Arr! Shiver me mainbraces and make them landlubbers walk the scurvy plank, Cap'n! " + - "I've come to join yer cut-throat, bilge swillin' crew! Also, arr!", - ) + player("Arr! Shiver me mainbraces and make them landlubbers walk the scurvy plank, Cap'n! I've come to join yer cut-throat, bilge swillin' crew! Also, arr!") npc("Are ye quite done, lad?") player("Yes, for the time being anyway.") - npc( - "Well, ye'll be glad to know that after that little performance I'd be glad to " + - "have ye on me crew!", - ) + npc("Well, ye'll be glad to know that after that little performance I'd be glad to have ye on me crew!") player("Huzzah!") npc("Course, I'll have te kill ye first.") player("Oh...") - npc( - "Don't ye worry, lad. After we take the island I'll have the boss haul yer body " + - "to the temple and...", - ) + npc("Don't ye worry, lad. After we take the island I'll have the boss haul yer body to the temple and...") npc("Err, never mind.") player("Never mind what?") npc("Ferget I said anything.") } - // ===== Progress 17: First swill delivery (the trick) ===== - private suspend fun Player.deliverSwillFirstTime() { npc("Be that the finest, most abrasive 'rum' I've ever smelled?") player("Yes! That it be!") @@ -126,30 +104,20 @@ class CaptainDonnie : Script { npc("Arr, ye tricky dog!") npc("Ye tried to trick old Donnie!") player("Oh well, I guess I'll have to try again.") - npc( - "Arr, lad, you tried to trick me, but I was too clever for ye!", - ) - npc( - "Besides, Rabid Jack would have my hide if I told ye it were him that sent me!", - ) + npc("Arr, lad, you tried to trick me, but I was too clever for ye!") + npc("Besides, Rabid Jack would have my hide if I told ye it were him that sent me!") player("I'm sure he would. Good job you caught me out, eh!") npc("Aye! Now get ye gone, and don't return without more 'rum'!") } - // ===== Repeat swill delivery (later attempts) ===== - private suspend fun Player.deliverSwillRepeat() { npc("Hey you! I can smell the 'rum' on ye!") - npc( - "If it isn't in my belly by the time I cout ter three, I'll have ye flogged!", - ) + npc("If it isn't in my belly by the time I cout ter three, I'll have ye flogged!") inventory.remove("unsanitary_swill") statement("Donnie drinks the 'rum' like it is water.") npc("Arrr...that hits the spot...") } - // ===== Helpers ===== - /** * Once Donnie has drunk the swill the whole crew joins him, so every zombie pirate * outside the compound switches to its drunk variant. diff --git a/game/src/main/kotlin/content/area/morytania/braindeath_island/Davey.kt b/game/src/main/kotlin/content/area/morytania/braindeath_island/Davey.kt index 420256462a..7c174bc0f8 100644 --- a/game/src/main/kotlin/content/area/morytania/braindeath_island/Davey.kt +++ b/game/src/main/kotlin/content/area/morytania/braindeath_island/Davey.kt @@ -17,6 +17,7 @@ import world.gregs.voidps.engine.entity.character.player.skill.Skill import world.gregs.voidps.engine.inv.add import world.gregs.voidps.engine.inv.inventory import world.gregs.voidps.engine.inv.remove +import world.gregs.voidps.engine.inv.replace import world.gregs.voidps.type.Tile class Davey : Script { @@ -31,74 +32,37 @@ class Davey : Script { } } - // ===== Default greeting (player hasn't reached the wrench step yet) ===== - private suspend fun Player.defaultGreeting() { player("Hello!") npc("'Ello guv.") player("Why are you not helping out the others?") - npc( - "Well you see, guv, I've got these unshakable religious convictions.", - ) + npc("Well you see, guv, I've got these unshakable religious convictions.") player("And those are?") - npc( - "Well, it's like this, see. I used to do a bit of the old priestin' on the side.", - ) - npc( - "You know, collectin' for repairs to the church roof, passing the collection " + - "plate, jumble sales, nicking the lead off the temple roof so they needed " + - "repairing again.", - ) + npc("Well, it's like this, see. I used to do a bit of the old priestin' on the side.") + npc("You know, collectin' for repairs to the church roof, passing the collection plate, jumble sales, nicking the lead off the temple roof so they needed repairing again.") npc("Real holy stuff, you know.") player("That doesn't sound all that holy to me.") npc("Well, that's because you're not a man of the cloth are you?") - npc( - "You are, in fact, what we refer to in layman's terms as a punter.", - ) - npc( - "But anyway, all that priestin' left me with an unshakable faith in three things.", - ) + npc("You are, in fact, what we refer to in layman's terms as a punter.") + npc("But anyway, all that priestin' left me with an unshakable faith in three things.") npc("The power of good over evil.") npc("The true glory of the human spirit.") - npc( - "And that I ain't goin out there with those things runnin' around.", - ) + npc("And that I ain't goin out there with those things runnin' around.") } - // ===== Progress 13: Wrench blessing request ===== - private suspend fun Player.rumDealHelpRequest(dave: NPC) { player("You used to be a priest, right?") - npc( - "I didn't nick anything, guv. I've got twenty people who'll swear blind I was...", - ) + npc("I didn't nick anything, guv. I've got twenty people who'll swear blind I was...") player("What?") - player( - "Never mind. I need some help with the spirit in the brewing equipment.", - ) + player("Never mind. I need some help with the spirit in the brewing equipment.") when { - inventory.contains("holy_wrench") -> { - npc( - "You mean more help? I've already blessed one wrench for you, that should " + - "be enough.", - ) - } - !inventory.contains("wrench") -> { - npc( - "Well, I'll bless something for you if you need it, but just don't spread " + - "it around that I'm giving out freebies.", - ) - } + inventory.contains("holy_wrench") -> npc("You mean more help? I've already blessed one wrench for you, that should be enough.") + !inventory.contains("wrench") -> npc("Well, I'll bless something for you if you need it, but just don't spread it around that I'm giving out freebies.") levels.get(Skill.Prayer) >= 47 -> { - npc( - "Trust me, guv, lots of people need help after coming into contact with " + - "the spirits we produce here.", - ) - askToBlessWrench(dave) - } - else -> { + npc("Trust me, guv, lots of people need help after coming into contact with the spirits we produce here.") askToBlessWrench(dave) } + else -> askToBlessWrench(dave) } } @@ -111,17 +75,12 @@ class Davey : Script { player("Nope?") npc("Nope. You don't strike me as being... devout, you know?") player("Come again?") - npc( - "Your holy aura, it's a little shoddy, if you catch my drift.", - ) + npc("Your holy aura, it's a little shoddy, if you catch my drift.") player("What?") npc("You need a Prayer of 47.") player("Oh!") player("Well why didn't you just say so?") - npc( - "I might have had a bit of an overestimation problem guv. I'm sure it won't " + - "happen again.", - ) + npc("I might have had a bit of an overestimation problem guv. I'm sure it won't happen again.") } } @@ -135,57 +94,35 @@ class Davey : Script { playBlessingCutscene(dave) player("Thanks!") npc("No problem, guv. Good luck with your little problem.") - npc( - "You might find that little wrench worth hanging on to after you're done with the " + - "Spirit.", - ) - player( - "Really? I mean, it's holy and everything, but I don't think it looks all that " + - "useful.", - ) - npc( - "Well, it may not look much, but you'll find that you might need a few less " + - "prayer potions if you have it in your pack, if you know what I mean.", - ) - player( - "Well, no. I don't know what you mean, but I'm sure I'll find out!", - ) + npc("You might find that little wrench worth hanging on to after you're done with the Spirit.") + player("Really? I mean, it's holy and everything, but I don't think it looks all that useful.") + npc("Well, it may not look much, but you'll find that you might need a few less prayer potions if you have it in your pack, if you know what I mean.") + player("Well, no. I don't know what you mean, but I'm sure I'll find out!") } - // ===== Progress 19: Post-quest greeting ===== - private suspend fun Player.postQuestGreeting() { if (inventory.contains("holy_wrench")) { npc("Ello again guv. How's things?") player("Good! Everything seems to have worked out ok.") - npc( - "I'm sure it'll stay that way so long as you don't know it's missing.", - ) + npc("I'm sure it'll stay that way so long as you don't know it's missing.") player("What?") npc("Nothin' guv, you just have a nice day.") } else { npc("Ello again guv. I take it you're here about the wrench?") player("What? The Holy Wrench? But I lost it somewhere...") npc("Well guv, maybe that's what I want you to think.") - if (inventory.spaces > 0) { - inventory.add("holy_wrench") + if (inventory.add("holy_wrench")) { npc("There you go. I've kept it nice and holy for you.") player("Thanks...") } else { - npc( - "As soon as you've got somewhere to put it I'll hand it over. I'll keep it " + - "nice and holy until then.", - ) + npc("As soon as you've got somewhere to put it I'll hand it over. I'll keep it nice and holy until then.") } } } - // ===== Helpers ===== - private suspend fun Player.playBlessingCutscene(davey: NPC) { davey.anim("emote_cheer") - inventory.remove("wrench") - inventory.add("holy_wrench") + inventory.replace("wrench", "holy_wrench") delay(4) moveCamera( tile = Tile(2134, 5099), diff --git a/game/src/main/kotlin/content/area/morytania/braindeath_island/FeverSpider.kt b/game/src/main/kotlin/content/area/morytania/braindeath_island/FeverSpider.kt index c379ea1527..bf3e43e4c7 100644 --- a/game/src/main/kotlin/content/area/morytania/braindeath_island/FeverSpider.kt +++ b/game/src/main/kotlin/content/area/morytania/braindeath_island/FeverSpider.kt @@ -11,7 +11,9 @@ import world.gregs.voidps.network.login.protocol.visual.update.player.EquipSlot class FeverSpider : Script { init { npcCondition("slayer_gloves") { it is Player && it.equipped(EquipSlot.Hands).id == "slayer_gloves" } + npcCondition("no_slayer_gloves") { it is Player && it.equipped(EquipSlot.Hands).id != "slayer_gloves" } + npcAttack("fever_spider", "disease") { // Protecting from melee blocks the bite itself but not the disease, which the // combat definition applies regardless. diff --git a/game/src/main/kotlin/content/area/morytania/braindeath_island/Luke.kt b/game/src/main/kotlin/content/area/morytania/braindeath_island/Luke.kt index 3fc8fba46e..6973b6b722 100644 --- a/game/src/main/kotlin/content/area/morytania/braindeath_island/Luke.kt +++ b/game/src/main/kotlin/content/area/morytania/braindeath_island/Luke.kt @@ -21,17 +21,12 @@ class Luke : Script { player("Are you all right?") npc("Who goes there?") npc("Arr! A landlubber!") - npc( - "Begone afore I take my cutlass to ye! I've been charged with guardin' this " + - "gate and no noodle-armed landlubbers will make it past me alive!", - ) + npc("Begone afore I take my cutlass to ye! I've been charged with guardin' this gate and no noodle-armed landlubbers will make it past me alive!") player("That's not a cutlass.") player("I think it's a twig.") npc("Ye cheeky begger! I was wavin' my finger at ye!") player("All right...") - npc( - "Arrr... just 'cos me body happens to be 50% wood does not mean I'm heartless.", - ) + npc("Arrr... just 'cos me body happens to be 50% wood does not mean I'm heartless.") npc("I got a bag of 'em here. Wanna see?") player("I think I'll pass.") mainQuestionsChoice() @@ -50,58 +45,25 @@ class Luke : Script { npc("Well, it all starts with this albatross...") npc("Wait, never mind, I'll skip forward a bit.") player("Are you sure?") - npc( - "I have to lad, Cap'n Donnie will flay what little is left of me if I told ye.", - ) - npc( - "But anyway, I got recruited to the zombie pirates along with the rest of the " + - "crew in an unspecified incident involvin this albatross.", - ) - npc( - "We was sailin' along happily, and I was partakin' of a little 'rum' in the crows " + - "nest.", - ) + npc("I have to lad, Cap'n Donnie will flay what little is left of me if I told ye.") + npc("But anyway, I got recruited to the zombie pirates along with the rest of the crew in an unspecified incident involvin this albatross.") + npc("We was sailin' along happily, and I was partakin' of a little 'rum' in the crows nest.",) npc("Well, we hit either a really rough wave or some rocks.") - npc( - "Twas kind of hard for me to tell which, as I was well out of it by then!", - ) + npc("Twas kind of hard for me to tell which, as I was well out of it by then!") npc("Regardless I toppled from the crows nest into the water.") player("Is that how you got so badly injured?") npc("No ${ladOrLass()}!") - npc( - "What happened next was that I discovered a new, previously uncharted reef of " + - "hard, spiky coral.", - ) - npc( - "I made a mental note of its location, and to this day it is still marked on our " + - "fleet's charts as Lukes Reef.", - ) - npc( - "I managed to grab a hold of our ship, the Inebriated, as it passed overhead.", - ) - npc( - "And then I discovered another, taller, spikier reef of even sharper and more " + - "painful coral.", - ) - npc( - "To this day it is still marked on our fleet's charts as The Other 50% Reef.", - ) + npc("What happened next was that I discovered a new, previously uncharted reef of hard, spiky coral.") + npc("I made a mental note of its location, and to this day it is still marked on our fleet's charts as Lukes Reef.") + npc("I managed to grab a hold of our ship, the Inebriated, as it passed overhead.",) + npc("And then I discovered another, taller, spikier reef of even sharper and more painful coral.") + npc("To this day it is still marked on our fleet's charts as The Other 50% Reef.",) player("Owwwwwwwww...") npc("It gets worse...") - npc( - "When they hauled what was left of me on deck, they dropped me onto the floor " + - "while they decided what to do with me.", - ) - npc( - "Bear in mind this would be on a ghost ship, the planks of which sweat a thick " + - "mixture of stagnant water...and pure salt crystals.", - ) + npc("When they hauled what was left of me on deck, they dropped me onto the floor while they decided what to do with me.") + npc("Bear in mind this would be on a ghost ship, the planks of which sweat a thick mixture of stagnant water...and pure salt crystals.") player("Oh...my...god...") - npc( - "But on the good side, all my thrashin' and pained squealin' settled the matter in " + - "the Captain's mind, and he had the shipwright carve me half a body out of his " + - "Witchwood Planks.", - ) + npc("But on the good side, all my thrashin' and pained squealin' settled the matter in the Captain's mind, and he had the shipwright carve me half a body out of his Witchwood Planks.") player("Witchwood? What's that?") npc("Tis a special, magical wood from a now extinct tree.") npc("Once they nailed it all in place the stuff moves like it is part of me body.") @@ -127,18 +89,11 @@ class Luke : Script { player("But seriously, what is going on here?") npc("I can't tell ye lad.") npc("The Cap'n would have me whittled down to toothpicks if I did.") - player( - "Well if you can't tell me, perhaps you could show me through the medium of " + - "Interpretive Dance?", - ) + player("Well if you can't tell me, perhaps you could show me through the medium of Interpretive Dance?") npc("No. Just...no.") player("Mime?") - npc( - "Look, lad I'm not tellin' ye a thing! So clear out while ye still can!", - ) + npc("Look, lad I'm not tellin' ye a thing! So clear out while ye still can!") } - // ===== Helpers ===== - private fun Player.ladOrLass(): String = if (male) "lad" else "lass" } diff --git a/game/src/main/kotlin/content/area/morytania/braindeath_island/ZombieProtester.kt b/game/src/main/kotlin/content/area/morytania/braindeath_island/ZombieProtester.kt index 10cb8b4467..e24933ddbb 100644 --- a/game/src/main/kotlin/content/area/morytania/braindeath_island/ZombieProtester.kt +++ b/game/src/main/kotlin/content/area/morytania/braindeath_island/ZombieProtester.kt @@ -60,8 +60,6 @@ class ZombieProtester : Script { } } - // ===== Pre-Donnie-drinking-swill: Just yelling "Arr!" ===== - private suspend fun Player.soberProtester() { player("Excuse me, but...") npc("Arr!") @@ -72,15 +70,12 @@ class ZombieProtester : Script { player("Fiiine...") } - // ===== Post-Donnie-drinking-swill: They mistake the player for a pirate ===== - private suspend fun Player.drunkProtester() { npc("Arrrr! Tis yerself! Have a drink!") - player( - "Errr...Arrr! I will in a sec, I've just go to, err, plunder some landlubbers...", - ) + player("Errr...Arrr! I will in a sec, I've just go to, err, plunder some landlubbers...") npc("Good huntin'!") } + companion object { /** How far a protester will chase from its post before losing interest. */ private const val LEASH_RANGE = 10 diff --git a/game/src/main/kotlin/content/area/morytania/braindeath_island/ZombieSwab.kt b/game/src/main/kotlin/content/area/morytania/braindeath_island/ZombieSwab.kt index 5b897ee3cc..83a0933752 100644 --- a/game/src/main/kotlin/content/area/morytania/braindeath_island/ZombieSwab.kt +++ b/game/src/main/kotlin/content/area/morytania/braindeath_island/ZombieSwab.kt @@ -82,8 +82,7 @@ class ZombieSwab : Script { npc("Yer goin ter die!") } statement( - "We interrupt this insult to bring you this soothing picture of a kitten. " + - "Normal service will be resumed shortly. Thank you for your patience.", + "We interrupt this insult to bring you this soothing picture of a kitten. Normal service will be resumed shortly. Thank you for your patience.", clickToContinue = false, ) open("rum_deal_censor") @@ -95,10 +94,7 @@ class ZombieSwab : Script { anim("emote_angry") when (number) { 1 -> { - player( - "...meaning you'll need a dozen Ogres and a team of highly skilled surgeons " + - "to remove them!", - ) + player("...meaning you'll need a dozen Ogres and a team of highly skilled surgeons to remove them!") backDown(swab) } 2 -> { @@ -106,10 +102,7 @@ class ZombieSwab : Script { backDown(swab) } 3 -> { - player( - "...and then I'll use a wrench to remove the handles, so you'll see it " + - "every time you cough!", - ) + player("...and then I'll use a wrench to remove the handles, so you'll see it every time you cough!") backDown(swab) } 4, 5 -> { @@ -117,10 +110,7 @@ class ZombieSwab : Script { swab.respawn(swab.def["respawn_delay", 50]) } 6 -> { - player( - "...so that you will know exactly what time it is due to the horrible pain " + - "in your earlobes!", - ) + player("...so that you will know exactly what time it is due to the horrible pain in your earlobes!") backDown(swab) } } From 15fd8320441027127d28afa86d9e5ef110ad23a2 Mon Sep 17 00:00:00 2001 From: GregHib Date: Wed, 2 Sep 2026 15:55:25 +0100 Subject: [PATCH 05/10] Formatting --- .../content/area/morytania/braindeath_island/Davey.kt | 1 - .../kotlin/content/area/morytania/braindeath_island/Luke.kt | 6 +++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/game/src/main/kotlin/content/area/morytania/braindeath_island/Davey.kt b/game/src/main/kotlin/content/area/morytania/braindeath_island/Davey.kt index 7c174bc0f8..93938df5ce 100644 --- a/game/src/main/kotlin/content/area/morytania/braindeath_island/Davey.kt +++ b/game/src/main/kotlin/content/area/morytania/braindeath_island/Davey.kt @@ -16,7 +16,6 @@ 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.add import world.gregs.voidps.engine.inv.inventory -import world.gregs.voidps.engine.inv.remove import world.gregs.voidps.engine.inv.replace import world.gregs.voidps.type.Tile diff --git a/game/src/main/kotlin/content/area/morytania/braindeath_island/Luke.kt b/game/src/main/kotlin/content/area/morytania/braindeath_island/Luke.kt index 6973b6b722..1f53b88db8 100644 --- a/game/src/main/kotlin/content/area/morytania/braindeath_island/Luke.kt +++ b/game/src/main/kotlin/content/area/morytania/braindeath_island/Luke.kt @@ -47,7 +47,7 @@ class Luke : Script { player("Are you sure?") npc("I have to lad, Cap'n Donnie will flay what little is left of me if I told ye.") npc("But anyway, I got recruited to the zombie pirates along with the rest of the crew in an unspecified incident involvin this albatross.") - npc("We was sailin' along happily, and I was partakin' of a little 'rum' in the crows nest.",) + npc("We was sailin' along happily, and I was partakin' of a little 'rum' in the crows nest.") npc("Well, we hit either a really rough wave or some rocks.") npc("Twas kind of hard for me to tell which, as I was well out of it by then!") npc("Regardless I toppled from the crows nest into the water.") @@ -55,9 +55,9 @@ class Luke : Script { npc("No ${ladOrLass()}!") npc("What happened next was that I discovered a new, previously uncharted reef of hard, spiky coral.") npc("I made a mental note of its location, and to this day it is still marked on our fleet's charts as Lukes Reef.") - npc("I managed to grab a hold of our ship, the Inebriated, as it passed overhead.",) + npc("I managed to grab a hold of our ship, the Inebriated, as it passed overhead.") npc("And then I discovered another, taller, spikier reef of even sharper and more painful coral.") - npc("To this day it is still marked on our fleet's charts as The Other 50% Reef.",) + npc("To this day it is still marked on our fleet's charts as The Other 50% Reef.") player("Owwwwwwwww...") npc("It gets worse...") npc("When they hauled what was left of me on deck, they dropped me onto the floor while they decided what to do with me.") From 854bb203707a8196d9e8dfb81e8b739b0fb58c9d Mon Sep 17 00:00:00 2001 From: GregHib Date: Wed, 2 Sep 2026 17:48:23 +0100 Subject: [PATCH 06/10] Fix how arrow hints work --- .../voidps/engine/client/EncodeExtensions.kt | 104 ++++++++++-------- .../lumbridge/swamp/chams_of_tears/Juna.kt | 20 +--- .../daemonheim/DungeoneeringTutor.kt | 7 +- .../kotlin/content/entity/death/NPCDeath.kt | 2 + .../content/entity/death/PlayerDeath.kt | 2 + .../kotlin/content/entity/npc/NPCHints.kt | 53 --------- .../content/minigame/barrows/BarrowsCrypts.kt | 3 +- .../kotlin/content/quest/InstanceLogout.kt | 2 + .../quest/member/ghosts_ahoy/GhostsAhoy.kt | 4 +- .../content/quest/member/rum_deal/RumDeal.kt | 7 +- .../skill/dungeoneering/DungeonMark.kt | 4 +- .../kotlin/content/entity/npc/NPCHintsTest.kt | 58 ---------- .../quest/member/rum_deal/RumDealTest.kt | 4 +- .../login/protocol/encode/HintEncoder.kt | 16 ++- 14 files changed, 93 insertions(+), 193 deletions(-) delete mode 100644 game/src/main/kotlin/content/entity/npc/NPCHints.kt delete mode 100644 game/src/test/kotlin/content/entity/npc/NPCHintsTest.kt diff --git a/engine/src/main/kotlin/world/gregs/voidps/engine/client/EncodeExtensions.kt b/engine/src/main/kotlin/world/gregs/voidps/engine/client/EncodeExtensions.kt index ccd11814e6..b7fd5a63c0 100644 --- a/engine/src/main/kotlin/world/gregs/voidps/engine/client/EncodeExtensions.kt +++ b/engine/src/main/kotlin/world/gregs/voidps/engine/client/EncodeExtensions.kt @@ -1,12 +1,12 @@ package world.gregs.voidps.engine.client import world.gregs.voidps.engine.client.ui.chat.Colours -import world.gregs.voidps.engine.client.update.view.Viewport import world.gregs.voidps.engine.data.definition.ClientScriptDefinitions import world.gregs.voidps.engine.data.definition.FontDefinitions import world.gregs.voidps.engine.entity.character.Character import world.gregs.voidps.engine.entity.character.npc.NPC import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.entity.character.player.Players import world.gregs.voidps.engine.entity.character.player.chat.ChatType import world.gregs.voidps.engine.get import world.gregs.voidps.network.login.protocol.encode.* @@ -213,67 +213,83 @@ fun Player.minimap(vararg states: Minimap) { fun Player.clearMinimap() = client?.sendMinimapState(0) ?: Unit /** - * The slot the next hint arrow goes in. Slots are only freed by [clearHint], so content that - * forgets to clear one leaks it; rather than dropping the arrow entirely once all eight are taken, - * the first slot is reused. + * Add an [arrow] hint to a [tile] with [radius] */ -private fun Viewport.freeHint(): Int { - val index = hints.indexOfFirst { it == 0 } - if (index == -1) { - return 0 +fun Player.markHint(tile: Tile, radius: Int = 1, arrow: Int = HintArrow.FILLED, direction: Direction = Direction.NONE, height: Int = 0) { + val viewport = viewport ?: return + val type = when (direction) { + Direction.WEST -> HintType.WEST + Direction.EAST -> HintType.EAST + Direction.SOUTH -> HintType.SOUTH + Direction.NORTH -> HintType.NORTH + else -> HintType.TILE } - return index + val index = hintIndex(type) ?: return + viewport.hints[index] = type + client?.arrowHint(type, index, sprite = arrow, x = tile.x, y = tile.y, level = tile.level, z = height, radius = radius) + return } /** - * Add an [arrow] hint to a [tile] with [radius] + * Clears arrow hints for those who have this character hinted */ -fun Player.hint(tile: Tile, radius: Int = 1, arrow: Int = HintArrow.FILLED, direction: Direction = Direction.NONE, height: Int = 0): Int { - val viewport = viewport ?: return -1 - val index = viewport.freeHint() - val type = when (direction) { - Direction.WEST -> 3 - Direction.EAST -> 4 - Direction.SOUTH -> 5 - Direction.NORTH -> 6 - else -> 2 +fun Character.clearHinted() { + val hints: Set = get("hinted_players") ?: return + val type = if (this is NPC) HintType.NPC else HintType.PLAYER + for (accountName in hints) { + val player = Players.findByAccount(accountName) ?: continue + player.clearHint(type, index) } - viewport.hints[index] = type - client?.arrowHint(type, index, sprite = arrow, x = tile.x, y = tile.y, level = tile.level, z = height, radius = radius) - return index + clear("hinted_players") } /** * Add an [arrow] hint over [character] */ -fun Player.hint(character: Character, arrow: Int = HintArrow.FILLED): Int { - val viewport = viewport ?: return -1 - val index = viewport.freeHint() - val type = when (character) { - is Player -> 10 - is NPC -> 1 - else -> return -1 - } - viewport.hints[index] = type +fun Player.markHint(character: Character, arrow: Int = HintArrow.FILLED) { + val viewport = viewport ?: return + val type = if (character is NPC) HintType.NPC else HintType.PLAYER + val hints = character.getOrPut("hinted_players") { mutableSetOf() } + hints.add(accountName) + val index = hintIndex(type) ?: return + viewport.hints[index] = character.index client?.arrowHint(type, index, sprite = arrow, entityIndex = character.index) - return index +} + +fun Player.clearHint(type: Int, entityIndex: Int) { + val viewport = viewport ?: return + val index = hintIndex(type) ?: return + if (viewport.hints[index] == entityIndex) { + clearHint(type) + } } /** - * Clear the hint at [index] (or all if -1) + * Clear the [type] hint */ -fun Player.clearHint(index: Int = -1) { +fun Player.clearHint(type: Int) { + val viewport = viewport ?: return + val index = hintIndex(type) ?: return + if (viewport.hints[index] == 0) { + return + } + client?.arrowHint(HintType.CLEAR, index) + viewport.hints[index] = 0 +} + +private fun hintIndex(type: Int) = when (type) { + HintType.CLEAR -> null + HintType.PLAYER -> 7 + else -> type - 1 +} + +fun Player.clearHints() { val viewport = viewport ?: return - if (index == -1) { - for (i in viewport.hints.indices) { - if (viewport.hints[i] == 0) { - continue - } - client?.arrowHint(0, i) - viewport.hints[i] = 0 + for (i in viewport.hints.indices) { + if (viewport.hints[i] == 0) { + continue } - } else { - client?.arrowHint(0, index) - viewport.hints[index] = 0 + client?.arrowHint(HintType.CLEAR, i) + viewport.hints[i] = 0 } } \ No newline at end of file diff --git a/game/src/main/kotlin/content/area/misthalin/lumbridge/swamp/chams_of_tears/Juna.kt b/game/src/main/kotlin/content/area/misthalin/lumbridge/swamp/chams_of_tears/Juna.kt index 652feee85e..297671a42e 100644 --- a/game/src/main/kotlin/content/area/misthalin/lumbridge/swamp/chams_of_tears/Juna.kt +++ b/game/src/main/kotlin/content/area/misthalin/lumbridge/swamp/chams_of_tears/Juna.kt @@ -1,22 +1,12 @@ package content.area.misthalin.lumbridge.swamp.chams_of_tears import content.entity.player.dialogue.* -import content.entity.player.dialogue.type.ChoiceOption -import content.entity.player.dialogue.type.choice -import content.entity.player.dialogue.type.npc -import content.entity.player.dialogue.type.player -import content.entity.player.dialogue.type.statement +import content.entity.player.dialogue.type.* import content.entity.player.modal.Tab import content.entity.player.modal.tab -import content.quest.closeTabs -import content.quest.openTabs -import content.quest.quest -import content.quest.questComplete -import content.quest.questCompleted -import content.quest.refreshQuestJournal +import content.quest.* import world.gregs.voidps.engine.Script import world.gregs.voidps.engine.client.clearCamera -import world.gregs.voidps.engine.client.command.adminCommand import world.gregs.voidps.engine.client.message import world.gregs.voidps.engine.client.moveCamera import world.gregs.voidps.engine.client.turnCamera @@ -58,12 +48,6 @@ import kotlin.math.floor class Juna : Script { init { - adminCommand("test") { - clearCamera() - moveCamera(Tile(3233, 9505, 2), height = 700, speed = 5, acceleration = 5) - turnCamera(Tile(3227, 9493, 2), height = 200, speed = 5, acceleration = 5) - } - objectOperate("Talk-to", "juna_tears,juna") { (target) -> talkWith(junaNpc()) if (equipped(EquipSlot.Weapon).id == "stone_bowl") { diff --git a/game/src/main/kotlin/content/area/wilderness/daemonheim/DungeoneeringTutor.kt b/game/src/main/kotlin/content/area/wilderness/daemonheim/DungeoneeringTutor.kt index f70e267a6e..55012d9251 100644 --- a/game/src/main/kotlin/content/area/wilderness/daemonheim/DungeoneeringTutor.kt +++ b/game/src/main/kotlin/content/area/wilderness/daemonheim/DungeoneeringTutor.kt @@ -13,12 +13,13 @@ 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.client.clearHint -import world.gregs.voidps.engine.client.hint +import world.gregs.voidps.engine.client.markHint import world.gregs.voidps.engine.client.ui.open 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.engine.timer.toTicks +import world.gregs.voidps.network.login.protocol.encode.HintType import world.gregs.voidps.type.Tile import java.util.concurrent.TimeUnit @@ -42,7 +43,7 @@ class DungeoneeringTutor : Script { } timerStop("dungeoneering_tutor_hint_timeout") { - clearHint() + clearHint(HintType.NPC) } } @@ -70,7 +71,7 @@ class DungeoneeringTutor : Script { choice { option("Yes, please.") { npc("It's this way.") - hint(Tile(3449, 3744), radius = 2) + markHint(Tile(3449, 3744), radius = 2) softTimers.start("dungeoneering_tutor_hint_timeout") } option("No thanks, not right now.") { diff --git a/game/src/main/kotlin/content/entity/death/NPCDeath.kt b/game/src/main/kotlin/content/entity/death/NPCDeath.kt index 6102912454..6d38d992d1 100644 --- a/game/src/main/kotlin/content/entity/death/NPCDeath.kt +++ b/game/src/main/kotlin/content/entity/death/NPCDeath.kt @@ -11,6 +11,7 @@ import content.entity.player.inv.item.tradeable import content.skill.slayer.* import content.social.clan.clan import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.client.clearHinted import world.gregs.voidps.engine.client.message import world.gregs.voidps.engine.client.ui.chat.plural import world.gregs.voidps.engine.data.Settings @@ -71,6 +72,7 @@ class NPCDeath( if (combat.deathSound != null) { (killer as? Player)?.sound(combat.deathSound!!.id) } + clearHinted() delay(if (ticks == 0) 4 else ticks) if (killer is Player) { AuditLog.event(killer, "killed", npc, tile) diff --git a/game/src/main/kotlin/content/entity/death/PlayerDeath.kt b/game/src/main/kotlin/content/entity/death/PlayerDeath.kt index 4d1f03dc64..72660f5e0c 100644 --- a/game/src/main/kotlin/content/entity/death/PlayerDeath.kt +++ b/game/src/main/kotlin/content/entity/death/PlayerDeath.kt @@ -20,6 +20,7 @@ import content.skill.prayer.getActivePrayerVarKey import content.skill.prayer.praying import content.skill.summoning.dismissFamiliar import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.client.clearHinted import world.gregs.voidps.engine.client.message import world.gregs.voidps.engine.data.Settings import world.gregs.voidps.engine.data.definition.Areas @@ -71,6 +72,7 @@ class PlayerDeath : Script { message("Oh dear, you are dead!") anim("human_death") queue.clear() + clearHinted() delay(5) clearAnim() attackers.clear() diff --git a/game/src/main/kotlin/content/entity/npc/NPCHints.kt b/game/src/main/kotlin/content/entity/npc/NPCHints.kt deleted file mode 100644 index ada57c445e..0000000000 --- a/game/src/main/kotlin/content/entity/npc/NPCHints.kt +++ /dev/null @@ -1,53 +0,0 @@ -package content.entity.npc - -import world.gregs.voidps.engine.Script -import world.gregs.voidps.engine.client.clearHint -import world.gregs.voidps.engine.client.hint -import world.gregs.voidps.engine.entity.character.npc.NPC -import world.gregs.voidps.engine.entity.character.player.Player -import world.gregs.voidps.engine.entity.character.player.Players -import world.gregs.voidps.network.login.protocol.encode.HintArrow - -/** - * Hint arrows sit in one of eight per-player viewport slots and are only handed back by - * [clearHint], so an arrow left on an npc that dies or times out costs that player a slot for the - * rest of their session. Marking through [markHint] records the slot on the npc so it can be - * released when the npc goes away. - */ -class NPCHints : Script { - init { - npcDespawn { - clearHints() - } - } -} - -/** - * Points a hint arrow at this npc for [player], releasing it when the npc despawns - which covers - * dying, since a spawned npc with nowhere to respawn to is removed once its death finishes. - */ -fun NPC.markHint(player: Player, arrow: Int = HintArrow.FILLED) { - val slots: MutableMap = getOrPut("hint_slots") { mutableMapOf() } - // Free the old slot first so re-marking reuses it rather than taking a second - val previous = slots.remove(player.accountName) - if (previous != null) { - player.clearHint(previous) - } - val slot = player.hint(this, arrow) - if (slot == -1) { - return - } - slots[player.accountName] = slot -} - -/** - * Hands back every arrow slot [markHint] took for this npc. Safe to call more than once - the - * second call finds nothing rather than clearing a slot something else has since taken. - */ -fun NPC.clearHints() { - val slots: MutableMap = get("hint_slots") ?: return - clear("hint_slots") - for ((account, slot) in slots) { - Players.findByAccount(account)?.clearHint(slot) - } -} diff --git a/game/src/main/kotlin/content/minigame/barrows/BarrowsCrypts.kt b/game/src/main/kotlin/content/minigame/barrows/BarrowsCrypts.kt index 8028b669cc..0b97caa20e 100644 --- a/game/src/main/kotlin/content/minigame/barrows/BarrowsCrypts.kt +++ b/game/src/main/kotlin/content/minigame/barrows/BarrowsCrypts.kt @@ -1,7 +1,6 @@ package content.minigame.barrows import content.entity.combat.killer -import content.entity.npc.markHint import content.entity.obj.door.enterDoor import content.entity.player.dialogue.type.choice import content.entity.player.dialogue.type.statement @@ -236,7 +235,7 @@ class BarrowsCrypts : Script { npc.say(if (npc.tile.level == 3) "You dare disturb my rest!" else "You dare steal from us!") npc.interactPlayer(player, "Attack") player["${brother}_spawn"] = npc.index - npc.markHint(player) + player.markHint(npc) } } } diff --git a/game/src/main/kotlin/content/quest/InstanceLogout.kt b/game/src/main/kotlin/content/quest/InstanceLogout.kt index 8477c07e76..b4308ed9e7 100644 --- a/game/src/main/kotlin/content/quest/InstanceLogout.kt +++ b/game/src/main/kotlin/content/quest/InstanceLogout.kt @@ -1,6 +1,7 @@ package content.quest import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.client.clearHinted class InstanceLogout : Script { init { @@ -16,6 +17,7 @@ class InstanceLogout : Script { if (get("instance_logout", false)) { exitInstance() } + clearHinted() } } } diff --git a/game/src/main/kotlin/content/quest/member/ghosts_ahoy/GhostsAhoy.kt b/game/src/main/kotlin/content/quest/member/ghosts_ahoy/GhostsAhoy.kt index 9cd4773803..90aa061552 100644 --- a/game/src/main/kotlin/content/quest/member/ghosts_ahoy/GhostsAhoy.kt +++ b/game/src/main/kotlin/content/quest/member/ghosts_ahoy/GhostsAhoy.kt @@ -1,7 +1,6 @@ package content.quest.member.ghosts_ahoy import content.entity.effect.transform -import content.entity.npc.markHint import content.entity.player.bank.ownsItem import content.entity.player.dialogue.Neutral import content.entity.player.dialogue.type.item @@ -16,6 +15,7 @@ import content.quest.questStage import content.quest.refreshQuestJournal import world.gregs.voidps.engine.Script import world.gregs.voidps.engine.client.instruction.handle.interactPlayer +import world.gregs.voidps.engine.client.markHint import world.gregs.voidps.engine.client.message import world.gregs.voidps.engine.entity.character.jingle import world.gregs.voidps.engine.entity.character.npc.NPCs @@ -280,7 +280,7 @@ class GhostsAhoy : Script { } else if (lobster == null) { message("You are attacked by a giant lobster!!") val lobster = NPCs.add("giant_lobster_ghosts_ahoy", Tile(3616, 3543, 0), ticks = TimeUnit.MINUTES.toTicks(8), owner = this) - lobster.markHint(this) + markHint(lobster) lobster.interactPlayer(this, "Attack") } return@objectOperate diff --git a/game/src/main/kotlin/content/quest/member/rum_deal/RumDeal.kt b/game/src/main/kotlin/content/quest/member/rum_deal/RumDeal.kt index 49490c442b..df2ab80280 100644 --- a/game/src/main/kotlin/content/quest/member/rum_deal/RumDeal.kt +++ b/game/src/main/kotlin/content/quest/member/rum_deal/RumDeal.kt @@ -1,8 +1,6 @@ package content.quest.member.rum_deal import content.entity.combat.killer -import content.entity.npc.clearHints -import content.entity.npc.markHint import content.entity.player.dialogue.Angry import content.entity.player.dialogue.Confused import content.entity.player.dialogue.Mad @@ -18,6 +16,7 @@ import content.quest.questJournal import content.quest.questStage import world.gregs.voidps.engine.Script import world.gregs.voidps.engine.client.instruction.handle.interactPlayer +import world.gregs.voidps.engine.client.markHint import world.gregs.voidps.engine.client.message import world.gregs.voidps.engine.client.ui.dialogue.talkWith import world.gregs.voidps.engine.entity.character.npc.NPC @@ -425,7 +424,7 @@ class RumDeal : Script { owner = this, ) if (spirit != null) { - spirit.markHint(this) + markHint(spirit) spirit.interactPlayer(this, "Attack") } } @@ -436,8 +435,6 @@ class RumDeal : Script { if (killer.questStage("rum_deal") == 13) { killer["rum_deal"] = "spirit_banished" } - // Takes the arrow away as the spirit dies rather than waiting for it to despawn - clearHints() killer.message("You have banished the Evil Spirit!") } } diff --git a/game/src/main/kotlin/content/skill/dungeoneering/DungeonMark.kt b/game/src/main/kotlin/content/skill/dungeoneering/DungeonMark.kt index 061ebfbaac..5f47b83a23 100644 --- a/game/src/main/kotlin/content/skill/dungeoneering/DungeonMark.kt +++ b/game/src/main/kotlin/content/skill/dungeoneering/DungeonMark.kt @@ -2,8 +2,8 @@ package content.skill.dungeoneering import content.area.wilderness.daemonheim.DungeoneeringParty.Companion.dungeonLeader import content.area.wilderness.daemonheim.DungeoneeringParty.Companion.dungeonMembers -import content.entity.npc.markHint import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.client.markHint import world.gregs.voidps.engine.client.message import world.gregs.voidps.engine.entity.Approachable import world.gregs.voidps.engine.entity.character.npc.NPC @@ -22,7 +22,7 @@ class DungeonMark : Script { return } for (member in dungeonMembers) { - target.markHint(member) + member.markHint(target) } } } diff --git a/game/src/test/kotlin/content/entity/npc/NPCHintsTest.kt b/game/src/test/kotlin/content/entity/npc/NPCHintsTest.kt deleted file mode 100644 index 5be9eba640..0000000000 --- a/game/src/test/kotlin/content/entity/npc/NPCHintsTest.kt +++ /dev/null @@ -1,58 +0,0 @@ -package content.entity.npc - -import WorldTest -import org.junit.jupiter.api.Test -import world.gregs.voidps.type.Tile -import kotlin.test.assertEquals - -/** - * Arrow slots are only handed back explicitly, so an npc that is marked and then goes away without - * anyone clearing it would cost the player one of their eight slots for the rest of the session. - */ -class NPCHintsTest : WorldTest() { - - @Test - fun `Marked npc hands its arrow slot back when it despawns`() { - val player = createPlayer(Tile(3200, 3200)) - val npc = createNPC("man", Tile(3201, 3200)) - val hints = player.viewport!!.hints - - npc.markHint(player) - assertEquals(1, hints.count { it != 0 }, "marking should take a slot") - - npc.despawn() - tick(2) - - assertEquals(0, hints.count { it != 0 }, "despawning should give the slot back") - } - - @Test - fun `Re-marking the same npc reuses its slot`() { - val player = createPlayer(Tile(3200, 3200)) - val npc = createNPC("man", Tile(3201, 3200)) - val hints = player.viewport!!.hints - - npc.markHint(player) - npc.markHint(player) - npc.markHint(player) - - assertEquals(1, hints.count { it != 0 }, "a second mark should replace the first") - } - - @Test - fun `Separate npcs get separate slots`() { - val player = createPlayer(Tile(3200, 3200)) - val first = createNPC("man", Tile(3201, 3200)) - val second = createNPC("man", Tile(3202, 3200)) - val hints = player.viewport!!.hints - - first.markHint(player) - second.markHint(player) - assertEquals(2, hints.count { it != 0 }, "each npc should get its own slot") - - first.despawn() - tick(2) - - assertEquals(1, hints.count { it != 0 }, "only the despawned npc's slot should be freed") - } -} diff --git a/game/src/test/kotlin/content/quest/member/rum_deal/RumDealTest.kt b/game/src/test/kotlin/content/quest/member/rum_deal/RumDealTest.kt index a68383d036..67877e33b0 100644 --- a/game/src/test/kotlin/content/quest/member/rum_deal/RumDealTest.kt +++ b/game/src/test/kotlin/content/quest/member/rum_deal/RumDealTest.kt @@ -11,7 +11,7 @@ import npcOption import objectOption import org.junit.jupiter.api.Test import skipDialogues -import world.gregs.voidps.engine.client.hint +import world.gregs.voidps.engine.client.markHint import world.gregs.voidps.engine.client.ui.dialogue import world.gregs.voidps.engine.data.definition.CombatDefinitions import world.gregs.voidps.engine.entity.character.npc.NPCs @@ -552,7 +552,7 @@ class RumDealTest : WorldTest() { player.inventory.add("holy_wrench") // An arrow belonging to some other content, which must survive - val otherArrow = player.hint(Tile(2143, 5101, 1)) + val otherArrow = player.markHint(Tile(2143, 5101, 1)) val controls = GameObjects.find(Tile(2143, 5100, 1), "braindeath_island_brewing_controls_multi") player.itemOnObject(controls, player.inventory.indexOf("holy_wrench")) 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 9a8581f35b..993eff5d6b 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 @@ -19,6 +19,16 @@ object HintArrow { const val RED_MINIMAP = 8 } +object HintType { + const val CLEAR = 0 + const val NPC = 1 + const val TILE = 2 + const val WEST = 3 + const val EAST = 4 + const val SOUTH = 5 + const val NORTH = 6 + const val PLAYER = 10 +} /** * Send system update timer * @param type of hint (0=clear, 1=npc, 2=tile_centre, 3=tile_west, 4=tile_east, 5=tile_south, 6=tile_north, 10=player) @@ -49,7 +59,7 @@ fun Client.arrowHint( // of it. Stopping short leaves the client reading the packets behind this one as the rest of // it, which desyncs the stream and takes the client down. if (type == 0) { - repeat(PAYLOAD - 1) { + repeat(11) { writeByte(0) } return@send @@ -58,6 +68,7 @@ fun Client.arrowHint( when (type) { 1, 10 -> { writeShort(entityIndex) + // Skip 6 writeInt(0) writeShort(0) } @@ -74,6 +85,3 @@ fun Client.arrowHint( } writeShort(model) } - -/** Bytes the client reads for a hint arrow, not counting the opcode. */ -private const val PAYLOAD = 12 \ No newline at end of file From a3d869c56b7d0aaaa45098430764350d52b1a905 Mon Sep 17 00:00:00 2001 From: GregHib Date: Wed, 2 Sep 2026 17:53:19 +0100 Subject: [PATCH 07/10] Formatting --- .../kotlin/content/quest/QuestCommands.kt | 20 +++++++++---------- .../content/quest/member/rum_deal/RumDeal.kt | 10 +++++----- 2 files changed, 14 insertions(+), 16 deletions(-) diff --git a/game/src/main/kotlin/content/quest/QuestCommands.kt b/game/src/main/kotlin/content/quest/QuestCommands.kt index c7566c438f..73387ab648 100644 --- a/game/src/main/kotlin/content/quest/QuestCommands.kt +++ b/game/src/main/kotlin/content/quest/QuestCommands.kt @@ -1,7 +1,7 @@ package content.quest import content.entity.player.inv.item.addOrDrop -import content.quest.refreshQuestJournal +import world.gregs.voidps.cache.config.data.QuestDefinition import world.gregs.voidps.engine.Script import world.gregs.voidps.engine.client.command.adminCommand import world.gregs.voidps.engine.client.command.stringArg @@ -47,13 +47,7 @@ class QuestCommands : Script { */ private fun reset(player: Player, args: List) { val questId = args.getOrNull(0) ?: return - val questDefinitions: QuestDefinitions = get() - val quest = questDefinitions.getOrNull(questId) - if (quest == null) { - player.message("No quest found with id '$questId'.") - return - } - + val quest = findQuest(player, questId) ?: return player.clear(questId) var cleared = 0 for (variable in VariableDefinitions.definitions.keys) { @@ -66,15 +60,19 @@ class QuestCommands : Script { player.message("Reset ${quest["name", questId]} to unstarted, clearing $cleared ${if (cleared == 1) "variable" else "variables"}.") } - private fun prepare(player: Player, args: List) { - val questId = args.getOrNull(0) ?: return + private fun findQuest(player: Player, questId: String): QuestDefinition? { val questDefinitions: QuestDefinitions = get() val quest = questDefinitions.getOrNull(questId) if (quest == null) { player.message("No quest found with id '$questId'.") - return + return null } + return quest + } + private fun prepare(player: Player, args: List) { + val questId = args.getOrNull(0) ?: return + val quest = findQuest(player, questId) ?: return val skills = quest["req_skills", emptyMap()] for ((name, level) in skills) { val skill = Skill.entries.firstOrNull { it.name == name } diff --git a/game/src/main/kotlin/content/quest/member/rum_deal/RumDeal.kt b/game/src/main/kotlin/content/quest/member/rum_deal/RumDeal.kt index df2ab80280..f368e43096 100644 --- a/game/src/main/kotlin/content/quest/member/rum_deal/RumDeal.kt +++ b/game/src/main/kotlin/content/quest/member/rum_deal/RumDeal.kt @@ -282,11 +282,11 @@ class RumDeal : Script { val slugs = get("rum_deal_slugling_count", 0) val karam = get("rum_deal_karamthulhu_count", 0) message( - when { - slugs == 0 && karam == 0 -> "This barrel is empty and smells nasty." - slugs == 1 && karam == 0 -> "There are some Sluglings in this barrel." - slugs == 0 && karam == 1 -> "There is a Karamthulhu in this barrel." - slugs == 1 && karam == 1 -> "There are some Sluglings and a Karamthulhu in this barrel." + when (slugs) { + 0 if karam == 0 -> "This barrel is empty and smells nasty." + 1 if karam == 0 -> "There are some Sluglings in this barrel." + 0 if karam == 1 -> "There is a Karamthulhu in this barrel." + 1 if karam == 1 -> "There are some Sluglings and a Karamthulhu in this barrel." else -> "There are $slugs loads of Sluglings and $karam Karamthulhu in this barrel." }, ) From 1ae0f2d6e3f31739e223092edaa908a568fe7a01 Mon Sep 17 00:00:00 2001 From: GregHib Date: Wed, 2 Sep 2026 17:53:44 +0100 Subject: [PATCH 08/10] Formatting --- .../kotlin/content/quest/member/rum_deal/RumDeal.kt | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/game/src/main/kotlin/content/quest/member/rum_deal/RumDeal.kt b/game/src/main/kotlin/content/quest/member/rum_deal/RumDeal.kt index f368e43096..44fdad114b 100644 --- a/game/src/main/kotlin/content/quest/member/rum_deal/RumDeal.kt +++ b/game/src/main/kotlin/content/quest/member/rum_deal/RumDeal.kt @@ -42,16 +42,6 @@ import java.util.concurrent.TimeUnit class RumDeal : Script { init { - - val zombieSwabs = listOf( - "a" to 2843, - "b" to 2844, - "c" to 2845, - "d" to 2846, - "e" to 2847, - "f" to 2848, - ) - questJournalOpen("rum_deal") { val lines = when (val stage = questStage("rum_deal")) { 0 -> notStartedJournal() From 94db221987b344da8ede6e3f999e9add2b476332 Mon Sep 17 00:00:00 2001 From: GregHib Date: Wed, 2 Sep 2026 17:57:20 +0100 Subject: [PATCH 09/10] Combine quest commands --- .../entity/player/modal/tab/QuestJournals.kt | 52 ------------------- .../kotlin/content/quest/QuestCommands.kt | 43 +++++++++++---- 2 files changed, 34 insertions(+), 61 deletions(-) diff --git a/game/src/main/kotlin/content/entity/player/modal/tab/QuestJournals.kt b/game/src/main/kotlin/content/entity/player/modal/tab/QuestJournals.kt index 7c617e40fd..a6c07666f9 100644 --- a/game/src/main/kotlin/content/entity/player/modal/tab/QuestJournals.kt +++ b/game/src/main/kotlin/content/entity/player/modal/tab/QuestJournals.kt @@ -1,24 +1,13 @@ package content.entity.player.modal.tab import com.github.michaelbull.logging.InlineLogger -import content.entity.player.bank.bank -import content.entity.player.command.find import content.quest.refreshQuestJournal import world.gregs.voidps.engine.Script import world.gregs.voidps.engine.client.clearCamera -import world.gregs.voidps.engine.client.command.adminCommand -import world.gregs.voidps.engine.client.command.boolArg -import world.gregs.voidps.engine.client.command.stringArg import world.gregs.voidps.engine.client.ui.InterfaceApi import world.gregs.voidps.engine.client.ui.closeInterfaces import world.gregs.voidps.engine.data.definition.AccountDefinitions import world.gregs.voidps.engine.data.definition.QuestDefinitions -import world.gregs.voidps.engine.entity.character.player.Player -import world.gregs.voidps.engine.entity.character.player.Players -import world.gregs.voidps.engine.inv.beastOfBurden -import world.gregs.voidps.engine.inv.equipment -import world.gregs.voidps.engine.inv.inventory -import world.gregs.voidps.engine.inv.removeToLimit import world.gregs.voidps.engine.timer.Timer class QuestJournals( @@ -81,46 +70,5 @@ class QuestJournals( set("quest_journal_order", type) } } - - adminCommand( - "reset_quest", - stringArg("quest", desc = "Name of the quest to reset", autofill = questDefinitions.ids.keys), - boolArg("remove-items", desc = "Removes all quest related items from the player", optional = true), - stringArg("player-name", optional = true, autofill = accounts.displayNames.keys), - desc = "Resets a quest for the player", - handler = ::resetQuest, - ) - } - - /** - * Resets a player's progress on a quest - * Note: Not foolproof - * - Doesn't remove them from a quest area - * - Dropped quest items can avoid the reset - * - Doesn't reset rewards - * - Allows player to reclaim rewards (such as XP) - */ - fun resetQuest(player: Player, args: List) { - val target = Players.find(player, args.getOrNull(2)) ?: return - val id = args[0] - val def = questDefinitions.getOrNull(id) ?: return - val vars: List = def.getOrNull("variables") ?: emptyList() - for (variable in vars) { - target.clear(variable) - } - val removeItems = args.getOrNull(1)?.toBooleanStrictOrNull() ?: true - if (removeItems) { - val items: List = def.getOrNull("items") ?: emptyList() - for (item in items) { - removeItems(target, item) - } - } - } - - private fun removeItems(player: Player, item: String) { - player.inventory.removeToLimit(item, Int.MAX_VALUE) - player.bank.removeToLimit(item, Int.MAX_VALUE) - player.beastOfBurden.removeToLimit(item, Int.MAX_VALUE) - player.equipment.removeToLimit(item, Int.MAX_VALUE) } } diff --git a/game/src/main/kotlin/content/quest/QuestCommands.kt b/game/src/main/kotlin/content/quest/QuestCommands.kt index 73387ab648..31139a7927 100644 --- a/game/src/main/kotlin/content/quest/QuestCommands.kt +++ b/game/src/main/kotlin/content/quest/QuestCommands.kt @@ -1,19 +1,25 @@ package content.quest +import content.entity.player.bank.bank +import content.entity.player.command.find import content.entity.player.inv.item.addOrDrop import world.gregs.voidps.cache.config.data.QuestDefinition import world.gregs.voidps.engine.Script import world.gregs.voidps.engine.client.command.adminCommand import world.gregs.voidps.engine.client.command.stringArg import world.gregs.voidps.engine.client.message +import world.gregs.voidps.engine.client.ui.chat.plural import world.gregs.voidps.engine.data.definition.ItemDefinitions import world.gregs.voidps.engine.data.definition.QuestDefinitions -import world.gregs.voidps.engine.data.definition.VariableDefinitions import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.entity.character.player.Players import world.gregs.voidps.engine.entity.character.player.skill.Skill import world.gregs.voidps.engine.entity.character.player.skill.level.Level import world.gregs.voidps.engine.get +import world.gregs.voidps.engine.inv.beastOfBurden +import world.gregs.voidps.engine.inv.equipment import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.engine.inv.removeToLimit /** * Sets a player up to attempt a quest: skills raised to the requirements listed in `quests.toml`, @@ -25,14 +31,14 @@ class QuestCommands : Script { init { adminCommand( - "questreset", + "quest_reset", stringArg("quest-id", "the quest to reset", autofill = { get().ids.keys }), desc = "Reset a quest back to unstarted, clearing its variables", handler = ::reset, ) adminCommand( - "questprep", + "quest_prep", stringArg("quest-id", "the quest to prepare for", autofill = { get().ids.keys }), desc = "Meet a quest's skill, quest and item requirements", handler = ::prepare, @@ -44,20 +50,39 @@ class QuestCommands : Script { * variable named after it, which is how quest progress flags are named - Rum Deal's counters * and swab flags are all `rum_deal_*`. Variables a quest shares with a skill (the blindweed * farming patch, say) aren't named after the quest and are left alone. + * Note: Not foolproof + * - Doesn't remove them from a quest area + * - Dropped quest items can avoid the reset + * - Doesn't reset rewards + * - Allows player to reclaim rewards (such as XP) */ private fun reset(player: Player, args: List) { val questId = args.getOrNull(0) ?: return + val target = Players.find(player, args.getOrNull(2)) ?: return val quest = findQuest(player, questId) ?: return player.clear(questId) var cleared = 0 - for (variable in VariableDefinitions.definitions.keys) { - if (variable.startsWith("${questId}_") && player.variables.contains(variable)) { - player.clear(variable) - cleared++ + val vars: List = quest.getOrNull("variables") ?: emptyList() + for (variable in vars) { + target.clear(variable) + cleared++ + } + val removeItems = args.getOrNull(1)?.toBooleanStrictOrNull() ?: true + if (removeItems) { + val items: List = quest.getOrNull("items") ?: emptyList() + for (item in items) { + removeItems(target, item) } } player.refreshQuestJournal() - player.message("Reset ${quest["name", questId]} to unstarted, clearing $cleared ${if (cleared == 1) "variable" else "variables"}.") + player.message("Reset ${quest["name", questId]} to unstarted, clearing $cleared ${"variable".plural(cleared)}.") + } + + private fun removeItems(player: Player, item: String) { + player.inventory.removeToLimit(item, Int.MAX_VALUE) + player.bank.removeToLimit(item, Int.MAX_VALUE) + player.beastOfBurden.removeToLimit(item, Int.MAX_VALUE) + player.equipment.removeToLimit(item, Int.MAX_VALUE) } private fun findQuest(player: Player, questId: String): QuestDefinition? { @@ -65,7 +90,7 @@ class QuestCommands : Script { val quest = questDefinitions.getOrNull(questId) if (quest == null) { player.message("No quest found with id '$questId'.") - return null + return null } return quest } From 7b26274ce11da5dfdbe63471acb67396defe7ffd Mon Sep 17 00:00:00 2001 From: GregHib Date: Wed, 2 Sep 2026 18:11:02 +0100 Subject: [PATCH 10/10] Combine quest commands --- data/quest/quests.toml | 15 ++++++++++++++ .../kotlin/content/quest/QuestCommandsTest.kt | 20 +++++++++---------- .../quest/member/rum_deal/RumDealTest.kt | 9 ++------- 3 files changed, 27 insertions(+), 17 deletions(-) diff --git a/data/quest/quests.toml b/data/quest/quests.toml index 16934a04f6..026c80bf55 100644 --- a/data/quest/quests.toml +++ b/data/quest/quests.toml @@ -3220,6 +3220,21 @@ req_quests = ["zogre_flesh_eaters"] req_items = "Combat equipment and food, Slayer gloves." # Everything else is handed out by Captain Braindeath or the basement tool cupboard. req_item_ids = ["slayer_gloves", "spade"] +variables = [ + "rum_deal", + "rum_deal_pressure_count", + "rum_deal_brewing_control", + "rum_deal_squid_spot", + "rum_deal_slugling_count", + "rum_deal_karamthulhu_count", + "rum_deal_swab_a", + "rum_deal_swab_b", + "rum_deal_swab_c", + "rum_deal_swab_d", + "rum_deal_swab_e", + "rum_deal_swab_f", + "rum_deal_diversion", +] req_combat = "You will need to defeat a level 56 melee enemy and a level 70 ranged enemy." points = 2 reward = "Holy wrench
Access to Braindeath Island
Tangled fishbowl and net" diff --git a/game/src/test/kotlin/content/quest/QuestCommandsTest.kt b/game/src/test/kotlin/content/quest/QuestCommandsTest.kt index 8d9b180e9a..b7e1b57613 100644 --- a/game/src/test/kotlin/content/quest/QuestCommandsTest.kt +++ b/game/src/test/kotlin/content/quest/QuestCommandsTest.kt @@ -16,11 +16,11 @@ import kotlin.test.assertTrue class QuestCommandsTest : WorldTest() { @Test - fun `Questprep meets a quest's skill, quest and item requirements`() { + fun `Quest prep meets a quest's skill, quest and item requirements`() { val admin = createPlayer(name = "prep admin") admin.rights = PlayerRights.Admin - runTest { Commands.call(admin, "questprep rum_deal") } + runTest { Commands.call(admin, "quest_prep rum_deal") } tick() // req_skills from quests.toml @@ -39,30 +39,30 @@ class QuestCommandsTest : WorldTest() { } @Test - fun `Questprep never lowers a skill the player has already trained`() { + fun `Quest prep never lowers a skill the player has already trained`() { val admin = createPlayer(name = "prep admin2") admin.rights = PlayerRights.Admin admin.experience.set(Skill.Fishing, Level.experience(80)) - runTest { Commands.call(admin, "questprep rum_deal") } + runTest { Commands.call(admin, "quest_prep rum_deal") } tick() assertEquals(80, admin.levels.getMax(Skill.Fishing)) } @Test - fun `Questprep reports an unknown quest`() { + fun `Quest prep reports an unknown quest`() { val admin = createPlayer(name = "prep admin3") admin.rights = PlayerRights.Admin - runTest { Commands.call(admin, "questprep not_a_quest") } + runTest { Commands.call(admin, "quest_prep not_a_quest") } tick() assertTrue(admin.containsMessage("No quest found with id 'not_a_quest'.")) } @Test - fun `Questreset puts the quest back to unstarted and clears its variables`() { + fun `Quest reset puts the quest back to unstarted and clears its variables`() { val admin = createPlayer(name = "reset admin") admin.rights = PlayerRights.Admin admin["rum_deal"] = "collect_swill" @@ -71,7 +71,7 @@ class QuestCommandsTest : WorldTest() { admin["rum_deal_swab_a"] = 1 admin["rum_deal_brewing_control"] = 2 - runTest { Commands.call(admin, "questreset rum_deal") } + runTest { Commands.call(admin, "quest_reset rum_deal") } tick() assertEquals("unstarted", admin["rum_deal", "unstarted"]) @@ -82,11 +82,11 @@ class QuestCommandsTest : WorldTest() { } @Test - fun `Questreset reports an unknown quest`() { + fun `Quest reset reports an unknown quest`() { val admin = createPlayer(name = "reset admin2") admin.rights = PlayerRights.Admin - runTest { Commands.call(admin, "questreset not_a_quest") } + runTest { Commands.call(admin, "quest_reset not_a_quest") } tick() assertTrue(admin.containsMessage("No quest found with id 'not_a_quest'.")) diff --git a/game/src/test/kotlin/content/quest/member/rum_deal/RumDealTest.kt b/game/src/test/kotlin/content/quest/member/rum_deal/RumDealTest.kt index 67877e33b0..e6ebc02828 100644 --- a/game/src/test/kotlin/content/quest/member/rum_deal/RumDealTest.kt +++ b/game/src/test/kotlin/content/quest/member/rum_deal/RumDealTest.kt @@ -11,7 +11,6 @@ import npcOption import objectOption import org.junit.jupiter.api.Test import skipDialogues -import world.gregs.voidps.engine.client.markHint import world.gregs.voidps.engine.client.ui.dialogue import world.gregs.voidps.engine.data.definition.CombatDefinitions import world.gregs.voidps.engine.entity.character.npc.NPCs @@ -551,22 +550,18 @@ class RumDealTest : WorldTest() { player["rum_deal_brewing_control"] = 1 player.inventory.add("holy_wrench") - // An arrow belonging to some other content, which must survive - val otherArrow = player.markHint(Tile(2143, 5101, 1)) - val controls = GameObjects.find(Tile(2143, 5100, 1), "braindeath_island_brewing_controls_multi") player.itemOnObject(controls, player.inventory.indexOf("holy_wrench")) tick(6) val spirit = NPCs.at(player.tile.regionLevel).first { it.id == "evil_spirit" } val hints = player.viewport!!.hints - assertEquals(2, hints.count { it != 0 }, "the spirit's arrow should take a slot of its own") + assertEquals(1, hints.count { it != 0 }, "the spirit's arrow should take a slot of its own") spirit.damage(5000, source = player) tick(6) - assertEquals(1, hints.count { it != 0 }, "the spirit's arrow should be gone") - assertTrue(hints[otherArrow] != 0, "other content's arrow must survive") + assertEquals(0, hints.count { it != 0 }, "the spirit's arrow should be gone") } @Test