diff --git a/data/activity/event/random/beekeeper/beekeeper.ifaces.toml b/data/activity/event/random/beekeeper/beekeeper.ifaces.toml new file mode 100644 index 0000000000..7fe6080766 --- /dev/null +++ b/data/activity/event/random/beekeeper/beekeeper.ifaces.toml @@ -0,0 +1,33 @@ +[beehive_build] +id = 420 +type = "main_screen" + +[.part_1] +id = 12 + +[.part_2] +id = 13 + +[.part_3] +id = 14 + +[.part_4] +id = 15 + +[.slot_1] +id = 16 + +[.slot_2] +id = 17 + +[.slot_3] +id = 18 + +[.slot_4] +id = 19 + +[.close] +id = 38 + +[.build] +id = 39 diff --git a/data/activity/event/random/beekeeper/beekeeper.npcs.toml b/data/activity/event/random/beekeeper/beekeeper.npcs.toml new file mode 100644 index 0000000000..a6189e12d6 --- /dev/null +++ b/data/activity/event/random/beekeeper/beekeeper.npcs.toml @@ -0,0 +1,3 @@ +[bee_keeper] +id = 8649 +examine = "He looks after all these bees." diff --git a/data/activity/event/random/beekeeper/beekeeper.varbits.toml b/data/activity/event/random/beekeeper/beekeeper.varbits.toml new file mode 100644 index 0000000000..6521dec12d --- /dev/null +++ b/data/activity/event/random/beekeeper/beekeeper.varbits.toml @@ -0,0 +1,37 @@ +# Varp 805 drives the beehive-build interface: the client renders each source and frame slot's +# hive part (1-4) from these varbits; 0 is an empty slot. +[beekeeper_part_1] +id = 5893 +format = "int" + +[beekeeper_part_2] +id = 5894 +format = "int" + +[beekeeper_part_3] +id = 5895 +format = "int" + +[beekeeper_part_4] +id = 5896 +format = "int" + +[beekeeper_slot_1] +id = 5897 +format = "int" + +[beekeeper_slot_2] +id = 5898 +format = "int" + +[beekeeper_slot_3] +id = 5899 +format = "int" + +[beekeeper_slot_4] +id = 5900 +format = "int" + +[beekeeper_tries] +id = 5901 +format = "int" diff --git a/data/activity/event/random/beekeeper/beekeeper.vars.toml b/data/activity/event/random/beekeeper/beekeeper.vars.toml new file mode 100644 index 0000000000..1cdec579ec --- /dev/null +++ b/data/activity/event/random/beekeeper/beekeeper.vars.toml @@ -0,0 +1,3 @@ +[beekeeper_intro] +format = "boolean" +persist = true diff --git a/data/activity/event/random/capn_arnav/capn_arnav.ifaces.toml b/data/activity/event/random/capn_arnav/capn_arnav.ifaces.toml new file mode 100644 index 0000000000..11897f6163 --- /dev/null +++ b/data/activity/event/random/capn_arnav/capn_arnav.ifaces.toml @@ -0,0 +1,27 @@ +[capn_arnav_lock] +id = 185 +type = "main_screen" + +[.down_1] +id = 2 + +[.up_1] +id = 3 + +[.down_2] +id = 9 + +[.up_2] +id = 10 + +[.down_3] +id = 16 + +[.up_3] +id = 17 + +[.unlock] +id = 28 + +[.close] +id = 30 diff --git a/data/activity/event/random/capn_arnav/capn_arnav.npc-spawns.toml b/data/activity/event/random/capn_arnav/capn_arnav.npc-spawns.toml new file mode 100644 index 0000000000..7bd93584a1 --- /dev/null +++ b/data/activity/event/random/capn_arnav/capn_arnav.npc-spawns.toml @@ -0,0 +1,4 @@ +spawns = [ +# 6480 + { id = "capn_arnav", x = 1625, y = 5163, direction = "EAST" }, +] diff --git a/data/activity/event/random/capn_arnav/capn_arnav.npcs.toml b/data/activity/event/random/capn_arnav/capn_arnav.npcs.toml new file mode 100644 index 0000000000..87f05926b6 --- /dev/null +++ b/data/activity/event/random/capn_arnav/capn_arnav.npcs.toml @@ -0,0 +1,3 @@ +[capn_arnav] +id = 2308 +examine = "Distinctly piratical." diff --git a/data/activity/event/random/capn_arnav/capn_arnav.objs.toml b/data/activity/event/random/capn_arnav/capn_arnav.objs.toml new file mode 100644 index 0000000000..100cfc05af --- /dev/null +++ b/data/activity/event/random/capn_arnav/capn_arnav.objs.toml @@ -0,0 +1,9 @@ +[capn_arnav_chest] +id = 42337 +examine = "Cap'n Arnav's treasure chest." + +[capn_arnav_chest_open] +id = 42338 + +[capn_arnav_exit_portal] +id = 11369 diff --git a/data/activity/event/random/capn_arnav/capn_arnav.varbits.toml b/data/activity/event/random/capn_arnav/capn_arnav.varbits.toml new file mode 100644 index 0000000000..42d3e22925 --- /dev/null +++ b/data/activity/event/random/capn_arnav/capn_arnav.varbits.toml @@ -0,0 +1,13 @@ +# Client script 2112 repositions each cylinder's four models from these varbits: +# 0 = coins centred, 1 = bowl, 2 = bar, 3 = ring. +[arnav_lock_1] +id = 693 +format = "int" + +[arnav_lock_2] +id = 694 +format = "int" + +[arnav_lock_3] +id = 695 +format = "int" diff --git a/data/activity/event/random/capn_arnav/capn_arnav.vars.toml b/data/activity/event/random/capn_arnav/capn_arnav.vars.toml new file mode 100644 index 0000000000..0d33037e8a --- /dev/null +++ b/data/activity/event/random/capn_arnav/capn_arnav.vars.toml @@ -0,0 +1,11 @@ +[arnav_target] +format = "string" +persist = true + +[arnav_tries] +format = "int" +persist = true + +[arnav_solved] +format = "boolean" +persist = true diff --git a/data/activity/event/random/certer/certer.ifaces.toml b/data/activity/event/random/certer/certer.ifaces.toml new file mode 100644 index 0000000000..345098c8fd --- /dev/null +++ b/data/activity/event/random/certer/certer.ifaces.toml @@ -0,0 +1,17 @@ +# Certer's identify interface (184). Its load script builds the three option rows and the item +# model from varbits, so we set those and open it. The clickable option rows are components 4-6 +# (A/B/C, top to bottom); their "Select" action is declared here so clicks validate. +[certer_identify] +id = 184 + +[.option_1] +id = 4 +options = { Select = 0 } + +[.option_2] +id = 5 +options = { Select = 0 } + +[.option_3] +id = 6 +options = { Select = 0 } diff --git a/data/activity/event/random/certer/certer.npcs.toml b/data/activity/event/random/certer/certer.npcs.toml new file mode 100644 index 0000000000..8bc4900b92 --- /dev/null +++ b/data/activity/event/random/certer/certer.npcs.toml @@ -0,0 +1,3 @@ +[giles] +id = 2538 +examine = "One of the certificate brothers." diff --git a/data/activity/event/random/certer/certer.varbits.toml b/data/activity/event/random/certer/certer.varbits.toml new file mode 100644 index 0000000000..67d66d7d7f --- /dev/null +++ b/data/activity/event/random/certer/certer.varbits.toml @@ -0,0 +1,30 @@ +# Read by interface 184's load script (cs2 2136). +# The three option descriptions (keys into enum 2224): +[certer_desc_1] +id = 5856 + +[certer_desc_2] +id = 5858 + +[certer_desc_3] +id = 5860 + +# The item model: enum selector (2225-2228) + key into it. +[certer_model_enum] +id = 5857 + +[certer_model_key] +id = 5862 + +# Model display: rotation, scale and spin. +[certer_rotation_x] +id = 5855 + +[certer_rotation_y] +id = 5859 + +[certer_scale] +id = 5861 + +[certer_spin] +id = 5863 diff --git a/data/activity/event/random/certer/certer.vars.toml b/data/activity/event/random/certer/certer.vars.toml new file mode 100644 index 0000000000..610f4e4549 --- /dev/null +++ b/data/activity/event/random/certer/certer.vars.toml @@ -0,0 +1,2 @@ +[certer_answer] +format = "int" diff --git a/data/activity/event/random/drill_demon/drill_demon.anims.toml b/data/activity/event/random/drill_demon/drill_demon.anims.toml new file mode 100644 index 0000000000..b289e68bd8 --- /dev/null +++ b/data/activity/event/random/drill_demon/drill_demon.anims.toml @@ -0,0 +1,11 @@ +[drill_demon_jog] +id = 2764 + +[drill_demon_situp] +id = 2763 + +[drill_demon_pushup] +id = 2762 + +[drill_demon_starjump] +id = 2761 diff --git a/data/activity/event/random/drill_demon/drill_demon.gfx.toml b/data/activity/event/random/drill_demon/drill_demon.gfx.toml new file mode 100644 index 0000000000..e8c2749ba3 --- /dev/null +++ b/data/activity/event/random/drill_demon/drill_demon.gfx.toml @@ -0,0 +1,3 @@ +# Puff shown on a sign tile as its exercise changes. +[drill_demon_sign_change] +id = 2000 diff --git a/data/activity/event/random/drill_demon/drill_demon.objs.toml b/data/activity/event/random/drill_demon/drill_demon.objs.toml new file mode 100644 index 0000000000..5793010e41 --- /dev/null +++ b/data/activity/event/random/drill_demon/drill_demon.objs.toml @@ -0,0 +1,13 @@ +# The four exercise mats. Each mat's sign shows an exercise via its varbit +# (drill_demon_sign_1..4); the player uses the mat matching the sergeant's order. +[drill_demon_mat_1] +id = 10076 + +[drill_demon_mat_2] +id = 10077 + +[drill_demon_mat_3] +id = 10078 + +[drill_demon_mat_4] +id = 10079 diff --git a/data/activity/event/random/drill_demon/drill_demon.sounds.toml b/data/activity/event/random/drill_demon/drill_demon.sounds.toml new file mode 100644 index 0000000000..c64c4f74c4 --- /dev/null +++ b/data/activity/event/random/drill_demon/drill_demon.sounds.toml @@ -0,0 +1,11 @@ +[drill_demon_jog] +id = 2484 + +[drill_demon_situp] +id = 2486 + +[drill_demon_pushup] +id = 2481 + +[drill_demon_starjump] +id = 2492 diff --git a/data/activity/event/random/drill_demon/drill_demon.varbits.toml b/data/activity/event/random/drill_demon/drill_demon.varbits.toml new file mode 100644 index 0000000000..62b5ceb05c --- /dev/null +++ b/data/activity/event/random/drill_demon/drill_demon.varbits.toml @@ -0,0 +1,16 @@ +# Each mat's sign display: 1 jog, 2 sit-ups, 3 push-ups, 4 star jumps (0 = no sign shown). +[drill_demon_sign_1] +id = 1335 +format = "int" + +[drill_demon_sign_2] +id = 1336 +format = "int" + +[drill_demon_sign_3] +id = 1337 +format = "int" + +[drill_demon_sign_4] +id = 1338 +format = "int" diff --git a/data/activity/event/random/drill_demon/drill_demon.vars.toml b/data/activity/event/random/drill_demon/drill_demon.vars.toml new file mode 100644 index 0000000000..1b6771b327 --- /dev/null +++ b/data/activity/event/random/drill_demon/drill_demon.vars.toml @@ -0,0 +1,11 @@ +[drill_demon_task] +format = "int" +persist = true + +[drill_demon_correct] +format = "int" +persist = true + +# Set once the sergeant has given the first order; using a mat before then is a wasted attempt. +[drill_demon_ready] +format = "boolean" diff --git a/data/activity/event/random/evil_bob/evil_bob.npcs.toml b/data/activity/event/random/evil_bob/evil_bob.npcs.toml new file mode 100644 index 0000000000..581854190a --- /dev/null +++ b/data/activity/event/random/evil_bob/evil_bob.npcs.toml @@ -0,0 +1,12 @@ +[evil_bob_cat] +id = 2478 +examine = "It's a cat with an evil glint in its eye." + +[evil_bob] +id = 2479 +dialogue = "cat" +examine = "An evil incarnation of Bob the cat." + +[evil_bob_servant] +id = 2481 +examine = "A terrified human servant." diff --git a/data/activity/event/random/evil_bob/evil_bob.objs.toml b/data/activity/event/random/evil_bob/evil_bob.objs.toml new file mode 100644 index 0000000000..572950fd4c --- /dev/null +++ b/data/activity/event/random/evil_bob/evil_bob.objs.toml @@ -0,0 +1,11 @@ +[evil_bob_fishing_spot] +id = 8986 +examine = "You could catch a fish-like thing here." + +[evil_bob_uncooking_pot] +id = 8985 +examine = "A cold fire for uncooking fish." + +[evil_bob_exit_portal] +id = 8987 +examine = "A portal leading away from this dreadful place." diff --git a/data/activity/event/random/evil_bob/evil_bob.vars.toml b/data/activity/event/random/evil_bob/evil_bob.vars.toml new file mode 100644 index 0000000000..03c702c8a8 --- /dev/null +++ b/data/activity/event/random/evil_bob/evil_bob.vars.toml @@ -0,0 +1,26 @@ +[evil_bob_npc] +format = "int" + +[evil_bob_zone] +format = "int" +persist = true + +[evil_bob_complete] +format = "boolean" +persist = true + +[evil_bob_attentive] +format = "boolean" +persist = true + +[evil_bob_new_spot] +format = "boolean" +persist = true + +[evil_bob_seen_intro] +format = "boolean" +persist = true + +[evil_bob_servant_helped] +format = "boolean" +persist = true diff --git a/data/activity/event/random/evil_twin/evil_twin.anims.toml b/data/activity/event/random/evil_twin/evil_twin.anims.toml new file mode 100644 index 0000000000..e35050c330 --- /dev/null +++ b/data/activity/event/random/evil_twin/evil_twin.anims.toml @@ -0,0 +1,14 @@ +[evil_twin_claw_grab] +id = 4000 + +[evil_twin_lifted] +id = 4001 + +[evil_twin_claw_lower] +id = 4003 + +[evil_twin_operate] +id = 4004 + +[evil_twin_jailed] +id = 859 diff --git a/data/activity/event/random/evil_twin/evil_twin.gfx.toml b/data/activity/event/random/evil_twin/evil_twin.gfx.toml new file mode 100644 index 0000000000..b0d9d0910c --- /dev/null +++ b/data/activity/event/random/evil_twin/evil_twin.gfx.toml @@ -0,0 +1,2 @@ +[evil_twin_lifted] +id = 666 diff --git a/data/activity/event/random/evil_twin/evil_twin.ifaces.toml b/data/activity/event/random/evil_twin/evil_twin.ifaces.toml new file mode 100644 index 0000000000..b77f7479ff --- /dev/null +++ b/data/activity/event/random/evil_twin/evil_twin.ifaces.toml @@ -0,0 +1,28 @@ +# Evil Twin crane controls (240). The arrow buttons move the claw one tile per click and the +# red button lowers it onto whoever is standing on the glowing mark. The buttons' option names +# ("Up", "Grab"...) come from the interface definition in the cache; the camera looks south over +# the pen, so screen-up is south. +[evil_twin_crane] +id = 240 +type = "inventory_tab" + +[.tries] +id = 27 + +[.grab] +id = 28 + +[.up] +id = 29 + +[.down] +id = 30 + +[.left] +id = 31 + +[.right] +id = 32 + +[.close] +id = 33 diff --git a/data/activity/event/random/evil_twin/evil_twin.npcs.toml b/data/activity/event/random/evil_twin/evil_twin.npcs.toml new file mode 100644 index 0000000000..23f67a21f7 --- /dev/null +++ b/data/activity/event/random/evil_twin/evil_twin.npcs.toml @@ -0,0 +1,242 @@ +# Molly and her lookalikes. Appearance index n = colour + model * 4 (colour 0-3, model 0-4); +# molly_n, twin_suspect_n and twin_suspect_carried_n share the same look for a given n. +[molly_0] +id = 3892 +examine = "She seems worried." + +[molly_1] +clone = "molly_0" +id = 3893 + +[molly_2] +clone = "molly_0" +id = 3894 + +[molly_3] +clone = "molly_0" +id = 3895 + +[molly_4] +clone = "molly_0" +id = 3896 + +[molly_5] +clone = "molly_0" +id = 3897 + +[molly_6] +clone = "molly_0" +id = 3898 + +[molly_7] +clone = "molly_0" +id = 3899 + +[molly_8] +clone = "molly_0" +id = 3900 + +[molly_9] +clone = "molly_0" +id = 3901 + +[molly_10] +clone = "molly_0" +id = 3902 + +[molly_11] +clone = "molly_0" +id = 3903 + +[molly_12] +clone = "molly_0" +id = 3904 + +[molly_13] +clone = "molly_0" +id = 3905 + +[molly_14] +clone = "molly_0" +id = 3906 + +[molly_15] +clone = "molly_0" +id = 3907 + +[molly_16] +clone = "molly_0" +id = 3908 + +[molly_17] +clone = "molly_0" +id = 3909 + +[molly_18] +clone = "molly_0" +id = 3910 + +[molly_19] +clone = "molly_0" +id = 3911 + +[twin_suspect_0] +id = 3852 +wander_range = 3 +examine = "Could this be Molly's evil twin?" + +[twin_suspect_1] +clone = "twin_suspect_0" +id = 3853 + +[twin_suspect_2] +clone = "twin_suspect_0" +id = 3854 + +[twin_suspect_3] +clone = "twin_suspect_0" +id = 3855 + +[twin_suspect_4] +clone = "twin_suspect_0" +id = 3856 + +[twin_suspect_5] +clone = "twin_suspect_0" +id = 3857 + +[twin_suspect_6] +clone = "twin_suspect_0" +id = 3858 + +[twin_suspect_7] +clone = "twin_suspect_0" +id = 3859 + +[twin_suspect_8] +clone = "twin_suspect_0" +id = 3860 + +[twin_suspect_9] +clone = "twin_suspect_0" +id = 3861 + +[twin_suspect_10] +clone = "twin_suspect_0" +id = 3862 + +[twin_suspect_11] +clone = "twin_suspect_0" +id = 3863 + +[twin_suspect_12] +clone = "twin_suspect_0" +id = 3864 + +[twin_suspect_13] +clone = "twin_suspect_0" +id = 3865 + +[twin_suspect_14] +clone = "twin_suspect_0" +id = 3866 + +[twin_suspect_15] +clone = "twin_suspect_0" +id = 3867 + +[twin_suspect_16] +clone = "twin_suspect_0" +id = 3868 + +[twin_suspect_17] +clone = "twin_suspect_0" +id = 3869 + +[twin_suspect_18] +clone = "twin_suspect_0" +id = 3870 + +[twin_suspect_19] +clone = "twin_suspect_0" +id = 3871 + +# Dangling-from-the-claw variants shown while a suspect is carried to the jail. +[twin_suspect_carried_0] +id = 3872 + +[twin_suspect_carried_1] +clone = "twin_suspect_carried_0" +id = 3873 + +[twin_suspect_carried_2] +clone = "twin_suspect_carried_0" +id = 3874 + +[twin_suspect_carried_3] +clone = "twin_suspect_carried_0" +id = 3875 + +[twin_suspect_carried_4] +clone = "twin_suspect_carried_0" +id = 3876 + +[twin_suspect_carried_5] +clone = "twin_suspect_carried_0" +id = 3877 + +[twin_suspect_carried_6] +clone = "twin_suspect_carried_0" +id = 3878 + +[twin_suspect_carried_7] +clone = "twin_suspect_carried_0" +id = 3879 + +[twin_suspect_carried_8] +clone = "twin_suspect_carried_0" +id = 3880 + +[twin_suspect_carried_9] +clone = "twin_suspect_carried_0" +id = 3881 + +[twin_suspect_carried_10] +clone = "twin_suspect_carried_0" +id = 3882 + +[twin_suspect_carried_11] +clone = "twin_suspect_carried_0" +id = 3883 + +[twin_suspect_carried_12] +clone = "twin_suspect_carried_0" +id = 3884 + +[twin_suspect_carried_13] +clone = "twin_suspect_carried_0" +id = 3885 + +[twin_suspect_carried_14] +clone = "twin_suspect_carried_0" +id = 3886 + +[twin_suspect_carried_15] +clone = "twin_suspect_carried_0" +id = 3887 + +[twin_suspect_carried_16] +clone = "twin_suspect_carried_0" +id = 3888 + +[twin_suspect_carried_17] +clone = "twin_suspect_carried_0" +id = 3889 + +[twin_suspect_carried_18] +clone = "twin_suspect_carried_0" +id = 3890 + +[twin_suspect_carried_19] +clone = "twin_suspect_carried_0" +id = 3891 diff --git a/data/activity/event/random/evil_twin/evil_twin.objs.toml b/data/activity/event/random/evil_twin/evil_twin.objs.toml new file mode 100644 index 0000000000..e7137284aa --- /dev/null +++ b/data/activity/event/random/evil_twin/evil_twin.objs.toml @@ -0,0 +1,7 @@ +[evil_twin_claw] +id = 14976 +examine = "A giant mechanical claw." + +# Glowing mark on the floor showing where the claw will drop. +[evil_twin_claw_marker] +id = 14977 diff --git a/data/activity/event/random/evil_twin/evil_twin.sounds.toml b/data/activity/event/random/evil_twin/evil_twin.sounds.toml new file mode 100644 index 0000000000..109fa842fb --- /dev/null +++ b/data/activity/event/random/evil_twin/evil_twin.sounds.toml @@ -0,0 +1,8 @@ +[evil_twin_claw_move] +id = 2273 + +[evil_twin_claw_lower] +id = 2272 + +[evil_twin_claw_drop] +id = 2271 diff --git a/data/activity/event/random/evil_twin/evil_twin.vars.toml b/data/activity/event/random/evil_twin/evil_twin.vars.toml new file mode 100644 index 0000000000..f261c3d2aa --- /dev/null +++ b/data/activity/event/random/evil_twin/evil_twin.vars.toml @@ -0,0 +1,19 @@ +# Appearance index shared by Molly and her twin: colour + model * 4 (0-19). +# Hash, tries and caught persist so logging out mid-event resumes where the player left off. +[evil_twin_hash] +format = "int" +persist = true + +[evil_twin_tries] +format = "int" +persist = true + +[evil_twin_claw_x] +format = "int" + +[evil_twin_claw_y] +format = "int" + +[evil_twin_caught] +format = "boolean" +persist = true diff --git a/data/activity/event/random/freaky_forester/freaky_forester.npc-spawns.toml b/data/activity/event/random/freaky_forester/freaky_forester.npc-spawns.toml new file mode 100644 index 0000000000..ac6c0a0ec2 --- /dev/null +++ b/data/activity/event/random/freaky_forester/freaky_forester.npc-spawns.toml @@ -0,0 +1,10 @@ +spawns = [ + { id = "pheasant_1_tail", x = 2596, y = 4772, direction = "SOUTH" }, + { id = "pheasant_1_tail_2", x = 2605, y = 4773, direction = "SOUTH" }, + { id = "pheasant_2_tails", x = 2598, y = 4780, direction = "SOUTH" }, + { id = "pheasant_2_tails_2", x = 2607, y = 4779, direction = "SOUTH" }, + { id = "pheasant_3_tails", x = 2595, y = 4777, direction = "SOUTH" }, + { id = "pheasant_3_tails_2", x = 2609, y = 4776, direction = "SOUTH" }, + { id = "pheasant_4_tails", x = 2601, y = 4783, direction = "SOUTH" }, + { id = "pheasant_4_tails_2", x = 2604, y = 4769, direction = "SOUTH" }, +] diff --git a/data/activity/event/random/freaky_forester/freaky_forester.npcs.toml b/data/activity/event/random/freaky_forester/freaky_forester.npcs.toml new file mode 100644 index 0000000000..4b64fa2fb1 --- /dev/null +++ b/data/activity/event/random/freaky_forester/freaky_forester.npcs.toml @@ -0,0 +1,43 @@ +[freaky_forester] +id = 2458 +wander_range = 3 +examine = "He's at home in the forests." + +[pheasant_1_tail] +id = 4277 +hitpoints = 50 +att = 3 +str = 3 +def = 3 +style = "stab" +max_hit_melee = 10 +respawn_delay = 5 +examine = "A brightly coloured game bird." + +[pheasant_1_tail_2] +id = 7445 +clone = "pheasant_1_tail" + +[pheasant_2_tails] +id = 7446 +clone = "pheasant_1_tail" + +[pheasant_2_tails_2] +id = 13246 +clone = "pheasant_1_tail" + +[pheasant_3_tails] +id = 13247 +clone = "pheasant_1_tail" + +[pheasant_3_tails_2] +id = 13248 +clone = "pheasant_1_tail" + +[pheasant_4_tails] +id = 13249 +clone = "pheasant_1_tail" + +[pheasant_4_tails_2] +id = 13250 +clone = "pheasant_1_tail" diff --git a/data/activity/event/random/freaky_forester/freaky_forester.vars.toml b/data/activity/event/random/freaky_forester/freaky_forester.vars.toml new file mode 100644 index 0000000000..10712e54cc --- /dev/null +++ b/data/activity/event/random/freaky_forester/freaky_forester.vars.toml @@ -0,0 +1,3 @@ +[freaky_forester_task] +format = "int" +persist = true diff --git a/data/activity/event/random/gift/costume.ifaces.toml b/data/activity/event/random/gift/costume.ifaces.toml new file mode 100644 index 0000000000..19479af704 --- /dev/null +++ b/data/activity/event/random/gift/costume.ifaces.toml @@ -0,0 +1,14 @@ +# Iffie's "Cash in your points for costume items" interface (201). The costume rows are drawn +# entirely by its load script (cs2 2121) - set icons from constants and the "Points:" values from +# the costume_points varbits - as clickable children of the list (component 15) at offsets +# 1, 11, 21, 31, 41 (Mime, Frog, Zombie, Camo, Lederhosen). Component 16 is the scrollbar. +[costume_reward_select] +id = 201 +type = "main_screen" + +[.close] +id = 14 + +[.rewards] +id = 15 +options = { Claim = 0, Examine = 9 } diff --git a/data/activity/event/random/gift/costume.varbits.toml b/data/activity/event/random/gift/costume.varbits.toml new file mode 100644 index 0000000000..db651fc754 --- /dev/null +++ b/data/activity/event/random/gift/costume.varbits.toml @@ -0,0 +1,47 @@ +# Read by interface 201's load script (cs2 2121/2122) - the "Points:" shown beside each +# costume row. All five mirror the player's costume_points balance. +[costume_points_mime] +id = 5903 +format = "int" + +[costume_points_frog] +id = 5904 +format = "int" + +[costume_points_zombie] +id = 5905 +format = "int" + +[costume_points_camo] +id = 5906 +format = "int" + +[costume_points_lederhosen] +id = 5907 +format = "int" + +# Also read by cs2 2123 - a claimed (fully owned) costume greys its row out. The frog mask has +# its own flag separate from the rest of the royal outfit. +[costume_claimed_mime] +id = 2698 +format = "boolean" + +[costume_claimed_frog] +id = 2699 +format = "boolean" + +[costume_claimed_zombie] +id = 2700 +format = "boolean" + +[costume_claimed_camo] +id = 2701 +format = "boolean" + +[costume_claimed_lederhosen] +id = 2702 +format = "boolean" + +[costume_claimed_frog_mask] +id = 2776 +format = "boolean" diff --git a/data/activity/event/random/gift/gift.drops.toml b/data/activity/event/random/gift/gift.drops.toml new file mode 100644 index 0000000000..3d4a757348 --- /dev/null +++ b/data/activity/event/random/gift/gift.drops.toml @@ -0,0 +1,19 @@ +# What's inside a mystery box - an equal shot at each oddment, with one slot rolling the +# rare drop table. https://runescape.wiki/w/Mystery_box_(random_events) +[mystery_box] +roll = 13 +drops = [ + { id = "cabbage" }, + { id = "diamond" }, + { id = "bucket" }, + { id = "coins", amount = 500 }, + { id = "flier" }, + { id = "old_boot" }, + { id = "body_rune" }, + { id = "onion" }, + { id = "mithril_scimitar" }, + { id = "casket" }, + { id = "steel_platebody" }, + { id = "nature_rune", amount = 20 }, + { table = "rare_drop_table" }, +] diff --git a/data/activity/event/random/gift/gift.ifaces.toml b/data/activity/event/random/gift/gift.ifaces.toml new file mode 100644 index 0000000000..0fb421292a --- /dev/null +++ b/data/activity/event/random/gift/gift.ifaces.toml @@ -0,0 +1,18 @@ +# The "Choose Your Reward!" interface (202) opened from a random event gift. The reward choices +# live in the random_event_gift inventory drawn by the grid (component 15); clicking one selects +# it and the confirm button (26) claims it. +[random_event_gift_select] +id = 202 +type = "main_screen" + +[.close] +id = 14 + +[.rewards] +id = 15 +inventory = "random_event_gift" +options = { Select = 0 } + +[.confirm] +id = 26 +options = { Confirm = 0 } diff --git a/data/activity/event/random/gift/gift.invs.toml b/data/activity/event/random/gift/gift.invs.toml new file mode 100644 index 0000000000..63fbcda048 --- /dev/null +++ b/data/activity/event/random/gift/gift.invs.toml @@ -0,0 +1,7 @@ +# The rewards on offer inside a player's random event gift; rolled once when first opened and +# kept until one is claimed. +[random_event_gift] +id = 307 +width = 4 +height = 7 +stack = "Always" diff --git a/data/activity/event/random/gift/gift.tables.toml b/data/activity/event/random/gift/gift.tables.toml new file mode 100644 index 0000000000..2ec5e66a49 --- /dev/null +++ b/data/activity/event/random/gift/gift.tables.toml @@ -0,0 +1,427 @@ +# Category pools for a random event gift's reward slots - one weighted pick per category. +# https://runescape.wiki/w/Random_event_gift +[gift_runes] +item = "item" +weight = "int" +min = "int" +max = "int" + +[.death_rune] +item = "death_rune" +weight = 8 +min = 23 +max = 23 + +[.chaos_rune] +item = "chaos_rune" +weight = 10 +min = 17 +max = 26 + +[.nature_rune] +item = "nature_rune" +weight = 8 +min = 17 +max = 20 + +[.law_rune] +item = "law_rune" +weight = 8 +min = 20 +max = 20 + +[.cosmic_rune] +item = "cosmic_rune" +weight = 8 +min = 20 +max = 23 + +[.mind_rune] +item = "mind_rune" +weight = 10 +min = 17 +max = 23 + +[.body_rune] +item = "body_rune" +weight = 10 +min = 26 +max = 26 + +[.fire_rune] +item = "fire_rune" +weight = 12 +min = 17 +max = 26 + +[.earth_rune] +item = "earth_rune" +weight = 12 +min = 20 +max = 40 + +[.air_rune] +item = "air_rune" +weight = 12 +min = 26 +max = 26 + +[.water_rune] +item = "water_rune" +weight = 12 +min = 20 +max = 26 + +[gift_ores] +item = "item" +weight = "int" +min = "int" +max = "int" + +[.clay] +item = "clay" +weight = 10 +min = 7 +max = 7 + +[.soft_clay] +item = "soft_clay" +weight = 10 +min = 7 +max = 15 + +[.copper_ore] +item = "copper_ore" +weight = 10 +min = 5 +max = 5 + +[.iron_ore] +item = "iron_ore" +weight = 12 +min = 7 +max = 7 + +[.gold_ore] +item = "gold_ore" +weight = 8 +min = 6 +max = 10 + +[.mithril_ore] +item = "mithril_ore" +weight = 6 +min = 9 +max = 25 + +[.adamantite_ore] +item = "adamantite_ore" +weight = 4 +min = 4 +max = 7 + +[.runite_ore] +item = "runite_ore" +weight = 1 +min = 1 +max = 1 + +[gift_bars] +item = "item" +weight = "int" +min = "int" +max = "int" + +[.bronze_bar] +item = "bronze_bar" +weight = 12 +min = 1 +max = 3 + +[.iron_bar] +item = "iron_bar" +weight = 12 +min = 2 +max = 6 + +[.silver_bar] +item = "silver_bar" +weight = 10 +min = 2 +max = 9 + +[.steel_bar] +item = "steel_bar" +weight = 10 +min = 2 +max = 10 + +[.gold_bar] +item = "gold_bar" +weight = 8 +min = 5 +max = 10 + +[.mithril_bar] +item = "mithril_bar" +weight = 6 +min = 5 +max = 10 + +[.adamant_bar] +item = "adamant_bar" +weight = 4 +min = 2 +max = 5 + +[gift_gems] +item = "item" +weight = "int" +min = "int" +max = "int" + +[.uncut_sapphire] +item = "uncut_sapphire" +weight = 12 +min = 1 +max = 6 + +[.uncut_emerald] +item = "uncut_emerald" +weight = 10 +min = 1 +max = 2 + +[.uncut_ruby] +item = "uncut_ruby" +weight = 8 +min = 1 +max = 2 + +[.uncut_diamond] +item = "uncut_diamond" +weight = 5 +min = 1 +max = 3 + +[.sapphire] +item = "sapphire" +weight = 6 +min = 1 +max = 1 + +[.emerald] +item = "emerald" +weight = 5 +min = 1 +max = 1 + +[.ruby] +item = "ruby" +weight = 4 +min = 1 +max = 1 + +[.diamond] +item = "diamond" +weight = 2 +min = 1 +max = 1 + +[gift_herbs] +item = "item" +weight = "int" +min = "int" +max = "int" + +[.grimy_tarromin] +item = "grimy_tarromin" +weight = 12 +min = 1 +max = 4 + +[.grimy_harralander] +item = "grimy_harralander" +weight = 12 +min = 3 +max = 4 + +[.grimy_ranarr] +item = "grimy_ranarr" +weight = 6 +min = 1 +max = 2 + +[.grimy_toadflax] +item = "grimy_toadflax" +weight = 8 +min = 2 +max = 3 + +[.grimy_irit] +item = "grimy_irit" +weight = 10 +min = 2 +max = 4 + +[.grimy_avantoe] +item = "grimy_avantoe" +weight = 8 +min = 2 +max = 4 + +[.grimy_kwuarm] +item = "grimy_kwuarm" +weight = 8 +min = 2 +max = 3 + +[.grimy_snapdragon] +item = "grimy_snapdragon" +weight = 5 +min = 1 +max = 3 + +[.grimy_cadantine] +item = "grimy_cadantine" +weight = 8 +min = 3 +max = 3 + +[.grimy_lantadyme] +item = "grimy_lantadyme" +weight = 6 +min = 1 +max = 2 + +[.grimy_dwarf_weed] +item = "grimy_dwarf_weed" +weight = 6 +min = 2 +max = 2 + +[gift_seeds] +item = "item" +weight = "int" +min = "int" +max = "int" + +[.barley_seed] +item = "barley_seed" +weight = 12 +min = 8 +max = 8 + +[.sweetcorn_seed] +item = "sweetcorn_seed" +weight = 10 +min = 2 +max = 4 + +[.yanillian_seed] +item = "yanillian_seed" +weight = 10 +min = 4 +max = 4 + +[.jute_seed] +item = "jute_seed" +weight = 10 +min = 6 +max = 6 + +[.potato_seed] +item = "potato_seed" +weight = 12 +min = 8 +max = 8 + +[.marigold_seed] +item = "marigold_seed" +weight = 8 +min = 1 +max = 1 + +[.snapdragon_seed] +item = "snapdragon_seed" +weight = 2 +min = 1 +max = 1 + +[gift_charms] +item = "item" +weight = "int" +min = "int" +max = "int" + +[.gold_charm] +item = "gold_charm" +weight = 4 +min = 2 +max = 2 + +[.green_charm] +item = "green_charm" +weight = 3 +min = 2 +max = 2 + +[.crimson_charm] +item = "crimson_charm" +weight = 2 +min = 2 +max = 2 + +[.blue_charm] +item = "blue_charm" +weight = 1 +min = 1 +max = 1 + +[gift_other] +item = "item" +weight = "int" +min = "int" +max = "int" + +[.flax] +item = "flax" +weight = 8 +min = 64 +max = 99 + +[.steel_platebody] +item = "steel_platebody" +weight = 6 +min = 1 +max = 1 + +[.book_of_knowledge] +item = "book_of_knowledge" +weight = 6 +min = 1 +max = 1 + +[.tooth_half_of_a_key] +item = "tooth_half_of_a_key" +weight = 1 +min = 1 +max = 1 + +[.loop_half_of_a_key] +item = "loop_half_of_a_key" +weight = 1 +min = 1 +max = 1 + +[.cabbage] +item = "cabbage" +weight = 4 +min = 1 +max = 1 + +[.bucket] +item = "bucket" +weight = 4 +min = 1 +max = 1 diff --git a/data/activity/event/random/gift/gift.vars.toml b/data/activity/event/random/gift/gift.vars.toml new file mode 100644 index 0000000000..b162feb537 --- /dev/null +++ b/data/activity/event/random/gift/gift.vars.toml @@ -0,0 +1,29 @@ +# The gift reward slot the player has selected (slot + 1; 0 = nothing selected yet). +[gift_selected] +format = "int" + +# Saved up from gifts; spent on any outfit at Iffie's costume store. +[costume_points] +format = "int" +persist = true + +# Earned from each outfit's own random event; only spendable on that outfit. +[mime_costume_points] +format = "int" +persist = true + +[frog_costume_points] +format = "int" +persist = true + +[zombie_costume_points] +format = "int" +persist = true + +[camo_costume_points] +format = "int" +persist = true + +[lederhosen_costume_points] +format = "int" +persist = true diff --git a/data/activity/event/random/gravedigger/gravedigger.ifaces.toml b/data/activity/event/random/gravedigger/gravedigger.ifaces.toml new file mode 100644 index 0000000000..ebe5ed3d48 --- /dev/null +++ b/data/activity/event/random/gravedigger/gravedigger.ifaces.toml @@ -0,0 +1,39 @@ +# Checking a coffin (141) shows the nine items found inside; reading a gravestone (143) shows +# an icon of the profession of whoever ought to be buried beneath it. +[gravedigger_coffin] +id = 141 +type = "main_screen" + +[.item_1] +id = 3 + +[.item_2] +id = 4 + +[.item_3] +id = 5 + +[.item_4] +id = 6 + +[.item_5] +id = 7 + +[.item_6] +id = 8 + +[.item_7] +id = 9 + +[.item_8] +id = 10 + +[.item_9] +id = 11 + +[gravedigger_gravestone] +id = 143 +type = "main_screen" + +[.icon] +id = 2 diff --git a/data/activity/event/random/gravedigger/gravedigger.npcs.toml b/data/activity/event/random/gravedigger/gravedigger.npcs.toml new file mode 100644 index 0000000000..860aaf6e05 --- /dev/null +++ b/data/activity/event/random/gravedigger/gravedigger.npcs.toml @@ -0,0 +1,3 @@ +[leo_gravedigger] +id = 3508 +examine = "He's a busy man." diff --git a/data/activity/event/random/gravedigger/gravedigger.objs.toml b/data/activity/event/random/gravedigger/gravedigger.objs.toml new file mode 100644 index 0000000000..4cccb9ef2d --- /dev/null +++ b/data/activity/event/random/gravedigger/gravedigger.objs.toml @@ -0,0 +1,65 @@ +# Filled graves - the object id shows which profession's coffin lies inside +# (0 lumberjack, 1 cook, 2 miner, 3 farmer, 4 potter). +[gravedigger_grave_0] +id = 12721 +examine = "A grave with a coffin in it." + +[gravedigger_grave_1] +clone = "gravedigger_grave_0" +id = 12722 + +[gravedigger_grave_2] +clone = "gravedigger_grave_0" +id = 12723 + +[gravedigger_grave_3] +clone = "gravedigger_grave_0" +id = 12724 + +[gravedigger_grave_4] +clone = "gravedigger_grave_0" +id = 12725 + +[gravedigger_open_grave_0] +id = 12726 +examine = "An open, empty grave." + +[gravedigger_open_grave_1] +clone = "gravedigger_open_grave_0" +id = 12727 + +[gravedigger_open_grave_2] +clone = "gravedigger_open_grave_0" +id = 12728 + +[gravedigger_open_grave_3] +clone = "gravedigger_open_grave_0" +id = 12729 + +[gravedigger_open_grave_4] +clone = "gravedigger_open_grave_0" +id = 12730 + +[gravedigger_gravestone_0] +id = 12716 +examine = "It shows who ought to be buried here." + +[gravedigger_gravestone_1] +clone = "gravedigger_gravestone_0" +id = 12717 + +[gravedigger_gravestone_2] +clone = "gravedigger_gravestone_0" +id = 12718 + +[gravedigger_gravestone_3] +clone = "gravedigger_gravestone_0" +id = 12719 + +[gravedigger_gravestone_4] +clone = "gravedigger_gravestone_0" +id = 12720 + +[gravedigger_mausoleum] +id = 12731 +examine = "Like a bank, but spookier." diff --git a/data/activity/event/random/gravedigger/gravedigger.vars.toml b/data/activity/event/random/gravedigger/gravedigger.vars.toml new file mode 100644 index 0000000000..1073ef00db --- /dev/null +++ b/data/activity/event/random/gravedigger/gravedigger.vars.toml @@ -0,0 +1,25 @@ +# What lies in each grave site: 0 = open and empty, 1-5 = that coffin (index + 1). +# Everything persists so logging out mid-event resumes where the player left off. +[gravedigger_started] +format = "boolean" +persist = true + +[gravedigger_site_0] +format = "int" +persist = true + +[gravedigger_site_1] +format = "int" +persist = true + +[gravedigger_site_2] +format = "int" +persist = true + +[gravedigger_site_3] +format = "int" +persist = true + +[gravedigger_site_4] +format = "int" +persist = true diff --git a/data/activity/event/random/kiss_the_frog/kiss_the_frog.anims.toml b/data/activity/event/random/kiss_the_frog/kiss_the_frog.anims.toml new file mode 100644 index 0000000000..caa7abc7d3 --- /dev/null +++ b/data/activity/event/random/kiss_the_frog/kiss_the_frog.anims.toml @@ -0,0 +1,11 @@ +[frog_kiss] +id = 2374 + +[morph_from_frog] +id = 2375 + +[human_kiss_the_frog] +id = 2376 + +[morph_to_frog] +id = 2377 diff --git a/data/activity/event/random/kiss_the_frog.npc-spawns.toml b/data/activity/event/random/kiss_the_frog/kiss_the_frog.npc-spawns.toml similarity index 93% rename from data/activity/event/random/kiss_the_frog.npc-spawns.toml rename to data/activity/event/random/kiss_the_frog/kiss_the_frog.npc-spawns.toml index 5d7fcc92b0..b8841a1c70 100644 --- a/data/activity/event/random/kiss_the_frog.npc-spawns.toml +++ b/data/activity/event/random/kiss_the_frog/kiss_the_frog.npc-spawns.toml @@ -13,5 +13,5 @@ spawns = [ { id = "frog_11_frogland", x = 2442, y = 4770 }, { id = "frog_11_frogland", x = 2441, y = 4766 }, { id = "frog_herald", x = 2446, y = 4770, direction = "WEST" }, - { id = "frog_9_frogland", x = 2440, y = 4774 }, + { id = "frog_2_frogland", x = 2440, y = 4774 }, ] \ No newline at end of file diff --git a/data/activity/event/random/kiss_the_frog/kiss_the_frog.npcs.toml b/data/activity/event/random/kiss_the_frog/kiss_the_frog.npcs.toml new file mode 100644 index 0000000000..7ea9aeea0f --- /dev/null +++ b/data/activity/event/random/kiss_the_frog/kiss_the_frog.npcs.toml @@ -0,0 +1,68 @@ +[frog_frogland] +id = 390 +dialogue = "old" +wander_range = 3 +examine = "A frog." + +[frog_2_frogland] +clone = "frog_frogland" +id = 391 +dialogue = "old" + +[frog_3_frogland] +clone = "frog_frogland" +id = 392 +dialogue = "old" + +[frog_4_frogland] +clone = "frog_frogland" +id = 393 +dialogue = "old" + +[frog_5_frogland] +clone = "frog_frogland" +id = 394 +dialogue = "old" + +[frog_6_frogland] +clone = "frog_frogland" +id = 395 +dialogue = "old" + +[frog_7_frogland] +clone = "frog_frogland" +id = 396 +dialogue = "old" + +[frog_8_frogland] +clone = "frog_frogland" +id = 403 +dialogue = "old" + +[frog_herald] +id = 2472 +dialogue = "old" +examine = "Speaks on behalf of His and Her Royal Frogness." + +[frog_prince] +id = 2474 +examine = "A handsome prince." + +[frog_princess] +id = 2475 +examine = "A beautiful princess." + +[frog_9_frogland] +clone = "frog_frogland" +id = 3300 +dialogue = "old" + +[frog_10_frogland] +clone = "frog_frogland" +id = 8636 +dialogue = "old" + +[frog_11_frogland] +clone = "frog_frogland" +id = 8637 +dialogue = "old" diff --git a/data/activity/event/random/kiss_the_frog/kiss_the_frog.vars.toml b/data/activity/event/random/kiss_the_frog/kiss_the_frog.vars.toml new file mode 100644 index 0000000000..7a4aa08c8f --- /dev/null +++ b/data/activity/event/random/kiss_the_frog/kiss_the_frog.vars.toml @@ -0,0 +1,13 @@ +[ktf_fail] +format = "boolean" +persist = true + +[ktf_offended] +format = "int" +persist = true + +[ktf_crown] +format = "int" + +[ktf_escape] +format = "int" diff --git a/data/activity/event/random/lost_and_found/lost_and_found.objs.toml b/data/activity/event/random/lost_and_found/lost_and_found.objs.toml new file mode 100644 index 0000000000..36bc6a230f --- /dev/null +++ b/data/activity/event/random/lost_and_found/lost_and_found.objs.toml @@ -0,0 +1,23 @@ +[abyss_appendage_1] +id = 8998 + +[abyss_appendage_2] +id = 8999 + +[abyss_appendage_3] +id = 9000 + +[abyss_appendage_4] +id = 9001 + +[abyss_appendage_5] +id = 9002 + +[abyss_appendage_6] +id = 9003 + +[abyss_appendage_7] +id = 9004 + +[abyss_appendage_8] +id = 9005 diff --git a/data/activity/event/random/lost_and_found/lost_and_found.varbits.toml b/data/activity/event/random/lost_and_found/lost_and_found.varbits.toml new file mode 100644 index 0000000000..0a02409c00 --- /dev/null +++ b/data/activity/event/random/lost_and_found/lost_and_found.varbits.toml @@ -0,0 +1,15 @@ +[lost_and_found_appendage_1] +id = 874 +format = "int" + +[lost_and_found_appendage_2] +id = 875 +format = "int" + +[lost_and_found_appendage_3] +id = 876 +format = "int" + +[lost_and_found_appendage_4] +id = 877 +format = "int" diff --git a/data/activity/event/random/lost_and_found/lost_and_found.vars.toml b/data/activity/event/random/lost_and_found/lost_and_found.vars.toml new file mode 100644 index 0000000000..50b537a60b --- /dev/null +++ b/data/activity/event/random/lost_and_found/lost_and_found.vars.toml @@ -0,0 +1,3 @@ +# Not persisted: the appendage varbits reshuffle on relog and the odd slot is re-picked with them. +[laf_odd] +format = "int" diff --git a/data/activity/event/random/maze/maze.ifaces.toml b/data/activity/event/random/maze/maze.ifaces.toml new file mode 100644 index 0000000000..0b5db0fcdc --- /dev/null +++ b/data/activity/event/random/maze/maze.ifaces.toml @@ -0,0 +1,3 @@ +[maze_timer] +id = 209 +type = "overlay" diff --git a/data/activity/event/random/maze/maze.objs.toml b/data/activity/event/random/maze/maze.objs.toml new file mode 100644 index 0000000000..6ee04427a8 --- /dev/null +++ b/data/activity/event/random/maze/maze.objs.toml @@ -0,0 +1,47 @@ +[maze_wall] +id = 3626 + +# The cache has no swung-open wall variant; the opened ids reuse the same +# object which the door handler swings by rotation. Opened ids are defined +# first so map objects resolve to the closed state. +[maze_door_1_opened] +id = 3628 + +[maze_door_2_opened] +id = 3629 + +[maze_door_3_opened] +id = 3630 + +[maze_door_4_opened] +id = 3631 + +[maze_door_5_opened] +id = 3632 + +[maze_door_1_closed] +id = 3628 + +[maze_door_2_closed] +id = 3629 + +[maze_door_3_closed] +id = 3630 + +[maze_door_4_closed] +id = 3631 + +[maze_door_5_closed] +id = 3632 + +[maze_chest] +id = 3635 +examine = "A chest, I wonder what's inside." + +[maze_chest_opened] +id = 3636 +examine = "An open chest." + +[strange_shrine] +id = 3634 +examine = "A strange shrine." diff --git a/data/activity/event/random/maze/maze.tables.toml b/data/activity/event/random/maze/maze.tables.toml new file mode 100644 index 0000000000..9fd0166198 --- /dev/null +++ b/data/activity/event/random/maze/maze.tables.toml @@ -0,0 +1,78 @@ +[maze_chest_rewards] +item = "item" +amount = "int" +text = "string" + +[.air_rune] +item = "air_rune" +amount = 15 +text = "You've found some air runes!" + +[.water_rune] +item = "water_rune" +amount = 10 +text = "You've found some water runes!" + +[.earth_rune] +item = "earth_rune" +amount = 10 +text = "You've found some earth runes!" + +[.fire_rune] +item = "fire_rune" +amount = 10 +text = "You've found some fire runes!" + +[.bronze_arrow] +item = "bronze_arrow" +amount = 20 +text = "You've found some bronze arrows!" + +[.bronze_bolts] +item = "bronze_bolts" +amount = 10 +text = "You've found some bronze bolts!" + +[.iron_arrow] +item = "iron_arrow" +amount = 15 +text = "You've found some iron arrows!" + +[.attack_potion] +item = "attack_potion_2" +amount = 1 +text = "You've found an attack potion!" + +[.strength_potion] +item = "strength_potion_2" +amount = 1 +text = "You've found a strength potion!" + +[.defence_potion] +item = "defence_potion_2" +amount = 1 +text = "You've found a defence potion!" + +[maze_start_points] +tile = "tile" + +[.south_east] +tile = { x = 2928, y = 4553 } + +[.south_middle] +tile = { x = 2917, y = 4553 } + +[.south_west] +tile = { x = 2908, y = 4555 } + +[.west_south] +tile = { x = 2891, y = 4589 } + +[.west_north] +tile = { x = 2891, y = 4595 } + +[.north_middle] +tile = { x = 2926, y = 4597 } + +[.north_east] +tile = { x = 2931, y = 4597 } diff --git a/data/activity/event/random/maze/maze.varps.toml b/data/activity/event/random/maze/maze.varps.toml new file mode 100644 index 0000000000..8256de9173 --- /dev/null +++ b/data/activity/event/random/maze/maze.varps.toml @@ -0,0 +1,3 @@ +[maze_timer_bar] +id = 531 +format = "int" diff --git a/data/activity/event/random/maze/maze.vars.toml b/data/activity/event/random/maze/maze.vars.toml new file mode 100644 index 0000000000..0fedb7d508 --- /dev/null +++ b/data/activity/event/random/maze/maze.vars.toml @@ -0,0 +1,3 @@ +[maze_chests_opened] +format = "int" +persist = true diff --git a/data/activity/event/random/mime_theatre/mime_theatre.anims.toml b/data/activity/event/random/mime_theatre/mime_theatre.anims.toml new file mode 100644 index 0000000000..be149657a2 --- /dev/null +++ b/data/activity/event/random/mime_theatre/mime_theatre.anims.toml @@ -0,0 +1,7 @@ +[mime_spotlight_off] +id = 1135 +ticks = 3 + +[mime_spotlight_on] +id = 1136 +ticks = 3 diff --git a/data/activity/event/random/mime_theatre/mime_theatre.objs.toml b/data/activity/event/random/mime_theatre/mime_theatre.objs.toml new file mode 100644 index 0000000000..7df8bfe623 --- /dev/null +++ b/data/activity/event/random/mime_theatre/mime_theatre.objs.toml @@ -0,0 +1,3 @@ +[mime_spotlight] +id = 3644 +examine = "A theatre spotlight." diff --git a/data/activity/event/random/mime_theatre/mime_theatre.vars.toml b/data/activity/event/random/mime_theatre/mime_theatre.vars.toml new file mode 100644 index 0000000000..96f333ef9f --- /dev/null +++ b/data/activity/event/random/mime_theatre/mime_theatre.vars.toml @@ -0,0 +1,7 @@ +[mime_emote] +format = "string" +persist = true + +[mime_correct] +format = "int" +persist = true diff --git a/data/activity/event/random/pillory/pillory.ifaces.toml b/data/activity/event/random/pillory/pillory.ifaces.toml new file mode 100644 index 0000000000..d2238ec676 --- /dev/null +++ b/data/activity/event/random/pillory/pillory.ifaces.toml @@ -0,0 +1,47 @@ +# Pillory lock puzzle (interface 189). The spinning lock (child 4) and three swinging keys (children +# 5-7) are model components we set each round; children 8-10 are the clickable key selectors +# (setting=2 in the cache => interface option), and children 11-16 are the progress padlocks. +[pillory_lock] +id = 189 + +[.lock] +id = 4 + +[.key_1] +id = 5 + +[.key_2] +id = 6 + +[.key_3] +id = 7 + +[.button_1] +id = 8 +options = { Select = 0 } + +[.button_2] +id = 9 +options = { Select = 0 } + +[.button_3] +id = 10 +options = { Select = 0 } + +[.padlock_1] +id = 11 + +[.padlock_2] +id = 12 + +[.padlock_3] +id = 13 + +[.padlock_4] +id = 14 + +[.padlock_5] +id = 15 + +[.padlock_6] +id = 16 diff --git a/data/activity/event/random/pillory/pillory.npcs.toml b/data/activity/event/random/pillory/pillory.npcs.toml new file mode 100644 index 0000000000..f89ed634f0 --- /dev/null +++ b/data/activity/event/random/pillory/pillory.npcs.toml @@ -0,0 +1,4 @@ +[pillory_guard] +id = 2791 +categories = ["human"] +examine = "He locks miscreants in the pillory." diff --git a/data/activity/event/random/pillory/pillory.objs.toml b/data/activity/event/random/pillory/pillory.objs.toml new file mode 100644 index 0000000000..6997d1ccd8 --- /dev/null +++ b/data/activity/event/random/pillory/pillory.objs.toml @@ -0,0 +1,3 @@ +[pillory_cage] +id = 777 +examine = "A pillory to lock up miscreants." diff --git a/data/activity/event/random/pillory.shops.toml b/data/activity/event/random/pillory/pillory.shops.toml similarity index 100% rename from data/activity/event/random/pillory.shops.toml rename to data/activity/event/random/pillory/pillory.shops.toml diff --git a/data/activity/event/random/pillory/pillory.vars.toml b/data/activity/event/random/pillory/pillory.vars.toml new file mode 100644 index 0000000000..5a7b1aa0d6 --- /dev/null +++ b/data/activity/event/random/pillory/pillory.vars.toml @@ -0,0 +1,10 @@ +[pillory_target] +format = "int" +persist = true + +[pillory_correct] +format = "int" +persist = true + +[pillory_answer] +format = "int" diff --git a/data/activity/event/random/pinball/pinball.anims.toml b/data/activity/event/random/pinball/pinball.anims.toml new file mode 100644 index 0000000000..cdb72258c5 --- /dev/null +++ b/data/activity/event/random/pinball/pinball.anims.toml @@ -0,0 +1,5 @@ +[pinball_post_flash] +id = 4005 + +[pinball_post_reset] +id = 4006 diff --git a/data/activity/event/random/pinball/pinball.ifaces.toml b/data/activity/event/random/pinball/pinball.ifaces.toml new file mode 100644 index 0000000000..33f8985092 --- /dev/null +++ b/data/activity/event/random/pinball/pinball.ifaces.toml @@ -0,0 +1,6 @@ +[pinball_overlay] +id = 263 +type = "overlay" + +[.score] +id = 1 diff --git a/data/activity/event/random/pinball/pinball.npc-spawns.toml b/data/activity/event/random/pinball/pinball.npc-spawns.toml deleted file mode 100644 index 69e5d3680d..0000000000 --- a/data/activity/event/random/pinball/pinball.npc-spawns.toml +++ /dev/null @@ -1,4 +0,0 @@ -spawns = [ - { id = "flippa_pinball", x = 1973, y = 5038 }, - { id = "tilt_pinball", x = 1970, y = 5038 }, -] diff --git a/data/activity/event/random/pinball/pinball.objs.toml b/data/activity/event/random/pinball/pinball.objs.toml new file mode 100644 index 0000000000..6c97501865 --- /dev/null +++ b/data/activity/event/random/pinball/pinball.objs.toml @@ -0,0 +1,17 @@ +[pinball_post_1] +id = 15001 + +[pinball_post_2] +id = 15003 + +[pinball_post_3] +id = 15005 + +[pinball_post_4] +id = 15007 + +[pinball_post_5] +id = 15009 + +[pinball_cave_exit] +id = 15010 diff --git a/data/activity/event/random/pinball/pinball.vars.toml b/data/activity/event/random/pinball/pinball.vars.toml new file mode 100644 index 0000000000..df4e4227d9 --- /dev/null +++ b/data/activity/event/random/pinball/pinball.vars.toml @@ -0,0 +1,7 @@ +[pinball_score] +format = "int" +persist = true + +[pinball_target] +format = "int" +persist = true diff --git a/data/activity/event/random/prison_pete/prison_pete.anims.toml b/data/activity/event/random/prison_pete/prison_pete.anims.toml new file mode 100644 index 0000000000..10396be4b2 --- /dev/null +++ b/data/activity/event/random/prison_pete/prison_pete.anims.toml @@ -0,0 +1,11 @@ +[prison_pete_pull_lever] +id = 798 + +[prison_pete_lever_pull] +id = 2899 + +[prison_pete_stomp] +id = 794 + +[prison_pete_pick_up] +id = 827 diff --git a/data/activity/event/random/prison_pete/prison_pete.gfx.toml b/data/activity/event/random/prison_pete/prison_pete.gfx.toml new file mode 100644 index 0000000000..e24ff18c72 --- /dev/null +++ b/data/activity/event/random/prison_pete/prison_pete.gfx.toml @@ -0,0 +1,2 @@ +[prison_pete_balloon_pop] +id = 524 diff --git a/data/activity/event/random/prison_pete/prison_pete.ifaces.toml b/data/activity/event/random/prison_pete/prison_pete.ifaces.toml new file mode 100644 index 0000000000..6ae0f31b79 --- /dev/null +++ b/data/activity/event/random/prison_pete/prison_pete.ifaces.toml @@ -0,0 +1,6 @@ +[prison_pete_balloons] +id = 273 +type = "main_screen" + +[.model] +id = 3 diff --git a/data/activity/event/random/prison_pete/prison_pete.npcs.toml b/data/activity/event/random/prison_pete/prison_pete.npcs.toml new file mode 100644 index 0000000000..6026852726 --- /dev/null +++ b/data/activity/event/random/prison_pete/prison_pete.npcs.toml @@ -0,0 +1,23 @@ +[prison_pete] +id = 3118 +examine = "He wants to escape!" + +[balloon_animal_dog] +id = 11232 +wander_range = 3 +examine = "It looks like an animal." + +[balloon_animal_cat] +id = 11233 +wander_range = 3 +examine = "It looks like an animal." + +[balloon_animal_sheep] +id = 11234 +wander_range = 3 +examine = "It looks like an animal." + +[balloon_animal_goat] +id = 11235 +wander_range = 3 +examine = "It looks like an animal." diff --git a/data/activity/event/random/prison_pete/prison_pete.obj-spawns.toml b/data/activity/event/random/prison_pete/prison_pete.obj-spawns.toml new file mode 100644 index 0000000000..ff4415114f --- /dev/null +++ b/data/activity/event/random/prison_pete/prison_pete.obj-spawns.toml @@ -0,0 +1,4 @@ +spawns = [ + { id = "prison_pete_gate_west", x = 2085, y = 4459, type = 0, rotation = 1 }, + { id = "prison_pete_gate_east", x = 2086, y = 4459, type = 0, rotation = 1 }, +] diff --git a/data/activity/event/random/prison_pete/prison_pete.objs.toml b/data/activity/event/random/prison_pete/prison_pete.objs.toml new file mode 100644 index 0000000000..e2164e8a81 --- /dev/null +++ b/data/activity/event/random/prison_pete/prison_pete.objs.toml @@ -0,0 +1,8 @@ +[prison_pete_lever] +id = 10817 + +[prison_pete_gate_west] +id = 2134 + +[prison_pete_gate_east] +id = 2138 diff --git a/data/activity/event/random/prison_pete/prison_pete.sounds.toml b/data/activity/event/random/prison_pete/prison_pete.sounds.toml new file mode 100644 index 0000000000..d6fc4e3bbf --- /dev/null +++ b/data/activity/event/random/prison_pete/prison_pete.sounds.toml @@ -0,0 +1,2 @@ +[prison_pete_balloon_pop] +id = 3252 diff --git a/data/activity/event/random/prison_pete/prison_pete.vars.toml b/data/activity/event/random/prison_pete/prison_pete.vars.toml new file mode 100644 index 0000000000..fb23cbfe96 --- /dev/null +++ b/data/activity/event/random/prison_pete/prison_pete.vars.toml @@ -0,0 +1,11 @@ +[prison_pete_keys] +format = "int" +persist = true + +[prison_pete_target] +format = "string" +persist = true + +[prison_pete_pending] +format = "int" +persist = true diff --git a/data/activity/event/random/quiz_master/quiz_master.anims.toml b/data/activity/event/random/quiz_master/quiz_master.anims.toml new file mode 100644 index 0000000000..ab10397a03 --- /dev/null +++ b/data/activity/event/random/quiz_master/quiz_master.anims.toml @@ -0,0 +1,2 @@ +[quiz_show_sit] +id = 2378 diff --git a/data/activity/event/random/quiz_master/quiz_master.npcs.toml b/data/activity/event/random/quiz_master/quiz_master.npcs.toml new file mode 100644 index 0000000000..0a5b8787d2 --- /dev/null +++ b/data/activity/event/random/quiz_master/quiz_master.npcs.toml @@ -0,0 +1,5 @@ +[quiz_master] +id = 2477 +interact_distance = 4 +categories = ["human"] +examine = "Apparently a master of quizzes!" diff --git a/data/activity/event/random/quiz_master/quiz_master.vars.toml b/data/activity/event/random/quiz_master/quiz_master.vars.toml new file mode 100644 index 0000000000..2530f7c093 --- /dev/null +++ b/data/activity/event/random/quiz_master/quiz_master.vars.toml @@ -0,0 +1,7 @@ +[quiz_answer] +format = "int" +persist = true + +[quiz_correct] +format = "int" +persist = true diff --git a/data/activity/event/random/random-events.npcs.toml b/data/activity/event/random/random-events.npcs.toml deleted file mode 100644 index 7acfd6c2d2..0000000000 --- a/data/activity/event/random/random-events.npcs.toml +++ /dev/null @@ -1,110 +0,0 @@ -[frog_frogland] -id = 390 -wander_range = 3 -examine = "A frog." - -[frog_2_frogland] -clone = "frog_frogland" -id = 391 - -[frog_3_frogland] -clone = "frog_frogland" -id = 392 - -[frog_4_frogland] -clone = "frog_frogland" -id = 393 - -[frog_5_frogland] -clone = "frog_frogland" -id = 394 - -[frog_6_frogland] -clone = "frog_frogland" -id = 395 - -[frog_7_frogland] -clone = "frog_frogland" -id = 396 - -[frog_8_frogland] -clone = "frog_frogland" -id = 403 - -[giant_surprise_exam] -id = 487 -examine = "I guess he wants to be more than just the muscle." - -[mummy_surprise_exam] -id = 490 -examine = "If the mummy is at school, where are the kids?" - -[frog_herald] -id = 2472 -examine = "Speaks on behalf of His and Her Royal Frogness." - -[quiz_master] -id = 2477 -interact_distance = 4 -categories = ["human"] -examine = "Apparently a master of quizzes!" - -[prison_pete] -id = 3118 -examine = "He wants to escape!" - -[frog_9_frogland] -clone = "frog_frogland" -id = 3300 - -[mr_mordau] -id = 6117 -examine = "Professor of Unnatural History." - -[zombie_surprise_exam] -id = 6131 -examine = "Brains!" - -[goblin_surprise_exam] -id = 6132 -examine = "The studious one." - -[goblin_2_surprise_exam] -id = 6133 -examine = "He looks bored." - -[dunce_surprise_exam] -id = 6134 -examine = "This imp is clearly the class clown." - -[sandwich_lady] -id = 8629 -examine = "The sandwich lady." - -[frog_10_frogland] -clone = "frog_frogland" -id = 8636 - -[frog_11_frogland] -clone = "frog_frogland" -id = 8637 - -[balloon_animal_dog] -id = 11232 -wander_range = 3 -examine = "It looks like an animal." - -[balloon_animal_cat] -id = 11233 -wander_range = 3 -examine = "It looks like an animal." - -[balloon_animal_sheep] -id = 11234 -wander_range = 3 -examine = "It looks like an animal." - -[balloon_animal_goat] -id = 11235 -wander_range = 3 -examine = "It looks like an animal." \ No newline at end of file diff --git a/data/activity/event/random/random_event.areas.toml b/data/activity/event/random/random_event.areas.toml new file mode 100644 index 0000000000..c8b5d5257b --- /dev/null +++ b/data/activity/event/random/random_event.areas.toml @@ -0,0 +1,23 @@ +[random_event_exile_lumbridge] +x = [3221, 3223] +y = [3217, 3219] + +[random_event_exile_varrock] +x = [3211, 3213] +y = [3422, 3424] + +[random_event_exile_falador] +x = [2963, 2965] +y = [3377, 3379] + +[random_event_exile_edgeville] +x = [3092, 3094] +y = [3493, 3495] + +[random_event_exile_draynor] +x = [3092, 3094] +y = [3243, 3245] + +[random_event_exile_al_kharid] +x = [3292, 3294] +y = [3183, 3185] diff --git a/data/activity/event/random/random_event.drops.toml b/data/activity/event/random/random_event.drops.toml deleted file mode 100644 index 710d8e793c..0000000000 --- a/data/activity/event/random/random_event.drops.toml +++ /dev/null @@ -1,5 +0,0 @@ -[pheasant_drop_table] -type = "all" -drops = [ - { id = "raw_pheasant" }, -] diff --git a/data/activity/event/random/random_event.items.toml b/data/activity/event/random/random_event.items.toml index 085fa8b700..e09ec7bddb 100644 --- a/data/activity/event/random/random_event.items.toml +++ b/data/activity/event/random/random_event.items.toml @@ -1,3 +1,7 @@ +[push_up] +id = 10946 +examine = "A sign." + [run] id = 10947 examine = "A sign." diff --git a/data/activity/event/random/random_event.npc-spawns.toml b/data/activity/event/random/random_event.npc-spawns.toml index c3603f8004..7c0c26dcd0 100644 --- a/data/activity/event/random/random_event.npc-spawns.toml +++ b/data/activity/event/random/random_event.npc-spawns.toml @@ -2,7 +2,7 @@ spawns = [ # 7754 { id = "quiz_master", x = 1952, y = 4768, level = 1, direction = "SOUTH" }, # 8261 - { id = "prison_pete", x = 2084, y = 4466, direction = "NORTH" }, + { id = "prison_pete", x = 2084, y = 4460, direction = "NORTH" }, { id = "balloon_animal_dog", x = 2078, y = 4462 }, { id = "balloon_animal_dog", x = 2090, y = 4464 }, { id = "balloon_animal_dog", x = 2091, y = 4461 }, diff --git a/data/activity/event/random/random_event.npcs.toml b/data/activity/event/random/random_event.npcs.toml index f3d8243a29..77ad89d3ed 100644 --- a/data/activity/event/random/random_event.npcs.toml +++ b/data/activity/event/random/random_event.npcs.toml @@ -1,44 +1,3 @@ -[freaky_forester] -id = 2458 -wander_range = 3 -examine = "He's at home in the forests." - -[pheasant_1_tail] -id = 4277 -hitpoints = 50 -att = 3 -str = 3 -def = 3 -style = "stab" -max_hit_melee = 10 -respawn_delay = 5 -drop_table = "pheasant" -examine = "A brightly coloured game bird." - -[pheasant_1_tail_2] -id = 7445 -clone = "pheasant_1_tail" - -[pheasant_2_tails] -id = 7446 -clone = "pheasant_1_tail" - -[pheasant_2_tails_2] -id = 13246 -clone = "pheasant_1_tail" - -[pheasant_3_tails] -id = 13247 -clone = "pheasant_1_tail" - -[pheasant_3_tails_2] -id = 13248 -clone = "pheasant_1_tail" - -[pheasant_4_tails] -id = 13249 -clone = "pheasant_1_tail" - -[pheasant_4_tails_2] -id = 13250 -clone = "pheasant_1_tail" \ No newline at end of file +[mysterious_old_man] +id = 410 +examine = "An old man with an aura of magic." diff --git a/data/activity/event/random/random_event.tables.toml b/data/activity/event/random/random_event.tables.toml new file mode 100644 index 0000000000..777c36c614 --- /dev/null +++ b/data/activity/event/random/random_event.tables.toml @@ -0,0 +1,93 @@ +[random_events] +event = "string" +weight = "int" + +[.maze] +event = "maze" +weight = 10 + +[.freaky_forester] +event = "freaky_forester" +weight = 10 + +[.sandwich_lady] +event = "sandwich_lady" +weight = 10 + +[.drill_demon] +event = "drill_demon" +weight = 10 + +[.certer] +event = "certer" +weight = 10 + +[.mime] +event = "mime" +weight = 10 + +[.evil_bob] +event = "evil_bob" +weight = 10 + +[.quiz_master] +event = "quiz_master" +weight = 10 + +[.pillory] +event = "pillory" +weight = 10 + +[.surprise_exam] +event = "surprise_exam" +weight = 10 + +[.kiss_the_frog] +event = "kiss_the_frog" +weight = 10 + +[.pinball] +event = "pinball" +weight = 10 + +[.prison_pete] +event = "prison_pete" +weight = 10 + +[.capn_arnav] +event = "capn_arnav" +weight = 10 + +[.beekeeper] +event = "beekeeper" +weight = 10 + +[.evil_twin] +event = "evil_twin" +weight = 10 + +[.gravedigger] +event = "gravedigger" +weight = 10 + +[random_event_exiles] +area = "string" + +[.lumbridge] +area = "random_event_exile_lumbridge" + +[.varrock] +area = "random_event_exile_varrock" + +[.falador] +area = "random_event_exile_falador" + +[.edgeville] +area = "random_event_exile_edgeville" + +[.draynor] +area = "random_event_exile_draynor" + +[.al_kharid] +area = "random_event_exile_al_kharid" + diff --git a/data/activity/event/random/random_event.vars.toml b/data/activity/event/random/random_event.vars.toml new file mode 100644 index 0000000000..b3f409ad21 --- /dev/null +++ b/data/activity/event/random/random_event.vars.toml @@ -0,0 +1,15 @@ +[random_event] +format = "string" +persist = true + +[random_event_origin] +format = "int" +persist = true + +[random_event_cooldown] +format = "int" +persist = true + +[random_events_disabled] +format = "boolean" +persist = true diff --git a/data/activity/event/random/sandwich_lady/sandwich_lady.anims.toml b/data/activity/event/random/sandwich_lady/sandwich_lady.anims.toml new file mode 100644 index 0000000000..1b280d1dbf --- /dev/null +++ b/data/activity/event/random/sandwich_lady/sandwich_lady.anims.toml @@ -0,0 +1,3 @@ +# The Sandwich Lady's baguette swing when she knocks out a player who picked wrong. +[sandwich_lady_knockout] +id = 3045 diff --git a/data/activity/event/random/sandwich_lady/sandwich_lady.ifaces.toml b/data/activity/event/random/sandwich_lady/sandwich_lady.ifaces.toml new file mode 100644 index 0000000000..1490cdd00c --- /dev/null +++ b/data/activity/event/random/sandwich_lady/sandwich_lady.ifaces.toml @@ -0,0 +1,28 @@ +# The Sandwich Lady's refreshment tray. The seven "Choose refreshment" buttons +# (components 10-22) are named after the food each one hands out. +[sandwich_lady_select] +id = 297 + +[.baguette] +id = 10 + +[.triangle_sandwich] +id = 12 + +[.square_sandwich] +id = 14 + +[.roll] +id = 16 + +[.meat_pie] +id = 18 + +[.doughnut] +id = 20 + +[.chocolate_bar] +id = 22 + +[.title] +id = 48 diff --git a/data/activity/event/random/sandwich_lady/sandwich_lady.npcs.toml b/data/activity/event/random/sandwich_lady/sandwich_lady.npcs.toml new file mode 100644 index 0000000000..3ce052a144 --- /dev/null +++ b/data/activity/event/random/sandwich_lady/sandwich_lady.npcs.toml @@ -0,0 +1,3 @@ +[sandwich_lady] +id = 8629 +examine = "The sandwich lady." diff --git a/data/activity/event/random/sandwich_lady/sandwich_lady.vars.toml b/data/activity/event/random/sandwich_lady/sandwich_lady.vars.toml new file mode 100644 index 0000000000..749a6950d8 --- /dev/null +++ b/data/activity/event/random/sandwich_lady/sandwich_lady.vars.toml @@ -0,0 +1,5 @@ +[sandwich_lady_food] +format = "string" + +[sandwich_lady_npc] +format = "int" diff --git a/data/activity/event/random/surprise_exam/surprise_exam.ifaces.toml b/data/activity/event/random/surprise_exam/surprise_exam.ifaces.toml new file mode 100644 index 0000000000..4b021e7647 --- /dev/null +++ b/data/activity/event/random/surprise_exam/surprise_exam.ifaces.toml @@ -0,0 +1,31 @@ +# Mr Mordaut's "what comes next?" pattern quiz (interface 103), a centred main-window modal. The +# three pattern icons sit on children 6-8; the four answer icons on children 10-13 are the clickable +# options, declared with a Select action so clicks resolve as interface options. +[surprise_exam_pattern] +id = 103 +type = "main_screen" + +[.pattern_1] +id = 6 + +[.pattern_2] +id = 7 + +[.pattern_3] +id = 8 + +[.option_1] +id = 10 +options = { Select = 0 } + +[.option_2] +id = 11 +options = { Select = 0 } + +[.option_3] +id = 12 +options = { Select = 0 } + +[.option_4] +id = 13 +options = { Select = 0 } diff --git a/data/activity/event/random/surprise_exam/surprise_exam.npcs.toml b/data/activity/event/random/surprise_exam/surprise_exam.npcs.toml new file mode 100644 index 0000000000..bf100e9c28 --- /dev/null +++ b/data/activity/event/random/surprise_exam/surprise_exam.npcs.toml @@ -0,0 +1,27 @@ +[giant_surprise_exam] +id = 487 +examine = "I guess he wants to be more than just the muscle." + +[mummy_surprise_exam] +id = 490 +examine = "If the mummy is at school, where are the kids?" + +[mr_mordau] +id = 6117 +examine = "Professor of Unnatural History." + +[zombie_surprise_exam] +id = 6131 +examine = "Brains!" + +[goblin_surprise_exam] +id = 6132 +examine = "The studious one." + +[goblin_2_surprise_exam] +id = 6133 +examine = "He looks bored." + +[dunce_surprise_exam] +id = 6134 +examine = "This imp is clearly the class clown." diff --git a/data/activity/event/random/surprise_exam/surprise_exam.objs.toml b/data/activity/event/random/surprise_exam/surprise_exam.objs.toml new file mode 100644 index 0000000000..e76edbe4de --- /dev/null +++ b/data/activity/event/random/surprise_exam/surprise_exam.objs.toml @@ -0,0 +1,12 @@ +# The four coloured exam doors in Mr Mordaut's classroom (region 7502). Ids match the 634 cache. +[exam_door_red] +id = 2188 + +[exam_door_blue] +id = 2189 + +[exam_door_purple] +id = 2192 + +[exam_door_green] +id = 2193 diff --git a/data/activity/event/random/surprise_exam/surprise_exam.vars.toml b/data/activity/event/random/surprise_exam/surprise_exam.vars.toml new file mode 100644 index 0000000000..5004f1c460 --- /dev/null +++ b/data/activity/event/random/surprise_exam/surprise_exam.vars.toml @@ -0,0 +1,10 @@ +[surprise_exam_answer] +format = "int" + +[surprise_exam_correct] +format = "int" +persist = true + +[surprise_exam_door] +format = "string" +persist = true diff --git a/data/area/fremennik_province/waterbirth_island/waterbirth_island.areas.toml b/data/area/fremennik_province/waterbirth_island/waterbirth_island.areas.toml index 5ddd690f6d..753fc26cce 100644 --- a/data/area/fremennik_province/waterbirth_island/waterbirth_island.areas.toml +++ b/data/area/fremennik_province/waterbirth_island/waterbirth_island.areas.toml @@ -11,10 +11,15 @@ tags = ["multi_combat"] [waterbirth_island_multi_area] x = [1792, 1983] y = [4352, 4415] -tags = ["multi_combat"] +tags = ["multi_combat", "no_random_events"] [waterbirth] x = [2496, 2559] y = [3712, 3775] -tags = ["penguin_area"] +tags = ["penguin_area", "no_random_events"] hint = "on a small crescent island." + +[waterbirth_island_dungeon] +x = [2432, 2559] +y = [10112, 10175] +tags = ["no_random_events"] diff --git a/data/area/kandarin/ancient_cavern/ancient_cavern.areas.toml b/data/area/kandarin/ancient_cavern/ancient_cavern.areas.toml index b84a9fa55b..8da199eccf 100644 --- a/data/area/kandarin/ancient_cavern/ancient_cavern.areas.toml +++ b/data/area/kandarin/ancient_cavern/ancient_cavern.areas.toml @@ -1,8 +1,14 @@ [kuradals_dungeon] x = [1600, 1663] y = [5248, 5311] +tags = ["no_random_events"] [kuradals_teleport] x = [1736, 1739] y = [5311, 5313] level = 1 + +[ancient_cavern] +x = [1728, 1791] +y = [5312, 5375] +tags = ["no_random_events"] \ No newline at end of file diff --git a/data/area/kandarin/seers_village/seers_village.npcs.toml b/data/area/kandarin/seers_village/seers_village.npcs.toml index 30aab92e77..608c621f14 100644 --- a/data/area/kandarin/seers_village/seers_village.npcs.toml +++ b/data/area/kandarin/seers_village/seers_village.npcs.toml @@ -72,9 +72,6 @@ id = 11456 [barbarian_seers_village_6] id = 12189 -[barbarian_seers_village_7] -id = 3508 - [barbarian_seers_village_8] id = 12234 diff --git a/data/area/misthalin/lumbridge/swamp/caves/lumbridge_swamp_caves.areas.toml b/data/area/misthalin/lumbridge/swamp/caves/lumbridge_swamp_caves.areas.toml index fc3c9aca8b..b72bf19ce7 100644 --- a/data/area/misthalin/lumbridge/swamp/caves/lumbridge_swamp_caves.areas.toml +++ b/data/area/misthalin/lumbridge/swamp/caves/lumbridge_swamp_caves.areas.toml @@ -14,7 +14,7 @@ tags = ["darkness"] [guthix_temple] x = [2496, 2623] y = [5696, 5823] -tags = ["multi_combat"] +tags = ["multi_combat", "no_random_events"] [wall_beast_spot_1] x = [3161] diff --git a/data/area/misthalin/tutorial_island/tutorial_island.areas.toml b/data/area/misthalin/tutorial_island/tutorial_island.areas.toml new file mode 100644 index 0000000000..c6f0c7a53d --- /dev/null +++ b/data/area/misthalin/tutorial_island/tutorial_island.areas.toml @@ -0,0 +1,4 @@ +[tutorial_island] +x = [3040, 3160] +y = [3040, 3136] +tags = ["no_random_events"] diff --git a/data/area/misthalin/varrock/grand_exchange/grand_exchange.areas.toml b/data/area/misthalin/varrock/grand_exchange/grand_exchange.areas.toml index c821e6aed3..8a413f223b 100644 --- a/data/area/misthalin/varrock/grand_exchange/grand_exchange.areas.toml +++ b/data/area/misthalin/varrock/grand_exchange/grand_exchange.areas.toml @@ -2,4 +2,4 @@ x = [3145, 3189] y = [3469, 3513] level = 0 -tags = ["grand_exchange"] +tags = ["grand_exchange", "no_random_events"] diff --git a/data/area/troll_country/god_wars_dungeon/god_wars.areas.toml b/data/area/troll_country/god_wars_dungeon/god_wars.areas.toml index 879b84f2f6..5e8cbf7d56 100644 --- a/data/area/troll_country/god_wars_dungeon/god_wars.areas.toml +++ b/data/area/troll_country/god_wars_dungeon/god_wars.areas.toml @@ -1,7 +1,7 @@ [godwars_dungeon_multi_area] x = [2816, 2943] y = [5248, 5375] -tags = ["multi_combat"] +tags = ["multi_combat", "no_random_events"] [godwars_dungeon] x = [2878, 2878, 2861, 2851, 2846, 2844, 2844, 2851, 2851, 2865, 2875, 2883, 2888, 2915, 2915, 2886, 2880] diff --git a/data/entity/player/dialogue/dialogue.ifaces.toml b/data/entity/player/dialogue/dialogue.ifaces.toml index 510aca9210..020d4ca93f 100644 --- a/data/entity/player/dialogue/dialogue.ifaces.toml +++ b/data/entity/player/dialogue/dialogue.ifaces.toml @@ -273,10 +273,57 @@ type = "dialogue_box" id = 188 type = "dialogue_box" +# The eight emote buttons (cache option names Think/Cry/.../Glass Wall); named by the emote so the +# handler can read the pick straight off the clicked component. +[.think] +id = 2 + +[.cry] +id = 3 + +[.laugh] +id = 4 + +[.dance] +id = 5 + +[.climb_rope] +id = 6 + +[.lean] +id = 7 + +[.glass_box] +id = 8 + +[.glass_wall] +id = 9 + [dialogue_macro_quiz_show] id = 191 type = "dialogue_box" +# The three golden models to compare, left to right (the cache runs a client script to spin them). +[.model_1] +id = 6 + +[.model_2] +id = 7 + +[.model_3] +id = 8 + +# The invisible clickable buttons layered over the three models. They're dialogue-box components +# (setting=1 in the cache), so a click arrives as a dialogue continue keyed by these names. +[.button_1] +id = 3 + +[.button_2] +id = 4 + +[.button_3] +id = 5 + [chat1_double_model] id = 173 diff --git a/data/entity/player/misc.invs.toml b/data/entity/player/misc.invs.toml index 2d7186df11..6af1e7e303 100644 --- a/data/entity/player/misc.invs.toml +++ b/data/entity/player/misc.invs.toml @@ -306,9 +306,6 @@ id = 299 [skill_guide_farming_belladonna] id = 300 -[macro_certer] -id = 307 - [roguetrader_toughsudukuinv] id = 308 diff --git a/data/skill/fishing/fishing.drops.toml b/data/skill/fishing/fishing.drops.toml new file mode 100644 index 0000000000..85be8ef367 --- /dev/null +++ b/data/skill/fishing/fishing.drops.toml @@ -0,0 +1,18 @@ +# What's inside a casket. https://runescape.wiki/w/Casket +[casket] +roll = 128 +drops = [ + { id = "uncut_sapphire", chance = 32 }, + { id = "uncut_emerald", chance = 16 }, + { id = "uncut_ruby", chance = 8 }, + { id = "uncut_diamond", chance = 2 }, + { id = "cosmic_talisman", chance = 8 }, + { id = "loop_half_of_a_key" }, + { id = "tooth_half_of_a_key" }, + { id = "coins", amount = 20, chance = 10 }, + { id = "coins", amount = 40, chance = 10 }, + { id = "coins", amount = 80, chance = 10 }, + { id = "coins", amount = 160, chance = 10 }, + { id = "coins", amount = 320, chance = 10 }, + { id = "coins", amount = 640, chance = 10 }, +] diff --git a/engine/src/main/kotlin/world/gregs/voidps/engine/client/instruction/InterfaceHandler.kt b/engine/src/main/kotlin/world/gregs/voidps/engine/client/instruction/InterfaceHandler.kt index e642b155ff..e7ec56b7ac 100644 --- a/engine/src/main/kotlin/world/gregs/voidps/engine/client/instruction/InterfaceHandler.kt +++ b/engine/src/main/kotlin/world/gregs/voidps/engine/client/instruction/InterfaceHandler.kt @@ -36,6 +36,8 @@ class InterfaceHandler( item = Item(ItemDefinitions.get(expected).stringId) } id == "common_item_costs" -> item = Item(ItemDefinitions.get(itemId).stringId) + id == "surprise_exam_pattern" -> item = Item(ItemDefinitions.get(itemId).stringId) + id == "costume_reward_select" -> item = Item(ItemDefinitions.get(itemId).stringId) id == "farming_equipment_store" || id == "farming_equipment_store_side" -> {} else -> { val inventory = getInventory(player, id, component, componentDefinition) ?: return null @@ -73,6 +75,7 @@ class InterfaceHandler( itemSlot == -1 && inventoryId == "returned_lent_items" -> 0 id == "price_checker" -> itemSlot / 2 id == "shop" -> itemSlot / 6 + id == "random_event_gift_select" -> itemSlot / 7 id == "grand_exchange" -> componentDefinition.stringId.removePrefix("collect_slot_").toInt() else -> itemSlot } diff --git a/game/src/main/kotlin/ContentLoader.kt b/game/src/main/kotlin/ContentLoader.kt index 9bb4bb0a07..3ac7416ccd 100644 --- a/game/src/main/kotlin/ContentLoader.kt +++ b/game/src/main/kotlin/ContentLoader.kt @@ -1,4 +1,5 @@ import com.github.michaelbull.logging.InlineLogger +import content.activity.event.random.RandomEvents import content.skill.prayer.PrayerApi import world.gregs.voidps.engine.Script import world.gregs.voidps.engine.client.ui.chat.plural @@ -44,6 +45,7 @@ class ContentLoader { private fun loadContentApis() { Script.interfaces.add(PrayerApi) + Script.interfaces.add(RandomEvents) } private fun loadScript(name: String): Any { diff --git a/game/src/main/kotlin/content/activity/event/random/CostumePoints.kt b/game/src/main/kotlin/content/activity/event/random/CostumePoints.kt new file mode 100644 index 0000000000..e494c633b1 --- /dev/null +++ b/game/src/main/kotlin/content/activity/event/random/CostumePoints.kt @@ -0,0 +1,15 @@ +package content.activity.event.random + +import world.gregs.voidps.engine.client.message +import world.gregs.voidps.engine.entity.character.player.Player + +/** + * Award a costume point locked to one outfit - each costume's own random event grants one on + * completion, spendable only on that outfit at Iffie's costume store. Points from the random + * event gift's "Save up for a costume!" choice go to the any-outfit `costume_points` balance + * instead. + */ +fun Player.rewardCostumePoint(costume: String) { + inc("${costume}_costume_points") + message("You've earned a point towards a costume; visit Iffie in Varrock's clothes shop to spend it.") +} diff --git a/game/src/main/kotlin/content/activity/event/random/InPlaceRandomEvent.kt b/game/src/main/kotlin/content/activity/event/random/InPlaceRandomEvent.kt new file mode 100644 index 0000000000..7659cf185b --- /dev/null +++ b/game/src/main/kotlin/content/activity/event/random/InPlaceRandomEvent.kt @@ -0,0 +1,91 @@ +package content.activity.event.random + +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.entity.character.mode.Follow +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.timer.Timer +import world.gregs.voidps.type.random + +/** + * Shared behaviour for in-place "nagging" random events (Certer, Sandwich Lady, Evil Twin...). + * The event NPC spawns next to the player, follows them, repeats nag lines, and applies the + * ignore penalty [RandomEvents.noteAndTeleport] once its lifetime runs out. Each event's own + * `npcOperate("Talk-to")` handler drives the puzzle and calls [endInPlaceEvent] when solved. + */ +class InPlaceRandomEvent : Script { + + init { + npcTimerStart(NAG_TIMER) { 1 } + npcTimerTick(NAG_TIMER) { tick(this) } + } + + private fun tick(npc: NPC): Int { + val owner = npc.owner + // Owner logged out or moved on to a different event: remove this orphaned NPC quietly. + // (The event can't be escaped by running - Follow keeps the NPC on the player - so the + // only exits are solving it or the ignore penalty below.) + if (owner == null || owner.get("random_event") != npc.get("random_event")) { + NPCs.remove(npc) + return Timer.CANCEL + } + if (!npc.watching(owner)) { + npc.watch(owner) + } + val remaining = npc.dec("random_event_life", refresh = false) + if (remaining <= 0) { + RandomEvents.noteAndTeleport(owner) + NPCs.remove(npc) + return Timer.CANCEL + } + val interval = npc.get("random_event_nag_interval", DEFAULT_INTERVAL) + if (remaining % interval == 0) { + npc.nag() + } + return Timer.CONTINUE + } + + private fun NPC.nag() { + val lines: List = get("random_event_nag_lines") ?: return + if (lines.isNotEmpty()) { + say(lines.random(random)) + } + } + + companion object { + const val NAG_TIMER = "random_event_nag" + private const val DEFAULT_INTERVAL = 25 + } +} + +/** + * Spawn an in-place random event NPC beside the player. It follows the player, nags every + * [nagInterval] ticks with a random line from [nagLines], and exiles the player via the ignore + * penalty after [lifetime] ticks. Returns the spawned NPC. + */ +fun Player.startInPlaceEvent( + id: String, + nagLines: List, + lifetime: Int = 300, + nagInterval: Int = 25, +): NPC { + val npc = NPCs.addRandom(id, tile.toCuboid(1), owner = this) ?: NPCs.add(id, tile, ticks = -1, owner = this) + npc["random_event"] = get("random_event") ?: id + npc["random_event_life"] = lifetime + npc["random_event_nag_interval"] = nagInterval + npc["random_event_nag_lines"] = nagLines + npc.mode = Follow(npc, this) + npc.watch(this) + npc.softTimers.start(InPlaceRandomEvent.NAG_TIMER) + return npc +} + +/** + * Stop an in-place event NPC's nag timer and remove it. The event's Talk-to handler calls this + * once the puzzle is solved (or dismissed); reward + [RandomEvents.complete] remain its job. + */ +fun endInPlaceEvent(npc: NPC) { + npc.softTimers.stop(InPlaceRandomEvent.NAG_TIMER) + NPCs.remove(npc) +} diff --git a/game/src/main/kotlin/content/activity/event/random/MysteryBox.kt b/game/src/main/kotlin/content/activity/event/random/MysteryBox.kt new file mode 100644 index 0000000000..6503c6ccca --- /dev/null +++ b/game/src/main/kotlin/content/activity/event/random/MysteryBox.kt @@ -0,0 +1,43 @@ +package content.activity.event.random + +import content.entity.player.inv.item.addOrDrop +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.client.message +import world.gregs.voidps.engine.client.ui.chat.an +import world.gregs.voidps.engine.entity.item.drop.DropTables +import world.gregs.voidps.engine.get +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.engine.inv.remove + +/** + * Mystery box: the surprise choice from a random event gift. Opening it rolls the `mystery_box` + * drop table - usually an oddment, with a slim shot at the rare drop table. + * https://runescape.wiki/w/Mystery_box_(random_events) + */ +class MysteryBox : Script { + + init { + itemOption("Open", "mystery_box") { (item, slot) -> + if (!inventory.remove(slot, item.id)) { + return@itemOption + } + val drop = get().getValue("mystery_box").roll(player = this).firstOrNull()?.toItem() + if (drop == null || drop.isEmpty()) { + message("Inside the box you find nothing! Better luck next time!") + return@itemOption + } + addOrDrop(drop.id, drop.amount) + val name = drop.def.name.lowercase() + val found = when { + drop.amount > 1 -> " ${drop.amount} x $name" + else -> "${name.an()} $name" + } + val flavour = when (drop.def.cost * drop.amount) { + in 0..100 -> "Better luck next time!" + in 101..500 -> "Well, it could have been worse." + else -> "Excellent!" + } + message("Inside the box you find$found! $flavour") + } + } +} diff --git a/game/src/main/kotlin/content/activity/event/random/RandomEventGift.kt b/game/src/main/kotlin/content/activity/event/random/RandomEventGift.kt new file mode 100644 index 0000000000..5534e7614b --- /dev/null +++ b/game/src/main/kotlin/content/activity/event/random/RandomEventGift.kt @@ -0,0 +1,213 @@ +package content.activity.event.random + +import content.entity.combat.inCombat +import content.entity.player.bank.noted +import content.entity.player.dialogue.type.skillLamp +import content.entity.player.dialogue.type.statement +import content.entity.player.inv.item.addOrDrop +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.data.definition.Tables +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.entity.item.Item +import world.gregs.voidps.engine.inv.Inventory +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.engine.inv.remove +import world.gregs.voidps.engine.inv.sendInventory +import world.gregs.voidps.type.random + +/** + * Random event gift: the reward box every random event hands out. Opening it deals one reward per + * category - coins scaled to the player's total level, a rune, coal, essence by Mining level, an + * ore, bar, gem, herb, seed, charm, an experience lamp, an oddment and a mystery box - into the gift's own inventory, + * alongside the emote unlock and save-up-for-a-costume choices, shown on the "Choose Your Reward!" + * interface (202). The choices keep until the player selects one and confirms, consuming the gift. + * https://runescape.wiki/w/Random_event_gift + */ +class RandomEventGift : Script { + + init { + itemOption("Open", "random_event_gift") { + if (inCombat) { + message("You won't be able to choose a reward during combat.") + return@itemOption + } + if (gift.isEmpty()) { + fillGift() + } + set("gift_selected", 0) + // The interface's own load script draws the reward grid from the gift inventory, so it + // only needs the container contents up front and click masks - no options script. + sendInventory(gift) + open("random_event_gift_select") + } + + interfaceOpened("random_event_gift_select") { id -> + interfaceOptions.unlockAll(id, "rewards", 0 until gift.size * 8) + interfaceOptions.unlockAll(id, "confirm", 0 until gift.size * 8) + } + + interfaceOption("Select", "random_event_gift_select:rewards") { + // The grid packs seven sub-components per reward and doesn't attach the item to clicks + val slot = if (it.item.isEmpty()) it.itemSlot / 7 else gift.indexOf(it.item.id) + // The emote and costume cells are drawn by the interface itself, so their inventory + // slots stay empty + if (slot == EMOTE_SLOT || slot == COSTUME_SLOT || (slot in gift.indices && gift[slot].isNotEmpty())) { + set("gift_selected", slot + 1) + } + } + + interfaceOption("Confirm", "random_event_gift_select:confirm") { + confirm() + } + + itemOption("Rub", "lamp") { (item, slot) -> + val skill = skillLamp() + // Constitution is measured in tenths internally (level 10 = 100) + val level = if (skill == Skill.Constitution) levels.getMax(skill) / 10 else levels.getMax(skill) + val experience = level * XP_PER_LEVEL + if (inventory.remove(slot, item.id)) { + exp(skill, experience) + statement("Your wish has been granted!
You have been awarded ${experience.toInt()} ${skill.name} experience!") + } + } + } + + private val Player.gift: Inventory + get() = inventories.inventory("random_event_gift") + + /** + * Deal one reward into each category's slot; the arrangement mirrors the official interface + * layout with coins first and the surprises at the bottom. + */ + private fun Player.fillGift() { + val mining = levels.getMax(Skill.Mining) + gift.transaction { + set(COINS_SLOT, Item("coins", coins())) + set(RUNE_SLOT, roll("gift_runes")) + set(COAL_SLOT, Item("coal", random.nextInt(3, 31))) + set( + ESSENCE_SLOT, + if (mining >= 30) { + Item("pure_essence", random.nextInt(18, 47)) + } else { + Item("rune_essence", random.nextInt(14, 22)) + }, + ) + set(ORE_SLOT, roll("gift_ores")) + set(BAR_SLOT, roll("gift_bars")) + set(GEM_SLOT, roll("gift_gems")) + set(HERB_SLOT, roll("gift_herbs")) + set(SEED_SLOT, roll("gift_seeds")) + set(CHARM_SLOT, roll("gift_charms")) + set(LAMP_SLOT, Item("lamp")) + set(OTHER_SLOT, roll("gift_other")) + set(SURPRISE_SLOT, Item("mystery_box")) + // The grid only draws cells whose slot holds an item; these two stand in for the + // "Unlock emote!" and "Save up for a costume!" choices and are never granted. + set(EMOTE_SLOT, Item("mime_mask")) + set(COSTUME_SLOT, Item("zombie_mask")) + } + } + + /** https://runescape.wiki/w/Random_event_gift - coins scale to a third of the total level. */ + private fun Player.coins(): Int { + val total = Skill.all.sumOf { if (it == Skill.Constitution) levels.getMax(it) / 10 else levels.getMax(it) } + return (total * 0.33).toInt().coerceAtLeast(30) + } + + private fun roll(table: String): Item { + val rows = Tables.get(table).rows() + var roll = random.nextInt(rows.sumOf { it.int("weight") }) + val row = rows.first { + roll -= it.int("weight") + roll < 0 + } + return Item(row.item("item"), random.nextInt(row.int("min"), row.int("max") + 1)) + } + + private fun Player.confirm() { + val slot = get("gift_selected", 0) - 1 + if (slot < 0) { + message("Choose a reward first.") + return + } + if (slot == EMOTE_SLOT) { + unlockEmote() + return + } + if (slot == COSTUME_SLOT) { + saveForCostume() + return + } + if (slot !in gift.indices) { + return + } + val reward = gift[slot] + if (reward.isEmpty() || !inventory.remove("random_event_gift")) { + return + } + inventories.clear("random_event_gift") + // Unstackable rewards come noted so a multi-item prize doesn't flood the inventory + val prize = if (reward.def.stackable != 1) reward.noted ?: reward else reward + addOrDrop(prize.id, prize.amount) + close("random_event_gift_select") + message("Enjoy your gift.") + } + + private fun Player.saveForCostume() { + if (!inventory.remove("random_event_gift")) { + return + } + val points = inc("costume_points") + inventories.clear("random_event_gift") + close("random_event_gift_select") + message("Enjoy your gift.") + message("You save up for a costume; you now have $points costume point${if (points == 1) "" else "s"}.") + } + + private fun Player.unlockEmote() { + val locked = EMOTES.filter { !get("unlocked_emote_$it", false) } + if (locked.isEmpty()) { + message("You've already unlocked all of the emotes.") + return + } + if (!inventory.remove("random_event_gift")) { + return + } + val emote = locked.random(random) + set("unlocked_emote_$emote", true) + inventories.clear("random_event_gift") + close("random_event_gift_select") + message("Enjoy your gift.") + message("You've unlocked the ${emote.replace('_', ' ')} emote!") + } + + companion object { + private const val COINS_SLOT = 0 + private const val RUNE_SLOT = 1 + private const val COAL_SLOT = 2 + private const val ESSENCE_SLOT = 3 + private const val ORE_SLOT = 4 + private const val BAR_SLOT = 5 + private const val GEM_SLOT = 6 + private const val HERB_SLOT = 7 + private const val SEED_SLOT = 8 + private const val CHARM_SLOT = 9 + private const val LAMP_SLOT = 10 + + // A genie lamp grants ten experience per level in the chosen skill + private const val XP_PER_LEVEL = 10.0 + + private const val OTHER_SLOT = 24 + private const val SURPRISE_SLOT = 25 + private const val EMOTE_SLOT = 26 + private const val COSTUME_SLOT = 27 + + private val EMOTES = listOf("idea", "stomp", "flap", "slap_head") + } +} diff --git a/game/src/main/kotlin/content/activity/event/random/RandomEventKidnap.kt b/game/src/main/kotlin/content/activity/event/random/RandomEventKidnap.kt new file mode 100644 index 0000000000..d8b57757dc --- /dev/null +++ b/game/src/main/kotlin/content/activity/event/random/RandomEventKidnap.kt @@ -0,0 +1,81 @@ +package content.activity.event.random + +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.client.message +import world.gregs.voidps.engine.entity.character.areaSound +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.name +import world.gregs.voidps.type.Tile + +/** + * The Mysterious Old Man appears next to the player and whisks them away to an event. + */ +class RandomEventKidnap : Script { + + init { + // An event interrupted by logout restarts fresh on login; the original + // location persists so completing it still returns the player there. + playerSpawn { + val event: String = get("random_event") ?: return@playerSpawn + RandomEvents.start(this, event) + } + + for (type in TELEPORT_TYPES) { + teleportTakeOff(type) { + if (contains("random_event")) { + message("You can't leave just yet.") + false + } else { + true + } + } + } + } + + companion object { + private val TELEPORT_TYPES = listOf( + "modern", + "ancient", + "lunar", + "dungeoneering", + "scroll", + "tablet", + "jewellery", + "fairy", + "fairy_ring", + "spirit_tree", + "kinship", + "ectophial", + "skull_sceptre", + "puro_puro", + "wilderness", + ) + } +} + +/** + * The Mysterious Old Man appears beside the player to herald a teleport ("kidnap") event. + * Kidnap-style launchers call this before [kidnap]; in-place events skip it and spawn their own + * NPC via [startInPlaceEvent]. + */ +suspend fun Player.mysteriousOldMan() { + val npc = NPCs.addRandom("mysterious_old_man", tile.toCuboid(1), ticks = 25, owner = this) + ?: NPCs.add("mysterious_old_man", tile, ticks = 25, owner = this) + npc.watch(this) + npc.say("Ah, $name, you'll do nicely!") + delay(2) +} + +/** + * Teleport the player into an event area; events call this from their launcher. + */ +suspend fun Player.kidnap(destination: Tile) { + areaSound("teleport", tile = tile, radius = 10) + anim("teleport_other_impact") + gfx("teleport_other_impact") + delay(3) + clearAnim() + tele(destination) +} diff --git a/game/src/main/kotlin/content/activity/event/random/RandomEventTrigger.kt b/game/src/main/kotlin/content/activity/event/random/RandomEventTrigger.kt new file mode 100644 index 0000000000..3c9c3bd213 --- /dev/null +++ b/game/src/main/kotlin/content/activity/event/random/RandomEventTrigger.kt @@ -0,0 +1,57 @@ +package content.activity.event.random + +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.client.command.adminCommand +import world.gregs.voidps.engine.client.command.playerCommand +import world.gregs.voidps.engine.client.command.stringArg +import world.gregs.voidps.engine.client.message +import world.gregs.voidps.engine.data.Settings +import world.gregs.voidps.engine.timer.Timer +import world.gregs.voidps.engine.timer.toTicks +import java.util.concurrent.TimeUnit + +/** + * Rolls for a random event on each experience drop, so only active players are targeted. + * The persisted cooldown clock starts on first login and restarts whenever an event + * begins or ends, keeping events infrequent regardless of experience rates. + */ +class RandomEventTrigger : Script { + + init { + playerSpawn { + if (!Settings["events.randomEvents.active", true]) { + return@playerSpawn + } + if (RandomEvents.optedOut(this)) { + return@playerSpawn + } + timers.startIfAbsent("random_event") + } + + timerStart("random_event") { TimeUnit.MINUTES.toTicks(5) } + + timerTick("random_event") { + RandomEvents.roll(this) + Timer.CONTINUE + } + + playerCommand("random_events", desc = "Toggle random events on or off") { + if (!Settings["events.randomEvents.optOut", false]) { + message("Random events can't be turned off on this world.") + return@playerCommand + } + if (toggle("random_events_disabled")) { + message("Random events are now off.") + } else { + message("Random events are now on.") + } + } + + adminCommand("random_event", stringArg("event", optional = true), desc = "Start a random event") { args -> + val event = args.getOrNull(0) ?: RandomEvents.pick() + if (event == null || !RandomEvents.start(this, event)) { + message("No random event found${if (args.isEmpty()) "" else " for '${args[0]}'"}.") + } + } + } +} diff --git a/game/src/main/kotlin/content/activity/event/random/RandomEvents.kt b/game/src/main/kotlin/content/activity/event/random/RandomEvents.kt new file mode 100644 index 0000000000..8e397c9505 --- /dev/null +++ b/game/src/main/kotlin/content/activity/event/random/RandomEvents.kt @@ -0,0 +1,185 @@ +package content.activity.event.random + +import content.bot.isBot +import content.entity.combat.inCombat +import content.entity.effect.transform +import content.entity.player.bank.isNote +import content.quest.clearInstance +import content.quest.instance +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.client.message +import world.gregs.voidps.engine.client.ui.dialogue +import world.gregs.voidps.engine.client.ui.menu +import world.gregs.voidps.engine.client.variable.hasClock +import world.gregs.voidps.engine.client.variable.start +import world.gregs.voidps.engine.data.Settings +import world.gregs.voidps.engine.data.definition.Areas +import world.gregs.voidps.engine.data.definition.ItemDefinitions +import world.gregs.voidps.engine.data.definition.Tables +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.player.Player +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.engine.inv.transact.operation.ReplaceItem.replace +import world.gregs.voidps.engine.map.collision.random +import world.gregs.voidps.engine.queue.strongQueue +import world.gregs.voidps.engine.timer.epochSeconds +import world.gregs.voidps.type.Tile +import world.gregs.voidps.type.random +import java.util.concurrent.TimeUnit + +/** + * Registry and lifecycle for anti-macro random events. + * https://runescape.wiki/w/Random_events?oldid=3667851 + * + * Each event registers a launcher in its script's init block and calls + * [complete] when the player succeeds or [fail] when they fail or abandon it. + */ +object RandomEvents : AutoCloseable { + + private val events = mutableMapOf Unit>() + + fun register(id: String, launcher: suspend Player.() -> Unit) { + Script.checkLoading() + events[id] = launcher + } + + // Reset registrations between script (re)loads so a reload can't leave stale launchers behind. + override fun close() { + events.clear() + } + + /** + * Weighted pick over the `random_events` table, skipping rows without a registered launcher. + */ + fun pick(): String? { + val rows = Tables.get("random_events").rows().filter { events.containsKey(it.string("event")) } + if (rows.isEmpty()) { + return null + } + var roll = random.nextInt(rows.sumOf { it.int("weight") }) + for (row in rows) { + roll -= row.int("weight") + if (roll < 0) { + return row.string("event") + } + } + return null + } + + /** + * Arm the initial cooldown on first activity shot at [event] + * (or a weighted [pick]) for players who are eligible and off cooldown. + */ + fun roll(player: Player, event: String? = null): Boolean { + if (!Settings["events.randomEvents.active", true]) { + return false + } + if (!player.contains("random_event_cooldown")) { + // First activity; events unlock once the initial cooldown expires + cooldown(player) + return false + } + if (!eligible(player)) { + return false + } + // Restart the cooldown immediately so a failed pick can't re-roll every attempt + cooldown(player) + return start(player, event ?: pick()) + } + + private fun eligible(player: Player): Boolean = !player.isBot && + !optedOut(player) && + !player.contains("random_event") && + player.instance() == null && + !player.inCombat && + player.menu == null && + player.dialogue == null && + player.mode == EmptyMode && + player.transform.isEmpty() && + !player.contains("delay") && + !player.hasClock("random_event_cooldown", epochSeconds()) && + Areas.get(player.tile.zone).none { it.tags.contains("no_random_events") } + + /** + * Whether the player has turned events off with `::randomevents`; only honoured on worlds + * with the `events.randomEvents.optOut` setting enabled. + */ + fun optedOut(player: Player): Boolean = Settings["events.randomEvents.optOut", false] && player["random_events_disabled", false] + + fun start(player: Player, id: String? = pick()): Boolean { + val launcher = events[id ?: return false] ?: return false + player["random_event"] = id + if (!player.contains("random_event_origin")) { + player["random_event_origin"] = player.tile.id + } + player.strongQueue("random_event_start") { + launcher.invoke(player) + } + return true + } + + /** + * Success; return the player to where they were taken from. + */ + fun complete(player: Player) { + player.tele(exit(player)) + } + + /** + * Success for an in-place event (Sandwich Lady, Certer...); the player never left, so just + * clear the event state and arm the cooldown without teleporting them anywhere. + */ + fun completeInPlace(player: Player) { + exit(player) + } + + /** + * Failure or abandonment; teleport the player to a random safe location with no reward. + */ + fun fail(player: Player) { + exit(player) + val row = Tables.get("random_event_exiles").rows().random(random) + player.tele(Areas[row.string("area")].random(player) ?: Tile(3222, 3218)) + player.message("You wake up feeling drowsy, unsure of where you are.") + } + + /** + * Ignore penalty for in-place events: note every noteable inventory item so the + * player can't macro through a full inventory, then exile them as [fail]. + */ + fun noteAndTeleport(player: Player) { + player.inventory.transaction { + for (index in player.inventory.indices) { + val item = player.inventory[index] + if (item.isEmpty() || item.isNote) { + continue + } + val noteId = item.def.noteId + if (noteId != -1) { + replace(index, item.id, ItemDefinitions.get(noteId).stringId) + } + } + } + fail(player) + } + + private fun exit(player: Player): Tile { + val origin = Tile(player["random_event_origin", player.tile.id]) + player.clearInstance() + player.clear("random_event") + player.clear("random_event_origin") + cooldown(player) + return origin + } + + /** + * Delay the next event by `events.randomEvents.cooldownMinutes` min to max minutes. + */ + private fun cooldown(player: Player) { + val min = Settings["events.randomEvents.cooldownMinutesMin", 60] + val max = Settings["events.randomEvents.cooldownMinutesMax", 120] + val minutes = random.nextInt(min, max + 1) + player.start("random_event_cooldown", TimeUnit.MINUTES.toSeconds(minutes.toLong()).toInt(), epochSeconds()) + } +} diff --git a/game/src/main/kotlin/content/activity/event/random/beekeeper/Beekeeper.kt b/game/src/main/kotlin/content/activity/event/random/beekeeper/Beekeeper.kt new file mode 100644 index 0000000000..2d14aa71d0 --- /dev/null +++ b/game/src/main/kotlin/content/activity/event/random/beekeeper/Beekeeper.kt @@ -0,0 +1,204 @@ +package content.activity.event.random.beekeeper + +import content.activity.event.random.RandomEvents +import content.activity.event.random.kidnap +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.dialogue.type.statement +import content.entity.player.inv.item.addOrDrop +import content.quest.smallInstance +import world.gregs.voidps.cache.definition.data.InterfaceDefinition +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.client.message +import world.gregs.voidps.engine.client.sendInterfaceSettings +import world.gregs.voidps.engine.client.ui.close +import world.gregs.voidps.engine.client.ui.open +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.name +import world.gregs.voidps.engine.get +import world.gregs.voidps.engine.map.zone.DynamicZones +import world.gregs.voidps.type.Delta +import world.gregs.voidps.type.Direction +import world.gregs.voidps.type.Region +import world.gregs.voidps.type.Tile +import world.gregs.voidps.type.Zone +import world.gregs.voidps.type.random + +/** + * Beekeeper random event: the Bee keeper drags the player to his field of beehives and asks for help + * building a new hive. The four hive components (interface 420, driven by varp 805's varbits) start + * jumbled beside the frame; drag them into the frame in the right order and click Build. Six wrong + * builds and the bees lose patience, chasing the player off to exile empty-handed; success earns a + * random event gift before the trip home. + * https://runescape.wiki/w/Beekeeper + */ +class Beekeeper : Script { + + init { + RandomEvents.register("beekeeper") { startEvent() } + + // The Bee keeper's cache option is "Talk-To" (capital O), like the pinball trolls. + npcOperate("Talk-To", "bee_keeper") { (keeper) -> + if (get("random_event") != "beekeeper" || keeper.owner != this) { + npc("bee_keeper", "Sorry, you're not the person I need.") + return@npcOperate + } + beekeeperTalk() + } + + interfaceSwap("beehive_build:*", "beehive_build:*") { fromId, toId, _, _ -> + if (get("random_event") != "beekeeper") { + return@interfaceSwap + } + swapParts(fromId.substringAfter(":"), toId.substringAfter(":")) + } + + interfaceOption("Build", "beehive_build:build") { + if (get("random_event") != "beekeeper") { + return@interfaceOption + } + build() + } + } + + private suspend fun Player.startEvent() { + // beekeeper_intro persists so a relogged player isn't lectured twice; it's cleared on event end. + shuffleParts() + set("beekeeper_tries", TRIES) + herald() + val offset = copyField() + kidnap(FIELD.add(offset)) + NPCs.add("bee_keeper", KEEPER.add(offset), Direction.SOUTH, ticks = -1, owner = this) + } + + /** + * Allocate a private instance and copy only the apiary's zones (the box between [AREA_SW] and + * [AREA_NE]) into it, leaving the rest of the instance empty. Returns the tile offset from the + * real field to its copy. + */ + private fun Player.copyField(): Delta { + val instance = smallInstance() + val offset = instance.offset(REGION) + val zones = get() + for (zoneX in (AREA_SW.x shr 3)..(AREA_NE.x shr 3)) { + for (zoneY in (AREA_SW.y shr 3)..(AREA_NE.y shr 3)) { + val from = Zone(zoneX, zoneY, 0) + val base = from.tile.add(offset) + zones.copy(from, Zone(base.x shr 3, base.y shr 3, base.level)) + } + } + set("instance_offset", offset.id) + return offset + } + + private suspend fun Player.herald() { + val keeper = NPCs.addRandom("bee_keeper", tile.toCuboid(1), ticks = 25, owner = this) + ?: NPCs.add("bee_keeper", tile, ticks = 25, owner = this) + keeper.watch(this) + keeper.say("I need some help, $name!") + delay(2) + } + + private suspend fun Player.beekeeperTalk() { + if (!get("beekeeper_intro", false)) { + set("beekeeper_intro", true) + npc("bee_keeper", "Ah, $name. I'm sorry to drag you away like this, but I need some help building a new hive for my bees.") + player("What do you want me to do?") + npc("bee_keeper", "All the components of the beehive are jumbled up. You've got to put them in the correct order for building a hive.") + player("Oh, very well. Let's take a look...") + } + openHive() + } + + /** Deal the four hive parts randomly across the source slots, leaving the frame empty. */ + private fun Player.shuffleParts() { + val parts = (1..4).shuffled(random) + for (slot in 1..4) { + set("beekeeper_part_$slot", parts[slot - 1]) + set("beekeeper_slot_$slot", 0) + } + } + + private fun Player.openHive() { + open("beehive_build") + // Send the access masks the cache doesn't: the Build button's click option, and drag flags + // (drag depth + drag-onto) for the part and frame slots so the client reports the drags. + interfaceOptions.unlockAll("beehive_build", "build", 0..0) + for (component in 12..19) { + sendInterfaceSettings(InterfaceDefinition.pack(HIVE_ID, component), 0, 0, DRAG_SETTINGS) + } + } + + /** Dragging one slot onto another swaps their contents; every slot pairing is allowed. */ + private fun Player.swapParts(from: String, to: String) { + val source = get("beekeeper_$from", 0) + val target = get("beekeeper_$to", 0) + set("beekeeper_$from", target) + set("beekeeper_$to", source) + } + + private suspend fun Player.build() { + if ((1..4).any { get("beekeeper_slot_$it", 0) == 0 }) { + statement("You need to move all 4 of the spinning components to the frame in the centre of the display.", clickToContinue = false) + return + } + if ((1..4).all { get("beekeeper_slot_$it", 0) == it }) { + succeed() + } else { + fail() + } + } + + private suspend fun Player.succeed() { + close("beehive_build") + npc("bee_keeper", "That's perfect! I'll get some bees moved in immediately. Now, I'm sure I must have something to offer you for all your help...") + addOrDrop("random_event_gift") + message("You've been given a gift!") + clearState() + RandomEvents.complete(this) + } + + private suspend fun Player.fail() { + val tries = dec("beekeeper_tries") + if (tries > 0) { + npc("bee_keeper", "No, that doesn't look right. You have to put the components in the right order, otherwise it'll be no good as a beehive. I'll let you have $tries more ${if (tries == 1) "try" else "tries"}.") + return + } + close("beehive_build") + npc("bee_keeper", "Uh-oh, the bees are fed up with waiting for you to build them a home!") + say("Aaaaargh - BEES!") + delay(2) + clearState() + RandomEvents.fail(this) + } + + private fun Player.clearState() { + clear("beekeeper_intro") + clear("beekeeper_tries") + for (slot in 1..4) { + clear("beekeeper_part_$slot") + clear("beekeeper_slot_$slot") + } + } + + companion object { + private const val HIVE_ID = 420 + private const val TRIES = 6 + private val REGION = Region(30, 78) + + // Only this box is copied into the instance: south-west and north-east corners of the apiary. + private val AREA_SW = Tile(1920, 5031) + private val AREA_NE = Tile(1943, 5055) + + private val FIELD = Tile(1931, 5044) + private val KEEPER = Tile(1931, 5045) + + // Access mask flags: drag depth 1 (bit 17) and drag-onto (bit 19). + private const val DRAG_SETTINGS = (1 shl 17) or (1 shl 19) + } +} diff --git a/game/src/main/kotlin/content/activity/event/random/capn_arnav/CapnArnav.kt b/game/src/main/kotlin/content/activity/event/random/capn_arnav/CapnArnav.kt new file mode 100644 index 0000000000..690af50485 --- /dev/null +++ b/game/src/main/kotlin/content/activity/event/random/capn_arnav/CapnArnav.kt @@ -0,0 +1,222 @@ +package content.activity.event.random.capn_arnav + +import content.activity.event.random.RandomEvents +import content.activity.event.random.kidnap +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.choice +import content.entity.player.dialogue.type.npc +import content.entity.player.inv.item.addOrDrop +import world.gregs.voidps.cache.definition.data.InterfaceDefinition +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.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.name +import world.gregs.voidps.engine.entity.obj.GameObjects +import world.gregs.voidps.network.login.protocol.encode.interfaceText +import world.gregs.voidps.type.Tile +import world.gregs.voidps.type.random + +/** + * Cap'n Arnav's Chest random event: the old pirate whisks the player to his shark-circled island and + * asks for help with the fiddly combination lock on his freshly dug-up chest. The lock (interface 185) + * has three cylinders of items; rolling each with the arrows until the middle row matches the target + * word and clicking Unlock opens the chest for a random event gift, and the exit portal sends the + * player back. Five wrong combinations and Arnav clobbers them with his bottle instead. + * https://runescape.wiki/w/Random_events?oldid=3667851#Cap'n_Arnav's_Chest + */ +class CapnArnav : Script { + + init { + RandomEvents.register("capn_arnav") { startEvent() } + + npcOperate("Talk-to", "capn_arnav") { + if (get("random_event") != "capn_arnav") { + npc("capn_arnav", "Ah, I cannot be stoppin' to chat to ye now. There be treasure out there, and I wants it.") + return@npcOperate + } + arnavTalk() + } + + objectOperate("Open", "capn_arnav_chest") { + if (get("random_event") != "capn_arnav" || get("arnav_solved", false)) { + return@objectOperate + } + openLock() + } + + interfaceOption("Up", "capn_arnav_lock:up_*") { + roll(it.component.removePrefix("up_").toInt(), 1) + } + + interfaceOption("Down", "capn_arnav_lock:down_*") { + roll(it.component.removePrefix("down_").toInt(), -1) + } + + interfaceOption("Unlock", "capn_arnav_lock:unlock") { + if (get("random_event") != "capn_arnav") { + return@interfaceOption + } + unlock() + } + + objectOperate("Enter", "capn_arnav_exit_portal") { + if (get("random_event") != "capn_arnav") { + return@objectOperate + } + if (!get("arnav_solved", false)) { + npc("capn_arnav", "Ye're not goin' anywhere till ye've opened me chest, matey!") + return@objectOperate + } + clearState() + RandomEvents.complete(this) + } + } + + private suspend fun Player.startEvent() { + if (!contains("arnav_target")) { + // arnav_target is only set once the lock is opened; a relog mid-event resumes + // with tries and the solved flag intact so the reward can't be re-earned. + set("arnav_tries", 0) + clear("arnav_solved") + } + arnavHerald() + kidnap(ISLAND) + } + + private suspend fun Player.arnavHerald() { + val arnav = NPCs.addRandom("capn_arnav", tile.toCuboid(1), ticks = 25, owner = this) + ?: NPCs.add("capn_arnav", tile, ticks = 25, owner = this) + arnav.watch(this) + val greetings = listOf( + "Avast there, $name!", + "Ahoy, $name!", + "I got treasure here, $name!", + "Will ye help an old pirate, $name?", + ) + arnav.say(greetings.random(random)) + delay(2) + } + + private suspend fun Player.arnavTalk() { + if (get("arnav_solved", false)) { + npc("capn_arnav", "Ah, well done matey, that's the right combination. Now be off with ye, through the portal!") + return + } + npc("capn_arnav", "Ah, hello there, ${if (male) "laddie" else "lassie"}! I've just dug up an old treasure chest of mine.") + npc("capn_arnav", "Problem is, these old hands o'mine aren't as useful as they used t'be, and the lock on that chest is a little bit too fiddly for ol' Cap'n Arnav. Could you help me out?") + choice { + option("Yes, I'll help you unlock your chest.") { + npc("capn_arnav", "There are three columns. What you need to do for me is to match up each picture with the word underneath the column, and then unlock the chest.") + openLock() + } + option("No, sorry.") + } + } + + private fun Player.openLock() { + if (!contains("arnav_target")) { + set("arnav_target", WORDS.random(random)) + } + for (column in 1..3) { + set("arnav_lock_$column", START) + } + open("capn_arnav_lock") + // Send the access masks so the client reports clicks on the arrows and unlock button; the + // cache marks the options enabled but doesn't send them, so the buttons don't respond otherwise. + for (component in listOf("down_1", "up_1", "down_2", "up_2", "down_3", "up_3", "unlock")) { + interfaceOptions.unlockAll("capn_arnav_lock", component, 0..0) + } + sendWord(get("arnav_target", "")) + } + + /** + * Interface 185's target-word children (31 and 32) are static text components the cache decoder + * drops, so the text is sent by raw packed component id instead of through [Player.interfaces]. + */ + private fun Player.sendWord(word: String) { + client?.interfaceText(InterfaceDefinition.pack(LOCK_ID, 31), word) + client?.interfaceText(InterfaceDefinition.pack(LOCK_ID, 32), word) + } + + /** The centred symbol of a column, as an index into [WORDS]. */ + private fun Player.column(column: Int) = get("arnav_lock_$column", START) + + // The cylinders are a wrapping wheel driven entirely by the arnav_lock varbits: client script + // 2112 repositions the four models whenever a varbit changes, so the arrows just step the value. + private fun Player.roll(column: Int, direction: Int) { + if (get("random_event") != "capn_arnav" || column !in 1..3) { + return + } + set("arnav_lock_$column", (column(column) + direction + WORDS.size) % WORDS.size) + } + + private suspend fun Player.unlock() { + val target = get("arnav_target") ?: return + close("capn_arnav_lock") + if ((1..3).all { WORDS[column(it)] == target }) { + solve() + } else { + wrongCombination() + } + } + + private suspend fun Player.solve() { + set("arnav_solved", true) + val chest = GameObjects.getShape(CHEST, 10) + if (chest != null && chest.id == "capn_arnav_chest") { + GameObjects.replace(chest, "capn_arnav_chest_open", ticks = CHEST_OPEN_TICKS) + } + npc("capn_arnav", "Ah, well done matey, that's the right combination. Here, have a little somethin' for helpin' me out.") + addOrDrop("random_event_gift") + message("You've been given a gift!") + } + + private suspend fun Player.wrongCombination() { + val tries = inc("arnav_tries") + when { + tries >= MAX_TRIES -> { + npc("capn_arnav", "Arrr! I'd better find someone else.") + message("Cap'n Arnav hits you over the head with his bottle.") + clearState() + RandomEvents.fail(this) + } + tries == 1 -> { + npc("capn_arnav", "Arrr! That be nowhere near close! Did ye not listen the first time around?") + npc("capn_arnav", "Right, right. What you need to do is click on the arrows to roll the cylinders round to the different items.") + npc("capn_arnav", "When the items in the middle row all match the words written under the column then you'll have solved the lock.") + npc("capn_arnav", "Is that a little clearer?") + } + else -> npc("capn_arnav", "Arrr! That be nowhere near close!") + } + } + + private fun Player.clearState() { + clear("arnav_target") + clear("arnav_tries") + clear("arnav_solved") + for (column in 1..3) { + clear("arnav_lock_$column") + } + } + + companion object { + private const val LOCK_ID = 185 + private const val MAX_TRIES = 5 + private const val CHEST_OPEN_TICKS = 25 + + // The centred symbol per varbit value (client script 2112's wheel order); coins start centred. + private val WORDS = listOf("Coins", "Bowl", "Bar", "Ring") + private const val START = 0 + + private val ISLAND = Tile(1626, 5163) + private val CHEST = Tile(1627, 5162) + } +} diff --git a/game/src/main/kotlin/content/activity/event/random/certer/Certer.kt b/game/src/main/kotlin/content/activity/event/random/certer/Certer.kt new file mode 100644 index 0000000000..2b9ab7e85e --- /dev/null +++ b/game/src/main/kotlin/content/activity/event/random/certer/Certer.kt @@ -0,0 +1,88 @@ +package content.activity.event.random.certer + +import content.activity.event.random.RandomEvents +import content.activity.event.random.startInPlaceEvent +import content.entity.player.dialogue.Happy +import content.entity.player.dialogue.Neutral +import content.entity.player.dialogue.type.npc +import content.entity.player.inv.item.addOrDrop +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.player.Player +import world.gregs.voidps.engine.entity.character.player.name +import world.gregs.voidps.type.random + +/** + * Certer random event: one of the certificate brothers appears beside the player and nags until + * spoken to. He shows an item (interface 184) and three descriptions to choose from; picking the + * one that matches the item earns a random event gift, a wrong pick earns nothing. + * Ignoring him applies the note-and-teleport penalty. + * + * The interface builds itself from varbits: its load script reads the three option descriptions + * (keys into enum 2224) and the item model (a key into enum 2225) - so we just set those. + * https://runescape.wiki/w/Random_events?oldid=3667851#Certer + */ +class Certer : Script { + + init { + RandomEvents.register("certer") { startEvent() } + + npcOperate("Talk-to", "giles") { (giles) -> + if (giles.owner != this) { + message("They aren't interested in talking to you.") + return@npcOperate + } + npc("giles", "Ah, hello, $name. Could you please help me identify this?") + openPuzzle() + } + + interfaceOption("Select", "certer_identify:option_*") { + val correct = it.component.removePrefix("option_").toInt() == get("certer_answer", 0) + close("certer_identify") + if (correct) { + npc("giles", "Thank you, I hope you like your present. I must be leaving now though.") + addOrDrop("random_event_gift") + } else { + npc("giles", "Sorry, I don't think so.") + } + RandomEvents.completeInPlace(this) + } + } + + private fun Player.startEvent() { + val giles = startInPlaceEvent("giles", nagLines()) + giles.anim("emote_bow") + } + + private fun Player.openPuzzle() { + val answer = (0 until ITEMS).random(random) + val options = ((0 until ITEMS).filter { it != answer }.shuffled(random).take(2) + answer).shuffled(random) + set("certer_answer", options.indexOf(answer) + 1) + set("certer_desc_1", options[0]) + set("certer_desc_2", options[1]) + set("certer_desc_3", options[2]) + // Show the model matching the answer's description (enum 2225: model key = (desc + 2) % 9). + set("certer_model_enum", 0) + set("certer_model_key", (answer + 2) % ITEMS) + // Present the model upright at a sensible size. + set("certer_rotation_x", 5) + set("certer_rotation_y", 4) + set("certer_scale", 10) + set("certer_spin", 0) + open("certer_identify") + } + + private fun Player.nagLines() = listOf( + "Greetings $name, I need your help.", + "ehem... Hello $name, please talk to me!", + "Hello, are you there $name?", + "It's really rude to ignore someone, $name!", + "No-one ignores me!", + ) + + companion object { + private const val ITEMS = 9 // enum 2224 holds nine item descriptions (keys 0-8) + } +} diff --git a/game/src/main/kotlin/content/activity/event/random/drill_demon/DrillDemon.kt b/game/src/main/kotlin/content/activity/event/random/drill_demon/DrillDemon.kt new file mode 100644 index 0000000000..c8610c8120 --- /dev/null +++ b/game/src/main/kotlin/content/activity/event/random/drill_demon/DrillDemon.kt @@ -0,0 +1,148 @@ +package content.activity.event.random.drill_demon + +import content.activity.event.random.RandomEvents +import content.activity.event.random.kidnap +import content.activity.event.random.mysteriousOldMan +import content.activity.event.random.rewardCostumePoint +import content.entity.gfx.areaGfx +import content.entity.player.dialogue.Neutral +import content.entity.player.dialogue.type.item +import content.entity.player.dialogue.type.npc +import content.entity.player.inv.item.addOrDrop +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 +import world.gregs.voidps.engine.entity.character.sound +import world.gregs.voidps.type.Direction +import world.gregs.voidps.type.Tile +import world.gregs.voidps.type.random + +/** + * Drill Demon random event: Sergeant Damien whisks the player to his exercise yard and barks out an + * exercise. Each of the four mats shows one exercise on its sign (a varbit); the player must use the + * mat matching the order. Four correct exercises earns a random event gift. + * https://runescape.wiki/w/Random_events?oldid=3667851#Drill_Demon + */ +class DrillDemon : Script { + + init { + RandomEvents.register("drill_demon") { startEvent() } + + npcOperate("Talk-to", "sergeant_damien") { + if (get("random_event") != "drill_demon") { + message("They aren't interested in talking to you.") + return@npcOperate + } + if (!get("drill_demon_ready", false)) { + npc("sergeant_damien", "I haven't given you the order yet, worm!", largeHead = true) + return@npcOperate + } + order(wrong = false) + } + + objectOperate("Use", "drill_demon_mat_*") { (mat) -> + if (get("random_event") != "drill_demon") { + message("You can't do that right now.") + return@objectOperate + } + if (!get("drill_demon_ready", false)) { + npc("sergeant_damien", "I haven't given you the order yet, worm!", largeHead = true) + return@objectOperate + } + val value = get("drill_demon_sign_${mat.id.removePrefix("drill_demon_mat_")}", 0) + val exercise = EXERCISES.getValue(value) + walkOverDelay(Tile(mat.tile.x, YARD.y)) // stand on the mat before exercising + face(Direction.SOUTH) + sound(exercise.sound) + delay(2) + anim(exercise.anim) + delay(EXERCISE_TICKS) + val correct = value == get("drill_demon_task", 0) + if (correct) { + inc("drill_demon_correct") + } + if (get("drill_demon_correct", 0) >= REQUIRED) { + finish() + } else { + assignRound() + order(wrong = !correct) + } + } + } + + private suspend fun Player.startEvent() { + clear("drill_demon_ready") + if (contains("drill_demon_task")) { + // Resuming after a relog: keep the ordered task and progress, just restore the signs. + shuffleSigns(reveal = false) + } else { + set("drill_demon_correct", 0) + assignRound(reveal = false) + } + mysteriousOldMan() + kidnap(YARD) + message("Follow Sergeant Damien's orders!") + delay(ORDER_DELAY) // give the player a few seconds before barking the first order + set("drill_demon_ready", true) + order(wrong = false) + } + + /** + * Shuffle the four exercises across the mats' signs and pick the one the player must perform. + * When [reveal] is set a puff of smoke sweeps across the signs from sign 1 to sign 4, each half a + * tick apart (the signs themselves all change this tick; only the puffs are staggered client-side). + */ + private fun Player.assignRound(reveal: Boolean = true) { + set("drill_demon_task", EXERCISES.keys.random(random)) + shuffleSigns(reveal) + } + + private fun Player.shuffleSigns(reveal: Boolean) { + val exercises = EXERCISES.keys.shuffled(random) + for (sign in 1..exercises.size) { + set("drill_demon_sign_$sign", exercises[sign - 1]) + if (reveal) { + areaGfx("drill_demon_sign_change", SIGN_TILES[sign - 1], delay = (sign - 1) * HALF_TICK) + } + } + } + + private suspend fun Player.order(wrong: Boolean) { + val task = EXERCISES.getValue(get("drill_demon_task", 1)) + val prefix = if (wrong) "Wrong exercise, worm! " else "" + npc("sergeant_damien", "$prefix${task.order} private!", largeHead = true) + item(task.sign, "Go to this mat and ${task.action}!") + } + + private suspend fun Player.finish() { + npc("sergeant_damien", "Well I'll be, you actually did it $name. Now take this and get yourself out of my sight.", largeHead = true) + addOrDrop("random_event_gift") + rewardCostumePoint("camo") + clear("drill_demon_ready") + clear("drill_demon_task") + clear("drill_demon_correct") + RandomEvents.complete(this) + } + + private data class Exercise(val anim: String, val sound: String, val order: String, val sign: String, val action: String) + + companion object { + private const val REQUIRED = 4 + private const val EXERCISE_TICKS = 4 + private const val ORDER_DELAY = 8 // ~5 seconds before the first order + private const val HALF_TICK = 15 // client ticks; a game tick is 30 + private val YARD = Tile(3163, 4820) + + // The sign board sits two tiles north of each mat (mat y + 2). + private val SIGN_TILES = listOf(Tile(3160, 4821), Tile(3162, 4821), Tile(3164, 4821), Tile(3166, 4821)) + + // Sign varbit values, 1-based so a sign is never the cleared 0 default (0 = no sign shown). + private val EXERCISES = mapOf( + 1 to Exercise("drill_demon_jog", "drill_demon_jog", "Get yourself over there and jog on that mat", "run", "jog on the spot"), + 2 to Exercise("drill_demon_situp", "drill_demon_situp", "Get on that mat and give me sit ups", "sit_up", "do some sit-ups"), + 3 to Exercise("drill_demon_pushup", "drill_demon_pushup", "Drop and give me push ups on that mat", "push_up", "do some push-ups"), + 4 to Exercise("drill_demon_starjump", "drill_demon_starjump", "I want to see you on that mat doing star jumps", "star_jump", "do some star jumps"), + ) + } +} diff --git a/game/src/main/kotlin/content/activity/event/random/evil_bob/EvilBob.kt b/game/src/main/kotlin/content/activity/event/random/evil_bob/EvilBob.kt new file mode 100644 index 0000000000..5052fef83a --- /dev/null +++ b/game/src/main/kotlin/content/activity/event/random/evil_bob/EvilBob.kt @@ -0,0 +1,363 @@ +package content.activity.event.random.evil_bob + +import content.activity.event.random.RandomEvents +import content.activity.event.random.kidnap +import content.entity.player.dialogue.Angry +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.Unamused +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.dialogue.type.statement +import content.entity.player.inv.item.addOrDrop +import content.quest.instanceOffset +import content.quest.setInstanceLogout +import content.quest.smallInstance +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.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.name +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.engine.inv.remove +import world.gregs.voidps.type.Region +import world.gregs.voidps.type.Tile +import world.gregs.voidps.type.random + +/** + * Evil Bob random event: an evil incarnation of Bob the cat whisks the player to the "ScapeRune" + * island and demands to be fed. A terrified servant hints which of the four fishing spots holds the + * fish Bob likes; the player nets it (it comes out already cooked), uncooks it at the cold fire, and + * serves the raw fish to Bob. The right fish sends him to sleep and opens the exit portal; a wrong + * one makes him more attentive so an extra correct fish is needed. Reward: a random event gift. + * https://runescape.wiki/w/Random_events?oldid=3667851#Evil_Bob + */ +class EvilBob : Script { + + init { + RandomEvents.register("evil_bob") { startEvent() } + + npcOperate("Talk-to", "evil_bob") { (bob) -> + if (bob.owner != this) { + message("It pays you no attention.") + return@npcOperate + } + evilBobDialogue() + } + + itemOnNPCOperate("raw_fish_like_thing", "evil_bob") { (bob) -> + if (bob.owner == this) serveCorrect() + } + itemOnNPCOperate("raw_fish_like_thing_incorrect", "evil_bob") { (bob) -> + if (bob.owner == this) serveWrong() + } + + npcOperate("Talk-to", "evil_bob_servant") { (servant) -> + if (servant.owner != this) { + message("They're too frightened to speak to you.") + return@npcOperate + } + servantDialogue() + } + + objectOperate("Net", "evil_bob_fishing_spot") { + netFishingSpot() + } + + itemOnObjectOperate("fish_like_thing", "evil_bob_uncooking_pot") { + uncook("fish_like_thing", "raw_fish_like_thing") + } + itemOnObjectOperate("fish_like_thing_incorrect", "evil_bob_uncooking_pot") { + uncook("fish_like_thing_incorrect", "raw_fish_like_thing_incorrect") + } + + objectOperate("Enter", "evil_bob_exit_portal", arrive = false) { (portal) -> + if (!get("evil_bob_complete", false)) { + npc("evil_bob", "You're going nowhere, human!") + return@objectOperate + } + walkOverDelay(portal.tile) + bob()?.let { face(it.tile) } + delay(2) + anim("emote_raspberry") + say("Be seeing you!") + delay(2) + reward() + clearState() + RandomEvents.complete(this) + message("Welcome back.") + } + } + + private suspend fun Player.startEvent() { + smallInstance(Region(ISLAND_REGION), levels = 1) + setInstanceLogout(Tile(this["random_event_origin", tile.id])) + val offset = instanceOffset() + for (zone in ZONES) { + GameObjects.add("evil_bob_fishing_spot", zone.spot.add(offset), collision = false) + } + GameObjects.add("evil_bob_uncooking_pot", POT.add(offset), collision = false) + GameObjects.add("evil_bob_exit_portal", PORTAL.add(offset), collision = false) + + evilBobCatIntro() + kidnap(ISLAND.add(offset)) + + val bob = NPCs.add("evil_bob", BOB_TILE.add(offset), ticks = -1, owner = this) + bob.watch(this) + set("evil_bob_npc", bob.index) + val servant = NPCs.add("evil_bob_servant", SERVANT_TILE.add(offset), ticks = -1, owner = this) + servant.watch(this) + + if (get("evil_bob_zone", 0) == 0) { + assignZone() + } + if (!inventory.contains("small_fishing_net")) { + inventory.add("small_fishing_net") + } + face(bob.tile) + message("Welcome to ScapeRune.") + evilBobDialogue() + } + + private suspend fun Player.evilBobCatIntro() { + val cat = NPCs.addRandom("evil_bob_cat", tile.toCuboid(1), ticks = 25, owner = this) + ?: NPCs.add("evil_bob_cat", tile, ticks = 25, owner = this) + cat.watch(this) + cat.say("Meow.") + delay(2) + } + + private fun Player.assignZone() { + set("evil_bob_zone", random.nextInt(ZONES.size) + 1) // 1-based; 0 means "unassigned" + } + + private fun Player.bob(): NPC? = NPCs.indexed(get("evil_bob_npc", -1))?.takeIf { it.id == "evil_bob" } + + private fun Player.currentZone(): Int { + val local = tile.minus(instanceOffset()) + return ZONES.indexOfFirst { it.contains(local) } + 1 // 0 when in no zone + } + + private suspend fun Player.netFishingSpot() { + when { + get("evil_bob_complete", false) -> + statement("Evil Bob's had his fill; there's no need to fish any more.") + get("evil_bob_new_spot", false) -> + statement("You don't know if this is a good place to go fishing. Perhaps you should ask someone, like one of the human servants.") + !inventory.contains("small_fishing_net") -> + npc("evil_bob_servant", "You'll need a fishing net. There are plenty scattered around the beach.") + inventory.isFull() -> + message("You don't have enough space in your inventory.") + holdsFish() -> + npc("evil_bob_servant", "You've already got a fish. Come over here to uncook it, then serve it to Evil Bob.") + else -> { + anim("fish_small_fishing_net") + message("You cast out your net...") + delay(5) + val correct = currentZone() == get("evil_bob_zone", 0) + inventory.add(if (correct) "fish_like_thing" else "fish_like_thing_incorrect") + clearAnim() + item("fish_like_thing", "You catch a... what is this?? Is this a fish?? And it's cooked already??") + } + } + } + + private fun Player.holdsFish() = inventory.contains("fish_like_thing") || + inventory.contains("fish_like_thing_incorrect") || + inventory.contains("raw_fish_like_thing") || + inventory.contains("raw_fish_like_thing_incorrect") + + private suspend fun Player.uncook(cooked: String, raw: String) { + anim("cook_fire") + delay(2) + if (inventory.remove(cooked)) { + inventory.add(raw) + } + clearAnim() + } + + private suspend fun Player.serveCorrect() { + if (!inventory.remove("raw_fish_like_thing")) { + return + } + npc("evil_bob", "Mmm, mmm... that's delicious.") + if (get("evil_bob_attentive", false)) { + set("evil_bob_attentive", false) + assignZone() + set("evil_bob_new_spot", true) + npc("evil_bob", "Now get me another, you no-good human.") + statement("Evil Bob seems slightly less attentive of you.") + } else { + npc("evil_bob", "Now, let me take... a little... catnap.") + set("evil_bob_complete", true) + bob()?.say("ZZZzzz") + statement("Evil Bob has fallen asleep. Slip away through the portal while you can!") + } + } + + private suspend fun Player.serveWrong() { + if (!inventory.remove("raw_fish_like_thing_incorrect")) { + return + } + set("evil_bob_attentive", true) + set("evil_bob_new_spot", true) + npc("evil_bob", "What was this? That was absolutely disgusting!") + npc("evil_bob", "Don't you know what kind of fish I like? Talk to my other servants for some advice.") + statement("Evil Bob seems more attentive of you.") + } + + private suspend fun Player.evilBobDialogue() { + when { + get("evil_bob_complete", false) -> + statement("Evil Bob appears to be sleeping, best not to wake him up.") + inventory.contains("raw_fish_like_thing") -> serveCorrect() + inventory.contains("raw_fish_like_thing_incorrect") -> serveWrong() + inventory.contains("fish_like_thing") || inventory.contains("fish_like_thing_incorrect") -> + npc("evil_bob", "What, are you giving me cooked fish? What am I going to do with that? Uncook it first!") + !get("evil_bob_seen_intro", false) -> { + set("evil_bob_seen_intro", true) + introDialogue() + } + else -> reasonsDialogue() + } + } + + private suspend fun Player.introDialogue() { + player("Where am I?") + npc("evil_bob", "On my island.") + player("Who brought me here?") + npc("evil_bob", "That would be telling.") + player("Take me to your leader!") + npc("evil_bob", "I am your leader, you are but a slave.") + player("I am not a slave, I am a free man!") + npc("evil_bob", "Ah-ha-ha-ha-ha-ha!") + npc("evil_bob", "Now catch me some fish, I'm hungry. Talk to my other servants, and hurry it up!") + } + + private suspend fun Player.reasonsDialogue() { + player("Let me out of here!") + npc("evil_bob", "I will never let you go, $name!") + choice { + option("Why not?") { + npc("evil_bob", "Because I say so! And because I can never have enough servants!") + npc("evil_bob", "Now catch me some fish, I'm hungry.") + } + option("What's it all about?") { + npc("evil_bob", "You are a skilled worker. A human like you is worth a great deal as a slave.") + player("A slave?? I will have nothing to do with you.") + npc("evil_bob", "It's just a matter of time before you do everything I ask. Just ask my servants!") + } + option("How is it possible that you're talking?") { + npc("evil_bob", "How is it possible that you're not meowing?") + player("Meowing?? Why would I be meowing?") + npc("evil_bob", "Most humans do; that's why I wear this amulet of Man speak.") + } + option("What did you do to Bob?") { + npc("evil_bob", "Bob? I am Bob! An incarnation of Bob here on ScapeRune.") + npc("evil_bob", "You work just as well for me. Now get to work, human! Fish for me!") + } + } + } + + private suspend fun Player.servantDialogue() { + when { + get("evil_bob_complete", false) -> { + player("Evil Bob has fallen asleep, come quickly!") + npc("evil_bob_servant", "You go, $name. I don't belong there... this is the only place I can ever go.") + } + !get("evil_bob_servant_helped", false) -> { + player("I need help, I've been kidnapped by an evil cat!") + npc("evil_bob_servant", "Meow! Errr... I c-c-c-can't help you... He'll kill us all!") + player("He's just a little cat! There must be something I can do!") + npc("evil_bob_servant", "F-f-f-fish... give him the f-f-f-fish he likes and he might f-f-f-fall asleep.") + set("evil_bob_servant_helped", true) + showSpot() + } + get("evil_bob_new_spot", false) -> { + npc("evil_bob_servant", "Look... over t-t-there! That fishing spot c-c-contains the f-f-f-fish he likes.") + showSpot() + } + else -> + npc("evil_bob_servant", "F-f-f-fish... give him the f-f-f-fish he likes and he might f-f-f-fall asleep.") + } + } + + /** Pans the camera to the assigned fishing spot, then hands control back and unlocks netting. */ + private suspend fun Player.showSpot() { + val zone = ZONES[get("evil_bob_zone", 1).coerceIn(1, ZONES.size) - 1] + val offset = instanceOffset() + moveCamera(zone.cameraMove.add(offset), zone.cameraMoveHeight) + turnCamera(zone.cameraTurn.add(offset), zone.cameraTurnHeight) + delay(5) + clearCamera() + set("evil_bob_new_spot", false) + } + + private fun Player.reward() { + addOrDrop("random_event_gift") + } + + private fun Player.clearState() { + for (id in FISH_ITEMS) { + while (inventory.remove(id)) { + // strip every fish-like thing so none survive the trip home + } + } + inventory.remove("small_fishing_net") + clear("evil_bob_npc") + clear("evil_bob_zone") + clear("evil_bob_complete") + clear("evil_bob_attentive") + clear("evil_bob_new_spot") + clear("evil_bob_seen_intro") + clear("evil_bob_servant_helped") + } + + private data class Zone( + val minX: Int, + val minY: Int, + val maxX: Int, + val maxY: Int, + val spot: Tile, + val cameraMove: Tile, + val cameraMoveHeight: Int, + val cameraTurn: Tile, + val cameraTurnHeight: Int, + ) { + fun contains(tile: Tile) = tile.x in minX..maxX && tile.y in minY..maxY + } + + companion object { + private const val ISLAND_REGION = 13642 + private val ISLAND = Tile(3419, 4776) + private val BOB_TILE = Tile(3420, 4777) + private val SERVANT_TILE = Tile(3423, 4777) + private val POT = Tile(3423, 4780) + private val PORTAL = Tile(3416, 4777) + + private val FISH_ITEMS = listOf( + "fish_like_thing", + "fish_like_thing_incorrect", + "raw_fish_like_thing", + "raw_fish_like_thing_incorrect", + ) + + // (region-13642 base 3392,4736 + local coords). Index order = zone id 1..4. + private val ZONES = listOf( + Zone(3421, 4789, 3427, 4792, Tile(3424, 4791), Tile(3422, 4773), 400, Tile(3422, 4786), 400), // north + Zone(3437, 4774, 3440, 4780, Tile(3438, 4777), Tile(3417, 4777), 440, Tile(3434, 4777), 440), // east + Zone(3419, 4763, 3426, 4765, Tile(3422, 4764), Tile(3423, 4782), 365, Tile(3421, 4766), 365), // south + Zone(3405, 4773, 3408, 4779, Tile(3406, 4776), Tile(3426, 4777), 325, Tile(3408, 4776), 300), // west + ) + } +} diff --git a/game/src/main/kotlin/content/activity/event/random/evil_twin/EvilTwin.kt b/game/src/main/kotlin/content/activity/event/random/evil_twin/EvilTwin.kt new file mode 100644 index 0000000000..c947f93e57 --- /dev/null +++ b/game/src/main/kotlin/content/activity/event/random/evil_twin/EvilTwin.kt @@ -0,0 +1,388 @@ +package content.activity.event.random.evil_twin + +import content.activity.event.random.RandomEvents +import content.activity.event.random.kidnap +import content.entity.effect.clearTransform +import content.entity.effect.transform +import content.entity.player.dialogue.Angry +import content.entity.player.dialogue.Happy +import content.entity.player.dialogue.Neutral +import content.entity.player.dialogue.Sad +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.player.modal.Tab +import content.entity.player.modal.tab +import content.quest.closeTabs +import content.quest.instance +import content.quest.instanceOffset +import content.quest.openTabs +import content.quest.setInstanceLogout +import content.quest.smallInstance +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.client.Minimap +import world.gregs.voidps.engine.client.clearCamera +import world.gregs.voidps.engine.client.clearMinimap +import world.gregs.voidps.engine.client.message +import world.gregs.voidps.engine.client.minimap +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.mode.EmptyMode +import world.gregs.voidps.engine.entity.character.mode.Wander +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.name +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.ObjectShape +import world.gregs.voidps.type.Direction +import world.gregs.voidps.type.Region +import world.gregs.voidps.type.Tile +import world.gregs.voidps.type.area.Cuboid +import world.gregs.voidps.type.random + +/** + * Evil Twin random event: Molly appears beside the player, pleads for help and whisks them away to + * her house, where her evil twin sister hides in a pen of lookalike civilians next door. The + * player drives a giant mechanical claw from the control panel (interface 240) and has two tries + * to grab the sister - she's the one dressed exactly like Molly. Success earns a random event + * gift; running out of tries gets the player thrown out with nothing. + * https://runescape.wiki/w/Random_events?oldid=3667851#Evil_Twin + */ +class EvilTwin : Script { + + init { + RandomEvents.register("evil_twin") { startEvent() } + + npcOperate("Talk-to", "molly_*") { (molly) -> + if (molly.owner != this) { + message("She isn't interested in talking to you.") + return@npcOperate + } + if (instance() == null) { + return@npcOperate + } + if (get("evil_twin_caught", false)) { + success() + } else { + reminderDialogue() + } + } + + objectOperate("Use", "control_panel") { + if (get("random_event") != "evil_twin") { + return@objectOperate + } + if (get("evil_twin_caught", false)) { + message("You've already caught the evil twin.") + return@objectOperate + } + open("evil_twin_crane") + } + + interfaceOpened("evil_twin_crane") { + tab(Tab.Inventory) + sendTries() + clawCamera() + } + + interfaceClosed("evil_twin_crane") { + clearCamera() + if (get("random_event") == "evil_twin" && instance() != null) { + removeClaw() + placeClaw(CLAW_HOME) + } + } + + // The camera looks south over the pen, so the on-screen directions flip. + interfaceOption("Up", "evil_twin_crane:up") { + moveClaw(Direction.SOUTH) + } + + interfaceOption("Down", "evil_twin_crane:down") { + moveClaw(Direction.NORTH) + } + + interfaceOption("Left", "evil_twin_crane:left") { + moveClaw(Direction.EAST) + } + + interfaceOption("Right", "evil_twin_crane:right") { + moveClaw(Direction.WEST) + } + + interfaceOption("Grab", "evil_twin_crane:grab") { + grab() + } + + interfaceOption("Close", "evil_twin_crane:close") { + close("evil_twin_crane") + } + } + + private suspend fun Player.startEvent() { + // A logout mid-house restarts here on login; rebuild the house and carry on where + // they left off rather than starting the event over. + if (get("evil_twin_tries", 0) > 0 || get("evil_twin_caught", false)) { + setupHouse() + return + } + val hash = random.nextInt(LOOKS) + set("evil_twin_hash", hash) + // There's no choice about taking part: Molly appears, pleads, and whisks the player away. + val molly = NPCs.addRandom("molly_$hash", tile.toCuboid(1), ticks = 25, owner = this) + ?: NPCs.add("molly_$hash", tile, ticks = 25, owner = this) + molly.watch(this) + molly.say(nagLines().random(random)) + delay(3) + set("evil_twin_tries", TRIES) + setupHouse() + houseDialogue() + } + + /** + * Copy Molly's house into a private instance and take the player and Molly there. The house + * furniture (control panel, cage, crates) comes with the map; the claw and its floor marker + * are spawned at [CLAW_HOME], and the five suspects - one per look sharing the twin's colour, + * only one matching her exactly - are set wandering the pen. Once the twin has been caught + * only she remains, waiting in the jail. + */ + private suspend fun Player.setupHouse() { + smallInstance(Region(HOUSE_REGION), levels = 1) + setInstanceLogout(Tile(this["random_event_origin", tile.id])) + val offset = instanceOffset() + set("evil_twin_claw_x", CLAW_HOME.x) + set("evil_twin_claw_y", CLAW_HOME.y) + if (GameObjects.at(CLAW_HOME.add(offset)).none { it.id == "evil_twin_claw" }) { + GameObjects.add("evil_twin_claw", CLAW_HOME.add(offset), collision = false) + } + if (GameObjects.at(CLAW_HOME.add(offset)).none { it.id == "evil_twin_claw_marker" }) { + GameObjects.add("evil_twin_claw_marker", CLAW_HOME.add(offset), shape = ObjectShape.GROUND_DECOR, collision = false) + } + kidnap(ARRIVAL.add(offset)) + closeTabs() + minimap(Minimap.HideMap) + val hash = get("evil_twin_hash", 0) + val host = NPCs.add("molly_$hash", MOLLY_TILE.add(offset), ticks = -1, owner = this) + host.watch(this) + if (get("evil_twin_caught", false)) { + NPCs.add("twin_suspect_$hash", JAIL.add(offset), ticks = -1, owner = this) + } else { + spawnSuspects(hash, PEN.offset(offset)) + } + } + + private fun Player.spawnSuspects(hash: Int, pen: Cuboid) { + val colour = hash % COLOURS + for (model in 0 until MODELS) { + val id = "twin_suspect_${colour + model * COLOURS}" + val npc = NPCs.addRandom(id, pen, owner = this) ?: continue + npc.mode = Wander(npc, npc.tile) + } + } + + private fun Player.clawTile(): Tile = Tile(get("evil_twin_claw_x", CLAW_HOME.x), get("evil_twin_claw_y", CLAW_HOME.y)) + + private fun Player.clawObject(): GameObject? = GameObjects.at(clawTile().add(instanceOffset())) + .firstOrNull { it.id == "evil_twin_claw" } + + private fun Player.markerObject(): GameObject? = GameObjects.at(clawTile().add(instanceOffset())) + .firstOrNull { it.id == "evil_twin_claw_marker" } + + /** Bird's-eye view over the pen, looking at wherever the claw is. */ + private fun Player.clawCamera() { + val offset = instanceOffset() + moveCamera(CAMERA.add(offset), CAMERA_HEIGHT) + turnCamera(clawTile().add(offset), CLAW_HEIGHT) + } + + private fun Player.moveClaw(direction: Direction) { + val next = clawTile().add(direction.delta) + if (!TRAVEL.contains(next.x, next.y, 0)) { + return + } + removeClaw() + placeClaw(next) + sound("evil_twin_claw_move") + clawCamera() + } + + private fun Player.removeClaw() { + clawObject()?.let { GameObjects.remove(it, collision = false) } + markerObject()?.let { GameObjects.remove(it, collision = false) } + } + + private fun Player.placeClaw(next: Tile) { + val tile = next.add(instanceOffset()) + if (GameObjects.at(tile).none { it.id == "evil_twin_claw" }) { + GameObjects.add("evil_twin_claw", tile, collision = false) + } + if (GameObjects.at(tile).none { it.id == "evil_twin_claw_marker" }) { + GameObjects.add("evil_twin_claw_marker", tile, shape = ObjectShape.GROUND_DECOR, collision = false) + } + set("evil_twin_claw_x", next.x) + set("evil_twin_claw_y", next.y) + } + + /** + * Drop the claw on whoever is standing on the marker right now - wander a tile off it and the + * claw comes up empty. A grabbed suspect gets hauled over the cage wall to the jail; the rest + * puff away if she was the twin, or the tries tick down if she wasn't. Either way the claw + * swings back to its home corner afterwards, ready for another go. + */ + private suspend fun Player.grab() { + if (get("evil_twin_caught", false)) { + return + } + val offset = instanceOffset() + val target = clawTile().add(offset) + sound("evil_twin_claw_move") + clawObject()?.anim("evil_twin_claw_grab") + val suspect = NPCs.at(target).firstOrNull { it.id.startsWith("twin_suspect_") } + if (suspect == null) { + delay(2) + message("The claw comes up empty.") + useTry() + return + } + val look = suspect.id.removePrefix("twin_suspect_").toInt() + val twin = look == get("evil_twin_hash", 0) + suspect.mode = EmptyMode + suspect.anim("evil_twin_lifted") + suspect.gfx("evil_twin_lifted") + turnCamera(JAIL.add(offset), CLAW_HEIGHT) + delay(5) + anim("evil_twin_operate") + removeClaw() // The claw leaves with her, and swings back once she's been dropped off + suspect.transform("twin_suspect_carried_$look", collision = false) + suspect.walkOverDelay(JAIL.add(offset)) + sound("evil_twin_claw_lower") + suspect.anim("evil_twin_claw_lower") + delay(3) + suspect.clearTransform() + suspect.face(tile) + sound("evil_twin_claw_drop") + if (twin) { + catchTwin(suspect) + } else { + message("You caught an innocent civilian!") + suspect.say("You're putting me in prison?!") + delay(2) + placeClaw(CLAW_HOME) + clawCamera() + useTry() + } + } + + private suspend fun Player.catchTwin(twin: NPC) { + message("You caught the evil twin!") + set("evil_twin_caught", true) + jingle("twin_is_caught") + twin.face(Direction.NORTH) + delay(2) + twin.anim("evil_twin_jailed") + for (npc in NPCs.at(twin.tile.regionLevel)) { + if (npc.id.startsWith("twin_suspect_") && npc != twin) { + npc.gfx("imp_puff") + NPCs.remove(npc) + } + } + delay(2) + placeClaw(CLAW_HOME) + close("evil_twin_crane") + statement("You've caught Molly's evil twin! Talk to Molly to collect your reward.") + } + + private suspend fun Player.useTry() { + val remaining = dec("evil_twin_tries") + sendTries() + if (remaining > 0) { + return + } + close("evil_twin_crane") + npc(mollyId(), "Such incompetence! I should never have asked a baboon like you to do a complex task like this!") + npc(mollyId(), "Get out of my sight!") + clearState() + openTabs() + clearMinimap() + RandomEvents.fail(this) + } + + private fun Player.sendTries() { + interfaces.sendText("evil_twin_crane", "tries", "Tries: ${get("evil_twin_tries", 0)}") + } + + private suspend fun Player.success() { + player("Well, I've managed to get her into the cage.") + npc(mollyId(), "Fantastic! For so many years I've had to put up with her and now she's locked up for good.") + npc(mollyId(), "Thank you for all your help. Take this as a reward.") + addOrDrop("random_event_gift") + clearState() + openTabs() + clearMinimap() + RandomEvents.complete(this) + } + + private suspend fun Player.houseDialogue() { + npc(mollyId(), "Thank you for coming, $name. I'm sorry to drag you away like this, but I really need your help.") + npc(mollyId(), "My evil twin sister has been committing crimes and everyone blames me! We look exactly alike - even our clothes are the same.") + npc(mollyId(), "I've managed to trap her next door, but she dragged several other women in with her.") + npc(mollyId(), "Through that door is a control panel that operates a giant mechanical claw. Use it to catch my sister!") + npc(mollyId(), "Be careful though; the claw's magic is running low, so you only have two attempts.") + choice { + option("Tell me about the controls.") { + npc(mollyId(), "The arrow buttons each move the claw one square; the glowing mark on the floor shows where it's aiming.") + npc(mollyId(), "When my sister stands on the mark, hit the red button to lower the claw and grab her.") + npc(mollyId(), "Make sure it's really her and not an innocent bystander!") + } + option("I'll get right to it.") + } + } + + private suspend fun Player.reminderDialogue() { + npc(mollyId(), "My sister is still on the loose! Use the control panel next door to catch her.") + npc(mollyId(), "Remember, she's the one that looks exactly like me.") + } + + private fun Player.mollyId() = "molly_${get("evil_twin_hash", 0)}" + + private fun Player.nagLines() = listOf( + "Please, could you help me, $name?", + "I need your help urgently, $name!", + ) + + private fun Player.clearState() { + clear("evil_twin_hash") + clear("evil_twin_tries") + clear("evil_twin_claw_x") + clear("evil_twin_claw_y") + clear("evil_twin_caught") + } + + companion object { + private const val HOUSE_REGION = 7504 // Region(29, 80), base 1856,5120 + private const val LOOKS = 20 // colour (0-3) + model (0-4) * 4 + private const val COLOURS = 4 + private const val MODELS = 5 + private const val TRIES = 2 + private const val CAMERA_HEIGHT = 800 + private const val CLAW_HEIGHT = 250 + + private val ARRIVAL = Tile(1860, 5136) + private val MOLLY_TILE = Tile(1860, 5135) + private val CLAW_HOME = Tile(1870, 5132) + private val JAIL = Tile(1866, 5124) + private val CAMERA = Tile(1870, 5140) + + // The pen the suspects wander, and the box the claw can be driven anywhere within. + private val PEN = Cuboid(1867, 5126, 1874, 5131, 0, 0) + private val TRAVEL = Cuboid(1865, 5124, 1875, 5133, 0, 0) + } +} diff --git a/game/src/main/kotlin/content/activity/event/random/freaky_forester/FreakyForester.kt b/game/src/main/kotlin/content/activity/event/random/freaky_forester/FreakyForester.kt new file mode 100644 index 0000000000..202b20f00a --- /dev/null +++ b/game/src/main/kotlin/content/activity/event/random/freaky_forester/FreakyForester.kt @@ -0,0 +1,115 @@ +package content.activity.event.random.freaky_forester + +import content.activity.event.random.RandomEvents +import content.activity.event.random.kidnap +import content.activity.event.random.rewardCostumePoint +import content.entity.combat.killer +import content.entity.player.dialogue.Happy +import content.entity.player.dialogue.Neutral +import content.entity.player.dialogue.type.npc +import content.entity.player.inv.item.addOrDrop +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 +import world.gregs.voidps.engine.entity.item.floor.FloorItems +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.engine.inv.remove +import world.gregs.voidps.type.Tile +import world.gregs.voidps.type.random + +/** + * Freaky Forester random event: the player is whisked to the forester's clearing and told to kill + * a pheasant with a specific number of tails. Only the assigned pheasant yields the correct raw + * pheasant; handing it back rewards a random event gift. + * https://runescape.wiki/w/Random_events?oldid=3667851#Freaky_Forester + */ +class FreakyForester : Script { + + init { + RandomEvents.register("freaky_forester") { startEvent() } + + npcOperate("Talk-to", "freaky_forester") { + if (get("random_event") != "freaky_forester") { + message("The forester is too busy to talk to you.") + return@npcOperate + } + foresterDialogue() + } + + // One pheasant per trip: don't let the player farm the clearing. + canAttack("pheasant_*") { + if (carriesRawPheasant()) { + message("You don't need to attack any more pheasants.") + false + } else { + true + } + } + + npcDeath("pheasant_*") { death -> + val killer = killer as? Player ?: return@npcDeath + if (killer.get("random_event") != "freaky_forester") { + return@npcDeath + } + death.dropItems = false + // The pheasant drops the raw bird for the killer to pick up and hand to the forester. + val correct = tailCount(id) == killer.get("freaky_forester_task", 0) + val bird = if (correct) "raw_pheasant" else "raw_pheasant_incorrect" + FloorItems.add(tile, bird, revealTicks = FloorItems.NEVER, disappearTicks = 300, owner = killer) + } + } + + private suspend fun Player.startEvent() { + if (!contains("freaky_forester_task")) { + // Keep the assigned pheasant on a relog resume. + set("freaky_forester_task", random.nextInt(1, TAILS + 1)) + } + kidnap(CLEARING) + giveTask() + } + + private suspend fun Player.foresterDialogue() { + when { + inventory.contains("raw_pheasant") -> { + // Dialogue first: walking off cancels the handler, so only take the bird + // once the suspending lines are done and the reward is guaranteed. + npc("freaky_forester", "Thanks, $name, you may leave the area now.") + reward() + inventory.remove("raw_pheasant") + clear("freaky_forester_task") + RandomEvents.complete(this) + } + inventory.contains("raw_pheasant_incorrect") -> { + npc("freaky_forester", "That's not the right one.") + inventory.remove("raw_pheasant_incorrect") + } + else -> giveTask() + } + } + + private suspend fun Player.giveTask() { + val tails = get("freaky_forester_task", 1) + npc( + "freaky_forester", + "Hey there $name. Can you kill the ${TAIL_WORDS[tails]} tailed pheasant please. " + + "Bring me the raw pheasant when you're done.", + ) + } + + private suspend fun Player.reward() { + npc("freaky_forester", "Please take this gift as a reward for your help, many thanks!") + addOrDrop("random_event_gift") + rewardCostumePoint("lederhosen") + } + + private fun Player.carriesRawPheasant() = inventory.contains("raw_pheasant") || inventory.contains("raw_pheasant_incorrect") + + private fun tailCount(pheasantId: String) = pheasantId.removePrefix("pheasant_").substringBefore("_").toIntOrNull() ?: 0 + + companion object { + private const val TAILS = 4 + private val TAIL_WORDS = arrayOf("", "one", "two", "three", "four") + private val CLEARING = Tile(2601, 4777) + } +} diff --git a/game/src/main/kotlin/content/activity/event/random/gravedigger/Gravedigger.kt b/game/src/main/kotlin/content/activity/event/random/gravedigger/Gravedigger.kt new file mode 100644 index 0000000000..6bcac4a482 --- /dev/null +++ b/game/src/main/kotlin/content/activity/event/random/gravedigger/Gravedigger.kt @@ -0,0 +1,309 @@ +package content.activity.event.random.gravedigger + +import content.activity.event.random.RandomEvents +import content.activity.event.random.kidnap +import content.activity.event.random.rewardCostumePoint +import content.entity.player.dialogue.Happy +import content.entity.player.dialogue.Neutral +import content.entity.player.dialogue.Sad +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.entity.player.modal.Tab +import content.entity.player.modal.tab +import content.quest.instance +import content.quest.instanceOffset +import content.quest.openTabs +import content.quest.setInstanceLogout +import content.quest.smallInstance +import content.skill.magic.spell.spellBook +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.message +import world.gregs.voidps.engine.client.minimap +import world.gregs.voidps.engine.client.ui.close +import world.gregs.voidps.engine.client.ui.open +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.name +import world.gregs.voidps.engine.entity.obj.GameObject +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.engine.inv.remove +import world.gregs.voidps.type.Region +import world.gregs.voidps.type.Tile +import world.gregs.voidps.type.random + +/** + * Gravedigger random event: Leo the gravedigger appears and whisks the player to his graveyard, + * where he's buried five coffins in the wrong graves. Checking a coffin shows the belongings of + * whoever's inside and each gravestone shows the profession of whoever ought to lie beneath it; + * the player digs the coffins up and reburies each in its proper grave, banking spare items at + * the mausoleum. Telling Leo it's done when all five match earns a random event gift + * and the zombie emotes; he'll also take anyone home who'd rather give up. + * https://runescape.wiki/w/Random_events?oldid=3667851#Gravedigger + */ +class Gravedigger : Script { + + init { + RandomEvents.register("gravedigger") { startEvent() } + + npcOperate("Talk-to", "leo_gravedigger") { (leo) -> + if (leo.owner != this) { + message("He's too busy to talk.") + return@npcOperate + } + if (instance() == null) { + return@npcOperate + } + leoDialogue() + } + + objectOperate("Take-coffin", "gravedigger_grave_*") { (grave) -> + if (get("random_event") != "gravedigger") { + return@objectOperate + } + takeCoffin(grave) + } + + itemOnObjectOperate("coffin*", "gravedigger_open_grave_*") { (grave, coffin) -> + if (get("random_event") != "gravedigger") { + return@itemOnObjectOperate + } + buryCoffin(grave, coffin.id) + } + + itemOption("Check", "coffin*") { (coffin) -> + open("gravedigger_coffin") + for ((index, item) in CONTENTS[coffinIndex(coffin.id)].withIndex()) { + interfaces.sendItem("gravedigger_coffin", "item_${index + 1}", item) + } + } + + objectOperate("Read", "gravedigger_gravestone_*") { (stone) -> + open("gravedigger_gravestone") + val profession = stone.id.removePrefix("gravedigger_gravestone_").toInt() + interfaces.sendItem("gravedigger_gravestone", "icon", ICONS[profession]) + } + + objectOperate("Deposit", "gravedigger_mausoleum") { + if (get("random_event") != "gravedigger") { + return@objectOperate + } + open("bank_deposit_box") + } + } + + private suspend fun Player.startEvent() { + // A logout mid-event restarts here on login; rebuild the graveyard and carry on + // where they left off rather than starting the event over. + if (get("gravedigger_started", false)) { + setupGraveyard() + return + } + // There's no choice about taking part: Leo appears, asks, and whisks the player away. + val leo = NPCs.addRandom("leo_gravedigger", tile.toCuboid(1), ticks = 25, owner = this) + ?: NPCs.add("leo_gravedigger", tile, ticks = 25, owner = this) + leo.watch(this) + leo.say("Can I borrow you for a minute, $name?") + delay(3) + set("gravedigger_started", true) + scrambleGraves() + setupGraveyard() + introDialogue() + } + + /** + * Bury the coffins in a shuffled arrangement - the map's graves come pre-solved, so reshuffle + * until at least one coffin is in the wrong grave. + */ + private fun Player.scrambleGraves() { + var shuffle: List + do { + shuffle = SITES.indices.shuffled(random) + } while (shuffle.withIndex().all { it.index == it.value }) + for ((site, coffin) in shuffle.withIndex()) { + set("gravedigger_site_$site", coffin + 1) + } + } + + /** + * Copy the graveyard into a private instance and take the player and Leo there. The graves, + * gravestones and mausoleum come with the map; each grave is then replaced to match whichever + * coffin lies in it. + */ + private suspend fun Player.setupGraveyard() { + smallInstance(Region(GRAVEYARD_REGION), levels = 1) + setInstanceLogout(Tile(this["random_event_origin", tile.id])) + val offset = instanceOffset() + for (site in SITES.indices) { + refreshGrave(site) + } + kidnap(ARRIVAL.add(offset)) + // Close every tab except the inventory - the coffins live there + for (tabName in HIDDEN_TABS) { + close(tabName) + } + set("spell_book", spellBook) + close(spellBook) + minimap(Minimap.HideMap) + tab(Tab.Inventory) + val leo = NPCs.add("leo_gravedigger", LEO_TILE.add(offset), ticks = -1, owner = this) + leo.watch(this) + } + + /** Swap the object at a grave site to match its contents var. */ + private fun Player.refreshGrave(site: Int) { + val tile = SITES[site].add(instanceOffset()) + for (obj in GameObjects.at(tile)) { + if (obj.id.startsWith("gravedigger_grave_") || obj.id.startsWith("gravedigger_open_grave_")) { + GameObjects.remove(obj) + } + } + val contents = get("gravedigger_site_$site", 0) + val id = if (contents == 0) "gravedigger_open_grave_$site" else "gravedigger_grave_${contents - 1}" + GameObjects.add(id, tile) + } + + private fun Player.siteAt(tile: Tile): Int = SITES.indexOf(tile.minus(instanceOffset())) + + private suspend fun Player.takeCoffin(grave: GameObject) { + val site = siteAt(grave.tile) + if (site == -1) { + return + } + val contents = get("gravedigger_site_$site", 0) + if (contents == 0) { + return + } + if (inventory.isFull()) { + message("You need space in your inventory to take the coffin.") + return + } + anim("climb_down") // Kneel down and dig + delay(1) + if (!inventory.add(coffinName(contents - 1))) { + return + } + set("gravedigger_site_$site", 0) + refreshGrave(site) + } + + private suspend fun Player.buryCoffin(grave: GameObject, coffin: String) { + val site = siteAt(grave.tile) + if (site == -1 || get("gravedigger_site_$site", 0) != 0) { + return + } + anim("climb_down") + delay(1) + if (!inventory.remove(coffin)) { + return + } + set("gravedigger_site_$site", coffinIndex(coffin) + 1) + refreshGrave(site) + message("You put the coffin into the grave.") + } + + private fun Player.solved(): Boolean = SITES.indices.all { get("gravedigger_site_$it", 0) == it + 1 } + + private suspend fun Player.introDialogue() { + npc("leo_gravedigger", "Sorry to interrupt, but I could really use your help.") + npc("leo_gravedigger", "I've been reburying the coffins from these five graves, but I've clean forgotten which coffin came from which grave!") + npc("leo_gravedigger", "Check the coffins to see whose remains are inside, and read the gravestones to see who ought to be buried where. Then put each coffin in its proper grave.") + npc("leo_gravedigger", "Don't forget to store any items that you don't need in the mausoleum. I'll take them to the bank while you work.") + } + + private suspend fun Player.leoDialogue() { + npc("leo_gravedigger", "How are you getting on?") + choice { + option("There, finished!") { + if (solved()) { + success() + } else { + npc("leo_gravedigger", "Well, that's a good attempt, but it's just not right.") + npc("leo_gravedigger", "Try looking in the coffins to get a better idea of who is in them, and then read the gravestones to find who needs to be in there.") + player("All right, I'll give it another shot.") + } + } + option("How do I do this again?") { + npc("leo_gravedigger", "Check the coffins to see whose remains are inside, and read the gravestones to see who ought to be buried where. Then put each coffin in its proper grave.") + } + option("I want to leave.") { + npc("leo_gravedigger", "In that case, I'll take you back to where I found you.") + exitGraveyard() + } + } + } + + private suspend fun Player.success() { + npc("leo_gravedigger", "Wonderful! That's taken care of all of them.") + npc("leo_gravedigger", "Here, I'll take you back to where I found you, and give you your reward.") + addOrDrop("random_event_gift") + rewardCostumePoint("zombie") + set("unlocked_emote_zombie_walk", true) + set("unlocked_emote_zombie_dance", true) + exitGraveyard() + } + + private fun Player.exitGraveyard() { + for (coffin in SITES.indices) { + while (inventory.remove(coffinName(coffin))) { + // Leo keeps his coffins + } + } + for (site in SITES.indices) { + clear("gravedigger_site_$site") + } + clear("gravedigger_started") + openTabs() + clearMinimap() + RandomEvents.complete(this) + } + + private fun coffinName(index: Int) = if (index == 0) "coffin" else "coffin_${index + 1}" + + private fun coffinIndex(name: String) = if (name == "coffin") 0 else name.removePrefix("coffin_").toInt() - 1 + + companion object { + private const val GRAVEYARD_REGION = 7758 // Region(30, 78), base 1920,4992 + + private val ARRIVAL = Tile(1928, 5002) + private val LEO_TILE = Tile(1928, 5003) + + // Cutscene.closeTabs minus the inventory + private val HIDDEN_TABS = listOf( + "combat_styles", + "task_system", + "stats", + "quest_journals", + "worn_equipment", + "prayer_list", + "emotes", + "notes", + ) + + // Grave sites in profession order: lumberjack, cook, miner, farmer, potter. + // Each has a matching gravestone (12716-12720) beside it in the map. + private val SITES = listOf( + Tile(1924, 4996), + Tile(1926, 4999), + Tile(1928, 4996), + Tile(1930, 4999), + Tile(1932, 4996), + ) + + // What's found inside each profession's coffin (interface 141), and the profession + // icon shown when reading a gravestone (interface 143). + private val CONTENTS = listOf( + listOf(7611, 7598, 7598, 7598, 7598, 7603, 7598, 7605, 7612), // lumberjack + listOf(7604, 7601, 7598, 7600, 7598, 7611, 7598, 7598, 7598), // cook + listOf(7598, 7598, 7606, 7598, 7597, 7598, 7607, 7598, 7611), // miner + listOf(7598, 7598, 7610, 7611, 7609, 7598, 7602, 7598, 7598), // farmer + listOf(7598, 7599, 7608, 7613, 7598, 7598, 7598, 7611, 7598), // potter + ) + private val ICONS = listOf(7614, 7615, 7616, 7617, 7618) + } +} diff --git a/game/src/main/kotlin/content/activity/event/random/kiss_the_frog/KissTheFrog.kt b/game/src/main/kotlin/content/activity/event/random/kiss_the_frog/KissTheFrog.kt new file mode 100644 index 0000000000..7d2b0118b0 --- /dev/null +++ b/game/src/main/kotlin/content/activity/event/random/kiss_the_frog/KissTheFrog.kt @@ -0,0 +1,298 @@ +package content.activity.event.random.kiss_the_frog + +import content.activity.event.random.RandomEvents +import content.activity.event.random.endInPlaceEvent +import content.activity.event.random.kidnap +import content.activity.event.random.rewardCostumePoint +import content.activity.event.random.startInPlaceEvent +import content.entity.effect.clearTransform +import content.entity.effect.transform +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.type.npc +import content.entity.player.dialogue.type.player +import content.entity.player.inv.item.addOrDrop +import content.quest.closeTabs +import content.quest.openTabs +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.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.name +import world.gregs.voidps.engine.queue.queue +import world.gregs.voidps.type.Tile +import world.gregs.voidps.type.random + +/** + * Kiss the Frog (Frog Princess) random event: the Frog Herald hops up to the player and whisks them + * to the Land of the Frogs. Among the identical frogs one wears a crown - the Frog Prince (for female + * players) or Princess (for male players). Kissing the crowned frog restores it to human form and + * earns a gift box. + * + * Talking to the other frogs offends the royal; do it too often and the royal turns the player into a + * frog and banishes them to a small frog cave, where a second crowned royal will send them home (empty + * handed, dumped somewhere random in Gielinor) if spoken to. + * https://runescape.wiki/w/Random_events?oldid=3667851#Frog + */ +class KissTheFrog : Script { + + init { + RandomEvents.register("kiss_the_frog") { startEvent() } + + npcOperate("Talk-to", "frog_herald") { (herald) -> + if (get("random_event") != "kiss_the_frog") { + message("It hops away from you.") + return@npcOperate + } + if (herald.owner == this) { + // The herald that appeared beside the player: whisk them to the Land of the Frogs. + npc("frog_herald", "Hey, $name, the Frog ${royal()} needs your help!") + endInPlaceEvent(herald) + kidnap(LAND) + spawnCrown() + restrictTabs() + explain() + } else { + explain() + } + } + + // `frog_*frogland` (no mandatory underscore) matches the bare `frog_frogland` baked into the + // region as well as the numbered `frog_N_frogland` variants; `frog_*_frogland` would miss the + // bare frogs, so talking to them gave "Nothing interesting happens". + npcOperate("Talk-to", "frog_*frogland") { (frog) -> + if (get("random_event") != "kiss_the_frog") { + return@npcOperate + } + when (frog.index) { + get("ktf_crown", -1) -> royalFrog(frog) + get("ktf_escape", -1) -> escapeFrog(frog) + else -> plainFrog(frog) + } + } + + // The crown and escape royals have no lifetime timer, so remove them on logout; the + // relog resume in startEvent spawns fresh ones. + playerDespawn { + if (get("random_event") == "kiss_the_frog") { + removeRoyals() + } + } + } + + private fun Player.startEvent() { + when { + get("ktf_fail", false) -> { + // Relogged inside the frog cave: restore the frog form and the escape royal. + transform(PLAYER_FROG) + tele(FAIL_CAVE) + restrictTabs() + val escape = NPCs.add(ROYAL, ESCAPE_TILE, ticks = -1, owner = this) + set("ktf_escape", escape.index) + } + tile.region == LAND.region -> { + // Relogged in the Land of the Frogs: hide a fresh crowned royal among the frogs. + spawnCrown() + restrictTabs() + } + else -> { + clear("ktf_offended") + startInPlaceEvent("frog_herald", nagLines()) + } + } + } + + /** The royal the player must rescue - a male player helps the Princess, a female player the Prince. */ + private fun Player.royal() = if (male) "Princess" else "Prince" + + private fun Player.crownFrog() = if (male) "frog_princess" else "frog_prince" + + /** Spawn the crowned royal (npc 3300) hidden among the plain frogs of the Land of the Frogs. */ + private fun Player.spawnCrown() { + val frog = NPCs.add(ROYAL, CROWN_TILES.random(random), ticks = -1, owner = this) + set("ktf_crown", frog.index) + } + + /** + * In the Land of the Frogs the player only keeps six tabs: Friends List, Ignore List, Clan Chat, + * Options, Music Player and Notes. [closeTabs] closes everything else (and Notes), so re-open Notes. + */ + private fun Player.restrictTabs() { + closeTabs() + open("notes") + } + + private suspend fun Player.explain() { + val royal = royal() + val subject = if (male) "She" else "He" + val him = if (male) "Her" else "Him" + npc("frog_herald", "Welcome to the Land of the Frogs.") + player("What am I doing here?") + npc("frog_herald", "The Frog $royal sent for you.") + player("Who is the Frog $royal?") + npc("frog_herald", "$subject is the frog with the crown. Make sure you speak to $him, not the other frogs, or $subject'll be offended.") + } + + /** The crowned royal in the Land of the Frogs: kiss to win. */ + private suspend fun Player.royalFrog(frog: NPC) { + val id = frog.id + npc(id, "$name, you must help me! I have been turned into a frog by a well-meaning wizard with an unfortunate obsession with frogs.") + npc(id, "The only thing that will restore my true form is a kiss.") + player("Excuses, excuses! Okay, if that's what you want...") + kiss(frog) + } + + private suspend fun Player.kiss(frog: NPC) { + // If the player clicks off the "Thank you" dialogue (which cancels this handler), the walk fires + // this trigger and they still fade out, collect the reward and teleport home; reading it through + // does the same at the end. + walkTrigger = { queue("ktf_kiss") { completeKiss() } } + // Turn to face each other before leaning in. + face(frog.tile) + frog.face(tile) + delay(1) + frog.anim("frog_kiss") + anim("human_kiss_the_frog") + delay(3) + frog.gfx("spell_splash") + frog.anim("morph_from_frog") + delay(2) + frog.transform(crownFrog()) // the frog stands up as the human royal + delay(2) + npc(crownFrog(), "Thank you so much, $name. I must return to my fairy tale kingdom now, but I will leave you a reward for your kindness.") + frog.anim("emote_blow_kiss") + frog.gfx("emote_blow_kiss") + delay(3) + completeKiss() + } + + /** Fade to black, hand over the reward and teleport the player home. */ + private suspend fun Player.completeKiss() { + if (get("random_event") != "kiss_the_frog") { + return + } + walkTrigger = null + open("fade_out") + delay(2) + addOrDrop("random_event_gift") + rewardCostumePoint("frog") + message("You've been given a gift!") + finishEvent() + open("fade_in") + } + + /** The crowned royal in the frog cave: talking to it releases a hexed player (no reward). */ + private suspend fun Player.escapeFrog(frog: NPC) { + // The cave is enclosed, so the player must not be able to strand themselves by walking off. If + // they click off the dialogue (which cancels this handler), the walk fires this trigger and they + // fade out and teleport home anyway; reading the dialogue through does the same at the end. + walkTrigger = { queue("ktf_escape_cave") { escapeCave() } } + npc(frog.id, "Oh, another poor soul hexed into a frog for their bad manners.") + npc(frog.id, "Here, I'll send you on your way. Do be more polite next time.") + escapeCave() + } + + /** Fade to black and dump the hexed player somewhere random (no reward). */ + private suspend fun Player.escapeCave() { + if (get("random_event") != "kiss_the_frog") { + return + } + walkTrigger = null + open("fade_out") + delay(2) + failEvent() + open("fade_in") + } + + private suspend fun Player.plainFrog(frog: NPC) { + if (get("ktf_fail", false)) { + // Already a frog - the plain frogs have nothing to say to another frog. + npc(frog.id, "Ribbit.") + return + } + // Ignoring the royal to talk to plain frogs offends them; do it too often and the royal turns + // the player into a frog and banishes them to the frog cave. + val offences = inc("ktf_offended") + if (offences < OFFENCE_LIMIT) { + npc(frog.id, "Ribbit.") + message("The Frog ${royal()} looks offended that you spoke to another frog.") + return + } + npc(frog.id, "Well, we'll see how you like being a frog!") + banishToCave() + } + + /** Third offence: morph the player into a frog and teleport them to the frog cave to escape from. */ + private suspend fun Player.banishToCave() { + set("ktf_fail", true) + message("You've been turned into a frog!") + anim("morph_to_frog") + delay(2) + transform(PLAYER_FROG) + delay(2) + tele(FAIL_CAVE) + val escape = NPCs.add(ROYAL, ESCAPE_TILE, ticks = -1, owner = this) + set("ktf_escape", escape.index) + } + + /** Successful kiss: return the player home. */ + private fun Player.finishEvent() { + cleanup() + RandomEvents.complete(this) + } + + /** Escaped the frog cave: dump the player somewhere random with no reward. */ + private fun Player.failEvent() { + cleanup() + RandomEvents.fail(this) + } + + private fun Player.cleanup() { + openTabs() + clearTransform() + removeRoyals() + clear("ktf_fail") + clear("ktf_offended") + } + + private fun Player.removeRoyals() { + NPCs.indexed(get("ktf_crown", -1))?.let { if (it.owner == this) NPCs.remove(it) } + NPCs.indexed(get("ktf_escape", -1))?.let { if (it.owner == this) NPCs.remove(it) } + clear("ktf_crown") + clear("ktf_escape") + } + + private fun Player.nagLines() = listOf( + "$name, the Frog ${royal()} needs your help.", + "Greetings from the Frog ${royal()}, $name!", + "Talk to the Frog ${royal()}, $name!", + "Please respond to the Frog ${royal()}, $name!", + ) + + companion object { + // Number of times the player can talk to the wrong frogs before the royal turns them into one. + private const val OFFENCE_LIMIT = 3 + + // The crowned royal frog (Frog Prince/Princess) and the plain frog the player is morphed into. + private const val ROYAL = "frog_9_frogland" + private const val PLAYER_FROG = "frog_frogland" + + private val LAND = Tile(2445, 4770) + + // The small cave the player is banished to on failure, and the escape royal's spot within it. + private val FAIL_CAVE = Tile(2464, 4782) + private val ESCAPE_TILE = Tile(2464, 4784) + + // Spots among the plain frogs near the arrival tile [LAND] where the crowned royal is hidden. + private val CROWN_TILES = listOf( + Tile(2442, 4774), + ) + } +} diff --git a/game/src/main/kotlin/content/activity/event/random/lost_and_found/LostAndFound.kt b/game/src/main/kotlin/content/activity/event/random/lost_and_found/LostAndFound.kt new file mode 100644 index 0000000000..205a60749e --- /dev/null +++ b/game/src/main/kotlin/content/activity/event/random/lost_and_found/LostAndFound.kt @@ -0,0 +1,117 @@ +package content.activity.event.random.lost_and_found + +import content.activity.event.random.RandomEvents +import content.entity.player.dialogue.type.statement +import content.entity.player.inv.item.addOrDrop +import content.quest.closeTabs +import content.quest.openTabs +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.message +import world.gregs.voidps.engine.client.minimap +import world.gregs.voidps.engine.client.ui.open +import world.gregs.voidps.engine.data.Settings +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.skill.Skill +import world.gregs.voidps.engine.entity.obj.GameObject +import world.gregs.voidps.type.Tile +import world.gregs.voidps.type.random + +/** + * Lost and Found random event: a magic spellbook teleport can fault, dropping the player onto the + * Abyssal plane where four strange appendages surround them. Three appendages share the same shape; + * operating the odd one out forwards the player back to where they came from, while a wrong pick + * reshuffles the shapes and drains some Magic. Escaping earns a random event gift. + * https://runescape.wiki/w/Random_events?oldid=3667851#Lost_and_Found + */ +class LostAndFound : Script { + + init { + RandomEvents.register("lost_and_found") { startEvent() } + + // Only a magic spellbook teleport can slip through; the destination becomes the return point. + teleportLand("modern") { + if (!Settings["events.randomEvents.active", true]) { + return@teleportLand + } + val chance = Settings["events.randomEvents.teleportChance", 300] + if (chance >= 0 && random.nextInt(chance) == 0) { + RandomEvents.roll(this, "lost_and_found") + } + } + + objectOperate("Operate", "abyss_appendage_*") { (appendage) -> + if (get("random_event") != "lost_and_found") { + return@objectOperate + } + operate(appendage) + } + } + + private suspend fun Player.startEvent() { + open("fade_out") + delay(2) + shuffleAppendages() + closeTabs() + minimap(Minimap.HideMap) + tele(PLANE) + message("You have slipped through to the Abyssal plane!") + open("fade_in") + statement("There has been a fault in the teleportation matrix. Please operate the odd appendage out to be forwarded to your destination.", clickToContinue = false) + } + + /** Show three appendages with one shape and a single odd one with another; the odd slot is 1-based. */ + private fun Player.shuffleAppendages() { + val odd = random.nextInt(APPENDAGES.size) + val shape = random.nextInt(4) * 2 + val flip = random.nextInt(2) + for (index in APPENDAGES.indices) { + set("lost_and_found_appendage_${index + 1}", shape + if (index == odd) 1 - flip else flip) + } + set("laf_odd", odd + 1) + } + + private suspend fun Player.operate(appendage: GameObject) { + val index = APPENDAGES.indexOf(appendage.tile) + 1 + if (index == 0) { + return + } + if (index == get("laf_odd", 0)) { + escape() + } else { + shuffleAppendages() + message("That was not the correct appendage!") + levels.drain(Skill.Magic, if (levels.get(Skill.Magic) >= 10) random.nextInt(1, 11) else 1) + } + } + + private suspend fun Player.escape() { + open("fade_out") + delay(2) + reward() + clear("laf_odd") + openTabs() + clearMinimap() + RandomEvents.complete(this) + open("fade_in") + statement("The Abyssal Services Department apologises for the inconvenience.", clickToContinue = false) + } + + private fun Player.reward() { + addOrDrop("random_event_gift") + } + + companion object { + private val PLANE = Tile(2332, 4770) + + // The four appendage tiles, in the same order as the lost_and_found_appendage_1-4 varbits. + private val APPENDAGES = listOf( + Tile(2336, 4771), + Tile(2332, 4775), + Tile(2327, 4771), + Tile(2332, 4766), + ) + } +} diff --git a/game/src/main/kotlin/content/activity/event/random/maze/Maze.kt b/game/src/main/kotlin/content/activity/event/random/maze/Maze.kt new file mode 100644 index 0000000000..4a0acf191a --- /dev/null +++ b/game/src/main/kotlin/content/activity/event/random/maze/Maze.kt @@ -0,0 +1,151 @@ +package content.activity.event.random.maze + +import content.activity.event.random.RandomEvents +import content.activity.event.random.kidnap +import content.activity.event.random.mysteriousOldMan +import content.entity.obj.door.enterDoor +import content.entity.player.dialogue.type.item +import content.entity.player.dialogue.type.statement +import content.entity.player.inv.item.addOrDrop +import content.quest.closeTabs +import content.quest.instance +import content.quest.instanceOffset +import content.quest.openTabs +import content.quest.setInstanceLogout +import content.quest.smallInstance +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.message +import world.gregs.voidps.engine.client.minimap +import world.gregs.voidps.engine.client.ui.close +import world.gregs.voidps.engine.client.ui.open +import world.gregs.voidps.engine.data.definition.Tables +import world.gregs.voidps.engine.entity.character.jingle +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.entity.obj.replace +import world.gregs.voidps.engine.timer.Timer +import world.gregs.voidps.type.Region +import world.gregs.voidps.type.Tile +import world.gregs.voidps.type.random + +/** + * Maze random event; reach the shrine at the centre before the time runs out for a random event gift. + * https://runescape.wiki/w/Maze + */ +class Maze : Script { + + private val duration = 300 + + // Wall tile to the player tiles it can't be opened from + private val oneWayWalls = mapOf( + Tile(2904, 4573) to setOf(Tile(2904, 4573), Tile(2904, 4572), Tile(2904, 4574)), + Tile(2906, 4586) to setOf(Tile(2906, 4586), Tile(2907, 4586)), + Tile(2902, 4575) to setOf(Tile(2903, 4575)), + Tile(2924, 4583) to setOf(Tile(2923, 4583)), + ) + + init { + RandomEvents.register("maze") { + startMaze() + } + + timerStart("maze") { 1 } + + timerTick("maze") { + val ticks = dec("maze_ticks") + set("maze_timer_bar", ticks / 3) + when { + instance() == null -> { + RandomEvents.fail(this) + Timer.CANCEL + } + ticks <= 0 -> { + message("You failed to reach the centre of the maze in time.") + RandomEvents.fail(this) + Timer.CANCEL + } + else -> Timer.CONTINUE + } + } + + timerStop("maze") { + close("maze_timer") + openTabs() + clearMinimap() + clear("maze_ticks") + clear("maze_chests_opened") + } + + objectOperate("Touch", "strange_shrine") { (target) -> + if (get("random_event") != "maze") { + return@objectOperate + } + softTimers.stop("maze") + // The player finishes on the shrine's footprint, so the engine's face_entity + // auto-face resolves to the wrong direction. Clear it and face the centre tile. + clear("face_entity") + face(target.tile.add(target.width / 2, target.height / 2)) + delay(2) + anim("emote_cheer") + delay(2) + addOrDrop("random_event_gift") + RandomEvents.complete(this) + jingle("maze_complete") + } + + objectOperate("Open", "maze_wall") { + message("That bit doesn't open.") + } + + objectOperate("Open", "maze_door_*_closed") { (target) -> + val blocked = oneWayWalls[target.tile.minus(instanceOffset())] + if (blocked != null && tile.minus(instanceOffset()) in blocked) { + statement("I don't think that's the right way.") + } else { + enterDoor(target) + } + } + + objectOperate("Open", "maze_chest") { (target) -> + if (get("maze_ticks", 0) <= 0 || this["maze_chests_opened", 0] >= MAX_CHESTS) { + message("You find nothing of interest.") + return@objectOperate + } + inc("maze_chests_opened") + anim("open_chest") + target.replace("maze_chest_opened", ticks = 50) + val row = Tables.get("maze_chest_rewards").rows().random(random) + val reward = row.item("item") + val amount = row.int("amount") + addOrDrop(reward, amount) + item(reward, row.string("text")) + } + + objectOperate("Search", "maze_chest_opened") { + message("You find nothing of interest.") + } + } + + private suspend fun Player.startMaze() { + // Allocate the instance before the old-man intro so a resumed maze timer (on relog) sees a + // live instance during the intro delay instead of failing the event. + smallInstance(Region(MAZE_REGION), levels = 1) + setInstanceLogout(Tile(this["random_event_origin", tile.id])) + mysteriousOldMan() + val start = Tables.get("maze_start_points").rows().random(random).tile("tile") + kidnap(start.add(instanceOffset())) + closeTabs() + minimap(Minimap.HideMap) + set("maze_ticks", duration) + set("maze_timer_bar", duration / 3) + open("maze_timer") + softTimers.start("maze") + message("You need to reach the maze centre, then you'll be returned to where you were.") + } + + companion object { + private const val MAZE_REGION = 11591 + private const val MAX_CHESTS = 10 + } +} diff --git a/game/src/main/kotlin/content/activity/event/random/mime_theatre/Mime.kt b/game/src/main/kotlin/content/activity/event/random/mime_theatre/Mime.kt new file mode 100644 index 0000000000..100e8df373 --- /dev/null +++ b/game/src/main/kotlin/content/activity/event/random/mime_theatre/Mime.kt @@ -0,0 +1,159 @@ +package content.activity.event.random.mime_theatre + +import content.activity.event.random.RandomEvents +import content.activity.event.random.kidnap +import content.activity.event.random.mysteriousOldMan +import content.activity.event.random.rewardCostumePoint +import content.entity.player.dialogue.Neutral +import content.entity.player.dialogue.type.npc +import content.entity.player.dialogue.type.statement +import content.entity.player.inv.item.addOrDrop +import content.skill.magic.jewellery.teleport +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.client.ui.close +import world.gregs.voidps.engine.client.ui.open +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.obj.GameObjects +import world.gregs.voidps.engine.suspend.Suspension +import world.gregs.voidps.engine.suspend.pauseString +import world.gregs.voidps.type.Tile +import world.gregs.voidps.type.random + +/** + * Mime random event: the Mysterious Old Man drops the player into the theatre where the Mime performs + * emotes under a spotlight. After each performance the Mime bows to the player, who copies it by + * picking the matching emote on the mime interface (188). Three correct copies unlock the mime emotes + * and award a random event gift, then the player is teleported home. + * https://runescape.wiki/w/Random_events?oldid=3667851#Mime + */ +class Mime : Script { + + init { + RandomEvents.register("mime") { startEvent() } + + // Each emote button is named after its emote, so the clicked component is the pick; hand it + // back to the round that's waiting. + interfaceOption(id = "$INTERFACE:*") { + if (get("random_event") != "mime") { + return@interfaceOption + } + (suspension as? Suspension.StringEntry)?.resume(it.component) + } + } + + private suspend fun Player.startEvent() { + if (!contains("mime_emote")) { + // mime_emote is only set mid-performance; keep the score when resuming after a relog. + set("mime_correct", 0) + } + mysteriousOldMan() + kidnap(SPAWN) + npc("mysterious_old_man", "Here's a little challenge for you:
Copy the Mime's performance, then you'll be released.") + walkOverDelay(WATCH) + + val mime = NPCs.firstOrNull(MIME_TILE) { it.id == "mime" } ?: NPCs.add("mime", MIME_TILE, ticks = -1, owner = this) + runMime(mime) + } + + private suspend fun Player.runMime(mime: NPC) { + while (get("mime_correct", 0) < REQUIRED) { + val expected = EMOTES.random(random) + set("mime_emote", expected) + + // Spotlight the Mime and perform the emote facing the audience; the prompt sits in the + // chatbox (no continue button) while the player watches. + centeredMessage("Watch the Mime.", "See what emote he performs.") + lightMime(on = true) + lightPlayer(on = false) + delay(SPOTLIGHT_TICKS) // let the spotlight finish turning on before the Mime performs + mime.face(AUDIENCE) + mime.anim("emote_$expected") + delay(PERFORM_TICKS) + + // Bow to the player, then hand over to them. + mime.face(tile) + mime.anim("emote_bow") + delay(BOW_TICKS) + lightMime(on = false) + lightPlayer(on = true) + delay(SPOTLIGHT_TICKS) // let the spotlight finish turning on before the player performs + + val chosen = awaitEmote() + anim("emote_$chosen") + if (chosen == expected) { + centeredMessage("Correct!") + inc("mime_correct") + mime.face(AUDIENCE) + delay(CORRECT_TICKS) + } else { + statement("That wasn't quite right. Watch the Mime again.", clickToContinue = false) + } + } + finish() + } + + private suspend fun Player.awaitEmote(): String { + open(INTERFACE) + val emote = pauseString() + close(INTERFACE) + return emote + } + + /** Show a no-prompt message with an empty first line so its (centred) text sits a line lower. */ + private fun Player.centeredMessage(vararg lines: String) { + val padded = listOf("", *lines) + val id = "dialogue_message_np${padded.size}" + if (!open(id)) { + return + } + padded.forEachIndexed { index, line -> interfaces.sendText(id, "line${index + 1}", line) } + } + + /** Spotlight over the Mime (2010, 4761). */ + private fun lightMime(on: Boolean) = spotlight(MIME_LIGHT, on) + + /** Spotlight over the player's spot (2007, 4761). */ + private fun lightPlayer(on: Boolean) = spotlight(PLAYER_LIGHT, on) + + private fun spotlight(tile: Tile, on: Boolean) { + GameObjects.at(tile).firstOrNull { it.id == "mime_spotlight" } + ?.anim(if (on) "mime_spotlight_on" else "mime_spotlight_off") + } + + private fun Player.finish() { + for (unlock in MIME_EMOTES) { + set("unlocked_emote_$unlock", true) + } + addOrDrop("random_event_gift") + rewardCostumePoint("mime") + clear("mime_emote") + clear("mime_correct") + + // Clear the event state (so the teleport isn't blocked) then modern-teleport home. + val origin = Tile(this["random_event_origin", tile.id]) + RandomEvents.completeInPlace(this) + teleport(origin, "modern") + } + + companion object { + private const val REQUIRED = 3 + private const val INTERFACE = "dialogue_macro_mime_emotes" + private const val PERFORM_TICKS = 4 + private const val BOW_TICKS = 3 + private const val SPOTLIGHT_TICKS = 3 // duration of the spotlight on/off animation + private const val CORRECT_TICKS = 8 + + private val SPAWN = Tile(2008, 4764) + private val WATCH = Tile(2008, 4762) + private val MIME_TILE = Tile(2011, 4762) + private val AUDIENCE = Tile(2011, 4756) // south of the Mime, where the watchers sit + private val MIME_LIGHT = Tile(2010, 4761) + private val PLAYER_LIGHT = Tile(2007, 4761) + + // The eight emotes the Mime can perform, matching the buttons on interface 188. + private val EMOTES = listOf("think", "cry", "laugh", "dance", "climb_rope", "lean", "glass_box", "glass_wall") + private val MIME_EMOTES = listOf("glass_wall", "glass_box", "climb_rope", "lean") + } +} diff --git a/game/src/main/kotlin/content/activity/event/random/pillory/Pillory.kt b/game/src/main/kotlin/content/activity/event/random/pillory/Pillory.kt new file mode 100644 index 0000000000..7375cb7deb --- /dev/null +++ b/game/src/main/kotlin/content/activity/event/random/pillory/Pillory.kt @@ -0,0 +1,125 @@ +package content.activity.event.random.pillory + +import content.activity.event.random.RandomEvents +import content.activity.event.random.kidnap +import content.entity.player.inv.item.addOrDrop +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.npc.NPCs +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.entity.character.player.name +import world.gregs.voidps.type.Tile +import world.gregs.voidps.type.random +import kotlin.math.min + +/** + * Pillory random event: a guard arrests the player and locks them in a pillory. To escape they + * unlock it (interface 189) by picking the swinging key whose shape matches the spinning lock, a set + * number of times in a row. A wrong pick resets the streak and raises the target (up to six). Escaping + * rewards a random event gift. + * https://runescape.wiki/w/Random_events?oldid=3667851#Pillory + */ +class Pillory : Script { + + init { + RandomEvents.register("pillory") { startEvent() } + + objectOperate("Unlock", "pillory_cage", arrive = false) { + if (get("random_event") != "pillory") { + message("You can't unlock the pillory, you'll let all the prisoners out!") + return@objectOperate + } + renderPuzzle() + open("pillory_lock") + message("Pick the swinging key that matches the hole in the spinning lock.") + } + + // The key selectors (components 8-10) are option buttons (setting=2 in the cache). + interfaceOption("Select", "pillory_lock:button_*") { + if (get("random_event") != "pillory") { + return@interfaceOption + } + answer(it.component.removePrefix("button_").toInt()) + } + } + + private suspend fun Player.startEvent() { + set("pillory_target", 3) + set("pillory_correct", 0) + pilloryGuard() + kidnap(LOCATIONS.random(random)) + message("Solve the pillory puzzle to be returned to where you came from.") + } + + private suspend fun Player.pilloryGuard() { + val guard = NPCs.addRandom("pillory_guard", tile.toCuboid(1), ticks = 25, owner = this) + ?: NPCs.add("pillory_guard", tile, ticks = 25, owner = this) + guard.watch(this) + guard.say("$name, you're under arrest!") + delay(2) + } + + /** Shuffle three distinct keys onto the interface, pick one as the lock, and update the padlocks. */ + private fun Player.renderPuzzle() { + val keys = (0..3).shuffled(random).take(3) + val lock = keys.random(random) + set("pillory_answer", keys.indexOf(lock) + 1) // 1-based slot of the matching key + interfaces.sendModel("pillory_lock", "lock", LOCK_MODEL + lock) + for (slot in 1..3) { + interfaces.sendModel("pillory_lock", "key_$slot", KEY_MODEL + keys[slot - 1]) + } + val target = get("pillory_target", 3) + val correct = get("pillory_correct", 0) + for (i in 1..6) { + interfaces.sendModel("pillory_lock", "padlock_$i", if (i <= correct) PADLOCK_GREEN else PADLOCK_RED) + interfaces.sendVisibility("pillory_lock", "padlock_$i", i <= target) + } + } + + private suspend fun Player.answer(slot: Int) { + if (slot == get("pillory_answer", 0)) { + val correct = inc("pillory_correct") + if (correct >= get("pillory_target", 3)) { + escape() + } else { + renderPuzzle() // the interface stays open for the next round + message("Correct! $correct down, ${get("pillory_target", 3) - correct} to go!") + } + } else { + set("pillory_correct", 0) + set("pillory_target", min(MAX_TARGET, get("pillory_target", 3) + 1)) + close("pillory_lock") + message("Bah, that's not right. Use the key that matches the hole in the spinning lock.") + } + } + + private fun Player.escape() { + close("pillory_lock") + message("You've escaped!") + addOrDrop("random_event_gift") + clear("pillory_target") + clear("pillory_correct") + clear("pillory_answer") + RandomEvents.complete(this) + } + + companion object { + private const val LOCK_MODEL = 9753 // lock type L -> model 9753 + L + private const val KEY_MODEL = 9749 // key type K -> model 9749 + K + private const val PADLOCK_RED = 9757 + private const val PADLOCK_GREEN = 9758 + private const val MAX_TARGET = 6 + + // The pillory cages (object 777) as placed in the 634 map: Varrock, Seers' Village, Yanille. + private val LOCATIONS = listOf( + Tile(3228, 3407), + Tile(3230, 3407), + Tile(2681, 3489), + Tile(2683, 3489), + Tile(2685, 3489), + Tile(2608, 3105), + ) + } +} diff --git a/game/src/main/kotlin/content/activity/event/random/pinball/Pinball.kt b/game/src/main/kotlin/content/activity/event/random/pinball/Pinball.kt new file mode 100644 index 0000000000..a6255ba47b --- /dev/null +++ b/game/src/main/kotlin/content/activity/event/random/pinball/Pinball.kt @@ -0,0 +1,235 @@ +package content.activity.event.random.pinball + +import content.activity.event.random.RandomEvents +import content.activity.event.random.kidnap +import content.activity.event.random.mysteriousOldMan +import content.entity.player.dialogue.Confused +import content.entity.player.dialogue.Neutral +import content.entity.player.dialogue.Quiz +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.closeTabs +import content.quest.instanceOffset +import content.quest.openTabs +import content.quest.smallInstance +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.message +import world.gregs.voidps.engine.client.minimap +import world.gregs.voidps.engine.client.ui.close +import world.gregs.voidps.engine.client.ui.open +import world.gregs.voidps.engine.entity.character.npc.NPCs +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.engine.get +import world.gregs.voidps.engine.map.zone.DynamicZones +import world.gregs.voidps.type.Delta +import world.gregs.voidps.type.Direction +import world.gregs.voidps.type.Region +import world.gregs.voidps.type.Tile +import world.gregs.voidps.type.Zone +import world.gregs.voidps.type.random + +/** + * Pinball random event: the Mysterious Old Man teleports the player into a private copy of the trolls' + * pinball arena. One of five posts flashes with rings; tagging it scores a point and lights a new one, + * while tagging an unlit post resets the score to zero. Score ten points, then leave through the cave + * exit (the troll guards Flippa and Tilt block the way until then) for a random event gift. + * https://runescape.wiki/w/Random_events?oldid=3667851#Pinball + */ +class Pinball : Script { + + init { + RandomEvents.register("pinball") { startEvent() } + + objectOperate("Tag", "pinball_post_*") { (post) -> + if (get("random_event") != "pinball") { + return@objectOperate + } + tagPost(post) + } + + objectOperate("Exit", "pinball_cave_exit") { + if (get("random_event") != "pinball") { + return@objectOperate + } + exitArena() + } + + // The trolls' cache option is "Talk-To" (note the capital O), not the usual "Talk-to". + for (guard in GUARDS) { + npcOperate("Talk-To", guard) { + if (get("random_event") != "pinball") { + message("He's busy right now.") + return@npcOperate + } + guardTalk(guard) + } + } + } + + private suspend fun Player.startEvent() { + if (!contains("pinball_score")) { + // pinball_score is set for the whole event; a relog resumes with the score intact. + set("pinball_score", 0) + } + clear("pinball_target") + mysteriousOldMan() + val offset = copyArena() + kidnap(ARENA.add(offset)) + NPCs.add("flippa_pinball", FLIPPA.add(offset), Direction.NORTH, ticks = -1, owner = this) + NPCs.add("tilt_pinball", TILT.add(offset), Direction.NORTH, ticks = -1, owner = this) + closeTabs() + minimap(Minimap.HideMap) + open("pinball_overlay") + sendScore() + rules(offset) + lightNextPost() + } + + /** + * Allocate a private instance and copy only the arena's zones (the box between [AREA_SW] and + * [AREA_NE]) into it, leaving the rest of the instance empty. Returns the tile offset from the real + * arena to its copy. + */ + private fun Player.copyArena(): Delta { + val instance = smallInstance() + val offset = instance.offset(REGION) + val zones = get() + for (zoneX in (AREA_SW.x shr 3)..(AREA_NE.x shr 3)) { + for (zoneY in (AREA_SW.y shr 3)..(AREA_NE.y shr 3)) { + val from = Zone(zoneX, zoneY, 0) + val base = from.tile.add(offset) + zones.copy(from, Zone(base.x shr 3, base.y shr 3, base.level)) + } + } + set("instance_offset", offset.id) + return offset + } + + private suspend fun Player.rules(offset: Delta) { + val oldMan = NPCs.add("mysterious_old_man", OLD_MAN.add(offset), ticks = -1, owner = this) + oldMan.face(this) + face(oldMan) + npc("mysterious_old_man", "The rules of the game are quite simple. You have to score 10 points by tagging the flashing pillars.") + npc("mysterious_old_man", "Don't tag the ones that do not have rings around the base, as that will reset your points, and don't try and get past those trolls until you are done!") + npc("mysterious_old_man", "See you later!") + oldMan.anim("emote_wave") + delay(4) + oldMan.gfx("imp_puff") + delay(1) + NPCs.remove(oldMan) + statement("Tag the post with the $FLASHING_RINGS.", clickToContinue = false) + } + + private suspend fun Player.tagPost(post: GameObject) { + if (get("pinball_score", 0) >= REQUIRED) { + return + } + anim("take") + val tagged = POSTS.indexOfFirst { it.id == post.id } + if (tagged != get("pinball_target", 0) - 1) { + set("pinball_score", 0) + sendScore() + lightNextPost() + statement("Wrong post! Your score has been reset. Tag the post with the $FLASHING_RINGS.", clickToContinue = false) + return + } + val score = inc("pinball_score") + sendScore() + if (score >= REQUIRED) { + resetPost(tagged) + clear("pinball_target") + statement("Congratulations - you can now leave the arena.", clickToContinue = false) + } else { + lightNextPost() + statement("Well done! Now tag the next post.", clickToContinue = false) + } + } + + private suspend fun Player.exitArena() { + if (get("pinball_score", 0) < REQUIRED) { + guardTalk(GUARDS.first()) + return + } + reward() + close("pinball_overlay") + openTabs() + clearMinimap() + clear("pinball_score") + clear("pinball_target") + RandomEvents.complete(this) // clears the instance and teleports the player home + } + + private suspend fun Player.guardTalk(guard: String) { + if (get("pinball_score", 0) >= REQUIRED) { + player("So...I'm free to go now, right?") + npc(guard, "Yer, get going. We get break now.") + player("Ok. Err...have a nice break.") + } else { + player("Err...what am I supposed to do here again?") + npc(guard, "You gotta poke dem pillars.") + player("What? All of them?") + npc(guard, "No, no. If you poke dem ones dat don't have a dem flashin' rings, den dats bad.") + player("So I have to poke the pillars that are flashing?") + npc(guard, "Yer. You do dat ten times, you get prize.") + } + } + + private fun Player.reward() { + addOrDrop("random_event_gift") + } + + /** Reset the previous flashing post and start a new random one flashing; the target is 1-based. */ + private fun Player.lightNextPost() { + resetPost(get("pinball_target", 0) - 1) + val next = POSTS.indices.random(random) + animatePost(next, "pinball_post_flash") + set("pinball_target", next + 1) + } + + private fun Player.resetPost(index: Int) { + if (index in POSTS.indices) { + animatePost(index, "pinball_post_reset") + } + } + + private fun Player.animatePost(index: Int, anim: String) { + val post = POSTS[index] + GameObjects.at(post.tile.add(instanceOffset())).firstOrNull { it.id == post.id }?.anim(anim) + } + + private fun Player.sendScore() { + interfaces.sendText("pinball_overlay", "score", "Score: ${get("pinball_score", 0)}") + } + + private data class Post(val id: String, val tile: Tile) + + companion object { + private const val REQUIRED = 10 + private const val FLASHING_RINGS = "flashing rings" + private val REGION = Region(30, 78) + + // Only this box is copied into the instance: south-west and north-east corners of the arena. + private val AREA_SW = Tile(1960, 5032) + private val AREA_NE = Tile(1982, 5055) + + private val ARENA = Tile(1972, 5046) + private val OLD_MAN = Tile(1971, 5046) + private val FLIPPA = Tile(1973, 5038) + private val TILT = Tile(1970, 5038) + private val GUARDS = listOf("flippa_pinball", "tilt_pinball") + + private val POSTS = listOf( + Post("pinball_post_1", Tile(1967, 5046)), + Post("pinball_post_2", Tile(1969, 5049)), + Post("pinball_post_3", Tile(1972, 5050)), + Post("pinball_post_4", Tile(1975, 5049)), + Post("pinball_post_5", Tile(1977, 5046)), + ) + } +} diff --git a/game/src/main/kotlin/content/activity/event/random/prison_pete/PrisonPete.kt b/game/src/main/kotlin/content/activity/event/random/prison_pete/PrisonPete.kt new file mode 100644 index 0000000000..8387ed89e9 --- /dev/null +++ b/game/src/main/kotlin/content/activity/event/random/prison_pete/PrisonPete.kt @@ -0,0 +1,268 @@ +package content.activity.event.random.prison_pete + +import content.activity.event.random.RandomEvents +import content.activity.event.random.kidnap +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.choice +import content.entity.player.dialogue.type.npc +import content.entity.player.dialogue.type.player +import content.entity.player.inv.item.addOrDrop +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.World +import world.gregs.voidps.engine.entity.character.jingle +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.entity.obj.GameObject +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.engine.inv.remove +import world.gregs.voidps.type.Direction +import world.gregs.voidps.type.Tile +import world.gregs.voidps.type.random + +/** + * Prison Pete random event: Evil Bob's cat throws the player into the ScapeRune prison alongside + * Pete. Pulling the big lever shows one of the four balloon animal shapes; popping a balloon of that + * shape yields a key that opens one of the door's locks, while popping the wrong shape yields a key + * that doesn't fit. Hand Pete three correct keys to unlock the door, escape and receive a random event gift. + * https://runescape.wiki/w/Random_events?oldid=3667851#Prison_Pete + */ +class PrisonPete : Script { + + init { + RandomEvents.register("prison_pete") { startEvent() } + + objectOperate("Pull", "prison_pete_lever") { (lever) -> + if (get("random_event") != "prison_pete") { + return@objectOperate + } + pullLever(lever) + } + + // The prison's decoy doors ("This doesn't look to be the way out.") never open. + objectOperate("Open", "locked_door_10_closed") { + message("The doors won't open.") + } + + // The exit gates only open once Pete has unlocked all three locks. + objectOperate("Open", "prison_pete_gate_*") { (gate) -> + if (get("random_event") == "prison_pete" && get("prison_pete_keys", 0) >= REQUIRED) { + escape(gate) + } else { + message("The doors won't open.") + } + } + + npcOperate("Pop", "balloon_animal_*") { (balloon) -> + if (get("random_event") != "prison_pete") { + return@npcOperate + } + popBalloon(balloon) + } + + npcOperate("Talk-to", "prison_pete") { (pete) -> + if (get("random_event") != "prison_pete") { + return@npcOperate + } + peteTalk(pete) + } + + itemOnNPCOperate("prison_key_prison_pete", "prison_pete") { (pete) -> + if (get("random_event") != "prison_pete") { + return@itemOnNPCOperate + } + returnKey(pete) + } + } + + private suspend fun Player.startEvent() { + if (!contains("prison_pete_keys")) { + // prison_pete_keys is set for the whole event; a relog resumes with progress intact. + set("prison_pete_keys", 0) + clear("prison_pete_target") + clear("prison_pete_pending") + } + catIntro() + kidnap(PRISON) + message("Welcome to ScapeRune.") + } + + /** The prison is Evil Bob's, so his cat does the kidnapping instead of the Mysterious Old Man. */ + private suspend fun Player.catIntro() { + val cat = NPCs.addRandom("evil_bob_cat", tile.toCuboid(1), ticks = 25, owner = this) + ?: NPCs.add("evil_bob_cat", tile, ticks = 25, owner = this) + cat.watch(this) + cat.say("Meow.") + delay(2) + } + + private suspend fun Player.pullLever(lever: GameObject) { + if (get("prison_pete_keys", 0) >= REQUIRED) { + message("You've found all the keys.") + return + } + face(lever) + delay(2) + anim("prison_pete_pull_lever") + lever.anim("prison_pete_lever_pull") + delay(2) + val balloon = BALLOONS.random(random) + set("prison_pete_target", balloon.npc) + open("prison_pete_balloons") + interfaces.sendModel("prison_pete_balloons", "model", balloon.model) + } + + private suspend fun Player.popBalloon(balloon: NPC) { + val target: String? = get("prison_pete_target") + if (target == null) { + message("Pull the lever to see which balloon to pop.") + return + } + if (get("prison_pete_keys", 0) >= REQUIRED) { + message("You've found all the keys.") + return + } + if (inventory.isFull()) { + message("You don't have enough inventory space.") + return + } + walkOverDelay(balloon.tile) + clear("prison_pete_target") + anim("prison_pete_stomp") + sound("prison_pete_balloon_pop") + balloon.gfx("prison_pete_balloon_pop") + if (balloon.id == target) { + // The key only opens a lock once it's handed to Pete. + inc("prison_pete_pending") + } + delay(1) + val spawn = balloon.tile + NPCs.remove(balloon) + World.queue("prison_pete_balloon_respawn_${balloon.index}", BALLOON_RESPAWN_TICKS) { + NPCs.add(balloon.id, spawn) + } + anim("prison_pete_pick_up") + delay(1) + inventory.add("prison_key_prison_pete") + npc("prison_pete", "Great, now you've got a key! Bring it to me so I can try it on the door.") + } + + private suspend fun Player.peteTalk(pete: NPC) { + if (inventory.contains("prison_key_prison_pete")) { + returnKey(pete) + return + } + choice("What would you like to say?") { + option("What is this place?") { + npc("prison_pete", "Don't you remember? This is ScapeRune's prison. Evil Bob caught you and brought you here.") + player("What gives him the right to lock me up? I demand to see a lawyer! I know my rights!") + npc("prison_pete", "Evil Bob doesn't care about people's rights. He's cruel and utterly merciless. He's a cat.") + player("How do I get out of here? I can't be held captive by a cat!") + escapeInfo() + } + option("How do I get out of here?") { + escapeInfo() + } + } + } + + private suspend fun Player.escapeInfo() { + npc("prison_pete", "Some of these balloon animals have keys in them, and if you pull the big lever it tells you which shape animal contains the correct key, but I can never find it.") + npc("prison_pete", "You need to pull the lever to find out which shape animal contains the key, then pop that sort of animal to get the key.") + npc("prison_pete", "Bring me any keys you get and I'll try them on the doors.") + player("What happens if I get it wrong?") + npc("prison_pete", "You haven't got a life sentence, so they'll let you out eventually. You should be able to escape much faster if you go pull that lever and pop the right balloon animals.") + } + + private suspend fun Player.returnKey(pete: NPC) { + if (!inventory.remove("prison_key_prison_pete")) { + return + } + face(pete) + pete.face(this) + npc("prison_pete", "Ooh, thanks! I'll see if it's the right one...") + if (get("prison_pete_pending", 0) <= 0) { + jingle("prison_pete_failure") + npc("prison_pete", "Aww, that was the wrong key! Try the lever again to see which balloon you need.") + return + } + dec("prison_pete_pending") + pete.face(Direction.SOUTH_EAST) + pete.anim("pick_pocket") + delay(2) + pete.face(this) + if (inc("prison_pete_keys") >= REQUIRED) { + npc("prison_pete", "You did it, you got all the keys right!") + npc("prison_pete", "Thank you! You're my friend FOREVER!") + player("Let's get out of here before that cat notices.") + } else { + npc("prison_pete", "Hooray, you got the right one! Now pull the lever again and let's get the next lock unlocked.") + } + } + + private suspend fun Player.escape(gate: GameObject) { + openGates() + walkToDelay(gate.tile.addY(-1), forceWalk = true) + message("You quickly escape the prison with Pete.") + npc("prison_pete", "Thanks a lot for your help! Here, have a present:") + addOrDrop("random_event_gift") + player("Thanks! See you around!") + while (inventory.remove("prison_key_prison_pete")) { + // strip any dud keys so none survive the trip home + } + clear("prison_pete_keys") + clear("prison_pete_target") + clear("prison_pete_pending") + anim("teleport_modern") + sound("teleport") + gfx("teleport_modern") + delay(3) + RandomEvents.complete(this) + anim("teleport_land_modern") + gfx("teleport_land_modern") + sound("teleport_land") + message("Welcome back.") + } + + /** Swing both gate leaves outward (south); they swing shut again behind the player. */ + private fun openGates() { + swingOpen("prison_pete_gate_west", GATE_WEST, rotation = 0) + swingOpen("prison_pete_gate_east", GATE_EAST, rotation = 2) + } + + private fun swingOpen(id: String, tile: Tile, rotation: Int) { + val gate = GameObjects.getShape(tile, 0) ?: return + if (gate.id != id) { + return + } + GameObjects.replace(gate, id, rotation = rotation, ticks = GATE_OPEN_TICKS) + } + + private data class Balloon(val npc: String, val model: Int) + + companion object { + private const val REQUIRED = 3 + private const val GATE_OPEN_TICKS = 15 + private const val BALLOON_RESPAWN_TICKS = 25 + private val PRISON = Tile(2086, 4462) + private val GATE_WEST = Tile(2085, 4459) + private val GATE_EAST = Tile(2086, 4459) + + // Interface models paired with the balloon shape they depict. + private val BALLOONS = listOf( + Balloon("balloon_animal_dog", 10749), + Balloon("balloon_animal_cat", 10750), + Balloon("balloon_animal_sheep", 10751), + Balloon("balloon_animal_goat", 10752), + ) + } +} diff --git a/game/src/main/kotlin/content/activity/event/random/quiz_master/QuizMaster.kt b/game/src/main/kotlin/content/activity/event/random/quiz_master/QuizMaster.kt new file mode 100644 index 0000000000..a62730600a --- /dev/null +++ b/game/src/main/kotlin/content/activity/event/random/quiz_master/QuizMaster.kt @@ -0,0 +1,135 @@ +package content.activity.event.random.quiz_master + +import content.activity.event.random.RandomEvents +import content.activity.event.random.kidnap +import content.entity.player.dialogue.Confused +import content.entity.player.dialogue.Goofy +import content.entity.player.dialogue.Hysterics +import content.entity.player.dialogue.type.npc +import content.entity.player.dialogue.type.player +import content.entity.player.inv.item.addOrDrop +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.npc.NPCs +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.entity.character.player.name +import world.gregs.voidps.engine.suspend.Suspension +import world.gregs.voidps.engine.suspend.pauseInt +import world.gregs.voidps.type.Tile +import world.gregs.voidps.type.random + +/** + * Quiz Master random event: the Quiz Master whisks the player to his studio for a game of + * "Odd One Out". Three golden models are shown on interface 191; the player picks the one that + * doesn't belong with the other two. Four correct answers win a random event + * gift. Wrong answers cost nothing but a turn. + * https://runescape.wiki/w/Random_events?oldid=3667851#Quiz_Master + */ +class QuizMaster : Script { + + init { + RandomEvents.register("quiz_master") { startEvent() } + + // The three answer buttons are dialogue-box components (setting=1 in the cache), so a click + // arrives as a dialogue continue rather than an interface option; resume the waiting question + // with the button's slot. + continueDialogue("dialogue_macro_quiz_show:button_*") { + val slot = it.substringAfter(":button_").toIntOrNull() ?: return@continueDialogue + (suspension as? Suspension.IntEntry)?.resume(slot) + } + + // Any other interaction cancels the suspended question; talking to the Quiz Master + // resumes the show from the current score. + npcOperate("Talk-to", "quiz_master") { + if (get("random_event") != "quiz_master") { + return@npcOperate + } + runQuiz() + } + } + + private suspend fun Player.startEvent() { + if (!contains("quiz_answer")) { + // quiz_answer is only set mid-show; keep the score when resuming after a relog. + set("quiz_correct", 0) + } + quizHerald() + kidnap(ROOM) + face(QUIZ_MASTER) + anim("quiz_show_sit") + intro() + runQuiz() + } + + private suspend fun Player.runQuiz() { + while (true) { + if (askQuestion() == get("quiz_answer", 0)) { + if (inc("quiz_correct") >= REQUIRED) { + break + } + npc("quiz_master", "Wow, you're a smart one! You're absolutely RIGHT! Okay, next question!") + } else { + npc("quiz_master", "WRONG! That's just WRONG! Okay, next question!") + } + } + win() + } + + private suspend fun Player.quizHerald() { + val herald = NPCs.addRandom("quiz_master", tile.toCuboid(1), ticks = 25, owner = this) + ?: NPCs.add("quiz_master", tile, ticks = 25, owner = this) + herald.watch(this) + herald.say("Hey $name! It's your lucky day!") + delay(2) + } + + private suspend fun Player.intro() { + npc("quiz_master", "WELCOME to the GREATEST QUIZ SHOW in the whole of RuneScape: O D D O N E O U T") + player("I'm sure I didn't ask to take part in a quiz show...") + npc("quiz_master", "Please welcome our newest contestant: $name! Just pick the O D D O N E O U T. Four questions right, and then you win!") + } + + /** Shows a fresh "odd one out" and suspends until the player picks a button, returning its slot. */ + private suspend fun Player.askQuestion(): Int { + val set = SETS.random(random) + val answer = set[0] + val models = set.toList().shuffled(random) + set("quiz_answer", models.indexOf(answer) + 1) // 1-based slot of the odd one out + interfaces.sendModel("dialogue_macro_quiz_show", "model_1", models[0]) + interfaces.sendModel("dialogue_macro_quiz_show", "model_2", models[1]) + interfaces.sendModel("dialogue_macro_quiz_show", "model_3", models[2]) + open("dialogue_macro_quiz_show") + val slot = pauseInt() + close("dialogue_macro_quiz_show") + return slot + } + + private suspend fun Player.win() { + npc("quiz_master", "CONGRATULATIONS! You are a WINNER! Please take your PRIZE!") + addOrDrop("random_event_gift") + clearAnim() // stand up out of the contestant's seat + clear("quiz_answer") + clear("quiz_correct") + RandomEvents.complete(this) + message("Welcome back.") + } + + companion object { + private const val REQUIRED = 4 + private val ROOM = Tile(1952, 4764, 1) + private val QUIZ_MASTER = Tile(1952, 4768, 1) + + // Golden models (8828-8837) grouped so the first of each triple is the odd one out. + // 28 battleaxe, 29 salmon, 30 trout, 31 necklace, 32 shield, 33 helm, 34 ring, + // 35 secateurs, 36 sword, 37 trowel. + private val SETS = listOf( + intArrayOf(8828, 8829, 8830), // weapon vs two fish + intArrayOf(8831, 8837, 8835), // jewellery vs two gardening tools + intArrayOf(8830, 8832, 8833), // fish vs two pieces of armour + intArrayOf(8835, 8834, 8831), // gardening tool vs two pieces of jewellery + intArrayOf(8837, 8836, 8828), // gardening tool vs two weapons + ) + } +} diff --git a/game/src/main/kotlin/content/activity/event/random/sandwich_lady/SandwichLady.kt b/game/src/main/kotlin/content/activity/event/random/sandwich_lady/SandwichLady.kt new file mode 100644 index 0000000000..2db5669227 --- /dev/null +++ b/game/src/main/kotlin/content/activity/event/random/sandwich_lady/SandwichLady.kt @@ -0,0 +1,116 @@ +package content.activity.event.random.sandwich_lady + +import content.activity.event.random.RandomEvents +import content.activity.event.random.startInPlaceEvent +import content.entity.combat.inCombat +import content.entity.player.dialogue.Happy +import content.entity.player.dialogue.type.npc +import content.entity.player.inv.item.addOrDrop +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.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.name +import world.gregs.voidps.type.random + +/** + * Sandwich Lady random event: she appears beside the player pushing a refreshment tray and nags + * until spoken to. Talking opens her tray (interface 297); picking the food she offered hands it + * over, picking any other knocks the player out and teleports them away with nothing. + * https://runescape.wiki/w/Random_events?oldid=3667851#Sandwich_Lady + */ +class SandwichLady : Script { + + init { + RandomEvents.register("sandwich_lady") { startEvent() } + + npcOperate("Talk-to", "sandwich_lady") { (lady) -> + if (lady.owner != this) { + npc("sandwich_lady", "This is for ${lady.owner?.name ?: "someone else"}, not you!") + return@npcOperate + } + val food = get("sandwich_lady_food") ?: return@npcOperate + if (inCombat) { + // No time for the tray mid-fight; she just hands over the right one. + serve(food) + return@npcOperate + } + npc("sandwich_lady", "You look hungry to me. I tell you what - have a ${description(food)} on me.") + interfaces.sendText("sandwich_lady_select", "title", "Have a ${description(food)} for free!") + open("sandwich_lady_select") + } + + interfaceOption("Choose refreshment", "sandwich_lady_select:*") { + val food = get("sandwich_lady_food") ?: return@interfaceOption + val lady = lady() + close("sandwich_lady_select") + if (it.component == food) { + serve(food) + } else { + knockOut(lady) + } + } + } + + private fun Player.startEvent() { + val food = FOODS.random(random) + set("sandwich_lady_food", food) + val lady = startInPlaceEvent("sandwich_lady", nagLines(), nagInterval = 30) + set("sandwich_lady_npc", lady.index) + lady.say("Sandwich delivery for $name!") + } + + private suspend fun Player.knockOut(lady: NPC?) { + message("The sandwich lady knocks you out and you wake up somewhere... different.") + lady?.say("Hey, I didn't say you could have that!") + lady?.anim("sandwich_lady_knockout") // swings the baguette + anim("human_death") + open("fade_out") + delay(3) + clearEvent() + RandomEvents.fail(this) + clearAnim() + open("fade_in") + } + + private fun Player.clearEvent() { + clear("sandwich_lady_food") + clear("sandwich_lady_npc") + } + + private fun Player.lady(): NPC? = NPCs.indexed(get("sandwich_lady_npc", -1))?.takeIf { it.id == "sandwich_lady" } + + private suspend fun Player.serve(food: String) { + message("The sandwich lady gives you a ${description(food)}!") + addOrDrop(food) + addOrDrop("random_event_gift") + npc("sandwich_lady", "Hope that fills you up!") + clearEvent() + // Clearing the event state removes the following NPC on its next tick. + RandomEvents.completeInPlace(this) + } + + private fun Player.nagLines() = listOf( + "All types of sandwiches, $name.", + "Come on $name, I made these specifically!", + "You better start showing some manners!", + "You think I made these just for fun?!!?", + ) + + private fun description(food: String) = food.replace('_', ' ') + + companion object { + private val FOODS = listOf( + "baguette", + "triangle_sandwich", + "square_sandwich", + "roll", + "meat_pie", + "doughnut", + "chocolate_bar", + ) + } +} diff --git a/game/src/main/kotlin/content/activity/event/random/surprise_exam/SurpriseExam.kt b/game/src/main/kotlin/content/activity/event/random/surprise_exam/SurpriseExam.kt new file mode 100644 index 0000000000..eefb3cce6e --- /dev/null +++ b/game/src/main/kotlin/content/activity/event/random/surprise_exam/SurpriseExam.kt @@ -0,0 +1,158 @@ +package content.activity.event.random.surprise_exam + +import content.activity.event.random.RandomEvents +import content.activity.event.random.kidnap +import content.activity.event.random.mysteriousOldMan +import content.entity.player.dialogue.Neutral +import content.entity.player.dialogue.type.npc +import content.entity.player.dialogue.type.skillLamp +import content.entity.player.dialogue.type.statement +import content.entity.player.inv.item.addOrDrop +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.data.definition.ItemDefinitions +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.exp.exp +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.engine.inv.remove +import world.gregs.voidps.type.Tile +import world.gregs.voidps.type.random + +/** + * Surprise Exam random event: the Mysterious Old Man teleports the player into Mr Mordaut's classroom. + * Talking to Mordaut opens a "what comes next?" pattern quiz (interface 103) - pick the item that + * belongs with the three shown. Three correct answers pass the exam; Mordaut then names one of four + * coloured doors, and leaving through it returns the player with a random event gift. + * https://runescape.wiki/w/Random_events?oldid=3667851#Surprise_Exam + */ +class SurpriseExam : Script { + + init { + RandomEvents.register("surprise_exam") { startEvent() } + + npcOperate("Talk-to", "mr_mordau") { (mordaut) -> + if (get("random_event") != "surprise_exam") { + npc("mr_mordau", "I'm rather busy, please don't interrupt my class.", largeHead = true) + return@npcOperate + } + face(mordaut.tile) + val door = get("surprise_exam_door") + if (door != null) { + npc("mr_mordau", "Well done! Please exit through the ${DOOR_TEXT[door]} door.", largeHead = true) + } else { + npc("mr_mordau", "Please answer these questions for me.", largeHead = true) + openQuestion() + } + } + + // The answer icons are option buttons on a main-window interface. + interfaceOption("Select", "surprise_exam_pattern:option_*") { + if (get("random_event") != "surprise_exam") { + return@interfaceOption + } + answer(it.component.removePrefix("option_").toInt()) // option_1..4 + } + + objectOperate("Open", "exam_door_*") { (door) -> + if (get("random_event") != "surprise_exam") { + return@objectOperate + } + when (get("surprise_exam_door")) { + null -> statement("I should probably speak with Mr. Mordaut first.") + door.id -> finish() + else -> statement("The door won't budge. Perhaps I should ask for directions.") + } + } + + itemOption("Read", "book_of_knowledge") { (item, slot) -> + val skill = skillLamp() + if (inventory.remove(slot, item.id)) { + exp(skill, levels.getMax(skill) * XP_PER_LEVEL) + statement("You feel more knowledgeable about ${skill.name.lowercase()}.") + } + } + } + + private suspend fun Player.startEvent() { + if (!contains("surprise_exam_correct")) { + // surprise_exam_correct is set for the whole exam; a relog resumes with progress intact. + set("surprise_exam_correct", 0) + } + mysteriousOldMan() + kidnap(CLASSROOM) + message("Speak to Mr Mordaut to begin your exam.") + } + + /** Show three icons of one category plus four options (the matching item among decoys). */ + private fun Player.openQuestion() { + val set = SETS.random(random) + val shuffled = set.shuffled(random) + val correct = shuffled.random(random) + val index = shuffled.indexOf(correct) // which option slot holds the answer + val pattern = shuffled.filter { it != correct } + val decoy = SETS.filter { it != set }.random(random) + set("surprise_exam_answer", index + 1) // 1-based to dodge the set-clears-0 gotcha + for (i in 1..3) { + interfaces.sendItem("surprise_exam_pattern", "pattern_$i", ItemDefinitions.get(pattern[i - 1]).id) + } + for (slot in 0..3) { + val item = if (slot == index) correct else decoy[slot] + interfaces.sendItem("surprise_exam_pattern", "option_${slot + 1}", ItemDefinitions.get(item).id) + } + open("surprise_exam_pattern") + // Send the access mask so the client reports clicks on the answer icons; the cache marks the + // option enabled but doesn't send it, so without this the icons don't respond. + for (slot in 1..4) { + interfaceOptions.unlockAll("surprise_exam_pattern", "option_$slot", 0..0) + } + } + + private suspend fun Player.answer(option: Int) { + close("surprise_exam_pattern") + if (option == get("surprise_exam_answer", 0)) { + if (inc("surprise_exam_correct") >= REQUIRED) { + set("surprise_exam_door", DOORS.random(random)) + npc("mr_mordau", "Excellent work! You've passed. Please exit through the ${DOOR_TEXT[get("surprise_exam_door")]} door.", largeHead = true) + return + } + npc("mr_mordau", "Excellent work! Now for another...", largeHead = true) + } else { + npc("mr_mordau", "I'm afraid that isn't correct. Now for another...", largeHead = true) + } + openQuestion() + } + + private fun Player.finish() { + message("You've passed the exam!") + addOrDrop("random_event_gift") + clear("surprise_exam_answer") + clear("surprise_exam_correct") + clear("surprise_exam_door") + RandomEvents.complete(this) + jingle("surprise_exam_passed") + } + + companion object { + private const val REQUIRED = 3 + private const val XP_PER_LEVEL = 15.0 + private val CLASSROOM = Tile(1886, 5025) + + private val DOOR_TEXT = mapOf( + "exam_door_red" to "red cross", + "exam_door_blue" to "blue star", + "exam_door_purple" to "purple circle", + "exam_door_green" to "green square", + ) + private val DOORS = DOOR_TEXT.keys.toList() + + private val SETS = listOf( + listOf("gardening_trowel", "secateurs", "seed_dibber", "rake"), + listOf("salmon", "shark", "trout", "shrimps"), + listOf("bronze_sword", "wooden_shield", "bronze_med_helm", "adamant_battleaxe"), + listOf("fly_fishing_rod", "barbarian_rod", "small_fishing_net", "harpoon"), + ) + } +} diff --git a/game/src/main/kotlin/content/area/misthalin/varrock/Iffie.kt b/game/src/main/kotlin/content/area/misthalin/varrock/Iffie.kt index 6f54e038d2..9bbf69225c 100644 --- a/game/src/main/kotlin/content/area/misthalin/varrock/Iffie.kt +++ b/game/src/main/kotlin/content/area/misthalin/varrock/Iffie.kt @@ -1,12 +1,24 @@ package content.area.misthalin.varrock -import content.entity.npc.shop.openShop +import content.entity.player.bank.ownsItem import content.entity.player.dialogue.* import content.entity.player.dialogue.type.choice import content.entity.player.dialogue.type.npc +import content.entity.player.inv.item.addOrDrop 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.entity.character.player.male +import world.gregs.voidps.engine.inv.sendInventory +/** + * Iffie's costume store: costume points saved up from random event gifts are spent here on the + * random event costumes, one piece at a time. The "Cash in your points" interface (201) draws + * the five costume rows itself and shows the player's balance from the costume_points varbits; + * clicking a piece claims it for a point - the outfit's own event points spend before the + * any-outfit gift points. + */ class Iffie : Script { init { @@ -14,9 +26,15 @@ class Iffie : Script { npc("Hello, dearie! Were you wanting to collect a random event costume, or is there something else I can do for you today?") choice { option("I've come for a random event costume.") { + val generic = get("costume_points", 0) + val total = generic + COSTUMES.sumOf { get("${it.name}_costume_points", 0) } + if (total == 0) { + npc("Then you'll be needing a costume point, dearie. Complete a costume's random event, or pick 'Save up for a costume!' from a random event gift, and come back to see me.") + return@option + } npc("Some of these costumes even come with a free emote!") - npc("Just buy one piece of the mine of zombie costumes and I'll show you the relevant moves.") - openShop("iffies_random_costume_shop") + npc("You have $generic point${if (generic == 1) "" else "s"} to spend on any outfit; points earned from an outfit's own event show on its row. Each piece costs one point.") + openCostumeStore() } option("Aren't you selling anything?") { npc("Oh, yes, but only costumes. Thessalia sells some other clothes and runs the makeover service.") @@ -28,7 +46,77 @@ class Iffie : Script { } npcOperate("Claim-costume", "iffie") { - openShop("iffies_random_costume_shop") + openCostumeStore() } + + interfaceOpened("costume_reward_select") { id -> + interfaceOptions.unlockAll(id, "rewards", 0 until ROWS * ROW_STRIDE) + } + + // Each piece child carries its item (from enum 2240), so the click names the exact piece + interfaceOption("Claim", "costume_reward_select:rewards") { + claim(it.item.id) + } + } + + private fun Player.openCostumeStore() { + syncPoints() + // The interface dims owned pieces by checking the bank container client-side, which the + // client only knows about once it's been sent + sendInventory("bank") + open("costume_reward_select") + } + + /** + * The interface reads each row's "Points:" from its own varbit - mirror the balance to all - + * and greys out a costume once every piece is owned. The frog mask greys separately from the + * rest of the royal outfit. + */ + private fun Player.syncPoints() { + val generic = get("costume_points", 0) + for (costume in COSTUMES) { + set("costume_points_${costume.name}", generic + get("${costume.name}_costume_points", 0)) + val pieces = if (costume.name == "frog") costume.pieces - "frog_mask" else costume.pieces + set("costume_claimed_${costume.name}", pieces.all { ownsItem(it) }) + } + set("costume_claimed_frog_mask", ownsItem("frog_mask")) + } + + private fun Player.claim(piece: String) { + val costume = COSTUMES.firstOrNull { piece in it.pieces } ?: return + if (ownsItem(piece)) { + message("You already have that piece, dearie.") + return + } + // Points from the outfit's own event spend first, saving the any-outfit gift points + val locked = get("${costume.name}_costume_points", 0) + val generic = get("costume_points", 0) + when { + locked > 0 -> set("${costume.name}_costume_points", locked - 1) + generic > 0 -> set("costume_points", generic - 1) + else -> { + message("You don't have any points for that outfit; complete its random event or pick 'Save up for a costume!' from a random event gift.") + return + } + } + addOrDrop(piece) + syncPoints() + message("You spend a costume point on the ${piece.replace('_', ' ')}.") + } + + private data class Costume(val name: String, val pieces: List) + + companion object { + private const val ROWS = 5 + private const val ROW_STRIDE = 10 + + // In the interface's row order. + private val COSTUMES = listOf( + Costume("mime", listOf("mime_mask", "mime_top", "mime_legs", "mime_gloves", "mime_boots")), + Costume("frog", listOf("frog_mask", "princess_blouse", "princess_skirt", "prince_tunic", "prince_leggings")), + Costume("zombie", listOf("zombie_shirt", "zombie_trousers", "zombie_gloves", "zombie_boots", "zombie_mask")), + Costume("camo", listOf("camo_top", "camo_bottoms", "camo_helmet")), + Costume("lederhosen", listOf("lederhosen_top", "lederhosen_hat", "lederhosen_shorts")), + ) } } diff --git a/game/src/main/kotlin/content/skill/fishing/Casket.kt b/game/src/main/kotlin/content/skill/fishing/Casket.kt new file mode 100644 index 0000000000..85ebd214be --- /dev/null +++ b/game/src/main/kotlin/content/skill/fishing/Casket.kt @@ -0,0 +1,39 @@ +package content.skill.fishing + +import content.entity.player.inv.item.addOrDrop +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.client.message +import world.gregs.voidps.engine.entity.character.sound +import world.gregs.voidps.engine.entity.item.drop.DropTables +import world.gregs.voidps.engine.get +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.engine.inv.remove + +/** + * Caskets from big net fishing and mystery boxes hold a little treasure: usually coins or an + * uncut gem, with a slim shot at a key half. + * https://runescape.wiki/w/Casket + */ +class Casket : Script { + + init { + itemOption("Open", "casket") { (item, slot) -> + if (!inventory.remove(slot, item.id)) { + return@itemOption + } + sound("casket_open") + val drop = get().getValue("casket").roll(player = this).firstOrNull()?.toItem() + if (drop == null || drop.isEmpty()) { + return@itemOption + } + addOrDrop(drop.id, drop.amount) + val name = drop.def.name.lowercase() + val found = when { + drop.amount > 1 -> "${drop.amount} x $name" + name.first() in "aeiou" -> "an $name" + else -> "a $name" + } + message("You open the casket and find $found.") + } + } +} diff --git a/game/src/main/kotlin/content/skill/magic/spell/Teleports.kt b/game/src/main/kotlin/content/skill/magic/spell/Teleports.kt index de717d9055..345090e54b 100644 --- a/game/src/main/kotlin/content/skill/magic/spell/Teleports.kt +++ b/game/src/main/kotlin/content/skill/magic/spell/Teleports.kt @@ -56,7 +56,11 @@ class Teleports : Script { } } - teleportTakeOff("lumbridge_home_teleport") { + // Registered under the book's type ("modern") because takeOff is keyed by type, not spell. + teleportTakeOff("modern") { + if (it != "lumbridge_home_teleport") { + return@teleportTakeOff true + } val seconds = remaining("home_teleport_timeout", epochSeconds()) if (seconds > 0) { val remaining = TimeUnit.SECONDS.toMinutes(seconds.toLong()) diff --git a/game/src/main/resources/game.properties b/game/src/main/resources/game.properties index 01b077c95b..5f02579d05 100644 --- a/game/src/main/resources/game.properties +++ b/game/src/main/resources/game.properties @@ -222,6 +222,21 @@ events.shootingStars.enabled=true events.shootingStars.minRespawnTimeMinutes=60 events.shootingStars.maxRespawnTimeMinutes=120 +# Whether random events are enabled for the server +events.randomEvents.active=true + +# Whether players can turn off random events for themselves with the ::randomevents command +events.randomEvents.optOut=false + +# Min minutes until you can receive another a random event +events.randomEvents.cooldownMinutesMin=60 + +# Max minutes until you can receive another a random event +events.randomEvents.cooldownMinutesMax=120 + +# 1 in X chance of receiving the lost and found teleport random event +events.randomEvents.teleportChance=300 + # Whether Stronghold of Security doors should give questions or not strongholdOfSecurity.quiz=true diff --git a/game/src/test/kotlin/WorldTest.kt b/game/src/test/kotlin/WorldTest.kt index a11b670db7..5ac3769c29 100644 --- a/game/src/test/kotlin/WorldTest.kt +++ b/game/src/test/kotlin/WorldTest.kt @@ -306,6 +306,7 @@ abstract class WorldTest : KoinTest { properties["events.tearsOfGuthix.active"] = false properties["storage.autoSave.minutes"] = 0 properties["storage.disabled"] = true + properties["events.randomEvents.active"] = false properties["bots.count"] = 0 properties.remove("world.id") properties.remove("world.name") diff --git a/game/src/test/kotlin/content/activity/event/random/BeekeeperTest.kt b/game/src/test/kotlin/content/activity/event/random/BeekeeperTest.kt new file mode 100644 index 0000000000..edbd2184de --- /dev/null +++ b/game/src/test/kotlin/content/activity/event/random/BeekeeperTest.kt @@ -0,0 +1,144 @@ +package content.activity.event.random + +import WorldTest +import content.quest.instanceOffset +import interfaceOption +import kotlinx.coroutines.test.runTest +import npcOption +import org.junit.jupiter.api.Test +import skipDialogues +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.inv.inventory +import world.gregs.voidps.network.client.instruction.MoveInventoryItem +import world.gregs.voidps.type.Tile +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class BeekeeperTest : WorldTest() { + + private val origin = Tile(3221, 3218) + private val field = Tile(1931, 5044) + + private fun enter(name: String): Player { + val player = createPlayer(origin, name) + RandomEvents.start(player, "beekeeper") + tick(8) + return player + } + + private fun Player.keeper(): NPC? = (-2..2).flatMap { dx -> (-2..2).map { dy -> tile.add(dx, dy) } } + .firstNotNullOfOrNull { t -> NPCs.firstOrNull(t) { it.id == "bee_keeper" && it.owner == this } } + + private fun Player.talkThroughIntro() { + npcOption(keeper()!!, "Talk-To") + tick(2) + skipDialogues() + tick() + } + + private fun Player.drag(from: Int, to: Int) = runTest { + instructions.send(MoveInventoryItem(420, from, -1, 0, 420, to, -1, 0)) + } + + private fun Player.placeParts(correct: Boolean) { + for (source in 1..4) { + val part = get("beekeeper_part_$source", 0) + val destination = if (correct) part else (part % 4) + 1 + drag(11 + source, 15 + destination) + tick() + } + } + + private fun Player.build() { + interfaceOption("beehive_build", "build", "Build") + tick(2) + skipDialogues() + tick() + } + + @Test + fun `The Bee keeper drags the player to a private copy of his field`() { + val player = enter("bees_start") + + assertEquals("beekeeper", player.get("random_event")) + assertEquals(field, player.tile.minus(player.instanceOffset())) + assertTrue(player.contains("instance_offset")) + assertEquals(6, player.get("beekeeper_tries", 0)) + assertEquals(listOf(1, 2, 3, 4), (1..4).map { player.get("beekeeper_part_$it", 0) }.sorted()) + } + + @Test + fun `Building with missing components is rejected`() { + val player = enter("bees_missing") + player.talkThroughIntro() + + // No skipDialogues: the rejection is a no-prompt statement that stays open. + player.interfaceOption("beehive_build", "build", "Build") + tick(2) + + assertEquals(6, player.get("beekeeper_tries", 0)) + assertEquals("beekeeper", player.get("random_event")) + } + + @Test + fun `Dragging a part moves it into the frame`() { + val player = enter("bees_drag") + player.talkThroughIntro() + + val part = player.get("beekeeper_part_1", 0) + player.drag(12, 16) + tick() + + assertEquals(0, player.get("beekeeper_part_1", 0)) + assertEquals(part, player.get("beekeeper_slot_1", 0)) + } + + @Test + fun `The correct order builds the hive for a gift`() { + val player = enter("bees_win") + player.talkThroughIntro() + + player.placeParts(correct = true) + player.build() + tick(2) + + assertEquals(1, player.inventory.count("random_event_gift")) + assertNull(player.get("random_event")) + assertEquals(origin, player.tile) + assertTrue(player.contains("random_event_cooldown")) + } + + @Test + fun `A wrong order costs a try`() { + val player = enter("bees_wrong") + player.talkThroughIntro() + + player.placeParts(correct = false) + player.build() + + assertEquals(5, player.get("beekeeper_tries", 0)) + assertEquals("beekeeper", player.get("random_event")) + } + + @Test + fun `Six wrong builds exile the player empty-handed`() { + val player = enter("bees_fail") + player.talkThroughIntro() + + player.placeParts(correct = false) + repeat(6) { + player.build() + } + tick(4) + + assertNull(player.get("random_event")) + assertNotEquals(origin, player.tile) + assertTrue(player.contains("random_event_cooldown")) + assertFalse(player.inventory.contains("random_event_gift")) + } +} diff --git a/game/src/test/kotlin/content/activity/event/random/CapnArnavTest.kt b/game/src/test/kotlin/content/activity/event/random/CapnArnavTest.kt new file mode 100644 index 0000000000..731e651482 --- /dev/null +++ b/game/src/test/kotlin/content/activity/event/random/CapnArnavTest.kt @@ -0,0 +1,132 @@ +package content.activity.event.random + +import WorldTest +import interfaceOption +import objectOption +import org.junit.jupiter.api.Test +import skipDialogues +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.entity.obj.GameObjects +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.type.Tile +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class CapnArnavTest : WorldTest() { + + private val origin = Tile(3221, 3218) + private val island = Tile(1626, 5163) + private val words = listOf("Coins", "Bowl", "Bar", "Ring") + + private fun enter(name: String): Player { + val player = createPlayer(origin, name) + RandomEvents.start(player, "capn_arnav") + tick(8) + return player + } + + private fun Player.openLock() { + val chest = GameObjects.getShape(Tile(1627, 5162), 10)!! + objectOption(chest, "Open") + tick(2) + } + + private fun Player.solveColumns() { + val presses = words.indexOf(get("arnav_target", "")) + for (column in 1..3) { + repeat(presses) { + interfaceOption("capn_arnav_lock", "up_$column", "Up") + tick() + } + } + } + + private fun Player.unlock() { + interfaceOption("capn_arnav_lock", "unlock", "Unlock") + tick(2) + skipDialogues() + tick() + } + + @Test + fun `Cap'n Arnav kidnaps the player to his island`() { + val player = enter("arnav_start") + + assertEquals("capn_arnav", player.get("random_event")) + assertEquals(island, player.tile) + } + + @Test + fun `Opening the chest picks a target and opens the lock`() { + val player = enter("arnav_lock") + + player.openLock() + + assertTrue(player.get("arnav_target", "") in words) + assertEquals(3, (1..3).count { player.get("arnav_lock_$it", 0) == 0 }) + } + + @Test + fun `The right combination opens the chest for a gift`() { + val player = enter("arnav_solve") + player.openLock() + + player.solveColumns() + player.unlock() + + assertTrue(player.get("arnav_solved", false)) + assertEquals(1, player.inventory.count("random_event_gift")) + } + + @Test + fun `A wrong combination counts a try`() { + val player = enter("arnav_wrong") + player.openLock() + + player.interfaceOption("capn_arnav_lock", "up_1", "Up") + tick() + player.unlock() + + assertEquals(1, player.get("arnav_tries", 0)) + assertEquals("capn_arnav", player.get("random_event")) + assertFalse(player.get("arnav_solved", false)) + } + + @Test + fun `Five wrong combinations get the player clobbered and exiled`() { + val player = enter("arnav_fail") + + repeat(5) { + player.openLock() + player.interfaceOption("capn_arnav_lock", "up_1", "Up") + tick() + player.unlock() + } + tick(2) + + assertNull(player.get("random_event")) + assertNotEquals(island, player.tile) + assertNotEquals(origin, player.tile) + assertTrue(player.contains("random_event_cooldown")) + assertTrue(player.inventory.isEmpty()) + } + + @Test + fun `The exit portal returns the player home after solving`() { + val player = enter("arnav_finish") + player.openLock() + player.solveColumns() + player.unlock() + + val portal = GameObjects.getShape(Tile(1626, 5165), 10)!! + player.objectOption(portal, "Enter") + tick(4) + + assertNull(player.get("random_event")) + assertEquals(origin, player.tile) + assertTrue(player.contains("random_event_cooldown")) + } +} diff --git a/game/src/test/kotlin/content/activity/event/random/CerterTest.kt b/game/src/test/kotlin/content/activity/event/random/CerterTest.kt new file mode 100644 index 0000000000..ec19b6b589 --- /dev/null +++ b/game/src/test/kotlin/content/activity/event/random/CerterTest.kt @@ -0,0 +1,81 @@ +package content.activity.event.random + +import WorldTest +import interfaceOption +import npcOption +import org.junit.jupiter.api.Test +import skipDialogues +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.inv.inventory +import world.gregs.voidps.type.Tile +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class CerterTest : WorldTest() { + + private val spot = Tile(3221, 3218) + + private fun setup(name: String): Pair { + val player = createPlayer(spot, name) + player["random_event"] = "certer" + player["random_event_origin"] = spot.id + val giles = createNPC("giles", spot.addX(1)) + giles["owner"] = player.accountName + return player to giles + } + + private fun openPuzzle(player: Player, giles: NPC) { + player.npcOption(giles, "Talk-to") + tick() + player.skipDialogues() // "Ah, hello... identify this?" -> opens the interface + tick() + } + + @Test + fun `Event spawns a certer beside the player`() { + val player = createPlayer(spot, "certer_spawn") + RandomEvents.start(player, "certer") + tick(2) + + val giles = (-2..2).flatMap { dx -> (-2..2).map { dy -> player.tile.add(dx, dy) } } + .firstNotNullOfOrNull { t -> NPCs.firstOrNull(t) { it.id == "giles" } } + assertTrue(giles != null, "Expected a certer spawned near the player") + } + + @Test + fun `Correctly identifying the item rewards loot and ends the event`() { + val (player, giles) = setup("certer_correct") + openPuzzle(player, giles) + + assertTrue(player.interfaces.contains("certer_identify")) + val answer = player.get("certer_answer", 0) + player.interfaceOption("certer_identify", "option_$answer", "Select") + tick() + player.skipDialogues() // "Thank you, I hope you like your present..." + tick() + + assertFalse(player.inventory.isEmpty(), "Expected the certer reward") + assertNull(player.get("random_event")) + assertTrue(player.contains("random_event_cooldown")) + } + + @Test + fun `A wrong answer ends the event with no reward`() { + val (player, giles) = setup("certer_wrong") + openPuzzle(player, giles) + + val answer = player.get("certer_answer", 0) + val wrong = if (answer == 1) 2 else 1 + player.interfaceOption("certer_identify", "option_$wrong", "Select") + tick() + player.skipDialogues() // "Sorry, I don't think so." + tick() + + assertTrue(player.inventory.isEmpty(), "Expected no reward for a wrong answer") + assertNull(player.get("random_event")) + assertTrue(player.contains("random_event_cooldown")) + } +} diff --git a/game/src/test/kotlin/content/activity/event/random/DrillDemonTest.kt b/game/src/test/kotlin/content/activity/event/random/DrillDemonTest.kt new file mode 100644 index 0000000000..e103229221 --- /dev/null +++ b/game/src/test/kotlin/content/activity/event/random/DrillDemonTest.kt @@ -0,0 +1,111 @@ +package content.activity.event.random + +import WorldTest +import objectOption +import org.junit.jupiter.api.Test +import skipDialogues +import world.gregs.voidps.engine.client.ui.dialogue +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.entity.obj.GameObject +import world.gregs.voidps.engine.entity.obj.GameObjects +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.type.Tile +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class DrillDemonTest : WorldTest() { + + private val yard = Tile(3163, 4820) + private val matTiles = listOf(Tile(3160, 4819), Tile(3162, 4819), Tile(3164, 4819), Tile(3166, 4819)) + + private fun enter(name: String, origin: Tile = Tile(3221, 3218)): Player { + val player = createPlayer(yard, name) + player["random_event"] = "drill_demon" + player["random_event_origin"] = origin.id + player["drill_demon_correct"] = 0 + player["drill_demon_ready"] = true // the order has been given + tick(2) // load the yard so the map mats resolve + return player + } + + private fun mats(): List = matTiles.mapIndexed { i, t -> GameObjects.find(t) { it.id == "drill_demon_mat_${i + 1}" } } + + // Deterministic signs: mat m shows exercise m (1-4); task points at one exercise. + private fun Player.layout(task: Int) { + for (m in 1..4) set("drill_demon_sign_$m", m) + set("drill_demon_task", task) + } + + /** Use whichever mat currently shows the assigned exercise, driving through the animation + order. */ + private fun useCorrectMat(player: Player, mats: List) { + while (player.dialogue != null) player.skipDialogues() // close the previous round's order + if (player.get("random_event") != "drill_demon") return + val task = player.get("drill_demon_task", 0) + val index = (1..4).first { player.get("drill_demon_sign_$it", 0) == task } + player.objectOption(mats[index - 1], "Use") + // Wait for the exercise + sign sweep to finish and Damien to respond (or the event to complete). + tickIf(60) { player.dialogue == null && player.get("random_event") == "drill_demon" } + } + + @Test + fun `Event kidnaps the player to the yard and assigns exercises`() { + val player = createPlayer(Tile(3221, 3218), "dd_start") + RandomEvents.start(player, "drill_demon") + tick(10) + + assertEquals("drill_demon", player.get("random_event")) + assertTrue(player.tile.within(yard, 4), "Expected the player in the yard, was ${player.tile}") + assertTrue(player.get("drill_demon_task", 0) in 1..4) + // All four exercises are shown across the four mats. + assertEquals(setOf(1, 2, 3, 4), (1..4).map { player.get("drill_demon_sign_$it", 0) }.toSet()) + } + + @Test + fun `Using the mat with the ordered exercise counts as correct`() { + val player = enter("dd_correct") + player.layout(task = 2) // exercise 2 is on mat 2 + player.objectOption(mats()[1], "Use") + tickIf { player.dialogue == null } + + assertEquals(1, player.get("drill_demon_correct", 0)) + } + + @Test + fun `Using a mat before the order is given gives no credit`() { + val player = enter("dd_early") + player.clear("drill_demon_ready") // order not given yet + player.layout(task = 1) + player.objectOption(mats()[0], "Use") // mat 1 would be correct once the order is given + tickIf { player.dialogue == null } // Damien scolds instead + + assertEquals(0, player.get("drill_demon_correct", 0)) + } + + @Test + fun `Using the wrong mat does not count`() { + val player = enter("dd_wrong") + player.layout(task = 2) + player.objectOption(mats()[0], "Use") // mat 1 shows exercise 1, not the ordered 2 + tickIf { player.dialogue == null } + + assertEquals(0, player.get("drill_demon_correct", 0)) + } + + @Test + fun `Four correct exercises reward a camo piece and return the player`() { + val origin = Tile(3221, 3218) + val player = enter("dd_finish", origin) + player.layout(task = 1) + val mats = mats() + + repeat(4) { useCorrectMat(player, mats) } + while (player.dialogue != null) player.skipDialogues() // Damien's closing line, then reward + tick(2) + + assertEquals(1, player.inventory.count("random_event_gift")) + assertEquals(1, player.get("camo_costume_points", 0)) + assertNull(player.get("random_event")) + assertEquals(origin, player.tile) + } +} diff --git a/game/src/test/kotlin/content/activity/event/random/EvilBobTest.kt b/game/src/test/kotlin/content/activity/event/random/EvilBobTest.kt new file mode 100644 index 0000000000..fdf4dd5738 --- /dev/null +++ b/game/src/test/kotlin/content/activity/event/random/EvilBobTest.kt @@ -0,0 +1,156 @@ +package content.activity.event.random + +import WorldTest +import content.quest.instance +import content.quest.instanceOffset +import itemOnObject +import npcOption +import objectOption +import org.junit.jupiter.api.Test +import skipDialogues +import world.gregs.voidps.engine.client.ui.dialogue +import world.gregs.voidps.engine.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.obj.GameObject +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.type.Tile +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class EvilBobTest : WorldTest() { + + private val origin = Tile(3221, 3218) + + // east zone (id 2) and west zone (id 4) stand/spot tiles (template coords). + private val eastSpot = Tile(3438, 4777) + private val westSpot = Tile(3406, 4776) + private val potTile = Tile(3423, 4780) + private val portalTile = Tile(3416, 4777) + + /** Runs the event, clears the intro dialogue, and pins the answer zone for determinism. */ + private fun enter(name: String, zone: Int = 2): Player { + val player = createPlayer(origin, name) + RandomEvents.start(player, "evil_bob") + tick(10) + while (player.dialogue != null) player.skipDialogues() + player["evil_bob_zone"] = zone + player.clear("evil_bob_new_spot") + return player + } + + private fun Player.spot(tile: Tile): GameObject = GameObjects.find(tile.add(instanceOffset())) { it.id == "evil_bob_fishing_spot" } + private fun Player.bob() = NPCs.indexed(get("evil_bob_npc", -1))!! + + private fun Player.serve() { + tele(bob().tile.addX(1)) // stand beside Evil Bob so the talk lands immediately + tick() + npcOption(bob(), "Talk-to") + tickIf { dialogue == null } // wait for the first line to open + while (dialogue != null) { + skipDialogues() + tick() + } + } + + @Test + fun `Event kidnaps the player to the island and sets up the event`() { + val player = enter("eb_start") + + assertEquals("evil_bob", player.get("random_event")) + assertNotNull(player.instance()) + assertTrue(player.get("evil_bob_zone", 0) in 1..4) + assertTrue(player.inventory.contains("small_fishing_net")) + assertEquals("evil_bob", player.bob().id) + } + + @Test + fun `Netting the assigned zone yields the fish Evil Bob likes`() { + val player = enter("eb_net_right", zone = 2) + player.tele(eastSpot.add(player.instanceOffset())) + tick() + + player.objectOption(player.spot(eastSpot), "Net") + tick(7) + + assertTrue(player.inventory.contains("fish_like_thing")) + assertFalse(player.inventory.contains("fish_like_thing_incorrect")) + } + + @Test + fun `Netting a different zone yields the wrong fish`() { + val player = enter("eb_net_wrong", zone = 2) // east is correct... + player.tele(westSpot.add(player.instanceOffset())) // ...but we fish the west + tick() + + player.objectOption(player.spot(westSpot), "Net") + tick(7) + + assertTrue(player.inventory.contains("fish_like_thing_incorrect")) + } + + @Test + fun `Uncooking a cooked fish at the pot produces the raw fish`() { + val player = enter("eb_uncook") + val offset = player.instanceOffset() + player.tele(potTile.addX(1).add(offset)) // stand beside the cold fire + tick() + player.inventory.add("fish_like_thing") + val pot = GameObjects.find(potTile.add(offset)) { it.id == "evil_bob_uncooking_pot" } + + player.itemOnObject(pot, player.inventory.indexOf("fish_like_thing")) + tickIf { player.inventory.contains("fish_like_thing") } + + assertFalse(player.inventory.contains("fish_like_thing")) + assertTrue(player.inventory.contains("raw_fish_like_thing")) + } + + @Test + fun `Serving the correct raw fish then leaving rewards a gift and returns the player`() { + val player = enter("eb_finish") + player.inventory.add("raw_fish_like_thing") + player.serve() + + assertTrue(player["evil_bob_complete", false]) + + val portal = GameObjects.find(portalTile.add(player.instanceOffset())) { it.id == "evil_bob_exit_portal" } + player.tele(portalTile.add(player.instanceOffset())) + tick() + player.objectOption(portal, "Enter") + tickIf { player.get("random_event") != null } // wait out the raspberry send-off + while (player.dialogue != null) player.skipDialogues() + tick(2) + + assertEquals(1, player.inventory.count("random_event_gift")) + assertNull(player.get("random_event")) + assertEquals(origin, player.tile) + assertTrue(player.contains("random_event_cooldown")) + } + + @Test + fun `A wrong fish makes Evil Bob attentive so an extra correct fish is needed`() { + val player = enter("eb_attentive") + + player.inventory.add("raw_fish_like_thing_incorrect") + player.serve() + assertTrue(player["evil_bob_attentive", false]) + assertFalse(player["evil_bob_complete", false]) + + // While attentive, a correct fish only resets him (new spot) instead of completing. + player.inventory.add("raw_fish_like_thing") + player.serve() + assertFalse(player["evil_bob_attentive", false]) + assertFalse(player["evil_bob_complete", false]) + + // Now, no longer attentive, the correct fish finishes the event. + player["evil_bob_zone"] = 2 + player.inventory.add("raw_fish_like_thing") + player.serve() + assertTrue(player["evil_bob_complete", false]) + } +} diff --git a/game/src/test/kotlin/content/activity/event/random/EvilTwinTest.kt b/game/src/test/kotlin/content/activity/event/random/EvilTwinTest.kt new file mode 100644 index 0000000000..b7c562b3e8 --- /dev/null +++ b/game/src/test/kotlin/content/activity/event/random/EvilTwinTest.kt @@ -0,0 +1,201 @@ +package content.activity.event.random + +import WorldTest +import content.quest.instance +import content.quest.instanceOffset +import dialogueOption +import interfaceOption +import npcOption +import objectOption +import org.junit.jupiter.api.Test +import skipDialogues +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.npc.NPCs +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.entity.obj.GameObjects +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.type.Tile +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class EvilTwinTest : WorldTest() { + + private val origin = Tile(3221, 3218) + + private fun start(name: String): Pair { + val player = createPlayer(origin, name) + RandomEvents.start(player, "evil_twin") + tick(2) + val molly = (-2..2).flatMap { dx -> (-2..2).map { dy -> player.tile.add(dx, dy) } } + .firstNotNullOfOrNull { tile -> NPCs.firstOrNull(tile) { it.id.startsWith("molly_") } } + assertNotNull(molly, "Expected Molly spawned near the player") + return player to molly + } + + private fun enterHouse(name: String): Player { + val player = createPlayer(origin, name) + RandomEvents.start(player, "evil_twin") + tick(10) // Molly appears, pleads, and teleports the player away + player.skipDialogues() // Her explanation in the house... + player.dialogueOption(2) // ..."I'll get right to it." + tick() + return player + } + + private fun Player.openCrane() { + val panel = GameObjects.add("control_panel", tile) + objectOption(panel, "Use") + tick(2) + } + + private fun Player.clawTile(): Tile = Tile(get("evil_twin_claw_x", 0), get("evil_twin_claw_y", 0)).add(instanceOffset()) + + private fun Player.suspects(): List = NPCs.at(tile.regionLevel).filter { it.id.startsWith("twin_suspect_") } + + private fun Player.grab(suspect: NPC?) { + if (suspect != null) { + suspect.mode = EmptyMode + suspect.tele(clawTile()) + tick() + } + interfaceOption("evil_twin_crane", "grab", "Grab") + tick(25) // The claw sequence walks the suspect over to the jail + } + + @Test + fun `Event spawns Molly beside the player`() { + val (player, molly) = start("et_spawn") + + assertEquals("evil_twin", player.get("random_event")) + assertEquals(player.get("evil_twin_hash", 0), molly.id.removePrefix("molly_").toInt()) + } + + @Test + fun `Molly takes the player to her house`() { + val player = enterHouse("et_house") + + assertNotNull(player.instance()) + val suspects = player.suspects() + assertEquals(5, suspects.size) + assertTrue( + suspects.any { it.id == "twin_suspect_${player.get("evil_twin_hash", 0)}" }, + "Expected one suspect matching Molly's look", + ) + } + + @Test + fun `The claw moves around the pen but not beyond it`() { + val player = enterHouse("et_claw") + player.openCrane() + assertTrue(player.interfaces.contains("evil_twin_crane")) + val home = player.clawTile() + + player.interfaceOption("evil_twin_crane", "up", "Up") // South, over the pen + tick() + assertEquals(home.addY(-1), player.clawTile()) + assertTrue(GameObjects.at(player.clawTile()).any { it.id == "evil_twin_claw" }) + assertTrue(GameObjects.at(player.clawTile()).any { it.id == "evil_twin_claw_marker" }) + assertTrue(GameObjects.at(home).none { it.id == "evil_twin_claw" }, "claw left behind at home") + assertTrue(GameObjects.at(home).none { it.id == "evil_twin_claw_marker" }, "marker left behind at home") + + player.interfaceOption("evil_twin_crane", "down", "Down") // Back north to home + tick() + player.interfaceOption("evil_twin_crane", "down", "Down") // The north edge of its reach + tick() + assertEquals(home.addY(1), player.clawTile()) + + player.interfaceOption("evil_twin_crane", "down", "Down") // Past the edge - blocked + tick() + assertEquals(home.addY(1), player.clawTile()) + } + + @Test + fun `Grabbing the twin cages her and Molly pays out`() { + val player = enterHouse("et_catch") + player.openCrane() + + val twin = player.suspects().first { it.id == "twin_suspect_${player.get("evil_twin_hash", 0)}" } + player.grab(twin) + player.skipDialogues() // "You've caught Molly's evil twin!..." + tick() + + assertTrue(player.get("evil_twin_caught", false)) + assertEquals(1, player.suspects().size) + // The claw only swings back home once she's been dropped at the cage + val home = Tile(1870, 5132).add(player.instanceOffset()) + assertTrue(GameObjects.at(home).any { it.id == "evil_twin_claw" }) + assertTrue(GameObjects.at(home).any { it.id == "evil_twin_claw_marker" }) + + val molly = NPCs.at(player.tile.regionLevel).first { it.id.startsWith("molly_") } + player.npcOption(molly, "Talk-to") + tick(2) + player.skipDialogues() + tick(2) + + assertFalse(player.inventory.isEmpty(), "Expected a gem reward from Molly") + assertNull(player.get("random_event")) + assertEquals(origin, player.tile) + assertTrue(player.contains("random_event_cooldown")) + } + + @Test + fun `Catching the twin after a failed grab still succeeds`() { + val player = enterHouse("et_retry") + player.openCrane() + + val innocent = player.suspects().first { it.id != "twin_suspect_${player.get("evil_twin_hash", 0)}" } + player.grab(innocent) + assertEquals(1, player.get("evil_twin_tries", 0)) + + // Second grab with the twin still wandering: the tile check happens on the click itself, + // so she can't step off the mark before the claw drops. + val twin = player.suspects().first { it.id == "twin_suspect_${player.get("evil_twin_hash", 0)}" } + twin.tele(player.clawTile()) + player.interfaceOption("evil_twin_crane", "grab", "Grab") + tick(25) + + assertTrue(player.get("evil_twin_caught", false), "Expected the twin to be caught on the second try") + } + + @Test + fun `Logging out mid-event puts the player back in the house`() { + val player = enterHouse("et_relog") + player.openCrane() + player.grab(null) // One try used before "logging out" + val hash = player.get("evil_twin_hash", 0) + + // A login restarts the active event, exactly like RandomEventKidnap.playerSpawn + RandomEvents.start(player, "evil_twin") + tick(8) + + assertNotNull(player.instance()) + assertEquals(hash, player.get("evil_twin_hash", 0)) + assertEquals(1, player.get("evil_twin_tries", 0)) + assertEquals(5, player.suspects().size) + } + + @Test + fun `Two failed grabs get the player thrown out with nothing`() { + val player = enterHouse("et_fail") + player.openCrane() + + player.grab(null) // Nobody on the mark + assertEquals(1, player.get("evil_twin_tries", 0)) + + val innocent = player.suspects().first { it.id != "twin_suspect_${player.get("evil_twin_hash", 0)}" } + player.grab(innocent) + player.skipDialogues() // "Such incompetence!..." + tick(2) + + assertTrue(player.inventory.isEmpty(), "Expected no reward after failing") + assertNull(player.get("random_event")) + assertNotEquals(origin, player.tile) + assertTrue(player.contains("random_event_cooldown")) + } +} diff --git a/game/src/test/kotlin/content/activity/event/random/FreakyForesterTest.kt b/game/src/test/kotlin/content/activity/event/random/FreakyForesterTest.kt new file mode 100644 index 0000000000..83d3f27f43 --- /dev/null +++ b/game/src/test/kotlin/content/activity/event/random/FreakyForesterTest.kt @@ -0,0 +1,111 @@ +package content.activity.event.random + +import WorldTest +import content.entity.combat.hit.damage +import npcOption +import org.junit.jupiter.api.Test +import skipDialogues +import world.gregs.voidps.engine.entity.character.mode.combat.CombatApi +import world.gregs.voidps.engine.entity.character.player.name +import world.gregs.voidps.engine.entity.item.floor.FloorItems +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.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class FreakyForesterTest : WorldTest() { + + private val clearing = Tile(2601, 4777) + + private fun startInClearing(name: String, task: Int, origin: Tile = Tile(3221, 3218)) = createPlayer(clearing, name).apply { + this["random_event"] = "freaky_forester" + this["random_event_origin"] = origin.id + this["freaky_forester_task"] = task + } + + @Test + fun `Event teleports the player to the clearing and assigns a pheasant`() { + val player = createPlayer(Tile(3221, 3218), "ff_start") + RandomEvents.start(player, "freaky_forester") + tick(10) + + assertEquals("freaky_forester", player.get("random_event")) + assertTrue(player.tile.within(clearing, 4), "Expected the player in the clearing, was ${player.tile}") + assertTrue(player.get("freaky_forester_task", 0) in 1..4) + } + + @Test + fun `Killing the assigned pheasant drops the correct raw pheasant for the killer`() { + val player = startInClearing("ff_correct_kill", task = 2) + val pheasant = createNPC("pheasant_2_tails", Tile(2603, 4777)) + val dropTile = pheasant.tile + + pheasant.damage(1000, source = player) + tick(4) + + val drop = FloorItems.firstOrNull(dropTile, "raw_pheasant") + assertNotNull(drop) + assertEquals(player.name, drop.owner) + assertNull(FloorItems.firstOrNull(dropTile, "raw_pheasant_incorrect")) + } + + @Test + fun `Killing the wrong pheasant drops an incorrect raw pheasant`() { + val player = startInClearing("ff_wrong_kill", task = 2) + val pheasant = createNPC("pheasant_1_tail", Tile(2603, 4777)) + val dropTile = pheasant.tile + + pheasant.damage(1000, source = player) + tick(4) + + assertNotNull(FloorItems.firstOrNull(dropTile, "raw_pheasant_incorrect")) + assertNull(FloorItems.firstOrNull(dropTile, "raw_pheasant")) + } + + @Test + fun `Handing in the correct pheasant rewards a gift and returns the player`() { + val origin = Tile(3221, 3218) + val player = startInClearing("ff_handin", task = 3, origin = origin) + player.inventory.add("raw_pheasant") + val forester = createNPC("freaky_forester", Tile(2601, 4776)) + + player.npcOption(forester, "Talk-to") + tick() + player.skipDialogues() + tick(2) + + assertEquals(1, player.inventory.count("random_event_gift")) + assertEquals(1, player.get("lederhosen_costume_points", 0)) + assertFalse(player.inventory.contains("raw_pheasant")) + assertNull(player.get("random_event")) + assertEquals(origin, player.tile) + } + + @Test + fun `Handing in the wrong pheasant is rejected and keeps the player in the event`() { + val player = startInClearing("ff_reject", task = 3) + player.inventory.add("raw_pheasant_incorrect") + val forester = createNPC("freaky_forester", Tile(2601, 4776)) + + player.npcOption(forester, "Talk-to") + tick() + player.skipDialogues() + + assertFalse(player.inventory.contains("raw_pheasant_incorrect")) + assertEquals("freaky_forester", player.get("random_event")) + assertEquals(clearing, player.tile) + } + + @Test + fun `Cannot attack another pheasant while carrying a raw pheasant`() { + val player = startInClearing("ff_block", task = 2) + player.inventory.add("raw_pheasant") + val pheasant = createNPC("pheasant_4_tails", Tile(2603, 4777)) + + assertFalse(CombatApi.canAttack(player, pheasant)) + } +} diff --git a/game/src/test/kotlin/content/activity/event/random/GravediggerTest.kt b/game/src/test/kotlin/content/activity/event/random/GravediggerTest.kt new file mode 100644 index 0000000000..aec515f1d7 --- /dev/null +++ b/game/src/test/kotlin/content/activity/event/random/GravediggerTest.kt @@ -0,0 +1,169 @@ +package content.activity.event.random + +import WorldTest +import content.quest.instance +import content.quest.instanceOffset +import dialogueOption +import equipItem +import itemOnObject +import npcOption +import objectOption +import org.junit.jupiter.api.Test +import skipDialogues +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.obj.GameObject +import world.gregs.voidps.engine.entity.obj.GameObjects +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.type.Tile +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class GravediggerTest : WorldTest() { + + private val origin = Tile(3221, 3218) + private val sites = listOf( + Tile(1924, 4996), + Tile(1926, 4999), + Tile(1928, 4996), + Tile(1930, 4999), + Tile(1932, 4996), + ) + + private fun enter(name: String): Player { + val player = createPlayer(origin, name) + RandomEvents.start(player, "gravedigger") + tick(10) // Leo appears, asks, and teleports the player away + player.skipDialogues() // His explanation of the task + tick() + return player + } + + private fun Player.graveAt(site: Int): GameObject = GameObjects.at(sites[site].add(instanceOffset())) + .first { it.id.startsWith("gravedigger_grave_") || it.id.startsWith("gravedigger_open_grave_") } + + private fun Player.takeCoffin(site: Int) { + objectOption(graveAt(site), "Take-coffin") + tick(15) // Walk over and dig it up + } + + private fun Player.buryCoffin(site: Int, coffin: String) { + itemOnObject(graveAt(site), inventory.indexOf(coffin)) + tick(15) + } + + private fun Player.leo(): NPC = NPCs.at(tile.regionLevel).first { it.id == "leo_gravedigger" } + + private fun Player.claimFinished() { + npcOption(leo(), "Talk-to") + tick(10) // Walk over to Leo + skipDialogues() // "How are you getting on?" + dialogueOption(1) // "There, finished!" + tick() + skipDialogues() + tick(2) + } + + private fun coffinName(index: Int) = if (index == 0) "coffin" else "coffin_${index + 1}" + + @Test + fun `Leo kidnaps the player to a scrambled graveyard`() { + val player = enter("gd_start") + + assertEquals("gravedigger", player.get("random_event")) + assertNotNull(player.instance()) + val contents = sites.indices.map { player.get("gravedigger_site_$it", 0) } + assertTrue(contents.all { it in 1..5 }, "Expected every grave filled: $contents") + assertFalse(contents.withIndex().all { it.value == it.index + 1 }, "Expected a scrambled arrangement") + for (site in sites.indices) { + assertEquals("gravedigger_grave_${contents[site] - 1}", player.graveAt(site).id) + } + } + + @Test + fun `Take a coffin, check it and read the gravestone`() { + val player = enter("gd_check") + val contents = player.get("gravedigger_site_0", 0) + + player.takeCoffin(0) + val coffin = coffinName(contents - 1) + assertTrue(player.inventory.contains(coffin)) + assertEquals("gravedigger_open_grave_0", player.graveAt(0).id) + + player.equipItem(coffin, option = "Check") + tick() + assertTrue(player.interfaces.contains("gravedigger_coffin")) + + val stone = GameObjects.at(Tile(1924, 4998).add(player.instanceOffset())).first { it.id.startsWith("gravedigger_gravestone_") } + player.objectOption(stone, "Read") + tick(5) + assertTrue(player.interfaces.contains("gravedigger_gravestone")) + } + + @Test + fun `A wrong arrangement earns a hint, the right one pays out`() { + val player = enter("gd_solve") + + // Dig up all five coffins then claim it's done - Leo isn't fooled + for (site in sites.indices) { + player.takeCoffin(site) + } + player.claimFinished() + assertEquals("gravedigger", player.get("random_event")) + + // Rebury each in its proper grave + for (site in sites.indices) { + player.buryCoffin(site, coffinName(site)) + } + player.claimFinished() + + assertTrue(player.inventory.contains("random_event_gift"), "Expected a random event gift") + assertEquals(1, player.get("zombie_costume_points", 0)) + assertTrue(sites.indices.none { player.inventory.contains(coffinName(it)) }, "Expected Leo to keep his coffins") + assertTrue(player.get("unlocked_emote_zombie_walk", false)) + assertTrue(player.get("unlocked_emote_zombie_dance", false)) + assertNull(player.get("random_event")) + assertEquals(origin, player.tile) + assertTrue(player.contains("random_event_cooldown")) + } + + @Test + fun `Leaving early returns the player with nothing`() { + val player = enter("gd_leave") + player.takeCoffin(0) + + player.npcOption(player.leo(), "Talk-to") + tick(10) // Walk over to Leo + player.skipDialogues() + player.dialogueOption(3) // "I want to leave." + tick() + player.skipDialogues() + tick(2) + + assertTrue(player.inventory.isEmpty(), "Expected no reward and no coffins kept") + assertNull(player.get("random_event")) + assertEquals(origin, player.tile) + assertTrue(player.contains("random_event_cooldown")) + } + + @Test + fun `Logging out mid-event puts the player back in the graveyard`() { + val player = enter("gd_relog") + val contents = player.get("gravedigger_site_0", 0) + player.takeCoffin(0) + val arrangement = sites.indices.map { player.get("gravedigger_site_$it", 0) } + + // A login restarts the active event, exactly like RandomEventKidnap.playerSpawn + RandomEvents.start(player, "gravedigger") + tick(8) + + assertNotNull(player.instance()) + assertEquals(arrangement, sites.indices.map { player.get("gravedigger_site_$it", 0) }) + assertEquals("gravedigger_open_grave_0", player.graveAt(0).id) + assertTrue(player.inventory.contains(coffinName(contents - 1)), "Expected the dug-up coffin kept across relog") + } +} diff --git a/game/src/test/kotlin/content/activity/event/random/KissTheFrogTest.kt b/game/src/test/kotlin/content/activity/event/random/KissTheFrogTest.kt new file mode 100644 index 0000000000..7f26414907 --- /dev/null +++ b/game/src/test/kotlin/content/activity/event/random/KissTheFrogTest.kt @@ -0,0 +1,134 @@ +package content.activity.event.random + +import WorldTest +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.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.inv.inventory +import world.gregs.voidps.type.Tile +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class KissTheFrogTest : WorldTest() { + + private val origin = Tile(3221, 3218) + private val land = Tile(2445, 4770) + + private fun start(name: String): Player { + val player = createPlayer(origin, name) + RandomEvents.start(player, "kiss_the_frog") + tick(2) + return player + } + + private fun Player.herald(): NPC? = (-2..2).flatMap { dx -> (-2..2).map { dy -> tile.add(dx, dy) } } + .firstNotNullOfOrNull { t -> NPCs.firstOrNull(t) { it.id == "frog_herald" && it.owner == this } } + + /** Tick and clear any dialogue until the event ends. */ + private fun Player.driveUntilDone() { + tickIf(200) { + while (dialogue != null) skipDialogues() + get("random_event") == "kiss_the_frog" + } + } + + private fun Player.enterLand() { + npcOption(herald()!!, "Talk-to") + tickIf(40) { + while (dialogue != null) skipDialogues() + get("ktf_crown", -1) == -1 + } + while (dialogue != null) skipDialogues() // finish the herald's explanation + tick() + } + + @Test + fun `The Frog Herald appears beside the player`() { + val player = start("ktf_start") + + assertEquals("kiss_the_frog", player.get("random_event")) + assertNotNull(player.herald()) + } + + @Test + fun `Talking to the herald whisks the player to the land and hides a crowned frog`() { + val player = start("ktf_land") + player.enterLand() + + assertTrue(player.tile.within(land, 20), "Expected the land, was ${player.tile}") + val crown = NPCs.indexed(player.get("ktf_crown", -1)) + assertNotNull(crown) + assertEquals("frog_9_frogland", crown!!.id) + // Only the six social tabs remain; combat gear and inventory are hidden. + assertTrue(!player.interfaces.contains("inventory"), "Inventory tab should be hidden") + assertTrue(player.interfaces.contains("music_player"), "Music Player tab should remain") + assertTrue(player.interfaces.contains("notes"), "Notes tab should remain") + } + + @Test + fun `Kissing the crowned frog rewards a gift and returns the player`() { + val player = start("ktf_kiss") + player.enterLand() + val crown = NPCs.indexed(player.get("ktf_crown", -1))!! + player.tele(crown.tile.addX(1)) + + player.npcOption(crown, "Talk-to") + player.driveUntilDone() + + assertEquals(1, player.inventory.count("random_event_gift")) + assertEquals(1, player.get("frog_costume_points", 0)) + assertNull(player.get("random_event")) + assertEquals(origin, player.tile) + } + + private fun Player.talk(npc: NPC) { + npcOption(npc, "Talk-to") + tick() + while (dialogue != null) skipDialogues() + tick(2) + } + + @Test + fun `Offending the royal repeatedly turns the player into a frog`() { + val player = start("ktf_wrong") + player.enterLand() + val plain = createNPC("frog_frogland", player.tile.addX(1)) + + // The first offences only annoy the royal; the player stays human. + player.talk(plain) + assertTrue(!player.get("ktf_fail", false)) + player.talk(plain) + assertTrue(!player.get("ktf_fail", false)) + // The third offence transforms them. + player.talk(plain) + + assertTrue(player.get("ktf_fail", false)) + } + + @Test + fun `A transformed player who talks to the royal is dumped somewhere with no reward`() { + val player = start("ktf_dump") + player.enterLand() + val plain = createNPC("frog_frogland", player.tile.addX(1)) + repeat(3) { player.talk(plain) } + assertTrue(player.get("ktf_fail", false)) + // The third offence banishes the player to the cave and spawns the escape royal. + tickIf(20) { NPCs.indexed(player.get("ktf_escape", -1)) == null } + val escape = NPCs.indexed(player.get("ktf_escape", -1))!! + player.tele(escape.tile.addX(1)) + + player.npcOption(escape, "Talk-to") + player.driveUntilDone() + + assertEquals(0, player.inventory.count("random_event_gift")) + assertNull(player.get("random_event")) + assertTrue(!player.get("ktf_fail", false)) + } +} diff --git a/game/src/test/kotlin/content/activity/event/random/LostAndFoundTest.kt b/game/src/test/kotlin/content/activity/event/random/LostAndFoundTest.kt new file mode 100644 index 0000000000..738390b720 --- /dev/null +++ b/game/src/test/kotlin/content/activity/event/random/LostAndFoundTest.kt @@ -0,0 +1,86 @@ +package content.activity.event.random + +import WorldTest +import objectOption +import org.junit.jupiter.api.Test +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.obj.GameObjects +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.type.Tile +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class LostAndFoundTest : WorldTest() { + + private val origin = Tile(3221, 3218) + private val plane = Tile(2332, 4770) + private val appendages = listOf( + Tile(2336, 4771), + Tile(2332, 4775), + Tile(2327, 4771), + Tile(2332, 4766), + ) + + private fun enter(name: String, tile: Tile = origin): Player { + val player = createPlayer(tile, name) + RandomEvents.start(player, "lost_and_found") + tick(6) + return player + } + + private fun Player.operate(tile: Tile) { + val appendage = GameObjects.getShape(tile, 10)!! + objectOption(appendage, "Operate") + tick(8) + } + + private fun Player.oddTile() = appendages[get("laf_odd", 0) - 1] + + private fun Player.wrongTile() = appendages.first { it != oddTile() } + + @Test + fun `The player slips through to the Abyssal plane`() { + val player = enter("laf_start") + + assertEquals("lost_and_found", player.get("random_event")) + assertEquals(plane, player.tile) + assertTrue(player.get("laf_odd", 0) in 1..4) + } + + @Test + fun `Three appendages match and the odd one differs`() { + val player = enter("laf_shapes") + + val shapes = (1..4).map { player.get("lost_and_found_appendage_$it", -1) } + val odd = player.get("laf_odd", 0) + + assertEquals(1, shapes.count { it == shapes[odd - 1] }) + assertEquals(3, shapes.count { it != shapes[odd - 1] }) + assertEquals(1, shapes.distinct().size - 1) + } + + @Test + fun `The wrong appendage drains Magic and reshuffles`() { + val player = enter("laf_wrong") + player.levels.set(Skill.Magic, 50) + + player.operate(player.wrongTile()) + + assertEquals("lost_and_found", player.get("random_event")) + assertTrue(player.levels.get(Skill.Magic) < 50) + } + + @Test + fun `The odd appendage forwards the player home`() { + val player = enter("laf_right") + + player.operate(player.oddTile()) + + assertNull(player.get("random_event")) + assertEquals(origin, player.tile) + assertTrue(player.contains("random_event_cooldown")) + assertEquals(1, player.inventory.count("random_event_gift")) + } +} diff --git a/game/src/test/kotlin/content/activity/event/random/MazeTest.kt b/game/src/test/kotlin/content/activity/event/random/MazeTest.kt new file mode 100644 index 0000000000..a3e1680527 --- /dev/null +++ b/game/src/test/kotlin/content/activity/event/random/MazeTest.kt @@ -0,0 +1,134 @@ +package content.activity.event.random + +import WorldTest +import containsMessage +import content.quest.instance +import objectOption +import org.junit.jupiter.api.Test +import world.gregs.voidps.engine.entity.Despawn +import world.gregs.voidps.engine.entity.Spawn +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.entity.character.player.Players +import world.gregs.voidps.engine.entity.obj.GameObjects +import world.gregs.voidps.engine.entity.obj.ObjectShape +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 + +class MazeTest : WorldTest() { + + @Test + fun `Touching the shrine rewards and returns the player`() { + val player = createPlayer(Tile(3221, 3218), "maze_complete") + RandomEvents.start(player, "maze") + tick(10) + + assertNotNull(player.instance()) + assertTrue(player.interfaces.contains("maze_timer")) + + val shrine = createObject("strange_shrine", player.tile.addY(1)) + player.objectOption(shrine, "Touch") + tick(10) + + assertNull(player.instance()) + assertEquals(Tile(3221, 3218), player.tile) + assertNull(player.get("random_event")) + assertTrue(player.contains("random_event_cooldown")) + assertEquals(1, player.inventory.count("random_event_gift")) + } + + @Test + fun `Running out of time exiles the player without reward`() { + val player = createPlayer(Tile(3221, 3218), "maze_timeout") + RandomEvents.start(player, "maze") + tick(10) + assertNotNull(player.instance()) + + tick(305) + + assertNull(player.instance()) + assertNull(player.get("random_event")) + assertEquals(0, player.inventory.count("coins")) + assertTrue(player.tile.x in 3221..3223 && player.tile.y in 3217..3219, "Expected exile to Lumbridge, was at ${player.tile}") + } + + @Test + fun `Logging out mid-maze restarts the event on login`() { + val player = createPlayer(Tile(3221, 3218), "maze_relog") + RandomEvents.start(player, "maze") + tick(10) + assertNotNull(player.instance()) + val origin = Tile(player["random_event_origin", 0]) + + logout(player) + assertNull(player.instance()) + login(player) + tick(10) + + assertEquals("maze", player.get("random_event")) + assertNotNull(player.instance()) + assertEquals(origin, Tile(player["random_event_origin", 0])) + + val shrine = createObject("strange_shrine", player.tile.addY(1)) + player.objectOption(shrine, "Touch") + tick(10) + + assertEquals(Tile(3221, 3218), player.tile) + } + + @Test + fun `Maze walls swing open and close behind the player`() { + val player = createPlayer(Tile(3221, 3218), "maze_door") + val door = createObject("maze_door_1_closed", Tile(3221, 3219), shape = ObjectShape.WALL_STRAIGHT) + + player.objectOption(door, "Open") + tickIf { GameObjects.findOrNull(Tile(3221, 3219), "maze_door_1_closed") != null } + + assertNull(GameObjects.findOrNull(Tile(3221, 3219), "maze_door_1_closed")) + tickIf { GameObjects.findOrNull(Tile(3221, 3219), "maze_door_1_closed") == null } + assertNotNull(GameObjects.findOrNull(Tile(3221, 3219), "maze_door_1_closed")) + } + + @Test + fun `Chests give a small reward once opened`() { + val player = createPlayer(Tile(3221, 3218), "maze_chest") + player["maze_ticks"] = 100 + val chest = createObject("maze_chest", Tile(3221, 3219)) + + player.objectOption(chest, "Open") + tick(3) + + assertEquals(15, player.inventory.count("air_rune")) + assertEquals(1, player["maze_chests_opened", 0]) + assertNull(GameObjects.findOrNull(Tile(3221, 3219), "maze_chest")) + assertNotNull(GameObjects.findOrNull(Tile(3221, 3219), "maze_chest_opened")) + } + + @Test + fun `Chests give nothing once the cap is reached`() { + val player = createPlayer(Tile(3221, 3218), "maze_chest_cap") + player["maze_ticks"] = 100 + player["maze_chests_opened"] = 10 + val chest = createObject("maze_chest", Tile(3221, 3219)) + + player.objectOption(chest, "Open") + tick(3) + + assertEquals(0, player.inventory.count("air_rune")) + assertTrue(player.containsMessage("You find nothing of interest.")) + assertNotNull(GameObjects.findOrNull(Tile(3221, 3219), "maze_chest")) + } + + private fun login(player: Player) { + Players.add(player) + Spawn.player(player) + } + + private fun logout(player: Player) { + Despawn.player(player) + Players.remove(player) + } +} diff --git a/game/src/test/kotlin/content/activity/event/random/MimeTest.kt b/game/src/test/kotlin/content/activity/event/random/MimeTest.kt new file mode 100644 index 0000000000..5040da9b88 --- /dev/null +++ b/game/src/test/kotlin/content/activity/event/random/MimeTest.kt @@ -0,0 +1,75 @@ +package content.activity.event.random + +import WorldTest +import interfaceOption +import org.junit.jupiter.api.Test +import skipDialogues +import world.gregs.voidps.engine.entity.character.npc.NPCs +import world.gregs.voidps.engine.entity.character.player.Player +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 + +class MimeTest : WorldTest() { + + private val origin = Tile(3221, 3218) + private val iface = "dialogue_macro_mime_emotes" + + /** Runs the event through the intro up to the first emote selection (interface 188 open). */ + private fun enter(name: String): Player { + val player = createPlayer(origin, name) + RandomEvents.start(player, "mime") + tick(6) // mysterious old man + kidnap + player.skipDialogues() // "Here's a little challenge..." + tick(3) // walk to the watch spot + tickIf { !player.interfaces.contains(iface) } // "Watch the Mime." + performance -> opens the interface + return player + } + + private fun Player.pick(emote: String) { + interfaceOption(iface, emote, optionIndex = 0) + tick() + } + + private fun Player.pickCorrect() = pick(get("mime_emote")!!) + + @Test + fun `Event drops the player into the theatre and opens the mime interface`() { + val player = enter("mime_start") + + assertEquals("mime", player.get("random_event")) + assertTrue(player.interfaces.contains(iface)) + assertNotNull(player.get("mime_emote")) + assertNotNull(NPCs.firstOrNull(Tile(2011, 4762)) { it.id == "mime" }) + } + + @Test + fun `A wrong emote earns no credit`() { + val player = enter("mime_wrong") + val expected = player.get("mime_emote")!! + + player.pick(if (expected == "think") "cry" else "think") + + assertEquals(0, player.get("mime_correct", 0)) + } + + @Test + fun `Copying three emotes unlocks the emotes, rewards a gift and returns the player`() { + val player = enter("mime_finish") + + repeat(3) { + player.pickCorrect() + tickIf { !player.interfaces.contains(iface) && player.get("random_event") == "mime" } + } + tickIf { player.tile != origin } // wait out the modern teleport home + + assertEquals(1, player.inventory.count("random_event_gift")) + assertEquals(1, player.get("mime_costume_points", 0)) + assertTrue(player["unlocked_emote_glass_wall", false]) + assertNull(player.get("random_event")) + assertEquals(origin, player.tile) + } +} diff --git a/game/src/test/kotlin/content/activity/event/random/PilloryTest.kt b/game/src/test/kotlin/content/activity/event/random/PilloryTest.kt new file mode 100644 index 0000000000..f859c41f17 --- /dev/null +++ b/game/src/test/kotlin/content/activity/event/random/PilloryTest.kt @@ -0,0 +1,93 @@ +package content.activity.event.random + +import WorldTest +import interfaceOption +import objectOption +import org.junit.jupiter.api.Test +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.entity.obj.GameObjects +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.type.Tile +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class PilloryTest : WorldTest() { + + private val origin = Tile(3221, 3218) + private val cages = listOf( + Tile(3228, 3408), + Tile(3230, 3408), + Tile(2681, 3488), + Tile(2683, 3488), + Tile(2685, 3488), + Tile(2608, 3104), + ) + + private fun enter(name: String): Player { + val player = createPlayer(origin, name) + RandomEvents.start(player, "pillory") + tick(8) + return player + } + + private fun openPuzzle(player: Player) { + val cage = GameObjects.add("pillory_cage", player.tile) + player.objectOption(cage, "Unlock") + tick() + } + + private fun Player.pickAnswer() { + interfaceOption("pillory_lock", "button_${get("pillory_answer", 0)}", "Select") + tick() + } + + @Test + fun `Guard arrests the player into a pillory cage`() { + val player = enter("pil_start") + + assertEquals("pillory", player.get("random_event")) + // The player lands on (or, if the stocks block the tile, right beside) a pillory cage. + assertTrue(cages.any { player.tile.within(it, 1) }, "Expected by a cage, was ${player.tile}") + assertEquals(3, player.get("pillory_target", 0)) + } + + @Test + fun `A correct key increments the streak`() { + val player = enter("pil_correct") + openPuzzle(player) + + player.pickAnswer() + + assertEquals(1, player.get("pillory_correct", 0)) + } + + @Test + fun `A wrong key resets the streak and raises the target`() { + val player = enter("pil_wrong") + openPuzzle(player) + + val answer = player.get("pillory_answer", 0) + val wrong = (1..3).first { it != answer } + player.interfaceOption("pillory_lock", "button_$wrong", "Select") + tick() + + assertEquals(0, player.get("pillory_correct", 0)) + assertEquals(4, player.get("pillory_target", 0)) + } + + @Test + fun `Three correct keys free the player and reward loot`() { + val player = enter("pil_finish") + openPuzzle(player) + + repeat(3) { player.pickAnswer() } + tick(2) + + assertFalse(player.inventory.isEmpty()) + assertNull(player.get("random_event")) + assertEquals(origin, player.tile) + assertTrue(player.contains("random_event_cooldown")) + } +} diff --git a/game/src/test/kotlin/content/activity/event/random/PinballTest.kt b/game/src/test/kotlin/content/activity/event/random/PinballTest.kt new file mode 100644 index 0000000000..ba4d933236 --- /dev/null +++ b/game/src/test/kotlin/content/activity/event/random/PinballTest.kt @@ -0,0 +1,108 @@ +package content.activity.event.random + +import WorldTest +import content.quest.instance +import objectOption +import org.junit.jupiter.api.Test +import skipDialogues +import world.gregs.voidps.engine.client.ui.dialogue +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.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 + +class PinballTest : WorldTest() { + + private val origin = Tile(3221, 3218) + + private fun enter(name: String): Player { + val player = createPlayer(origin, name) + RandomEvents.start(player, "pinball") + tick(8) + while (player.dialogue != null) { + player.skipDialogues() + tick() + } + tick(6) // the old man waves, puffs away and the first post lights up + return player + } + + private fun Player.tag(post: String) { + val obj = createObject(post, tile.addY(1)) + objectOption(obj, "Tag") + tick(4) + } + + @Test + fun `The old man kidnaps the player to a private pinball arena`() { + val player = enter("pb_start") + + assertEquals("pinball", player.get("random_event")) + assertNotNull(player.instance()) + assertTrue(player.interfaces.contains("pinball_overlay")) + assertEquals(1, player.get("pinball_target", 0)) // deterministic first post in tests + assertEquals(0, player.get("pinball_score", 0)) + } + + @Test + fun `Tagging the flashing post scores a point and lights the next`() { + val player = enter("pb_score") + + player.tag("pinball_post_1") + + assertEquals(1, player.get("pinball_score", 0)) + assertEquals(1, player.get("pinball_target", 0)) + } + + @Test + fun `Tagging an unlit post resets the score`() { + val player = enter("pb_reset") + player.tag("pinball_post_1") + assertEquals(1, player.get("pinball_score", 0)) + + player.tag("pinball_post_2") + + assertEquals(0, player.get("pinball_score", 0)) + assertEquals("pinball", player.get("random_event")) + } + + @Test + fun `The trolls block the exit until ten points`() { + val player = enter("pb_blocked") + val exit = createObject("pinball_cave_exit", player.tile.addY(1)) + + player.objectOption(exit, "Exit") + tick(2) + while (player.dialogue != null) { + player.skipDialogues() + tick() + } + + assertEquals("pinball", player.get("random_event")) + assertNotNull(player.instance()) + assertTrue(player.inventory.isEmpty()) + } + + @Test + fun `Ten points opens the way out for a gift`() { + val player = enter("pb_win") + + repeat(10) { + player.tag("pinball_post_1") + } + assertEquals(10, player.get("pinball_score", 0)) + + val exit = createObject("pinball_cave_exit", player.tile.addY(1)) + player.objectOption(exit, "Exit") + tick(10) + + assertEquals(1, player.inventory.count("random_event_gift")) + assertNull(player.get("random_event")) + assertNull(player.instance()) + assertEquals(origin, player.tile) + assertTrue(player.contains("random_event_cooldown")) + } +} diff --git a/game/src/test/kotlin/content/activity/event/random/PrisonPeteTest.kt b/game/src/test/kotlin/content/activity/event/random/PrisonPeteTest.kt new file mode 100644 index 0000000000..bb002416af --- /dev/null +++ b/game/src/test/kotlin/content/activity/event/random/PrisonPeteTest.kt @@ -0,0 +1,167 @@ +package content.activity.event.random + +import WorldTest +import npcOption +import objectOption +import org.junit.jupiter.api.Test +import skipDialogues +import world.gregs.voidps.engine.client.ui.dialogue +import world.gregs.voidps.engine.entity.character.npc.NPC +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.entity.obj.GameObjects +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.type.Tile +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class PrisonPeteTest : WorldTest() { + + private val origin = Tile(3221, 3218) + private val prison = Tile(2086, 4462) + private val balloons = listOf( + "balloon_animal_dog", + "balloon_animal_cat", + "balloon_animal_sheep", + "balloon_animal_goat", + ) + + private fun enter(name: String): Player { + val player = createPlayer(origin, name) + RandomEvents.start(player, "prison_pete") + tick(8) + return player + } + + private fun Player.pullLever() { + val lever = GameObjects.add("prison_pete_lever", tile) + objectOption(lever, "Pull") + tick(6) + } + + private fun Player.popBalloon(correct: Boolean) { + val target = get("prison_pete_target")!! + val id = if (correct) target else balloons.first { it != target } + val balloon = createNPC(id, tile.addY(1)) + npcOption(balloon, "Pop") + tick(7) + skipDialogues() + tick() + } + + private fun Player.handKeyToPete(): NPC { + val pete = createNPC("prison_pete", tile.addY(1)) + npcOption(pete, "Talk-to") + tick(2) + skipDialogues() + // A correct key adds a pickpocket animation delay before Pete's response. + tick(3) + if (dialogue != null) { + skipDialogues() + } + tick() + return pete + } + + @Test + fun `Evil Bob cat kidnaps the player to the prison`() { + val player = enter("pp_start") + + assertEquals("prison_pete", player.get("random_event")) + assertEquals(prison, player.tile) + assertEquals(0, player.get("prison_pete_keys", 0)) + } + + @Test + fun `Popping without pulling the lever does nothing`() { + val player = enter("pp_no_lever") + val balloon = createNPC(balloons.first(), player.tile.addY(1)) + + player.npcOption(balloon, "Pop") + tick(3) + + assertFalse(player.inventory.contains("prison_key_prison_pete")) + } + + @Test + fun `The lever picks a target balloon shape`() { + val player = enter("pp_lever") + + player.pullLever() + + assertTrue(player.get("prison_pete_target") in balloons) + } + + @Test + fun `Popping the right balloon earns a key for Pete`() { + val player = enter("pp_correct") + player.pullLever() + + player.popBalloon(correct = true) + + assertEquals(0, player.get("prison_pete_keys", 0)) // locks only open once Pete gets the key + assertEquals(1, player.get("prison_pete_pending", 0)) + assertTrue(player.inventory.contains("prison_key_prison_pete")) + assertNull(player.get("prison_pete_target")) + } + + @Test + fun `Popping the wrong balloon gives a dud key`() { + val player = enter("pp_wrong") + player.pullLever() + + player.popBalloon(correct = false) + + assertEquals(0, player.get("prison_pete_keys", 0)) + assertEquals(0, player.get("prison_pete_pending", 0)) + assertTrue(player.inventory.contains("prison_key_prison_pete")) + } + + @Test + fun `Pete rejects a dud key`() { + val player = enter("pp_reject") + player.pullLever() + player.popBalloon(correct = false) + + player.handKeyToPete() + + assertFalse(player.inventory.contains("prison_key_prison_pete")) + assertEquals(0, player.get("prison_pete_keys", 0)) + assertEquals("prison_pete", player.get("random_event")) + } + + @Test + fun `The gates stay shut until Pete is freed`() { + val player = enter("pp_gate_shut") + + player.objectOption(gate(), "Open") + tick(8) + + assertEquals("prison_pete", player.get("random_event")) + assertNull(player.get("prison_pete_target")) + } + + @Test + fun `Three correct keys open the gates and reward the player`() { + val player = enter("pp_finish") + + repeat(3) { + player.pullLever() + player.popBalloon(correct = true) + player.handKeyToPete() + } + player.objectOption(gate(), "Open") + tick(10) + player.skipDialogues() + tick(10) + + assertFalse(player.inventory.isEmpty()) + assertFalse(player.inventory.contains("prison_key_prison_pete")) + assertNull(player.get("random_event")) + assertEquals(origin, player.tile) + assertTrue(player.contains("random_event_cooldown")) + } + + private fun gate() = GameObjects.getShape(Tile(2085, 4459), 0)!! +} diff --git a/game/src/test/kotlin/content/activity/event/random/QuizMasterTest.kt b/game/src/test/kotlin/content/activity/event/random/QuizMasterTest.kt new file mode 100644 index 0000000000..64b8c9619a --- /dev/null +++ b/game/src/test/kotlin/content/activity/event/random/QuizMasterTest.kt @@ -0,0 +1,76 @@ +package content.activity.event.random + +import WorldTest +import dialogueOption +import org.junit.jupiter.api.Test +import skipDialogues +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.type.Tile +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class QuizMasterTest : WorldTest() { + + private val origin = Tile(3221, 3218) + private val quiz = "dialogue_macro_quiz_show" + + /** Runs the event and skips the intro so the first question interface is open. */ + private fun enter(name: String): Player { + val player = createPlayer(origin, name) + RandomEvents.start(player, "quiz_master") + tick(8) + player.skipDialogues() // advance the three intro lines -> opens the first question + tick() + return player + } + + /** Clicks the button at [slot] (a dialogue-continue) and lets the answer resolve. */ + private fun Player.pick(slot: Int) { + dialogueOption("button_$slot", quiz) + tick() + } + + private fun Player.pickAnswer() = pick(get("quiz_answer", 0)) + + @Test + fun `Event whisks the player to the studio and opens the quiz`() { + val player = enter("quiz_start") + + assertEquals("quiz_master", player.get("random_event")) + assertTrue(player.interfaces.contains(quiz)) + assertTrue(player.get("quiz_answer", 0) in 1..3) + } + + @Test + fun `A correct answer counts and a wrong one does not`() { + val player = enter("quiz_answer") + + val answer = player.get("quiz_answer", 0) + player.pick(if (answer == 1) 2 else 1) // wrong button + assertEquals(0, player.get("quiz_correct", 0)) + player.skipDialogues() // "WRONG!" -> reopens the quiz + tick() + + player.pickAnswer() + assertEquals(1, player.get("quiz_correct", 0)) + } + + @Test + fun `Four correct answers win a random event gift`() { + val player = enter("quiz_coins") + + repeat(4) { + player.pickAnswer() + player.skipDialogues() // "RIGHT!" (or the winner line on the 4th) -> the prize + tick() + } + tick() + + assertEquals(1, player.inventory.count("random_event_gift")) + assertNull(player.get("random_event")) + assertEquals(origin, player.tile) + assertTrue(player.contains("random_event_cooldown")) + } +} diff --git a/game/src/test/kotlin/content/activity/event/random/RandomEventGiftTest.kt b/game/src/test/kotlin/content/activity/event/random/RandomEventGiftTest.kt new file mode 100644 index 0000000000..6af38c2e64 --- /dev/null +++ b/game/src/test/kotlin/content/activity/event/random/RandomEventGiftTest.kt @@ -0,0 +1,178 @@ +package content.activity.event.random + +import WorldTest +import equipItem +import interfaceOption +import org.junit.jupiter.api.Test +import skipDialogues +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.type.Tile +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class RandomEventGiftTest : WorldTest() { + + private fun openGift(name: String): Player { + val player = createPlayer(Tile(3221, 3218), name) + player.inventory.add("random_event_gift") + player.equipItem("random_event_gift", option = "Open") + tick() + return player + } + + private val Player.gift + get() = inventories.inventory("random_event_gift") + + private fun Player.select(slot: Int) { + // The grid packs seven sub-components per reward, like the client sends + interfaceOption("random_event_gift_select", "rewards", "Select", item = gift[slot], slot = slot * 7) + tick() + } + + @Test + fun `Opening a gift deals a reward for every category`() { + val player = openGift("gift_open") + + assertTrue(player.interfaces.contains("random_event_gift_select")) + assertEquals("coins", player.gift[0].id) + assertTrue(player.gift[0].amount >= 30) + assertEquals("coal", player.gift[2].id) + assertTrue(player.gift[3].id.endsWith("essence")) + assertEquals("lamp", player.gift[10].id) + assertEquals("mystery_box", player.gift[25].id) + assertTrue(player.gift[24].isNotEmpty(), "Expected an oddment reward") + } + + @Test + fun `Reopening an unclaimed gift keeps the same choices`() { + val player = openGift("gift_reopen") + val rewards = player.gift.items.toList() + + player.interfaceOption("random_event_gift_select", "close", "Close") + tick() + player.equipItem("random_event_gift", option = "Open") + tick() + + assertEquals(rewards, player.gift.items.toList()) + } + + @Test + fun `Selecting with a bare grid click like the client sends works`() { + val player = openGift("gift_bare") + val amount = player.gift[2].amount + + // The client sends the packed sub-component slot with no item attached + player.interfaceOption("random_event_gift_select", "rewards", "Select", slot = 2 * 7) + tick() + player.interfaceOption("random_event_gift_select", "confirm", "Confirm") + tick() + + assertEquals(amount, player.inventory.count("coal_noted"), "Expected unstackable coal granted noted") + assertFalse(player.inventory.contains("random_event_gift")) + } + + @Test + fun `Choosing the emote unlocks a random event emote`() { + val player = openGift("gift_emote") + + // The emote cell is drawn by the interface; its container slot is empty + player.interfaceOption("random_event_gift_select", "rewards", "Select", slot = 26 * 7) + tick() + player.interfaceOption("random_event_gift_select", "confirm", "Confirm") + tick() + + assertTrue(player.get("unlocked_emote_idea", false), "Expected an emote unlocked") + assertFalse(player.inventory.contains("random_event_gift")) + } + + @Test + fun `Confirming without choosing keeps the gift`() { + val player = openGift("gift_none") + + player.interfaceOption("random_event_gift_select", "confirm", "Confirm") + tick() + + assertTrue(player.inventory.contains("random_event_gift")) + assertFalse(player.inventory.contains("coins")) + } + + @Test + fun `Choosing coins and confirming consumes the gift`() { + val player = openGift("gift_coins") + val amount = player.gift[0].amount + + player.select(0) + player.interfaceOption("random_event_gift_select", "confirm", "Confirm") + tick() + + assertEquals(amount, player.inventory.count("coins")) + assertFalse(player.inventory.contains("random_event_gift")) + assertFalse(player.interfaces.contains("random_event_gift_select")) + assertTrue(player.gift.isEmpty(), "Expected the unclaimed choices cleared") + } + + @Test + fun `Rubbing the lamp grants ten experience per level in the chosen skill`() { + val player = createPlayer(Tile(3221, 3218), "gift_lamp") + player.inventory.add("lamp") + + player.equipItem("lamp", option = "Rub") + tick() + player.interfaceOption("skill_stat_advance", "thieving", "Select", optionIndex = 0) + player.interfaceOption("skill_stat_advance", "confirm", "Confirm") + tick() + player.skipDialogues() + + assertEquals(10.0, player.experience.get(Skill.Thieving)) + assertFalse(player.inventory.contains("lamp")) + } + + @Test + fun `Constitution lamp experience uses the real level not hitpoints`() { + val player = createPlayer(Tile(3221, 3218), "gift_lamp_con") + player.inventory.add("lamp") + val before = player.experience.get(Skill.Constitution) + + player.equipItem("lamp", option = "Rub") + tick() + player.interfaceOption("skill_stat_advance", "constitution", "Select", optionIndex = 0) + player.interfaceOption("skill_stat_advance", "confirm", "Confirm") + tick() + player.skipDialogues() + + // Level 10 Constitution (stored as 100 internally) grants 100 xp, not 1000 + assertEquals(100.0, player.experience.get(Skill.Constitution) - before) + assertFalse(player.inventory.contains("lamp")) + } + + @Test + fun `Opening a mystery box rewards a random item`() { + val player = createPlayer(Tile(3221, 3218), "gift_mystery") + player.inventory.add("mystery_box") + + player.equipItem("mystery_box", option = "Open") + tick(2) + + assertFalse(player.inventory.contains("mystery_box")) + assertFalse(player.inventory.isEmpty(), "Expected something inside the box") + assertTrue(player["messages", emptyList()].any { it.startsWith("Inside the box you find") }) + } + + @Test + fun `Saving up for a costume banks a costume point`() { + val player = openGift("gift_costume") + + // The costume cell is drawn by the interface; its container slot is empty + player.interfaceOption("random_event_gift_select", "rewards", "Select", slot = 27 * 7) + tick() + player.interfaceOption("random_event_gift_select", "confirm", "Confirm") + tick() + + assertEquals(1, player.get("costume_points", 0)) + assertFalse(player.inventory.contains("random_event_gift")) + } +} diff --git a/game/src/test/kotlin/content/activity/event/random/RandomEventTriggerTest.kt b/game/src/test/kotlin/content/activity/event/random/RandomEventTriggerTest.kt new file mode 100644 index 0000000000..7f30a1b4fe --- /dev/null +++ b/game/src/test/kotlin/content/activity/event/random/RandomEventTriggerTest.kt @@ -0,0 +1,101 @@ +package content.activity.event.random + +import WorldTest +import content.bot.Bot +import content.quest.instance +import io.mockk.mockk +import org.junit.jupiter.api.Test +import world.gregs.voidps.engine.data.Settings +import world.gregs.voidps.type.Tile +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class RandomEventTriggerTest : WorldTest() { + + @Test + fun `Starts a random event once cooldown expires`() { + val player = createPlayer(Tile(3221, 3218), "re_trigger") + player["random_event_cooldown"] = 1 + + RandomEvents.roll(player) + tick(10) + + assertEquals("maze", player["random_event"]) + assertNotNull(player.instance()) + assertTrue(player.contains("random_event_cooldown")) + } + + @Test + fun `First event arms the cooldown without an event`() { + val player = createPlayer(Tile(3221, 3218), "re_cooldown") + + RandomEvents.roll(player) + tick(10) + + assertNull(player.get("random_event")) + assertTrue(player.contains("random_event_cooldown")) + } + + @Test + fun `Bots don't receive random events`() { + val player = createPlayer(Tile(3221, 3218), "re_bot") { it["bot"] = mockk(relaxed = true) } + player["random_event_cooldown"] = 1 + + RandomEvents.roll(player) + tick(10) + + assertNull(player.get("random_event")) + } + + @Test + fun `No random event during another random event`() { + val player = createPlayer(Tile(3221, 3218), "re_active") + player["random_event"] = "maze" + player["random_event_cooldown"] = 1 + + RandomEvents.roll(player) + tick() + + assertFalse(player.queue.contains("random_event_start")) + } + + @Test + fun `Players who opt out don't receive random events`() { + Settings.load(mapOf("events.randomEvents.optOut" to "true")) + val player = createPlayer(Tile(3221, 3218), "re_opt_out") + player["random_event_cooldown"] = 1 + player["random_events_disabled"] = true + + RandomEvents.roll(player) + tick(10) + + assertNull(player.get("random_event")) + } + + @Test + fun `Opting out is ignored on worlds without the opt out setting`() { + Settings.load(mapOf("events.randomEvents.optOut" to "false")) + val player = createPlayer(Tile(3221, 3218), "re_opt_out_off") + player["random_event_cooldown"] = 1 + player["random_events_disabled"] = true + + RandomEvents.roll(player) + tick(10) + + assertEquals("maze", player["random_event"]) + } + + @Test + fun `No random event in areas tagged no_random_events`() { + val player = createPlayer(Tile(3098, 3107), "re_tutorial") + player["random_event_cooldown"] = 1 + + RandomEvents.roll(player) + tick(10) + + assertNull(player.get("random_event")) + } +} diff --git a/game/src/test/kotlin/content/activity/event/random/RandomEventsTest.kt b/game/src/test/kotlin/content/activity/event/random/RandomEventsTest.kt new file mode 100644 index 0000000000..242fb5fde7 --- /dev/null +++ b/game/src/test/kotlin/content/activity/event/random/RandomEventsTest.kt @@ -0,0 +1,79 @@ +package content.activity.event.random + +import WorldTest +import org.junit.jupiter.api.Test +import world.gregs.voidps.engine.entity.character.npc.NPCs +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 + +class RandomEventsTest : WorldTest() { + + @Test + fun `noteAndTeleport notes carried items, exiles the player and arms the cooldown`() { + val player = createPlayer(Tile(3221, 3218), "re_note") + player["random_event"] = "certer" + player["random_event_origin"] = Tile(3221, 3218).id + player.inventory.add("logs", 5) + player.inventory.add("coins", 100) + + RandomEvents.noteAndTeleport(player) + tick() + + // Noteable stack replaced with its banknote, coins (no note) untouched + assertEquals(5, player.inventory.count("logs_noted")) + assertEquals(0, player.inventory.count("logs")) + assertEquals(100, player.inventory.count("coins")) + // Event state cleared and player exiled somewhere away from the origin + assertNull(player.get("random_event")) + assertNull(player.get("random_event_origin")) + assertTrue(player.contains("random_event_cooldown")) + } + + @Test + fun `In-place event spawns a following NPC beside the player`() { + val player = createPlayer(Tile(3221, 3218), "re_inplace") + player["random_event"] = "certer" + + val npc = player.startInPlaceEvent("mysterious_old_man", listOf("You there!"), lifetime = 100) + tick() + + assertEquals("certer", npc.get("random_event")) + assertTrue(npc.tile.within(player.tile, 2), "Event NPC should spawn beside the player") + assertNotNull(NPCs.indexed(npc.index), "Event NPC should still be alive") + } + + @Test + fun `Ignoring an in-place event past its lifetime triggers the note-and-teleport penalty`() { + val player = createPlayer(Tile(3221, 3218), "re_inplace_ignore") + player["random_event"] = "certer" + player["random_event_origin"] = Tile(3221, 3218).id + player.inventory.add("logs", 3) + player.inventory.add("logs_noted", 5) + + val npc = player.startInPlaceEvent("mysterious_old_man", listOf("You there!"), lifetime = 2) + tick(6) + + assertNull(player.get("random_event")) + assertTrue(player.contains("random_event_cooldown")) + assertEquals(8, player.inventory.count("logs_noted")) // already-noted items stay noted + assertNull(NPCs.indexed(npc.index), "Event NPC should be removed after the penalty") + } + + @Test + fun `endInPlaceEvent removes the event NPC`() { + val player = createPlayer(Tile(3221, 3218), "re_inplace_end") + player["random_event"] = "certer" + + val npc = player.startInPlaceEvent("mysterious_old_man", listOf("You there!")) + tick() + endInPlaceEvent(npc) + tick() + + assertNull(NPCs.indexed(npc.index)) + } +} diff --git a/game/src/test/kotlin/content/activity/event/random/SandwichLadyTest.kt b/game/src/test/kotlin/content/activity/event/random/SandwichLadyTest.kt new file mode 100644 index 0000000000..10ae5c2599 --- /dev/null +++ b/game/src/test/kotlin/content/activity/event/random/SandwichLadyTest.kt @@ -0,0 +1,95 @@ +package content.activity.event.random + +import WorldTest +import interfaceOption +import npcOption +import org.junit.jupiter.api.Test +import skipDialogues +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.inv.add +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.type.Tile +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class SandwichLadyTest : WorldTest() { + + private val spot = Tile(3221, 3218) + + private fun setup(name: String, food: String): Pair { + val player = createPlayer(spot, name) + player["random_event"] = "sandwich_lady" + player["random_event_origin"] = spot.id + player["sandwich_lady_food"] = food + val lady = createNPC("sandwich_lady", spot.addX(1)) + lady["owner"] = player.accountName + player["sandwich_lady_npc"] = lady.index + return player to lady + } + + private fun openTray(player: Player, lady: NPC) { + player.npcOption(lady, "Talk-to") + tick() + player.skipDialogues() // "You look hungry..." -> opens the tray + tick() + } + + @Test + fun `Event spawns the sandwich lady beside the player with a chosen food`() { + val player = createPlayer(spot, "sl_spawn") + RandomEvents.start(player, "sandwich_lady") + tick(2) + + assertTrue(player.get("sandwich_lady_food") != null) + val lady = (-2..2).flatMap { dx -> (-2..2).map { dy -> player.tile.add(dx, dy) } } + .firstNotNullOfOrNull { t -> NPCs.firstOrNull(t) { it.id == "sandwich_lady" } } + assertTrue(lady != null, "Expected a sandwich lady spawned near the player") + } + + @Test + fun `Choosing the offered food hands it over and ends the event in place`() { + val (player, lady) = setup("sl_correct", "meat_pie") + openTray(player, lady) + + assertTrue(player.interfaces.contains("sandwich_lady_select")) + player.interfaceOption("sandwich_lady_select", "meat_pie", "Choose refreshment") + tick() + player.skipDialogues() // "Hope that fills you up!" + tick() + + assertEquals(1, player.inventory.count("meat_pie")) + assertEquals(spot, player.tile) // in-place: not teleported anywhere + assertNull(player.get("random_event")) + assertTrue(player.contains("random_event_cooldown")) + } + + @Test + fun `Choosing the wrong food knocks the player out and exiles them empty-handed`() { + val (player, lady) = setup("sl_wrong", "meat_pie") + player.inventory.add("logs", 4) + openTray(player, lady) + + player.interfaceOption("sandwich_lady_select", "baguette", "Choose refreshment") + tick(5) // baguette swing, knockout animation, screen fade, then teleport + + assertEquals(0, player.inventory.count("baguette")) + assertEquals(4, player.inventory.count("logs")) // knockout doesn't note items + assertNull(player.get("random_event")) + assertTrue(player.contains("random_event_cooldown")) + } + + @Test + fun `Another player can't take the sandwich`() { + val (owner, lady) = setup("sl_owner", "roll") + val intruder = createPlayer(spot.addY(1), "sl_intruder") + + intruder.npcOption(lady, "Talk-to") + tick() + + assertFalse(intruder.interfaces.contains("sandwich_lady_select")) + } +} diff --git a/game/src/test/kotlin/content/activity/event/random/SurpriseExamTest.kt b/game/src/test/kotlin/content/activity/event/random/SurpriseExamTest.kt new file mode 100644 index 0000000000..7b2c913333 --- /dev/null +++ b/game/src/test/kotlin/content/activity/event/random/SurpriseExamTest.kt @@ -0,0 +1,96 @@ +package content.activity.event.random + +import WorldTest +import interfaceOption +import npcOption +import objectOption +import org.junit.jupiter.api.Test +import skipDialogues +import world.gregs.voidps.engine.entity.character.npc.NPC +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.entity.obj.GameObjects +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 + +class SurpriseExamTest : WorldTest() { + + private val origin = Tile(3221, 3218) + private val classroom = Tile(1886, 5025) + private val iface = "surprise_exam_pattern" + + private fun enter(name: String): Player { + val player = createPlayer(origin, name) + RandomEvents.start(player, "surprise_exam") + tick(8) + return player + } + + /** Spawn Mordaut beside the player and talk to him to open the first question. */ + private fun openExam(player: Player): NPC { + val mordaut = createNPC("mr_mordau", player.tile.addX(1)) + player.npcOption(mordaut, "Talk-to") + tick() + player.skipDialogues() // "Please answer these questions" -> opens the interface + tick() + return mordaut + } + + private fun Player.pick(option: Int) { + interfaceOption(iface, "option_$option", "Select") + tick() + } + + private fun Player.pickCorrect() = pick(get("surprise_exam_answer", 0)) + + @Test + fun `Old man teleports the player into the classroom`() { + val player = enter("se_start") + + assertEquals("surprise_exam", player.get("random_event")) + assertTrue(player.tile.within(classroom, 6), "Expected the classroom, was ${player.tile}") + } + + @Test + fun `A correct answer counts and a wrong one does not`() { + val player = enter("se_answer") + openExam(player) + + val answer = player.get("surprise_exam_answer", 0) + val wrong = (1..4).first { it != answer } + player.pick(wrong) + assertEquals(0, player.get("surprise_exam_correct", 0)) + player.skipDialogues() // "isn't correct" -> reopens + tick() + + player.pickCorrect() + assertEquals(1, player.get("surprise_exam_correct", 0)) + } + + @Test + fun `Passing assigns a door, and the right door frees the player with a gift`() { + val player = enter("se_finish") + openExam(player) + + repeat(3) { + player.pickCorrect() + player.skipDialogues() // "Excellent, another" (or the pass line on the 3rd) + tick() + } + assertNotNull(player.get("surprise_exam_door")) + + // Leave via the assigned door (spawn it beside the player to interact deterministically). + player["surprise_exam_door"] = "exam_door_blue" + val door = GameObjects.add("exam_door_blue", player.tile) + player.objectOption(door, "Open") + tick(2) + + assertEquals(1, player.inventory.count("random_event_gift")) + assertNull(player.get("random_event")) + assertEquals(origin, player.tile) + assertTrue(player.contains("random_event_cooldown")) + } +} diff --git a/game/src/test/kotlin/content/area/misthalin/varrock/IffieTest.kt b/game/src/test/kotlin/content/area/misthalin/varrock/IffieTest.kt new file mode 100644 index 0000000000..5bb5c8281b --- /dev/null +++ b/game/src/test/kotlin/content/area/misthalin/varrock/IffieTest.kt @@ -0,0 +1,138 @@ +package content.area.misthalin.varrock + +import WorldTest +import dialogueOption +import interfaceOption +import npcOption +import org.junit.jupiter.api.Test +import skipDialogues +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.entity.item.Item +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.type.Tile +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class IffieTest : WorldTest() { + + private fun visit(name: String, points: Int): Player { + val player = createPlayer(Tile(3204, 3418), name) + if (points > 0) { + player["costume_points"] = points + } + val iffie = createNPC("iffie", Tile(3204, 3419)) + player.npcOption(iffie, "Claim-costume") + tick(3) + return player + } + + @Test + fun `The store shows the player's points on every costume row`() { + val player = visit("iffie_stock", points = 3) + + assertTrue(player.interfaces.contains("costume_reward_select")) + assertEquals(3, player.get("costume_points_mime", 0)) + assertEquals(3, player.get("costume_points_lederhosen", 0)) + } + + @Test + fun `Claiming a costume spends a point on its next missing piece`() { + val player = visit("iffie_claim", points = 2) + + // The piece children carry their item, so the click names the exact piece + player.interfaceOption("costume_reward_select", "rewards", "Claim", item = Item("mime_mask", 1), slot = 1) + tick() + + assertEquals(1, player.inventory.count("mime_mask")) + assertEquals(1, player.get("costume_points", 0)) + assertEquals(1, player.get("costume_points_mime", 0), "Expected the shown balance refreshed") + + player.interfaceOption("costume_reward_select", "rewards", "Claim", item = Item("camo_helmet", 1), slot = 31) + tick() + + assertEquals(1, player.inventory.count("camo_helmet")) + assertEquals(0, player.get("costume_points", 0)) + } + + @Test + fun `Outfit points combine with generic on the row and spend first`() { + val player = createPlayer(Tile(3204, 3418), "iffie_locked") + player["costume_points"] = 1 + player["mime_costume_points"] = 2 + val iffie = createNPC("iffie", Tile(3204, 3419)) + player.npcOption(iffie, "Claim-costume") + tick(3) + + assertEquals(3, player.get("costume_points_mime", 0), "Expected locked + generic combined on the row") + assertEquals(1, player.get("costume_points_camo", 0), "Expected only generic on other rows") + + player.interfaceOption("costume_reward_select", "rewards", "Claim", item = Item("mime_mask", 1), slot = 1) + tick() + + assertEquals(1, player.inventory.count("mime_mask")) + assertEquals(1, player.get("mime_costume_points", 0), "Expected the locked point spent first") + assertEquals(1, player.get("costume_points", 0), "Expected the generic point kept") + } + + @Test + fun `Outfit points can't buy another outfit`() { + val player = createPlayer(Tile(3204, 3418), "iffie_cross") + player["mime_costume_points"] = 1 + val iffie = createNPC("iffie", Tile(3204, 3419)) + player.npcOption(iffie, "Claim-costume") + tick(3) + + player.interfaceOption("costume_reward_select", "rewards", "Claim", item = Item("camo_top", 1), slot = 31) + tick() + + assertTrue(player.inventory.isEmpty(), "Expected a mime point not to buy camo") + assertEquals(1, player.get("mime_costume_points", 0)) + } + + @Test + fun `A completed costume greys its row out`() { + val player = visit("iffie_complete", points = 4) + + for (piece in listOf("camo_helmet", "camo_top", "camo_bottoms")) { + player.interfaceOption("costume_reward_select", "rewards", "Claim", item = Item(piece, 1), slot = 31) + tick() + } + + assertTrue(player.get("costume_claimed_camo", false), "Expected the camo row greyed out") + assertFalse(player.get("costume_claimed_mime", false)) + + player.interfaceOption("costume_reward_select", "rewards", "Claim", item = Item("frog_mask", 1), slot = 11) + tick() + + assertTrue(player.get("costume_claimed_frog_mask", false), "Expected the frog mask greyed out") + assertFalse(player.get("costume_claimed_frog", false), "Expected the royal outfit still available") + } + + @Test + fun `No costume points means no costume`() { + val player = visit("iffie_broke", points = 0) + + player.interfaceOption("costume_reward_select", "rewards", "Claim", item = Item("mime_mask", 1), slot = 1) + tick() + + assertTrue(player.inventory.isEmpty()) + assertEquals(0, player.get("costume_points", 0)) + } + + @Test + fun `Iffie explains costume points to newcomers`() { + val player = createPlayer(Tile(3204, 3418), "iffie_chat") + val iffie = createNPC("iffie", Tile(3204, 3419)) + + player.npcOption(iffie, "Talk-to") + tick(3) + player.skipDialogues() + player.dialogueOption(1) // "I've come for a random event costume." + tick() + player.skipDialogues() + tick() + + assertFalse(player.interfaces.contains("costume_reward_select"), "Expected no store without points") + } +} diff --git a/game/src/test/kotlin/content/skill/fishing/CasketTest.kt b/game/src/test/kotlin/content/skill/fishing/CasketTest.kt new file mode 100644 index 0000000000..c77208d472 --- /dev/null +++ b/game/src/test/kotlin/content/skill/fishing/CasketTest.kt @@ -0,0 +1,26 @@ +package content.skill.fishing + +import WorldTest +import equipItem +import org.junit.jupiter.api.Test +import world.gregs.voidps.engine.inv.add +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.type.Tile +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class CasketTest : WorldTest() { + + @Test + fun `Opening a casket rewards a little treasure`() { + val player = createPlayer(Tile(3221, 3218), "casket_open") + player.inventory.add("casket") + + player.equipItem("casket", option = "Open") + tick(2) + + assertFalse(player.inventory.contains("casket")) + assertFalse(player.inventory.isEmpty(), "Expected treasure inside the casket") + assertTrue(player["messages", emptyList()].any { it.startsWith("You open the casket and find") }) + } +} diff --git a/tools/build.gradle.kts b/tools/build.gradle.kts index 4ddfcff2cb..4e5cdb117d 100644 --- a/tools/build.gradle.kts +++ b/tools/build.gradle.kts @@ -68,4 +68,3 @@ tasks.register("renderPhotoBooth") { val cliArgs = (findProperty("args") as String?)?.split(" ")?.filter { it.isNotBlank() } ?: emptyList() args = cliArgs } -