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..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
@@ -137,6 +137,15 @@ 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
+
+ /** 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
@@ -472,6 +481,9 @@ 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
"req_quest_points" -> REQ_QUEST_POINTS
"req_quests" -> REQ_QUESTS
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/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/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/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/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/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/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/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/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..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
@@ -1955,10 +1976,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
@@ -3216,7 +3269,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/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/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/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/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/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