From a84df07225b564bd5a0ab49b7fe7557dcb55482a Mon Sep 17 00:00:00 2001 From: Alexander Date: Sun, 30 Aug 2026 17:48:12 -0400 Subject: [PATCH 1/4] 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 20766adcf3..0e59e59fb0 100644 --- a/game/src/main/kotlin/content/quest/Quest.kt +++ b/game/src/main/kotlin/content/quest/Quest.kt @@ -27,6 +27,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 cfad11d4d70474a6a5fc625cedabf3f653fa6698 Mon Sep 17 00:00:00 2001 From: Alexander Date: Sun, 30 Aug 2026 18:31:58 -0400 Subject: [PATCH 2/4] 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 79408abdc629e88b5d18a4a9e8ca83dc6957421f Mon Sep 17 00:00:00 2001 From: Alexander Date: Tue, 1 Sep 2026 18:31:22 -0400 Subject: [PATCH 3/4] Add In Search of the Myreque quest Full quest from Vanstrom's request in the Canifis tavern through to the escape back up into Canifis: the boat negotiation with Cyreg, the rope bridge repaired a rung at a time, Curpile Fyod's questions, meeting the Myreque, and Vanstrom's ambush of the hideout. Quest stages are referenced by the names declared in the quest's varp rather than by their numbers, via a new questStage(quest, stage) lookup. DynamicZones gains a zone-block copy so a cutscene can be staged in a copy of a few rooms rather than a whole region, and questprep/questreset learn "id:amount" requirements and a reset_vars list for the flags this quest keeps under the client's own varbit names. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01K22iXYZjnQnTr3ZUtbuGBG --- .../gregs/voidps/cache/definition/Params.kt | 4 + .../area/morytania/canifis/canifis.teles.toml | 9 - .../tunnels/meiyerditch_tunnels.npcs.toml | 2 + .../mort_myre_swamp.areas.toml | 5 + .../in_search_of_the_myreque.anims.toml | 33 + .../in_search_of_the_myreque.combat.toml | 11 + .../in_search_of_the_myreque.drops.toml | 6 + .../in_search_of_the_myreque.gfx.toml | 11 + .../in_search_of_the_myreque.npcs.toml | 19 + .../in_search_of_the_myreque.objs.toml | 17 + .../in_search_of_the_myreque.sounds.toml | 17 + .../in_search_of_the_myreque.varbits.toml | 46 ++ .../in_search_of_the_myreque.varps.toml | 2 +- .../in_search_of_the_myreque.vars.toml | 20 + data/quest/quests.toml | 34 +- .../voidps/engine/map/zone/DynamicZones.kt | 19 + .../engine/map/zone/DynamicZonesTest.kt | 21 + .../area/morytania/canifis/CanifisTavern.kt | 5 - .../area/morytania/canifis/VanstromKlause.kt | 238 +++++++ .../morytania/mort_myre_swamp/CurpileFyod.kt | 271 ++++++++ .../morytania/mort_myre_swamp/HaroldEvans.kt | 112 ++++ .../morytania/mort_myre_swamp/IvanStrom.kt | 114 ++++ .../mort_myre_swamp/MortMyreSwamp.kt | 37 -- .../mort_myre_swamp/MyrequeStranger.kt | 64 ++ .../mort_myre_swamp/PolmafiFerdygris.kt | 123 ++++ .../mort_myre_swamp/RadigadPonfit.kt | 115 ++++ .../morytania/mort_myre_swamp/SaniPiliu.kt | 133 ++++ .../morytania/mort_myre_swamp/SwampyBoat.kt | 32 +- .../morytania/mort_myre_swamp/VeliafHurtz.kt | 447 ++++++++++++++ .../morytania/mort_ton/CyregPaddlehorn.kt | 394 +++++++++++- .../src/main/kotlin/content/quest/Cutscene.kt | 21 + game/src/main/kotlin/content/quest/Quest.kt | 6 + .../kotlin/content/quest/QuestCommands.kt | 30 +- .../quest/member/myreque/SearchMyreque.kt | 450 ++++++++++++++ .../kotlin/content/quest/QuestCommandsTest.kt | 45 ++ .../quest/member/myreque/SearchMyrequeTest.kt | 579 ++++++++++++++++++ 36 files changed, 3415 insertions(+), 77 deletions(-) create mode 100644 data/quest/members/in_search_of_the_myreque/in_search_of_the_myreque.anims.toml create mode 100644 data/quest/members/in_search_of_the_myreque/in_search_of_the_myreque.combat.toml create mode 100644 data/quest/members/in_search_of_the_myreque/in_search_of_the_myreque.drops.toml create mode 100644 data/quest/members/in_search_of_the_myreque/in_search_of_the_myreque.gfx.toml create mode 100644 data/quest/members/in_search_of_the_myreque/in_search_of_the_myreque.objs.toml create mode 100644 data/quest/members/in_search_of_the_myreque/in_search_of_the_myreque.sounds.toml create mode 100644 data/quest/members/in_search_of_the_myreque/in_search_of_the_myreque.varbits.toml create mode 100644 data/quest/members/in_search_of_the_myreque/in_search_of_the_myreque.vars.toml create mode 100644 game/src/main/kotlin/content/area/morytania/canifis/VanstromKlause.kt create mode 100644 game/src/main/kotlin/content/area/morytania/mort_myre_swamp/CurpileFyod.kt create mode 100644 game/src/main/kotlin/content/area/morytania/mort_myre_swamp/HaroldEvans.kt create mode 100644 game/src/main/kotlin/content/area/morytania/mort_myre_swamp/IvanStrom.kt delete mode 100644 game/src/main/kotlin/content/area/morytania/mort_myre_swamp/MortMyreSwamp.kt create mode 100644 game/src/main/kotlin/content/area/morytania/mort_myre_swamp/MyrequeStranger.kt create mode 100644 game/src/main/kotlin/content/area/morytania/mort_myre_swamp/PolmafiFerdygris.kt create mode 100644 game/src/main/kotlin/content/area/morytania/mort_myre_swamp/RadigadPonfit.kt create mode 100644 game/src/main/kotlin/content/area/morytania/mort_myre_swamp/SaniPiliu.kt create mode 100644 game/src/main/kotlin/content/area/morytania/mort_myre_swamp/VeliafHurtz.kt create mode 100644 game/src/main/kotlin/content/quest/member/myreque/SearchMyreque.kt create mode 100644 game/src/test/kotlin/content/quest/member/myreque/SearchMyrequeTest.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 c27b9b4aad..409f58c50e 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 @@ -141,6 +141,9 @@ object Params { /** Item ids a quest expects the player to bring; used by the questprep command. */ const val REQ_ITEM_IDS = 5267 + /** Variables a quest keeps outside of its own name; used by the questreset command. */ + const val RESET_VARS = 5268 + const val REQ_QUEST_POINTS = 5139 const val REQ_QUESTS = 5140 const val REQ_SKILLS = 5141 @@ -477,6 +480,7 @@ object Params { "region" -> REGION "req_combat" -> REQ_COMBAT "req_item_ids" -> REQ_ITEM_IDS + "reset_vars" -> RESET_VARS "req_items" -> REQ_ITEMS "req_quest_points" -> REQ_QUEST_POINTS "req_quests" -> REQ_QUESTS diff --git a/data/area/morytania/canifis/canifis.teles.toml b/data/area/morytania/canifis/canifis.teles.toml index 62f0600b1b..e5dfc25d02 100644 --- a/data/area/morytania/canifis/canifis.teles.toml +++ b/data/area/morytania/canifis/canifis.teles.toml @@ -23,12 +23,3 @@ option = "Climb-down" tile = { x = 3422, y = 3484 } to = { x = 3440, y = 9888 } -[canifis_tavern_trapdoor] -option = "Open" -tile = { x = 3495, y = 3465 } -to = { x = 3477, y = 9845 } - -[canifis_tavern_ladder] -option = "Climb-up" -tile = { x = 3477, y = 9846 } -to = { x = 3495, y = 3466 } diff --git a/data/area/morytania/meiyerditch/tunnels/meiyerditch_tunnels.npcs.toml b/data/area/morytania/meiyerditch/tunnels/meiyerditch_tunnels.npcs.toml index f77a436c22..19d16f2afd 100644 --- a/data/area/morytania/meiyerditch/tunnels/meiyerditch_tunnels.npcs.toml +++ b/data/area/morytania/meiyerditch/tunnels/meiyerditch_tunnels.npcs.toml @@ -4,10 +4,12 @@ examine = "Looks like the leader of the Myreque." [sani_piliu_meiyerditch_tunnels] id = 1570 +hitpoints = 300 examine = "A pretty young rogue." [harold_evans_meiyerditch_tunnels] id = 1571 +hitpoints = 300 examine = "Hot headed, big built youth, who trained in the militia." [ivan_strom_meiyerditch_tunnels] diff --git a/data/area/morytania/mort_myre_swamp/mort_myre_swamp.areas.toml b/data/area/morytania/mort_myre_swamp/mort_myre_swamp.areas.toml index 8a6ad20879..3843efe87c 100644 --- a/data/area/morytania/mort_myre_swamp/mort_myre_swamp.areas.toml +++ b/data/area/morytania/mort_myre_swamp/mort_myre_swamp.areas.toml @@ -23,3 +23,8 @@ tags = ["no_cannon", "no_cannon_damp"] [filliman_grotto] x = [3433, 3447] y = [3331, 3343] + +# The rope bridge over the Hollows, from the tile the southern tree lands on up to the top rung +[mort_myre_rope_bridge] +x = [3502, 3502] +y = [3427, 3430] diff --git a/data/quest/members/in_search_of_the_myreque/in_search_of_the_myreque.anims.toml b/data/quest/members/in_search_of_the_myreque/in_search_of_the_myreque.anims.toml new file mode 100644 index 0000000000..010a9db128 --- /dev/null +++ b/data/quest/members/in_search_of_the_myreque/in_search_of_the_myreque.anims.toml @@ -0,0 +1,33 @@ +[squeeze_past_stalagmite] +id = 3278 + +# Curpile's warning jab, and the punch that puts the player out cold +[curpile_punch] +id = 422 + +[curpile_knockout_punch] +id = 390 + +# Also used for Sani and Harold going down in Vanstrom's ambush +[myreque_knockout] +id = 837 + +# Vanstrom's ambush in the hideout +[vanstrom_transform] +id = 8594 + +[vanstrom_hover] #9855 in 667 +id = 1500 + +[vanstrom_summon] +id = 4707 + +# Vanstrom's pet shares the hellhound skeleton's attack, block and death +[skeleton_hellhound_attack] +id = 6562 + +[skeleton_hellhound_defend] +id = 6563 + +[skeleton_hellhound_death] +id = 6564 diff --git a/data/quest/members/in_search_of_the_myreque/in_search_of_the_myreque.combat.toml b/data/quest/members/in_search_of_the_myreque/in_search_of_the_myreque.combat.toml new file mode 100644 index 0000000000..eab162cd62 --- /dev/null +++ b/data/quest/members/in_search_of_the_myreque/in_search_of_the_myreque.combat.toml @@ -0,0 +1,11 @@ +[skeleton_hellhound] +defend_anim = "skeleton_hellhound_defend" +death_anim = "skeleton_hellhound_death" +death_sound = "skeleton_hellhound_death" + +[skeleton_hellhound.melee] +range = 1 +anim = "skeleton_hellhound_attack" +sound = "skeleton_hellhound_attack" +target_sound = "skeleton_hellhound_hit" +target_hit = { offense = "crush", max = 120 } diff --git a/data/quest/members/in_search_of_the_myreque/in_search_of_the_myreque.drops.toml b/data/quest/members/in_search_of_the_myreque/in_search_of_the_myreque.drops.toml new file mode 100644 index 0000000000..a7d0cb5ebe --- /dev/null +++ b/data/quest/members/in_search_of_the_myreque/in_search_of_the_myreque.drops.toml @@ -0,0 +1,6 @@ +[skeleton_hellhound_drop_table] +type = "all" +drops = [ + { id = "big_bones", amount = 4 }, + { id = "uncut_ruby", amount = 2 }, +] diff --git a/data/quest/members/in_search_of_the_myreque/in_search_of_the_myreque.gfx.toml b/data/quest/members/in_search_of_the_myreque/in_search_of_the_myreque.gfx.toml new file mode 100644 index 0000000000..b2f81a166a --- /dev/null +++ b/data/quest/members/in_search_of_the_myreque/in_search_of_the_myreque.gfx.toml @@ -0,0 +1,11 @@ +[vanstrom_transform_graphic] +id = 785 + +[vanstrom_summon_graphic] +id = 731 + +[curpile_knockout_stars] +id = 245 + +[skeleton_hellhound_summon] +id = 86 diff --git a/data/quest/members/in_search_of_the_myreque/in_search_of_the_myreque.npcs.toml b/data/quest/members/in_search_of_the_myreque/in_search_of_the_myreque.npcs.toml index 82ecf7a37a..609ecbcd50 100644 --- a/data/quest/members/in_search_of_the_myreque/in_search_of_the_myreque.npcs.toml +++ b/data/quest/members/in_search_of_the_myreque/in_search_of_the_myreque.npcs.toml @@ -33,7 +33,26 @@ str = 110 def = 100 style = "crush" max_hit_melee = 120 +combat_def = "skeleton_hellhound" +attack_speed = 4 hunt_mode = "cowardly" slayer_xp = 55.0 categories = ["hellhounds"] +drop_table = "skeleton_hellhound" examine = "A creature summoned by Vanstrom to kill the remaining Myreque." + +# Vanstrom's forms during the Myreque hideout cutscene +[route_vanstrom_vampire_misty] +id = 1578 +dialogue = "old" + +[route_vanstrom] +id = 1579 + +[route_vanstrom_vampire] +id = 1580 +dialogue = "old" + +[route_vanstrom_vampire_flying] +id = 1581 +dialogue = "old" diff --git a/data/quest/members/in_search_of_the_myreque/in_search_of_the_myreque.objs.toml b/data/quest/members/in_search_of_the_myreque/in_search_of_the_myreque.objs.toml new file mode 100644 index 0000000000..558f505638 --- /dev/null +++ b/data/quest/members/in_search_of_the_myreque/in_search_of_the_myreque.objs.toml @@ -0,0 +1,17 @@ +[swamp_bridge1] +id = 5002 + +[spooky_tree_base_forbridge] +id = 5003 + +[route_cavewalltunnel] +id = 5046 + +[route_stalagmite_cave_entrace] +id = 5050 + +[freedomfighterundergrounddooropenl] +id = 30263 + +[freedomfighterundergrounddooropenr] +id = 30264 diff --git a/data/quest/members/in_search_of_the_myreque/in_search_of_the_myreque.sounds.toml b/data/quest/members/in_search_of_the_myreque/in_search_of_the_myreque.sounds.toml new file mode 100644 index 0000000000..afc880f1b8 --- /dev/null +++ b/data/quest/members/in_search_of_the_myreque/in_search_of_the_myreque.sounds.toml @@ -0,0 +1,17 @@ +[vanstrom_mist] +id = 1186 + +[vanstrom_appear] +id = 1190 + +[vanstrom_vampire] +id = 1194 + +[skeleton_hellhound_attack] +id = 1187 + +[skeleton_hellhound_hit] +id = 1185 + +[skeleton_hellhound_death] +id = 1188 diff --git a/data/quest/members/in_search_of_the_myreque/in_search_of_the_myreque.varbits.toml b/data/quest/members/in_search_of_the_myreque/in_search_of_the_myreque.varbits.toml new file mode 100644 index 0000000000..1aa70a2dae --- /dev/null +++ b/data/quest/members/in_search_of_the_myreque/in_search_of_the_myreque.varbits.toml @@ -0,0 +1,46 @@ +[routequestionscorrect] +id = 173 +persist = true +format = "int" + +[routequestionsanswered] +id = 174 +persist = true +format = "int" + +[roomnum] +id = 175 +persist = true +format = "boolean" + +# The three rungs of the rope bridge over the Hollows, south to north +[bridgerung1] +id = 176 +persist = true +format = "boolean" + +[bridgerung2] +id = 177 +persist = true +format = "boolean" + +[bridgerung3] +id = 178 +persist = true +format = "boolean" + +# Swaps the stranger in the 'Hair of the Dog' between Vanstrom Klause and a plain stranger +[thsfm_vanstrom_hide] +id = 396 +persist = true +format = "boolean" + +[thsfm_spoken_allmyreque] +id = 2496 +persist = true +format = "boolean" + +[route_bridgecomplete] +id = 3928 +persist = true +format = "boolean" diff --git a/data/quest/members/in_search_of_the_myreque/in_search_of_the_myreque.varps.toml b/data/quest/members/in_search_of_the_myreque/in_search_of_the_myreque.varps.toml index 941693bdce..623d216ad4 100644 --- a/data/quest/members/in_search_of_the_myreque/in_search_of_the_myreque.varps.toml +++ b/data/quest/members/in_search_of_the_myreque/in_search_of_the_myreque.varps.toml @@ -3,4 +3,4 @@ id = 387 persist = true format = "map" default = "unstarted" -values = { unstarted = 0, started = 1 } +values = { unstarted = 0, agreed_to_help = 5, refused_delivery = 10, persuaded_boatman = 15, gave_planks = 20, reached_hollows = 25, questioned_by_curpile = 52, answered_questions = 55, entered_hideout = 60, met_veliaf = 65, weapons_accepted = 67, delivered_weapons = 70, hellhound_summoned = 80, killed_hellhound = 85, shown_way_out = 90, in_inn_basement = 95, escaped_to_canifis = 97, completed = 105 } diff --git a/data/quest/members/in_search_of_the_myreque/in_search_of_the_myreque.vars.toml b/data/quest/members/in_search_of_the_myreque/in_search_of_the_myreque.vars.toml new file mode 100644 index 0000000000..3a77c48ff4 --- /dev/null +++ b/data/quest/members/in_search_of_the_myreque/in_search_of_the_myreque.vars.toml @@ -0,0 +1,20 @@ +# Which members of the Myreque the player has introduced themselves to +[met_sani] +persist = true +format = "boolean" + +[met_harold] +persist = true +format = "boolean" + +[met_ivan] +persist = true +format = "boolean" + +[met_radigad] +persist = true +format = "boolean" + +[met_polmafi] +persist = true +format = "boolean" diff --git a/data/quest/quests.toml b/data/quest/quests.toml index 16934a04f6..12fc4e25a6 100644 --- a/data/quest/quests.toml +++ b/data/quest/quests.toml @@ -1955,10 +1955,42 @@ req_skills = { } req_quests = ["nature_spirit"] req_items = "Steel longsword, 2 steel short swords, steel mace, steel warhammer, steel dagger, 75 steel nails, druid pouch (at least 5 charges), blessed silver sickle, 6 planks." +# Three rungs of the rope bridge at 75 nails and a plank each, three more planks for the boatman +# and ten coins for the crossing. +req_item_ids = [ + "steel_longsword", + "steel_sword:2", + "steel_dagger", + "steel_mace", + "steel_warhammer", + "steel_nails:225", + "hammer", + "plank:6", + "druid_pouch_2:5", + "silver_sickle_b", + "coins:10" +] req_combat = "You will need to defeat a level 44 enemy." points = 2 reward = "Access to the Hair of the Dog's cellar
Swamp boat shortcut from Mort'ton to The Hollows
Access to 2 uncut rubies and 4 big bones" -xp = "600 Attack XP lamp
600 Strength XP lamp
600 Crafting XP lamp" +# Quest flags kept under the client's own varbit names, so questreset can clear them too +reset_vars = [ + "bridgerung1", + "bridgerung2", + "bridgerung3", + "route_bridgecomplete", + "routequestionscorrect", + "routequestionsanswered", + "roomnum", + "thsfm_vanstrom_hide", + "thsfm_spoken_allmyreque", + "met_sani", + "met_harold", + "met_ivan", + "met_radigad", + "met_polmafi" +] +xp = "600 Attack XP
600 Defence XP
600 Strength XP
600 Constitution XP
600 Crafting XP" [jungle_potion] id = 66 diff --git a/engine/src/main/kotlin/world/gregs/voidps/engine/map/zone/DynamicZones.kt b/engine/src/main/kotlin/world/gregs/voidps/engine/map/zone/DynamicZones.kt index c29dabaf16..57f3c7f4db 100644 --- a/engine/src/main/kotlin/world/gregs/voidps/engine/map/zone/DynamicZones.kt +++ b/engine/src/main/kotlin/world/gregs/voidps/engine/map/zone/DynamicZones.kt @@ -51,6 +51,25 @@ class DynamicZones( update = true } + /** + * Copies a block of zones, for content that only needs part of a region - a room to stage a + * cutscene in, say - rather than all sixty four zones of one. + * @param from The south west zone of the block to copy + * @param to The south west zone the block is copied to + * @param width How many zones wide the block is + * @param height How many zones high the block is + * @param levels How many levels of it to copy, counting up from the level of [from] and [to] + */ + fun copy(from: Zone, to: Zone, width: Int, height: Int, levels: Int = 1) { + for (x in 0 until width) { + for (y in 0 until height) { + for (level in 0 until levels) { + copy(Zone(from.x + x, from.y + y, from.level + level), Zone(to.x + x, to.y + y, to.level + level)) + } + } + } + } + /** * @param from The region to be copied * @param to The region to be replaced diff --git a/engine/src/test/kotlin/world/gregs/voidps/engine/map/zone/DynamicZonesTest.kt b/engine/src/test/kotlin/world/gregs/voidps/engine/map/zone/DynamicZonesTest.kt index 7b8196ca5a..3a7abfb5df 100644 --- a/engine/src/test/kotlin/world/gregs/voidps/engine/map/zone/DynamicZonesTest.kt +++ b/engine/src/test/kotlin/world/gregs/voidps/engine/map/zone/DynamicZonesTest.kt @@ -37,6 +37,27 @@ internal class DynamicZonesTest { assertEquals(131140, zones.dynamicZone(zone)) } + @Test + fun `Copy a block of zones to another`() { + val from = Zone(4, 4) + val to = Zone(8, 8) + zones.copy(from, to, width = 2, height = 3, levels = 2) + + assertTrue(zones.dynamic(to.region)) + for (x in 0 until 2) { + for (y in 0 until 3) { + for (level in 0 until 2) { + assertNotNull( + zones.dynamicZone(Zone(to.x + x, to.y + y, level)), + "zone $x, $y, $level of the block", + ) + } + } + } + assertNull(zones.dynamicZone(Zone(to.x + 2, to.y, 0)), "nothing outside the block is copied") + assertNull(zones.dynamicZone(Zone(to.x, to.y, 2)), "and no levels above it") + } + @Test fun `Copy one region to another`() { val from = Region(8, 8) diff --git a/game/src/main/kotlin/content/area/morytania/canifis/CanifisTavern.kt b/game/src/main/kotlin/content/area/morytania/canifis/CanifisTavern.kt index fe0fa85b72..ae4e89c794 100644 --- a/game/src/main/kotlin/content/area/morytania/canifis/CanifisTavern.kt +++ b/game/src/main/kotlin/content/area/morytania/canifis/CanifisTavern.kt @@ -1,7 +1,6 @@ package content.area.morytania.canifis import content.entity.obj.door.DoubleDoor -import content.entity.obj.door.enterDoor import world.gregs.voidps.engine.Script import world.gregs.voidps.engine.entity.character.move.tele import world.gregs.voidps.engine.entity.character.sound @@ -9,10 +8,6 @@ import world.gregs.voidps.engine.entity.obj.replace class CanifisTavern : Script { init { - objectOperate("Search", "canifis_fake_wall_closed") { (target) -> - enterDoor(target) - } - objectOperate("Open", "canifis_wooden_door_left_closed,canifis_wooden_door_right_closed") { (target) -> anim("take") sound("locked") diff --git a/game/src/main/kotlin/content/area/morytania/canifis/VanstromKlause.kt b/game/src/main/kotlin/content/area/morytania/canifis/VanstromKlause.kt new file mode 100644 index 0000000000..7063f22c9e --- /dev/null +++ b/game/src/main/kotlin/content/area/morytania/canifis/VanstromKlause.kt @@ -0,0 +1,238 @@ +package content.area.morytania.canifis + +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.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.member.myreque.myrequeStage +import content.quest.questStage +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.entity.character.player.Player + +class VanstromKlause : Script { + init { + npcOperate("Talk-to", "stranger_2") { + if (questStage("in_search_of_the_myreque") == myrequeStage("unstarted")) { + npc("Hello there, how goes it stranger?") + firstMeetingChoices() + } else { + npc("Hello again, how's the quest going?") + returningChoices() + } + } + } + + private suspend fun Player.firstMeetingChoices() { + player("Quite well thanks for asking, how about you?") + npc( + "Hmm, well, I am a little concerned about some friends of mine, they're in dire need of " + + "some assistance, but I'm at a loss of how I can help them.", + ) + initialQuestionChoice() + } + + suspend fun Player.initialQuestionChoice() { + choice { + whatFriends() + whyHelp() + tooBusy() + option("Ok, thanks.") + } + } + + suspend fun Player.extendedQuestionChoice() { + choice { + whatFriends(extended = true) + whyHelp() + tooBusy() + offerToHelp() + option("Ok, thanks.") + } + } + + fun ChoiceOption.whatFriends(extended: Boolean = false): Unit = option("What friends are these?") { + npc( + "It's a personal tragedy that I have yet to meet them in the flesh. But their exploits " + + "make mouth watering hero stories...the real meat and drink of high adventure and " + + "daring...so they say.", + ) + player( + "What does that mean exactly? I mean, I have some stories. I'm quite a hero myself, " + + "you may actually be talking about me?", + ) + npc( + "They're regarded as heroes in Morytania, though some people see them as vigilantes. " + + "The local villagers call them the 'Myreque'. Some people call them terrorists while " + + "others call them freedom fighters!", + ) + + if (extended) { + extendedQuestionChoice() + } else { + initialQuestionChoice() + } + } + + fun ChoiceOption.whyHelp(): Unit = option("Why do they need help? Are they in trouble?") { + npc( + "I should imagine that heroes of such high calibre are almost always in some sort of " + + "trouble, wouldn't you? There's always some evil heel ready to grind the face of " + + "humanity into the dirt?", + ) + npc( + "However, the Myreque are almost certainly able to handle themselves...given the tools! " + + "I hear they're short of weapons, I was hoping to do it myself but I find that I'm " + + "rather short of time and ability.", + ) + player("What help do you hope to give them?") + npc("I'd have taken some weapons to them!") + player("What kind of weapons do they need?") + npc( + "Steel I believe. All six of them require steel weapons. I would have suggested a " + + "longsword, two shortswords, a dagger, a mace and a warhammer.", + ) + extendedQuestionChoice() + } + + fun ChoiceOption.tooBusy(): Unit = option("I wish I could help, but I'm busy at the moment.") { + npc( + "Hmm, the same as me I fear! Oh well, I'm sure they'll get on well by themselves, " + + "the little dears. Thanks for even considering it though.", + ) + } + + fun ChoiceOption.offerToHelp() { + option("Perhaps I could help you out here.") { + player("Perhaps I could help you out here. Maybe I could take these weapons to the Myreque.") + npc("Oh yes, well that would be very nice of you! Are you sure you want to help out?") + helpDetailsChoice() + } + } + + suspend fun Player.helpDetailsChoice() { + choice { + whatToDo() + whatWeapons() + whereToTake() + agreeToHelp() + declineHelp() + } + } + + fun ChoiceOption.whatToDo(): Unit = option("What would I have to do?") { + npc( + "Well, some freedom fighter, called the Myreque, require some weapons. You'd need to " + + "get the weapons they require and give them to the freedom fighters.", + ) + helpDetailsChoice() + } + + fun ChoiceOption.whatWeapons(): Unit = option("What weapons do I need to get?") { + npc( + "Well, I'm sure the following will be fine. A longsword, two shortswords, a dagger, " + + "a mace and a warhammer, all the items should be made of steel.", + ) + npc( + "You would also have to fund the costs of this yourself and once you've found them, " + + "I'll be sure to give you a nice surprise in return for your help.", + ) + helpDetailsChoice() + } + + fun ChoiceOption.whereToTake(): Unit = option("Where do I need to take the weapons?") { + npc( + "I believe there's a boatman in Mort'ton who can show you the way, I'm sure if you use " + + "your powers of persuasion you can get some information out of him! The Myreque " + + "have a lot of sympathisers.", + ) + helpDetailsChoice() + } + + fun ChoiceOption.agreeToHelp(): Unit = option("Yes, I'll do it!") { + set("in_search_of_the_myreque", "agreed_to_help") + npc( + "That's great news my friend, really great news! Perhaps the many peoples of Morytania " + + "now have an additional hero that they can come to rely upon?", + ) + } + + fun ChoiceOption.declineHelp(): Unit = option("Sorry, I can't do it!") { + npc( + "Oh well, that is a shame, I'm sorry to hear it. But if you change your mind, please " + + "visit me again.", + ) + } + + private suspend fun Player.returningChoices() { + choice { + needHelp() + farewell() + forgotTask() + } + } + + fun ChoiceOption.needHelp(): Unit = option("Not very well, I need some help.") { + npc("Oh dear... what do you need help with?") + helpQuestionsChoice() + } + + suspend fun Player.helpQuestionsChoice() { + choice { + whereMyreque() + whatWeaponsAgain() + howKnowMyreque() + anyoneElse() + farewell() + } + } + + fun ChoiceOption.whereMyreque(): Unit = option("Where are the Myreque?") { + npc( + "You know what? I'm not exactly sure. The only thing I've heard, and it is a rumour, " + + "but the boatman in Mort'ton might be able to help. But again, it is only a rumour.", + ) + helpQuestionsChoice() + } + + fun ChoiceOption.whatWeaponsAgain(): Unit = option("What weapons am I supposed to get again?") { + npc( + "Well, I'm sure the following will be fine. A longsword, two shortswords, a dagger, " + + "a mace and a warhammer, all the items should be made of steel.", + ) + helpQuestionsChoice() + } + + fun ChoiceOption.howKnowMyreque(): Unit = option("How do you know 'The Myreque'?") { + npc( + "Actually you know, I don't know them at all! I've never actually met them, but would " + + "love to at some point! However, I'm sure that you'll be much more able than I at " + + "tracking them down.", + ) + helpQuestionsChoice() + } + + fun ChoiceOption.anyoneElse(): Unit = option("Is there anyone else who can help me?") { + npc("Hmm. Apart from the boatman at Mor'ton, I have no clue my friend.") + helpQuestionsChoice() + } + + fun ChoiceOption.forgotTask(): Unit = option("What am I supposed to do again?") { + npc( + "You've forgotten already? Ha! That's funny! But I do understand, my memory isn't " + + "what it was!", + ) + npc( + "It would be great if you could get some steel weapons and take them to the Myreque, " + + "the boatman in Mort'ton should be able to help you find them. But you may need " + + "to be a bit persuasive!", + ) + } + + fun ChoiceOption.farewell(): Unit = option("Ok, thanks.") { + npc("Well, if you have any problems please feel free to come and chat with me.") + } +} diff --git a/game/src/main/kotlin/content/area/morytania/mort_myre_swamp/CurpileFyod.kt b/game/src/main/kotlin/content/area/morytania/mort_myre_swamp/CurpileFyod.kt new file mode 100644 index 0000000000..7f3cfac13a --- /dev/null +++ b/game/src/main/kotlin/content/area/morytania/mort_myre_swamp/CurpileFyod.kt @@ -0,0 +1,271 @@ +package content.area.morytania.mort_myre_swamp + +import content.entity.combat.hit.damage +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.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.quest.member.myreque.hasAllWeapons +import content.quest.member.myreque.myrequeStage +import content.quest.questStage +import world.gregs.voidps.engine.Script +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.entity.character.move.tele +import world.gregs.voidps.engine.entity.character.npc.NPC +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.type.random + +class CurpileFyod : Script { + init { + npcOperate("Talk-to", "curpile_fyod_mort_myre_swamp") { (curpile) -> + val progress = questStage("in_search_of_the_myreque") + when { + progress < myrequeStage("reached_hollows") -> { + npc("Hey...you's tresspassin' be off wiv ya.") + } + progress == myrequeStage("reached_hollows") || progress == myrequeStage("questioned_by_curpile") -> { + npc("Hey...what're you doin' here?") + initialChoice(curpile) + } + progress > myrequeStage("questioned_by_curpile") -> { + npc("Hey...leave me be...I'm guardin' this place.") + } + } + } + } + + suspend fun Player.initialChoice(curpile: NPC) { + choice { + comeToHelp(curpile) + dontHaveToAnswer(curpile) + noReason(curpile) + askForWayOut(curpile) + haveToGo() + } + } + + fun ChoiceOption.comeToHelp(curpile: NPC): Unit = option("I've come to help the Myreque, I've brought weapons.") { + if (!hasAllWeapons()) { + npc( + "It doesn't look as if you have the right weapons to me. Get out of here you fake!", + ) + return@option + } + npc( + "Ok, I see ya got da weapons...but how'd I know you're not gonna use em against my " + + "friends?", + ) + player("But I just want to help deliver these weapons.") + npc( + "Well, dat's as maybe, and I'm not doubting your sincerity here, you seems all sincered " + + "up to me...it's choking me up right here, you're making me cry...but hey, I's godda " + + "do my job or da kids don't get fed! Ok,", + ) + if (questStage("in_search_of_the_myreque") == myrequeStage("reached_hollows")) { + set("in_search_of_the_myreque", "questioned_by_curpile") + } + npc( + "so say I asks you a few questions and you were to answer them all correct and so on, " + + "well that'd make me believe you's...I'd get to feeling that you was the real deal " + + "an all. How's dat sound?", + ) + player("Sounds fine to me...go ahead, shoot!") + npc("Hey...don't tempt me! You's dicing wiv death here my friend!") + runQuiz(curpile) + } + + fun ChoiceOption.dontHaveToAnswer(curpile: NPC): Unit = option("I don't have to answer to you!") { + npc("You do if you know what's good for you!") + sendPunch(curpile) + } + + fun ChoiceOption.noReason(curpile: NPC): Unit = option("No reason!") { + npc( + "Come on...speak up! I may be able to help you. However, yous gotta know that your " + + "tresspassing and that by rights I should slap your around a bit, you know, just " + + "like as a warning.", + ) + initialChoice(curpile) + } + + fun ChoiceOption.askForWayOut(curpile: NPC): Unit = option("I'm lost, can you show me the way out?") { + npc( + "You got yourself in here... now get yourself out.. and quickly if I were you.", + ) + initialChoice(curpile) + } + + fun ChoiceOption.haveToGo(): Unit = option("I have to go.") { + npc("Sure...be on your way stranger.") + } + + private suspend fun Player.runQuiz(curpile: NPC) { + val asked = (0..QUESTIONS).shuffled(random).take(3) + var correct = 0 + + for ((index, q) in asked.withIndex()) { + val prefix = when (index) { + 0 -> "Ok, first question." + 1 -> "Ok, second question." + else -> "Ok, third and final question." + } + if (askQuestion(q, prefix)) { + correct++ + } + val ack = when (index) { + 0 -> "Ok, interesting answer. First question answered." + 1 -> "An interesting response. Second question answered." + else -> "Hmm, a calculated retort. Third question answered." + } + npc(ack) + } + + if (correct == 3) { + set("in_search_of_the_myreque", "answered_questions") + npc("Ok, I believes ya...you can go on.") + player("What's the combination to the door?") + npc("Oh, there isn't one. I'll unlock it for you.") + } else { + val word = if (correct == 1) "question" else "questions" + npc("I'm sorry but you got $correct $word correct! Go on get outta here...") + knockOutPlayer(curpile, correct) + } + } + + private suspend fun Player.askQuestion(questionIndex: Int, prefix: String): Boolean = when (questionIndex) { + 0 -> { + npc("$prefix What is the boatman's name?") + askBoatmanName() + } + 1 -> { + npc("$prefix Who is the leader of the Myreque?") + askMyrequeLeader() + } + 2 -> { + npc("$prefix Name the only female member of the Myreque.") + askFemaleMember() + } + 3 -> { + npc("$prefix Who is the youngest member of the Myreque?") + askYoungestMember() + } + 4 -> { + npc("$prefix What family is rumoured to rule Morytania?") + askRulingFamily() + } + else -> { + npc( + "$prefix Which member of the 'Myreque' originally had a profession as a scholar?", + ) + askScholar() + } + } + + private suspend fun Player.askBoatmanName(): Boolean { + var correct = false + choice("What is the boatman's name?") { + option("Geof Paddleman") {} + option("Cyreg Paddlebone") {} + option("Gyrec Paddlehorn") {} + option("Cyreg Paddlehorn") { correct = true } + option("I don't know!") {} + } + return correct + } + + private suspend fun Player.askMyrequeLeader(): Boolean { + var correct = false + choice("Who is the leader of the Myreque?") { + option("Sani Piliu") {} + option("Harold Evans") {} + option("Veliaf Hurtz") { correct = true } + option("Radigad Ponfit") {} + option("Don't know!") {} + } + return correct + } + + private suspend fun Player.askFemaleMember(): Boolean { + var correct = false + choice("Name the only female member of the Myreque?") { + option("Sani Piliu") { correct = true } + option("Santi Peliou") {} + option("Sani Peridou") {} + option("Sandi Pherimou") {} + option("Don't know!") {} + } + return correct + } + + private suspend fun Player.askYoungestMember(): Boolean { + var correct = false + choice("Who is the youngest member of the Myreque?") { + option("Sani Piliu") {} + option("Ivan Strom") { correct = true } + option("Veliaf Hurtz") {} + option("Radigad Ponfit") {} + option("Don't know!") {} + } + return correct + } + + private suspend fun Player.askRulingFamily(): Boolean { + var correct = false + choice("What family is rumoured to rule Morytania?") { + option("Drunken") {} + option("Drakan") { correct = true } + option("Draynor") {} + option("Praymore") {} + option("I don't know!") {} + } + return correct + } + + private suspend fun Player.askScholar(): Boolean { + var correct = false + choice("Who was previously a scholar?") { + option("Sani Piliu") {} + option("Harold Evans") {} + option("Radogad Ponfit") {} + option("Polmafi Ferdygris") { correct = true } + option("Don't know!") {} + } + return correct + } + + private suspend fun Player.sendPunch(curpile: NPC) { + curpile.anim("curpile_punch") + delay(1) + damage(30, source = curpile) + } + + private suspend fun Player.knockOutPlayer(curpile: NPC, correct: Int) { + val word = if (correct == 1) "question" else "questions" + message("Curpile Fyod: I'm sorry but you got $correct $word correct! Go on get outta here...") + curpile.anim("curpile_knockout_punch") + gfx("curpile_knockout_stars") + delay(2) + anim("myreque_knockout") + delay(1) + open("fade_out") + delay(3) + clearAnim() + tele(3522, 3285) + delay(2) + close("fade_out") + open("fade_in") + delay(3) + statement("Curpile knocked you out...You wake up back in Mort'ton...") + } + + private companion object { + private const val QUESTIONS = 5 + } +} diff --git a/game/src/main/kotlin/content/area/morytania/mort_myre_swamp/HaroldEvans.kt b/game/src/main/kotlin/content/area/morytania/mort_myre_swamp/HaroldEvans.kt new file mode 100644 index 0000000000..7c166f02ea --- /dev/null +++ b/game/src/main/kotlin/content/area/morytania/mort_myre_swamp/HaroldEvans.kt @@ -0,0 +1,112 @@ +package content.area.morytania.mort_myre_swamp + +import content.entity.player.dialogue.Confused +import content.entity.player.dialogue.Happy +import content.entity.player.dialogue.Neutral +import content.entity.player.dialogue.Quiz +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.member.myreque.myrequeStage +import content.quest.questStage +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.client.message +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.entity.character.player.name + +class HaroldEvans : Script { + init { + npcOperate("Talk-to", "harold_evans_meiyerditch_tunnels") { + when (questStage("in_search_of_the_myreque")) { + in myrequeStage("delivered_weapons")..myrequeStage("hellhound_summoned") -> { + message("This person isn't able to talk to you at this time.") + } + myrequeStage("entered_hideout") -> { + npc( + "Hail and well met friend! You'd better go and introduce yourself to Veliaf. " + + "But after come and have a chat if you've a mind to.", + ) + } + myrequeStage("met_veliaf"), myrequeStage("weapons_accepted") -> { + if (!get("met_harold", false)) { + set("met_harold", true) + npc( + "Well met friend! My name's Harold...nice to meet a fellow soldier. " + + "Just joined us have you?", + ) + player("Not exactly! I'd like to ask a few questions if that's Ok?") + soldierResponse() + } else { + npc("Well met $name! Here's to your strong sword arm!") + player("Do you mind if I ask a few questions?") + soldierResponse() + } + } + } + } + } + + private suspend fun Player.soldierResponse() { + npc("Sure go ahead, I'll answer as best I can.") + questionsChoice() + } + + suspend fun Player.questionsChoice() { + choice { + whatsYourJob() + tellMeAboutSquad() + graspOfSituation() + heardAnyRumors() + option("Ok, thanks.") + } + } + + fun ChoiceOption.whatsYourJob(): Unit = option("What's your job here?") { + npc( + "I generally do what Veliaf orders...taking a bridge defending a tactical retreat..." + + "you name it, I'm usually in the thick of it. It's funny, I seem to cheat death " + + "most of the time.", + ) + questionsChoice() + } + + fun ChoiceOption.tellMeAboutSquad(): Unit = option("What can you tell me about your squad?") { + npc( + "They're all good soldiers, though they're pretty much a bunch of irregulars. I " + + "consider them a good squad, ready to leap into the fray if needed, but we're " + + "seriously outnumbered by the Drakans and their blood.", + ) + npc( + "Most fights we have to improvise but this bunch are very good at that. That young " + + "lad over there, Ivan Strom, he saved our bacon in the last encounter by his " + + "faith in Saradomin, he weakened the Juves and we ran for it!", + ) + player("Juves? What are they?") + npc( + "It's short for 'juvenile', that's the stage that those vampires were at in the " + + "forest. They've only recently become vampiric and they're pretty inexperienced.", + ) + npc( + "However, they're stronger and faster than many well trained militia men, and twice " + + "as blood thirsty.", + ) + questionsChoice() + } + + fun ChoiceOption.graspOfSituation(): Unit = option("What's your grasp of the situation soldier?") { + npc( + "The situation is grave, it would be dangerous to think differently. We're really up " + + "against it here, but we have no choice, this is a battle for our right to exist " + + "and we need to employ any tactics to achieve our goal.", + ) + questionsChoice() + } + + fun ChoiceOption.heardAnyRumors(): Unit = option("Have you heard any rumors?") { + npc( + "Yes, I hear them all the time, thankfully I don't pay any attention to them, though.", + ) + questionsChoice() + } +} diff --git a/game/src/main/kotlin/content/area/morytania/mort_myre_swamp/IvanStrom.kt b/game/src/main/kotlin/content/area/morytania/mort_myre_swamp/IvanStrom.kt new file mode 100644 index 0000000000..a7be5ad861 --- /dev/null +++ b/game/src/main/kotlin/content/area/morytania/mort_myre_swamp/IvanStrom.kt @@ -0,0 +1,114 @@ +package content.area.morytania.mort_myre_swamp + +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.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.member.myreque.myrequeStage +import content.quest.questStage +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.client.message +import world.gregs.voidps.engine.entity.character.player.Player + +class IvanStrom : Script { + init { + npcOperate("Talk-to", "ivan_strom_meiyerditch_tunnels") { + when (questStage("in_search_of_the_myreque")) { + in myrequeStage("delivered_weapons")..myrequeStage("hellhound_summoned") -> { + message("This person isn't able to talk to you at this time.") + } + myrequeStage("entered_hideout") -> { + npc( + "Oh...er... hello...greetings that is...sorry... you need to speak to Veliaf " + + "first. I'm not sure if I'm supposed to be talking to you.", + ) + } + myrequeStage("met_veliaf"), myrequeStage("weapons_accepted") -> { + if (get("met_ivan", false)) { + npc("Oh, hello again. How are you?") + questionsChoice() + } else { + firstMeeting() + } + } + else -> aftermath() + } + } + } + + private suspend fun Player.firstMeeting() { + player( + "Hi, Veliaf said that I should introduce myself to all members of the Myreque.", + ) + set("met_ivan", true) + npc( + "Ok, Veliaf says it's ok for me to talk to you. My name is Ivan, nice to meet you.", + ) + player("Would you mind if I ask you a few questions?") + npc("Yeah, I suppose so, but I don't know how much I can help.") + questionsChoice() + } + + suspend fun Player.questionsChoice() { + choice { + whatAreYouDoing() + tellMeAboutMyreque() + aboutMorytania() + heardRumors() + option("Ok, thanks.") + } + } + + fun ChoiceOption.whatAreYouDoing(): Unit = option("What are you doing here?") { + npc( + "I'm not sure really, I should really be at the temple to Saradomin, but Veliaf says " + + "that the trip's too dangerous. I am a trainee cleric of Saradomin. I hope one day " + + "to serve him as faithfully as all his priests.", + ) + questionsChoice() + } + + fun ChoiceOption.tellMeAboutMyreque(): Unit = option("What can you tell me about the Myreque?") { + npc( + "Well, they're certainly brave and they're prepared to put up a fight against the " + + "evil of the Drakan clan. I'm proud to be associated with them, but with my poor " + + "skills I worry that I will let them down.", + ) + questionsChoice() + } + + fun ChoiceOption.aboutMorytania(): Unit = option("What do you know about Morytania?") { + npc( + "It's a lost land ruled by the evil Drakans. People cling to each other in fear every " + + "week when the blood tithes are to be paid. With Saradomin's help we'll bring " + + "light back to this dark land.", + ) + questionsChoice() + } + + fun ChoiceOption.heardRumors(): Unit = option("Have you heard any rumors?") { + npc( + "I'm sorry my friend, but I spend most of my free time in prayer to Saradomin. " + + "I prefer to cleanse my soul in case he should wish that I serve him closer.", + ) + questionsChoice() + } + + private suspend fun Player.aftermath() { + npc( + "Oh, thank Saradomin you managed to kill that awful beast! Oh my, I'm in so much " + + "shock after seeing Sani and Harold killed right in front of my own eyes.", + ) + player( + "I'm so sorry for your loss. If there's anything I can do, please let me know.", + ) + npc( + "I think you've done enough already, bringing that monster Vanstrom here!", + ) + } +} diff --git a/game/src/main/kotlin/content/area/morytania/mort_myre_swamp/MortMyreSwamp.kt b/game/src/main/kotlin/content/area/morytania/mort_myre_swamp/MortMyreSwamp.kt deleted file mode 100644 index 94c7fc17b1..0000000000 --- a/game/src/main/kotlin/content/area/morytania/mort_myre_swamp/MortMyreSwamp.kt +++ /dev/null @@ -1,37 +0,0 @@ -package content.area.morytania.mort_myre_swamp - -import world.gregs.voidps.engine.Script -import world.gregs.voidps.engine.client.message -import world.gregs.voidps.engine.entity.character.move.tele -import world.gregs.voidps.engine.entity.character.sound -import world.gregs.voidps.engine.entity.obj.replace -import world.gregs.voidps.type.Direction -import world.gregs.voidps.type.Tile - -class MortMyreSwamp : Script { - init { - objectOperate("Open", "swamp_wooden_doors_closed") { (target) -> - anim("take") - sound("locked") - delay(1) - target.replace("swamp_wooden_doors_opened", ticks = 3) - delay(2) - tele(3500, 9811) - } - - objectOperate("Climb", "swamp_bridge_tree") { (target) -> - val direction = if (target.tile.y == 3431) Direction.SOUTH else Direction.NORTH - anim("climb_up") - delay(2) - tele(target.tile.add(direction)) - message("You climb up the tree.") - face(direction) - delay(1) - walkOverDelay(target.tile.add(direction).add(direction).add(direction).add(direction)) - message("You climb down the tree.") - anim("climb_down") - delay(1) - tele(if (direction == Direction.SOUTH) Tile(3502, 3425) else Tile(3503, 3431)) - } - } -} diff --git a/game/src/main/kotlin/content/area/morytania/mort_myre_swamp/MyrequeStranger.kt b/game/src/main/kotlin/content/area/morytania/mort_myre_swamp/MyrequeStranger.kt new file mode 100644 index 0000000000..25a393bfe1 --- /dev/null +++ b/game/src/main/kotlin/content/area/morytania/mort_myre_swamp/MyrequeStranger.kt @@ -0,0 +1,64 @@ +package content.area.morytania.mort_myre_swamp + +import content.entity.player.dialogue.Angry +import content.entity.player.dialogue.Confused +import content.entity.player.dialogue.Neutral +import content.entity.player.dialogue.Quiz +import content.entity.player.dialogue.Shock +import content.entity.player.dialogue.type.npc +import content.entity.player.dialogue.type.player +import content.quest.member.myreque.myrequeStage +import content.quest.questComplete +import content.quest.questStage +import content.quest.refreshQuestJournal +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.entity.character.jingle +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.exp.exp +import world.gregs.voidps.engine.event.AuditLog + +class MyrequeStranger : Script { + init { + npcOperate("Talk-to", "stranger_3") { + val progress = questStage("in_search_of_the_myreque") + if (progress !in myrequeStage("delivered_weapons")..myrequeStage("escaped_to_canifis")) { + return@npcOperate npc("I think we've been through this before haven't we?") + } + npc("Hello friend? What can I do for you?") + player("Oh sorry, I thought you were someone else.") + if (progress != myrequeStage("escaped_to_canifis")) { + return@npcOperate + } + npc("Who did you think I was?") + player("I thought you were that dirty murderer Vanstrom.") + npc("Woah, it sounds like you've got a score to settle!") + player("I definitely have!") + completeQuest() + } + } + + private fun Player.completeQuest() { + jingle("quest_complete_1") + exp(Skill.Attack, 600.0) + exp(Skill.Defence, 600.0) + exp(Skill.Strength, 600.0) + exp(Skill.Constitution, 600.0) + exp(Skill.Crafting, 600.0) + inc("quest_points", 2) + AuditLog.event(this, "quest_completed", "in_search_of_the_myreque") + set("in_search_of_the_myreque", "completed") + refreshQuestJournal() + questComplete( + "In Search of the Myreque", + "2 Quest Points", + "600 Attack XP", + "600 Defence XP", + "600 Strength XP", + "600 Constitution XP", + "600 Crafting XP", + "A quick route to Mort'ton", + item = "steel_sword", + ) + } +} diff --git a/game/src/main/kotlin/content/area/morytania/mort_myre_swamp/PolmafiFerdygris.kt b/game/src/main/kotlin/content/area/morytania/mort_myre_swamp/PolmafiFerdygris.kt new file mode 100644 index 0000000000..6a183256df --- /dev/null +++ b/game/src/main/kotlin/content/area/morytania/mort_myre_swamp/PolmafiFerdygris.kt @@ -0,0 +1,123 @@ +package content.area.morytania.mort_myre_swamp + +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.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.member.myreque.myrequeStage +import content.quest.questStage +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.client.message +import world.gregs.voidps.engine.entity.character.player.Player + +class PolmafiFerdygris : Script { + init { + npcOperate("Talk-to", "polmafi_ferdygris") { + when (questStage("in_search_of_the_myreque")) { + in myrequeStage("delivered_weapons")..myrequeStage("hellhound_summoned") -> { + message("This person isn't able to talk to you at this time.") + } + myrequeStage("entered_hideout") -> { + npc( + "Good day. You should quickly introduce yourself to Veliaf and after that " + + "I'll be happy to have a chat with you.", + ) + } + myrequeStage("met_veliaf"), myrequeStage("weapons_accepted") -> { + if (!get("met_polmafi", false)) { + set("met_polmafi", true) + npc( + "Good! Now that you've introduced yourself to Veliaf and observed proper " + + "protocol, I'll be happy to chat with you. Now, please tell me, what " + + "can I do for you?", + ) + player("Do you mind if I ask a few questions?") + } else { + npc("Warmest salutations of the day my friend! How are you?") + player("Fine thanks. I'd like to ask a few questions if that's Ok?") + } + npc("Not at all, I'm sure I'll be able to help and if I can't I'll tell you so.") + questionsChoice() + } + else -> aftermath() + } + } + } + + suspend fun Player.questionsChoice() { + choice { + whatAreYouDoing() + tellMeAboutMyreque() + aboutMorytania() + heardRumors() + option("Ok, thanks.") + } + } + + fun ChoiceOption.whatAreYouDoing(): Unit = option("What are you doing here?") { + npc( + "I am the key advisor to Veliaf, he seeks my council on many issues before we engage " + + "the enemy. Not that we would be doing that any time soon, the situation is " + + "somewhat difficult and terrifying at the moment.", + ) + npc( + "However, we must always remember, 'All that is required for evil to survive is for " + + "good people to do nothing.'", + ) + questionsChoice() + } + + fun ChoiceOption.tellMeAboutMyreque(): Unit = option("What can you tell me about the Myreque?") { + npc( + "Well, we have a hierarchical chain of command. Veliaf reports to Calsidiu who is " + + "considered the head of the armed resistance against the Drakans.", + ) + npc( + "I've also heard people talking about a spiritual man, who inspires many people to " + + "join the resistance. He's said to be very influential with supposed contacts " + + "within the Drakan clan.", + ) + questionsChoice() + } + + fun ChoiceOption.aboutMorytania(): Unit = option("What do you know about Morytania?") { + npc( + "It has a rugged landscape,, situated to the east of the River Salve. Primary " + + "occupants seem to be human in apperance, but are in fact large vampiric predators.", + ) + npc( + "They maintain a stock of human villages in the same way that humans keep cattle. " + + "Weekly blood tithes are paid to the Vampyric overlord Drakan in return for " + + "moderate safety.", + ) + questionsChoice() + } + + fun ChoiceOption.heardRumors(): Unit = option("Have you heard any rumors?") { + npc( + "Yes, I hear them all the time, they're neither correct or even all that interesting, " + + "I suspect that we will be looking to make a strike against the human " + + "sympathisers some time soon.", + ) + questionsChoice() + } + + private suspend fun Player.aftermath() { + npc( + "I'm so sad since Sani and Harold were killed. The Myreque will be lonelier without " + + "them. Still, they didn't suffer at least.", + ) + player( + "My commiserations for your loss. I wish that there was something I could do to help.", + ) + npc( + "Don't worry. No one blames you. It could have been anyone that was tricked by him. " + + "Whilst I'm sad at the loss of my comrades, it serves no useful purpose to blame " + + "anyone for it.", + ) + } +} diff --git a/game/src/main/kotlin/content/area/morytania/mort_myre_swamp/RadigadPonfit.kt b/game/src/main/kotlin/content/area/morytania/mort_myre_swamp/RadigadPonfit.kt new file mode 100644 index 0000000000..21e40f45e7 --- /dev/null +++ b/game/src/main/kotlin/content/area/morytania/mort_myre_swamp/RadigadPonfit.kt @@ -0,0 +1,115 @@ +package content.area.morytania.mort_myre_swamp + +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.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.member.myreque.myrequeStage +import content.quest.questStage +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.client.message +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.entity.character.player.name + +class RadigadPonfit : Script { + init { + npcOperate("Talk-to", "radigad_ponfit") { + when (questStage("in_search_of_the_myreque")) { + in myrequeStage("delivered_weapons")..myrequeStage("hellhound_summoned") -> { + message("This person isn't able to talk to you at this time.") + } + myrequeStage("entered_hideout") -> { + npc( + "Greetings friend! Once you've introduced yourself to Veliaf please feel " + + "free to chat with me...", + ) + } + myrequeStage("met_veliaf"), myrequeStage("weapons_accepted") -> { + if (!get("met_radigad", false)) { + set("met_radigad", true) + npc( + "Ah it's $name. I see you've introduced yourself to Veliaf, " + + "good! Now, how can I be of service to you?", + ) + player("Do you mind if I ask a few questions?") + } else { + npc( + "Hello again! Radigad at your service. Are you planning to join the fray " + + "against the Morytania monster and the rest of the Drakans?", + ) + player("Maybe! I'd like to ask a few questions if that's Ok?") + } + npc("Sure, go ahead, I'll answer as best I can.") + questionsChoice() + } + else -> aftermath() + } + } + } + + suspend fun Player.questionsChoice() { + choice { + whatsYourJob() + tellMeAboutMyreque() + futurePlans() + heardRumors() + option("Ok, thanks.") + } + } + + fun ChoiceOption.whatsYourJob(): Unit = option("What's your job here?") { + npc( + "I help where I can. I'm a bit of an all rounder. I try to help Veliaf as much as " + + "I can. I've relied on his sword arm many a time, I like to think that he can " + + "rely on mine.", + ) + questionsChoice() + } + + fun ChoiceOption.tellMeAboutMyreque(): Unit = option("What can you tell me about the Myreque?") { + npc( + "We're just developing the resistance against the Drakans and the rest of the " + + "Vampiric entourage. We're seriously out numbered, Veliaf doesn't like to hear " + + "us saying 'hopeless' but that's our situation at the minute.", + ) + questionsChoice() + } + + fun ChoiceOption.futurePlans(): Unit = option("What are your plans for the future?") { + npc( + "I'm not really sure, I guess Veliaf might know that, however I hope that more " + + "people will join the ranks and take the fight to the dead hearts of our " + + "Vampiric foe so that we might one day reclaim our right to exist.", + ) + questionsChoice() + } + + fun ChoiceOption.heardRumors(): Unit = option("Have you heard any rumors?") { + npc( + "I have. Something you might find interesting is that our enigmatic leader 'Safalaan' " + + "has connections inside the Drakan Clan. I'm not sure how true that is but it " + + "would be great!", + ) + questionsChoice() + } + + private suspend fun Player.aftermath() { + player("Hello, are you ok?") + npc( + "Yeah, I'm Ok thanks. You certainly proved yourself against that unearthly monster " + + "that Vanstrom conjured. I'm sorry I can't be more upbeat, but I'm very sad " + + "about Harold and Sani. It's terrible to see your comrades cut down in combat, " + + "especially those two. They were still so very young.", + ) + } + + private fun getMyrequeMember(player: Player, index: Int): Boolean = player["myreque_member_$index", false] + + private fun setMyrequeMember(player: Player, index: Int, value: Boolean) { + player["myreque_member_$index"] = value + } +} diff --git a/game/src/main/kotlin/content/area/morytania/mort_myre_swamp/SaniPiliu.kt b/game/src/main/kotlin/content/area/morytania/mort_myre_swamp/SaniPiliu.kt new file mode 100644 index 0000000000..394d005777 --- /dev/null +++ b/game/src/main/kotlin/content/area/morytania/mort_myre_swamp/SaniPiliu.kt @@ -0,0 +1,133 @@ +package content.area.morytania.mort_myre_swamp + +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.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.member.myreque.myrequeStage +import content.quest.questStage +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.client.message +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.entity.character.player.name + +class SaniPiliu : Script { + init { + npcOperate("Talk-to", "sani_piliu_meiyerditch_tunnels") { + when (questStage("in_search_of_the_myreque")) { + in myrequeStage("delivered_weapons")..myrequeStage("hellhound_summoned") -> { + message("This person isn't able to talk to you at this time.") + } + myrequeStage("entered_hideout") -> { + npc( + "Hi there...you're new here aren't you? You'd best go and talk with Veliaf. " + + "But afterwards, I'd love to introduce myself properly.", + ) + } + myrequeStage("met_veliaf"), myrequeStage("weapons_accepted") -> { + if (!get("met_sani", false)) { + firstMeeting() + } else { + npc( + "Oh, hello $name nice to see you again! How's things?", + ) + player("Fine thanks, do you mind if I ask a few questions?") + npc("Sure, go ahead, I'll answer as best I can.") + questionsChoice() + } + } + } + } + } + + private suspend fun Player.firstMeeting() { + npc( + "Hello...very nice to meet you! My name is Sani Piliu, what's yours?", + ) + player("I am known as $name nice to meet you Sani.") + set("met_sani", true) + npc( + "The pleasure's all mine! Excuse me for being forward, but you somehow seem different " + + "to other people, somehow more determined. I sense a powerful aura. Sorry, that " + + "must seem really strange?", + ) + player( + "Well, perhaps a little but it's nice of you to say so in any case. I'd like to ask " + + "a few questions if that's ok?", + ) + npc("Sure, go ahead, I'll answer as best I can.") + questionsChoice() + } + + suspend fun Player.questionsChoice() { + choice { + tellAboutYourself() + tellMeAboutMyreque() + whatsGoingOn() + mainCamp() + option("Ok, thanks.") + } + } + + fun ChoiceOption.tellAboutYourself(): Unit = option("Tell me a bit about yourself.") { + npc( + "There's not much to tell really. I was about nine years old when my family was " + + "ripped to pieces by a vampire attack, one day a happy member of a loving " + + "family, the next day an orphan living a hand to mouth existence.", + ) + npc( + "Somehow I managed to get by, eking out a living where ever I could. People took " + + "pity on me, I earnt food by helping people out, before too long I was quite " + + "adept at helping myself, other people appreciated my skills.", + ) + npc( + "I bumped into Veliaf in Canifis. He saw that I would be an asset to the resistance " + + "so I joined. I want to fight back against those killers who took my family. " + + "One day I may escape to the west across the Salve! Who knows?", + ) + questionsChoice() + } + + fun ChoiceOption.tellMeAboutMyreque(): Unit = option("What can you tell me about the Myreque?") { + npc( + "As far as I know Veliaf is one of two Lieutenants who report to another soldier " + + "called Calsidiu. While we don't advertise the fact, there are quite a lot of " + + "people in Morytania who want to put up a fight.", + ) + npc( + "Our current operations are merely trying to find a weakness in the Drakans' " + + "hierarchy amd perhaps get a spy in there. We need to find out how to kill " + + "them before we then take the war to them.", + ) + player("You make it sound easy!") + npc( + "With determination you can achieve great things. However, Veliaf always tells us " + + "not to underestimate the enemy. After those juvenile vampires discovered us " + + "in the forest, it's a mistake I won't be making again.", + ) + questionsChoice() + } + + fun ChoiceOption.whatsGoingOn(): Unit = option("What's going on in Morytania?") { + npc( + "Lowernial Drakan and his siblings collect blood tithes from the locals as a way of " + + "controlling the population and keeping his brood happy. Sanguinesti and Castle " + + "Drakan hold sway over all the inhabitants of Morytania.", + ) + npc( + "We're nothing more than an ill ogranised rabble, but we're determined to fight " + + "against these evil monsters. We won't just offer our family members to the " + + "Drakans for a weekly snack, we're going to fight back!", + ) + questionsChoice() + } + + fun ChoiceOption.mainCamp(): Unit = option("Is this your main camp?") { + npc("It's our only camp! At least, I don't know of any others.") + questionsChoice() + } +} diff --git a/game/src/main/kotlin/content/area/morytania/mort_myre_swamp/SwampyBoat.kt b/game/src/main/kotlin/content/area/morytania/mort_myre_swamp/SwampyBoat.kt index 47eec3f063..c613d246e2 100644 --- a/game/src/main/kotlin/content/area/morytania/mort_myre_swamp/SwampyBoat.kt +++ b/game/src/main/kotlin/content/area/morytania/mort_myre_swamp/SwampyBoat.kt @@ -1,5 +1,7 @@ package content.area.morytania.mort_myre_swamp +import content.area.morytania.mort_ton.earlyCyregOptions +import content.entity.player.dialogue.Angry import content.entity.player.dialogue.Neutral import content.entity.player.dialogue.Quiz import content.entity.player.dialogue.type.choice @@ -7,13 +9,17 @@ import content.entity.player.dialogue.type.npc import content.entity.player.dialogue.type.statement import content.entity.player.modal.Tab import content.quest.closeTabs +import content.quest.member.myreque.myrequeStage import content.quest.openTabs -import content.quest.questCompleted +import content.quest.questStage import net.pearx.kasechange.toSnakeCase +import world.gregs.voidps.cache.definition.data.InterfaceDefinition import world.gregs.voidps.engine.Script import world.gregs.voidps.engine.client.Minimap import world.gregs.voidps.engine.client.clearMinimap import world.gregs.voidps.engine.client.minimap +import world.gregs.voidps.engine.client.sendScript +import world.gregs.voidps.engine.client.ui.close 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 @@ -36,11 +42,12 @@ class SwampyBoat : Script { objectOperate("Board", "swamp_boat_mort_ton") { talkWith(cyreg()) - if (!questCompleted("in_search_of_the_myreque")) { - npc("Hey, hands off my boat!") + if (questStage("in_search_of_the_myreque") <= myrequeStage("persuaded_boatman")) { + npc("Hey, hands off my boat!") + earlyCyregOptions() return@objectOperate } - npc("I'll have to charge you 10 gold to cover the loan of the boat. Is that okay?") + npc("It costs 10 gold to cover the loan of the boat. Will you pay?") choice { option("Yes. I'll pay the ten gold.") { if (!inventory.remove("coins", 10)) { @@ -61,8 +68,9 @@ class SwampyBoat : Script { objectOperate("Board ( Pay 10 )", "swamp_boat_mort_ton") { talkWith(cyreg()) - if (!questCompleted("in_search_of_the_myreque")) { - npc("Hey, hands off my boat!") + if (questStage("in_search_of_the_myreque") <= myrequeStage("persuaded_boatman")) { + npc("Hey, hands off my boat!") + earlyCyregOptions() return@objectOperate } if (!inventory.remove("coins", 10)) { @@ -76,22 +84,30 @@ class SwampyBoat : Script { private fun cyreg(): NPC = NPCs.findOrNull(Region(14131).toLevel(0), "cyreg_paddlehorn") ?: NPCs.find(Region(13875).toLevel(0), "cyreg_paddlehorn") private suspend fun Player.hollows() { - travel("the Hollows", Tile(3498, 3380)) + travel("the Hollows", Tile(3498, 3380)) { + if (questStage("in_search_of_the_myreque") == myrequeStage("gave_planks")) { + set("in_search_of_the_myreque", "reached_hollows") + } + } } - private suspend fun Player.travel(name: String, tile: Tile) { + private suspend fun Player.travel(name: String, tile: Tile, onArrival: Player.() -> Unit = {}) { open("fade_out") delay(1) closeTabs(Tab.Options, Tab.MusicPlayer) jingle("morytania_boatride") open("swamp_boat_journey") + open("total_blackness") + sendScript("text_colour_swapper", InterfaceDefinition.pack(333, 0), 0x433621) minimap(Minimap.HideMap) interfaces.sendAnimation("swamp_boat_journey", "boat", "boat_journey_${name.replace("'", "_").toSnakeCase()}") statement("You board the boat and journey to $name.", clickToContinue = false) delay(14) tele(tile) + onArrival() delay(1) clearMinimap() + close("total_blackness") openTabs(Tab.Options, Tab.MusicPlayer) open("fade_in") statement("You arrive in $name.") diff --git a/game/src/main/kotlin/content/area/morytania/mort_myre_swamp/VeliafHurtz.kt b/game/src/main/kotlin/content/area/morytania/mort_myre_swamp/VeliafHurtz.kt new file mode 100644 index 0000000000..5cc8c7fcf3 --- /dev/null +++ b/game/src/main/kotlin/content/area/morytania/mort_myre_swamp/VeliafHurtz.kt @@ -0,0 +1,447 @@ +package content.area.morytania.mort_myre_swamp + +import content.entity.combat.hit.damage +import content.entity.effect.transform +import content.entity.player.dialogue.Angry +import content.entity.player.dialogue.Evil +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.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.proj.shoot +import content.quest.member.myreque.checkMembers +import content.quest.member.myreque.hasAllWeapons +import content.quest.member.myreque.myrequeStage +import content.quest.member.myreque.spawnHellHound +import content.quest.questStage +import content.quest.setInstanceLogout +import content.quest.startCutscene +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.client.clearCamera +import world.gregs.voidps.engine.client.message +import world.gregs.voidps.engine.client.moveCamera +import world.gregs.voidps.engine.client.turnCamera +import world.gregs.voidps.engine.client.ui.open +import world.gregs.voidps.engine.entity.character.mode.PauseMode +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.sound +import world.gregs.voidps.engine.get +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.engine.inv.remove +import world.gregs.voidps.type.Direction +import world.gregs.voidps.type.Tile + +class VeliafHurtz : Script { + init { + npcOperate("Talk-to", "veliaf_hurtz_meiyerditch_tunnels") { + when (questStage("in_search_of_the_myreque")) { + myrequeStage("delivered_weapons") -> playMyrequeCutscene() + myrequeStage("entered_hideout") -> firstMeeting() + myrequeStage("met_veliaf") -> { + if (!checkMembers()) { + npc("Have you introduced yourself to the rest of the gang?") + introducedYourselfChoice() + } else { + npc( + "Hello again...so, you've introduced yourself to the team have you? Good. " + + "Now, let's have a look at those weapons you brought us.", + ) + if (hasAllWeapons()) { + acceptWeapons() + } else { + wrongWeapons() + } + } + } + myrequeStage("weapons_accepted") -> { + if (hasAllWeapons()) { + deliverWeaponsAndStartCutscene() + } else { + wrongWeapons() + } + } + myrequeStage("hellhound_summoned") -> { + message("Veliaf can't talk right now, he seems to be formulating a plan.") + } + else -> postCutsceneIntro() + } + } + } + + private suspend fun Player.firstMeeting() { + player("Hello there...") + npc("Hello there...how did you get in here? Who are you?") + player( + "I was asked to bring you some weapons...apparently you need them? Don't worry, " + + "I'm a friend.", + ) + set("in_search_of_the_myreque", "met_veliaf") + npc( + "Hmm, well, it's true we could do with some supplies. It's very good of you to bring " + + "them to us. I'm sorry but I'm a bit busy at the moment, please introduce yourself " + + "to the others, then we can talk about those weapons.", + ) + firstMeetingChoice() + } + + suspend fun Player.firstMeetingChoice() { + choice { + areYouLeader() + tellMeAboutOrganization() + tellMeAboutMorytania() + futurePlans() + option("Ok, thanks.") + } + } + + fun ChoiceOption.areYouLeader(): Unit = option("So are you the leader?") { + npc( + "Well, as much as anyone is, it's not official or anything, I guess the others just " + + "look up to me or something. I feel a certain responsibility for them so I guess " + + "that means I'm a leader of sorts.", + ) + firstMeetingChoice() + } + + fun ChoiceOption.tellMeAboutOrganization(): Unit = option("Can you tell me about your organization?") { + npc( + "Huh, organization? That's rich! We're really not that organized, however, the " + + "Myreque are dedicated to mounting a resistance against the Drakans and the evil " + + "that they've brough to Morytania.", + ) + firstMeetingChoice() + } + + fun ChoiceOption.tellMeAboutMorytania(): Unit = option("What can you tell me about Morytania?") { + npc( + "I can tell you that it used to be a beautiful country before Lowerniel and his " + + "quarelling siblings arrived.", + ) + player("Lowerniel? Who's that?") + npc( + "Lowerniel Vergidiyad Drakan - he's the head of the Drakan family and along with his " + + "brother Ranis and his sister Vanescula they play out their petty backstabbing " + + "games using the peoples of Morytania as their pawns.", + ) + player("You sound very bitter.") + npc( + "So would you if you lost everyone dear to you at the hands of such monsters.", + ) + firstMeetingChoice() + } + + fun ChoiceOption.futurePlans(): Unit = option("What are your plans for the future?") { + npc( + "At the moment we're maintaining a low profile after we were spotted scouting out " + + "the Drakans' castle. The forest where we camped is infested with vampire, and " + + "even though they're only juveniles in terms of experience, we only just managed " + + "to survive.", + ) + npc( + "After we've regrouped, we will try to recruit more people to our cause, at the " + + "minute we're hopelessly outnumberd.", + ) + firstMeetingChoice() + } + + suspend fun Player.introducedYourselfChoice() { + choice { + askQuestionsFirst() + goIntroduceNow() + whoElseToMeet() + } + } + + fun ChoiceOption.askQuestionsFirst(): Unit = option("I wanted to ask you some questions first.") { + npc("Well, ok, but only briefly, I have things to do.") + firstMeetingChoice() + } + + fun ChoiceOption.goIntroduceNow(): Unit = option("Not yet, I'll go do that now.") { + npc("Ok, great.") + } + + fun ChoiceOption.whoElseToMeet(): Unit = option("Who else do I have to introduce myself to?") { + val sani = if (get("met_sani", false)) "Sani" else "Sani" + val harold = if (get("met_harold", false)) "Harold" else "Harold" + val radigad = if (get("met_radigad", false)) "Radigad" else "Radigad" + val polmafi = if (get("met_polmafi", false)) "Polmafi" else "Polmafi" + val ivan = if (get("met_ivan", false)) "Ivan" else "Ivan" + statement("$sani
$harold
$radigad
$polmafi
$ivan") + } + + private suspend fun Player.wrongWeapons() { + npc( + "Well, those weapons aren't that useful. We really need specific ones, we have our " + + "own specialism.", + ) + weaponChoice() + } + + suspend fun Player.weaponChoice() { + choice { + goGetWeapons() + whichWeapons() + } + } + + fun ChoiceOption.goGetWeapons(): Unit = option("Ok, I'll go get them.") { + npc("Ok, I'd appreciate that.") + } + + fun ChoiceOption.whichWeapons(): Unit = option("Which weapons do you need?") { + npc("Here, I've compiled a list.") + showWeaponList() + } + + private suspend fun Player.acceptWeapons() { + set("in_search_of_the_myreque", "weapons_accepted") + npc("These weapons look great! Many thanks.") + deliverWeaponsAndStartCutscene() + } + + private suspend fun Player.deliverWeaponsAndStartCutscene() { + inventory.remove("steel_longsword", 1) + inventory.remove("steel_dagger", 1) + inventory.remove("steel_mace", 1) + inventory.remove("steel_warhammer", 1) + inventory.remove("steel_sword", 2) + set("thsfm_vanstrom_hide", true) + set("in_search_of_the_myreque", "delivered_weapons") + playMyrequeCutscene() + } + + private suspend fun Player.showWeaponList() { + val longsword = weapon(inventory.contains("steel_longsword"), "1 Steel ", "Longsword") + val shortSword = weapon(inventory.contains("steel_sword", 2), "2 Steel ", "Short Swords") + val dagger = weapon(inventory.contains("steel_dagger"), "1 Steel ", "Dagger") + val mace = weapon(inventory.contains("steel_mace"), "1 Steel ", "Mace") + val warhammer = weapon(inventory.contains("steel_warhammer"), "1 Steel ", "Warhammer") + statement("$longsword $shortSword $dagger $mace $warhammer") + } + + private fun weapon(has: Boolean, prefix: String, name: String): String = if (has) { + "$prefix$name" + } else { + "$prefix$name" + } + + private suspend fun Player.playMyrequeCutscene() { + open("fade_out") + val cutscene = startCutscene("myreque_ambush", AMBUSH_BASE, width = 6, height = 3) + setInstanceLogout(VELIAF_SPOT) + cutscene.onEnd { + clearCamera() + } + delay(4) + + val veliaf = NPCs.add("veliaf_hurtz_meiyerditch_tunnels", cutscene.convert(VELIAF_SPOT), Direction.SOUTH) + val harold = NPCs.add("harold_evans_meiyerditch_tunnels", cutscene.convert(HAROLD_SPOT), Direction.SOUTH) + val sani = NPCs.add("sani_piliu_meiyerditch_tunnels", cutscene.convert(SANI_SPOT), Direction.SOUTH) + val radigad = NPCs.add("radigad_ponfit", cutscene.convert(RADIGAD_SPOT), Direction.EAST) + for (member in listOf(veliaf, harold, sani, radigad)) { + member.mode = PauseMode + } + tele(cutscene.convert(PLAYER_SPOT), clearInterfaces = false) + delay(1) + veliaf.face(cutscene.tile(3506, 9837, 2)) + harold.face(cutscene.tile(3504, 9836, 2)) + sani.face(cutscene.tile(3508, 9836, 2)) + radigad.face(cutscene.tile(3511, 9831, 2)) + + moveCamera(cutscene.tile(3506, 9846, 2), 750) + turnCamera(cutscene.tile(3506, 9834, 2), 125) + open("fade_in") + delay(3) + face(cutscene.convert(VANSTROM_SPOT)) + moveCamera(cutscene.tile(3508, 9836, 2), 325) + turnCamera(cutscene.tile(3502, 9842, 2), 125) + npc( + "veliaf_hurtz_meiyerditch_tunnels", + """ + Hey, what's that mist coming through the door! + WE'VE A VAMPIRE IN THE ROOM! + """, + ) + + moveCamera(cutscene.tile(3506, 9846, 2), 750) + turnCamera(cutscene.tile(3506, 9834, 2), 125) + message("You see a shifting mist enter the room!") + sound("vanstrom_mist") + val mist = NPCs.add("route_vanstrom_vampire_misty", cutscene.tile(3504, 9832, 2), Direction.SOUTH) + delay(1) + mist.walkTo(cutscene.tile(3505, 9835, 2)) + delay(3) + NPCs.remove(mist) + sound("vanstrom_appear") + + val vanstrom = NPCs.add("route_vanstrom", cutscene.convert(VANSTROM_SPOT), Direction.SOUTH) + vanstrom.mode = PauseMode + vanstrom.face(cutscene.convert(PLAYER_SPOT)) + face(cutscene.convert(VANSTROM_SPOT)) + vanstrom.say("Ha...ha...ha... you took me straight to them!") + message("Vanstrom Klause: Ha...ha...ha...you took me straight to them!") + npc("stranger_2", "Ha...ha...ha... you took me straight to them!") + vanstrom.say("The little dears are going to wonder which side you're on!") + message("Vanstrom Klause: The little dears are going to wonder which side you're on!") + npc("stranger_2", "The little dears are going to wonder which side you're on!") + + moveCamera(cutscene.tile(3511, 9839, 2), 325) + turnCamera(cutscene.tile(3506, 9837, 2), 125) + face(cutscene.convert(SANI_SPOT)) + vanstrom.face(cutscene.convert(SANI_SPOT)) + sani.face(cutscene.convert(VANSTROM_SPOT)) + sani.say("It's Vanstrom...we're dead...") + message("Sani Piliu: It's Vanstrom...we're dead...") + npc("sani_piliu_meiyerditch_tunnels", "It's Vanstrom...we're dead...") + + face(cutscene.convert(VANSTROM_SPOT)) + vanstrom.strike(sani, cutscene.convert(VANSTROM_SPOT)) + sani.say("Ahhhhhh!") + vanstrom.say("Quite right...") + message("Vanstrom Klause: Quite right....") + npc("stranger_2", "Quite right...") + + turnCamera(cutscene.tile(3506, 9838, 2), 125, speed = 3, acceleration = 3) + delay(1) + turnCamera(cutscene.tile(3506, 9837, 2), 125, speed = 3, acceleration = 3) + vanstrom.face(cutscene.convert(HAROLD_SPOT)) + vanstrom.strike(harold, cutscene.convert(VANSTROM_SPOT).add(-1, 0)) + harold.say("Auugh!") + vanstrom.anim("vanstrom_transform") + vanstrom.transform("route_vanstrom_vampire") + vanstrom.say("Sorry, Harold, you too!") + message("Vanstrom Klause: Sorry Harold, you too!") + npc("stranger_2", "Sorry, Harold, you too!") + + delay(2) + vanstrom.transform("route_vanstrom_vampire_flying") + vanstrom.anim("vanstrom_hover") + sound("vanstrom_vampire") + vanstrom.say("And now I'm going to finish the rest of them off.") + message("Vanstrom Klause: And... now I'm going to finish the rest of them off...") + npc("route_vanstrom_vampire", "And now I'm going to finish the rest of them off.") + + vanstrom.face(cutscene.convert(PLAYER_SPOT)) + vanstrom.say("With my little pet!") + message("Vanstrom Klause: With my little pet!") + npc("route_vanstrom_vampire", "With my little pet!") + vanstrom.transform("route_vanstrom_vampire_misty") + vanstrom.say("Ha ha ha ha haaaaaa!") + sound("vanstrom_mist") + delay(2) + vanstrom.walkOverDelay(cutscene.tile(3504, 9832, 2)) + NPCs.remove(vanstrom) + message("Vanstrom Klause: Ha ha ha ha haaaaaa!") + npc("route_vanstrom_vampire", "Ha ha ha ha haaaaaa!") + + open("fade_out") + delay(2) + set("in_search_of_the_myreque", "hellhound_summoned") + tele(HOUND_SPOT) + cutscene.end() + spawnHellHound(this) + } + + private fun NPC.strike(target: NPC, from: Tile) { + anim("thrown_accurate") + gfx("iron_knife_throw") + from.shoot("iron_knife", target) + target.anim("myreque_knockout") + target["absorb_damage"] = 0 + target.damage(300, source = this) + } + + private suspend fun Player.postCutsceneIntro() { + npc( + "That murderer Vanstrom has killed Sani and Harold! He came in and killed them right " + + "in front of our eyes. And that beast he summoned probably would have killed the " + + "rest of us if it wasn't for you!", + ) + player( + "It was the least I could do! After all, I was the one he followed here, I'm sort of " + + "responsible.", + ) + npc( + "That is for your conscience to bear, but now perhaps you can understand why we " + + "fight for our freedom?", + ) + player("Yes, I think I understand now.") + aftermathChoice() + } + + suspend fun Player.aftermathChoice() { + choice { + joinOrganisation() + whatNext() + howToGetOut() + getRevenge() + option("Ok, thanks.") + } + } + + fun ChoiceOption.joinOrganisation(): Unit = option("I want to join your organisation.") { + npc( + "Hmm, well thanks for your support. But I'll need to talk to my superior first.", + ) + aftermathChoice() + } + + fun ChoiceOption.whatNext(): Unit = option("What do you plan to do now?") { + npc( + "Well, I guess Vanstrom will expect that we're dead. He's so arrogant that he doesn't " + + "really consider us a threat. I can understand that, if I was that powerful I " + + "would probably feel the same.", + ) + npc( + "I guess we'll regroup and then try to recruit more people. We first need to know " + + "how we can fight against these monsters, then we'll take the war to them!", + ) + aftermathChoice() + } + + fun ChoiceOption.howToGetOut(): Unit = option("How do I get out of here?") { + npc( + "If you go back into the main corridor which leads into this room, it goes down " + + "towards the basement of an Inn.", + ) + if (questStage("in_search_of_the_myreque") == myrequeStage("killed_hellhound")) { + set("in_search_of_the_myreque", "shown_way_out") + } + npc( + "If you search the wall, you'll find that it is in fact moveable. You should be able " + + "to get into the room, take the ladder to come out to the south of the " + + "'Hair of the Dog Tavern' in Canifis.", + ) + aftermathChoice() + } + + fun ChoiceOption.getRevenge(): Unit = option("Let's get revenge on Vanstrom!") { + npc( + "Yes, of course, we'll certainly get our revenge on him and his superiors! Then " + + "they'll taste the bitter sorrow that we've had to endure so long!", + ) + aftermathChoice() + } + + private companion object { + private val AMBUSH_BASE = Tile(3496, 9824) + + private val PLAYER_SPOT = Tile(3507, 9838, 2) + private val VELIAF_SPOT = Tile(3506, 9838, 2) + private val SANI_SPOT = Tile(3508, 9837, 2) + private val HAROLD_SPOT = Tile(3504, 9837, 2) + private val RADIGAD_SPOT = Tile(3510, 9831, 2) + private val VANSTROM_SPOT = Tile(3506, 9836, 2) + + private val HOUND_SPOT = Tile(3507, 9838, 0) + } +} diff --git a/game/src/main/kotlin/content/area/morytania/mort_ton/CyregPaddlehorn.kt b/game/src/main/kotlin/content/area/morytania/mort_ton/CyregPaddlehorn.kt index 6c30c1ee36..35c9659baf 100644 --- a/game/src/main/kotlin/content/area/morytania/mort_ton/CyregPaddlehorn.kt +++ b/game/src/main/kotlin/content/area/morytania/mort_ton/CyregPaddlehorn.kt @@ -1,22 +1,404 @@ package content.area.morytania.mort_ton +import content.entity.player.dialogue.Angry +import content.entity.player.dialogue.Confused +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.Scared +import content.entity.player.dialogue.type.ChoiceOption +import content.entity.player.dialogue.type.choice +import content.entity.player.dialogue.type.item import content.entity.player.dialogue.type.npc import content.entity.player.dialogue.type.player +import content.quest.member.myreque.hasAllWeapons +import content.quest.member.myreque.myrequeStage import content.quest.questCompleted +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 CyregPaddlehorn : Script { init { npcOperate("Talk-to", "cyreg_paddlehorn") { - if (questCompleted("in_search_of_the_myreque")) { - player("Thanks for your help in finding the Myreque.") - npc("Well, I'd like to say that you're welcome. But I heard that you led Vanstrom straight to their hideout and he killed Sani and Harold. I feel so guilty.") - player("Hmm, yeah, I know how you feel.") - } else { - npc("Hello there friend. I'm afraid all boat rides are out of service due to the high ghast activity in Mort Myre.") + when { + questCompleted("in_search_of_the_myreque") -> { + player("Thanks for your help in finding the Myreque.") + npc( + "Well, I'd like to say that you're welcome. But I heard that you led Vanstrom " + + "straight to their hideout and he killed Sani and Harold. I feel so guilty.", + ) + player("Hmm, yeah, I know how you feel.") + generalQuestionsChoice() + } + questStage("in_search_of_the_myreque") <= myrequeStage("persuaded_boatman") -> earlyCyregOptions() + else -> { + player("Can I ask some more questions?") + npc("Sure you can.") + generalQuestionsChoice() + } } } } } + +suspend fun Player.earlyCyregOptions() { + when (questStage("in_search_of_the_myreque")) { + myrequeStage("unstarted") -> npc( + "Sorry my friend but the boat rides are out of service due to the high ghast " + + "activity in Mort Myre.", + ) + myrequeStage("agreed_to_help") -> { + npc("Hello there friend.") + startWeaponDelivery() + } + myrequeStage("refused_delivery") -> askToFindMyreque() + myrequeStage("persuaded_boatman") -> askForBoatRide() + } +} + +private suspend fun Player.startWeaponDelivery() { + player("Hello there, I have some weapons for you to give to the 'Myreque'.") + if (!hasAllWeapons()) { + npc("Hmm, well, it doesn't look as if you've got the right sort of weapons to me!") + return + } + npc("Hmm, I'm sure I don't know what you're talking about.") + player("Come on, I know you're in cahoots with them, just take these weapons to them.") + npc( + "Ok, seriously, I did some work for them before, but now it's just too dangerous. " + + "I won't take the weapons to them, I'm sorry, it's just too dangerous.", + ) + + if (questStage("in_search_of_the_myreque") == myrequeStage("agreed_to_help")) { + set("in_search_of_the_myreque", "refused_delivery") + } + + askToFindMyreque() +} + +private suspend fun Player.askToFindMyreque() { + player("Can you tell me how to find the Myreque?") + npc( + "Their base is well hidden and I'm sorry but I can't reveal the directions. " + + "Sorry but I guess you're all out of luck.", + ) + persuasionChoice() +} + +private suspend fun Player.persuasionChoice() { + choice { + comeOnTellMe() + guessTheyllDie() + offerCash() + justWantToHelp() + option("Ok, thanks.") + } +} + +private fun ChoiceOption.comeOnTellMe(): Unit = option("Oh come on, you can tell me!") { + npc("I'm sorry, I can't I just can't... people are watching... ...eyes everywhere!") + persuasionChoice() +} + +private fun ChoiceOption.offerCash(): Unit = option("I'll give you some cash if you tell me.") { + npc("You think you can buy me!") + player("Er, no, I just want to compensate you for your troubles.") + npc("You keep your money and I'll keep my secrets.") + persuasionChoice() +} + +private fun ChoiceOption.justWantToHelp(): Unit = option("I just want to help them, I think they need help.") { + npc( + "Aye, well, they may do...but it's just not safe and it's not likely to get safer any " + + "time soon. Though I do feel sorry for Ivan, the baby of the group. He's seen too " + + "few winters to be involved in such toil.", + ) + persuasionChoice() +} + +private fun ChoiceOption.guessTheyllDie(): Unit = option("Well. I guess they'll just die without weapons.") { + npc( + "Hmm, you don't seem too concerned about their welfare... I'm glad I didn't tell you " + + "where they were...in any case they're resourceful, they can look after themselves.", + ) + player("What's that supposed to mean?") + npc( + "They're resourceful folks, that's all I'm saying. Their leader, Veliaf, looks after " + + "them well.", + ) + resourcefulChoice() +} + +private suspend fun Player.resourcefulChoice() { + choice { + comeOnTellMe() + resourcefulEnough() + offerCash() + justWantToHelp() + option("Ok, thanks.") + } +} + +private fun ChoiceOption.resourcefulEnough(): Unit = option("Resourceful enough to get their own steel weapons?") { + npc( + "Maybe they are...what do you care anyway? They've been up against it ever since they " + + "got started. All of 'em have suffered more loss and heartache than you'll ever know. " + + "Now, leave me be!", + ) + upAgainstChoice() +} + +private suspend fun Player.upAgainstChoice() { + choice { + whatHaveTheyBeenUpAgainst() + whatLossAndHeartache() + deathsOnYourHead() + justWantToHelp() + option("Ok, thanks.") + } +} + +private fun ChoiceOption.whatHaveTheyBeenUpAgainst(): Unit = option("What have they been up against?") { + npc( + "You're not from around here or you wouldn't be asking such foolish questions. " + + "Morytania is ruled by a cruel dark overlord by the name of Drakan. His reign over " + + "Morytania means we all live in fear.", + ) + upAgainstChoice() +} + +private fun ChoiceOption.whatLossAndHeartache(): Unit = option("What kind of loss and heartache?") { + npc( + "The worst kind, most have lost members of their family. Little Sani Piliu, she was " + + "orphaned overnight when a vampire went on the rampage... imagine that, losing your " + + "entire family in one night? Terrible!", + ) + player("It sounds awful... Who is Sani Pillu?") + npc( + "She's the only female member of the Myreque. She's already proven herself with her " + + "agility and light fingers, if you know what I mean!", + ) + upAgainstChoice() +} + +private fun ChoiceOption.deathsOnYourHead(): Unit = option("If you don't tell me, their deaths are on your head!") { + npc( + "There's death a plenty in this forsaken place...what do I care that some fool hardy " + + "vigilantes decided to go it alone against the drakans? Stupidity of youth is to " + + "blame, I shan't carry it on my shoulders.", + ) + forsakenChoice() +} + +private suspend fun Player.forsakenChoice() { + choice { + vigilanteFreedomFighter() + whoAreDrakans() + whatKindOfMan() + whyForsaken() + option("Ok, thanks.") + } +} + +private fun ChoiceOption.vigilanteFreedomFighter(): Unit = option( + "One man's vigilante is another man's freedom fighter!", +) { + npc( + "Aye, you can see it from both sides I suppose. But many of us consider it fool hardy " + + "to fight for something we'll never get, even Polmafi, a scholar, such as he was, " + + "agrees that the chances are slim.", + ) + player("Polmafi? Who's he?") + npc( + "Polmafi Ferdygris is one of the Myreque, he's a technical sort and advises on all " + + "sorts of things to Veliaf. He was a scholar before he became a renegade.", + ) + forsakenChoice() +} + +private fun ChoiceOption.whoAreDrakans(): Unit = option("Who are the Drakans?") { + npc( + "The Drakans are the family of overlords that rule Morytania. They're the ones to " + + "whom the blood tithes are paid. Too much I have told you already!", + ) + npc( + "Ignorance is better than these truths I tell you! I can pretend once more that I am " + + "a free man and some relief from this gloom can I feel again. Be gone with you now " + + "and leave me with my dreams.", + ) + forsakenChoice() +} + +private fun ChoiceOption.whyForsaken(): Unit = option("Why do you say that this place is 'forsaken'?") { + npc( + "All of these lands are forsaken of Saradomin's kindness, only cold death from the " + + "evil gods do we now feels. Those lucky ones to the west of the Salve little realise " + + "their fate if the river should one day become tained.", + ) + forsakenChoice() +} + +private fun ChoiceOption.whatKindOfMan(): Unit = option( + "What kind of man are you to say that you don't care?", +) { + npc( + "Don't dare to judge me young fool...what do you know of the heartache I carry? " + + "Can you not see the anchor of woe that holds me fast?", + ) + npc("Very well, if you would take your chance to help these strangers, who am I to stop you?") + player("But will you help me? Will you take me to them?") + offerBoat() +} + +private suspend fun Player.askForBoatRide() { + player("Will you still take me to the Myreque?") + offerBoat() +} + +private suspend fun Player.offerBoat() { + npc( + "No, I won't take you but you can use my boat. You'll be going through Mort Myre " + + "though so I won't be letting you go unless you've some defence against the Ghasts.", + ) + if (questStage("in_search_of_the_myreque") == myrequeStage("refused_delivery")) { + set("in_search_of_the_myreque", "persuaded_boatman") + } + if (inventory.contains("druid_pouch_2", 5) && inventory.contains("silver_sickle_b")) { + showDruidPouch() + } else { + player("I don't have anything which I can use against them at this time.") + } +} + +private suspend fun Player.showDruidPouch() { + item("druid_pouch_2", "You show the boatman your druid pouch.") + player( + "I have this druid pouch! This turns the Ghasts visible and I can kill them once I " + + "can see them.", + ) + npc( + "Very well, You can go! But you'll need to bring me some wood planks first, I need " + + "three and you need three.", + ) + if (!inventory.contains("plank", 3)) { + npc( + "The bridge you cross later is rotten and may need to be mended, so bring tools " + + "and steel metal fixers as you may find them useful.", + ) + return + } + npc( + "The bridge you cross later is rotten and may need to be mended, so bring tools " + + "and steel metal fixers as well, you may find them useful. I see that you have " + + "some with you now, do you want to give them to me?", + ) + plankChoice() +} + +private suspend fun Player.plankChoice() { + choice("Cyreg wants wooden planks!") { + givePlanks() + keepPlanks() + } +} + +private fun ChoiceOption.givePlanks(): Unit = option("Give wooden planks to Cyreg.") { + if (!inventory.remove("plank", 3)) { + return@option + } + set("in_search_of_the_myreque", "gave_planks") + item("plank", "The boatman takes 3 wooden planks from you.") + npc( + "Very well, you can take the boat. Just jump in when you're ready to leave. When you " + + "get to the hollows, just keep going North and look for an unusual tree.", + ) +} + +private fun ChoiceOption.keepPlanks(): Unit = option("Don't give any wooden planks to Cyreg.") { + player("Not just yet sorry.") +} + +private suspend fun Player.generalQuestionsChoice() { + choice { + whereAfterHollows() + tellMeAboutMyreque() + tellMeAboutDrakans() + tellMeAboutYourself() + option("Ok, thanks.") + } +} + +private fun ChoiceOption.whereAfterHollows(): Unit = option("Where do I go after I get to the hollows?") { + npc( + "You should head north and look for an unusual tree. You look like you know a thing " + + "or two about exploring so I guess I don't need to tell you to keep your eyes peeled!", + ) + generalQuestionsChoice() +} + +private fun ChoiceOption.tellMeAboutMyreque(): Unit = option("Tell me about the 'Myreque.'") { + npc("What do you want to know?") + myrequeQuestionsChoice() +} + +private fun ChoiceOption.tellMeAboutDrakans(): Unit = option("Tell me about the Drakans.") { + npc( + "Well it's rumoured that they're controlling the whole of Morytania. They live in " + + "the Sanguinesti reigon, bu I've never been, like most villagers in Morytania, " + + "I'm afraid to leave the village and dread what lies beyond.", + ) + generalQuestionsChoice() +} + +private fun ChoiceOption.tellMeAboutYourself(): Unit = option("Tell me about yourself.") { + npc( + "I'm just a humble boatman, Cyreg Paddlehorn is my name, like most of the Paddlehorns " + + "before me, I make my living by tackling the swamps of Mort Myre.", + ) + generalQuestionsChoice() +} + +private suspend fun Player.myrequeQuestionsChoice() { + choice { + whyCalledMyreque() + tellMeAboutMembers() + askDifferentQuestion() + } +} + +private fun ChoiceOption.whyCalledMyreque(): Unit = option("Why are they called, 'The Myreque'?") { + npc( + "The locals just called them after the place where they hide out, in Mort Myre. " + + "They are Myreque, 'hidden in the myre'.", + ) + myrequeQuestionsChoice() +} + +private fun ChoiceOption.tellMeAboutMembers(): Unit = option("Tell me about the members of the 'Myreque'.") { + npc( + "Well, you have Radigad Ponfit...he's your average mercenary from Asgarnia...ready to " + + "slice anyone's head off for a price. He's got a personal score to settle with the " + + "Drakans, though no one knows what it is.", + ) + npc( + "Then there's Veliaf, he's the leader. And then there's Ivan, he's the baby of the " + + "group. There's Sani Piliu she's a lovely girl, though a bit shady if you know what " + + "I mean.", + ) + npc( + "You've also got Harold Evans, he's a bit hot headed, always straight into the fray. " + + "And the brains of the operation reporting directly to Veliaf is Polmafi Ferdygris, " + + "he used to be quite a clever scholar!", + ) + myrequeQuestionsChoice() +} + +private fun ChoiceOption.askDifferentQuestion() { + option("~~~ Ask a different question. ~~~") { + generalQuestionsChoice() + } +} diff --git a/game/src/main/kotlin/content/quest/Cutscene.kt b/game/src/main/kotlin/content/quest/Cutscene.kt index b65b09a356..8f997e3a27 100644 --- a/game/src/main/kotlin/content/quest/Cutscene.kt +++ b/game/src/main/kotlin/content/quest/Cutscene.kt @@ -202,4 +202,25 @@ fun Player.closeTabs(vararg others: Tab) { } fun Player.startCutscene(name: String, region: Region = Region.EMPTY): Cutscene = Cutscene(this, name, region) + +/** + * Starts a cutscene in a private copy of a [width] by [height] block of chunks, the south west + * corner of which is the chunk [base] sits in. For scenes that only need a room or two rather than + * a whole region. + */ +fun Player.startCutscene(name: String, base: Tile, width: Int, height: Int, levels: Int = 4): Cutscene { + val instance = smallInstance() + return Cutscene(this, name, instance, copyChunks(instance, base, width, height, levels)) +} + +/** + * Copies a [width] by [height] block of chunks, starting at the chunk [base] sits in, into + * [instance], and returns the offset from the original chunks to the copy. + */ +fun Player.copyChunks(instance: Region, base: Tile, width: Int, height: Int, levels: Int = 4): Delta { + val offset = instance.tile.delta(base.zone.tile) + get().copy(base.zone, instance.tile.zone, width, height, levels) + set("instance_offset", offset.id) + return offset +} fun Player.startCutscene(name: String, region: Region, offset: Delta): Cutscene = Cutscene(this, name, region, offset) diff --git a/game/src/main/kotlin/content/quest/Quest.kt b/game/src/main/kotlin/content/quest/Quest.kt index 0e59e59fb0..0ca5c57f85 100644 --- a/game/src/main/kotlin/content/quest/Quest.kt +++ b/game/src/main/kotlin/content/quest/Quest.kt @@ -23,6 +23,7 @@ val quests = setOf( // members "creature_of_fenkenstrain", "druidic_ritual", + "in_search_of_the_myreque", "jungle_potion", "nature_spirit", "plague_city", @@ -44,6 +45,11 @@ fun Player.questStage(name: String): Int { return def.values.toInt(value) } +fun questStage(name: String, stage: String): Int { + val def = VariableDefinitions.get(name) ?: return 0 + return def.values.toInt(stage) +} + fun Player.questCompleted(name: String): Boolean { if (!quests.contains(name) && Settings["quests.requirements.skipMissing", false]) { return true diff --git a/game/src/main/kotlin/content/quest/QuestCommands.kt b/game/src/main/kotlin/content/quest/QuestCommands.kt index c7566c438f..0de19689fa 100644 --- a/game/src/main/kotlin/content/quest/QuestCommands.kt +++ b/game/src/main/kotlin/content/quest/QuestCommands.kt @@ -19,7 +19,8 @@ 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. + * them; supply-your-own items are read from the optional `req_item_ids` key, where an item + * needed more than once is written as "id:amount". */ class QuestCommands : Script { @@ -42,8 +43,9 @@ class QuestCommands : Script { /** * 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. + * and swab flags are all `rum_deal_*` - plus anything the quest lists under `reset_vars`, for + * the flags it keeps under the client's own varbit names. 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 @@ -56,8 +58,13 @@ class QuestCommands : Script { player.clear(questId) var cleared = 0 - for (variable in VariableDefinitions.definitions.keys) { - if (variable.startsWith("${questId}_") && player.variables.contains(variable)) { + val named = VariableDefinitions.definitions.keys.filter { it.startsWith("${questId}_") } + for (variable in named + quest["reset_vars", emptyList()]) { + if (VariableDefinitions.get(variable) == null) { + player.message("Unknown variable '$variable' in ${quest.stringId} reset list.") + continue + } + if (player.variables.contains(variable)) { player.clear(variable) cleared++ } @@ -94,13 +101,16 @@ class QuestCommands : Script { 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.") + for (entry in items) { + // Items a quest needs more than one of are written as "id:amount" + val id = entry.substringBefore(':') + val amount = entry.substringAfter(':', "1").toIntOrNull() ?: 1 + if (ItemDefinitions.getOrNull(id) == null) { + player.message("Unknown item '$id' in ${quest.stringId} requirements.") continue } - if (!player.inventory.contains(item)) { - player.addOrDrop(item) + if (!player.inventory.contains(id, amount)) { + player.addOrDrop(id, amount) given++ } } diff --git a/game/src/main/kotlin/content/quest/member/myreque/SearchMyreque.kt b/game/src/main/kotlin/content/quest/member/myreque/SearchMyreque.kt new file mode 100644 index 0000000000..7eae1b4ffb --- /dev/null +++ b/game/src/main/kotlin/content/quest/member/myreque/SearchMyreque.kt @@ -0,0 +1,450 @@ +package content.quest.member.myreque + +import content.entity.combat.killer +import content.entity.gfx.areaGfx +import content.entity.npc.markHint +import content.entity.obj.door.enterDoor +import content.entity.player.dialogue.type.statement +import content.quest.quest +import content.quest.questCompleted +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.message +import world.gregs.voidps.engine.data.definition.Areas +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.Players +import world.gregs.voidps.engine.entity.character.sound +import world.gregs.voidps.engine.entity.obj.GameObject +import world.gregs.voidps.engine.entity.obj.remove +import world.gregs.voidps.engine.entity.obj.replace +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.engine.inv.remove +import world.gregs.voidps.engine.queue.strongQueue +import world.gregs.voidps.engine.timer.Timer +import world.gregs.voidps.type.Tile +import world.gregs.voidps.type.area.Cuboid + +class SearchMyreque : Script { + + init { + questJournalOpen("in_search_of_the_myreque") { + val lines = when (quest("in_search_of_the_myreque")) { + "unstarted" -> notStartedJournal() + "completed" -> completedJournal() + else -> startedJournal() + } + questJournal("In Search of the Myreque", lines) + } + + objectApproach("Climb", "swamp_bridge_tree") { (target) -> + approachRange(1) + if (!tile.within(target.tile, 1)) { + return@objectApproach message("You can't reach that.") + } + climbTree(target) + } + + objectApproach("Walk-here", "swamp_bridge1") { (target) -> + if (tile == target.tile) { + return@objectApproach + } + if (!tile.within(target.tile, 1)) { + return@objectApproach message("You can't reach that.") + } + val givesWay = target.tile in Areas["mort_myre_rope_bridge"] && + target.tile.y != BRIDGE_LANDING && + !get(rung(target.tile.y), false) + if (!givesWay) { + walkTo(target.tile, noCollision = true) + return@objectApproach + } + target.replace("spooky_tree_base_forbridge", ticks = 200) + statement("This bridge looks pretty old. The wooden boards break apart beneath your feet.") + } + + objectApproach("Repair", "swamp_bridge1") { + message("This bridge rung already looks fixed, that's why you're able to stand on it.") + } + + objectApproach("Repair", "spooky_tree_base_forbridge") { (target) -> + if (!tile.within(target.tile, 1)) { + return@objectApproach message("You can't reach that.") + } + if (!inventory.contains("plank") || !inventory.contains("steel_nails", 75) || !inventory.contains("hammer")) { + return@objectApproach statement("You don't have the required materials to fix this part of the bridge.") + } + anim("smith_item") + inventory.remove("plank") + inventory.remove("steel_nails", 75) + delay(2) + target.remove() + set(rung(target.tile.y), true) + if (bridgeRepaired()) { + set("route_bridgecomplete", true) + } + walkTo(target.tile, noCollision = true) + } + + objectOperate("Open", "swamp_wooden_doors_closed") { (target) -> + arriveDelay() + val progress = questStage("in_search_of_the_myreque") + if (progress < myrequeStage("answered_questions")) { + return@objectOperate statement( + "There seems to be a strange combination on the door. It would take ages to work it out.", + ) + } + if (progress == myrequeStage("answered_questions")) { + set("in_search_of_the_myreque", "entered_hideout") + } + anim("human_lockedchest") + sound("locked") + delay(1) + target.replace("swamp_wooden_doors_opened", ticks = 3) + delay(2) + sound("iron_door_open") + tele(3500, 9812) + } + + objectOperate("Enter", "route_cavewalltunnel") { + tele(3491, 9824) + } + + objectOperate("Look", "route_stalagmite_cave_entrace") { + statement( + "It looks like an ordinary stalagmite, however, beyond it you can see that there is a " + + "small cave entrance.", + ) + } + + objectOperate("Squeeze-past", "route_stalagmite_cave_entrace") { + statement("You try to squeeze past this stalagmite into a small cavern entrance.") + anim("squeeze_past_stalagmite") + delay(3) + val progress = questStage("in_search_of_the_myreque") + tele(3505, 9832, if (progress < myrequeStage("hellhound_summoned")) 2 else 0) + if (progress == myrequeStage("hellhound_summoned")) { + delay(3) + spawnHellHound(this) + } + } + + objectOperate("Search", "canifis_fake_wall_closed") { (target) -> + if (questStage("in_search_of_the_myreque") < myrequeStage("shown_way_out")) { + return@objectOperate message("You see nothing interesting with this wall.") + } + message("You search the wall and find the door which Veliaf told you about.") + if (questStage("in_search_of_the_myreque") == myrequeStage("shown_way_out")) { + set("in_search_of_the_myreque", "in_inn_basement") + } + enterDoor(target) + message("You walk through.") + } + + objectOperate("Climb-up", "canifis_tavern_ladder") { + message("You climb the ladder out into the outside.") + if (questStage("in_search_of_the_myreque") == myrequeStage("in_inn_basement")) { + set("in_search_of_the_myreque", "escaped_to_canifis") + } + anim("climb_up") + delay(1) + tele(3495, 3466) + message("You emerge to the south of the 'Hair of the Dog' tavern in Canifis.") + } + + objectOperate("Open", "canifis_tavern_trapdoor") { + if (!questCompleted("in_search_of_the_myreque")) { + return@objectOperate message("This trap door seems locked, you're not really sure where it would lead to.") + } + delay(1) + tele(3477, 9845) + message("You open the trapdoor and find yourself in the inn basement.") + } + + npcDeath("skeleton_hellhound") { + val killer = killer as? Player ?: return@npcDeath + if (killer.questStage("in_search_of_the_myreque") == myrequeStage("hellhound_summoned")) { + killer["in_search_of_the_myreque"] = "killed_hellhound" + } + } + + npcTimerStart("hellhound_leash") { 1 } + + npcTimerTick("hellhound_leash") { + val owner = Players.findByAccount(get("owner", "")) + if (owner == null || !HIDEOUT.contains(owner.tile)) { + despawn() + return@npcTimerTick Timer.CANCEL + } + Timer.CONTINUE + } + } + + private suspend fun Player.climbTree(target: GameObject) { + val north = target.tile.y == NORTH_TREE + if (north && !get(rung(BRIDGE_TOP), false)) { + statement( + "This bridge run looks broken; you would have nowhere to stand. The southern " + + "side of the rope bridge looks much less damaged.", + ) + return + } + message("You climb the tree.") + val up = if (north) tile.y >= target.tile.y else tile.y <= target.tile.y + val landing = when { + north && up -> Tile(target.tile.x, BRIDGE_TOP) + north -> NORTH_BANK + up -> Tile(target.tile.x, BRIDGE_LANDING) + else -> SOUTH_BANK + } + anim(if (up) "climb_up" else "climb_down") + delay(1) + strongQueue("climb_bridge_tree", 1) { + tele(landing) + } + } + + private fun Player.notStartedJournal(): List = listOf( + "I can start this quest by talking to a stranger in the 'Hair", + "of the Dog Inn' located in the town of Canifis in the land of", + "Morytania.", + "", + "I need to complete the following quest:", + if (questCompleted("nature_spirit")) "Nature Spirit" else "Nature Spirit", + "", + "I also need to be able to defeat a level 97 foe.", + ) + + private fun completedJournal(): List = listOf( + "I was set a quest by Vanstrom Klause to deliver weapons", + "to a group called the Myreque. After negotiating with a", + "boatman, fixing a broken rope bridge and answering a", + "guard's questions I eventually found the group called the", + "Myreque.", + "", + "However, it seems that I was tricked by Vanstrom who", + "turned out to be a vampire following me to the Myreque's", + "hideout. He killed Sani and Harold, both young members of", + "the Myreque.", + "", + "During the quest I found a quicker route between Mort'ton", + "and Canifis, and I feel that I better understand the reason", + "for the darkness of Morytania.", + "", + "Perhaps in the future, I can offer help to the Myreque?", + "", + "QUEST COMPLETE!", + ) + + private fun Player.startedJournal(): List { + val progress = questStage("in_search_of_the_myreque") + val list = mutableListOf() + + list += "I talked to a stranger called Vanstrom Klause in the 'Hair" + list += "of the Dog Inn' located in the town of Canifis." + + if (progress < myrequeStage("delivered_weapons")) { + list += "Vanstrom Klause asked me to take some weapons to a" + list += "group called 'The Myreque'." + list += "The weapons I need to collect are:" + list += weaponLine("steel_longsword", 1, "Steel Longsword") + list += weaponLine("steel_sword", 2, "Steel Sword") + list += weaponLine("steel_dagger", 1, "Steel Dagger") + list += weaponLine("steel_mace", 1, "Steel Mace") + list += weaponLine("steel_warhammer", 1, "Steel Warhammer") + } else { + list += "Vanstrom Klause asked me to take some weapons to a" + list += "group called 'The Myreque'." + } + + if ((hasAllWeapons() || progress >= myrequeStage("delivered_weapons")) && progress < myrequeStage("refused_delivery")) { + list += "I have all the weapons Vanstrom asked me to get." + list += "Vanstrom said that the boatman in Mort'ton should be" + list += "able to help." + } + + if (progress in myrequeStage("refused_delivery") until myrequeStage("persuaded_boatman")) { + list += "The boatman won't take me to the Myreque's hideout." + list += "Vanstrom did say that I have to be persuasive." + } + + if (progress >= myrequeStage("persuaded_boatman")) { + list += "The boatman says he won't take me to the Myreque" + list += "Vanstrom did say that I have to be persuasive." + + if (progress == myrequeStage("persuaded_boatman")) { + val hasPouch = inventory.contains("druid_pouch_2", 5) && inventory.contains("silver_sickle_b") + if (hasPouch) { + list += "I've persuaded the boatman to help me find the Myreque." + list += "I have the druid pouch as a defence against the ghasts." + list += "The boatman says I need to collect 6 wooden planks" + list += "before he will let me use his boat." + + if (inventory.contains("plank", 6)) { + list += "I've got six planks, I should give them to the boatman." + } else { + val needed = 6 - inventory.count("plank") + list += "I need to collect $needed more wooden planks" + } + } else { + list += "I've persuaded the boatman to help me find the Myreque." + list += "He won't lend the boat unless I can defend against" + list += "Ghasts." + } + } + } + + if (progress in myrequeStage("gave_planks") until myrequeStage("reached_hollows")) { + list += "I've persuaded the boatman to help me find the Myreque." + list += "The boatman won't take me to the Myreque but I can use" + list += "his boat." + } + + if (progress in myrequeStage("reached_hollows") until myrequeStage("questioned_by_curpile")) { + list += "The boatman loaned me the use of his boat." + list += "I borrowed the boat and travelled to the 'Hollows' in the" + list += "middle of Mort Myre in search of the Myreque." + } + + if (progress in myrequeStage("questioned_by_curpile") until myrequeStage("answered_questions")) { + list += "I borrowed the boat and travelled to the 'Hollows' in the" + list += "middle of Mort Myre in search of the Myreque." + list += "While looking for the hideout, I came across a guard called" + list += "Curpile, he may know where the Myreque are hidden." + list += "A guard named Curpile asked me to answer some" + list += "questions before he'll let me continue my search for the" + list += "Myreque." + } + + if (progress in myrequeStage("answered_questions") until myrequeStage("entered_hideout")) { + list += "A guard named Curpile asked me some questions about the" + list += "Myreque which I answered, he'll unlock the door for me" + list += "now" + list += "I need to find the entrance to the Myreque's hideout." + } + + if (progress in myrequeStage("entered_hideout") until myrequeStage("met_veliaf")) { + list += "I entered an underground area, it might be the Myreque" + list += "hideout. I'd better look around fully to try and find them." + list += "I need to look for the members of the Myreque now." + } + + if (progress in myrequeStage("met_veliaf") until myrequeStage("hellhound_summoned")) { + list += "I entered an underground area, it might be the Myreque" + list += "hideout. I'd better look around fully to try and find them." + list += "I met Veliaf, he seems to be the leader of the Myreque." + list += "Veliaf is busy, he's asked me to introduce myself to the" + list += "other members of the Myreque." + list += "I need to talk to:" + list += if (get("met_sani", false)) "Sani Piliu" else "Sani Piliu" + list += if (get("met_harold", false)) "Harold Evans" else "Harold Evans" + list += if (get("met_radigad", false)) "Radigad Ponfit" else "Radigad Ponfit" + list += if (get("met_polmafi", false)) "Polmafi Ferdygris" else "Polmafi Ferdygris" + list += if (get("met_ivan", false)) "Ivan Strom" else "Ivan Strom" + + if (checkMembers()) { + list += "Veliaf was busy, so I introduced myself to the other" + list += "members of the Myreque." + list += "I've introduced myself to all the members now" + list += "perhaps I should talk to Veliaf again" + } + + if (progress in myrequeStage("weapons_accepted") until myrequeStage("hellhound_summoned")) { + list += "I tried talking to Veliaf but he was still busy." + list += "I should try talking to Veliaf in a few minutes when he's" + list += "less busy." + } + } + + if (progress in myrequeStage("hellhound_summoned") until myrequeStage("killed_hellhound")) { + list += "Veliaf was busy, so I introduced myself to the other" + list += "members of the Myreque." + list += "While talking to Veliaf a mist cloud appeared in the room." + list += "The mist transformed into Vanstrom, he's a vampire!" + list += "Vanstrom killed Sani and Harold then summoned a hell" + list += "hound!" + list += "I have to try and kill the hell hound to save the Myreque." + } + + if (progress in myrequeStage("killed_hellhound") until myrequeStage("shown_way_out")) { + list += "I killed the hell hound and saved the rest of the Myreque." + list += "I was tricked by Vanstrom, he was a vampire after all!" + list += "Maybe someone can show me the way out of here?" + } + + if (progress in myrequeStage("shown_way_out") until myrequeStage("in_inn_basement")) { + list += "I was tricked by Vanstrom, he was a vampire after all!" + list += "Maybe someone can show me the way out of here?" + list += "I talked to Veliaf, he says that I can get into the basement" + list += "of the Inn through a secret wall." + } + + if (progress in myrequeStage("in_inn_basement") until myrequeStage("escaped_to_canifis")) { + list += "I went through the secret wall Veliaf told me about into the" + list += "basement of the inn." + list += "I should try to find a way up to the surface from the" + list += "basement of the Inn." + } + + if (progress >= myrequeStage("escaped_to_canifis")) { + list += "I climbed up the ladder in the Inn basement to find my way" + list += "out." + list += "I climbed out of the basement of the Inn to find myself in" + list += "the town of Canifis where I first met that double crossing" + list += "Vanstrom. I wonder if I can still find him there?" + } + + return list + } + + private fun Player.weaponLine(item: String, amount: Int, displayName: String): String { + val collected = inventory.contains(item, amount) || questStage("in_search_of_the_myreque") >= myrequeStage("refused_delivery") + return if (collected) { + "$amount x $displayName" + } else { + "$amount x $displayName" + } + } + + private companion object { + private const val NORTH_TREE = 3431 + + private const val BRIDGE_LANDING = 3427 + + private const val BRIDGE_TOP = 3430 + + private val NORTH_BANK = Tile(3503, 3431) + private val SOUTH_BANK = Tile(3502, 3425) + } +} + +fun myrequeStage(name: String): Int = questStage("in_search_of_the_myreque", name) + +fun rung(y: Int): String = "bridgerung${y - 3427}" + +fun Player.bridgeRepaired(): Boolean = (3428..3430).all { get(rung(it), false) } + +fun Player.hasAllWeapons(): Boolean = inventory.contains("steel_longsword", "steel_dagger", "steel_mace", "steel_warhammer") && + inventory.contains("steel_sword", 2) + +fun Player.checkMembers(): Boolean = listOf("sani", "harold", "ivan", "radigad", "polmafi").all { get("met_$it", false) } + +fun spawnHellHound(player: Player): NPC { + val hound = NPCs.add("skeleton_hellhound", HELL_HOUND_SPOT, ticks = HELL_HOUND_TICKS, owner = player) + areaGfx("skeleton_hellhound_summon", HELL_HOUND_SPOT) + hound.markHint(player) + hound.softTimers.start("hellhound_leash") + hound.interactPlayer(player, "Attack") + player.message("The skeletal hell hound attacks.") + return hound +} + +private val HELL_HOUND_SPOT = Tile(3505, 9834, 0) + +private const val HELL_HOUND_TICKS = 800 + +private val HIDEOUT = Cuboid(Tile(3503, 9829, 0), width = 12, height = 18, levels = 1) diff --git a/game/src/test/kotlin/content/quest/QuestCommandsTest.kt b/game/src/test/kotlin/content/quest/QuestCommandsTest.kt index 8d9b180e9a..e787b6659e 100644 --- a/game/src/test/kotlin/content/quest/QuestCommandsTest.kt +++ b/game/src/test/kotlin/content/quest/QuestCommandsTest.kt @@ -11,6 +11,7 @@ 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.assertFalse import kotlin.test.assertTrue class QuestCommandsTest : WorldTest() { @@ -38,6 +39,50 @@ class QuestCommandsTest : WorldTest() { assertTrue(admin.inventory.contains("spade")) } + @Test + fun `Questprep hands out stacks of the items a quest needs more than one of`() { + val admin = createPlayer(name = "prep admin myreque") + admin.rights = PlayerRights.Admin + + runTest { Commands.call(admin, "questprep in_search_of_the_myreque") } + tick() + + assertEquals(25, admin.levels.getMax(Skill.Agility)) + assertTrue(admin.questCompleted("nature_spirit")) + assertTrue(admin.inventory.contains("steel_longsword")) + assertTrue(admin.inventory.contains("steel_sword", 2)) + assertTrue(admin.inventory.contains("steel_nails", 225)) + assertTrue(admin.inventory.contains("plank", 6)) + assertTrue(admin.inventory.contains("druid_pouch_2", 5)) + assertTrue(admin.inventory.contains("silver_sickle_b")) + assertTrue(admin.inventory.contains("coins", 10)) + assertTrue(admin.inventory.contains("hammer")) + } + + @Test + fun `Questreset clears the flags a quest keeps under other names`() { + val admin = createPlayer(name = "reset admin myreque") + admin.rights = PlayerRights.Admin + admin["in_search_of_the_myreque"] = "completed" + admin["bridgerung1"] = true + admin["bridgerung3"] = true + admin["route_bridgecomplete"] = true + admin["thsfm_vanstrom_hide"] = true + admin["met_sani"] = true + admin["met_polmafi"] = true + + runTest { Commands.call(admin, "questreset in_search_of_the_myreque") } + tick() + + assertEquals("unstarted", admin["in_search_of_the_myreque", "unstarted"]) + assertFalse(admin["bridgerung1", false]) + assertFalse(admin["bridgerung3", false]) + assertFalse(admin["route_bridgecomplete", false]) + assertFalse(admin["thsfm_vanstrom_hide", false], "Vanstrom sits back down in the tavern") + assertFalse(admin["met_sani", false]) + assertFalse(admin["met_polmafi", false]) + } + @Test fun `Questprep never lowers a skill the player has already trained`() { val admin = createPlayer(name = "prep admin2") diff --git a/game/src/test/kotlin/content/quest/member/myreque/SearchMyrequeTest.kt b/game/src/test/kotlin/content/quest/member/myreque/SearchMyrequeTest.kt new file mode 100644 index 0000000000..e9e878a04f --- /dev/null +++ b/game/src/test/kotlin/content/quest/member/myreque/SearchMyrequeTest.kt @@ -0,0 +1,579 @@ +package content.quest.member.myreque + +import WorldTest +import containsMessage +import content.entity.combat.hit.damage +import dialogueContinue +import dialogueOption +import npcOption +import objectOption +import org.junit.jupiter.api.Test +import skipDialogues +import world.gregs.voidps.engine.client.ui.InterfaceApi +import world.gregs.voidps.engine.client.ui.dialogue +import world.gregs.voidps.engine.data.definition.CombatDefinitions +import world.gregs.voidps.engine.entity.character.move.tele +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.item.floor.FloorItems +import world.gregs.voidps.engine.entity.obj.GameObjects +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 world.gregs.voidps.type.setRandom +import kotlin.random.Random +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * In Search of the Myreque object locations: + * rope bridge rungs (3502, 3427-3430) bridge trees (3502, 3426) and (3502, 3431) + * hillside doors (3508, 3446) stalagmite (3492, 9824) + * secret wall (3480, 9837) inn ladder (3477, 9846) + * tavern trapdoor (3495, 3465) + */ +class SearchMyrequeTest : WorldTest() { + + private fun Player.giveWeapons() { + inventory.add("steel_longsword") + inventory.add("steel_sword", 2) + inventory.add("steel_dagger") + inventory.add("steel_mace") + inventory.add("steel_warhammer") + } + + @Test + fun `Vanstrom talks the player into arming the Myreque`() { + val player = createPlayer(Tile(3503, 3476)) + val vanstrom = createNPC("stranger", Tile(3503, 3477)) + + player.npcOption(vanstrom, "Talk-to") + tick(2) + player.skipDialogues() // greeting through "I'm at a loss of how I can help them." + player.dialogueOption("line2") // Why do they need help? Are they in trouble? + player.skipDialogues() // the weapons he has in mind + player.dialogueOption("line4") // Perhaps I could help you out here. + player.skipDialogues() + player.dialogueOption("line4") // Yes, I'll do it! + player.dialogueContinue() + + assertEquals("agreed_to_help", player["in_search_of_the_myreque", "unstarted"]) + } + + @Test + fun `Cyreg refuses to take the weapons himself`() { + val player = createPlayer(Tile(3522, 3285)) + val cyreg = createNPC("cyreg_paddlehorn", Tile(3522, 3284)) + player["in_search_of_the_myreque"] = "agreed_to_help" + player.inventory.add("steel_longsword") + player.inventory.add("steel_sword", 2) + player.inventory.add("steel_dagger") + player.inventory.add("steel_mace") + player.inventory.add("steel_warhammer") + + player.npcOption(cyreg, "Talk-to") + tick(2) + player.skipDialogues() // he admits to having worked for them, then refuses + + assertEquals("refused_delivery", player["in_search_of_the_myreque", "unstarted"]) + } + + @Test + fun `Cyreg won't lend the boat out before he has been talked round`() { + val player = createPlayer(Tile(3522, 3285)) + createNPC("cyreg_paddlehorn", Tile(3522, 3284)) + val boat = GameObjects.find(Tile(3523, 3284), "swamp_boat_mort_ton") + + player.objectOption(boat, "Board") + tick(4) + + assertEquals(Tile(3522, 3285), player.tile) + } + + @Test + fun `A rotten bridge rung breaks underfoot and can be nailed back together`() { + val player = createPlayer(Tile(3502, 3427)) + player.inventory.add("plank", 6) + player.inventory.add("steel_nails", 225) + player.inventory.add("hammer") + + val rung = GameObjects.find(Tile(3502, 3428), "swamp_bridge1") + player.objectOption(rung, "Walk-here") + tick(2) + player.dialogueContinue() + + val broken = GameObjects.find(Tile(3502, 3428), "spooky_tree_base_forbridge") + player.objectOption(broken, "Repair") + tick(6) + + assertTrue(player["bridgerung1", false], "the southern rung should be fixed") + assertEquals(5, player.inventory.count("plank")) + assertEquals(150, player.inventory.count("steel_nails")) + } + + @Test + fun `Repairing a rung without the materials tells the player so`() { + val player = createPlayer(Tile(3502, 3427)) + val rung = GameObjects.find(Tile(3502, 3428), "swamp_bridge1") + + player.objectOption(rung, "Walk-here") + tick(2) + player.dialogueContinue() + val broken = GameObjects.find(Tile(3502, 3428), "spooky_tree_base_forbridge") + player.objectOption(broken, "Repair") + tick(2) + + assertFalse(player["bridgerung1", false], "an empty handed player can't fix the bridge") + } + + @Test + fun `Rungs out of arm's reach can't be stepped onto or repaired`() { + val player = createPlayer(Tile(3502, 3427)) + player.inventory.add("plank", 6) + player.inventory.add("steel_nails", 225) + player.inventory.add("hammer") + + val far = GameObjects.find(Tile(3502, 3430), "swamp_bridge1") + player.objectOption(far, "Walk-here") + tick(2) + + assertTrue(player.containsMessage("You can't reach that.")) + assertEquals(Tile(3502, 3427), player.tile, "the player stays put") + assertNull(GameObjects.findOrNull(Tile(3502, 3430), "spooky_tree_base_forbridge"), "and the far rung holds") + + // Break the rung in front of them, then try to fix it from the other end of the bridge + val near = GameObjects.find(Tile(3502, 3428), "swamp_bridge1") + player.objectOption(near, "Walk-here") + tick(2) + player.dialogueContinue() + val broken = GameObjects.find(Tile(3502, 3428), "spooky_tree_base_forbridge") + player.tele(3502, 3425, 0) + tick() + player.objectOption(broken, "Repair") + tick(4) + + assertFalse(player["bridgerung1", false], "a rung out of reach can't be repaired") + } + + @Test + fun `The trees are only in reach from the tile beside them`() { + val player = createPlayer(Tile(3502, 3427)) + for (rung in 1..3) { + player["bridgerung$rung"] = true + } + val tree = GameObjects.find(Tile(3502, 3431), "swamp_bridge_tree") + + // Step out onto the first rung, then try to climb the tree at the far end + val rung = GameObjects.find(Tile(3502, 3428), "swamp_bridge1") + player.objectOption(rung, "Walk-here") + tick(3) + assertEquals(Tile(3502, 3428), player.tile) + + player.objectOption(tree, "Climb") + tick(3) + assertTrue(player.containsMessage("You can't reach that.")) + assertEquals(Tile(3502, 3428), player.tile, "no shuffling along the bridge to it") + + // From the rung it stands on though, the tree drops the player off the bridge + player.tele(3502, 3430, 0) + tick() + player.objectOption(tree, "Climb") + tick(3) + + assertEquals(Tile(3503, 3431), player.tile, "the far bank") + } + + @Test + fun `The northern tree can't be climbed until the top rung is fixed`() { + val player = createPlayer(Tile(3502, 3432)) + val tree = GameObjects.find(Tile(3502, 3431), "swamp_bridge_tree") + + player.objectOption(tree, "Climb") + tick(3) + + assertNotEquals(Tile(3502, 3430), player.tile, "the broken top rung is nowhere to stand") + player.dialogueContinue() + + player["bridgerung3"] = true + player.objectOption(tree, "Climb") + tick(4) + + assertEquals(Tile(3502, 3430), player.tile) + } + + @Test + fun `Curpile only asks his questions of someone carrying the weapons`() { + val player = createPlayer(Tile(3508, 3441)) + val curpile = createNPC("curpile_fyod_mort_myre_swamp", Tile(3508, 3440)) + player["in_search_of_the_myreque"] = "reached_hollows" + player.inventory.add("steel_longsword") + player.inventory.add("steel_sword", 2) + player.inventory.add("steel_dagger") + player.inventory.add("steel_mace") + player.inventory.add("steel_warhammer") + + player.npcOption(curpile, "Talk-to") + tick(2) + player.dialogueContinue() + player.dialogueOption("line1") // I've come to help the Myreque, I've brought weapons. + player.skipDialogues() + + assertEquals("questioned_by_curpile", player["in_search_of_the_myreque", "unstarted"]) + } + + @Test + fun `Answering all three of Curpile's questions unlocks the door`() { + val player = createPlayer(Tile(3508, 3441)) + val curpile = createNPC("curpile_fyod_mort_myre_swamp", Tile(3508, 3440)) + player["in_search_of_the_myreque"] = "questioned_by_curpile" + player.giveWeapons() + + player.npcOption(curpile, "Talk-to") + tick(2) + player.dialogueContinue() + // The three questions are drawn at random, so seed the draw and answer what he asks + setRandom(Random(SEED)) + val asked = (0..5).shuffled(Random(SEED)).take(3) + player.dialogueOption("line1") // I've come to help the Myreque, I've brought weapons. + player.skipDialogues() + for (question in asked) { + player.dialogueOption("line${CORRECT_ANSWERS.getValue(question)}") + player.skipDialogues() + } + setRandom(Random) + + assertEquals("answered_questions", player["in_search_of_the_myreque", "unstarted"]) + } + + @Test + fun `Getting a question wrong has Curpile knock the player back to Mort'ton`() { + val player = createPlayer(Tile(3508, 3441)) + val curpile = createNPC("curpile_fyod_mort_myre_swamp", Tile(3508, 3440)) + player["in_search_of_the_myreque"] = "questioned_by_curpile" + player.giveWeapons() + + player.npcOption(curpile, "Talk-to") + tick(2) + player.dialogueContinue() + player.dialogueOption("line1") // I've come to help the Myreque, I've brought weapons. + player.skipDialogues() + repeat(3) { + player.dialogueOption("line5") // I don't know! + player.skipDialogues() + } + tick(12) + + assertEquals("questioned_by_curpile", player["in_search_of_the_myreque", "unstarted"], "he doesn't let them past") + assertEquals(Tile(3522, 3285), player.tile, "and they wake up back in Mort'ton") + } + + @Test + fun `Cyreg lends the boat out for three planks`() { + val player = createPlayer(Tile(3522, 3285)) + val cyreg = createNPC("cyreg_paddlehorn", Tile(3522, 3284)) + player["in_search_of_the_myreque"] = "persuaded_boatman" + player.inventory.add("druid_pouch_2", 5) + player.inventory.add("silver_sickle_b") + player.inventory.add("plank", 3) + + player.npcOption(cyreg, "Talk-to") + tick(2) + player.skipDialogues() // the ghast warning and the druid pouch + player.dialogueOption("line1") // Give wooden planks to Cyreg. + player.skipDialogues() + + assertEquals("gave_planks", player["in_search_of_the_myreque", "unstarted"]) + assertEquals(0, player.inventory.count("plank"), "he takes all three") + } + + @Test + fun `The boat ride to the Hollows costs ten gold`() { + val player = createPlayer(Tile(3522, 3285)) + createNPC("cyreg_paddlehorn", Tile(3522, 3284)) + player["in_search_of_the_myreque"] = "gave_planks" + player.inventory.add("coins", 10) + val boat = GameObjects.find(Tile(3523, 3284), "swamp_boat_mort_ton") + + player.objectOption(boat, "Board") + tick(4) + player.dialogueContinue() // It costs 10 gold to cover the loan of the boat. Will you pay? + player.dialogueOption("line1") // Yes. I'll pay the ten gold. + player.dialogueContinue() + tick(20) + + // Arriving is what counts, not clicking through the message that follows it + assertEquals(Tile(3498, 3380), player.tile) + assertEquals("reached_hollows", player["in_search_of_the_myreque", "unstarted"]) + assertEquals(0, player.inventory.count("coins")) + player.dialogueContinue() // You arrive in the Hollows. + } + + @Test + fun `Veliaf sends the player off to meet the rest of the Myreque`() { + val player = createPlayer(Tile(3507, 9838, 2)) + player["in_search_of_the_myreque"] = "met_veliaf" + val veliaf = createNPC("veliaf_hurtz_meiyerditch_tunnels", Tile(3506, 9838, 2)) + val sani = createNPC("sani_piliu_meiyerditch_tunnels", Tile(3508, 9837, 2)) + + player.npcOption(veliaf, "Talk-to") + tick(2) + player.dialogueContinue() // Have you introduced yourself to the rest of the gang? + player.dialogueOption("line2") // Not yet, I'll go do that now. + player.skipDialogues() + assertEquals("met_veliaf", player["in_search_of_the_myreque", "unstarted"], "the weapons wait until he's met everyone") + + player.npcOption(sani, "Talk-to") + tick(2) + player.skipDialogues() + + assertTrue(player["met_sani", false], "introducing yourself is remembered") + } + + @Test + fun `The journal reads through every stage of the quest`() { + val player = createPlayer(Tile(3503, 3476)) + + for (stage in JOURNAL_STAGES) { + player["in_search_of_the_myreque"] = stage + InterfaceApi.openQuestJournal(player, "in_search_of_the_myreque") + assertTrue(player.interfaces.contains("quest_scroll"), "the journal should open at $stage") + player.interfaces.close("quest_scroll") + } + } + + @Test + fun `The hillside doors stay shut until Curpile has been answered`() { + val player = createPlayer(Tile(3508, 3447)) + val doors = GameObjects.find(Tile(3508, 3446), "swamp_wooden_doors_closed") + + player.objectOption(doors, "Open") + tick(2) + assertEquals(Tile(3508, 3447), player.tile) + + player["in_search_of_the_myreque"] = "answered_questions" + player.objectOption(doors, "Open") + tick(6) + + assertEquals("entered_hideout", player["in_search_of_the_myreque", "unstarted"]) + assertEquals(Tile(3500, 9812), player.tile) + } + + @Test + fun `The stalagmite leads to the hideout, and to the emptier one after the ambush`() { + val player = createPlayer(Tile(3491, 9824)) + player["in_search_of_the_myreque"] = "entered_hideout" + val stalagmite = GameObjects.find(Tile(3492, 9824), "route_stalagmite_cave_entrace") + + player.objectOption(stalagmite, "Squeeze-past") + tick(2) + player.dialogueContinue() + tick(6) + assertEquals(Tile(3505, 9832, 2), player.tile) + + player["in_search_of_the_myreque"] = "killed_hellhound" + player.tele(3491, 9824, 0) + tick() + player.objectOption(stalagmite, "Squeeze-past") + tick(2) + player.dialogueContinue() + tick(6) + assertEquals(Tile(3505, 9832, 0), player.tile) + } + + @Test + fun `Vanstrom ambushes the hideout once the weapons are handed over`() { + val player = createPlayer(Tile(3507, 9838, 2)) + player["in_search_of_the_myreque"] = "weapons_accepted" + player.inventory.add("steel_longsword") + player.inventory.add("steel_sword", 2) + player.inventory.add("steel_dagger") + player.inventory.add("steel_mace") + player.inventory.add("steel_warhammer") + val veliaf = createNPC("veliaf_hurtz_meiyerditch_tunnels", Tile(3506, 9838, 2)) + val sani = createNPC("sani_piliu_meiyerditch_tunnels", Tile(3508, 9837, 2)) + val harold = createNPC("harold_evans_meiyerditch_tunnels", Tile(3504, 9837, 2)) + + player.npcOption(veliaf, "Talk-to") + repeat(80) { + tick() + if (player.dialogue != null) { + player.skipDialogues() + } + } + + assertEquals("hellhound_summoned", player["in_search_of_the_myreque", "unstarted"]) + assertEquals(Tile(3507, 9838, 0), player.tile, "the ambush leaves the player in the emptier room") + assertEquals(0, player.inventory.count("steel_longsword"), "the weapons are handed over") + assertTrue(player["thsfm_vanstrom_hide", false], "Vanstrom stops sitting in the tavern") + val hound = NPCs.at(player.tile.regionLevel).firstOrNull { it.id == "skeleton_hellhound" } + assertNotNull(hound, "a hell hound should be left behind") + assertTrue(player.viewport!!.hints.any { it != 0 }, "and marked with an arrow") + // The ambush runs in a copy of the room, so the real Sani and Harold are never touched + assertNull(player.get("instance"), "the cutscene instance should be cleaned up") + assertEquals(Tile(3508, 9837, 2), sani.tile) + assertEquals(sani.levels.getMax(Skill.Constitution), sani.levels.get(Skill.Constitution)) + assertEquals(harold.levels.getMax(Skill.Constitution), harold.levels.get(Skill.Constitution)) + } + + @Test + fun `Killing the hell hound saves the rest of the Myreque`() { + val player = createPlayer(Tile(3506, 9838)) + player["in_search_of_the_myreque"] = "hellhound_summoned" + val hound = spawnHellHound(player) + tick(2) + + hound.damage(5000, source = player) + tick(6) + + assertEquals("killed_hellhound", player["in_search_of_the_myreque", "unstarted"]) + } + + @Test + fun `The hell hound fights with its own animations and sounds`() { + val definition = get().getOrNull("skeleton_hellhound") + assertNotNull(definition, "the hell hound needs a combat definition to fight with") + + assertEquals("skeleton_hellhound_defend", definition.defendAnim) + assertEquals("skeleton_hellhound_death", definition.deathAnim) + assertEquals("skeleton_hellhound_death", definition.deathSound?.id) + + val melee = definition.attacks["melee"] + assertNotNull(melee, "the hell hound needs a melee attack") + assertEquals("skeleton_hellhound_attack", melee.anim) + assertEquals("skeleton_hellhound_attack", melee.sounds.first().id) + assertEquals("skeleton_hellhound_hit", melee.targetSounds.first().id) + val hit = melee.targetHits.first() + assertEquals("crush", hit.offense) + assertEquals(120, hit.max) + } + + @Test + fun `The hell hound gives up once the player leaves the hideout`() { + val player = createPlayer(Tile(3506, 9838)) + player["in_search_of_the_myreque"] = "hellhound_summoned" + val hound = spawnHellHound(player) + tick(2) + assertTrue(player.viewport!!.hints.any { it != 0 }) + + player.tele(3491, 9824, 0) + tick(3) + + assertEquals(-1, hound.index, "the hound should be gone") + assertEquals(0, player.viewport!!.hints.count { it != 0 }, "and give its arrow back") + } + + @Test + fun `The hell hound leaves bones and rubies behind`() { + val player = createPlayer(Tile(3506, 9838)) + player["in_search_of_the_myreque"] = "hellhound_summoned" + val hound = spawnHellHound(player) + tick(2) + + val deathTile = hound.tile + hound.damage(5000, source = player) + tick(8) + + // Bones and rubies don't stack, so each one is its own pile + val drops = FloorItems.at(deathTile.zone).flatten() + assertEquals(4, drops.count { it.id == "big_bones" }) + assertEquals(2, drops.count { it.id == "uncut_ruby" }) + } + + @Test + fun `Veliaf's secret wall only opens once he has pointed it out`() { + val player = createPlayer(Tile(3480, 9838)) + val wall = GameObjects.find(Tile(3480, 9837), "canifis_fake_wall_closed") + + player.objectOption(wall, "Search") + tick(2) + assertTrue(player.containsMessage("You see nothing interesting with this wall.")) + + player["in_search_of_the_myreque"] = "shown_way_out" + player.objectOption(wall, "Search") + tick(4) + + assertEquals("in_inn_basement", player["in_search_of_the_myreque", "unstarted"]) + } + + @Test + fun `The inn ladder comes out beside the tavern`() { + val player = createPlayer(Tile(3477, 9845)) + player["in_search_of_the_myreque"] = "in_inn_basement" + val ladder = GameObjects.find(Tile(3477, 9846), "canifis_tavern_ladder") + + player.objectOption(ladder, "Climb-up") + tick(4) + + assertEquals("escaped_to_canifis", player["in_search_of_the_myreque", "unstarted"]) + assertEquals(Tile(3495, 3466), player.tile) + } + + @Test + fun `The tavern trapdoor is a shortcut only for those who finished the quest`() { + val player = createPlayer(Tile(3495, 3466)) + val trapdoor = GameObjects.find(Tile(3495, 3465), "canifis_tavern_trapdoor") + + player.objectOption(trapdoor, "Open") + tick(4) + assertTrue(player.containsMessage("This trap door seems locked")) + assertEquals(Tile(3495, 3466), player.tile, "and it stays shut") + + player["in_search_of_the_myreque"] = "completed" + player.objectOption(trapdoor, "Open") + tick(4) + + assertEquals(Tile(3477, 9845), player.tile) + } + + @Test + fun `Telling the stranger about Vanstrom finishes the quest`() { + val player = createPlayer(Tile(3503, 3476)) + player["in_search_of_the_myreque"] = "escaped_to_canifis" + player["thsfm_vanstrom_hide"] = true + val stranger = createNPC("stranger", Tile(3503, 3477)) + val points = player["quest_points", 0] + + player.npcOption(stranger, "Talk-to") + tick(2) + player.skipDialogues() + + assertEquals("completed", player["in_search_of_the_myreque", "unstarted"]) + assertEquals(points + 2, player["quest_points", 0]) + } + + private companion object { + /** Any seed will do, as long as the test draws the same three questions Curpile does. */ + private const val SEED = 42 + + /** Which option each of Curpile's questions has the right answer on. */ + private val CORRECT_ANSWERS = mapOf(0 to 4, 1 to 3, 2 to 1, 3 to 2, 4 to 2, 5 to 4) + + /** One stage from every branch of the journal. */ + private val JOURNAL_STAGES = listOf( + "unstarted", + "agreed_to_help", + "refused_delivery", + "persuaded_boatman", + "gave_planks", + "reached_hollows", + "questioned_by_curpile", + "answered_questions", + "entered_hideout", + "met_veliaf", + "weapons_accepted", + "delivered_weapons", + "hellhound_summoned", + "killed_hellhound", + "shown_way_out", + "in_inn_basement", + "escaped_to_canifis", + "completed", + ) + } +} From 241dba0dcd2d2c008d00c32cc26facb7e34191a9 Mon Sep 17 00:00:00 2001 From: Alexander Date: Wed, 2 Sep 2026 19:12:22 -0400 Subject: [PATCH 4/4] Add Biohazard quest Ports Biohazard: Elena's distillator, Jerico's pigeons and the watchtower distraction, the rope ladder over the wall, the mourners' poisoned stew and headquarters, the errand boys, Guidor's test and King Lathas' confession, plus the Combat Training Camp the quest unlocks. The mourners lose the drop table that handed out a full outfit every kill and gain combat definitions of their own, taken from npc 370 in RuneScape-2011; mourner_2 drops the quarters key while the stew has them laid up, and all seven respawn. Shared code touched along the way: - Gate.replaceTogether, a fixed twin of Gate.replace, which walks the second half off the first half's new tile and turns both halves the same way. The original is left alone so the gates built around its placement don't move. - Teleport.takeOff and teleportLand accept "*" and run every registered handler. Ghosts Ahoy's teleportTakeOff("*") was dead, and PirateTreasure and LostAndFound both registered teleportLand("modern"), so one silently overwrote the other. - questprep gained prep_vars, for world state a prerequisite quest leaves behind that marking it complete doesn't reproduce. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01WQ1FECKKC3ZpsGK3hNVVnh --- .../gregs/voidps/cache/definition/Params.kt | 3 + .../ardougne/west/west_ardougne.anims.toml | 8 + .../ardougne/west/west_ardougne.combat.toml | 10 + .../ardougne/west/west_ardougne.drops.toml | 11 - .../ardougne/west/west_ardougne.npcs.toml | 27 +- .../ardougne/west/west_ardougne.sounds.toml | 5 + data/entity/obj/crates.objs.toml | 4 - data/entity/obj/door/door.objs.toml | 18 +- data/entity/obj/gates.objs.toml | 26 +- .../members/biohazard/biohazard.anims.toml | 2 + .../members/biohazard/biohazard.drops.toml | 6 + .../members/biohazard/biohazard.gfx.toml | 2 + .../members/biohazard/biohazard.objs.toml | 39 + .../members/biohazard/biohazard.varps.toml | 2 +- .../members/biohazard/biohazard.vars.toml | 14 + data/quest/quests.toml | 21 + .../entity/character/player/Teleport.kt | 18 +- .../area/asgarnia/rimmington/Chemist.kt | 142 +++ .../area/kandarin/ardougne/Ardougne.kt | 13 + .../content/area/kandarin/ardougne/Jerico.kt | 127 +++ .../area/kandarin/ardougne/KingLathas.kt | 93 ++ .../kandarin/ardougne/west_ardougne/Elena.kt | 192 +++- .../ardougne/west_ardougne/NurseSarah.kt | 79 ++ .../ardougne/west_ardougne/WestArdougne.kt | 10 +- .../training_camp/CombatTrainingCamp.kt | 102 ++ .../content/area/misthalin/varrock/Guidor.kt | 166 +++ .../misthalin/varrock/VarrockSouthEastGate.kt | 50 + .../kotlin/content/entity/obj/door/Door.kt | 2 +- .../kotlin/content/entity/obj/door/Gate.kt | 37 + game/src/main/kotlin/content/quest/Quest.kt | 1 + .../kotlin/content/quest/QuestCommands.kt | 14 +- .../quest/member/biohazard/Biohazard.kt | 460 ++++++++ .../quest/member/biohazard/ErrandBoys.kt | 211 ++++ .../quest/member/biohazard/RopeLadder.kt | 115 ++ .../kotlin/content/quest/QuestCommandsTest.kt | 14 + .../member/biohazard/BiohazardMournerTest.kt | 80 ++ .../quest/member/biohazard/BiohazardTest.kt | 999 ++++++++++++++++++ .../member/ghosts_ahoy/GhostsAhoyTest.kt | 16 + 38 files changed, 3061 insertions(+), 78 deletions(-) create mode 100644 data/area/kandarin/ardougne/west/west_ardougne.anims.toml create mode 100644 data/area/kandarin/ardougne/west/west_ardougne.combat.toml delete mode 100644 data/area/kandarin/ardougne/west/west_ardougne.drops.toml create mode 100644 data/area/kandarin/ardougne/west/west_ardougne.sounds.toml create mode 100644 data/quest/members/biohazard/biohazard.anims.toml create mode 100644 data/quest/members/biohazard/biohazard.drops.toml create mode 100644 data/quest/members/biohazard/biohazard.gfx.toml create mode 100644 data/quest/members/biohazard/biohazard.objs.toml create mode 100644 data/quest/members/biohazard/biohazard.vars.toml create mode 100644 game/src/main/kotlin/content/area/asgarnia/rimmington/Chemist.kt create mode 100644 game/src/main/kotlin/content/area/kandarin/ardougne/Jerico.kt create mode 100644 game/src/main/kotlin/content/area/kandarin/ardougne/KingLathas.kt create mode 100644 game/src/main/kotlin/content/area/kandarin/ardougne/west_ardougne/NurseSarah.kt create mode 100644 game/src/main/kotlin/content/area/kandarin/tree_gnome_stronghold/training_camp/CombatTrainingCamp.kt create mode 100644 game/src/main/kotlin/content/area/misthalin/varrock/Guidor.kt create mode 100644 game/src/main/kotlin/content/area/misthalin/varrock/VarrockSouthEastGate.kt create mode 100644 game/src/main/kotlin/content/quest/member/biohazard/Biohazard.kt create mode 100644 game/src/main/kotlin/content/quest/member/biohazard/ErrandBoys.kt create mode 100644 game/src/main/kotlin/content/quest/member/biohazard/RopeLadder.kt create mode 100644 game/src/test/kotlin/content/quest/member/biohazard/BiohazardMournerTest.kt create mode 100644 game/src/test/kotlin/content/quest/member/biohazard/BiohazardTest.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 409f58c50e..9961f8303c 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 @@ -144,6 +144,8 @@ object Params { /** Variables a quest keeps outside of its own name; used by the questreset command. */ const val RESET_VARS = 5268 + const val PREP_VARS = 5269 + const val REQ_QUEST_POINTS = 5139 const val REQ_QUESTS = 5140 const val REQ_SKILLS = 5141 @@ -479,6 +481,7 @@ object Params { "regen_rate_ticks" -> REGEN_RATE_TICKS "region" -> REGION "req_combat" -> REQ_COMBAT + "prep_vars" -> PREP_VARS "req_item_ids" -> REQ_ITEM_IDS "reset_vars" -> RESET_VARS "req_items" -> REQ_ITEMS diff --git a/data/area/kandarin/ardougne/west/west_ardougne.anims.toml b/data/area/kandarin/ardougne/west/west_ardougne.anims.toml new file mode 100644 index 0000000000..4d2b0bbe4f --- /dev/null +++ b/data/area/kandarin/ardougne/west/west_ardougne.anims.toml @@ -0,0 +1,8 @@ +[mourner_attack] +id = 422 + +[mourner_defend] +id = 424 + +[mourner_death] +id = 836 diff --git a/data/area/kandarin/ardougne/west/west_ardougne.combat.toml b/data/area/kandarin/ardougne/west/west_ardougne.combat.toml new file mode 100644 index 0000000000..0c3004b6e3 --- /dev/null +++ b/data/area/kandarin/ardougne/west/west_ardougne.combat.toml @@ -0,0 +1,10 @@ +[mourner] +defend_anim = "mourner_defend" +defend_sound = "mourner_defend" +death_anim = "mourner_death" +death_sound = "mourner_death" + +[mourner.melee] +range = 1 +anim = "mourner_attack" +target_hit = { offense = "crush", max = 20 } diff --git a/data/area/kandarin/ardougne/west/west_ardougne.drops.toml b/data/area/kandarin/ardougne/west/west_ardougne.drops.toml deleted file mode 100644 index 7139e299da..0000000000 --- a/data/area/kandarin/ardougne/west/west_ardougne.drops.toml +++ /dev/null @@ -1,11 +0,0 @@ -[mourner_drop_table] -type = "all" -drops = [ - { id = "bones" }, - { id = "gas_mask" }, - { id = "mourner_top" }, - { id = "mourner_trousers" }, - { id = "mourner_boots" }, - { id = "mourner_gloves" }, - { id = "mourner_cloak" }, -] diff --git a/data/area/kandarin/ardougne/west/west_ardougne.npcs.toml b/data/area/kandarin/ardougne/west/west_ardougne.npcs.toml index 8bf2d72277..25c397d335 100644 --- a/data/area/kandarin/ardougne/west/west_ardougne.npcs.toml +++ b/data/area/kandarin/ardougne/west/west_ardougne.npcs.toml @@ -34,8 +34,8 @@ examine = "A mourner, or plague healer." [mourner_1] id = 369 examine = "A mourner, or plague healer." -drop_table = "mourner" -combat_def = "guard" +combat_def = "mourner" +respawn_delay = 20 hitpoints = 190 att = 8 str = 8 @@ -46,8 +46,9 @@ max_hit_crush = 20 [mourner_2] id = 370 examine = "A mourner, or plague healer." -drop_table = "mourner" -combat_def = "guard" +drop_table = "mourner_2" +combat_def = "mourner" +respawn_delay = 20 hitpoints = 190 att = 8 str = 8 @@ -58,8 +59,8 @@ max_hit_crush = 20 [mourner_3] id = 371 examine = "A mourner, or plague healer." -drop_table = "mourner" -combat_def = "guard" +combat_def = "mourner" +respawn_delay = 20 hitpoints = 190 att = 8 str = 8 @@ -81,8 +82,8 @@ style = "crush" max_hit_melee = 20 attack_bonus = 6 examine = "A mourner, or plague healer." -drop_table = "mourner" -combat_def = "guard" +combat_def = "mourner" +respawn_delay = 20 max_hit_crush = 20 [mourner_5] @@ -94,8 +95,8 @@ def = 12 style = "crush" max_hit_melee = 30 examine = "A mourner, or plague healer." -drop_table = "mourner" -combat_def = "guard" +combat_def = "mourner" +respawn_delay = 20 attack_bonus = 6 max_hit_crush = 20 @@ -107,8 +108,8 @@ str = 10 def = 10 max_hit_melee = 20 categories = ["human"] -drop_table = "mourner" -combat_def = "guard" +combat_def = "mourner" +respawn_delay = 20 attack_bonus = 6 max_hit_crush = 20 examine = "A mourner, or plague healer." @@ -121,8 +122,8 @@ str = 95 def = 80 style = "stab" max_hit_melee = 100 -drop_table = "mourner" combat_def = "guard" +respawn_delay = 20 max_hit_stab = 100 examine = "A mourner, or plague healer." diff --git a/data/area/kandarin/ardougne/west/west_ardougne.sounds.toml b/data/area/kandarin/ardougne/west/west_ardougne.sounds.toml new file mode 100644 index 0000000000..c237f1c237 --- /dev/null +++ b/data/area/kandarin/ardougne/west/west_ardougne.sounds.toml @@ -0,0 +1,5 @@ +[mourner_defend] +id = 513 + +[mourner_death] +id = 512 diff --git a/data/entity/obj/crates.objs.toml b/data/entity/obj/crates.objs.toml index 104150d7db..34e3173ffc 100644 --- a/data/entity/obj/crates.objs.toml +++ b/data/entity/obj/crates.objs.toml @@ -42,10 +42,6 @@ examine = "A wooden crate for storage." id = 1999 examine = "A wooden crate for storage." -[crate_12] -id = 2064 -examine = "For storage." - [crate_13] id = 2071 examine = "Used for storage." diff --git a/data/entity/obj/door/door.objs.toml b/data/entity/obj/door/door.objs.toml index 4bbe63b7ac..c1c73c2396 100644 --- a/data/entity/obj/door/door.objs.toml +++ b/data/entity/obj/door/door.objs.toml @@ -446,34 +446,34 @@ id = 2003 id = 4640 examine = "An ornately fashioned door." -[bedroom_door_opened] +[guidor_door_opened] id = 2033 examine = "This is someone's bedroom door." -[bedroom_door_closed] +[guidor_door_closed] id = 2032 examine = "This is someone's bedroom door." -[door_30_opened] +[mourner_stew_door_up_opened] id = 2035 examine = "It's open." -[door_30_closed] +[mourner_stew_door_up_closed] id = 2034 examine = "It's closed." -[door_32_opened] -id = 2035 +[mourner_stew_door_opened] +id = 2037 examine = "It's open." -[door_32_closed] +[mourner_stew_door_closed] id = 2036 examine = "It's closed." -[door_36_opened] +[elena_house_door_opened] id = 2055 -[door_36_closed] +[elena_house_door_closed] id = 2054 examine = "It's a door." diff --git a/data/entity/obj/gates.objs.toml b/data/entity/obj/gates.objs.toml index 5edd8fc65c..05a82f1057 100644 --- a/data/entity/obj/gates.objs.toml +++ b/data/entity/obj/gates.objs.toml @@ -189,11 +189,12 @@ gate = false material = "iron" examine = "A wrought-iron gate." -[gate_34_opened] +[lathas_training_gate_left_opened] id = 2040 -[gate_34_closed] +[lathas_training_gate_left_closed] id = 2039 +material = "iron_door" examine = "It's closed." [kitchen_gate_2_opened] @@ -231,11 +232,12 @@ id = 2040 id = 2673 examine = "A solid looking gate." -[gate_35_opened] +[lathas_training_gate_right_opened] id = 2042 -[gate_35_closed] +[lathas_training_gate_right_closed] id = 2041 +material = "iron_door" examine = "It's closed." [gate_54_opened] @@ -289,24 +291,24 @@ id = 2042 id = 2674 examine = "A solid looking gate." -[gate_36_opened] +[guidor_gate_left_opened] id = 2052 -[gate_36_closed] +[guidor_gate_left_closed] id = 2050 examine = "A wooden gate." -[gate_37_opened] +[guidor_gate_right_opened] id = 2053 -[gate_37_closed] +[guidor_gate_right_closed] id = 2051 examine = "A wooden gate." -[gate_38_opened] +[mourner_quarters_gate_left_opened] id = 2059 -[gate_38_closed] +[mourner_quarters_gate_left_closed] id = 2058 examine = "It's closed." @@ -331,10 +333,10 @@ id = 2059 id = 2552 examine = "A metal gate bars your way." -[gate_39_opened] +[mourner_quarters_gate_right_opened] id = 2061 -[gate_39_closed] +[mourner_quarters_gate_right_closed] id = 2060 examine = "It's closed." diff --git a/data/quest/members/biohazard/biohazard.anims.toml b/data/quest/members/biohazard/biohazard.anims.toml new file mode 100644 index 0000000000..28e27d918e --- /dev/null +++ b/data/quest/members/biohazard/biohazard.anims.toml @@ -0,0 +1,2 @@ +[regicide_tightfit] +id = 1237 diff --git a/data/quest/members/biohazard/biohazard.drops.toml b/data/quest/members/biohazard/biohazard.drops.toml new file mode 100644 index 0000000000..fc85818a27 --- /dev/null +++ b/data/quest/members/biohazard/biohazard.drops.toml @@ -0,0 +1,6 @@ +[mourner_2_drop_table] +type = "all" +drops = [ + { id = "key_biohazard", lacks = "key_biohazard", variable = "biohazard", equals = "poisoned_stew", default = "unstarted" }, + { id = "key_biohazard", lacks = "key_biohazard", variable = "biohazard", equals = "found_distillator", default = "unstarted" }, +] diff --git a/data/quest/members/biohazard/biohazard.gfx.toml b/data/quest/members/biohazard/biohazard.gfx.toml new file mode 100644 index 0000000000..703f251196 --- /dev/null +++ b/data/quest/members/biohazard/biohazard.gfx.toml @@ -0,0 +1,2 @@ +[biopigeon_launch] +id = 72 diff --git a/data/quest/members/biohazard/biohazard.objs.toml b/data/quest/members/biohazard/biohazard.objs.toml new file mode 100644 index 0000000000..44e27fa93f --- /dev/null +++ b/data/quest/members/biohazard/biohazard.objs.toml @@ -0,0 +1,39 @@ +[jericos_cupboard_shut] +id = 2056 +examine = "A cupboard, closed." + +[jericos_cupboard_open] +id = 2057 +examine = "A cupboard, open." + +[bio_nurses_cupboard_shut] +id = 2062 +examine = "A storage box, closed." + +[bio_nurses_cupboard_open] +id = 2063 +examine = "A storage box, open." + +[mourner_crate_up] +id = 2064 +examine = "For storage." + +[mourner_stew_cauldron] +id = 2043 +examine = "Something's cooking." + +[bio_watchtower_fence] +id = 2067 +examine = "The mourners are watching from up there." + +[mourner_stew_fence] +id = 2068 +examine = "There's a gap in the fence." + +[bio_ladder] +id = 36307 +examine = "A rope ladder thrown over the wall." + +[bio_ladder_wall] +id = 36308 +examine = "The wall around West Ardougne." diff --git a/data/quest/members/biohazard/biohazard.varps.toml b/data/quest/members/biohazard/biohazard.varps.toml index f525cf41d4..3e4735239d 100644 --- a/data/quest/members/biohazard/biohazard.varps.toml +++ b/data/quest/members/biohazard/biohazard.varps.toml @@ -3,4 +3,4 @@ id = 68 persist = true format = "map" default = "unstarted" -values = { unstarted = 0, completed = 16 } \ No newline at end of file +values = { unstarted = 0, started = 1, spoke_to_jerico = 2, birdfeed_thrown = 3, pigeons_released = 4, crossed_wall = 5, poisoned_stew = 6, found_distillator = 7, collect_chemicals = 10, smuggle_chemicals = 12, sample_tested = 14, told_elena = 15, completed = 16 } diff --git a/data/quest/members/biohazard/biohazard.vars.toml b/data/quest/members/biohazard/biohazard.vars.toml new file mode 100644 index 0000000000..be86dfc2d6 --- /dev/null +++ b/data/quest/members/biohazard/biohazard.vars.toml @@ -0,0 +1,14 @@ +[biohazard_davinci_errand] +persist = true +format = "list" +values = ["none", "delivered", "painted"] + +[biohazard_chancy_errand] +persist = true +format = "list" +values = ["none", "delivered", "gambled"] + +[biohazard_hops_errand] +persist = true +format = "list" +values = ["none", "delivered", "drank"] diff --git a/data/quest/quests.toml b/data/quest/quests.toml index 12fc4e25a6..1549e92596 100644 --- a/data/quest/quests.toml +++ b/data/quest/quests.toml @@ -189,10 +189,31 @@ start_point = "Talk to Elena in her house in East Ardougne." start_path = [[ 2591, 3335 ]] req_quests = ["plague_city"] req_items = "Priest gown top and bottom." +req_item_ids = ["priest_gown_top", "priest_gown_bottom"] +prep_vars = { plaguecity_elena_at_home = true } req_combat = "You will need to defeat a level 33 mourner." points = 3 reward = "Ability to travel freely through the West Ardougne gate
Use of the Combat Training Camp north of Ardougne" xp = "1,250 Thieving XP" +variables = [ + "biohazard", + "biohazard_davinci_errand", + "biohazard_chancy_errand", + "biohazard_hops_errand", +] +items = [ + "ethenea", + "liquid_honey", + "sulphuric_broline", + "plague_sample", + "touch_paper", + "distillator", + "bird_feed", + "key_biohazard", + "pigeon_cage_full", + "pigeon_cage_empty", + "doctors_gown", +] [black_knights_fortress] id = 159 diff --git a/engine/src/main/kotlin/world/gregs/voidps/engine/entity/character/player/Teleport.kt b/engine/src/main/kotlin/world/gregs/voidps/engine/entity/character/player/Teleport.kt index 74e70d501a..97568c7032 100644 --- a/engine/src/main/kotlin/world/gregs/voidps/engine/entity/character/player/Teleport.kt +++ b/engine/src/main/kotlin/world/gregs/voidps/engine/entity/character/player/Teleport.kt @@ -24,7 +24,7 @@ interface Teleport { fun teleportLand(type: String, block: Player.() -> Unit) { Script.checkLoading() - land[type] = block + land.getOrPut(type) { mutableSetOf() }.add(block) } fun teleportRemoveItems(type: String, block: Player.(String) -> Boolean) { @@ -49,7 +49,7 @@ interface Teleport { companion object : AutoCloseable { private val takeOff = Object2ObjectOpenHashMap Boolean>>(5) private val items = Object2ObjectOpenHashMap Boolean>>(5) - private val land = Object2ObjectOpenHashMap Unit>(5) + private val land = Object2ObjectOpenHashMap Unit>>(5) private val objectTakeOff = Object2ObjectOpenHashMap Int>(50) private val objectLand = Object2ObjectOpenHashMap Unit>(20) @@ -57,7 +57,14 @@ interface Teleport { const val CANCEL = -1 fun takeOff(player: Player, type: String, item: String): Boolean { - for (handler in takeOff[type] ?: return true) { + if (!invoke(takeOff[type], player, item)) { + return false + } + return type == "*" || invoke(takeOff["*"], player, item) + } + + private fun invoke(handlers: Set Boolean>?, player: Player, item: String): Boolean { + for (handler in handlers ?: return true) { if (!handler.invoke(player, item)) { return false } @@ -75,7 +82,10 @@ interface Teleport { } fun land(player: Player, type: String) { - land[type]?.invoke(player) + land[type]?.forEach { handler -> handler.invoke(player) } + if (type != "*") { + land["*"]?.forEach { handler -> handler.invoke(player) } + } } suspend fun takeOff(player: Player, target: GameObject, option: String): Int { diff --git a/game/src/main/kotlin/content/area/asgarnia/rimmington/Chemist.kt b/game/src/main/kotlin/content/area/asgarnia/rimmington/Chemist.kt new file mode 100644 index 0000000000..75b13da643 --- /dev/null +++ b/game/src/main/kotlin/content/area/asgarnia/rimmington/Chemist.kt @@ -0,0 +1,142 @@ +package content.area.asgarnia.rimmington + +import content.entity.player.dialogue.Confused +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.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.inv.item.addOrDrop +import content.quest.questStage +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.client.message +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.entity.character.player.male +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.engine.inv.remove + +class Chemist : Script { + + init { + npcOperate("Talk-to", "chemist_rimmington") { + if (questStage("biohazard") in SMUGGLING) { + choice("What do you want to talk about?") { + lamps() + quest() + } + return@npcOperate + } + choice("Do you want to talk about lamps?") { + option("Yes.") { lampOil() } + option("No.") { smallTalk() } + } + } + } + + private fun ChoiceOption.lamps(): Unit = option("Lamps.") { + lampOil() + } + + private fun ChoiceOption.quest(): Unit = option("Your quest.") { + if (questStage("biohazard") == 10) { + closingTime() + } else { + moreTouchPaper() + } + } + + private suspend fun Player.lampOil() { + player("Hi, I need fuel for a lamp.") + npc("Hello there, the fuel you need is lamp oil, do you need help making it?") + choice { + option("Yes please.") { + npc("It's really quite simple. You use the small still in here. It's all set up, so there's no fiddling around with dials...") + npc("Just put ordinary swamp tar in, and then use a lantern or lamp to get the oil out.") + player("Thanks.") + } + option("No thanks.") + } + } + + private suspend fun Player.smallTalk() { + player("Hello.") + npc("Oh... hello, how's it going?") + player("Good thanks.") + npc("Good to hear, sorry but I have a few things to do right now.") + player("Well I'd better let you get on then.") + } + + private suspend fun Player.closingTime() { + npc("Sorry, I'm afraid we're just closing now. You'll have to come back another time.") + if (!inventory.contains("plague_sample")) { + return + } + choice { + carryingSample() + elenasFriend() + } + } + + private fun ChoiceOption.carryingSample(): Unit = option("This can't wait, I'm carrying a plague sample.") { + player("This can't wait, I'm carrying a plague sample that desperately needs analysis.") + confiscateSample() + } + + private fun ChoiceOption.elenasFriend(): Unit = option("It's ok, I'm Elena's friend.") { + npc("Oh, well that's different then. Must be pretty important to come all this way.") + npc("How's everyone doing there anyway? Wasn't there some plague scare?") + choice { + touchPaperForSample() + touchPaperForGuidor() + } + } + + private fun ChoiceOption.touchPaperForSample(): Unit = option("I need some more touch paper for this plague sample.") { + player("That's why I'm here. I need some more touch paper for this plague sample.") + confiscateSample() + } + + private fun ChoiceOption.touchPaperForGuidor(): Unit = option("I just need some touch paper for a guy called Guidor.") { + player("Who knows... I just need some touch paper for a guy called Guidor.") + npc("Guidor? This one's on me then... the poor guy. Sorry for the interrogation.") + npc("It's just that there's been rumours of a ${if (male) "man" else "woman"} travelling with the plague on ${if (male) "him" else "her"}.") + npc("They're even doing spot checks in Varrock. It's a pharmaceutical disaster!") + player("Oh right... so am I going to be ok carrying these three vials with me?") + npc("With touch paper as well? You're asking for trouble. You'd better use my errand boys, outside. Give them a vial each.") + npc("They're not the most reliable people in the world. One's a painter, one's a gambler, and one's a drunk. Still if you pay peanuts you'll get monkeys, right?") + npc("It's better than entering Varrock with half a laboratory in your napsack.") + player("Ok, thanks for your help. I know Elena appreciates it.") + npc("Yes well don't stand around here gassing. You'd better hurry if you want to see Guidor... He won't be around for much longer.") + addOrDrop("touch_paper") + set("biohazard", "smuggle_chemicals") + } + + private suspend fun Player.confiscateSample() { + inventory.remove("plague_sample") + message("He takes the plague sample from you.") + npc("You idiot! A plague sample should be confined to a lab! I'm taking it off you. I'm afraid it's the only responsible thing to do.") + } + + private suspend fun Player.moreTouchPaper() { + player("Hello again.") + npc("Oh hello, do you need more touch paper?") + if (inventory.contains("touch_paper")) { + player("No, I just wanted to say hello.") + npc("Oh... ok then... hello.") + player("Hi.") + return + } + player("Yes please.") + npc("Ok, here you go.") + addOrDrop("touch_paper") + message("The chemist gives you some touch paper.") + } + + private companion object { + val SMUGGLING = 10..14 + } +} diff --git a/game/src/main/kotlin/content/area/kandarin/ardougne/Ardougne.kt b/game/src/main/kotlin/content/area/kandarin/ardougne/Ardougne.kt index 3b284b75e3..4e4d200c42 100644 --- a/game/src/main/kotlin/content/area/kandarin/ardougne/Ardougne.kt +++ b/game/src/main/kotlin/content/area/kandarin/ardougne/Ardougne.kt @@ -1,9 +1,11 @@ package content.area.kandarin.ardougne +import content.entity.obj.door.enterDoor import content.entity.player.bank.bank import content.entity.player.dialogue.* import content.entity.player.dialogue.type.* import content.quest.quest +import content.quest.questStage import world.gregs.voidps.engine.Script import world.gregs.voidps.engine.client.message import world.gregs.voidps.engine.client.ui.open @@ -24,6 +26,8 @@ class Ardougne : Script { Tile(2566, 3332, 0), ) + private val elenaMissing = 22 + init { itemOnObjectOperate("bucket_of_water", "plague_mud") { if (quest("plague_city") == "about_digging") { @@ -73,6 +77,15 @@ class Ardougne : Script { } } + objectOperate("Open", "elena_house_door_closed") { (target) -> + if (tile.y < target.tile.y || questStage("plague_city") >= elenaMissing) { + enterDoor(target) + return@objectOperate + } + message("The door is locked.") + sound("locked") + } + objectOperate("Open", "alrenas_cupboard_shut") { (target) -> message("You open the cupboard.") anim("climb_down") diff --git a/game/src/main/kotlin/content/area/kandarin/ardougne/Jerico.kt b/game/src/main/kotlin/content/area/kandarin/ardougne/Jerico.kt new file mode 100644 index 0000000000..280e958227 --- /dev/null +++ b/game/src/main/kotlin/content/area/kandarin/ardougne/Jerico.kt @@ -0,0 +1,127 @@ +package content.area.kandarin.ardougne + +import content.entity.player.dialogue.Confused +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.ChoiceOption +import content.entity.player.dialogue.type.choice +import content.entity.player.dialogue.type.item +import content.entity.player.dialogue.type.npc +import content.entity.player.dialogue.type.player +import content.entity.player.inv.item.addOrDrop +import content.quest.questStage +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.client.message +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.entity.character.sound +import world.gregs.voidps.engine.entity.obj.remove +import world.gregs.voidps.engine.entity.obj.replace +import world.gregs.voidps.engine.inv.carriesItem +import world.gregs.voidps.engine.timer.toTicks +import java.util.concurrent.TimeUnit + +class Jerico : Script { + + init { + npcOperate("Talk-to", "jerico") { + when (questStage("biohazard")) { + 0 -> { + player("Hello.") + npc("Can I help you?") + player("Just passing by.") + } + 1 -> crossingTheWall() + 2, 3 -> distractionIdeas() + 4 -> { + player("Hello there.") + npc("The guards are distracted by the birds, you must go now, quickly traveller.") + } + 5 -> { + player("Hello again Jerico.") + npc("So you've returned traveller. Did you get what you wanted?") + player("Not yet.") + npc("Omart will be waiting by the wall, in case you need to cross again.") + } + else -> message("Jerico is busy looking for his bird feed.") + } + } + + objectOperate("Open", "jericos_cupboard_shut") { (target) -> + message("You open the cupboard.") + sound("cupboard_open") + anim("human_opencupboard") + target.replace("jericos_cupboard_open", ticks = TimeUnit.MINUTES.toTicks(1)) + } + + objectOperate("Close", "jericos_cupboard_open") { (target) -> + message("You close the cupboard.") + sound("cupboard_close") + anim("human_opencupboard") + target.remove() + } + + objectOperate("Search", "jericos_cupboard_open") { + searchCupboard() + } + } + + private suspend fun Player.crossingTheWall() { + player("Hello Jerico.") + npc("Hello, I've been expecting you. Elena tells me you need to cross the wall.") + player("That's right.") + npc("My messenger pigeons help me communicate with friends over the wall.") + set("biohazard", "spoke_to_jerico") + npc("I have arranged for two friends to aid you with a rope ladder. Omart is waiting for you at the southern end of the wall.") + npc("But be careful, if the mourners catch you the punishment will be severe.") + player("Thanks Jerico.") + } + + private suspend fun Player.distractionIdeas() { + player("Hello Jerico, I need someway to distract the watch tower, any ideas?") + npc("Hmmm. Nothing springs to mind.") + choice { + shoutAndScream() + usePigeons() + beQuiet() + noIdeas() + } + } + + private fun ChoiceOption.shoutAndScream(): Unit = option("Maybe you could shout and scream, and call them away?") { + npc("So they chase after me?") + player("Yes. How quickly can you run?") + npc("No. I don't like this idea.") + } + + private fun ChoiceOption.usePigeons(): Unit = option("Maybe I could use your messenger pigeons to distract them?") { + npc("You might have some luck with that idea. The pigeons are around the back of my house if you want to try that.") + player("Ok, maybe I'll give it a go.") + } + + private fun ChoiceOption.beQuiet(): Unit = option("Maybe if I'm really quiet they won't notice me?") { + npc("And what stops them from seeing you?") + player("Well... perhaps I wait till nightfall?") + npc("There's no time for that.") + } + + private fun ChoiceOption.noIdeas(): Unit = option("I can't think of anything either.") { + npc("That's too bad.") + } + + private suspend fun Player.searchCupboard() { + item("bird_feed", "The cupboard is full of bird feed.") + val stage = questStage("biohazard") + if (stage < 2) { + player("I guess pigeons really love this stuff.") + return + } + if (stage > 2 || carriesItem("bird_feed")) { + player("I don't need any more bird feed.") + return + } + addOrDrop("bird_feed") + player("Mmm, bird feed! Now what could I do with that?") + } +} diff --git a/game/src/main/kotlin/content/area/kandarin/ardougne/KingLathas.kt b/game/src/main/kotlin/content/area/kandarin/ardougne/KingLathas.kt new file mode 100644 index 0000000000..fd5a28cce3 --- /dev/null +++ b/game/src/main/kotlin/content/area/kandarin/ardougne/KingLathas.kt @@ -0,0 +1,93 @@ +package content.area.kandarin.ardougne + +import content.entity.player.dialogue.Angry +import content.entity.player.dialogue.Confused +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.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.questComplete +import content.quest.questStage +import content.quest.refreshQuestJournal +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.client.message +import world.gregs.voidps.engine.entity.character.jingle +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.entity.character.player.name +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 + +class KingLathas : Script { + + init { + npcOperate("Talk-to", "king_lathas") { + when (questStage("biohazard")) { + 15 -> confrontTheKing() + 16 -> { + player("Hello King Lathas.") + npc("Hello $name. I've got nothing for you right now.") + } + else -> message("The king is too busy to talk.") + } + } + } + + private suspend fun Player.confrontTheKing() { + player("I assume that you are the King of East Ardougne?") + npc("You assume correctly, but where do you get such impertinence.") + player("I get it from finding out that the plague is a hoax.") + npc("A hoax? I've never heard such a ridiculous thing...") + player("I have evidence, from Guidor of Varrock.") + npc("Ah... I see. Well then you are right about the plague. But I did it for the good of my people.") + player("When is it ever good to lie to people like that?") + npc("When it protects them from a far greater danger, a fear too big to fathom.") + choice { + dontUnderstand() + wastedTime() + } + } + + private fun ChoiceOption.wastedTime(): Unit = option("Well I've wasted enough of my time here.") { + npc("No time is ever wasted, thanks for all you've done.") + } + + private fun ChoiceOption.dontUnderstand(): Unit = option("I don't understand...") { + npc("Their King, Tyras, journeyed out to the West on a voyage of discovery. But he was captured by the Dark Lord.") + npc("The Dark Lord agreed to spare his life, but only on one condition... That he would drink from the Chalice of Eternity.") + player("So what happened?") + npc("The chalice corrupted him. He joined forces with the Dark Lord, the embodiment of pure evil, banished all those years ago...") + npc("And so I erected this wall, not just to protect my people, but to protect all the people of RuneScape.") + npc("Now, with the King of West Ardougne, the Dark Lord has an ally on the inside.") + npc("So I'm sorry that I lied about the plague. I just hope that you can understand my reasons.") + player("Well at least I know now, but what can we do about it?") + npc("Nothing at the moment, I'm waiting for my scouts to come back. They will tell us how we can get through the mountains.") + npc("When this happens, can I count on your support?") + player("Absolutely!") + npc("Thank the gods! I give you permission to use my training area.") + npc("It's located just to the north west of Ardougne, there you can prepare for the challenge ahead.") + player("Ok. There's just one thing I don't understand, how do you know so much about King Tyras?") + npc("How could I not do? He was my brother.") + completeQuest() + } + + private suspend fun Player.completeQuest() { + set("biohazard", "completed") + jingle("quest_complete_1") + exp(Skill.Thieving, 1250.0) + inc("quest_points", 3) + AuditLog.event(this, "quest_completed", "biohazard") + refreshQuestJournal() + questComplete( + "Biohazard", + "3 Quest Points", + "1,250 Thieving XP", + "Access to the Combat Training Camp", + item = "distillator", + ) + } +} diff --git a/game/src/main/kotlin/content/area/kandarin/ardougne/west_ardougne/Elena.kt b/game/src/main/kotlin/content/area/kandarin/ardougne/west_ardougne/Elena.kt index e1d8fccd37..f211f9d53f 100644 --- a/game/src/main/kotlin/content/area/kandarin/ardougne/west_ardougne/Elena.kt +++ b/game/src/main/kotlin/content/area/kandarin/ardougne/west_ardougne/Elena.kt @@ -1,12 +1,28 @@ package content.area.kandarin.ardougne.west_ardougne -import content.entity.player.dialogue.* +import content.entity.player.dialogue.Angry +import content.entity.player.dialogue.Confused +import content.entity.player.dialogue.Happy +import content.entity.player.dialogue.Idle +import content.entity.player.dialogue.Neutral +import content.entity.player.dialogue.Quiz +import content.entity.player.dialogue.Sad +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.startQuest -import content.quest.quest +import content.entity.player.inv.item.addOrDrop +import content.quest.questCompleted +import content.quest.questStage +import content.quest.refreshQuestJournal import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.client.message import world.gregs.voidps.engine.client.ui.open +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.inv.carriesItem +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.engine.inv.remove class Elena : Script { @@ -26,30 +42,164 @@ class Elena : Script { } npcOperate("Talk-to", "elena2_vis") { - when (quest("biohazard")) { - "unstarted" -> { - player("Good day to you, Elena.") - npc("You too, thanks for freeing me.") - npc("It's just a shame the mourners confiscated my equipment.") - player("What did they take?") - npc("My distillator. I can't test any plague samples without it. They're holding it in the Mourner Headquarters in West Ardougne.") - npc("I must somehow retrieve that distillator if I am to find a cure for this awful affliction. Do you think you could help me?") - if (startQuest("biohazard")) { - // todo add quest - } else { - player("I'm busy at the moment, I'm afraid.") - npc("Fair enough.") - } - } - "started" -> started() + when (questStage("biohazard")) { + 0 -> distillatorRequest() + 1 -> anyLuck() + 2, 3 -> spokenToJerico() + 4 -> guardsDistracted() + 5, 6 -> notYetFound() + 7 -> returnDistillator() + 10, 12 -> reminders() + 14 -> guidorsFindings() + 15 -> seeTheKing() else -> completed() } } } - fun started() { + private suspend fun Player.distillatorRequest() { + player("Good day to you, Elena.") + npc("You too, thanks for freeing me.") + npc("It's just a shame the mourners confiscated my equipment.") + player("What did they take?") + npc("My distillator, I can't test any plague samples without it. They're holding it in the mourner quarters in West Ardougne.") + npc("I must somehow retrieve that distillator if I am to find a cure for this awful affliction.") + if (!questCompleted("plague_city")) { + player("Well, good luck.") + npc("Thanks traveller.") + return + } + choice { + acceptQuest() + goodLuck() + } + } + + private fun ChoiceOption.acceptQuest(): Unit = option("I'll try to retrieve it for you.") { + npc("I was hoping you would say that. Unfortunately they discovered the tunnel and filled it in. We need another way over the wall.") + player("Any ideas?") + set("biohazard", "started") + set("plaguecity_dug_mud_pile", false) + refreshQuestJournal() + npc("My father's friend Jerico is in communication with West Ardougne. He might be able to help us, he lives next to the chapel.") + } + + private fun ChoiceOption.goodLuck(): Unit = option("Well, good luck.") { + npc("Thanks traveller.") + } + + private suspend fun Player.anyLuck() { + player("Hello Elena.") + npc("Hello brave adventurer. Any luck finding my distillator?") + player("No, I'm afraid not.") + npc("Speak to Jerico, he will help you to cross the wall. He lives next to the chapel.") + } + + private suspend fun Player.spokenToJerico() { + player("Hello Elena, I've spoken to Jerico.") + npc("Was he able to help?") + player("He has two friends who will help me cross the wall, but first I need to distract the watch tower.") + npc("Hmm, could be tricky.") + } + + private suspend fun Player.guardsDistracted() { + player("Elena, I've distracted the guards at the watch tower.") + npc("Yes, I saw. Quickly meet with Jerico's friends and cross the wall before the pigeons fly off.") + } + + private suspend fun Player.notYetFound() { + player("Hello again.") + npc("You're back, did you find the distillator?") + player("I'm afraid not.") + npc("I can't test the samples without the distillator. Please don't give up until you find it.") + } + + private suspend fun Player.returnDistillator() { + npc("So, have you managed to retrieve my distillator?") + if (!carriesItem("distillator")) { + player("I'm afraid not.") + npc("Oh, you haven't... People may be dying even as we speak.") + return + } + player("Yes, here it is!") + npc("You have? That's great! Now can you pass me those reaction agents please?") + inventory.remove("distillator") + message("You hand Elena the distillator and an assortment of vials.") + player("Those look pretty fancy.") + npc("Well, yes and no. The liquid honey isn't worth much, but the others are. Especially this colourless ethenea. Be careful with the sulphuric broline, it's highly poisonous.") + player("You're not kidding, I can smell it from here!") + message("Elena puts the agents through the distillator.") + delay(2) + npc("I don't understand... the touch paper hasn't changed colour at all...") + npc("You'll need to go and see my old mentor Guidor. He lives in Varrock. Take these vials and this sample to him.") + set("biohazard", "collect_chemicals") + for (item in CHEMICALS) { + addOrDrop(item) + } + message("Elena gives you three vials and a sample in a tin container.") + npc("But first you'll need some more touch paper. Go and see the chemist in Rimmington.") + npc("Just don't get into any fights, and be careful who you speak to.") + npc("Those vials are fragile, and plague carriers don't tend to be too popular.") + } + + private suspend fun Player.reminders() { + npc("What are you doing back here?") + choice { + saidGoodbye() + lostTheChemicals() + forgotTheTask() + } + } + + private fun ChoiceOption.saidGoodbye(): Unit = option("I just find it hard to say goodbye sometimes.") { + npc("Yes... I have feelings for you too...") + npc("Now get back to work!") + } + + private fun ChoiceOption.lostTheChemicals(): Unit = option("I'm afraid I've lost some of the stuff that you gave me...") { + npc("That's alright, I've got plenty.") + for (item in CHEMICALS) { + if (!carriesItem(item)) { + addOrDrop(item) + } + } + npc("Ok, so that's your colourless ethenea... Some highly toxic sulphuric broline... And some bog-standard liquid honey.") + player("Great, I'll be on my way.") + } + + private fun ChoiceOption.forgotTheTask(): Unit = option("I've forgotten what I need to do.") { + npc("Go to Rimmington and get some touch paper from the chemist. Use his errand boys to smuggle the vials into Varrock.") + npc("Then collect the samples and take them to Guidor, my old mentor.") + player("Ok, I'll get to it.") + } + + private suspend fun Player.guidorsFindings() { + npc("You're back! So what did Guidor say?") + player("Nothing.") + npc("What?") + player("He said that there is no plague.") + npc("So what, this thing has all been a big hoax?") + player("Or maybe we're about to uncover something huge.") + npc("Then I think this thing may be bigger than both of us.") + player("What do you mean?") + set("biohazard", "told_elena") + npc("I mean you need to go right to the top... You need to see the King of East Ardougne!") + } + + private suspend fun Player.seeTheKing() { + player("Hello Elena.") + npc("You must go see King Lathas immediately!") + } + + private suspend fun Player.completed() { + player("Hello Elena.") + npc("Hey, how are you?") + player("Good thanks, yourself?") + npc("Not bad, let me know when you hear from King Lathas again.") + player("Will do.") } - fun completed() { + private companion object { + val CHEMICALS = listOf("ethenea", "liquid_honey", "sulphuric_broline", "plague_sample") } } diff --git a/game/src/main/kotlin/content/area/kandarin/ardougne/west_ardougne/NurseSarah.kt b/game/src/main/kotlin/content/area/kandarin/ardougne/west_ardougne/NurseSarah.kt new file mode 100644 index 0000000000..899a366cc4 --- /dev/null +++ b/game/src/main/kotlin/content/area/kandarin/ardougne/west_ardougne/NurseSarah.kt @@ -0,0 +1,79 @@ +package content.area.kandarin.ardougne.west_ardougne + +import content.entity.player.bank.ownsItem +import content.entity.player.dialogue.Confused +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.type.npc +import content.entity.player.dialogue.type.player +import content.entity.player.inv.item.addOrDrop +import content.quest.questStage +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.client.message +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.entity.character.sound +import world.gregs.voidps.engine.entity.obj.remove +import world.gregs.voidps.engine.entity.obj.replace +import world.gregs.voidps.engine.timer.toTicks +import java.util.concurrent.TimeUnit + +class NurseSarah : Script { + + init { + npcOperate("Talk-to", "nurse_sarah_west_ardougne") { + when (questStage("biohazard")) { + in 6..7 -> { + player("Hello nurse.") + npc("Oh hello there.") + npc("I'm afraid I can't stop and talk, a group of mourners have become ill with food poisoning. I need to go over and see what I can do.") + player("Hmmm, strange that!") + } + in 0..5 -> { + player("Hello nurse.") + npc("I don't know how much longer I can cope here.") + player("What? Is the plague getting to you?") + npc("No, strangely enough the people here don't seem to be affected. It's just the awful living conditions that are making people ill.") + player("I was under the impression that everyone here was affected.") + npc("Me too, but that doesn't seem to be the case.") + } + else -> { + player("Hello there.") + npc("Hello my dear, how are you feeling?") + player("I'm ok thanks.") + npc("Well in that case I'd better get back to work. Take care.") + player("You too.") + } + } + } + + objectOperate("Open", "bio_nurses_cupboard_shut") { (target) -> + message("You open the cupboard.") + sound("cupboard_open") + anim("human_opencupboard") + target.replace("bio_nurses_cupboard_open", ticks = TimeUnit.MINUTES.toTicks(1)) + } + + objectOperate("Close", "bio_nurses_cupboard_open") { (target) -> + message("You close the cupboard.") + sound("cupboard_close") + anim("human_opencupboard") + target.remove() + } + + objectOperate("Search", "bio_nurses_cupboard_open") { + searchCupboard() + } + } + + private fun Player.searchCupboard() { + message("You search the cupboard...") + if (questStage("biohazard") < 6 || ownsItem("doctors_gown")) { + message("but you find nothing of interest.") + return + } + addOrDrop("doctors_gown") + message("and find a doctor's gown.") + } +} diff --git a/game/src/main/kotlin/content/area/kandarin/ardougne/west_ardougne/WestArdougne.kt b/game/src/main/kotlin/content/area/kandarin/ardougne/west_ardougne/WestArdougne.kt index 76348865f6..dc353b1347 100644 --- a/game/src/main/kotlin/content/area/kandarin/ardougne/west_ardougne/WestArdougne.kt +++ b/game/src/main/kotlin/content/area/kandarin/ardougne/west_ardougne/WestArdougne.kt @@ -138,7 +138,15 @@ class WestArdougne : Script { } } - objectOperate("Open", "ardougne_wall_door_2_closed,ardougne_wall_door_closed") { + objectOperate("Open", "trapdoor_mourner_tunnels") { + message("The trapdoor is bolted on the other side.") + } + + objectOperate("Open", "ardougne_wall_door_2_closed,ardougne_wall_door_closed") { (target) -> + if (questCompleted("biohazard")) { + enterDoor(target, delay = 2) + return@objectOperate + } message("You pull on the large wooden doors...") delay(2) message("...But they will not open.") diff --git a/game/src/main/kotlin/content/area/kandarin/tree_gnome_stronghold/training_camp/CombatTrainingCamp.kt b/game/src/main/kotlin/content/area/kandarin/tree_gnome_stronghold/training_camp/CombatTrainingCamp.kt new file mode 100644 index 0000000000..9be8d40d9e --- /dev/null +++ b/game/src/main/kotlin/content/area/kandarin/tree_gnome_stronghold/training_camp/CombatTrainingCamp.kt @@ -0,0 +1,102 @@ +package content.area.kandarin.tree_gnome_stronghold.training_camp + +import content.entity.npc.shop.openShop +import content.entity.obj.door.Gate +import content.entity.player.dialogue.Angry +import content.entity.player.dialogue.Confused +import content.entity.player.dialogue.Happy +import content.entity.player.dialogue.Neutral +import content.entity.player.dialogue.type.choice +import content.entity.player.dialogue.type.npc +import content.entity.player.dialogue.type.player +import content.quest.questCompleted +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.entity.obj.GameObject +import world.gregs.voidps.engine.entity.obj.GameObjects +import world.gregs.voidps.type.Tile + +class CombatTrainingCamp : Script { + + init { + objectOperate("Open", "lathas_training_gate_left_closed,lathas_training_gate_right_closed") { (target) -> + val entering = tile.y <= target.tile.y + if (entering && !questCompleted("biohazard")) { + npc("guard_combat_training_camp", "This is a restricted area, you can only enter under the authority of King Lathas.") + return@objectOperate + } + if (entering) { + npc("guard_combat_training_camp", "The king has granted you access to this training area. Make good use of it, soon all your strength will be needed.") + } + openGate(target, entering) + } + + npcOperate("Talk-to", "guard_combat_training_camp") { + player("Hello there.") + npc("What do you want? Leave us be.") + } + + npcOperate("Talk-to", "guard_combat_training_camp_2") { + player("Hello.") + npc("Well hello brave warrior. These ogres have been terrorising the area, they've eaten four children this week alone.") + player("Brutes!") + npc("So we decided to use them for target practice. A fair punishment.") + player("Indeed.") + } + + npcOperate("Talk-to", "guard_combat_training_camp_3") { + player("Hello.") + npc("Hello soldier.") + player("I'm more of an adventurer really.") + npc("In this day and age we're all soldiers. No time to waste gassing, Fight! Fight!") + } + + npcOperate("Trade", "shopkeeper_combat_training_camp") { (target) -> + openShop(target.def["shop"]) + } + + npcOperate("Talk-to", "shopkeeper_combat_training_camp") { (target) -> + player("Hello.") + npc("So, are you looking to buy weapons? King Lathas keeps us very well stocked.") + choice { + option("What do you have?") { + npc("Take a look.") + openShop(target.def["shop"]) + } + option("No thanks.") + } + } + } + + private suspend fun Player.openGate(gate: GameObject, entering: Boolean) { + val column = if (tile.x == GATE_LEFT.x || tile.x == GATE_RIGHT.x) tile.x else gate.tile.x + val near = Tile(column, if (entering) GATE_LEFT.y else GATE_LEFT.y + 1) + val far = Tile(column, if (entering) GATE_LEFT.y + 1 else GATE_LEFT.y) + if (tile != near) { + walkOverDelay(near) + } + val left = GameObjects.findOrNull(GATE_LEFT, "lathas_training_gate_left_closed") + val right = GameObjects.findOrNull(GATE_RIGHT, "lathas_training_gate_right_closed") + if (left == null || right == null) { + walkOverDelay(far) + return + } + Gate.replaceTogether( + this, left, right, + flip = false, + ticks = GATE_TICKS, + collision = false, + current = "_closed", + next = "_opened", + objRotation = 3, + hingeTileRotation = 1, + ) + walkOverDelay(far) + } + + private companion object { + val GATE_LEFT = Tile(2517, 3356) + val GATE_RIGHT = Tile(2518, 3356) + const val GATE_TICKS = 3 + } +} diff --git a/game/src/main/kotlin/content/area/misthalin/varrock/Guidor.kt b/game/src/main/kotlin/content/area/misthalin/varrock/Guidor.kt new file mode 100644 index 0000000000..3b360adf22 --- /dev/null +++ b/game/src/main/kotlin/content/area/misthalin/varrock/Guidor.kt @@ -0,0 +1,166 @@ +package content.area.misthalin.varrock + +import content.entity.obj.door.enterDoor +import content.entity.player.dialogue.Angry +import content.entity.player.dialogue.Confused +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.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.questStage +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.client.message +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.entity.character.player.equip.equipped +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.engine.inv.remove +import world.gregs.voidps.network.login.protocol.visual.update.player.EquipSlot + +class Guidor : Script { + + init { + npcOperate("Talk-to", "guidor") { + if (questStage("biohazard") != 12) { + player("Hello again Guidor.") + npc("Well, hello traveller. I still can't understand why they would lie about the plague.") + player("It's strange, anyway how are you doing?") + npc("I'm hanging in there.") + player("Good for you.") + return@npcOperate + } + player("Hello, you must be Guidor. I understand that you are unwell.") + npc("Is my wife asking priests to visit me now? I'm a man of science for god's sake.") + npc("Ever since she heard rumours of a plague carrier travelling from Ardougne she's kept me under house arrest.") + npc("Of course she means well, and I am quite frail now...
So what brings you here?") + choice { + stopAPlague() + blessTheRoom() + } + } + + npcOperate("Talk-to", "guidors_wife") { + if (questStage("biohazard") != 12) { + sickHusband() + return@npcOperate + } + if (dressedAsPriest()) { + npc("A priest - thank goodness! My husband is very ill! Perhaps you could read him his last rites?") + player("I'll see what I can do.") + return@npcOperate + } + player("Hello, I'm a friend of Elena, here to see Guidor.") + npc("I'm afraid...(she sobs)...that Guidor is not long for this world! So I'm not letting people see him now.") + player("I'm really sorry to hear about Guidor.") + player("But I do have some very important business to attend to.") + npc("You heartless rogue! What could be more important than Guidor's life? A life spent well, if not always wisely... I just hope that Saradomin shows mercy on his soul!") + player("Guidor is a religious man?") + npc("Oh goodness no! But I am! If only I could get him to see a priest!") + } + + objectOperate("Open", "guidor_door_closed") { (target) -> + if (tile.x > target.tile.x) { + enterDoor(target) + return@objectOperate + } + if (dressedAsPriest()) { + message("Guidor's wife allows you to go in.") + enterDoor(target) + return@objectOperate + } + message("Guidor's wife refuses to let you enter.") + npc("guidors_wife", "Please leave my husband alone. He's very sick, and I don't want anyone bothering him.") + player("I'm sorry to hear that.
Is there anything I can do?") + npc("guidors_wife", "Thank you, but I just want him to see a priest.") + player("A priest? Hmmm...") + } + } + + private fun Player.dressedAsPriest(): Boolean = equipped(EquipSlot.Chest).id == "priest_gown_top" && equipped(EquipSlot.Legs).id == "priest_gown_bottom" + + private suspend fun Player.sickHusband() { + player("Hello again.") + if (questStage("biohazard") in 14..15) { + npc("Hello there. I fear Guidor may not be long for this world!") + return + } + npc("Oh hello, I can't chat now, I have to keep an eye on my husband. He's very ill!") + player("I'm sorry to hear that!") + } + + private fun ChoiceOption.stopAPlague(): Unit = option("I've come to ask your assistance in stopping a plague.") { + player("Well it's funny you should ask actually... I've come to ask your assistance in stopping a plague that could kill thousands.") + npc("So you're the plague carrier!") + choice { + option("No! Well, yes...") { + player("No! Well, yes... but not exactly. It's contained in a sealed unit from Elena.") + analyseSample() + } + option("I've been sent by your old pupil Elena.") { + player("I've been sent by your old pupil Elena, she's trying to halt the virus.") + analyseSample() + } + } + } + + private fun ChoiceOption.blessTheRoom(): Unit = option("I was just going to bless your room and I've done that now.") { + player("Oh nothing, I was just going to bless your room and I've done that now. Goodbye.") + } + + private suspend fun Player.analyseSample() { + npc("Elena eh?") + player("Yes, she wants you to analyse it. You might be the only one who can help.") + npc("Right then, sounds like we'd better get to work!") + if (!inventory.contains("plague_sample")) { + npc("Seems like you don't actually HAVE the plague sample. It's a long way to come empty-handed... and quite a long way back too.") + return + } + player("I have the plague sample.") + npc("Now I'll be needing some liquid honey, some sulphuric broline, and then...") + player("... some ethenea?") + npc("Indeed!") + if (REAGENTS.any { !inventory.contains(it) }) { + npc("Look, I need all three reagents to test the plague sample. Come back when you've got them.") + return + } + if (!inventory.contains("touch_paper")) { + npc("Oh. You don't have any touch paper, and so I won't be able to help after all.") + return + } + for (item in REAGENTS + "plague_sample" + "touch_paper") { + inventory.remove(item) + } + set("biohazard", "sample_tested") + npc("Now I'll just apply these to the sample and... I don't get it... the touch paper has remained the same.") + choice { + elenaKnew() + whatDoesThatMean() + } + } + + private fun ChoiceOption.elenaKnew(): Unit = option("That's why Elena wanted you to do it.") { + player("That's why Elena wanted you to do it, because she wasn't sure what was happening.") + npc("Well that's just it, nothing has happened.") + noPlague() + } + + private fun ChoiceOption.whatDoesThatMean(): Unit = option("So what does that mean exactly.") { + noPlague() + } + + private suspend fun Player.noPlague() { + npc("I don't know what this sample is, but it certainly isn't toxic.") + player("So what about the plague?") + npc("Don't you understand? There is no plague.") + npc("I'm very sorry, I can see that you've worked very hard for this... but it seems that someone has been lying to you.") + npc("The only question is... why?") + } + + private companion object { + val REAGENTS = listOf("ethenea", "liquid_honey", "sulphuric_broline") + } +} diff --git a/game/src/main/kotlin/content/area/misthalin/varrock/VarrockSouthEastGate.kt b/game/src/main/kotlin/content/area/misthalin/varrock/VarrockSouthEastGate.kt new file mode 100644 index 0000000000..ea29a33280 --- /dev/null +++ b/game/src/main/kotlin/content/area/misthalin/varrock/VarrockSouthEastGate.kt @@ -0,0 +1,50 @@ +package content.area.misthalin.varrock + +import content.entity.obj.door.enterDoor +import content.entity.player.dialogue.Neutral +import content.entity.player.dialogue.type.npc +import content.quest.questStage +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.client.message +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.engine.inv.removeToLimit + +class VarrockSouthEastGate : Script { + + init { + npcOperate("Talk-to", "guard_biohazard") { + npc("Please don't disturb me, I've got to keep an eye out for suspicious individuals.") + } + + objectOperate("Open", "guidor_gate_left_closed,guidor_gate_right_closed") { (target) -> + if (tile.x >= target.tile.x || questStage("biohazard") !in SEARCHED) { + enterDoor(target) + return@objectOperate + } + search() + enterDoor(target) + } + } + + private suspend fun Player.search() { + npc("guard_biohazard", "Halt. I need to conduct a search on you. There have been reports of someone bringing a virus into Varrock.") + message("The guard searches you.") + delay(1) + for (vial in VIALS) { + val amount = inventory.count(vial) + if (amount <= 0) { + continue + } + inventory.removeToLimit(vial, amount) + message("He takes the vial of ${vial.replace('_', ' ')} from you.") + delay(1) + } + npc("guard_biohazard", "You may now pass.") + } + + private companion object { + val SEARCHED = listOf(10, 12) + val VIALS = listOf("ethenea", "liquid_honey", "sulphuric_broline") + } +} diff --git a/game/src/main/kotlin/content/entity/obj/door/Door.kt b/game/src/main/kotlin/content/entity/obj/door/Door.kt index 8e6651e8ed..53e2801496 100644 --- a/game/src/main/kotlin/content/entity/obj/door/Door.kt +++ b/game/src/main/kotlin/content/entity/obj/door/Door.kt @@ -107,7 +107,7 @@ object Door { areaSound(soundName(definition, suffix), obj.tile) } - private fun soundName(definition: ObjectDefinition, suffix: String): String { + internal fun soundName(definition: ObjectDefinition, suffix: String): String { val type = if (definition.isGate()) "gate" else "door" val material = definition["material", ""] if (material.isEmpty()) { diff --git a/game/src/main/kotlin/content/entity/obj/door/Gate.kt b/game/src/main/kotlin/content/entity/obj/door/Gate.kt index 79513a0966..4028a1533c 100644 --- a/game/src/main/kotlin/content/entity/obj/door/Gate.kt +++ b/game/src/main/kotlin/content/entity/obj/door/Gate.kt @@ -3,7 +3,9 @@ package content.entity.obj.door import content.entity.obj.Replace import content.entity.obj.door.Door.rotation import world.gregs.voidps.cache.definition.data.ObjectDefinition +import world.gregs.voidps.engine.entity.character.areaSound import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.entity.character.sound import world.gregs.voidps.engine.entity.obj.GameObject object Gate { @@ -42,5 +44,40 @@ object Gate { ) } + fun replaceTogether( + player: Player, + obj: GameObject, + double: GameObject, + flip: Boolean, + ticks: Int, + collision: Boolean, + current: String, + next: String, + objRotation: Int, + hingeTileRotation: Int, + onRevert: (() -> Unit)? = null, + ) { + val first = if (flip) double else obj + val second = if (flip) obj else double + val definition = first.def(player) + val tile = Door.tile(first, hingeTileRotation) + val suffix = if (next.endsWith("opened")) "open" else "close" + val opposite = if (suffix == "open") "close" else "open" + Replace.objects( + first, + definition.stringId.replace(current, next), + tile, + first.rotation(objRotation), + second, + second.def(player).stringId.replace(current, next), + second.tile.add(tile.delta(first.tile)), + second.rotation(-objRotation), + ticks, + collision = collision, + onRevert = onRevert ?: { areaSound(Door.soundName(definition, opposite), first.tile) }, + ) + player.sound(Door.soundName(definition, suffix)) + } + fun ObjectDefinition.isGate() = name.contains("gate", true) && this["gate", true] } diff --git a/game/src/main/kotlin/content/quest/Quest.kt b/game/src/main/kotlin/content/quest/Quest.kt index 9a1acb138a..a7eecb55d1 100644 --- a/game/src/main/kotlin/content/quest/Quest.kt +++ b/game/src/main/kotlin/content/quest/Quest.kt @@ -22,6 +22,7 @@ val quests = setOf( "imp_catcher", "pirates_treasure", // members + "biohazard", "creature_of_fenkenstrain", "druidic_ritual", "hand_in_the_sand", diff --git a/game/src/main/kotlin/content/quest/QuestCommands.kt b/game/src/main/kotlin/content/quest/QuestCommands.kt index 0de19689fa..d5cb2cd856 100644 --- a/game/src/main/kotlin/content/quest/QuestCommands.kt +++ b/game/src/main/kotlin/content/quest/QuestCommands.kt @@ -115,9 +115,21 @@ class QuestCommands : Script { } } + val variables = quest["prep_vars", emptyMap()] + var set = 0 + for ((variable, value) in variables) { + if (VariableDefinitions.get(variable) == null) { + player.message("Unknown variable '$variable' in ${quest.stringId} prep list.") + continue + } + player[variable] = value + set++ + } + player.message( "Prepared for ${quest["name", questId]}: ${skills.size} skills, " + - "${quests.size} ${if (quests.size == 1) "quest" else "quests"}, $given items.", + "${quests.size} ${if (quests.size == 1) "quest" else "quests"}, $given items, " + + "$set ${if (set == 1) "variable" else "variables"}.", ) } } diff --git a/game/src/main/kotlin/content/quest/member/biohazard/Biohazard.kt b/game/src/main/kotlin/content/quest/member/biohazard/Biohazard.kt new file mode 100644 index 0000000000..06d2ce0c1f --- /dev/null +++ b/game/src/main/kotlin/content/quest/member/biohazard/Biohazard.kt @@ -0,0 +1,460 @@ +package content.quest.member.biohazard + +import content.entity.obj.door.Gate +import content.entity.obj.door.enterDoor +import content.entity.player.bank.ownsItem +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.Scared +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.entity.proj.shoot +import content.quest.questCompleted +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.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.equip.equipped +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.inv.carriesItem +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.engine.inv.remove +import world.gregs.voidps.engine.inv.removeToLimit +import world.gregs.voidps.engine.inv.replace +import world.gregs.voidps.network.login.protocol.visual.update.player.EquipSlot +import world.gregs.voidps.type.Direction +import world.gregs.voidps.type.Tile + +class Biohazard : Script { + + init { + questJournalOpen("biohazard") { + val stage = questStage("biohazard") + questJournal("Biohazard", if (stage == 0) notStartedJournal() else startedJournal(stage)) + } + + objectOperate("Investigate", "bio_watchtower_fence") { + val mourner = NPCs.findOrNull(tile.regionLevel, "mourner_vial_4") ?: return@objectOperate + talkWith(mourner) + npc("Keep away civilian.") + player("What's it to you?") + npc("This tower is here for your protection.") + } + + itemOnObjectOperate("bird_feed", "bio_watchtower_fence") { + throwBirdFeed() + } + + itemOption("Open", "pigeon_cage_full") { + releasePigeons() + } + + objectOperate("Squeeze-through", "mourner_stew_fence") { (target) -> + squeezeFence(target) + } + + itemOnObjectOperate("rotten_apple", "mourner_stew_cauldron") { + poisonStew() + } + + itemOption("Eat", "rotten_apple") { (_, slot) -> + if (!inventory.remove(slot, "rotten_apple")) { + return@itemOption + } + message("You eat an apple...") + player("Yuck!") + message("...but it's rotten so you spit it out.") + } + + objectOperate("Open", "mourner_stew_door_closed") { (target) -> + openStewDoor(target) + } + + objectOperate("Open", "mourner_stew_door_up_closed") { (target) -> + if (tile.x >= target.tile.x || equipped(EquipSlot.Chest).id == "doctors_gown") { + enterDoor(target) + return@objectOperate + } + message("The mourner is refusing to open the door.") + } + + objectOperate("Open", "mourner_quarters_gate_left_closed,mourner_quarters_gate_right_closed") { (target) -> + if (tile.x < target.tile.x && !inventory.contains("key_biohazard")) { + message("The gate is locked.") + sound("locked") + delay(1) + message("You need a key.") + return@objectOperate + } + unlockGate(target) + } + + itemOnObjectOperate("key_biohazard", "mourner_quarters_gate_left_closed,mourner_quarters_gate_right_closed") { (target) -> + unlockGate(target) + } + + objectOperate("Search", "mourner_crate_up") { + searchDistillatorCrate() + } + + objectOperate("Search", "crate_272,crate_273") { + message("You search the crate but find nothing.") + } + + teleportLand("*") { + destroyPlagueSample() + } + + npcOperate("Talk-to", "mourner_1") { (target) -> + if (questStage("biohazard") !in FOOD_POISONED) { + npc("How did you get in here? This is a restricted area!") + target.interactPlayer(this, "Attack") + return@npcOperate + } + player("Hello.") + npc("Hello Doc, I feel terrible. I think it was the stew.") + player("Be more careful with your ingredients next time.") + } + + npcOperate("Talk-to", "mourner_3") { + if (questStage("biohazard") !in FOOD_POISONED) { + npc("Who are you? Go away!") + return@npcOperate + } + if (equipped(EquipSlot.Chest).id != "doctors_gown") { + player("Hello there.") + npc("Oh dear oh dear. I feel terrible, I think it was the stew.") + player("You should be more careful with your ingredients.") + npc("I need a doctor. The nurse's hut is to the south west. Go now and bring us a doctor, that's an order.") + return@npcOperate + } + player("Hello.") + npc("Hello Doc, I feel terrible. I think it was the stew.") + player("Be more careful with your ingredients next time.") + npc("There is one mourner, who's really sick, resting upstairs. You should see to him first.") + player("Ok, I'll see what I can do.") + } + + npcOperate("Talk-to", "mourner_2") { (target) -> + sickMourner(target) + } + } + + private suspend fun Player.throwBirdFeed() { + if (questStage("biohazard") != 2) { + message("Nothing interesting happens.") + return + } + if (!inventory.remove("bird_feed")) { + return + } + set("biohazard", "birdfeed_thrown") + message("You throw a handful of seeds onto the watchtower.") + delay(2) + message("The mourners do not seem to notice.") + } + + private suspend fun Player.releasePigeons() { + if (questStage("biohazard") != 3 || tile.x !in TOWER_X || tile.y !in TOWER_Y) { + message("The pigeons don't want to leave.") + return + } + if (!inventory.replace("pigeon_cage_full", "pigeon_cage_empty")) { + return + } + set("biohazard", "pigeons_released") + for (tile in PIGEON_TILES) { + shoot("biopigeon_launch", tile, delay = 5, flightTime = 126, height = 16, endHeight = 125, curve = 20, offset = 10) + } + message("The pigeons fly towards the watchtower.") + delay(2) + message("The mourners are frantically trying to scare the pigeons away.") + } + + private suspend fun Player.squeezeFence(fence: GameObject) { + val enter = tile.x <= fence.tile.x + val direction = if (enter) Direction.EAST else Direction.WEST + walkToDelay(Tile(if (enter) FENCE_WEST else FENCE_EAST, fence.tile.y)) + face(direction) + message("You squeeze through the fence.") + anim("regicide_tightfit") + exactMoveDelay(Tile(if (enter) FENCE_EAST else FENCE_WEST, fence.tile.y), delay = 76, direction = direction) + } + + private suspend fun Player.poisonStew() { + if (!inventory.remove("rotten_apple")) { + return + } + message("You place the rotten apple in the pot...") + delay(4) + if (questStage("biohazard") == 5) { + message("and it quickly dissolves into the stew.") + set("biohazard", "poisoned_stew") + delay(4) + } + message("That wasn't very nice.") + } + + private suspend fun Player.openStewDoor(door: GameObject) { + val southDoor = door.tile.y == STEW_DOOR_SOUTH_Y + val enter = if (southDoor) tile.y <= door.tile.y else tile.y >= door.tile.y + if (!enter) { + enterDoor(door) + return + } + if (questStage("biohazard") !in FOOD_POISONED) { + statement("The door is locked. You can hear the mourners eating... You need to distract them from their stew.") + return + } + if (equipped(EquipSlot.Chest).id == "doctors_gown") { + npc("mourner_armed_vis", "In you go doc.") + enterDoor(door) + return + } + npc("mourner_armed_vis", "Stay away from there.") + player("Why?") + npc("mourner_armed_vis", "Several mourners are ill with food poisoning, we're waiting for a doctor.") + } + + private fun Player.destroyPlagueSample() { + val amount = inventory.count("plague_sample") + if (amount <= 0) { + return + } + inventory.removeToLimit("plague_sample", amount) + message("The Plague Sample is fragile and is destroyed in the crossing.") + } + + private suspend fun Player.unlockGate(gate: GameObject) { + val entering = tile.x < gate.tile.x + val row = if (tile.y == QUARTERS_GATE_LEFT.y || tile.y == QUARTERS_GATE_RIGHT.y) tile.y else gate.tile.y + val near = Tile(if (entering) QUARTERS_GATE_LEFT.x - 1 else QUARTERS_GATE_LEFT.x, row, 1) + val far = Tile(if (entering) QUARTERS_GATE_LEFT.x else QUARTERS_GATE_LEFT.x - 1, row, 1) + if (tile != near) { + walkOverDelay(near) + } + if (entering && inventory.contains("key_biohazard")) { + message("The key fits the gate.") + delay(1) + } + val left = GameObjects.findOrNull(QUARTERS_GATE_LEFT, "mourner_quarters_gate_left_closed") + val right = GameObjects.findOrNull(QUARTERS_GATE_RIGHT, "mourner_quarters_gate_right_closed") + if (left == null || right == null) { + walkOverDelay(far) + return + } + Gate.replaceTogether( + this, left, right, + flip = false, + ticks = GATE_TICKS, + collision = false, + current = "_closed", + next = "_opened", + objRotation = 3, + hingeTileRotation = 1, + ) + walkOverDelay(far) + } + + private suspend fun Player.searchDistillatorCrate() { + message("You search the crate...") + delay(4) + if (ownsItem("distillator") || questStage("biohazard") < 6) { + message("It's empty.") + return + } + if (questStage("biohazard") == 6) { + set("biohazard", "found_distillator") + } + addOrDrop("distillator") + message("and find Elena's distillator.") + } + + private suspend fun Player.sickMourner(mourner: NPC) { + if (questStage("biohazard") != 6) { + message("The mourner is sick.") + delay(4) + message("He doesn't feel like talking.") + return + } + if (equipped(EquipSlot.Chest).id != "doctors_gown") { + message("The mourner doesn't feel like talking.") + return + } + player("Hello there.") + npc("You're here at last! I don't know what I've eaten but I feel like I'm on death's door.") + player("Hmm... interesting, sounds like food poisoning.") + npc("Yes, I'd figured that out already. What can you give me to help.") + choice { + holdBreath(mourner) + prayForYou(mourner) + fatal(mourner) + } + } + + private fun ChoiceOption.holdBreath(mourner: NPC): Unit = option("Just hold your breath and count to ten.") { + npc("What? How will that help? What kind of doctor are you?") + player("Erm... I'm new, I just started.") + unmasked(mourner) + } + + private fun ChoiceOption.prayForYou(mourner: NPC): Unit = option("The best I can do is pray for you.") { + npc("Pray for me? You're not a doctor... You're an imposter!") + mourner.interactPlayer(this, "Attack") + } + + private fun ChoiceOption.fatal(mourner: NPC): Unit = option("There's nothing I can do, it's fatal.") { + npc("No, I'm too young to die! I've never even had a girlfriend.") + player("That's life for you.") + npc("Wait a minute, where's your equipment?") + player("It's erm... at home.") + unmasked(mourner) + } + + private suspend fun Player.unmasked(mourner: NPC) { + npc("You're no doctor!") + mourner.interactPlayer(this, "Attack") + } + + private fun Player.notStartedJournal(): List { + val plague = questCompleted("plague_city") + return listOf( + "I can start this quest by speaking to Elena who is in East", + "Ardougne.", + "", + "Requirements.", + if (plague) "I need to complete Plague City before I can attempt this" else "I need to complete Plague City before I can attempt this", + if (plague) "quest." else "quest.", + ) + } + + private fun Player.startedJournal(stage: Int): List { + val lines = mutableListOf() + lines += "I've spoken to Elena, the Mourners stole her distillator." + lines += "" + if (stage < 2) { + lines += "I need to talk to Jerico about getting over the wall and into" + lines += "West Ardougne." + return lines + } + lines += "I've spoken to Jerico about getting into West Ardougne." + if (stage < 5) { + lines += "" + lines += watchtowerJournal(stage) + return lines + } + lines += "I've crossed the wall into West Ardougne." + lines += "Omart and Kilron will stay to help me out again." + lines += "" + if (stage < 7) { + lines += "Somewhere in the city is Elena's distillator - I must find it" + lines += "and return it to her." + if (stage == 6) { + lines += "" + lines += "I have rather unkindly poisoned the mourners' stew by" + lines += "putting rotten apples into it!" + lines += "At least the mourners aren't very nice people, so I don't" + lines += "feel bad about this." + } + return lines + } + if (stage == 7) { + lines += "I managed to find Elena's distillator." + lines += "" + if (carriesItem("distillator")) { + lines += "Now I can return the distillator to Elena." + } else { + lines += "I seem to have mislaid the distillator" + lines += "I had better try to find another, or Elena will be upset." + } + return lines + } + if (stage == 10) { + lines += "I've found Elena's distillator and returned it to her." + lines += "" + lines += deliveryJournal() + return lines + } + lines += "Elena gave me some chemicals to take to Guidor." + lines += "" + if (stage < 14) { + lines += "The Varrock guards are looking out for someone carrying" + lines += "suspicious materials. I must smuggle the items to Guidor" + lines += "secretly." + return lines + } + lines += "I've given all the items to Guidor." + lines += "" + if (stage == 14) { + lines += "Guidor's findings were very interesting." + lines += "Apparently there is no Plague!" + lines += "I should see what Elena thinks of this." + return lines + } + lines += "I've told Elena of Guidor's findings, she was horrified!" + lines += "" + if (stage == 15) { + lines += "I need to confront the King of East Ardougne." + return lines + } + lines += "I've spoken to King Lathas, he admits the Plague was fake!" + lines += "I have been given permission to use the Royal Training" + lines += "Grounds which are located in North West of Ardougne." + lines += "QUEST COMPLETE!" + return lines + } + + private fun watchtowerJournal(stage: Int): List = when (stage) { + 2 -> listOf( + "Omart will be able to get me over the wall.", + "He's waiting at the South end of the wall.", + ) + 3 -> listOf("I've chucked some birdfeed onto the Watch Tower.") + else -> listOf( + "I've chucked some birdfeed onto the Watch Tower.", + "The Watch Tower is now surrounded by flapping pigeons!", + "Maybe I can sneak over the wall while the mourners are", + "distracted.", + ) + } + + private fun Player.deliveryJournal(): List = listOf( + "Elena's asked me to take the following items to Guidor who", + "lives in Varrock.", + if (carriesItem("plague_sample")) "I have the Plague Sample." else "Plague Sample from Elena.", + if (carriesItem("liquid_honey")) "I have the Liquid Honey." else "Liquid Honey", + if (carriesItem("sulphuric_broline")) "I have the Sulphuric Broline." else "Sulphuric Broline", + if (carriesItem("ethenea")) "I have the Ethenea." else "Ethenea", + if (carriesItem("touch_paper")) "I have the Touch Paper." else "Touch Paper from the Chemist in Rimmington.", + ) + + private companion object { + val FOOD_POISONED = 6..7 + val TOWER_X = 2559..2563 + val TOWER_Y = 3300..3307 + + val QUARTERS_GATE_LEFT = Tile(2552, 3325, 1) + val QUARTERS_GATE_RIGHT = Tile(2552, 3326, 1) + const val GATE_TICKS = 3 + val PIGEON_TILES = listOf(Tile(2560, 3304), Tile(2561, 3303), Tile(2561, 3305)) + const val FENCE_WEST = 2541 + const val FENCE_EAST = 2542 + const val STEW_DOOR_SOUTH_Y = 3320 + } +} diff --git a/game/src/main/kotlin/content/quest/member/biohazard/ErrandBoys.kt b/game/src/main/kotlin/content/quest/member/biohazard/ErrandBoys.kt new file mode 100644 index 0000000000..245392dd5c --- /dev/null +++ b/game/src/main/kotlin/content/quest/member/biohazard/ErrandBoys.kt @@ -0,0 +1,211 @@ +package content.quest.member.biohazard + +import content.entity.player.dialogue.Angry +import content.entity.player.dialogue.Confused +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.entity.player.dialogue.type.statement +import content.entity.player.inv.item.addOrDrop +import content.quest.questStage +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.client.message +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.engine.inv.remove + +class ErrandBoys : Script { + + init { + npcOperate("Talk-to", "da_vinci_rimmington") { + if (busy(DA_VINCI, "Oh, it's you again. Please don't distract me now, I'm contemplating the sublime.", "Da Vinci does not feel sufficiently moved to talk.")) { + return@npcOperate + } + player("Hello, I hear you're an errand boy for the chemist.") + npc("Well that's my job yes. But I don't necessarily define my identity in such black and white terms.") + player("Good for you. Now can you take a vial to Varrock for me?") + npc("Go on then.") + offerVials(DA_VINCI, "ethenea", "painted") { + player("Ok, we're meeting at the Dancing Donkey in Varrock right?") + npc("That's right.") + } + } + + npcOperate("Talk-to", "chancy_rimmington") { + if (busy(CHANCY, "Look, I've got your vial but I'm not taking two. I always like to play the percentages.", "Chancy doesn't feel like talking.")) { + return@npcOperate + } + player("Hello, I've got a vial for you to take to Varrock.") + npc("Tssch... that chemist asks for a lot for the wages he pays.") + player("Maybe you should ask him for more money.") + npc("Nah... I just use my initiative here and there.") + offerVials(CHANCY, "liquid_honey", "gambled") { + player("Right. I'll see you later in the Dancing Donkey Inn.") + npc("Be lucky.") + } + } + + npcOperate("Talk-to", "hops_rimmington") { + if (busy(HOPS, "I suppose I'd better get going. I'll meet you at the Dancing Donkey Inn.", "He isn't in a fit state to talk.")) { + return@npcOperate + } + player("Hi, I've got something for you to take to Varrock.") + npc("Sounds like pretty thirsty work.") + player("Well, there's an Inn in Varrock if you're desperate.") + npc("Don't worry, I'm a pretty resourceful fellow you know.") + offerVials(HOPS, "sulphuric_broline", "drank") { + player("Ok, I'll see you in Varrock.") + npc("Sure, I'm a regular at the Dancing Donkey Inn as it happens.") + } + } + + npcOperate("Talk-to", "da_vinci") { + if (!waiting(DA_VINCI, "Da Vinci does not feel sufficiently moved to talk.")) { + return@npcOperate + } + npc("Hello again. I hope your journey was as pleasant as mine.") + if (get(DA_VINCI, "none") == "delivered") { + player("Well, as they say, it's always sunny in RuneScape.") + npc("Ok, here it is.") + handBack(DA_VINCI, "ethenea", "He gives you the vial of ethenea.") + player("Thanks, you've been a big help.") + return@npcOperate + } + player("Yep. Anyway, I'll take the package off you now.") + npc("Package? That's a funny way to describe a liquid of such exquisite beauty!") + set(DA_VINCI, "none") + choice { + badFeeling() + giveMeTheStuff() + } + } + + npcOperate("Talk-to", "chancy") { + if (!waiting(CHANCY, "Chancy doesn't feel like talking.")) { + return@npcOperate + } + player("Hi, thanks for doing that.") + if (get(CHANCY, "none") == "delivered") { + npc("No problem.") + handBack(CHANCY, "liquid_honey", "He gives you the vial of liquid honey.") + npc("Next time give me something more valuable... I couldn't get anything for this on the blackmarket.") + player("That was the idea.") + return@npcOperate + } + npc("No problem. I've got some money for you actually.") + player("What do you mean?") + npc("Well, it turns out that potion you gave me, was quite valuable...") + player("What?") + npc("I know that I probably shouldn't have sold it... but some friends and I were having a little wager, the odds were just too good!") + player("You sold my vial and gambled with the money?!") + npc("Actually yes... but praise be to Saradomin because I won! So all's well that ends well right?") + set(CHANCY, "none") + choice { + nothingFurther() + noIdea() + } + } + + npcOperate("Talk-to", "hops") { + if (!waiting(HOPS, "He isn't in a fit state to talk.")) { + return@npcOperate + } + player("Hello, how was your journey?") + npc("Pretty thirst-inducing actually...") + player("Please tell me that you haven't drunk the contents...") + if (get(HOPS, "none") == "delivered") { + npc("Oh the gods no! What do you take me for?") + npc("Here's your vial anyway.") + handBack(HOPS, "sulphuric_broline", "He gives you the vial of sulphuric broline.") + player("Thanks, I'll let you get your drink now.") + return@npcOperate + } + npc("Of course I can tell you that I haven't drunk the contents...") + npc("But I'd be lying. Sorry about that me old mucker, can I get you a drink.") + set(HOPS, "none") + player("No I think you've done enough for now.") + } + } + + private suspend fun Player.busy(errand: String, carrying: String, silent: String): Boolean { + if (get(errand, "none") != "none") { + npc(carrying) + return true + } + if (questStage("biohazard") != 12) { + message(silent) + return true + } + return false + } + + private fun Player.waiting(errand: String, silent: String): Boolean { + if (questStage("biohazard") != 12 || get(errand, "none") == "none") { + message(silent) + return false + } + return true + } + + private suspend fun Player.offerVials(errand: String, wanted: String, wasted: String, farewell: suspend Player.() -> Unit) { + choice { + for (vial in VIALS) { + option("You give him the vial of ${vial.replace('_', ' ')}...") { + if (!inventory.remove(vial)) { + statement("You can't give him what you don't have.") + return@option + } + set(errand, if (vial == wanted) "delivered" else wasted) + message("You give him the vial of ${vial.replace('_', ' ')}.") + farewell() + } + } + } + } + + private fun Player.handBack(errand: String, vial: String, text: String) { + set(errand, "none") + addOrDrop(vial) + message(text) + } + + private fun ChoiceOption.badFeeling(): Unit = option("I'm getting a bad feeling about this.") { + player("I'm getting a bad feeling about this. You do still have it don't you?") + paintedIt() + } + + private fun ChoiceOption.giveMeTheStuff(): Unit = option("Just give me the stuff now please.") { + player("Just give me the stuff now please. You do still have it don't you?") + paintedIt() + } + + private suspend fun Player.paintedIt() { + npc("Absolutely. It's just not stored in a vial anymore.") + player("What?") + npc("Instead it has been liberated. It now gleams from the canvas of my latest epic: The Majesty of Varrock!") + player("That's great. Thanks to you I'll have to walk back to East Ardougne to get another vial.") + npc("Well you can't put a price on art.") + } + + private fun ChoiceOption.nothingFurther(): Unit = option("No! Nothing could be further from the truth!") { + npc("Well, there's no pleasing some people.") + } + + private fun ChoiceOption.noIdea(): Unit = option("You have no idea what you have just done!") { + npc("Ignorance is bliss I'm afraid.") + } + + private companion object { + const val DA_VINCI = "biohazard_davinci_errand" + const val CHANCY = "biohazard_chancy_errand" + const val HOPS = "biohazard_hops_errand" + val VIALS = listOf("ethenea", "liquid_honey", "sulphuric_broline") + } +} diff --git a/game/src/main/kotlin/content/quest/member/biohazard/RopeLadder.kt b/game/src/main/kotlin/content/quest/member/biohazard/RopeLadder.kt new file mode 100644 index 0000000000..5925d4b6ba --- /dev/null +++ b/game/src/main/kotlin/content/quest/member/biohazard/RopeLadder.kt @@ -0,0 +1,115 @@ +package content.quest.member.biohazard + +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.choice +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.message +import world.gregs.voidps.engine.entity.character.move.tele +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.entity.obj.GameObjects +import world.gregs.voidps.engine.entity.obj.replace +import world.gregs.voidps.type.Tile + +class RopeLadder : Script { + + init { + npcOperate("Talk-to", "omart_west_ardougne") { + when (questStage("biohazard")) { + 0, 1 -> { + player("Hello there.") + npc("Hello.") + player("How are you?") + npc("Fine thanks.") + } + 2, 3 -> { + player("Omart, Jerico said you might be able to help me.") + npc("He informed me of your problem traveller. I would be glad to help, I have a rope ladder and my associate, Kilron, is waiting on the other side.") + player("Good stuff.") + npc("Unfortunately we can't risk it with the watch tower so close. So first we need to distract the guards in the tower.") + player("How?") + npc("Try asking Jerico, if he's not too busy with his pigeons. I'll be waiting here for you.") + } + 4 -> throwLadder() + in 5..15 -> { + player("Hello Omart.") + npc("Hello traveller. The guards are still distracted if you wish to cross the wall.") + crossChoice(west = true) + } + else -> { + player("Hello Omart.") + npc("Hello adventurer. I'm afraid it's too risky to use the ladder again, but I believe Edmond's working on another tunnel.") + } + } + } + + npcOperate("Talk-to", "kilron_west_ardougne") { + if (questStage("biohazard") < 5) { + player("Hello there.") + npc("Hello.") + player("How are you?") + npc("Busy.") + return@npcOperate + } + player("Hello Kilron.") + npc("Hello traveller. Do you need to go back over?") + choice { + option("Not yet Kilron.") { + npc("Okay, just give me the word.") + } + option("Yes I do.") { + npc("Quickly now!") + climbLadder(west = false) + } + } + } + } + + private suspend fun Player.throwLadder() { + npc("Well done, the guards are having real trouble with those birds. You must go now traveller, it's your only chance.") + message("Omart calls to his associate.") + delay(2) + npc("Kilron!") + message("He throws one end of the rope ladder over the wall.") + delay(2) + npc("You must go now traveller.") + crossChoice(west = true) + } + + private suspend fun Player.crossChoice(west: Boolean) { + choice { + option("Ok, let's do it.") { + climbLadder(west) + } + option("I'll be back soon.") { + npc("Don't take too long, those mourners will soon be rid of those birds.") + } + } + } + + private suspend fun Player.climbLadder(west: Boolean) { + for (tile in WALL_TILES) { + GameObjects.findOrNull(tile, "bio_ladder_wall")?.replace("bio_ladder", ticks = LADDER_TICKS) + } + if (questStage("biohazard") == 4) { + set("biohazard", "crossed_wall") + } + delay(1) + message("You climb up the rope ladder...") + delay(2) + tele(if (west) WEST_LANDING else EAST_LANDING) + message("and drop down on the other side.") + } + + private companion object { + val WALL_TILES = listOf(Tile(2557, 3267), Tile(2558, 3267)) + val WEST_LANDING = Tile(2556, 3267) + val EAST_LANDING = Tile(2559, 3267) + const val LADDER_TICKS = 5 + } +} diff --git a/game/src/test/kotlin/content/quest/QuestCommandsTest.kt b/game/src/test/kotlin/content/quest/QuestCommandsTest.kt index e787b6659e..f40d61d931 100644 --- a/game/src/test/kotlin/content/quest/QuestCommandsTest.kt +++ b/game/src/test/kotlin/content/quest/QuestCommandsTest.kt @@ -83,6 +83,20 @@ class QuestCommandsTest : WorldTest() { assertFalse(admin["met_polmafi", false]) } + @Test + fun `Questprep sets up the world state a quest inherits from its prerequisites`() { + val admin = createPlayer(name = "prep admin biohazard") + admin.rights = PlayerRights.Admin + + runTest { Commands.call(admin, "questprep biohazard") } + tick() + + assertTrue(admin.questCompleted("plague_city")) + assertTrue(admin.inventory.contains("priest_gown_top")) + assertTrue(admin.inventory.contains("priest_gown_bottom")) + assertTrue(admin["plaguecity_elena_at_home", false], "Elena should be home to start the quest") + } + @Test fun `Questprep never lowers a skill the player has already trained`() { val admin = createPlayer(name = "prep admin2") diff --git a/game/src/test/kotlin/content/quest/member/biohazard/BiohazardMournerTest.kt b/game/src/test/kotlin/content/quest/member/biohazard/BiohazardMournerTest.kt new file mode 100644 index 0000000000..fcd24595e1 --- /dev/null +++ b/game/src/test/kotlin/content/quest/member/biohazard/BiohazardMournerTest.kt @@ -0,0 +1,80 @@ +package content.quest.member.biohazard + +import WorldTest +import content.entity.combat.hit.damage +import org.junit.jupiter.api.Test +import world.gregs.voidps.engine.data.definition.AnimationDefinitions +import world.gregs.voidps.engine.data.definition.CombatDefinitions +import world.gregs.voidps.engine.data.definition.SoundDefinitions +import world.gregs.voidps.engine.entity.character.npc.NPCs +import world.gregs.voidps.engine.entity.item.floor.FloorItems +import world.gregs.voidps.engine.get +import world.gregs.voidps.type.Tile +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class BiohazardMournerTest : WorldTest() { + + override var loadNpcs: Boolean = true + + private val deathTicks = 20 + + private val respawnTicks = 24 + + @Test + fun `The mourners the world spawns upstairs are the ones carrying the key`() { + val player = createPlayer(Tile(2551, 3325, 1)) + player["biohazard"] = "poisoned_stew" + val mourner = NPCs.findOrNull(Tile(2551, 3324, 1), "mourner_2") + assertNotNull(mourner, "a mourner should spawn upstairs in the headquarters") + + mourner.damage(5000, source = player) + tick(deathTicks) + + val keys = FloorItems.at(mourner.tile.zone).flatten().count { it.id == "key_biohazard" } + assertEquals(1, keys, "the spawned mourner should drop a key") + } + + @Test + fun `A killed mourner comes back`() { + val player = createPlayer(Tile(2551, 3325, 1)) + player["biohazard"] = "poisoned_stew" + val mourner = NPCs.findOrNull(Tile(2551, 3324, 1), "mourner_2") + assertNotNull(mourner) + + mourner.damage(5000, source = player) + tick(deathTicks) + assertTrue(mourner.hide, "the body should be gone") + + tick(respawnTicks) + + assertFalse(mourner.hide, "and another mourner should take its place") + assertEquals(Tile(2551, 3324, 1), mourner.tile) + } + + @Test + fun `Mourners fight with their own animations and sounds`() { + val sounds: SoundDefinitions = get() + val definition = get().getOrNull("mourner") + assertNotNull(definition, "the mourners need a combat definition to fight with") + + assertEquals("mourner_defend", definition.defendAnim) + assertEquals("mourner_defend", definition.defendSound?.id) + assertEquals("mourner_death", definition.deathAnim) + assertEquals("mourner_death", definition.deathSound?.id) + assertEquals(424, AnimationDefinitions.get("mourner_defend").id) + assertEquals(836, AnimationDefinitions.get("mourner_death").id) + assertEquals(513, sounds.get("mourner_defend").id) + assertEquals(512, sounds.get("mourner_death").id) + + val melee = definition.attacks["melee"] + assertNotNull(melee, "the mourners need a melee attack") + assertEquals("mourner_attack", melee.anim) + assertEquals(422, AnimationDefinitions.get("mourner_attack").id) + assertTrue(melee.sounds.isEmpty(), "mourners punch silently") + assertEquals("crush", melee.targetHits.first().offense) + assertEquals(20, melee.targetHits.first().max) + } +} diff --git a/game/src/test/kotlin/content/quest/member/biohazard/BiohazardTest.kt b/game/src/test/kotlin/content/quest/member/biohazard/BiohazardTest.kt new file mode 100644 index 0000000000..5c2b27c62c --- /dev/null +++ b/game/src/test/kotlin/content/quest/member/biohazard/BiohazardTest.kt @@ -0,0 +1,999 @@ +package content.quest.member.biohazard + +import WorldTest +import containsMessage +import content.entity.combat.hit.damage +import dialogueOption +import equipItem +import io.mockk.mockkObject +import io.mockk.unmockkObject +import io.mockk.verify +import itemOnObject +import itemOption +import kotlinx.coroutines.test.runTest +import npcOption +import objectOption +import org.junit.jupiter.api.Test +import skipDialogues +import world.gregs.voidps.engine.client.command.Commands +import world.gregs.voidps.engine.client.ui.InterfaceApi +import world.gregs.voidps.engine.client.ui.dialogue +import world.gregs.voidps.engine.client.update.batch.ZoneBatchUpdates +import world.gregs.voidps.engine.entity.character.mode.EmptyMode +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.player.Player +import world.gregs.voidps.engine.entity.character.player.PlayerRights +import world.gregs.voidps.engine.entity.character.player.Teleport +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.item.floor.FloorItems +import world.gregs.voidps.engine.entity.obj.GameObjects +import world.gregs.voidps.engine.inv.add +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.network.login.protocol.encode.zone.SoundAddition +import world.gregs.voidps.network.login.protocol.encode.zone.ZoneUpdate +import world.gregs.voidps.type.Tile +import world.gregs.voidps.type.Zone +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class BiohazardTest : WorldTest() { + + @Test + fun `Elena sets the player looking for her distillator`() { + val player = createPlayer(Tile(2592, 3335)) + player["plague_city"] = "completed" + val elena = createNPC("elena2_vis", Tile(2592, 3336)) + + player.npcOption(elena, "Talk-to") + tick(2) + player.skipDialogues() + player.dialogueOption("line1") + player.skipDialogues() + + assertEquals("started", player["biohazard", "unstarted"]) + assertFalse(player["plaguecity_dug_mud_pile", false], "the mourners fill Edmond's tunnel back in") + } + + @Test + fun `Elena takes no for an answer`() { + val player = createPlayer(Tile(2592, 3335)) + player["plague_city"] = "completed" + val elena = createNPC("elena2_vis", Tile(2592, 3336)) + + player.npcOption(elena, "Talk-to") + tick(2) + player.skipDialogues() + player.dialogueOption("line2") + player.skipDialogues() + + assertEquals("unstarted", player["biohazard", "unstarted"]) + } + + @Test + fun `Questprep leaves a player able to walk up to Elena and start`() { + val player = createPlayer(Tile(2592, 3335), name = "biohazard prep") + player.rights = PlayerRights.Admin + runTest { Commands.call(player, "questprep biohazard") } + tick() + + val elena = createNPC("elena2", Tile(2592, 3336)) + player.npcOption(elena, "Talk-to") + tick(2) + player.skipDialogues() + player.dialogueOption("line1") + player.skipDialogues() + + assertEquals("started", player["biohazard", "unstarted"]) + } + + @Test + fun `Elena won't start the quest before Plague City is finished`() { + val player = createPlayer(Tile(2592, 3335)) + val elena = createNPC("elena2_vis", Tile(2592, 3336)) + + player.npcOption(elena, "Talk-to") + tick(2) + player.skipDialogues() + + assertEquals("unstarted", player["biohazard", "unstarted"]) + } + + @Test + fun `Jerico arranges the rope ladder and stocks the bird feed`() { + val player = createPlayer(Tile(2612, 3325)) + player["biohazard"] = "started" + val jerico = createNPC("jerico", Tile(2612, 3324)) + + player.npcOption(jerico, "Talk-to") + tick(2) + player.skipDialogues() + assertEquals("spoke_to_jerico", player["biohazard", "unstarted"]) + + player.tele(2611, 3325) + val cupboard = GameObjects.find(Tile(2611, 3326), "jericos_cupboard_shut") + player.objectOption(cupboard, "Open") + tick(2) + val open = GameObjects.find(Tile(2611, 3326), "jericos_cupboard_open") + player.objectOption(open, "Search") + tick(2) + player.skipDialogues() + + assertEquals(1, player.inventory.count("bird_feed")) + } + + @Test + fun `Bird feed thrown on the watchtower goes unnoticed on its own`() { + val player = createPlayer(Tile(2560, 3301)) + player["biohazard"] = "spoke_to_jerico" + player.inventory.add("bird_feed") + val fence = GameObjects.find(Tile(2560, 3301), "bio_watchtower_fence") + + player.itemOnObject(fence, player.inventory.indexOf("bird_feed")) + tick(6) + + assertEquals("birdfeed_thrown", player["biohazard", "unstarted"]) + assertEquals(0, player.inventory.count("bird_feed")) + assertTrue(player.containsMessage("You throw a handful of seeds onto the watchtower.")) + assertTrue(player.containsMessage("The mourners do not seem to notice.")) + } + + @Test + fun `Pigeons let out beside the seeded tower swarm the mourners`() { + val player = createPlayer(Tile(2560, 3301)) + player["biohazard"] = "birdfeed_thrown" + player.inventory.add("pigeon_cage_full") + + player.itemOption("Open", "pigeon_cage_full") + tick(6) + + assertEquals("pigeons_released", player["biohazard", "unstarted"]) + assertEquals(0, player.inventory.count("pigeon_cage_full")) + assertEquals(1, player.inventory.count("pigeon_cage_empty"), "the cage is kept, empty") + assertTrue(player.containsMessage("The pigeons fly towards the watchtower.")) + assertTrue(player.containsMessage("The mourners are frantically trying to scare the pigeons away.")) + } + + @Test + fun `Pigeons stay put when let out away from the watchtower`() { + val player = createPlayer(Tile(2592, 3335)) + player["biohazard"] = "birdfeed_thrown" + player.inventory.add("pigeon_cage_full") + + player.itemOption("Open", "pigeon_cage_full") + tick(2) + + assertEquals("birdfeed_thrown", player["biohazard", "unstarted"]) + assertEquals(1, player.inventory.count("pigeon_cage_full")) + assertTrue(player.containsMessage("The pigeons don't want to leave.")) + } + + @Test + fun `Bird feed does nothing once the pigeons have already flown`() { + val player = createPlayer(Tile(2560, 3301)) + player["biohazard"] = "crossed_wall" + player.inventory.add("bird_feed") + val fence = GameObjects.find(Tile(2560, 3301), "bio_watchtower_fence") + + player.itemOnObject(fence, player.inventory.indexOf("bird_feed")) + tick(2) + + assertEquals(1, player.inventory.count("bird_feed"), "the feed is kept") + assertTrue(player.containsMessage("Nothing interesting happens.")) + } + + @Test + fun `Omart's rope ladder drops the player into West Ardougne`() { + val player = createPlayer(Tile(2559, 3265)) + player["biohazard"] = "pigeons_released" + val omart = createNPC("omart_west_ardougne", Tile(2559, 3266)) + + player.npcOption(omart, "Talk-to") + playThrough(player, 12) + player.dialogueOption("line1") + playThrough(player, 8) + + assertEquals("crossed_wall", player["biohazard", "unstarted"]) + assertEquals(Tile(2556, 3267), player.tile) + } + + @Test + fun `Kilron takes the player back over the wall`() { + val player = createPlayer(Tile(2557, 3265)) + player["biohazard"] = "crossed_wall" + val kilron = createNPC("kilron_west_ardougne", Tile(2557, 3266)) + + player.npcOption(kilron, "Talk-to") + playThrough(player, 12) + player.dialogueOption("line2") + playThrough(player, 8) + + assertEquals(Tile(2559, 3267), player.tile) + } + + @Test + fun `The gap in the fence leads through to the mourners' yard`() { + val player = createPlayer(Tile(2541, 3331)) + val fence = GameObjects.find(Tile(2541, 3331), "mourner_stew_fence") + + player.objectOption(fence, "Squeeze-through") + tick(8) + + assertEquals(Tile(2542, 3331), player.tile) + assertTrue(player.containsMessage("You squeeze through the fence.")) + } + + @Test + fun `A rotten apple in the cauldron poisons the mourners' stew`() { + val player = createPlayer(Tile(2543, 3331)) + player["biohazard"] = "crossed_wall" + player.inventory.add("rotten_apple") + val cauldron = GameObjects.find(Tile(2543, 3332), "mourner_stew_cauldron") + + player.itemOnObject(cauldron, player.inventory.indexOf("rotten_apple")) + tick(12) + + assertEquals("poisoned_stew", player["biohazard", "unstarted"]) + assertEquals(0, player.inventory.count("rotten_apple")) + assertTrue(player.containsMessage("and it quickly dissolves into the stew.")) + } + + @Test + fun `Eating a rotten apple is its own punishment`() { + val player = createPlayer(Tile(2543, 3331)) + player.inventory.add("rotten_apple") + + player.itemOption("Eat", "rotten_apple") + tick(2) + player.skipDialogues() + + assertEquals(0, player.inventory.count("rotten_apple")) + assertTrue(player.containsMessage("...but it's rotten so you spit it out.")) + } + + @Test + fun `The nurse only leaves her gown out once the mourners fall ill`() { + val player = createPlayer(Tile(2516, 3277)) + player["biohazard"] = "crossed_wall" + val box = GameObjects.find(Tile(2516, 3278), "bio_nurses_cupboard_shut") + + player.objectOption(box, "Open") + tick(2) + val open = GameObjects.find(Tile(2516, 3278), "bio_nurses_cupboard_open") + player.objectOption(open, "Search") + tick(2) + assertEquals(0, player.inventory.count("doctors_gown")) + assertTrue(player.containsMessage("but you find nothing of interest.")) + + player["biohazard"] = "poisoned_stew" + player.objectOption(open, "Search") + tick(2) + + assertEquals(1, player.inventory.count("doctors_gown")) + assertTrue(player.containsMessage("and find a doctor's gown.")) + } + + @Test + fun `The mourners' door is locked until their stew is poisoned`() { + val player = createPlayer(Tile(2551, 3320)) + player["biohazard"] = "crossed_wall" + val door = GameObjects.find(Tile(2551, 3320), "mourner_stew_door_closed") + + player.objectOption(door, "Open") + tick(2) + player.skipDialogues() + + assertNotEquals(Tile(2551, 3321), player.tile, "the door stays shut") + } + + @Test + fun `Only the doctor gets past the mourner on the door`() { + val player = createPlayer(Tile(2551, 3320)) + player["biohazard"] = "poisoned_stew" + val door = GameObjects.find(Tile(2551, 3320), "mourner_stew_door_closed") + + player.objectOption(door, "Open") + tick(2) + player.skipDialogues() + assertNotEquals(Tile(2551, 3321), player.tile) + + player.inventory.add("doctors_gown") + player.equipItem("doctors_gown", option = "Wear") + tick(2) + player.objectOption(door, "Open") + tick(2) + player.skipDialogues() + tick(6) + + assertEquals(Tile(2551, 3321), player.tile) + } + + @Test + fun `The landing door needs the gown but never traps anyone upstairs`() { + val player = createPlayer(Tile(2546, 3325, 1)) + player["biohazard"] = "poisoned_stew" + val door = GameObjects.find(Tile(2547, 3325, 1), "mourner_stew_door_up_closed") + + player.objectOption(door, "Open") + tick(4) + assertTrue(player.containsMessage("The mourner is refusing to open the door.")) + assertEquals(Tile(2546, 3325, 1), player.tile) + + player.inventory.add("doctors_gown") + player.equipItem("doctors_gown", option = "Wear") + tick(2) + player.objectOption(door, "Open") + tick(6) + + assertEquals(Tile(2547, 3325, 1), player.tile) + } + + @Test + fun `A sick mourner is carrying the key to the quarters gate`() { + val player = createPlayer(Tile(2551, 3325, 1)) + player["biohazard"] = "poisoned_stew" + val mourner = createNPC("mourner_2", Tile(2551, 3324, 1)) + val deathTile = mourner.tile + + mourner.damage(5000, source = player) + tick(DEATH_TICKS) + + assertEquals(1, keysOn(deathTile), "the mourner should drop a key") + } + + @Test + fun `Mourners keep hold of the key outside the quest`() { + val player = createPlayer(Tile(2551, 3325, 1)) + val mourner = createNPC("mourner_2", Tile(2551, 3324, 1)) + val deathTile = mourner.tile + + mourner.damage(5000, source = player) + tick(DEATH_TICKS) + + assertEquals(0, keysOn(deathTile)) + } + + @Test + fun `A second key isn't dropped for someone already carrying one`() { + val player = createPlayer(Tile(2551, 3325, 1)) + player["biohazard"] = "poisoned_stew" + player.inventory.add("key_biohazard") + val mourner = createNPC("mourner_2", Tile(2551, 3324, 1)) + val deathTile = mourner.tile + + mourner.damage(5000, source = player) + tick(DEATH_TICKS) + + assertEquals(0, keysOn(deathTile)) + } + + @Test + fun `The quarters gate only opens for the key`() { + val player = createPlayer(Tile(2551, 3325, 1)) + player["biohazard"] = "poisoned_stew" + val gate = GameObjects.find(Tile(2552, 3325, 1), "mourner_quarters_gate_left_closed") + + player.objectOption(gate, "Open") + tick(4) + assertTrue(player.containsMessage("You need a key.")) + assertEquals(Tile(2551, 3325, 1), player.tile) + + player.inventory.add("key_biohazard") + player.itemOnObject(gate, player.inventory.indexOf("key_biohazard")) + tick(8) + + assertTrue(player.containsMessage("The key fits the gate.")) + assertEquals(Tile(2552, 3325, 1), player.tile) + } + + @Test + fun `Opening the quarters gate uses the key already in hand`() { + val player = createPlayer(Tile(2551, 3325, 1)) + player["biohazard"] = "poisoned_stew" + player.inventory.add("key_biohazard") + val gate = GameObjects.find(Tile(2552, 3325, 1), "mourner_quarters_gate_left_closed") + + player.objectOption(gate, "Open") + tick(8) + + assertTrue(player.containsMessage("The key fits the gate.")) + assertEquals(Tile(2552, 3325, 1), player.tile) + assertEquals(1, player.inventory.count("key_biohazard"), "the key isn't used up") + } + + @Test + fun `The quarters gate opens from the inside without a key`() { + val player = createPlayer(Tile(2552, 3325, 1)) + player["biohazard"] = "found_distillator" + val gate = GameObjects.find(Tile(2552, 3325, 1), "mourner_quarters_gate_left_closed") + + player.objectOption(gate, "Open") + tick(8) + + assertEquals(Tile(2551, 3325, 1), player.tile) + assertFalse(player.containsMessage("You need a key.")) + } + + @Test + fun `Leaving the quarters says nothing about the key you are carrying`() { + val player = createPlayer(Tile(2552, 3325, 1)) + player["biohazard"] = "found_distillator" + player.inventory.add("key_biohazard") + val gate = GameObjects.find(Tile(2552, 3325, 1), "mourner_quarters_gate_left_closed") + + player.objectOption(gate, "Open") + tick(8) + + assertEquals(Tile(2551, 3325, 1), player.tile) + assertFalse(player.containsMessage("The key fits the gate."), "nothing is unlocked on the way out") + } + + @Test + fun `Clicking the quarters gate from off its row walks you onto it first`() { + val player = createPlayer(Tile(2551, 3324, 1)) + player["biohazard"] = "poisoned_stew" + player.inventory.add("key_biohazard") + val gate = GameObjects.find(Tile(2552, 3325, 1), "mourner_quarters_gate_left_closed") + + player.objectOption(gate, "Open") + tick(12) + + assertEquals(Tile(2552, 3325, 1), player.tile) + } + + @Test + fun `The right half of the quarters gate takes you through its own row`() { + val player = createPlayer(Tile(2551, 3326, 1)) + player["biohazard"] = "poisoned_stew" + player.inventory.add("key_biohazard") + val gate = GameObjects.find(Tile(2552, 3326, 1), "mourner_quarters_gate_right_closed") + + player.objectOption(gate, "Open") + tick(12) + + assertEquals(Tile(2552, 3326, 1), player.tile, "the row is taken from the half that was clicked") + } + + @Test + fun `The quarters gate swings onto the row it started on`() { + val player = createPlayer(Tile(2551, 3325, 1)) + player["biohazard"] = "poisoned_stew" + player.inventory.add("key_biohazard") + val gate = GameObjects.find(Tile(2552, 3325, 1), "mourner_quarters_gate_left_closed") + + player.objectOption(gate, "Open") + tick(2) + + val left = GameObjects.findOrNull(Tile(2551, 3325, 1), 2059) + assertNotNull(left, "the left half should swing west onto its own row") + assertEquals(3, left.rotation) + val right = GameObjects.findOrNull(Tile(2551, 3326, 1), 2061) + assertNotNull(right, "and the right half onto its own") + assertEquals(1, right.rotation) + } + + @Test + fun `The quarters gate bangs shut behind you like every other gate`() { + val player = createPlayer(Tile(2551, 3325, 1)) + player["biohazard"] = "poisoned_stew" + player.inventory.add("key_biohazard") + val gate = GameObjects.find(Tile(2552, 3325, 1), "mourner_quarters_gate_left_closed") + mockkObject(ZoneBatchUpdates) + try { + player.objectOption(gate, "Open") + tick(8) + + val updates = mutableListOf() + verify { ZoneBatchUpdates.add(any(), capture(updates)) } + val sounds = updates.filterIsInstance() + assertEquals(listOf(66), sounds.map { it.id }) + } finally { + unmockkObject(ZoneBatchUpdates) + } + } + + @Test + fun `The crates around the distillator's are empty`() { + val player = createPlayer(Tile(2553, 3325, 1)) + val crate = GameObjects.find(Tile(2553, 3324, 1), "crate_272") + + player.objectOption(crate, "Search") + tick(2) + + assertTrue(player.containsMessage("You search the crate but find nothing.")) + } + + @Test + fun `The other crate model stacked by the distillator is empty too`() { + val player = createPlayer(Tile(2553, 3325, 1)) + val crate = createObject("crate_273", Tile(2553, 3326, 1)) + + player.objectOption(crate, "Search") + tick(4) + + assertTrue(player.containsMessage("You search the crate but find nothing.")) + } + + @Test + fun `The mourner tunnel trapdoor is bolted from below`() { + val player = createPlayer(Tile(2542, 3326)) + val trapdoor = GameObjects.find(Tile(2542, 3327), "trapdoor_mourner_tunnels") + + player.objectOption(trapdoor, "Open") + tick(2) + + assertTrue(player.containsMessage("The trapdoor is bolted on the other side.")) + assertEquals(Tile(2542, 3326), player.tile) + } + + @Test + fun `The plague sample doesn't survive a teleport`() { + val player = createPlayer(Tile(2592, 3335)) + player["biohazard"] = "smuggle_chemicals" + player.inventory.add("plague_sample") + player.inventory.add("plague_sample") + player.inventory.add("plague_sample") + player.inventory.add("ethenea") + assertEquals(3, player.inventory.count("plague_sample"), "three separate slots, it doesn't stack") + + Teleport.teleport(player, Tile(3222, 3218), "modern") + tick(12) + + assertEquals(0, player.inventory.count("plague_sample")) + assertTrue(player.containsMessage("The Plague Sample is fragile and is destroyed in the crossing.")) + assertEquals(1, player.inventory.count("ethenea"), "the vials travel fine") + } + + @Test + fun `The plague sample doesn't survive any of the other teleports either`() { + for (type in listOf("jewellery", "wilderness", "tablet", "scroll", "fairy_ring", "ectophial")) { + val player = createPlayer(Tile(2592, 3335), name = "teleporter $type") + player["biohazard"] = "smuggle_chemicals" + player.inventory.add("plague_sample") + + Teleport.teleport(player, Tile(3222, 3218), type) + tick(12) + + assertEquals(0, player.inventory.count("plague_sample"), "$type should destroy the sample") + assertTrue(player.containsMessage("The Plague Sample is fragile and is destroyed in the crossing."), type) + } + } + + @Test + fun `The crate upstairs holds Elena's distillator, and only one of them`() { + val player = createPlayer(Tile(2554, 3326, 1)) + player["biohazard"] = "poisoned_stew" + val crate = GameObjects.find(Tile(2554, 3327, 1), "mourner_crate_up") + + player.objectOption(crate, "Search") + tick(8) + assertEquals("found_distillator", player["biohazard", "unstarted"]) + assertEquals(1, player.inventory.count("distillator")) + + player.objectOption(crate, "Search") + tick(8) + + assertEquals(1, player.inventory.count("distillator")) + assertTrue(player.containsMessage("It's empty.")) + } + + @Test + fun `Elena swaps the distillator for a set of chemicals`() { + val player = createPlayer(Tile(2592, 3335)) + player["biohazard"] = "found_distillator" + player.inventory.add("distillator") + val elena = createNPC("elena2_vis", Tile(2592, 3336)) + + player.npcOption(elena, "Talk-to") + playThrough(player, 20) + + assertEquals("collect_chemicals", player["biohazard", "unstarted"]) + assertEquals(0, player.inventory.count("distillator")) + for (item in CHEMICALS) { + assertEquals(1, player.inventory.count(item), "Elena should hand over $item") + } + } + + @Test + fun `Elena replaces chemicals the player has lost`() { + val player = createPlayer(Tile(2592, 3335)) + player["biohazard"] = "collect_chemicals" + player.inventory.add("ethenea") + val elena = createNPC("elena2_vis", Tile(2592, 3336)) + + player.npcOption(elena, "Talk-to") + tick(2) + player.skipDialogues() + player.dialogueOption("line2") + player.skipDialogues() + + for (item in CHEMICALS) { + assertEquals(1, player.inventory.count(item), "Elena should replace $item") + } + } + + @Test + fun `The chemist hands over touch paper for Guidor`() { + val player = createPlayer(Tile(2934, 3209)) + player["biohazard"] = "collect_chemicals" + player.inventory.add("plague_sample") + val chemist = createNPC("chemist_rimmington", Tile(2934, 3210)) + + player.npcOption(chemist, "Talk-to") + tick(2) + player.dialogueOption("line2") + player.skipDialogues() + player.dialogueOption("line2") + player.skipDialogues() + player.dialogueOption("line2") + player.skipDialogues() + + assertEquals("smuggle_chemicals", player["biohazard", "unstarted"]) + assertEquals(1, player.inventory.count("touch_paper")) + assertEquals(1, player.inventory.count("plague_sample"), "the sample stays hidden") + } + + @Test + fun `Waving the plague sample at the chemist loses it`() { + val player = createPlayer(Tile(2934, 3209)) + player["biohazard"] = "collect_chemicals" + player.inventory.add("plague_sample") + val chemist = createNPC("chemist_rimmington", Tile(2934, 3210)) + + player.npcOption(chemist, "Talk-to") + tick(2) + player.dialogueOption("line2") + player.skipDialogues() + player.dialogueOption("line1") + player.skipDialogues() + + assertEquals(0, player.inventory.count("plague_sample")) + assertTrue(player.containsMessage("He takes the plague sample from you.")) + } + + @Test + fun `Each errand boy takes a vial off to Varrock`() { + val player = createPlayer(Tile(2928, 3218)) + player["biohazard"] = "smuggle_chemicals" + for (item in VIALS) { + player.inventory.add(item) + } + + talkTo(player, createNPC("da_vinci_rimmington", Tile(2927, 3218)), "line1") + assertEquals("delivered", player["biohazard_davinci_errand", "none"]) + assertEquals(0, player.inventory.count("ethenea")) + + moveTo(player, Tile(2929, 3221)) + talkTo(player, createNPC("chancy_rimmington", Tile(2929, 3222)), "line3") + assertEquals("gambled", player["biohazard_chancy_errand", "none"]) + assertEquals(0, player.inventory.count("sulphuric_broline")) + + moveTo(player, Tile(2930, 3217)) + talkTo(player, createNPC("hops_rimmington", Tile(2930, 3218)), "line2") + assertEquals("drank", player["biohazard_hops_errand", "none"]) + assertEquals(0, player.inventory.count("liquid_honey")) + } + + @Test + fun `An errand boy only hands back the vial he had no use for`() { + val player = createPlayer(Tile(3272, 3388)) + player["biohazard"] = "smuggle_chemicals" + player["biohazard_davinci_errand"] = "delivered" + player["biohazard_chancy_errand"] = "gambled" + player["biohazard_hops_errand"] = "drank" + + talkTo(player, createNPC("da_vinci", Tile(3272, 3389))) + assertEquals(1, player.inventory.count("ethenea"), "Da Vinci brought the ethenea through") + assertEquals("none", player["biohazard_davinci_errand", "none"]) + + moveTo(player, Tile(3271, 3387)) + talkTo(player, createNPC("chancy", Tile(3271, 3388)), "line1") + assertEquals(0, player.inventory.count("sulphuric_broline"), "Chancy sold it") + assertEquals("none", player["biohazard_chancy_errand", "none"]) + + moveTo(player, Tile(3268, 3388)) + talkTo(player, createNPC("hops", Tile(3268, 3389))) + assertEquals(0, player.inventory.count("liquid_honey"), "Hops drank it") + assertEquals("none", player["biohazard_hops_errand", "none"]) + } + + @Test + fun `An errand boy won't take a second vial`() { + val player = createPlayer(Tile(2928, 3218)) + player["biohazard"] = "smuggle_chemicals" + player["biohazard_davinci_errand"] = "delivered" + player.inventory.add("liquid_honey") + + talkTo(player, createNPC("da_vinci_rimmington", Tile(2927, 3218))) + + assertEquals(1, player.inventory.count("liquid_honey"), "he's already carrying one") + assertEquals("delivered", player["biohazard_davinci_errand", "none"]) + } + + @Test + fun `The Varrock gate guard confiscates every vial carried through`() { + val player = createPlayer(Tile(3263, 3405)) + player["biohazard"] = "smuggle_chemicals" + for (item in VIALS) { + player.inventory.add(item) + } + player.inventory.add("touch_paper") + val gate = GameObjects.find(Tile(3264, 3405), "guidor_gate_left_closed") + + player.objectOption(gate, "Open") + playThrough(player, 20) + + for (item in VIALS) { + assertEquals(0, player.inventory.count(item), "the guard should take the $item") + } + assertEquals(1, player.inventory.count("touch_paper"), "paper isn't a vial") + assertEquals(Tile(3264, 3405), player.tile) + } + + @Test + fun `Guidor's wife only lets a priest into the bedroom`() { + val player = createPlayer(Tile(3282, 3382)) + player["biohazard"] = "smuggle_chemicals" + val door = GameObjects.find(Tile(3282, 3382), "guidor_door_closed") + + player.objectOption(door, "Open") + tick(2) + player.skipDialogues() + assertTrue(player.containsMessage("Guidor's wife refuses to let you enter.")) + assertNotEquals(Tile(3283, 3382), player.tile) + + player.inventory.add("priest_gown_top") + player.inventory.add("priest_gown_bottom") + player.equipItem("priest_gown_top", option = "Wear") + tick() + player.equipItem("priest_gown_bottom", option = "Wear") + tick(2) + player.objectOption(door, "Open") + tick(6) + + assertTrue(player.containsMessage("Guidor's wife allows you to go in.")) + assertEquals(Tile(3283, 3382), player.tile) + } + + @Test + fun `Guidor tests the sample and finds no plague`() { + val player = createPlayer(Tile(3284, 3383)) + player["biohazard"] = "smuggle_chemicals" + for (item in CHEMICALS) { + player.inventory.add(item) + } + player.inventory.add("touch_paper") + val guidor = createNPC("guidor", Tile(3284, 3382)) + + player.npcOption(guidor, "Talk-to") + tick(2) + player.skipDialogues() + player.dialogueOption("line1") + player.skipDialogues() + player.dialogueOption("line2") + player.skipDialogues() + + assertEquals("sample_tested", player["biohazard", "unstarted"]) + for (item in CHEMICALS) { + assertEquals(0, player.inventory.count(item), "Guidor uses the $item") + } + assertEquals(0, player.inventory.count("touch_paper")) + } + + @Test + fun `Guidor sends the player away without all three reagents`() { + val player = createPlayer(Tile(3284, 3383)) + player["biohazard"] = "smuggle_chemicals" + player.inventory.add("plague_sample") + player.inventory.add("ethenea") + player.inventory.add("touch_paper") + val guidor = createNPC("guidor", Tile(3284, 3382)) + + player.npcOption(guidor, "Talk-to") + tick(2) + player.skipDialogues() + player.dialogueOption("line1") + player.skipDialogues() + player.dialogueOption("line2") + player.skipDialogues() + + assertEquals("smuggle_chemicals", player["biohazard", "unstarted"]) + assertEquals(1, player.inventory.count("plague_sample"), "nothing is taken") + } + + @Test + fun `Elena sends the player to confront the king`() { + val player = createPlayer(Tile(2592, 3335)) + player["biohazard"] = "sample_tested" + val elena = createNPC("elena2_vis", Tile(2592, 3336)) + + player.npcOption(elena, "Talk-to") + tick(2) + player.skipDialogues() + + assertEquals("told_elena", player["biohazard", "unstarted"]) + } + + @Test + fun `King Lathas admits the hoax and completes the quest`() { + val player = createPlayer(Tile(2577, 3299, 1)) + player["biohazard"] = "told_elena" + val lathas = createNPC("king_lathas", Tile(2577, 3298, 1)) + val points = player["quest_points", 0] + + player.npcOption(lathas, "Talk-to") + tick(2) + player.skipDialogues() + player.dialogueOption("line1") + player.skipDialogues() + + assertEquals("completed", player["biohazard", "unstarted"]) + assertEquals(points + 3, player["quest_points", 0]) + assertEquals(1250.0, player.experience.get(Skill.Thieving)) + } + + @Test + fun `The king won't talk about the plague before Guidor's findings`() { + val player = createPlayer(Tile(2577, 3299, 1)) + val lathas = createNPC("king_lathas", Tile(2577, 3298, 1)) + + player.npcOption(lathas, "Talk-to") + tick(2) + + assertTrue(player.containsMessage("The king is too busy to talk.")) + } + + @Test + fun `The training camp gate opens once the quest is done`() { + val player = createPlayer(Tile(2517, 3356)) + val gate = GameObjects.find(Tile(2517, 3356), "lathas_training_gate_left_closed") + + player.objectOption(gate, "Open") + tick(2) + player.skipDialogues() + tick(4) + + assertEquals(Tile(2517, 3356), player.tile, "the gate stays shut") + } + + @Test + fun `The training camp gate swings onto the column it started on`() { + val player = createPlayer(Tile(2517, 3356)) + player["biohazard"] = "completed" + val gate = GameObjects.find(Tile(2517, 3356), "lathas_training_gate_left_closed") + + player.objectOption(gate, "Open") + tick(2) + player.skipDialogues() + tick(1) + + val left = GameObjects.findOrNull(Tile(2517, 3357), 2040) + assertNotNull(left, "the left half should swing north onto its own column") + assertEquals(0, left.rotation) + val right = GameObjects.findOrNull(Tile(2518, 3357), 2042) + assertNotNull(right, "and the right half onto its own") + assertEquals(2, right.rotation) + + tick(6) + assertEquals(Tile(2517, 3357), player.tile) + } + + @Test + fun `The training camp gate uses the iron door sound RuneScape-2011 gives it`() { + val player = createPlayer(Tile(2517, 3356), name = "training gate listener") + player["biohazard"] = "completed" + val gate = GameObjects.find(Tile(2517, 3356), "lathas_training_gate_left_closed") + mockkObject(ZoneBatchUpdates) + try { + player.objectOption(gate, "Open") + tick(2) + player.skipDialogues() + tick(8) + + val updates = mutableListOf() + verify { ZoneBatchUpdates.add(any(), capture(updates)) } + val sounds = updates.filterIsInstance() + assertEquals(listOf(70), sounds.map { it.id }) + } finally { + unmockkObject(ZoneBatchUpdates) + } + } + + @Test + fun `The Ardougne wall doors open once the quest is done`() { + val player = createPlayer(Tile(2558, 3300)) + val doors = GameObjects.find(Tile(2558, 3300), "ardougne_wall_door_closed") + + player.objectOption(doors, "Open") + tick(4) + assertTrue(player.containsMessage("...But they will not open.")) + + player["biohazard"] = "completed" + player.objectOption(doors, "Open") + tick(6) + + assertNotEquals(Tile(2558, 3300), player.tile, "the doors let the player through") + } + + @Test + fun `Elena's house is shut up while she is missing`() { + val player = createPlayer(Tile(2592, 3339)) + val door = GameObjects.find(Tile(2592, 3339), "elena_house_door_closed") + + player.objectOption(door, "Open") + tick(4) + assertTrue(player.containsMessage("The door is locked.")) + assertEquals(Tile(2592, 3339), player.tile) + + player["plague_city"] = "completed" + player.objectOption(door, "Open") + tick(6) + + assertEquals(Tile(2592, 3338), player.tile) + } + + @Test + fun `The journal reads through every stage of the quest`() { + val player = createPlayer(Tile(2592, 3335)) + + for (stage in JOURNAL_STAGES) { + player["biohazard"] = stage + InterfaceApi.openQuestJournal(player, "biohazard") + assertTrue(player.interfaces.contains("quest_scroll"), "the journal should open at $stage") + player.interfaces.close("quest_scroll") + } + } + + private fun playThrough(player: Player, ticks: Int) { + repeat(ticks) { + tick() + if (player.dialogue != null) { + player.skipDialogues() + } + } + } + + private fun talkTo(player: Player, npc: NPC, vararg options: String) { + player.npcOption(npc, "Talk-to") + playThrough(player, 8) + for (option in options) { + player.dialogueOption(option) + playThrough(player, 8) + } + } + + private fun keysOn(tile: Tile) = FloorItems.at(tile.zone).flatten().count { it.id == "key_biohazard" } + + private fun moveTo(player: Player, tile: Tile) { + player.mode = EmptyMode + player.tele(tile) + tick() + } + + private companion object { + const val DEATH_TICKS = 20 + + val VIALS = listOf("ethenea", "liquid_honey", "sulphuric_broline") + val CHEMICALS = VIALS + "plague_sample" + + val JOURNAL_STAGES = listOf( + "unstarted", + "started", + "spoke_to_jerico", + "birdfeed_thrown", + "pigeons_released", + "crossed_wall", + "poisoned_stew", + "found_distillator", + "collect_chemicals", + "smuggle_chemicals", + "sample_tested", + "told_elena", + "completed", + ) + } +} diff --git a/game/src/test/kotlin/content/quest/member/ghosts_ahoy/GhostsAhoyTest.kt b/game/src/test/kotlin/content/quest/member/ghosts_ahoy/GhostsAhoyTest.kt index 0c94b1d213..f54c3b89c0 100644 --- a/game/src/test/kotlin/content/quest/member/ghosts_ahoy/GhostsAhoyTest.kt +++ b/game/src/test/kotlin/content/quest/member/ghosts_ahoy/GhostsAhoyTest.kt @@ -2,6 +2,8 @@ package content.quest.member.ghosts_ahoy import FakeRandom import WorldTest +import containsMessage +import content.entity.effect.transform import content.quest.quest import dialogueOption import itemOnItem @@ -14,6 +16,7 @@ import org.junit.jupiter.api.Test import skipDialogues import world.gregs.voidps.engine.entity.character.move.tele import world.gregs.voidps.engine.entity.character.npc.NPCs +import world.gregs.voidps.engine.entity.character.player.Teleport import world.gregs.voidps.engine.entity.character.player.equip.equipped import world.gregs.voidps.engine.entity.character.player.skill.Skill import world.gregs.voidps.engine.entity.item.floor.FloorItems @@ -367,4 +370,17 @@ class GhostsAhoyTest : WorldTest() { assertEquals(1, player.inventory.count("ectophial")) assertEquals("completed", player.quest("ghosts_ahoy")) } + + @Test + fun `The ghost disguise blocks a teleport of any kind`() { + val player = createPlayer(Tile(3688, 3510), name = "disguised teleporter") + player.equipment.set(EquipSlot.Hat.index, "bedsheet") + player.transform("ahoy_ghost_disguise") + + Teleport.teleport(player, Tile(3222, 3218), "jewellery") + tick(12) + + assertEquals(Tile(3688, 3510), player.tile, "the teleport should be refused") + assertTrue(player.containsMessage("Please remove your ghost disguise before teleporting.")) + } }