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("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/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/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/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/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/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/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/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 297abf5d61..a7eecb55d1 100644 --- a/game/src/main/kotlin/content/quest/Quest.kt +++ b/game/src/main/kotlin/content/quest/Quest.kt @@ -22,13 +22,16 @@ val quests = setOf( "imp_catcher", "pirates_treasure", // members + "biohazard", "creature_of_fenkenstrain", "druidic_ritual", "hand_in_the_sand", + "in_search_of_the_myreque", "jungle_potion", "nature_spirit", "plague_city", "priest_in_peril", + "rum_deal", "lost_city", "tears_of_guthix", "the_golem", @@ -45,6 +48,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 new file mode 100644 index 0000000000..d5cb2cd856 --- /dev/null +++ b/game/src/main/kotlin/content/quest/QuestCommands.kt @@ -0,0 +1,135 @@ +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, where an item + * needed more than once is written as "id:amount". + */ +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_*` - 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 + 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 + 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++ + } + } + 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 (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(id, amount)) { + player.addOrDrop(id, amount) + given++ + } + } + + 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, " + + "$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/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/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/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..49490c442b --- /dev/null +++ b/game/src/main/kotlin/content/quest/member/rum_deal/RumDeal.kt @@ -0,0 +1,823 @@ +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 +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.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.markHint(this) + 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" + } + // Takes the arrow away as the spirit dies rather than waiting for it to despawn + clearHints() + 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/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/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/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/QuestCommandsTest.kt b/game/src/test/kotlin/content/quest/QuestCommandsTest.kt new file mode 100644 index 0000000000..f40d61d931 --- /dev/null +++ b/game/src/test/kotlin/content/quest/QuestCommandsTest.kt @@ -0,0 +1,153 @@ +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.assertFalse +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 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 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") + 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/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.")) + } } 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", + ) + } +} 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..a68383d036 --- /dev/null +++ b/game/src/test/kotlin/content/quest/member/rum_deal/RumDealTest.kt @@ -0,0 +1,583 @@ +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.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 +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 + 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 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(1, hints.count { it != 0 }, "the spirit's arrow should be gone") + assertTrue(hints[otherArrow] != 0, "other content's arrow must survive") + } + + @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 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