From 99b5eb58fef9a9f34e3a545d45ae511b00e38856 Mon Sep 17 00:00:00 2001 From: GregHib Date: Fri, 1 May 2026 21:58:10 +0100 Subject: [PATCH 01/11] Basic evil tree defs --- data/activity/evil_tree/evil_tree.objs.toml | 163 ++++++++++++++ data/activity/evil_tree/evil_tree.tables.toml | 202 ++++++++++++++++++ data/entity/obj/trees.objs.toml | 166 -------------- 3 files changed, 365 insertions(+), 166 deletions(-) create mode 100644 data/activity/evil_tree/evil_tree.objs.toml create mode 100644 data/activity/evil_tree/evil_tree.tables.toml diff --git a/data/activity/evil_tree/evil_tree.objs.toml b/data/activity/evil_tree/evil_tree.objs.toml new file mode 100644 index 0000000000..848ebae63b --- /dev/null +++ b/data/activity/evil_tree/evil_tree.objs.toml @@ -0,0 +1,163 @@ +[evil_tree_normal_full] +id = 11434 +upgrade_time = 50 +examine = "This looks like a challenge anyone can deal with." + +[evil_tree_normal_half] +id = 11435 +examine = "This looks like a challenge anyone can deal with." + +[evil_tree_normal_weak] +id = 11436 +examine = "This looks like a challenge anyone can deal with." + +[evil_tree_normal_death] +id = 11925 + +[evil_tree_normal_stump] +id = 12713 + +#[evil_tree_normal_stump] +#id = 14839 + +[evil_oak_tree] +id = 11437 +examine = "His bite looks tougher than his bark." + +[evil_oak_tree_stump] +id = 11438 +examine = "His bite looks tougher than his bark." + +[evil_oak_tree_2] +id = 11438 +examine = "His bite looks tougher than his bark." + +[evil_oak_tree_2_stump] +id = 11439 +examine = "His bite looks tougher than his bark." + +[evil_oak_tree_three] +id = 11439 +examine = "His bite looks tougher than his bark." + +[evil_oak_tree_three_stump] +id = 11926 + +[evil_willow_tree] +id = 11440 +examine = "He'll weep when he's done being evil!" + +[evil_willow_tree_stump] +id = 11441 +examine = "He'll weep when he's done being evil!" + +[evil_willow_tree_2] +id = 11441 +examine = "He'll weep when he's done being evil!" + +[evil_willow_tree_2_stump] +id = 11442 +examine = "He'll weep when he's done being evil!" + +[evil_willow_tree_3] +id = 11442 +examine = "He'll weep when he's done being evil!" + +[evil_willow_tree_3_stump] +id = 11927 + +[evil_maple_tree] +id = 11443 +examine = "Where did his red leaves go?" + +[evil_maple_tree_stump] +id = 11444 +examine = "Where did his red leaves go?" + +[evil_maple_tree_2] +id = 11444 +examine = "Where did his red leaves go?" + +[evil_maple_tree_2_stump] +id = 11915 +examine = "Where did his red leaves go?" + +[dead_tree_18] +id = 11510 +examine = "This tree has long been dead." + +[evil_maple_tree_3] +id = 11915 +examine = "Where did his red leaves go?" + +[evil_maple_tree_3_stump] +id = 11928 + +[evil_yew_tree] +id = 11916 +examine = "A tough evil tree!" + +[evil_yew_tree_stump] +id = 11917 +examine = "A tough evil tree!" + +[evil_yew_tree_2] +id = 11917 +examine = "A tough evil tree!" + +[evil_yew_tree_2_stump] +id = 11918 +examine = "A tough evil tree!" + +[evil_yew_tree_3] +id = 11918 +examine = "A tough evil tree!" + +[evil_yew_tree_3_stump] +id = 11929 + +[evil_magic_tree] +id = 11919 +examine = "An incredibly tough challenge!" + +[evil_magic_tree_stump] +id = 11920 +examine = "An incredibly tough challenge!" + +[evil_magic_tree_2] +id = 11920 +examine = "An incredibly tough challenge!" + +[evil_magic_tree_2_stump] +id = 11921 +examine = "An incredibly tough challenge!" + +[evil_magic_tree_3] +id = 11921 +examine = "An incredibly tough challenge!" + +[evil_magic_tree_3_stump] +id = 12711 + +[evil_elder_tree] +id = 11922 +examine = "The most fearsome of evil trees!" + +[evil_elder_tree_stump] +id = 11923 +examine = "The most fearsome of evil trees!" + +[evil_elder_tree_2] +id = 11923 +examine = "The most fearsome of evil trees!" + +[evil_elder_tree_2_stump] +id = 11924 +examine = "The most fearsome of evil trees!" + +[evil_elder_tree_3] +id = 11924 +examine = "The most fearsome of evil trees!" + +[evil_elder_tree_3_stump] +id = 12712 diff --git a/data/activity/evil_tree/evil_tree.tables.toml b/data/activity/evil_tree/evil_tree.tables.toml new file mode 100644 index 0000000000..89630b43ef --- /dev/null +++ b/data/activity/evil_tree/evil_tree.tables.toml @@ -0,0 +1,202 @@ +[evil_tree_type] +woodcutting = "int" +farming = "int" +nurture_xp = "int" +burn_xp = "int" +root_xp = "int" +tree_xp = "int" +buff = "int" + +[normal] +woodcutting = 1 +farming = 1 +nurture_xp = 200 +burn_xp = 2000 +root_xp = 30 +tree_xp = 30 +buff = 6 + +[oak] +woodcutting = 15 +farming = 7 +nurture_xp = 450 +burn_xp = 3000 +root_xp = 64 +tree_xp = 64 +buff = 10 + +[willow] +woodcutting = 30 +farming = 15 +nurture_xp = 660 +burn_xp = 4500 +root_xp = 91 +tree_xp = 91 +buff = 14 + +[maple] +woodcutting = 45 +farming = 22 +nurture_xp = 1215 +burn_xp = 6750 +root_xp = 111 +tree_xp = 111 +buff = 18 + +[yew] +woodcutting = 60 +farming = 30 +nurture_xp = 1725 +burn_xp = 10125 +root_xp = 175 +tree_xp = 175 +buff = 22 + +[magic] +woodcutting = 75 +farming = 37 +nurture_xp = 3115 +burn_xp = 15175 +root_xp = 250 +tree_xp = 250 +buff = 26 + +[elder] +woodcutting = 90 +farming = 42 +nurture_xp = 7300 +burn_xp = 26005 +root_xp = 325 +tree_xp = 325 +buff = 30 + + +[evil_tree_place] +hint = "string" +tile = "list" + +[.yew] +hint = "Close to a collection of yew trees" +tiles = [ + { x = 2754, y = 3424 }, + { x = 3174, y = 3211 }, +] + +[.dungeon] +hint = "Close to a dungeon, entrance
and within a tropical jungle" +tiles = [ + { x = 2760, y = 2697 }, + { x = 2737, y = 3157 }, +] + +[.willow] +hint = "Close to a large collection of
willow trees" +tiles = [ + { x = 2707, y = 3506 }, + { x = 3096, y = 3227 }, +] + +[.mine] +hint = "Close to a mine on the outskirts
of a city" +tiles = [ + { x = 2723, y = 3330 }, + { x = 2917, y = 3366 }, +] + +[.runecrafting] +hint = "Close to a Runecrafting altar" +tiles = [ + { x = 3050, y = 3456 }, + { x = 2830, y = 3013 }, +] + +[.legends] +hint = "Close to the home of 'Legends'" +tiles = [ + { x = 2720, y = 3407 }, + { x = 2723, y = 3330 }, +] + +[.yanille] +hint = "Close to the town you call Yanille" +tiles = [ + { x = 2602, y = 3117 }, + { x = 2520, y = 3102 }, +] + +[.seers] +hint = "Close to the village you call 'Seers'" +tiles = [ + { x = 2664, y = 3486 }, + { x = 2707, y = 3506 }, +] + +[.draynor] +hint = "Close to the village you humans call 'Draynor'" +tiles = [ + { x = 3092, y = 3312 }, + { x = 3096, y = 3227 }, +] + +[.lumbridge] +hint = "Due west of the town you call Lumbridge" +tiles = [ + { x = 3096, y = 3227 }, + { x = 3174, y = 3211 }, +] + +[.ogre] +hint = "In a location with rare trees
and ogres nearby" +tiles = [ + { x = 2375, y = 3054 }, + { x = 2467, y = 2841 }, +] + +[.elves] +hint = "In the lands inhabited by elves" +tiles = [ + { x = 2212, y = 3174 }, + { x = 2290, y = 3122 }, +] + +[.varrock] +hint = "Just outside of the city
you call Varrock" +tiles = [ + { x = 3320, y = 3457 }, + { x = 3220, y = 3511 }, +] + +[.rellekka] +hint = "North as the crow flies
from Seers' Village" +tiles = [ + { x = 2702, y = 3640 }, + { x = 2707, y = 3506 }, +] + +[.ardougne] +hint = "North as the crow flies
from the market of Ardougne" +tiles = [ + { x = 2664, y = 3486 }, + { x = 2653, y = 3415 }, +] + +[.karamja] +hint = "On the island known as Karamja" +tiles = [ + { x = 2913, y = 2897 }, + { x = 2830, y = 3013 }, +] + +[.island] +hint = "On the southern coast of a tropical island" +tiles = [ + { x = 2760, y = 2697 }, + { x = 2913, y = 2897 }, +] + +[.gnome] +hint = "To the south of a tree
gnome settlement" +tiles = [ + { x = 2452, y = 3346 }, + { x = 2520, y = 3102 }, +] diff --git a/data/entity/obj/trees.objs.toml b/data/entity/obj/trees.objs.toml index 545a8700d4..64f878f9d8 100644 --- a/data/entity/obj/trees.objs.toml +++ b/data/entity/obj/trees.objs.toml @@ -627,172 +627,6 @@ examine = "This tree has been cut down." id = 11112 examine = "This tree has long been dead." -[evil_tree] -id = 11434 -upgrade_time = 50 -examine = "This looks like a challenge anyone can deal with." - -[evil_tree_stump] -id = 11435 -examine = "This looks like a challenge anyone can deal with." - -[evil_tree_2] -id = 11435 -examine = "This looks like a challenge anyone can deal with." - -[evil_tree_2_stump] -id = 11436 -examine = "This looks like a challenge anyone can deal with." - -[evil_tree_3] -id = 11436 -examine = "This looks like a challenge anyone can deal with." - -[evil_tree_3_stump] -id = 11925 - -[evil_oak_tree] -id = 11437 -examine = "His bite looks tougher than his bark." - -[evil_oak_tree_stump] -id = 11438 -examine = "His bite looks tougher than his bark." - -[evil_oak_tree_2] -id = 11438 -examine = "His bite looks tougher than his bark." - -[evil_oak_tree_2_stump] -id = 11439 -examine = "His bite looks tougher than his bark." - -[evil_oak_tree_three] -id = 11439 -examine = "His bite looks tougher than his bark." - -[evil_oak_tree_three_stump] -id = 11926 - -[evil_willow_tree] -id = 11440 -examine = "He'll weep when he's done being evil!" - -[evil_willow_tree_stump] -id = 11441 -examine = "He'll weep when he's done being evil!" - -[evil_willow_tree_2] -id = 11441 -examine = "He'll weep when he's done being evil!" - -[evil_willow_tree_2_stump] -id = 11442 -examine = "He'll weep when he's done being evil!" - -[evil_willow_tree_3] -id = 11442 -examine = "He'll weep when he's done being evil!" - -[evil_willow_tree_3_stump] -id = 11927 - -[evil_maple_tree] -id = 11443 -examine = "Where did his red leaves go?" - -[evil_maple_tree_stump] -id = 11444 -examine = "Where did his red leaves go?" - -[evil_maple_tree_2] -id = 11444 -examine = "Where did his red leaves go?" - -[evil_maple_tree_2_stump] -id = 11915 -examine = "Where did his red leaves go?" - -[dead_tree_18] -id = 11510 -examine = "This tree has long been dead." - -[evil_maple_tree_3] -id = 11915 -examine = "Where did his red leaves go?" - -[evil_maple_tree_3_stump] -id = 11928 - -[evil_yew_tree] -id = 11916 -examine = "A tough evil tree!" - -[evil_yew_tree_stump] -id = 11917 -examine = "A tough evil tree!" - -[evil_yew_tree_2] -id = 11917 -examine = "A tough evil tree!" - -[evil_yew_tree_2_stump] -id = 11918 -examine = "A tough evil tree!" - -[evil_yew_tree_3] -id = 11918 -examine = "A tough evil tree!" - -[evil_yew_tree_3_stump] -id = 11929 - -[evil_magic_tree] -id = 11919 -examine = "An incredibly tough challenge!" - -[evil_magic_tree_stump] -id = 11920 -examine = "An incredibly tough challenge!" - -[evil_magic_tree_2] -id = 11920 -examine = "An incredibly tough challenge!" - -[evil_magic_tree_2_stump] -id = 11921 -examine = "An incredibly tough challenge!" - -[evil_magic_tree_3] -id = 11921 -examine = "An incredibly tough challenge!" - -[evil_magic_tree_3_stump] -id = 12711 - -[elder_evil_tree] -id = 11922 -examine = "The most fearsome of evil trees!" - -[elder_evil_tree_stump] -id = 11923 -examine = "The most fearsome of evil trees!" - -[elder_evil_tree_2] -id = 11923 -examine = "The most fearsome of evil trees!" - -[elder_evil_tree_2_stump] -id = 11924 -examine = "The most fearsome of evil trees!" - -[elder_evil_tree_3] -id = 11924 -examine = "The most fearsome of evil trees!" - -[elder_evil_tree_3_stump] -id = 12712 - [oak_tree_pale] clone = "oak" id = 11999 From 571457cc267cffc002d98065c9c7e55203bc5c58 Mon Sep 17 00:00:00 2001 From: GregHib Date: Sat, 2 May 2026 20:55:38 +0100 Subject: [PATCH 02/11] Fix evil tree data --- data/activity/evil_tree/evil_tree.tables.toml | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/data/activity/evil_tree/evil_tree.tables.toml b/data/activity/evil_tree/evil_tree.tables.toml index 89630b43ef..e0eefcd656 100644 --- a/data/activity/evil_tree/evil_tree.tables.toml +++ b/data/activity/evil_tree/evil_tree.tables.toml @@ -7,7 +7,7 @@ root_xp = "int" tree_xp = "int" buff = "int" -[normal] +[.normal] woodcutting = 1 farming = 1 nurture_xp = 200 @@ -16,7 +16,7 @@ root_xp = 30 tree_xp = 30 buff = 6 -[oak] +[.oak] woodcutting = 15 farming = 7 nurture_xp = 450 @@ -25,7 +25,7 @@ root_xp = 64 tree_xp = 64 buff = 10 -[willow] +[.willow] woodcutting = 30 farming = 15 nurture_xp = 660 @@ -34,7 +34,7 @@ root_xp = 91 tree_xp = 91 buff = 14 -[maple] +[.maple] woodcutting = 45 farming = 22 nurture_xp = 1215 @@ -43,7 +43,7 @@ root_xp = 111 tree_xp = 111 buff = 18 -[yew] +[.yew] woodcutting = 60 farming = 30 nurture_xp = 1725 @@ -52,7 +52,7 @@ root_xp = 175 tree_xp = 175 buff = 22 -[magic] +[.magic] woodcutting = 75 farming = 37 nurture_xp = 3115 @@ -61,7 +61,7 @@ root_xp = 250 tree_xp = 250 buff = 26 -[elder] +[.elder] woodcutting = 90 farming = 42 nurture_xp = 7300 @@ -73,7 +73,7 @@ buff = 30 [evil_tree_place] hint = "string" -tile = "list" +tiles = "list" [.yew] hint = "Close to a collection of yew trees" From 558e2ee8652c8efd9a05a38e1840c7bd8eafe365 Mon Sep 17 00:00:00 2001 From: GregHib Date: Mon, 4 May 2026 22:54:57 +0100 Subject: [PATCH 03/11] WIP --- data/activity/evil_tree/evil_tree.objs.toml | 198 +++++++++++------- data/activity/evil_tree/evil_tree.tables.toml | 21 ++ .../content/activity/evil_tree/EvilTree.kt | 109 ++++++++++ 3 files changed, 251 insertions(+), 77 deletions(-) create mode 100644 game/src/main/kotlin/content/activity/evil_tree/EvilTree.kt diff --git a/data/activity/evil_tree/evil_tree.objs.toml b/data/activity/evil_tree/evil_tree.objs.toml index 848ebae63b..1345f4a534 100644 --- a/data/activity/evil_tree/evil_tree.objs.toml +++ b/data/activity/evil_tree/evil_tree.objs.toml @@ -1,3 +1,23 @@ +[evil_tree_seedling] +id = 11391 +examine = "A small seedling emerging from the ground." + +[evil_tree_sapling] +id = 11392 +examine = "A strange sapling emerging from the ground." + +[evil_tree_sapling_large] +id = 11393 +examine = "A fast-growing sapling emerging from the ground." + +[evil_tree_young] +id = 11394 +examine = "A young tree emerging from the ground." + +[evil_tree_young_large] +id = 11395 +examine = "A fast-growing sapling emerging from the ground." + [evil_tree_normal_full] id = 11434 upgrade_time = 50 @@ -15,149 +35,173 @@ examine = "This looks like a challenge anyone can deal with." id = 11925 [evil_tree_normal_stump] +id = 14846 + +[evil_tree_normal_stump_blank] id = 12713 +examine = "It looks better dead than alive." -#[evil_tree_normal_stump] -#id = 14839 +[evil_tree_normal_stump_reward] +id = 14839 +examine = "Looks like there's rewards to be had!" -[evil_oak_tree] +[evil_tree_oak_full] id = 11437 examine = "His bite looks tougher than his bark." -[evil_oak_tree_stump] +[evil_tree_oak_half] id = 11438 examine = "His bite looks tougher than his bark." -[evil_oak_tree_2] -id = 11438 -examine = "His bite looks tougher than his bark." - -[evil_oak_tree_2_stump] -id = 11439 -examine = "His bite looks tougher than his bark." - -[evil_oak_tree_three] +[evil_tree_oak_weak] id = 11439 examine = "His bite looks tougher than his bark." -[evil_oak_tree_three_stump] +[evil_tree_oak_death] id = 11926 -[evil_willow_tree] -id = 11440 -examine = "He'll weep when he's done being evil!" +[evil_tree_oak_stump] +id = 14847 -[evil_willow_tree_stump] -id = 11441 -examine = "He'll weep when he's done being evil!" +[evil_tree_oak_stump_blank] +id = 12714 +examine = "It looks better dead than alive." -[evil_willow_tree_2] -id = 11441 +[evil_tree_oak_stump_reward] +id = 14840 +examine = "Looks like there's rewards to be had!" + +[evil_tree_willow_full] +id = 11440 examine = "He'll weep when he's done being evil!" -[evil_willow_tree_2_stump] -id = 11442 +[evil_tree_willow_half] +id = 11441 examine = "He'll weep when he's done being evil!" -[evil_willow_tree_3] +[evil_tree_willow_weak] id = 11442 examine = "He'll weep when he's done being evil!" -[evil_willow_tree_3_stump] +[evil_tree_willow_death] id = 11927 -[evil_maple_tree] +[evil_tree_willow_stump] +id = 14848 + +[evil_tree_willow_stump_blank] +id = 12715 +examine = "It looks better dead than alive." + +[evil_tree_willow_stump_reward] +id = 14841 +examine = "Looks like there's rewards to be had!" + +[evil_tree_maple_full] id = 11443 examine = "Where did his red leaves go?" -[evil_maple_tree_stump] +[evil_tree_maple_half] id = 11444 examine = "Where did his red leaves go?" -[evil_maple_tree_2] -id = 11444 +[evil_tree_maple_weak] +id = 11445 examine = "Where did his red leaves go?" -[evil_maple_tree_2_stump] -id = 11915 -examine = "Where did his red leaves go?" +[evil_tree_maple_death] +id = 11928 -[dead_tree_18] -id = 11510 -examine = "This tree has long been dead." +[evil_tree_maple_stump] +id = 14849 -[evil_maple_tree_3] -id = 11915 -examine = "Where did his red leaves go?" +[evil_tree_maple_stump_blank] +id = 14835 +examine = "It looks better dead than alive." -[evil_maple_tree_3_stump] -id = 11928 +[evil_tree_maple_stump_reward] +id = 14842 +examine = "Looks like there's rewards to be had!" -[evil_yew_tree] +[evil_tree_yew_full] id = 11916 examine = "A tough evil tree!" -[evil_yew_tree_stump] +[evil_tree_yew_half] id = 11917 examine = "A tough evil tree!" -[evil_yew_tree_2] -id = 11917 -examine = "A tough evil tree!" - -[evil_yew_tree_2_stump] -id = 11918 -examine = "A tough evil tree!" - -[evil_yew_tree_3] +[evil_tree_yew_weak] id = 11918 examine = "A tough evil tree!" -[evil_yew_tree_3_stump] +[evil_tree_yew_death] id = 11929 -[evil_magic_tree] -id = 11919 -examine = "An incredibly tough challenge!" +[evil_tree_yew_stump] +id = 14883 -[evil_magic_tree_stump] -id = 11920 -examine = "An incredibly tough challenge!" +[evil_tree_yew_stump_blank] +id = 14836 +examine = "It looks better dead than alive." -[evil_magic_tree_2] -id = 11920 +[evil_tree_yew_stump_reward] +id = 14843 +examine = "Looks like there's rewards to be had!" + +[evil_tree_magic_full] +id = 11919 examine = "An incredibly tough challenge!" -[evil_magic_tree_2_stump] -id = 11921 +[evil_tree_magic_half] +id = 11920 examine = "An incredibly tough challenge!" -[evil_magic_tree_3] +[evil_tree_magic_weak] id = 11921 examine = "An incredibly tough challenge!" -[evil_magic_tree_3_stump] +[evil_tree_magic_death] id = 12711 -[evil_elder_tree] -id = 11922 -examine = "The most fearsome of evil trees!" +[evil_tree_magic_stump] +id = 14884 -[evil_elder_tree_stump] -id = 11923 -examine = "The most fearsome of evil trees!" +[evil_tree_magic_stump_blank] +id = 14837 +examine = "It looks better dead than alive." -[evil_elder_tree_2] -id = 11923 +[evil_tree_magic_stump_reward] +id = 14844 +examine = "Looks like there's rewards to be had!" + +[evil_tree_elder_full] +id = 11922 examine = "The most fearsome of evil trees!" -[evil_elder_tree_2_stump] -id = 11924 +[evil_tree_elder_half] +id = 11923 examine = "The most fearsome of evil trees!" -[evil_elder_tree_3] +[evil_tree_elder_weak] id = 11924 examine = "The most fearsome of evil trees!" -[evil_elder_tree_3_stump] +[evil_tree_elder_death] id = 12712 + +[evil_tree_elder_stump] +id = 14885 + +[evil_tree_elder_stump_blank] +id = 14838 +examine = "It looks better dead than alive." + +[evil_tree_elder_stump_reward] +id = 14845 +examine = "Looks like there's rewards to be had!" + +[evil_branches_west_spawn] +id = 11427 + +[] \ No newline at end of file diff --git a/data/activity/evil_tree/evil_tree.tables.toml b/data/activity/evil_tree/evil_tree.tables.toml index e0eefcd656..ef1f88fb36 100644 --- a/data/activity/evil_tree/evil_tree.tables.toml +++ b/data/activity/evil_tree/evil_tree.tables.toml @@ -6,6 +6,7 @@ burn_xp = "int" root_xp = "int" tree_xp = "int" buff = "int" +health = "int" [.normal] woodcutting = 1 @@ -15,6 +16,7 @@ burn_xp = 2000 root_xp = 30 tree_xp = 30 buff = 6 +health = 150 [.oak] woodcutting = 15 @@ -24,6 +26,7 @@ burn_xp = 3000 root_xp = 64 tree_xp = 64 buff = 10 +health = 300 [.willow] woodcutting = 30 @@ -33,6 +36,7 @@ burn_xp = 4500 root_xp = 91 tree_xp = 91 buff = 14 +health = 450 [.maple] woodcutting = 45 @@ -42,6 +46,7 @@ burn_xp = 6750 root_xp = 111 tree_xp = 111 buff = 18 +health = 600 [.yew] woodcutting = 60 @@ -51,6 +56,7 @@ burn_xp = 10125 root_xp = 175 tree_xp = 175 buff = 22 +health = 750 [.magic] woodcutting = 75 @@ -60,6 +66,7 @@ burn_xp = 15175 root_xp = 250 tree_xp = 250 buff = 26 +health = 750 [.elder] woodcutting = 90 @@ -69,6 +76,7 @@ burn_xp = 26005 root_xp = 325 tree_xp = 325 buff = 30 +health = 900 [evil_tree_place] @@ -200,3 +208,16 @@ tiles = [ { x = 2452, y = 3346 }, { x = 2520, y = 3102 }, ] + +[evil_branches] +tile = "tile" +spawn = "obj" +fight = "obj" + +[.west] +tile = { x = -1, y = 1 } +spawn = 11427 +11427 +11429 { x = 1, y = -1 } +11431 { x = 3, y = 1 } +11433 { x = 1, y = 3 } diff --git a/game/src/main/kotlin/content/activity/evil_tree/EvilTree.kt b/game/src/main/kotlin/content/activity/evil_tree/EvilTree.kt new file mode 100644 index 0000000000..5f401dfa3b --- /dev/null +++ b/game/src/main/kotlin/content/activity/evil_tree/EvilTree.kt @@ -0,0 +1,109 @@ +package content.activity.evil_tree + +import com.github.michaelbull.logging.InlineLogger +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.client.message +import world.gregs.voidps.engine.client.ui.chat.Colours +import world.gregs.voidps.engine.client.ui.chat.toTag +import world.gregs.voidps.engine.data.Settings +import world.gregs.voidps.engine.data.definition.Tables +import world.gregs.voidps.engine.entity.character.player.Players +import world.gregs.voidps.engine.entity.obj.GameObject +import world.gregs.voidps.engine.entity.obj.GameObjects +import world.gregs.voidps.engine.entity.obj.replace +import world.gregs.voidps.engine.map.Spiral +import world.gregs.voidps.type.Direction +import world.gregs.voidps.type.Tile +import world.gregs.voidps.type.random + +class EvilTree : Script { + + val logger = InlineLogger() + + var tree: GameObject = GameObject(0) + var spawnTile: Tile = Tile.EMPTY + var type: String = "normal" + + init { + worldSpawn { + if (Settings["events.evilTree.enabled", false]) { + start() + } + } + + settingsReload { + if (Settings["events.evilTree.enabled", false] && spawnTile == Tile.EMPTY) { + start() + } else if (!Settings["events.evilTree.enabled", false]) { + clear() + } + } + + objectOperate("Nurture", "evil_tree_seedling,evil_tree_sapling*,evil_tree_young*") { (target) -> + nextStage(target) + } + + objectOperate("Chop", "evil_tree_*") { (target) -> + nextStage(target) + } + } + + private fun nextStage(target: GameObject) { + tree = when { + target.id == "evil_tree_seedling" -> target.replace("evil_tree_sapling", spawnTile.add(1, 1)) + target.id == "evil_tree_sapling" -> target.replace("evil_tree_sapling_large", spawnTile.add(1, 1)) + target.id == "evil_tree_sapling_large" -> { + val centre = spawnTile.add(1, 1) + for (player in Players.at(centre)) { + player.walkTo(centre.add(Direction.SOUTH).add(Direction.SOUTH)) + } + for (tile in Spiral.spiral(centre, 1)) { + for (player in Players.at(tile)) { + val dir = player.tile.delta(centre).toDirection() + player.walkTo(player.tile.add(dir)) + } + } + target.replace("evil_tree_young", spawnTile) + } + target.id == "evil_tree_young" -> target.replace("evil_tree_young_large", spawnTile) + target.id == "evil_tree_young_large" -> target.replace("evil_tree_${type}_full") + target.id.endsWith("full") -> target.replace(target.id.replace("_full", "_half")) + target.id.endsWith("half") -> target.replace(target.id.replace("_half", "_weak")) + target.id.endsWith("weak") -> target.replace(target.id.replace("_weak", "_stump")) + target.id.endsWith("stump") -> target.replace(target.id.replace("_stump", "_death")) + else -> return + } + } + + fun clear() { + GameObjects.remove(tree) + tree = GameObject(0) + spawnTile = Tile.EMPTY + type = "normal" + } + + fun start() { + if (spawnTile != Tile.EMPTY) { + return + } + val place = Tables.get("evil_tree_place").rows().random(random) + if (Settings["world.messages", false]) { + for (player in Players) { + val hint = place.string("hint").replaceFirstChar { it.lowercase() }.replace("
", " ") + player.message("${Colours.DARK_RED.toTag()}An evil tree has spawned $hint.") + } + } + spawnTile = place.tileList("tiles").random(random) + val type = Tables.get("evil_tree_type").rows().random(random) + logger.info { "Evil tree event has started at: ${place.rowId} (${spawnTile.x}, ${spawnTile.y}) type ${type.rowId}." } + this.type = type.rowId + val centre = spawnTile.add(1, 1) + tree = GameObjects.add("evil_tree_seedling", centre) + // Push players out + for (player in Players.at(centre)) { + player.walkTo(centre.add(Direction.cardinal.random(random))) + } + + + } +} \ No newline at end of file From 40be43f87bb7183cff6f451c5fffadc116a17186 Mon Sep 17 00:00:00 2001 From: GregHib Date: Wed, 6 May 2026 18:18:03 +0100 Subject: [PATCH 04/11] Add lots of missing messages from rs3 --- .../area/misthalin/lumbridge/swamp/chams_of_tears/Juna.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/game/src/main/kotlin/content/area/misthalin/lumbridge/swamp/chams_of_tears/Juna.kt b/game/src/main/kotlin/content/area/misthalin/lumbridge/swamp/chams_of_tears/Juna.kt index 652feee85e..d1a25ff03a 100644 --- a/game/src/main/kotlin/content/area/misthalin/lumbridge/swamp/chams_of_tears/Juna.kt +++ b/game/src/main/kotlin/content/area/misthalin/lumbridge/swamp/chams_of_tears/Juna.kt @@ -8,6 +8,7 @@ import content.entity.player.dialogue.type.player import content.entity.player.dialogue.type.statement import content.entity.player.modal.Tab import content.entity.player.modal.tab +import content.quest.clearInstance import content.quest.closeTabs import content.quest.openTabs import content.quest.quest From 02cbef2115ae9250e537555aa98940a9b4bd85e3 Mon Sep 17 00:00:00 2001 From: GregHib Date: Thu, 7 May 2026 20:52:53 +0100 Subject: [PATCH 05/11] Evil tree wip --- data/activity/evil_tree/evil_tree.objs.toml | 26 ++++- data/activity/evil_tree/evil_tree.tables.toml | 98 +++++++++++++++++-- .../activity/evil_tree/evil_tree.varbits.toml | 4 + .../content/activity/evil_tree/EvilTree.kt | 35 ++++++- 4 files changed, 151 insertions(+), 12 deletions(-) diff --git a/data/activity/evil_tree/evil_tree.objs.toml b/data/activity/evil_tree/evil_tree.objs.toml index 1345f4a534..5731059efc 100644 --- a/data/activity/evil_tree/evil_tree.objs.toml +++ b/data/activity/evil_tree/evil_tree.objs.toml @@ -106,7 +106,7 @@ id = 11444 examine = "Where did his red leaves go?" [evil_tree_maple_weak] -id = 11445 +id = 11915 examine = "Where did his red leaves go?" [evil_tree_maple_death] @@ -201,7 +201,29 @@ examine = "It looks better dead than alive." id = 14845 examine = "Looks like there's rewards to be had!" +[evil_branches_west] +id = 11426 + [evil_branches_west_spawn] id = 11427 -[] \ No newline at end of file +[evil_branches_south] +id = 11428 + +[evil_branches_south_spawn] +id = 11429 + +[evil_branches_east] +id = 11430 + +[evil_branches_east_spawn] +id = 11431 + +[evil_branches_north] +id = 11432 + +[evil_branches_north_spawn] +id = 11433 + +[evil_tree_fire] +id = 11425 diff --git a/data/activity/evil_tree/evil_tree.tables.toml b/data/activity/evil_tree/evil_tree.tables.toml index ef1f88fb36..1d3a735b7e 100644 --- a/data/activity/evil_tree/evil_tree.tables.toml +++ b/data/activity/evil_tree/evil_tree.tables.toml @@ -210,14 +210,96 @@ tiles = [ ] [evil_branches] -tile = "tile" +deltaX = "int" +deltaY = "int" spawn = "obj" -fight = "obj" +dir = "int" [.west] -tile = { x = -1, y = 1 } -spawn = 11427 -11427 -11429 { x = 1, y = -1 } -11431 { x = 3, y = 1 } -11433 { x = 1, y = 3 } +deltaX = -1 +deltaY = 1 +spawn = "evil_branches_west_spawn" +dir = 0 + +[.south] +deltaX = 1 +deltaY = -1 +spawn = "evil_branches_south_spawn" +dir = 3 + +[.east] +deltaX = 3 +deltaY = 1 +spawn = "evil_branches_east_spawn" +dir = 2 + +[.north] +deltaX = 1 +deltaY = 3 +spawn = "evil_branches_north_spawn" +dir = 1 + +[evil_fires] +deltaX = "int" +deltaY = "int" +dir = "int" + +[.zero] +deltaX = -1 +deltaY = 1 +dir = 2 + +[.one] +deltaX = -1 +deltaY = 0 +dir = 2 + +[.two] +deltaX = 0 +deltaY = -1 +dir = 1 + +[.three] +deltaX = 1 +deltaY = -1 +dir = 1 + +[.four] +deltaX = 2 +deltaY = -1 +dir = 1 + +[.five] +deltaX = 3 +deltaY = 0 +dir = 0 + +[.six] +deltaX = 3 +deltaY = 1 +dir = 0 + +[.seven] +deltaX = 3 +deltaY = 2 +dir = 0 + +[.eight] +deltaX = 2 +deltaY = 3 +dir = 3 + +[.nine] +deltaX = 1 +deltaY = 3 +dir = 3 + +[.ten] +deltaX = 0 +deltaY = 3 +dir = 3 + +[.eleven] +deltaX = -1 +deltaY = 2 +dir = 2 diff --git a/data/activity/evil_tree/evil_tree.varbits.toml b/data/activity/evil_tree/evil_tree.varbits.toml index d756e014e3..82b7b99714 100644 --- a/data/activity/evil_tree/evil_tree.varbits.toml +++ b/data/activity/evil_tree/evil_tree.varbits.toml @@ -2,3 +2,7 @@ id = 1545 format = "boolean" persist = true + +[evil_tree_rewards] +id = 1542 +format = "boolean" \ No newline at end of file diff --git a/game/src/main/kotlin/content/activity/evil_tree/EvilTree.kt b/game/src/main/kotlin/content/activity/evil_tree/EvilTree.kt index 5f401dfa3b..896b799889 100644 --- a/game/src/main/kotlin/content/activity/evil_tree/EvilTree.kt +++ b/game/src/main/kotlin/content/activity/evil_tree/EvilTree.kt @@ -23,6 +23,7 @@ class EvilTree : Script { var tree: GameObject = GameObject(0) var spawnTile: Tile = Tile.EMPTY var type: String = "normal" + val roots = arrayOfNulls(4) init { worldSpawn { @@ -46,6 +47,15 @@ class EvilTree : Script { objectOperate("Chop", "evil_tree_*") { (target) -> nextStage(target) } + + objectDespawn("evil_branches_*_spawn") { + for (root in roots) { + root ?: continue + root.replace(root.id.removeSuffix("_spawn")) + } + } + + // TODO controller on leprechaun to do branch attacks } private fun nextStage(target: GameObject) { @@ -66,20 +76,41 @@ class EvilTree : Script { target.replace("evil_tree_young", spawnTile) } target.id == "evil_tree_young" -> target.replace("evil_tree_young_large", spawnTile) - target.id == "evil_tree_young_large" -> target.replace("evil_tree_${type}_full") + target.id == "evil_tree_young_large" -> { + var index = 0 + for (branch in Tables.get("evil_branches").rows()) { + val spawn = branch.obj("spawn") + val dir = branch.int("dir") + val tile = target.tile.add(branch.int("deltaX"), branch.int("deltaY")) + roots[index++] = GameObjects.add(spawn, tile, rotation = dir, ticks = 2) + } + target.replace("evil_tree_${type}_full") + } target.id.endsWith("full") -> target.replace(target.id.replace("_full", "_half")) target.id.endsWith("half") -> target.replace(target.id.replace("_half", "_weak")) target.id.endsWith("weak") -> target.replace(target.id.replace("_weak", "_stump")) - target.id.endsWith("stump") -> target.replace(target.id.replace("_stump", "_death")) + target.id.endsWith("stump") -> { + clearRoots() + target.replace(target.id.replace("_stump", "_death")) + } else -> return } } + fun clear() { GameObjects.remove(tree) tree = GameObject(0) spawnTile = Tile.EMPTY type = "normal" + clearRoots() + } + + private fun clearRoots() { + for (root in roots) { + GameObjects.remove(root ?: continue) + } + roots.fill(null) } fun start() { From 9a4b4f29df2b1c772a59de90645847c7cb77c728 Mon Sep 17 00:00:00 2001 From: GregHib Date: Fri, 8 May 2026 15:48:37 +0100 Subject: [PATCH 06/11] WIP --- data/activity/evil_tree/evil_tree.anims.toml | 2 + data/activity/evil_tree/evil_tree.npcs.toml | 6 + data/activity/evil_tree/evil_tree.tables.toml | 9 +- .../content/activity/evil_tree/EvilTree.kt | 118 +++++++++++++++--- 4 files changed, 120 insertions(+), 15 deletions(-) create mode 100644 data/activity/evil_tree/evil_tree.anims.toml create mode 100644 data/activity/evil_tree/evil_tree.npcs.toml diff --git a/data/activity/evil_tree/evil_tree.anims.toml b/data/activity/evil_tree/evil_tree.anims.toml new file mode 100644 index 0000000000..c8e1267177 --- /dev/null +++ b/data/activity/evil_tree/evil_tree.anims.toml @@ -0,0 +1,2 @@ +[nurture_sapling] +id = 1132 diff --git a/data/activity/evil_tree/evil_tree.npcs.toml b/data/activity/evil_tree/evil_tree.npcs.toml new file mode 100644 index 0000000000..3ca37550aa --- /dev/null +++ b/data/activity/evil_tree/evil_tree.npcs.toml @@ -0,0 +1,6 @@ +[leprechaun_evil_tree] +id = 418 +wander_radius = 4 + +[leprechaun_panic] +id = 419 diff --git a/data/activity/evil_tree/evil_tree.tables.toml b/data/activity/evil_tree/evil_tree.tables.toml index 1d3a735b7e..da9efefc95 100644 --- a/data/activity/evil_tree/evil_tree.tables.toml +++ b/data/activity/evil_tree/evil_tree.tables.toml @@ -7,6 +7,7 @@ root_xp = "int" tree_xp = "int" buff = "int" health = "int" +seed_health = "int" [.normal] woodcutting = 1 @@ -17,6 +18,7 @@ root_xp = 30 tree_xp = 30 buff = 6 health = 150 +seed_health = 2 [.oak] woodcutting = 15 @@ -27,6 +29,7 @@ root_xp = 64 tree_xp = 64 buff = 10 health = 300 +seed_health = 4 [.willow] woodcutting = 30 @@ -37,6 +40,7 @@ root_xp = 91 tree_xp = 91 buff = 14 health = 450 +seed_health = 6 [.maple] woodcutting = 45 @@ -47,6 +51,7 @@ root_xp = 111 tree_xp = 111 buff = 18 health = 600 +seed_health = 8 [.yew] woodcutting = 60 @@ -57,6 +62,7 @@ root_xp = 175 tree_xp = 175 buff = 22 health = 750 +seed_health = 10 [.magic] woodcutting = 75 @@ -67,6 +73,7 @@ root_xp = 250 tree_xp = 250 buff = 26 health = 750 +seed_health = 12 [.elder] woodcutting = 90 @@ -77,7 +84,7 @@ root_xp = 325 tree_xp = 325 buff = 30 health = 900 - +seed_health = 14 [evil_tree_place] hint = "string" diff --git a/game/src/main/kotlin/content/activity/evil_tree/EvilTree.kt b/game/src/main/kotlin/content/activity/evil_tree/EvilTree.kt index 896b799889..a5559dc06c 100644 --- a/game/src/main/kotlin/content/activity/evil_tree/EvilTree.kt +++ b/game/src/main/kotlin/content/activity/evil_tree/EvilTree.kt @@ -1,17 +1,28 @@ package content.activity.evil_tree import com.github.michaelbull.logging.InlineLogger +import content.entity.effect.transform import world.gregs.voidps.engine.Script import world.gregs.voidps.engine.client.message import world.gregs.voidps.engine.client.ui.chat.Colours import world.gregs.voidps.engine.client.ui.chat.toTag import world.gregs.voidps.engine.data.Settings import world.gregs.voidps.engine.data.definition.Tables +import world.gregs.voidps.engine.entity.character.npc.NPC +import world.gregs.voidps.engine.entity.character.npc.NPCs +import world.gregs.voidps.engine.entity.character.player.Player import world.gregs.voidps.engine.entity.character.player.Players +import world.gregs.voidps.engine.entity.character.player.chat.ChatType +import world.gregs.voidps.engine.entity.character.player.skill.Skill +import world.gregs.voidps.engine.entity.character.player.skill.exp.exp +import world.gregs.voidps.engine.entity.character.player.skill.level.Level.has import world.gregs.voidps.engine.entity.obj.GameObject import world.gregs.voidps.engine.entity.obj.GameObjects import world.gregs.voidps.engine.entity.obj.replace import world.gregs.voidps.engine.map.Spiral +import world.gregs.voidps.engine.queue.weakQueue +import world.gregs.voidps.engine.suspend.awaitDialogues +import world.gregs.voidps.engine.timer.Timer import world.gregs.voidps.type.Direction import world.gregs.voidps.type.Tile import world.gregs.voidps.type.random @@ -24,8 +35,12 @@ class EvilTree : Script { var spawnTile: Tile = Tile.EMPTY var type: String = "normal" val roots = arrayOfNulls(4) + var health = 100 + var leprechaun = NPC() init { + // TODO spawn roots on players with 2x2 regions + worldSpawn { if (Settings["events.evilTree.enabled", false]) { start() @@ -40,8 +55,14 @@ class EvilTree : Script { } } + // TODO grows self every 1 minute objectOperate("Nurture", "evil_tree_seedling,evil_tree_sapling*,evil_tree_young*") { (target) -> - nextStage(target) + val level = Tables.intOrNull("evil_tree_type.${type}.farming") ?: return@objectOperate + if (!has(Skill.Farming, level, " to help this sapling grow")) { + return@objectOperate + } + message("You begin tending to the sapling.", ChatType.Filter) + nurture(target) } objectOperate("Chop", "evil_tree_*") { (target) -> @@ -55,14 +76,70 @@ class EvilTree : Script { } } - // TODO controller on leprechaun to do branch attacks + npcSpawn("leprechaun_evil_tree") { + softTimers.start("evil_tree_timer") + } + + timerStart("evil_tree_timer") { + 10 + } + + timerTick("evil_tree_timer") { + when (tree.id) { + "evil_tree_seedling", "evil_tree_sapling", "evil_tree_sapling_large", "evil_tree_young" + "evil_tree_young_large" -> takeHealth(10) + } + // TODO lightning strike every 30 mins + Timer.CONTINUE + } + // TODO light fires + } + + private fun takeHealth(amount: Int) { + health -= amount + if (health <= 0) { + nextStage(tree) + } + } + + fun Player.nurture(target: GameObject) { + if (tree != target) { + return + } + anim("nurture_sapling") + weakQueue("nurture_evil_sapling", 3) { + if (tree != target) { + return@weakQueue + } + exp(Skill.Farming, Tables.int("evil_tree_type.${type}.nurture_xp") / 10.0) + takeHealth(1) + nurture(target) + } + } + + suspend fun Player.chop(target: GameObject) { + while (awaitDialogues()) { + if (!GameObjects.contains(target)) { + return + } + + // TODO branch attacks when chopping root + } } private fun nextStage(target: GameObject) { tree = when { - target.id == "evil_tree_seedling" -> target.replace("evil_tree_sapling", spawnTile.add(1, 1)) - target.id == "evil_tree_sapling" -> target.replace("evil_tree_sapling_large", spawnTile.add(1, 1)) + target.id == "evil_tree_seedling" -> { + health = Tables.int("evil_tree_type.${type}.seed_health") + leprechaun.say("Whoa!") + target.replace("evil_tree_sapling", spawnTile.add(1, 1)) + } + target.id == "evil_tree_sapling" -> { + health = Tables.int("evil_tree_type.${type}.seed_health") + leprechaun.say("Whoa!") + target.replace("evil_tree_sapling_large", spawnTile.add(1, 1)) + } target.id == "evil_tree_sapling_large" -> { + health = Tables.int("evil_tree_type.${type}.seed_health") val centre = spawnTile.add(1, 1) for (player in Players.at(centre)) { player.walkTo(centre.add(Direction.SOUTH).add(Direction.SOUTH)) @@ -73,17 +150,17 @@ class EvilTree : Script { player.walkTo(player.tile.add(dir)) } } + leprechaun.say("Whoa!") target.replace("evil_tree_young", spawnTile) } - target.id == "evil_tree_young" -> target.replace("evil_tree_young_large", spawnTile) + target.id == "evil_tree_young" -> { + health = Tables.int("evil_tree_type.${type}.seed_health") + leprechaun.say("Whoa!") + target.replace("evil_tree_young_large", spawnTile) + } target.id == "evil_tree_young_large" -> { - var index = 0 - for (branch in Tables.get("evil_branches").rows()) { - val spawn = branch.obj("spawn") - val dir = branch.int("dir") - val tile = target.tile.add(branch.int("deltaX"), branch.int("deltaY")) - roots[index++] = GameObjects.add(spawn, tile, rotation = dir, ticks = 2) - } + health = Tables.int("evil_tree_type.${type}.health") + spawnEvilTree(target.tile) target.replace("evil_tree_${type}_full") } target.id.endsWith("full") -> target.replace(target.id.replace("_full", "_half")) @@ -91,18 +168,32 @@ class EvilTree : Script { target.id.endsWith("weak") -> target.replace(target.id.replace("_weak", "_stump")) target.id.endsWith("stump") -> { clearRoots() + // TODO respawn in 2 hours target.replace(target.id.replace("_stump", "_death")) } else -> return } } + private fun spawnEvilTree(target: Tile) { + var index = 0 + for (branch in Tables.get("evil_branches").rows()) { + val spawn = branch.obj("spawn") + val dir = branch.int("dir") + val tile = target.add(branch.int("deltaX"), branch.int("deltaY")) + roots[index++] = GameObjects.add(spawn, tile, rotation = dir, ticks = 2) + } + leprechaun.transform("leprechaun_panic") + leprechaun.say("Whoa!") + } + fun clear() { GameObjects.remove(tree) tree = GameObject(0) spawnTile = Tile.EMPTY type = "normal" + leprechaun = NPC() clearRoots() } @@ -134,7 +225,6 @@ class EvilTree : Script { for (player in Players.at(centre)) { player.walkTo(centre.add(Direction.cardinal.random(random))) } - - + leprechaun = NPCs.add("leprechaun_evil_tree", spawnTile.add(-1, -1)) } } \ No newline at end of file From fd66b8bfa76f39c4f1725a818c494939f05f2a4d Mon Sep 17 00:00:00 2001 From: GregHib Date: Sat, 9 May 2026 17:13:50 +0100 Subject: [PATCH 07/11] Add spirit tree dialogue --- data/activity/evil_tree/evil_tree.tables.toml | 36 +++--- .../content/activity/evil_tree/EvilTree.kt | 106 ++++++++++++++++-- .../quest/member/the_grand_tree/SpiritTree.kt | 58 +++++++--- 3 files changed, 157 insertions(+), 43 deletions(-) diff --git a/data/activity/evil_tree/evil_tree.tables.toml b/data/activity/evil_tree/evil_tree.tables.toml index da9efefc95..9258d1d197 100644 --- a/data/activity/evil_tree/evil_tree.tables.toml +++ b/data/activity/evil_tree/evil_tree.tables.toml @@ -91,126 +91,126 @@ hint = "string" tiles = "list" [.yew] -hint = "Close to a collection of yew trees" +hint = "close to a collection of yew trees" tiles = [ { x = 2754, y = 3424 }, { x = 3174, y = 3211 }, ] [.dungeon] -hint = "Close to a dungeon, entrance
and within a tropical jungle" +hint = "close to a dungeon, entrance
and within a tropical jungle" tiles = [ { x = 2760, y = 2697 }, { x = 2737, y = 3157 }, ] [.willow] -hint = "Close to a large collection of
willow trees" +hint = "close to a large collection of
willow trees" tiles = [ { x = 2707, y = 3506 }, { x = 3096, y = 3227 }, ] [.mine] -hint = "Close to a mine on the outskirts
of a city" +hint = "close to a mine on the outskirts
of a city" tiles = [ { x = 2723, y = 3330 }, { x = 2917, y = 3366 }, ] [.runecrafting] -hint = "Close to a Runecrafting altar" +hint = "close to a Runecrafting altar" tiles = [ { x = 3050, y = 3456 }, { x = 2830, y = 3013 }, ] [.legends] -hint = "Close to the home of 'Legends'" +hint = "close to the home of 'Legends'" tiles = [ { x = 2720, y = 3407 }, { x = 2723, y = 3330 }, ] [.yanille] -hint = "Close to the town you call Yanille" +hint = "close to the town you call Yanille" tiles = [ { x = 2602, y = 3117 }, { x = 2520, y = 3102 }, ] [.seers] -hint = "Close to the village you call 'Seers'" +hint = "close to the village you call 'Seers'" tiles = [ { x = 2664, y = 3486 }, { x = 2707, y = 3506 }, ] [.draynor] -hint = "Close to the village you humans call 'Draynor'" +hint = "close to the village you humans call 'Draynor'" tiles = [ { x = 3092, y = 3312 }, { x = 3096, y = 3227 }, ] [.lumbridge] -hint = "Due west of the town you call Lumbridge" +hint = "due west of the town you call Lumbridge" tiles = [ { x = 3096, y = 3227 }, { x = 3174, y = 3211 }, ] [.ogre] -hint = "In a location with rare trees
and ogres nearby" +hint = "in a location with rare trees
and ogres nearby" tiles = [ { x = 2375, y = 3054 }, { x = 2467, y = 2841 }, ] [.elves] -hint = "In the lands inhabited by elves" +hint = "in the lands inhabited by elves" tiles = [ { x = 2212, y = 3174 }, { x = 2290, y = 3122 }, ] [.varrock] -hint = "Just outside of the city
you call Varrock" +hint = "just outside of the city
you call Varrock" tiles = [ { x = 3320, y = 3457 }, { x = 3220, y = 3511 }, ] [.rellekka] -hint = "North as the crow flies
from Seers' Village" +hint = "north as the crow flies
from Seers' Village" tiles = [ { x = 2702, y = 3640 }, { x = 2707, y = 3506 }, ] [.ardougne] -hint = "North as the crow flies
from the market of Ardougne" +hint = "north as the crow flies
from the market of Ardougne" tiles = [ { x = 2664, y = 3486 }, { x = 2653, y = 3415 }, ] [.karamja] -hint = "On the island known as Karamja" +hint = "on the island known as Karamja" tiles = [ { x = 2913, y = 2897 }, { x = 2830, y = 3013 }, ] [.island] -hint = "On the southern coast of a tropical island" +hint = "on the southern coast of a tropical island" tiles = [ { x = 2760, y = 2697 }, { x = 2913, y = 2897 }, ] [.gnome] -hint = "To the south of a tree
gnome settlement" +hint = "to the south of a tree gnome settlement" tiles = [ { x = 2452, y = 3346 }, { x = 2520, y = 3102 }, diff --git a/game/src/main/kotlin/content/activity/evil_tree/EvilTree.kt b/game/src/main/kotlin/content/activity/evil_tree/EvilTree.kt index a5559dc06c..f35f091be5 100644 --- a/game/src/main/kotlin/content/activity/evil_tree/EvilTree.kt +++ b/game/src/main/kotlin/content/activity/evil_tree/EvilTree.kt @@ -2,11 +2,17 @@ package content.activity.evil_tree import com.github.michaelbull.logging.InlineLogger import content.entity.effect.transform +import content.entity.player.dialogue.type.statement +import content.skill.woodcutting.Hatchet +import net.pearx.kasechange.toCamelCase import world.gregs.voidps.engine.Script import world.gregs.voidps.engine.client.message import world.gregs.voidps.engine.client.ui.chat.Colours import world.gregs.voidps.engine.client.ui.chat.toTag +import world.gregs.voidps.engine.client.variable.remaining +import world.gregs.voidps.engine.client.variable.start import world.gregs.voidps.engine.data.Settings +import world.gregs.voidps.engine.data.definition.Rows import world.gregs.voidps.engine.data.definition.Tables import world.gregs.voidps.engine.entity.character.npc.NPC import world.gregs.voidps.engine.entity.character.npc.NPCs @@ -19,6 +25,8 @@ import world.gregs.voidps.engine.entity.character.player.skill.level.Level.has import world.gregs.voidps.engine.entity.obj.GameObject import world.gregs.voidps.engine.entity.obj.GameObjects import world.gregs.voidps.engine.entity.obj.replace +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.engine.inv.remove import world.gregs.voidps.engine.map.Spiral import world.gregs.voidps.engine.queue.weakQueue import world.gregs.voidps.engine.suspend.awaitDialogues @@ -31,12 +39,20 @@ class EvilTree : Script { val logger = InlineLogger() - var tree: GameObject = GameObject(0) - var spawnTile: Tile = Tile.EMPTY - var type: String = "normal" - val roots = arrayOfNulls(4) - var health = 100 - var leprechaun = NPC() + companion object { + var place = "" + var tree: GameObject = GameObject(0) + var spawnTile: Tile = Tile.EMPTY + var type: String = "normal" + val roots = arrayOfNulls(4) + var health = 100 + var leprechaun = NPC() + + fun isSapling() = when (tree.id) { + "evil_tree_seedling", "strange_sapling", "evil_tree_sapling_large", "evil_tree_young", "evil_tree_young_large" -> true + else -> false + } + } init { // TODO spawn roots on players with 2x2 regions @@ -65,6 +81,11 @@ class EvilTree : Script { nurture(target) } + objectOperate("Inspect", "evil_tree_*") { + val level = Tables.int("evil_tree_type.${type}.woodcutting") + statement("This is an Evil${if (type == "normal") "" else " ${type.toCamelCase()}"} tree. A Woodcutting / Firemaking level of at least ${level} is required to interact with this tree and the surrounding roots.") + } + objectOperate("Chop", "evil_tree_*") { (target) -> nextStage(target) } @@ -117,15 +138,80 @@ class EvilTree : Script { } suspend fun Player.chop(target: GameObject) { + val hatchet = Hatchet.best(this) + if (hatchet == null) { + message("You need a hatchet to chop down this tree.") + message("You do not have a hatchet which you have the woodcutting level to use.") + return + } + val row = Rows.get("evil_tree_type.${type}") while (awaitDialogues()) { - if (!GameObjects.contains(target)) { + val level = row.int("woodcutting") + if (!GameObjects.contains(target) || !has(Skill.Woodcutting, level, message = true)) { return } - + if (!Hatchet.hasRequirements(this, hatchet, message = true)) { + break + } + val remaining = remaining("action_delay") + if (remaining < 0) { + anim("${hatchet.id}_chop") + start("action_delay", 3) + pause(3) + } else if (remaining > 0) { + pause(remaining) + } + if (!GameObjects.contains(target)) { + break + } +// if (success(player.levels.get(Skill.Woodcutting), hatchet, log)) { + exp(Skill.Woodcutting, row.int("tree_xp") / 10.0) +// } // TODO branch attacks when chopping root } } + suspend fun Player.light(target: GameObject) { + val row = Rows.get("evil_tree_type.${type}") + var first = true + while (awaitDialogues()) { + val level = row.int("woodcutting") + if (!GameObjects.contains(target) || !has(Skill.Firemaking, level, message = " to set fire to this evil tree")) { + return + } +// message("That part of the tree is already on fire!") + if (!inventory.contains("tinderbox")) { + message("You need a tinderbox in order to light a fire.") + return + } + if (!inventory.contains("kindling")) { + message("You don't have the required items to light this.") + return + } + val remaining = remaining("action_delay") + if (remaining < 0) { + if (first) { + message("You crouch to light the kindling") + first = false + } + anim("light_fire") + start("action_delay", 4) + pause(4) + } else if (remaining > 0) { + pause(remaining) + } + if (!GameObjects.contains(target) || health < 0) { + break + } + "You dive out of the way as a new root bursts from the ground." + "The logs are magically escorted to your bank." +// if (success(player.levels.get(Skill.Woodcutting), hatchet, log)) { + inventory.remove("kindling") + exp(Skill.Firemaking, row.int("burn_xp") / 10.0) +// } + } + } + private fun nextStage(target: GameObject) { tree = when { target.id == "evil_tree_seedling" -> { @@ -144,6 +230,7 @@ class EvilTree : Script { for (player in Players.at(centre)) { player.walkTo(centre.add(Direction.SOUTH).add(Direction.SOUTH)) } + // TODO npcs too for (tile in Spiral.spiral(centre, 1)) { for (player in Players.at(tile)) { val dir = player.tile.delta(centre).toDirection() @@ -209,6 +296,7 @@ class EvilTree : Script { return } val place = Tables.get("evil_tree_place").rows().random(random) + EvilTree.place = place.rowId if (Settings["world.messages", false]) { for (player in Players) { val hint = place.string("hint").replaceFirstChar { it.lowercase() }.replace("
", " ") @@ -218,7 +306,7 @@ class EvilTree : Script { spawnTile = place.tileList("tiles").random(random) val type = Tables.get("evil_tree_type").rows().random(random) logger.info { "Evil tree event has started at: ${place.rowId} (${spawnTile.x}, ${spawnTile.y}) type ${type.rowId}." } - this.type = type.rowId + EvilTree.type = type.rowId val centre = spawnTile.add(1, 1) tree = GameObjects.add("evil_tree_seedling", centre) // Push players out diff --git a/game/src/main/kotlin/content/quest/member/the_grand_tree/SpiritTree.kt b/game/src/main/kotlin/content/quest/member/the_grand_tree/SpiritTree.kt index bffc8a0c22..4925e39ab5 100644 --- a/game/src/main/kotlin/content/quest/member/the_grand_tree/SpiritTree.kt +++ b/game/src/main/kotlin/content/quest/member/the_grand_tree/SpiritTree.kt @@ -1,16 +1,19 @@ package content.quest.member.the_grand_tree +import content.activity.evil_tree.EvilTree import content.entity.player.dialogue.Happy import content.entity.player.dialogue.Neutral import content.entity.player.dialogue.Quiz import content.entity.player.dialogue.type.choice import content.entity.player.dialogue.type.npc +import content.entity.player.dialogue.type.player import content.entity.player.dialogue.type.statement import content.quest.questCompleted 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.data.definition.EnumDefinitions +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.Teleport import world.gregs.voidps.type.Tile @@ -18,29 +21,52 @@ import world.gregs.voidps.type.Tile class SpiritTree : Script { init { - objectOperate("Talk-to", "spirit_tree,spirit_tree_fullygrown") { + objectOperate("Talk-to", "spirit_tree,spirit_tree_fullygrown,spirit_tree_gnome,spirit_tree_stronghold") { if (!questCompleted("the_grand_tree")) { statement("The tree doesn't feel like talking.") return@objectOperate } - npc("spirit_tree", "Hello gnome friend. Where would you like to go?") - updatePosition(this) - open("spirit_tree") - } - - objectOperate("Talk-to", "spirit_tree_gnome,spirit_tree_stronghold") { - if (!questCompleted("the_grand_tree")) { - statement("The tree doesn't feel like talking.") - return@objectOperate - } - npc("spirit_tree_gnome", "You friend of gnome people, you friend of mine. Would you like me to take you somewhere?") - choice { - option("No thanks, old tree.") - option("Where can I go?") { - npc("spirit_tree_gnome", "You can travel to the trees which are related to me.") + npc( + "spirit_tree", "If you are a friend of the gnome people, you are a friend of mine. Do you wish to travel, or are you ${ + when { + EvilTree.tree.id == "strange_sapling" -> "interested in the strange sapling?" + EvilTree.tree.id.endsWith("_stump") -> "to ask about the evil tree?" + else -> "to help dispatch the evil tree?" + } + }" + ) + choice("What would you like to ask about?") { + option("Travel.") { updatePosition(this) open("spirit_tree") } + if (EvilTree.isSapling()) { + option("Strange sapling.") { + npc("I can help you to find the strange sapling, but my knowledge outside of the anima mundi is limited.") + npc("It can be found ${Tables.string("evil_tree_place.${EvilTree.place}.hint")}.") + } + } else { + option("Evil tree.") { + if (EvilTree.tree.id.endsWith("_stump")) { + npc("spirit_tree", "The taint of the evil tree is not currently on the land. There won't be an evil tree for a long time.") + npc("spirit_tree", "The taint of the evil tree is not currently on the land. There will be an evil tree in approximately [number] hour [number] minutes.") + return@option + } + npc("spirit_tree", "Would you like me to teleport you directly there?") + choice { + option("Yes please.") { + Teleport.teleport(this, EvilTree.spawnTile.add(-1, -1), "spirit_tree", sound = false) + } + option("What is this 'evil tree'?") { + npc("spirit_tree", "It is an abomination of nature that must be destroyed as quickly as possible. We do not know where it will appear, but, when it does, you should go to it immediately and help out!") + } + option("I've changed my mind, I want to stay here.") + } + } + } + option("Nothing.") { + player("Nothing, thanks.") + } } } From 6aece0b4d25dc6382de4ad2773877af861cf8d15 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Tue, 1 Sep 2026 18:09:35 -0700 Subject: [PATCH 08/11] Finish the Evil Tree Distraction & Diversion Completes the work-in-progress evil tree event so it can be played end to end: a seedling sprouts, grows into a tree, gets chopped and burned down, and pays out rewards. Event lifecycle - Two world timers drive the event: `evil_tree_spawn` schedules the next tree, `evil_tree` ticks the live one. Timers are used over `World.queue` because clearing a queue invokes its pending block, which would respawn a tree during teardown. - A tree sprouts two minutes after startup and two hours after the last one died, both configurable, and its location is announced when world messages are enabled. Growth - Saplings advance a stage every `seed_health` growth points, gained either from the growth timer or from nurturing, which pays the tier's farming experience spread across the stage. - Roots briefly shoot out of the ground near a sprouting sapling, once per player per tree. - The final stage displaces anyone standing where the three by three tree appears, sets the tree's health and bursts four roots out of the ground. Chopping and burning - Chopping the trunk uses the woodcutting success rates of the matching log and takes a point of health per successful chop; the tree swaps to its damaged and dying models at two thirds and one third health. - Chopping a root yields evil tree kindling and dies after three to seven successful chops. A replacement root bursts out every minute, shoving and stunning anyone stood on top of it. - Lighting kindling against the trunk places a fire in one of the twelve spots around the tree; every lit fire burns a point of health per tick cycle. - Lightning strikes at ten, twenty and thirty minutes, capping the tree at half then a quarter of its health before felling it outright. Rewards - Kindling is handed in to the leprechaun, or taken from the reward stump, capped at two hundred a day and paying coins, noted logs and evil tree magic scaled by the amount handed in. - Evil tree magic halves the chance of felling a tree, escorts logs to the bank once the inventory is full and doubles the bird's nest rate. - Players can help with two trees a day; inspecting a tree doesn't count. Also fixes several defects in the existing work in progress: the growth timer registered player handlers against an npc timer so it never ran, a string concatenation in the stage `when` meant neither young stage matched, the burning code used the Lunar Diplomacy kindling item, the chop and light loops were never wired up to an option, and `wander_radius` isn't a valid npc parameter. The spirit tree now gives a real countdown to the next tree and its sapling branch is reachable. --- data/activity/evil_tree/evil_tree.gfx.toml | 3 + data/activity/evil_tree/evil_tree.npcs.toml | 1 - data/activity/evil_tree/evil_tree.tables.toml | 62 +- .../activity/evil_tree/evil_tree.varbits.toml | 3 +- data/activity/evil_tree/evil_tree.vars.toml | 22 + data/entity/npc/wander_ranges.tables.toml | 6 + .../content/activity/evil_tree/EvilTree.kt | 725 +++++++++++++----- .../activity/evil_tree/EvilTreeLeprechaun.kt | 165 ++++ .../activity/evil_tree/EvilTreeState.kt | 86 +++ .../lumbridge/swamp/chams_of_tears/Juna.kt | 1 - .../quest/member/the_grand_tree/SpiritTree.kt | 49 +- .../content/skill/woodcutting/Woodcutting.kt | 74 +- game/src/main/resources/game.properties | 18 + .../activity/evil_tree/EvilTreeTest.kt | 319 ++++++++ 14 files changed, 1282 insertions(+), 252 deletions(-) create mode 100644 data/activity/evil_tree/evil_tree.vars.toml create mode 100644 game/src/main/kotlin/content/activity/evil_tree/EvilTreeLeprechaun.kt create mode 100644 game/src/main/kotlin/content/activity/evil_tree/EvilTreeState.kt create mode 100644 game/src/test/kotlin/content/activity/evil_tree/EvilTreeTest.kt diff --git a/data/activity/evil_tree/evil_tree.gfx.toml b/data/activity/evil_tree/evil_tree.gfx.toml index 4c4b8429fc..48e78a40c9 100644 --- a/data/activity/evil_tree/evil_tree.gfx.toml +++ b/data/activity/evil_tree/evil_tree.gfx.toml @@ -3,3 +3,6 @@ id = 2136 [evil_root] id = 314 + +[evil_tree_bank] +id = 312 diff --git a/data/activity/evil_tree/evil_tree.npcs.toml b/data/activity/evil_tree/evil_tree.npcs.toml index 3ca37550aa..b1d89fa538 100644 --- a/data/activity/evil_tree/evil_tree.npcs.toml +++ b/data/activity/evil_tree/evil_tree.npcs.toml @@ -1,6 +1,5 @@ [leprechaun_evil_tree] id = 418 -wander_radius = 4 [leprechaun_panic] id = 419 diff --git a/data/activity/evil_tree/evil_tree.tables.toml b/data/activity/evil_tree/evil_tree.tables.toml index 9258d1d197..d865c8e480 100644 --- a/data/activity/evil_tree/evil_tree.tables.toml +++ b/data/activity/evil_tree/evil_tree.tables.toml @@ -1,5 +1,6 @@ [evil_tree_type] woodcutting = "int" +firemaking = "int" farming = "int" nurture_xp = "int" burn_xp = "int" @@ -8,83 +9,116 @@ tree_xp = "int" buff = "int" health = "int" seed_health = "int" +fire_life = "int" +fire_life_default = 100 +reward_coins = "int" +reward_logs = "list" +reward_amounts = "list" [.normal] woodcutting = 1 +firemaking = 1 farming = 1 nurture_xp = 200 burn_xp = 2000 root_xp = 30 -tree_xp = 30 +tree_xp = 151 buff = 6 health = 150 seed_health = 2 +reward_coins = 370 +reward_logs = [ "logs" ] +reward_amounts = [ 24 ] [.oak] woodcutting = 15 +firemaking = 15 farming = 7 nurture_xp = 450 burn_xp = 3000 root_xp = 64 -tree_xp = 64 +tree_xp = 324 buff = 10 health = 300 seed_health = 4 +reward_coins = 652 +reward_logs = [ "oak_logs" ] +reward_amounts = [ 48 ] [.willow] woodcutting = 30 +firemaking = 30 farming = 15 nurture_xp = 660 burn_xp = 4500 root_xp = 91 -tree_xp = 91 +tree_xp = 457 buff = 14 health = 450 seed_health = 6 +reward_coins = 2254 +reward_logs = [ "willow_logs" ] +reward_amounts = [ 452 ] [.maple] woodcutting = 45 +firemaking = 45 farming = 22 nurture_xp = 1215 burn_xp = 6750 root_xp = 111 -tree_xp = 111 +tree_xp = 558 buff = 18 health = 600 seed_health = 8 +reward_coins = 2560 +reward_logs = [ "maple_logs" ] +reward_amounts = [ 262 ] [.yew] woodcutting = 60 +firemaking = 60 farming = 30 nurture_xp = 1725 burn_xp = 10125 -root_xp = 175 -tree_xp = 175 +root_xp = 128 +tree_xp = 644 buff = 22 health = 750 seed_health = 10 +reward_coins = 4668 +reward_logs = [ "yew_logs" ] +reward_amounts = [ 34 ] [.magic] woodcutting = 75 +firemaking = 75 farming = 37 -nurture_xp = 3115 +nurture_xp = 3110 burn_xp = 15175 -root_xp = 250 -tree_xp = 250 +root_xp = 141 +tree_xp = 709 buff = 26 health = 750 seed_health = 12 +reward_coins = 9474 +reward_logs = [ "magic_logs" ] +reward_amounts = [ 20 ] [.elder] -woodcutting = 90 +woodcutting = 85 +firemaking = 85 farming = 42 -nurture_xp = 7300 -burn_xp = 26005 -root_xp = 325 -tree_xp = 325 +nurture_xp = 6875 +burn_xp = 20005 +root_xp = 155 +tree_xp = 778 buff = 30 health = 900 seed_health = 14 +reward_coins = 17388 +reward_logs = [ "magic_logs", "yew_logs", "teak_logs" ] +reward_amounts = [ 36, 118, 750 ] [evil_tree_place] hint = "string" diff --git a/data/activity/evil_tree/evil_tree.varbits.toml b/data/activity/evil_tree/evil_tree.varbits.toml index 82b7b99714..9719735037 100644 --- a/data/activity/evil_tree/evil_tree.varbits.toml +++ b/data/activity/evil_tree/evil_tree.varbits.toml @@ -5,4 +5,5 @@ persist = true [evil_tree_rewards] id = 1542 -format = "boolean" \ No newline at end of file +format = "boolean" +persist = true diff --git a/data/activity/evil_tree/evil_tree.vars.toml b/data/activity/evil_tree/evil_tree.vars.toml new file mode 100644 index 0000000000..85789cb988 --- /dev/null +++ b/data/activity/evil_tree/evil_tree.vars.toml @@ -0,0 +1,22 @@ +[evil_tree_day] +format = "long" +persist = true + +[evil_tree_trees] +format = "int" +persist = true + +[evil_tree_spawn_id] +format = "int" +persist = true + +[evil_tree_kindling_handed] +format = "int" +persist = true + +[evil_tree_buff] +format = "int" +persist = true + +[evil_tree_noticed] +format = "int" diff --git a/data/entity/npc/wander_ranges.tables.toml b/data/entity/npc/wander_ranges.tables.toml index 33e01a5d0c..a4ffa88144 100644 --- a/data/entity/npc/wander_ranges.tables.toml +++ b/data/entity/npc/wander_ranges.tables.toml @@ -52144,3 +52144,9 @@ wander_range = 0 #[.21079] #wander_range = 20 #max_range = 20 + +[.leprechaun_evil_tree] +wander_range = 0 + +[.leprechaun_panic] +wander_range = 0 diff --git a/game/src/main/kotlin/content/activity/evil_tree/EvilTree.kt b/game/src/main/kotlin/content/activity/evil_tree/EvilTree.kt index f35f091be5..52db62a491 100644 --- a/game/src/main/kotlin/content/activity/evil_tree/EvilTree.kt +++ b/game/src/main/kotlin/content/activity/evil_tree/EvilTree.kt @@ -4,17 +4,22 @@ import com.github.michaelbull.logging.InlineLogger import content.entity.effect.transform import content.entity.player.dialogue.type.statement import content.skill.woodcutting.Hatchet -import net.pearx.kasechange.toCamelCase +import content.skill.woodcutting.chopSuccess +import net.pearx.kasechange.toLowerSpaceCase +import world.gregs.voidps.engine.GameLoop import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.client.command.adminCommand +import world.gregs.voidps.engine.client.command.stringArg import world.gregs.voidps.engine.client.message import world.gregs.voidps.engine.client.ui.chat.Colours import world.gregs.voidps.engine.client.ui.chat.toTag import world.gregs.voidps.engine.client.variable.remaining import world.gregs.voidps.engine.client.variable.start import world.gregs.voidps.engine.data.Settings +import world.gregs.voidps.engine.data.config.RowDefinition import world.gregs.voidps.engine.data.definition.Rows import world.gregs.voidps.engine.data.definition.Tables -import world.gregs.voidps.engine.entity.character.npc.NPC +import world.gregs.voidps.engine.entity.World import world.gregs.voidps.engine.entity.character.npc.NPCs import world.gregs.voidps.engine.entity.character.player.Player import world.gregs.voidps.engine.entity.character.player.Players @@ -25,294 +30,624 @@ import world.gregs.voidps.engine.entity.character.player.skill.level.Level.has import world.gregs.voidps.engine.entity.obj.GameObject import world.gregs.voidps.engine.entity.obj.GameObjects import world.gregs.voidps.engine.entity.obj.replace +import world.gregs.voidps.engine.inv.add import world.gregs.voidps.engine.inv.inventory import world.gregs.voidps.engine.inv.remove -import world.gregs.voidps.engine.map.Spiral import world.gregs.voidps.engine.queue.weakQueue import world.gregs.voidps.engine.suspend.awaitDialogues import world.gregs.voidps.engine.timer.Timer +import world.gregs.voidps.engine.timer.toTicks import world.gregs.voidps.type.Direction import world.gregs.voidps.type.Tile import world.gregs.voidps.type.random +import java.util.concurrent.TimeUnit class EvilTree : Script { - val logger = InlineLogger() - - companion object { - var place = "" - var tree: GameObject = GameObject(0) - var spawnTile: Tile = Tile.EMPTY - var type: String = "normal" - val roots = arrayOfNulls(4) - var health = 100 - var leprechaun = NPC() - - fun isSapling() = when (tree.id) { - "evil_tree_seedling", "strange_sapling", "evil_tree_sapling_large", "evil_tree_young", "evil_tree_young_large" -> true - else -> false - } - } + private val logger = InlineLogger() init { - // TODO spawn roots on players with 2x2 regions - worldSpawn { if (Settings["events.evilTree.enabled", false]) { - start() + schedule(STARTUP_MINUTES) } } settingsReload { - if (Settings["events.evilTree.enabled", false] && spawnTile == Tile.EMPTY) { - start() - } else if (!Settings["events.evilTree.enabled", false]) { + if (!Settings["events.evilTree.enabled", false]) { + World.timers.clear("evil_tree_spawn") clear() + return@settingsReload } + if (!EvilTreeState.active && !World.timers.contains("evil_tree_spawn")) { + schedule(STARTUP_MINUTES) + } + } + + worldTimerStart("evil_tree_spawn") { EvilTreeState.respawnTicks } + + worldTimerTick("evil_tree_spawn") { + spawn() + Timer.CANCEL } - // TODO grows self every 1 minute + worldTimerStart("evil_tree") { TICK_INTERVAL } + + worldTimerTick("evil_tree") { tick() } + objectOperate("Nurture", "evil_tree_seedling,evil_tree_sapling*,evil_tree_young*") { (target) -> - val level = Tables.intOrNull("evil_tree_type.${type}.farming") ?: return@objectOperate - if (!has(Skill.Farming, level, " to help this sapling grow")) { - return@objectOperate - } - message("You begin tending to the sapling.", ChatType.Filter) - nurture(target) + startNurture(target) } objectOperate("Inspect", "evil_tree_*") { - val level = Tables.int("evil_tree_type.${type}.woodcutting") - statement("This is an Evil${if (type == "normal") "" else " ${type.toCamelCase()}"} tree. A Woodcutting / Firemaking level of at least ${level} is required to interact with this tree and the surrounding roots.") + inspect() } objectOperate("Chop", "evil_tree_*") { (target) -> - nextStage(target) + chop(target) + } + + objectOperate("Chop", "evil_branches_*") { (target) -> + chopRoot(target) + } + + objectOperate("Light fire", "evil_tree_*") { (target) -> + light(target) } objectDespawn("evil_branches_*_spawn") { - for (root in roots) { - root ?: continue - root.replace(root.id.removeSuffix("_spawn")) + settle(this) + } + + objectDespawn("evil_tree_fire") { + EvilTreeState.fires.values.remove(this) + } + + playerSpawn { + if (this["evil_tree_buff", 0] > 0) { + timers.restart("evil_tree_buff") } } - npcSpawn("leprechaun_evil_tree") { - softTimers.start("evil_tree_timer") + timerStart("evil_tree_buff") { TimeUnit.SECONDS.toTicks(1) } + + timerTick("evil_tree_buff") { + if (dec("evil_tree_buff") <= 0) Timer.CANCEL else Timer.CONTINUE } - timerStart("evil_tree_timer") { - 10 + timerStop("evil_tree_buff") { + message("${Colours.DARK_RED.toTag()}Your evil tree magic has worn off.") } - timerTick("evil_tree_timer") { - when (tree.id) { - "evil_tree_seedling", "evil_tree_sapling", "evil_tree_sapling_large", "evil_tree_young" + "evil_tree_young_large" -> takeHealth(10) + adminCommand("eviltree", stringArg("minutes"), desc = "Start a new evil tree event in [minutes]", handler = ::command) + } + + private fun command(player: Player, args: List) { + clear() + schedule(args.getOrNull(0)?.toIntOrNull() ?: 0) + player.message("Evil tree event scheduled.") + } + + private fun schedule(minutes: Int) { + EvilTreeState.respawnTicks = TimeUnit.MINUTES.toTicks(minutes) + World.timers.clear("evil_tree_spawn") + World.timers.start("evil_tree_spawn") + } + + private fun spawn() { + clear() + val state = EvilTreeState + val place = Tables.get("evil_tree_place").rows().random(random) + state.place = place.rowId + state.spawnTile = place.tileList("tiles").random(random) + state.type = Tables.get("evil_tree_type").rows().random(random).rowId + state.spawnId++ + state.tree = GameObjects.add("evil_tree_seedling", state.centre) + displace() + state.leprechaun = NPCs.add("leprechaun_evil_tree", state.spawnTile.add(-1, -1)) + World.timers.clear("evil_tree") + World.timers.start("evil_tree") + announce(place) + logger.info { "Evil tree event has started at: ${state.place} (${state.spawnTile.x}, ${state.spawnTile.y}) type ${state.type}." } + } + + private fun announce(place: RowDefinition) { + if (!Settings["world.messages", false]) { + return + } + val hint = place.string("hint").replaceFirstChar { it.lowercase() }.replace("
", " ") + for (player in Players) { + player.message("${Colours.DARK_RED.toTag()}An evil tree has begun to sprout $hint.") + } + } + + fun clear() { + val state = EvilTreeState + if (!state.active) { + return + } + World.timers.clear("evil_tree") + clearRoots() + clearFires() + GameObjects.remove(state.tree) + NPCs.remove(state.leprechaun) + state.reset() + } + + private fun clearRoots() { + for (root in EvilTreeState.roots.values) { + GameObjects.remove(root.obj) + } + EvilTreeState.roots.clear() + } + + private fun clearFires() { + for (fire in EvilTreeState.fires.values) { + GameObjects.remove(fire) + } + EvilTreeState.fires.clear() + } + + /* + * World tick + */ + + private fun tick(): Int { + val state = EvilTreeState + if (!state.active) { + return Timer.CANCEL + } + if (state.deathDelay > 0) { + state.deathDelay -= TICK_INTERVAL + if (state.deathDelay <= 0) { + state.tree = state.tree.replace("evil_tree_${state.type}_stump", state.spawnTile) + return Timer.CANCEL + } + return Timer.CONTINUE + } + if (!state.grown) { + growthTick() + return Timer.CONTINUE + } + if (state.fires.isNotEmpty()) { + damage(state.fires.size) + if (state.deathDelay > 0) { + return Timer.CONTINUE } - // TODO lightning strike every 30 mins - Timer.CONTINUE } - // TODO light fires + rootTick() + lightning() + return Timer.CONTINUE + } + + private fun growthTick() { + val state = EvilTreeState + notice() + state.growthDelay += TICK_INTERVAL + if (state.growthDelay < Settings["events.evilTree.growthTicks", 100]) { + return + } + state.growthDelay = 0 + addGrowth() + } + + /** + * Roots briefly shoot out of the ground around a sprouting sapling, once per player per tree. + */ + private fun notice() { + val state = EvilTreeState + Players.forEachInRadius(state.centre, NOTICE_RADIUS) { player -> + if (player["evil_tree_noticed", 0] == state.spawnId) { + return@forEachInRadius + } + player["evil_tree_noticed"] = state.spawnId + player.gfx("evil_root") + player.say("What was that?") + } + } + + private fun rootTick() { + val state = EvilTreeState + state.rootDelay += TICK_INTERVAL + if (state.rootDelay < ROOT_RESPAWN_TICKS) { + return + } + state.rootDelay = 0 + val row = Tables.get("evil_branches").rows().filterNot { state.roots.containsKey(it.rowId) }.randomOrNull(random) ?: return + spawnRoot(row) + } + + private fun lightning() { + val state = EvilTreeState + if (state.strikes >= LIGHTNING_STRIKES) { + return + } + val minutes = (GameLoop.tick - state.grownTick) / TimeUnit.MINUTES.toTicks(1) + if (minutes < (state.strikes + 1) * LIGHTNING_INTERVAL_MINUTES) { + return + } + state.strikes++ + say("A bolt of lightning strikes the evil tree!") + if (state.strikes >= LIGHTNING_STRIKES) { + damage(state.health) + return + } + val cap = state.maxHealth shr state.strikes + if (state.health > cap) { + damage(state.health - cap) + } } - private fun takeHealth(amount: Int) { - health -= amount - if (health <= 0) { - nextStage(tree) + private fun say(message: String) { + val state = EvilTreeState + state.leprechaun.say(message) + Players.forEachInRadius(state.centre, 15) { player -> + player.message("${Colours.DARK_RED.toTag()}$message") } } - fun Player.nurture(target: GameObject) { - if (tree != target) { + /* + * Growth + */ + + private fun Player.startNurture(target: GameObject) { + val state = EvilTreeState + if (!state.isTree(target) || !state.sapling) { + return + } + val level = Tables.intOrNull("evil_tree_type.${state.type}.farming") ?: return + if (!has(Skill.Farming, level, " to help this sapling grow")) { + return + } + if (!interact()) { + return + } + message("You begin tending to the sapling.", ChatType.Filter) + nurture(target) + } + + private fun Player.nurture(target: GameObject) { + val state = EvilTreeState + if (!state.isTree(target) || !state.sapling) { return } anim("nurture_sapling") weakQueue("nurture_evil_sapling", 3) { - if (tree != target) { + if (!state.isTree(target) || !state.sapling) { return@weakQueue } - exp(Skill.Farming, Tables.int("evil_tree_type.${type}.nurture_xp") / 10.0) - takeHealth(1) - nurture(target) + exp(Skill.Farming, nurtureExperience()) + addGrowth() + nurture(state.tree) } } - suspend fun Player.chop(target: GameObject) { - val hatchet = Hatchet.best(this) - if (hatchet == null) { - message("You need a hatchet to chop down this tree.") - message("You do not have a hatchet which you have the woodcutting level to use.") + private fun nurtureExperience(): Double { + val state = EvilTreeState + val row = Rows.get("evil_tree_type.${state.type}") + return row.int("nurture_xp") / 10.0 / row.int("seed_health") + } + + private fun addGrowth() { + val state = EvilTreeState + state.growth++ + if (state.growth < Tables.int("evil_tree_type.${state.type}.seed_health")) { return } - val row = Rows.get("evil_tree_type.${type}") + state.growth = 0 + grow() + } + + private fun grow() { + val state = EvilTreeState + state.tree = when (state.tree.id) { + "evil_tree_seedling" -> state.tree.replace("evil_tree_sapling", state.centre) + "evil_tree_sapling" -> state.tree.replace("evil_tree_sapling_large", state.centre) + "evil_tree_sapling_large" -> { + displace() + state.tree.replace("evil_tree_young", state.spawnTile) + } + "evil_tree_young" -> state.tree.replace("evil_tree_young_large", state.spawnTile) + "evil_tree_young_large" -> return mature() + else -> return + } + state.leprechaun.say("Whoa!") + } + + private fun mature() { + val state = EvilTreeState + state.maxHealth = Tables.int("evil_tree_type.${state.type}.health") + state.health = state.maxHealth + state.grownTick = GameLoop.tick.toLong() + state.tree = state.tree.replace("evil_tree_${state.type}_full", state.spawnTile) + state.leprechaun.transform("leprechaun_panic") + state.leprechaun.say("It's alive!") + for (row in Tables.get("evil_branches").rows()) { + spawnRoot(row) + } + } + + /** + * Moves anyone standing where the tree is about to grow out of the way. + */ + private fun displace() { + val state = EvilTreeState + for (tile in state.spawnTile.toCuboid(3, 3)) { + for (player in Players.at(tile)) { + push(player, state.centre) + } + } + } + + private fun push(player: Player, from: Tile) { + val delta = player.tile.delta(from) + val direction = if (delta.x == 0 && delta.y == 0) Direction.cardinal.random(random) else delta.toDirection() + if (direction == Direction.NONE) { + return + } + player.walkTo(player.tile.add(direction.delta)) + } + + /* + * Roots + */ + + private fun spawnRoot(row: RowDefinition) { + val state = EvilTreeState + if (state.roots.containsKey(row.rowId)) { + return + } + val tile = state.spawnTile.add(row.int("deltaX"), row.int("deltaY")) + GameObjects.add(row.obj("spawn"), tile, rotation = row.int("dir"), ticks = ROOT_BURST_TICKS) + burst(tile) + } + + /** + * The burst animation object has expired, replace it with the root players can chop. + */ + private fun settle(spawn: GameObject) { + val state = EvilTreeState + if (!state.alive) { + return + } + val row = Tables.get("evil_branches").rows().firstOrNull { it.obj("spawn") == spawn.id } ?: return + if (state.roots.containsKey(row.rowId)) { + return + } + val root = GameObjects.add(spawn.id.removeSuffix("_spawn"), spawn.tile, rotation = spawn.rotation) + state.roots[row.rowId] = EvilTreeState.Root(root, random.nextInt(ROOT_LIFE.first, ROOT_LIFE.last + 1)) + } + + private fun burst(tile: Tile) { + Players.forEachInRadius(tile, 1) { player -> + player.message("You dive out of the way as a new root bursts from the ground.") + push(player, tile) + player.anim("step_back_startled") + player["delay"] = STUN_TICKS + player.start("stunned", STUN_TICKS) + player.start("movement_delay", STUN_TICKS) + } + } + + private fun killRoot(row: String) { + val root = EvilTreeState.roots.remove(row) ?: return + GameObjects.remove(root.obj) + } + + /* + * Chopping + */ + + private suspend fun Player.chop(target: GameObject) { + val state = EvilTreeState + if (!state.isTree(target) || !state.alive) { + return + } + val hatchet = hatchet() ?: return + val row = Rows.get("evil_tree_type.${state.type}") + if (!has(Skill.Woodcutting, row.int("woodcutting"), message = true) || !interact()) { + return + } + val rates = Rows.get("logs.${row.itemList("reward_logs").first()}") while (awaitDialogues()) { - val level = row.int("woodcutting") - if (!GameObjects.contains(target) || !has(Skill.Woodcutting, level, message = true)) { - return + if (!state.alive || state.deathDelay > 0) { + break } if (!Hatchet.hasRequirements(this, hatchet, message = true)) { break } - val remaining = remaining("action_delay") - if (remaining < 0) { - anim("${hatchet.id}_chop") - start("action_delay", 3) - pause(3) - } else if (remaining > 0) { - pause(remaining) + if (!swing(hatchet.id)) { + continue } - if (!GameObjects.contains(target)) { - break + if (!chopSuccess(levels.get(Skill.Woodcutting), hatchet, rates)) { + continue } -// if (success(player.levels.get(Skill.Woodcutting), hatchet, log)) { exp(Skill.Woodcutting, row.int("tree_xp") / 10.0) -// } - // TODO branch attacks when chopping root + damage(1) } + clearAnim() } - suspend fun Player.light(target: GameObject) { - val row = Rows.get("evil_tree_type.${type}") - var first = true + private suspend fun Player.chopRoot(target: GameObject) { + val state = EvilTreeState + val side = state.roots.entries.firstOrNull { it.value.obj == target }?.key ?: return + val hatchet = hatchet() ?: return + val row = Rows.get("evil_tree_type.${state.type}") + if (!has(Skill.Woodcutting, row.int("woodcutting"), message = true) || !interact()) { + return + } + val rates = Rows.get("logs.${row.itemList("reward_logs").first()}") while (awaitDialogues()) { - val level = row.int("woodcutting") - if (!GameObjects.contains(target) || !has(Skill.Firemaking, level, message = " to set fire to this evil tree")) { - return + val root = state.roots[side] + if (root == null || root.obj != target) { + break } -// message("That part of the tree is already on fire!") - if (!inventory.contains("tinderbox")) { - message("You need a tinderbox in order to light a fire.") - return + if (!Hatchet.hasRequirements(this, hatchet, message = true)) { + break } - if (!inventory.contains("kindling")) { - message("You don't have the required items to light this.") - return + if (inventory.isFull()) { + message("Your inventory is too full to hold any more kindling.") + break } - val remaining = remaining("action_delay") - if (remaining < 0) { - if (first) { - message("You crouch to light the kindling") - first = false - } - anim("light_fire") - start("action_delay", 4) - pause(4) - } else if (remaining > 0) { - pause(remaining) + if (!swing(hatchet.id)) { + continue + } + if (!chopSuccess(levels.get(Skill.Woodcutting), hatchet, rates)) { + continue } - if (!GameObjects.contains(target) || health < 0) { + exp(Skill.Woodcutting, row.int("root_xp") / 10.0) + inventory.add("evil_tree_kindling") + if (--root.life <= 0) { + killRoot(side) break } - "You dive out of the way as a new root bursts from the ground." - "The logs are magically escorted to your bank." -// if (success(player.levels.get(Skill.Woodcutting), hatchet, log)) { - inventory.remove("kindling") - exp(Skill.Firemaking, row.int("burn_xp") / 10.0) -// } } + clearAnim() + } + + private fun Player.hatchet() = Hatchet.best(this) ?: run { + message("You need a hatchet to chop down this tree.") + message("You do not have a hatchet which you have the woodcutting level to use.") + null + } + + /** + * Waits out the shared action delay, returns whether a chop actually landed this cycle. + */ + private suspend fun Player.swing(hatchet: String): Boolean { + val remaining = remaining("action_delay") + if (remaining > 0) { + pause(remaining) + return false + } + anim("${hatchet}_chop") + start("action_delay", CHOP_TICKS) + pause(CHOP_TICKS) + return true } - private fun nextStage(target: GameObject) { - tree = when { - target.id == "evil_tree_seedling" -> { - health = Tables.int("evil_tree_type.${type}.seed_health") - leprechaun.say("Whoa!") - target.replace("evil_tree_sapling", spawnTile.add(1, 1)) + /* + * Fires + */ + + private suspend fun Player.light(target: GameObject) { + val state = EvilTreeState + if (!state.isTree(target) || !state.alive) { + return + } + val row = Rows.get("evil_tree_type.${state.type}") + if (!has(Skill.Firemaking, row.int("firemaking"), message = " to set fire to this evil tree") || !interact()) { + return + } + var first = true + while (awaitDialogues()) { + if (!state.alive || state.deathDelay > 0) { + break } - target.id == "evil_tree_sapling" -> { - health = Tables.int("evil_tree_type.${type}.seed_health") - leprechaun.say("Whoa!") - target.replace("evil_tree_sapling_large", spawnTile.add(1, 1)) + if (!inventory.contains("tinderbox")) { + message("You need a tinderbox in order to light a fire.") + break } - target.id == "evil_tree_sapling_large" -> { - health = Tables.int("evil_tree_type.${type}.seed_health") - val centre = spawnTile.add(1, 1) - for (player in Players.at(centre)) { - player.walkTo(centre.add(Direction.SOUTH).add(Direction.SOUTH)) - } - // TODO npcs too - for (tile in Spiral.spiral(centre, 1)) { - for (player in Players.at(tile)) { - val dir = player.tile.delta(centre).toDirection() - player.walkTo(player.tile.add(dir)) - } - } - leprechaun.say("Whoa!") - target.replace("evil_tree_young", spawnTile) + if (!inventory.contains("evil_tree_kindling")) { + message("You don't have any kindling to burn.") + break } - target.id == "evil_tree_young" -> { - health = Tables.int("evil_tree_type.${type}.seed_health") - leprechaun.say("Whoa!") - target.replace("evil_tree_young_large", spawnTile) + val spot = freeFire() ?: run { + message("There's nowhere left to light a fire.") + null + } ?: break + val remaining = remaining("action_delay") + if (remaining > 0) { + pause(remaining) + continue } - target.id == "evil_tree_young_large" -> { - health = Tables.int("evil_tree_type.${type}.health") - spawnEvilTree(target.tile) - target.replace("evil_tree_${type}_full") + if (first) { + message("You crouch to light the kindling.") + first = false } - target.id.endsWith("full") -> target.replace(target.id.replace("_full", "_half")) - target.id.endsWith("half") -> target.replace(target.id.replace("_half", "_weak")) - target.id.endsWith("weak") -> target.replace(target.id.replace("_weak", "_stump")) - target.id.endsWith("stump") -> { - clearRoots() - // TODO respawn in 2 hours - target.replace(target.id.replace("_stump", "_death")) + anim("light_fire") + start("action_delay", LIGHT_TICKS) + pause(LIGHT_TICKS) + if (!state.alive || state.deathDelay > 0 || state.fires.containsKey(spot.rowId)) { + continue } - else -> return + if (!inventory.remove("evil_tree_kindling")) { + break + } + exp(Skill.Firemaking, row.int("burn_xp") / 10.0) + val tile = state.spawnTile.add(spot.int("deltaX"), spot.int("deltaY")) + state.fires[spot.rowId] = GameObjects.add("evil_tree_fire", tile, rotation = spot.int("dir"), ticks = row.int("fire_life")) } + clearAnim() } - private fun spawnEvilTree(target: Tile) { - var index = 0 - for (branch in Tables.get("evil_branches").rows()) { - val spawn = branch.obj("spawn") - val dir = branch.int("dir") - val tile = target.add(branch.int("deltaX"), branch.int("deltaY")) - roots[index++] = GameObjects.add(spawn, tile, rotation = dir, ticks = 2) + private fun freeFire(): RowDefinition? = Tables.get("evil_fires").rows().filterNot { EvilTreeState.fires.containsKey(it.rowId) }.randomOrNull(random) + + /* + * Damage and death + */ + + private fun damage(amount: Int) { + val state = EvilTreeState + if (!state.alive || state.deathDelay > 0) { + return } - leprechaun.transform("leprechaun_panic") - leprechaun.say("Whoa!") + state.health = (state.health - amount).coerceAtLeast(0) + if (state.health <= 0) { + kill() + return + } + val id = when { + state.health * 3 > state.maxHealth * 2 -> "evil_tree_${state.type}_full" + state.health * 3 > state.maxHealth -> "evil_tree_${state.type}_half" + else -> "evil_tree_${state.type}_weak" + } + if (state.tree.id == id) { + return + } + state.tree = state.tree.replace(id, state.spawnTile) } - - fun clear() { - GameObjects.remove(tree) - tree = GameObject(0) - spawnTile = Tile.EMPTY - type = "normal" - leprechaun = NPC() + private fun kill() { + val state = EvilTreeState + state.dead = true clearRoots() + clearFires() + state.leprechaun.transform("leprechaun_evil_tree") + state.tree = state.tree.replace("evil_tree_${state.type}_death", state.spawnTile) + state.deathDelay = DEATH_TICKS + val minutes = Settings["events.evilTree.minRespawnTimeMinutes", 120]..Settings["events.evilTree.maxRespawnTimeMinutes", 120] + schedule(minutes.random(random)) } - private fun clearRoots() { - for (root in roots) { - GameObjects.remove(root ?: continue) - } - roots.fill(null) - } + /* + * Inspect + */ - fun start() { - if (spawnTile != Tile.EMPTY) { + private suspend fun Player.inspect() { + val state = EvilTreeState + val level = Tables.intOrNull("evil_tree_type.${state.type}.woodcutting") ?: return + val name = if (state.type == "normal") "Evil tree" else "Evil ${state.type.toLowerSpaceCase()} tree" + if (!state.alive) { + statement("This is going to be an $name. A Woodcutting and Firemaking level of at least $level is required to interact with it and the surrounding roots.") return } - val place = Tables.get("evil_tree_place").rows().random(random) - EvilTree.place = place.rowId - if (Settings["world.messages", false]) { - for (player in Players) { - val hint = place.string("hint").replaceFirstChar { it.lowercase() }.replace("
", " ") - player.message("${Colours.DARK_RED.toTag()}An evil tree has spawned $hint.") - } - } - spawnTile = place.tileList("tiles").random(random) - val type = Tables.get("evil_tree_type").rows().random(random) - logger.info { "Evil tree event has started at: ${place.rowId} (${spawnTile.x}, ${spawnTile.y}) type ${type.rowId}." } - EvilTree.type = type.rowId - val centre = spawnTile.add(1, 1) - tree = GameObjects.add("evil_tree_seedling", centre) - // Push players out - for (player in Players.at(centre)) { - player.walkTo(centre.add(Direction.cardinal.random(random))) - } - leprechaun = NPCs.add("leprechaun_evil_tree", spawnTile.add(-1, -1)) + val percent = (state.health * 100) / state.maxHealth.coerceAtLeast(1) + statement("This is an $name. A Woodcutting and Firemaking level of at least $level is required to interact with this tree and the surrounding roots.
There is $percent% of this tree left.") + } + + companion object { + const val TICK_INTERVAL = 10 + const val STARTUP_MINUTES = 2 + const val ROOT_BURST_TICKS = 2 + const val ROOT_RESPAWN_TICKS = 100 + const val STUN_TICKS = 2 + const val CHOP_TICKS = 3 + const val LIGHT_TICKS = 4 + const val DEATH_TICKS = 20 + const val LIGHTNING_STRIKES = 3 + const val LIGHTNING_INTERVAL_MINUTES = 10 + const val NOTICE_RADIUS = 8 + val ROOT_LIFE = 3..7 } -} \ No newline at end of file +} diff --git a/game/src/main/kotlin/content/activity/evil_tree/EvilTreeLeprechaun.kt b/game/src/main/kotlin/content/activity/evil_tree/EvilTreeLeprechaun.kt new file mode 100644 index 0000000000..67a45784ef --- /dev/null +++ b/game/src/main/kotlin/content/activity/evil_tree/EvilTreeLeprechaun.kt @@ -0,0 +1,165 @@ +package content.activity.evil_tree + +import com.github.michaelbull.logging.InlineLogger +import content.entity.player.dialogue.Happy +import content.entity.player.dialogue.Neutral +import content.entity.player.dialogue.Quiz +import content.entity.player.dialogue.type.choice +import content.entity.player.dialogue.type.npc +import content.entity.player.dialogue.type.statement +import net.pearx.kasechange.toLowerSpaceCase +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.client.message +import world.gregs.voidps.engine.data.Settings +import world.gregs.voidps.engine.data.definition.Rows +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.entity.character.player.skill.Skill +import world.gregs.voidps.engine.entity.character.player.skill.level.Level.has +import world.gregs.voidps.engine.event.AuditLog +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.engine.inv.transact.TransactionError +import world.gregs.voidps.engine.inv.transact.operation.AddItem.add +import world.gregs.voidps.engine.inv.transact.operation.RemoveItemLimit.removeToLimit +import world.gregs.voidps.type.random +import java.util.concurrent.TimeUnit + +class EvilTreeLeprechaun : Script { + + private val logger = InlineLogger() + + init { + npcOperate("Talk-to", "leprechaun_evil_tree,leprechaun_panic") { + npc("leprechaun_evil_tree", "Top o' the mornin' to yez! Terrible business, this evil tree.") + menu() + } + + objectOperate("Take-rewards", "evil_tree_*_stump_reward") { + claim() + } + } + + private suspend fun Player.menu() { + choice("What would you like to say?") { + option("What is this evil tree?") { + npc("leprechaun_evil_tree", "'Tis a tree grown from the taint in the land. Chop it, burn it, do whatever yez can - just be rid of it.") + npc("leprechaun_evil_tree", "Chop the roots for kindling, then set the kindling alight against the trunk. The fires burn it far faster than any hatchet.") + menu() + } + option("Could I borrow some tools?") { + loan() + menu() + } + option("I'd like my reward.") { + claim() + } + option("Nothing, thanks.") + } + } + + private suspend fun Player.loan() { + val missing = TOOLS.filterNot { inventory.contains(it) } + if (missing.isEmpty()) { + npc("leprechaun_evil_tree", "Yez've got everythin' yez need already.") + return + } + if (inventory.spaces < missing.size) { + npc("leprechaun_evil_tree", "Yez've no room for anythin' else.") + return + } + for (tool in missing) { + inventory.transaction { add(tool) } + } + npc("leprechaun_evil_tree", "Here yez go - mind yez don't lose 'em.") + statement("The leprechaun hands you a ${missing.joinToString(" and a ") { it.toLowerSpaceCase() }}.") + } + + private suspend fun Player.claim() { + val state = EvilTreeState + if (!state.active) { + npc("leprechaun_evil_tree", "There's no evil tree about just now.") + return + } + val row = Rows.get("evil_tree_type.${state.type}") + if (!has(Skill.Woodcutting, row.int("woodcutting"), message = true)) { + return + } + val limit = Settings["events.evilTree.dailyKindlingLimit", 200] + resetDaily() + val handIn = minOf(inventory.count("evil_tree_kindling"), limit - this["evil_tree_kindling_handed", 0]) + if (handIn <= 0) { + npc("leprechaun_evil_tree", "Yez've no kindling for me to be tradin' today.") + return + } + val scale = handIn.toDouble() / limit + val index = random.nextInt(row.intList("reward_amounts").size) + val logs = "${row.itemList("reward_logs")[index]}_noted" + val amount = (row.intList("reward_amounts")[index] * scale).toInt() + val coins = (row.int("reward_coins") * scale).toInt() + inventory.transaction { + removeToLimit("evil_tree_kindling", handIn) + if (coins > 0) { + add("coins", coins) + } + if (amount > 0) { + add(logs, amount) + } + } + if (inventory.transaction.error != TransactionError.None) { + logger.warn { "Issue rewarding evil tree kindling $handIn" } + statement("You don't have enough inventory space to claim your reward.") + return + } + inc("evil_tree_kindling_handed", handIn) + this["evil_tree_rewards"] = false + AuditLog.event(this, "evil_tree_reward", state.type, handIn) + grantMagic(row.int("buff"), scale) + statement("You hand over $handIn kindling and receive your reward.") + } + + private fun Player.grantMagic(minutes: Int, scale: Double) { + val seconds = (TimeUnit.MINUTES.toSeconds(minutes.toLong()) * scale).toInt() + if (seconds <= 0) { + return + } + inc("evil_tree_buff", seconds) + timers.startIfAbsent("evil_tree_buff") + message("You feel the evil tree's magic settle on you.") + } + + companion object { + val TOOLS = listOf("bronze_hatchet", "tinderbox") + } +} + +/** + * Rolls the players daily evil tree counters over when the day changes. + */ +fun Player.resetDaily() { + val day = TimeUnit.MILLISECONDS.toDays(System.currentTimeMillis()) + if (this["evil_tree_day", -1L] == day) { + return + } + this["evil_tree_day"] = day + this["evil_tree_trees"] = 0 + this["evil_tree_kindling_handed"] = 0 + this["evil_tree_spawn_id"] = 0 +} + +/** + * Whether the player is allowed to interact with the current evil tree, counting it + * towards their daily limit the first time they do. + */ +fun Player.interact(): Boolean { + resetDaily() + if (this["evil_tree_spawn_id", 0] == EvilTreeState.spawnId) { + return true + } + if (this["evil_tree_trees", 0] >= Settings["events.evilTree.dailyTreeLimit", 2]) { + message("You've already helped with as many evil trees as you can today.") + return false + } + this["evil_tree_spawn_id"] = EvilTreeState.spawnId + inc("evil_tree_trees") + this["evil_tree_rewards"] = true + return true +} diff --git a/game/src/main/kotlin/content/activity/evil_tree/EvilTreeState.kt b/game/src/main/kotlin/content/activity/evil_tree/EvilTreeState.kt new file mode 100644 index 0000000000..51ff4154a4 --- /dev/null +++ b/game/src/main/kotlin/content/activity/evil_tree/EvilTreeState.kt @@ -0,0 +1,86 @@ +package content.activity.evil_tree + +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.GameObject +import world.gregs.voidps.type.Tile + +/** + * State of the single evil tree the world currently has, if any. + */ +object EvilTreeState { + + var place: String = "" + var type: String = "normal" + var spawnTile: Tile = Tile.EMPTY + var tree: GameObject = GameObject(0) + var leprechaun: NPC = NPC() + var health: Int = 0 + var maxHealth: Int = 0 + var growth: Int = 0 + var grownTick: Long = NOT_GROWN + var strikes: Int = 0 + var dead: Boolean = false + var spawnId: Int = 0 + var growthDelay: Int = 0 + var rootDelay: Int = 0 + var deathDelay: Int = 0 + var respawnTicks: Int = 0 + val roots = mutableMapOf() + val fires = mutableMapOf() + + val active: Boolean + get() = spawnTile != Tile.EMPTY + + val grown: Boolean + get() = grownTick != NOT_GROWN + + /** + * A fully grown tree that hasn't been cut down yet. + */ + val alive: Boolean + get() = grown && !dead + + val sapling: Boolean + get() = active && !grown && !dead + + /** + * Centre of the three by three tree, where the one by one sapling stages sit. + */ + val centre: Tile + get() = spawnTile.add(1, 1) + + /** + * Whether [obj] is the current evil tree, at any stage of its growth. + */ + fun isTree(obj: GameObject): Boolean = active && (obj.tile == spawnTile || obj.tile == centre) + + fun reset() { + place = "" + type = "normal" + spawnTile = Tile.EMPTY + tree = GameObject(0) + leprechaun = NPC() + health = 0 + maxHealth = 0 + growth = 0 + grownTick = NOT_GROWN + strikes = 0 + dead = false + growthDelay = 0 + rootDelay = 0 + deathDelay = 0 + roots.clear() + fires.clear() + } + + class Root(var obj: GameObject, var life: Int) + + const val NOT_GROWN = -1L +} + +/** + * Whether the player still has "evil tree magic" left over from an evil tree reward. + */ +val Player.evilTreeMagic: Boolean + get() = this["evil_tree_buff", 0] > 0 diff --git a/game/src/main/kotlin/content/area/misthalin/lumbridge/swamp/chams_of_tears/Juna.kt b/game/src/main/kotlin/content/area/misthalin/lumbridge/swamp/chams_of_tears/Juna.kt index d1a25ff03a..652feee85e 100644 --- a/game/src/main/kotlin/content/area/misthalin/lumbridge/swamp/chams_of_tears/Juna.kt +++ b/game/src/main/kotlin/content/area/misthalin/lumbridge/swamp/chams_of_tears/Juna.kt @@ -8,7 +8,6 @@ import content.entity.player.dialogue.type.player import content.entity.player.dialogue.type.statement import content.entity.player.modal.Tab import content.entity.player.modal.tab -import content.quest.clearInstance import content.quest.closeTabs import content.quest.openTabs import content.quest.quest diff --git a/game/src/main/kotlin/content/quest/member/the_grand_tree/SpiritTree.kt b/game/src/main/kotlin/content/quest/member/the_grand_tree/SpiritTree.kt index 4925e39ab5..02ffe653e2 100644 --- a/game/src/main/kotlin/content/quest/member/the_grand_tree/SpiritTree.kt +++ b/game/src/main/kotlin/content/quest/member/the_grand_tree/SpiritTree.kt @@ -1,7 +1,6 @@ package content.quest.member.the_grand_tree -import content.activity.evil_tree.EvilTree -import content.entity.player.dialogue.Happy +import content.activity.evil_tree.EvilTreeState import content.entity.player.dialogue.Neutral import content.entity.player.dialogue.Quiz import content.entity.player.dialogue.type.choice @@ -11,12 +10,16 @@ import content.entity.player.dialogue.type.statement import content.quest.questCompleted import world.gregs.voidps.engine.Script import world.gregs.voidps.engine.client.message +import world.gregs.voidps.engine.client.ui.chat.plural import world.gregs.voidps.engine.client.ui.open import world.gregs.voidps.engine.data.definition.EnumDefinitions import world.gregs.voidps.engine.data.definition.Tables +import world.gregs.voidps.engine.entity.World import world.gregs.voidps.engine.entity.character.player.Player import world.gregs.voidps.engine.entity.character.player.Teleport +import world.gregs.voidps.engine.timer.toTicks import world.gregs.voidps.type.Tile +import java.util.concurrent.TimeUnit class SpiritTree : Script { @@ -27,35 +30,36 @@ class SpiritTree : Script { return@objectOperate } npc( - "spirit_tree", "If you are a friend of the gnome people, you are a friend of mine. Do you wish to travel, or are you ${ + "spirit_tree", + "If you are a friend of the gnome people, you are a friend of mine. Do you wish to travel, or are you ${ when { - EvilTree.tree.id == "strange_sapling" -> "interested in the strange sapling?" - EvilTree.tree.id.endsWith("_stump") -> "to ask about the evil tree?" - else -> "to help dispatch the evil tree?" + EvilTreeState.sapling -> "interested in the strange sapling?" + EvilTreeState.alive -> "to help dispatch the evil tree?" + else -> "to ask about the evil tree?" } - }" + }", ) choice("What would you like to ask about?") { option("Travel.") { updatePosition(this) open("spirit_tree") } - if (EvilTree.isSapling()) { + if (EvilTreeState.sapling) { option("Strange sapling.") { - npc("I can help you to find the strange sapling, but my knowledge outside of the anima mundi is limited.") - npc("It can be found ${Tables.string("evil_tree_place.${EvilTree.place}.hint")}.") + npc("spirit_tree", "I can help you to find the strange sapling, but my knowledge outside of the anima mundi is limited.") + npc("spirit_tree", "It can be found ${Tables.string("evil_tree_place.${EvilTreeState.place}.hint")}.") } } else { option("Evil tree.") { - if (EvilTree.tree.id.endsWith("_stump")) { - npc("spirit_tree", "The taint of the evil tree is not currently on the land. There won't be an evil tree for a long time.") - npc("spirit_tree", "The taint of the evil tree is not currently on the land. There will be an evil tree in approximately [number] hour [number] minutes.") + if (!EvilTreeState.alive) { + npc("spirit_tree", "The taint of the evil tree is not currently on the land. There will be an evil tree in approximately ${evilTreeCountdown()}.") return@option } + npc("spirit_tree", "The evil tree has taken root ${Tables.string("evil_tree_place.${EvilTreeState.place}.hint").replaceFirstChar { it.lowercase() }.replace("
", " ")}.") npc("spirit_tree", "Would you like me to teleport you directly there?") choice { option("Yes please.") { - Teleport.teleport(this, EvilTree.spawnTile.add(-1, -1), "spirit_tree", sound = false) + Teleport.teleport(this, EvilTreeState.spawnTile.add(-1, -1), "spirit_tree", sound = false) } option("What is this 'evil tree'?") { npc("spirit_tree", "It is an abomination of nature that must be destroyed as quickly as possible. We do not know where it will appear, but, when it does, you should go to it immediately and help out!") @@ -112,6 +116,23 @@ class SpiritTree : Script { } } + /** + * How long until the next evil tree sprouts, in plain english. + */ + private fun evilTreeCountdown(): String { + val ticks = World.timers.remaining("evil_tree_spawn") + if (ticks <= 0) { + return "a very short time" + } + val minutes = TimeUnit.MINUTES.toTicks(1) + val hours = ticks / (minutes * 60) + val remainder = (ticks % (minutes * 60)) / minutes + if (hours <= 0) { + return "$remainder ${"minute".plural(remainder)}" + } + return "$hours ${"hour".plural(hours)} $remainder ${"minute".plural(remainder)}" + } + fun updatePosition(player: Player) { var closest = Int.MAX_VALUE var tile = 0 diff --git a/game/src/main/kotlin/content/skill/woodcutting/Woodcutting.kt b/game/src/main/kotlin/content/skill/woodcutting/Woodcutting.kt index aabaf76224..c690bd5a65 100644 --- a/game/src/main/kotlin/content/skill/woodcutting/Woodcutting.kt +++ b/game/src/main/kotlin/content/skill/woodcutting/Woodcutting.kt @@ -1,5 +1,7 @@ package content.skill.woodcutting +import content.activity.evil_tree.evilTreeMagic +import content.entity.player.bank.bank import content.skill.summoning.familiarBoost import net.pearx.kasechange.toLowerSpaceCase import world.gregs.voidps.engine.Script @@ -36,6 +38,8 @@ import world.gregs.voidps.type.random class Woodcutting(val drops: DropTables) : Script { + private val unbankableLogs = setOf("teak_logs", "mahogany_logs", "arctic_pine_logs", "achey_tree_logs", "eucalyptus_logs") + init { objectOperate("Chop", handler = ::chopDown) objectOperate("Chop down", handler = ::chopDown) @@ -85,7 +89,7 @@ class Woodcutting(val drops: DropTables) : Script { if (!GameObjects.contains(target)) { break } - if (success(player.levels.get(Skill.Woodcutting) + player.familiarBoost(Skill.Woodcutting), hatchet, log)) { + if (chopSuccess(player.levels.get(Skill.Woodcutting) + player.familiarBoost(Skill.Woodcutting), hatchet, log)) { val xp = log.int("xp") / 10.0 player.exp(Skill.Woodcutting, xp) tryDropNest(player, ivy) @@ -103,7 +107,7 @@ class Woodcutting(val drops: DropTables) : Script { } fun tryDropNest(player: Player, ivy: Boolean) { - val dropChance = 254 + val dropChance = if (player.evilTreeMagic) 127 else 254 if (random.nextInt(dropChance) != 0) return val table = drops.get("birds_nest_table") ?: return @@ -120,26 +124,6 @@ class Woodcutting(val drops: DropTables) : Script { FloorItems.add(tile = dropTile, id = drop.id, amount = drop.amount.first, disappearTicks = 50) } - fun success(level: Int, hatchet: Item, log: RowDefinition): Boolean { - val chanceRange = log.intRange("chance") - val hatchetLowDifference = log.intRange("chance_hatchet_dif_low") - val hatchetHighDifference = log.intRange("chance_hatchet_dif_high") - val lowHatchetChance = calculateChance(hatchet, hatchetLowDifference) - val highHatchetChance = calculateChance(hatchet, hatchetHighDifference) - val chance = chanceRange.first + lowHatchetChance..chanceRange.last + highHatchetChance - return Level.success(level, chance) - } - - fun calculateChance(hatchet: Item, treeHatchetDifferences: IntRange): Int = (0 until hatchet.def["rank", 0]).sumOf { calculateHatchetChance(it, treeHatchetDifferences) } - - /** - * Calculates the chance of success out of 256 given a [hatchet] and the hatchet chances for that tree [treeHatchetDifferences] - * @param hatchet The index of the hatchet (0..7) - * @param treeHatchetDifferences The min and max increase chance between each hatchet - * @return chance of success - */ - fun calculateHatchetChance(hatchet: Int, treeHatchetDifferences: IntRange): Int = if (hatchet % 4 < 2) treeHatchetDifferences.last else treeHatchetDifferences.first - fun addLog(player: Player, log: String): Boolean { if (log == "poison_ivy_berries") { return true @@ -147,14 +131,17 @@ class Woodcutting(val drops: DropTables) : Script { val added = player.inventory.add(log) if (added) { player.message("You get some ${log.toLowerSpaceCase()}.") - } else { - player.inventoryFull() + return true + } + if (bankLog(player, log)) { + return true } - return added + player.inventoryFull() + return false } fun deplete(player: Player, log: RowDefinition, obj: GameObject): Boolean { - val depleteRate = log.int("deplete_rate") / 1000.0 + val depleteRate = log.int("deplete_rate") / if (player.evilTreeMagic) 2000.0 else 1000.0 val depleted = random.nextDouble() <= depleteRate if (!depleted) { return false @@ -173,6 +160,21 @@ class Woodcutting(val drops: DropTables) : Script { return true } + /** + * Evil tree magic escorts logs to the bank once the players inventory is full. + */ + private fun bankLog(player: Player, log: String): Boolean { + if (!player.evilTreeMagic || unbankableLogs.contains(log)) { + return false + } + if (!player.bank.add(log)) { + return false + } + player.gfx("evil_tree_bank") + player.message("The logs are magically escorted to your bank.") + return true + } + /** * Returns regrow delay based on the type of tree and number of players online */ @@ -183,3 +185,23 @@ class Woodcutting(val drops: DropTables) : Script { return ResourceRespawn.ticks(log.int("respawn_delay")) } } + +fun chopSuccess(level: Int, hatchet: Item, log: RowDefinition): Boolean { + val chanceRange = log.intRange("chance") + val hatchetLowDifference = log.intRange("chance_hatchet_dif_low") + val hatchetHighDifference = log.intRange("chance_hatchet_dif_high") + val lowHatchetChance = calculateChance(hatchet, hatchetLowDifference) + val highHatchetChance = calculateChance(hatchet, hatchetHighDifference) + val chance = chanceRange.first + lowHatchetChance..chanceRange.last + highHatchetChance + return Level.success(level, chance) +} + +fun calculateChance(hatchet: Item, treeHatchetDifferences: IntRange): Int = (0 until hatchet.def["rank", 0]).sumOf { calculateHatchetChance(it, treeHatchetDifferences) } + +/** + * Calculates the chance of success out of 256 given a [hatchet] and the hatchet chances for that tree [treeHatchetDifferences] + * @param hatchet The index of the hatchet (0..7) + * @param treeHatchetDifferences The min and max increase chance between each hatchet + * @return chance of success + */ +fun calculateHatchetChance(hatchet: Int, treeHatchetDifferences: IntRange): Int = if (hatchet % 4 < 2) treeHatchetDifferences.last else treeHatchetDifferences.first diff --git a/game/src/main/resources/game.properties b/game/src/main/resources/game.properties index af6181f426..f2a4d60af3 100644 --- a/game/src/main/resources/game.properties +++ b/game/src/main/resources/game.properties @@ -263,6 +263,24 @@ events.shootingStars.enabled=true events.shootingStars.minRespawnTimeMinutes=60 events.shootingStars.maxRespawnTimeMinutes=120 +# Whether evil trees appear as an event in the game world +events.evilTree.enabled=true + +# The time (in minutes) before a new Evil tree seedling sprouts after the last tree died. +# A new tree will appear a random time between the min and max values. +# (identical values will spawn exactly every X minutes) +events.evilTree.minRespawnTimeMinutes=120 +events.evilTree.maxRespawnTimeMinutes=120 + +# Number of ticks between a seedling growing a step on its own, without anyone nurturing it +events.evilTree.growthTicks=100 + +# Number of evil trees a player can interact with each day +events.evilTree.dailyTreeLimit=2 + +# Number of evil tree kindling a player can hand in to the leprechaun each day +events.evilTree.dailyKindlingLimit=200 + # Whether random events are enabled for the server events.randomEvents.active=true diff --git a/game/src/test/kotlin/content/activity/evil_tree/EvilTreeTest.kt b/game/src/test/kotlin/content/activity/evil_tree/EvilTreeTest.kt new file mode 100644 index 0000000000..ade6fa13ef --- /dev/null +++ b/game/src/test/kotlin/content/activity/evil_tree/EvilTreeTest.kt @@ -0,0 +1,319 @@ +package content.activity.evil_tree + +import WorldTest +import dialogueContinue +import dialogueOption +import npcOption +import objectOption +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import world.gregs.voidps.engine.GameLoop +import world.gregs.voidps.engine.client.ui.dialogue +import world.gregs.voidps.engine.entity.World +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.engine.entity.obj.GameObjects +import world.gregs.voidps.engine.entity.obj.ObjectLayer +import world.gregs.voidps.engine.inv.add +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.engine.timer.toTicks +import world.gregs.voidps.type.Tile +import java.util.concurrent.TimeUnit + +internal class EvilTreeTest : WorldTest() { + + @AfterEach + fun resetEvilTree() { + EvilTreeState.reset() + } + + @Test + fun `Nurturing a sapling grows it and gives farming experience`() { + val player = createPlayer(emptyTile.add(1, 3)) + val seedling = sapling("normal") + + player.objectOption(seedling, "Nurture") + tickIf { GameObjects.getLayer(EvilTreeState.centre, ObjectLayer.GROUND)?.id == "evil_tree_seedling" } + + assertEquals("evil_tree_sapling", EvilTreeState.tree.id) + // seed_health of two nurtures, each paying nurture_xp / 10 / seed_health + assertEquals(20.0, player.experience.get(Skill.Farming)) + } + + @Test + fun `A sapling grows on its own`() { + settings["events.evilTree.growthTicks"] = "10" + sapling("normal") + World.timers.start("evil_tree") + + tick(TICK + TICK) + + assertEquals("evil_tree_sapling", EvilTreeState.tree.id) + } + + @Test + fun `Nurturing the last stage grows the tree and spawns roots`() { + val player = createPlayer(emptyTile.add(1, 3)) + val young = sapling("normal", "evil_tree_young_large", emptyTile) + + player.objectOption(young, "Nurture") + tickIf { !EvilTreeState.grown } + + assertEquals("evil_tree_normal_full", EvilTreeState.tree.id) + assertEquals(150, EvilTreeState.health) + assertEquals("leprechaun_panic", EvilTreeState.leprechaun.transformId) + // A root bursts out of each of the four sides + tickIf { EvilTreeState.roots.size < 4 } + assertEquals(4, EvilTreeState.roots.size) + } + + @Test + fun `Chopping the tree damages it and gives woodcutting experience`() { + val player = createPlayer(emptyTile.add(1, 3)) + player.levels.set(Skill.Woodcutting, 99) + player.inventory.add("dragon_hatchet") + val tree = grownTree("normal") + + player.objectOption(tree, "Chop") + tickIf { EvilTreeState.health > 145 } + + assertTrue(player.experience.get(Skill.Woodcutting) > 0) + assertTrue(EvilTreeState.health <= 145) + } + + @Test + fun `Losing health swaps the tree for the damaged models`() { + grownTree("normal") + + burnTo(90) // above a third of 150 + assertEquals("evil_tree_normal_half", EvilTreeState.tree.id) + + burnTo(30) + assertEquals("evil_tree_normal_weak", EvilTreeState.tree.id) + } + + @Test + fun `Chopping a root gives kindling`() { + val player = createPlayer(emptyTile.add(1, 4)) + player.levels.set(Skill.Woodcutting, 99) + player.inventory.add("dragon_hatchet") + grownTree("normal") + val root = createRoot() + + player.objectOption(root, "Chop") + tickIf { !player.inventory.contains("evil_tree_kindling") } + + assertTrue(player.inventory.contains("evil_tree_kindling")) + assertTrue(player.experience.get(Skill.Woodcutting) > 0) + } + + @Test + fun `A root dies once it has been chopped through`() { + val player = createPlayer(emptyTile.add(1, 4)) + player.levels.set(Skill.Woodcutting, 99) + player.inventory.add("dragon_hatchet") + grownTree("normal") + val root = createRoot() + + player.objectOption(root, "Chop") + tickIf(limit = 200) { EvilTreeState.roots.isNotEmpty() } + + assertTrue(EvilTreeState.roots.isEmpty()) + assertEquals(3, player.inventory.count("evil_tree_kindling")) + } + + @Test + fun `Burning kindling gives firemaking experience and lights a fire`() { + val player = createPlayer(emptyTile.add(1, 3)) + player.levels.set(Skill.Firemaking, 99) + player.inventory.add("tinderbox") + player.inventory.add("evil_tree_kindling", 2) + val tree = grownTree("normal") + + player.objectOption(tree, "Light fire") + tickIf { player.inventory.count("evil_tree_kindling") == 2 } + + assertEquals(200.0, player.experience.get(Skill.Firemaking)) + assertEquals(1, EvilTreeState.fires.size) + } + + @Test + fun `Lit fires burn the tree down over time`() { + grownTree("normal") + EvilTreeState.fires["zero"] = createObject("evil_tree_fire", emptyTile.add(-1, 1)) + World.timers.start("evil_tree") + + tick(TICK) + + assertEquals(149, EvilTreeState.health) + } + + @Test + fun `Lightning strikes cut the tree down after thirty minutes`() { + grownTree("normal") + World.timers.start("evil_tree") + + EvilTreeState.grownTick = GameLoop.tick.toLong() - TimeUnit.MINUTES.toTicks(10) + tick(TICK) + assertEquals(75, EvilTreeState.health) + + EvilTreeState.grownTick = GameLoop.tick.toLong() - TimeUnit.MINUTES.toTicks(20) + tick(TICK) + assertEquals(37, EvilTreeState.health) + + EvilTreeState.grownTick = GameLoop.tick.toLong() - TimeUnit.MINUTES.toTicks(30) + tick(TICK) + assertEquals(0, EvilTreeState.health) + } + + @Test + fun `A dead tree leaves a stump and schedules the next spawn`() { + grownTree("normal") + + burnTo(0) + + assertEquals("evil_tree_normal_death", EvilTreeState.tree.id) + assertTrue(World.timers.contains("evil_tree_spawn")) + + tick(EvilTree.DEATH_TICKS + TICK) + assertEquals("evil_tree_normal_stump", EvilTreeState.tree.id) + } + + @Test + fun `Handing in kindling pays coins logs and evil tree magic`() { + val player = createPlayer(emptyTile.add(-1, 0)) + player.levels.set(Skill.Woodcutting, 99) + player.inventory.add("evil_tree_kindling", 100) + grownTree("normal") + + claim(player) + + assertEquals(0, player.inventory.count("evil_tree_kindling")) + assertEquals(185, player.inventory.count("coins")) // half of 370 + assertEquals(12, player.inventory.count("logs_noted")) // half of 24 + assertEquals(100, player["evil_tree_kindling_handed", 0]) + assertEquals(180, player["evil_tree_buff", 0]) // half of six minutes, in seconds + } + + @Test + fun `Handing in kindling is capped each day`() { + val player = createPlayer(emptyTile.add(-1, 0)) + player.levels.set(Skill.Woodcutting, 99) + player.inventory.add("evil_tree_kindling", 28) + grownTree("normal") + player["evil_tree_day"] = TimeUnit.MILLISECONDS.toDays(System.currentTimeMillis()) + player["evil_tree_kindling_handed"] = 200 + + claim(player) + + assertEquals(28, player.inventory.count("evil_tree_kindling")) + assertEquals(0, player.inventory.count("coins")) + } + + @Test + fun `Rewards can be taken from the stump`() { + val player = createPlayer(emptyTile.add(1, 3)) + player.levels.set(Skill.Woodcutting, 99) + player.inventory.add("evil_tree_kindling", 200) + player["evil_tree_rewards"] = true + val stump = sapling("normal", "evil_tree_normal_stump", emptyTile) + + player.objectOption(stump, "Take-rewards") + tickIf { player.inventory.contains("evil_tree_kindling") } + + assertEquals(370, player.inventory.count("coins")) + assertEquals(24, player.inventory.count("logs_noted")) + assertFalse(player["evil_tree_rewards", false]) + } + + @Test + fun `The leprechaun lends a hatchet and a tinderbox`() { + val player = createPlayer(emptyTile.add(-1, 0)) + grownTree("normal") + + player.npcOption(EvilTreeState.leprechaun, "Talk-to") + tickIf { player.dialogue == null } + player.dialogueContinue() + player.dialogueOption("line2") + player.dialogueContinue(2) + + assertTrue(player.inventory.contains("bronze_hatchet")) + assertTrue(player.inventory.contains("tinderbox")) + } + + @Test + fun `Interacting with a third tree in a day is refused`() { + val player = createPlayer(emptyTile) + player["evil_tree_day"] = TimeUnit.MILLISECONDS.toDays(System.currentTimeMillis()) + player["evil_tree_trees"] = 2 + player["evil_tree_spawn_id"] = 0 + EvilTreeState.spawnId = 7 + + assertFalse(player.interact()) + assertEquals(2, player["evil_tree_trees", 0]) + } + + @Test + fun `Interacting twice with the same tree only counts once`() { + val player = createPlayer(emptyTile) + EvilTreeState.spawnId = 3 + + assertTrue(player.interact()) + assertTrue(player.interact()) + assertEquals(1, player["evil_tree_trees", 0]) + assertTrue(player["evil_tree_rewards", false]) + } + + private fun sapling(type: String, id: String = "evil_tree_seedling", tile: Tile = emptyTile.add(1, 1)): GameObject { + EvilTreeState.reset() + EvilTreeState.spawnTile = emptyTile + EvilTreeState.type = type + EvilTreeState.spawnId++ + EvilTreeState.tree = createObject(id, tile) + EvilTreeState.leprechaun = createNPC("leprechaun_evil_tree", emptyTile.add(-1, -1)) + return EvilTreeState.tree + } + + private fun grownTree(type: String): GameObject { + sapling(type, "evil_tree_${type}_full", emptyTile) + EvilTreeState.maxHealth = 150 + EvilTreeState.health = 150 + EvilTreeState.grownTick = GameLoop.tick.toLong() + return EvilTreeState.tree + } + + private fun createRoot(): GameObject { + val root = createObject("evil_branches_north", emptyTile.add(1, 3)) + EvilTreeState.roots["north"] = EvilTreeState.Root(root, 3) + return root + } + + /** + * Burns the tree down to [health] through a single tick of one lit fire, so the tree + * takes the damage through the same path players do. + */ + private fun burnTo(health: Int) { + EvilTreeState.health = health + 1 + EvilTreeState.fires["zero"] = createObject("evil_tree_fire", emptyTile.add(-1, 1)) + World.timers.startIfAbsent("evil_tree") + tick(TICK) + EvilTreeState.fires.clear() + assertEquals(health, EvilTreeState.health) + } + + private fun claim(player: Player) { + player.npcOption(EvilTreeState.leprechaun, "Talk-to") + tickIf { player.dialogue == null } + player.dialogueContinue() + player.dialogueOption("line3") + player.dialogueContinue() + } + + companion object { + private const val TICK = EvilTree.TICK_INTERVAL + 1 + } +} From 6423d7abb7df41a733d269ecd00f4d5428bb59db Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Tue, 1 Sep 2026 18:45:44 -0700 Subject: [PATCH 09/11] Fix objects added from a despawn handler vanishing server side `GameObjects.remove` emitted its despawn event midway through its own bookkeeping, before clearing the object's map flag. An object added by a handler claimed that flag, and the clear which followed wiped it again. The client had already been sent the ObjectAddition, so the new object was drawn, but `map` held zero for that tile and layer, which `getLayer` treats as empty. Every server side lookup - `getLayer`, `findOrNull`, `contains` and so the object interaction dispatch - found nothing there. The object was visible and completely inert. Evil tree roots hit this: they burst out as a temporary `_spawn` model and the settled root is added in its place from an `objectDespawn` handler, so the roots could be seen but never chopped. Emit the despawn once the map flag is cleared and any original object underneath has been restored, so handlers only ever observe a settled tile. Two existing tests pinned the old ordering of the event against the original's re-add and have been updated to the new one. Drives the evil tree root tests through the real burst and settle path rather than placing roots into the event state by hand, which is why the suite stayed green while the feature was broken in game. --- .../voidps/engine/entity/obj/GameObjects.kt | 4 +- .../engine/entity/obj/GameObjectsTest.kt | 55 +++++++++++++++- .../activity/evil_tree/EvilTreeTest.kt | 64 ++++++++++++++----- 3 files changed, 104 insertions(+), 19 deletions(-) diff --git a/engine/src/main/kotlin/world/gregs/voidps/engine/entity/obj/GameObjects.kt b/engine/src/main/kotlin/world/gregs/voidps/engine/entity/obj/GameObjects.kt index 66496d0fd3..a023d4784b 100644 --- a/engine/src/main/kotlin/world/gregs/voidps/engine/entity/obj/GameObjects.kt +++ b/engine/src/main/kotlin/world/gregs/voidps/engine/entity/obj/GameObjects.kt @@ -161,7 +161,6 @@ object GameObjects : ZoneBatchUpdates.Sender { collisionRemove.modify(obj) } size-- - Despawn.gameObject(obj) // Re-add original (if exists) map.remove(obj, REPLACED) if (original > 1) { @@ -172,6 +171,9 @@ object GameObjects : ZoneBatchUpdates.Sender { } size++ } + // Emitted last so handlers which add an object in this ones place aren't undone by the + // map bookkeeping above. + Despawn.gameObject(obj) } else if (original == obj.value(replaced = false) && original != 0) { // Remove original map.add(obj, REPLACED) diff --git a/engine/src/test/kotlin/world/gregs/voidps/engine/entity/obj/GameObjectsTest.kt b/engine/src/test/kotlin/world/gregs/voidps/engine/entity/obj/GameObjectsTest.kt index 5298901605..470a77b58d 100644 --- a/engine/src/test/kotlin/world/gregs/voidps/engine/entity/obj/GameObjectsTest.kt +++ b/engine/src/test/kotlin/world/gregs/voidps/engine/entity/obj/GameObjectsTest.kt @@ -143,8 +143,9 @@ class GameObjectsTest : KoinMock() { ZoneBatchUpdates.add(obj.tile.zone, ObjectAddition(tile = obj.tile.id, id = 1234, type = ObjectShape.CENTRE_PIECE_STRAIGHT, rotation = 0)) spawns.add(obj) ZoneBatchUpdates.add(obj.tile.zone, ObjectRemoval(tile = obj.tile.id, type = ObjectShape.CENTRE_PIECE_STRAIGHT, rotation = 0)) - despawns.add(obj) + // The original is restored before the despawn is emitted, so handlers see the tile settled ZoneBatchUpdates.add(obj.tile.zone, ObjectAddition(tile = obj.tile.id, id = 123, type = ObjectShape.CENTRE_PIECE_STRAIGHT, rotation = 1)) + despawns.add(obj) } } @@ -176,8 +177,9 @@ class GameObjectsTest : KoinMock() { spawns.add(override) // Remove 4321 ZoneBatchUpdates.add(obj.tile.zone, ObjectRemoval(tile = obj.tile.id, type = ObjectShape.CENTRE_PIECE_STRAIGHT, rotation = 0)) - despawns.add(override) + // The original is restored before the despawn is emitted, so handlers see the tile settled ZoneBatchUpdates.add(obj.tile.zone, ObjectAddition(tile = obj.tile.id, id = 123, type = ObjectShape.CENTRE_PIECE_STRAIGHT, rotation = 1)) + despawns.add(override) } } @@ -269,6 +271,55 @@ class GameObjectsTest : KoinMock() { assertFalse(GameObjects.contains(second)) } + /** + * Despawn handlers run while [GameObjects.remove] is midway through its own bookkeeping, so an + * object added by one used to be wiped from the map the moment remove resumed - leaving it + * drawn on the client but unreachable by every server side lookup. + */ + @Test + fun `Object added by a despawn handler stays in the world`() { + val obj = GameObject(id = 1234, x = 10, y = 10, level = 0, shape = ObjectShape.CENTRE_PIECE_STRAIGHT, rotation = 1) + val settled = GameObject(id = 4321, x = 10, y = 10, level = 0, shape = ObjectShape.CENTRE_PIECE_STRAIGHT, rotation = 1) + addOnDespawn(obj, settled) + + GameObjects.add(obj, collision = false) + GameObjects.remove(obj, collision = false) + + assertEquals(settled, GameObjects.getLayer(obj.tile, ObjectLayer.GROUND)) + assertTrue(GameObjects.contains(settled)) + assertFalse(GameObjects.contains(obj)) + } + + @Test + fun `Object added by a despawn handler stays in the world over an original`() { + val original = GameObject(id = 1111, x = 10, y = 10, level = 0, shape = ObjectShape.CENTRE_PIECE_STRAIGHT, rotation = 1) + val obj = GameObject(id = 1234, x = 10, y = 10, level = 0, shape = ObjectShape.CENTRE_PIECE_STRAIGHT, rotation = 1) + val settled = GameObject(id = 4321, x = 10, y = 10, level = 0, shape = ObjectShape.CENTRE_PIECE_STRAIGHT, rotation = 1) + GameObjects.set(original.intId, original.x, original.y, original.level, original.shape, original.rotation, ObjectDefinition.EMPTY) + addOnDespawn(obj, settled) + + GameObjects.add(obj, collision = false) + GameObjects.remove(obj, collision = false) + + assertEquals(settled, GameObjects.getLayer(obj.tile, ObjectLayer.GROUND)) + assertTrue(GameObjects.contains(settled)) + } + + /** + * Registers a despawn handler which swaps [obj] out for [replacement] the moment it despawns. + */ + private fun addOnDespawn(obj: GameObject, replacement: GameObject) { + object : Despawn { + init { + objectDespawn { + if (this == obj) { + GameObjects.add(replacement, collision = false) + } + } + } + } + } + @AfterEach fun teardown() { unmockkObject(ZoneBatchUpdates) diff --git a/game/src/test/kotlin/content/activity/evil_tree/EvilTreeTest.kt b/game/src/test/kotlin/content/activity/evil_tree/EvilTreeTest.kt index ade6fa13ef..1d3ee06fa7 100644 --- a/game/src/test/kotlin/content/activity/evil_tree/EvilTreeTest.kt +++ b/game/src/test/kotlin/content/activity/evil_tree/EvilTreeTest.kt @@ -8,10 +8,12 @@ import objectOption import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test import world.gregs.voidps.engine.GameLoop import world.gregs.voidps.engine.client.ui.dialogue +import world.gregs.voidps.engine.data.definition.Tables import world.gregs.voidps.engine.entity.World import world.gregs.voidps.engine.entity.character.player.Player import world.gregs.voidps.engine.entity.character.player.skill.Skill @@ -96,13 +98,22 @@ internal class EvilTreeTest : WorldTest() { assertEquals("evil_tree_normal_weak", EvilTreeState.tree.id) } + @Test + fun `Roots burst out of the ground and settle into the world`() { + matureTree("normal") + + assertEquals(4, EvilTreeState.roots.size) + for (root in EvilTreeState.roots.values) { + assertEquals(root.obj, GameObjects.getLayer(root.obj.tile, ObjectLayer.GROUND)) + assertTrue(GameObjects.contains(root.obj)) + } + } + @Test fun `Chopping a root gives kindling`() { - val player = createPlayer(emptyTile.add(1, 4)) - player.levels.set(Skill.Woodcutting, 99) - player.inventory.add("dragon_hatchet") - grownTree("normal") - val root = createRoot() + matureTree("normal") + val player = chopper(emptyTile.add(1, 4)) + val root = EvilTreeState.roots.getValue("north").obj player.objectOption(root, "Chop") tickIf { !player.inventory.contains("evil_tree_kindling") } @@ -113,16 +124,15 @@ internal class EvilTreeTest : WorldTest() { @Test fun `A root dies once it has been chopped through`() { - val player = createPlayer(emptyTile.add(1, 4)) - player.levels.set(Skill.Woodcutting, 99) - player.inventory.add("dragon_hatchet") - grownTree("normal") - val root = createRoot() + matureTree("normal") + val player = chopper(emptyTile.add(1, 4)) + val root = EvilTreeState.roots.getValue("north").obj player.objectOption(root, "Chop") - tickIf(limit = 200) { EvilTreeState.roots.isNotEmpty() } + tickIf(limit = 200) { EvilTreeState.roots.containsKey("north") } - assertTrue(EvilTreeState.roots.isEmpty()) + assertFalse(EvilTreeState.roots.containsKey("north")) + assertNull(GameObjects.getLayer(root.tile, ObjectLayer.GROUND)) assertEquals(3, player.inventory.count("evil_tree_kindling")) } @@ -286,10 +296,32 @@ internal class EvilTreeTest : WorldTest() { return EvilTreeState.tree } - private fun createRoot(): GameObject { - val root = createObject("evil_branches_north", emptyTile.add(1, 3)) - EvilTreeState.roots["north"] = EvilTreeState.Root(root, 3) - return root + /** + * Grows a tree the whole way through the world tick, so its roots burst out and settle the same + * way they do in game rather than being placed into [EvilTreeState] by hand. + */ + private fun matureTree(type: String): GameObject { + settings["events.evilTree.growthTicks"] = "10" + sapling(type, "evil_tree_${type}_full", emptyTile) + EvilTreeState.maxHealth = 150 + EvilTreeState.health = 150 + EvilTreeState.grownTick = GameLoop.tick.toLong() + World.timers.start("evil_tree") + for (row in Tables.get("evil_branches").rows()) { + val tile = emptyTile.add(row.int("deltaX"), row.int("deltaY")) + GameObjects.add(row.obj("spawn"), tile, rotation = row.int("dir"), ticks = EvilTree.ROOT_BURST_TICKS) + } + tickIf(limit = 100) { EvilTreeState.roots.size < 4 } + // Roots respawn on a timer, which would fight the tests that chop one down + World.timers.clear("evil_tree") + return EvilTreeState.tree + } + + private fun chopper(tile: Tile): Player { + val player = createPlayer(tile) + player.levels.set(Skill.Woodcutting, 99) + player.inventory.add("dragon_hatchet") + return player } /** From 5a1b0e4f890c56f112f1362f8ba0c6ea8a2d7f94 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Tue, 1 Sep 2026 19:34:06 -0700 Subject: [PATCH 10/11] Fix evil tree magic never banking logs The woodcutting loop breaks out on a full inventory before it ever reaches `addLog`, so the banking branch added for evil tree magic was unreachable and logs simply stopped being cut. Let the loop continue while the buff is going to escort the log to the bank, keeping the "inventory is too full" exit for everyone else and for the log types the buff doesn't carry. Also stores the buff's remaining duration in ticks rather than seconds. It was counted down by a timer whose one second interval rounds to a single tick, so the buff ran out in about six tenths of the time it was granted for. --- .../content/activity/evil_tree/EvilTree.kt | 2 +- .../activity/evil_tree/EvilTreeLeprechaun.kt | 7 ++-- .../content/skill/woodcutting/Woodcutting.kt | 9 +++-- .../activity/evil_tree/EvilTreeTest.kt | 2 +- .../skill/woodcutting/WoodcuttingTest.kt | 35 +++++++++++++++++++ 5 files changed, 48 insertions(+), 7 deletions(-) diff --git a/game/src/main/kotlin/content/activity/evil_tree/EvilTree.kt b/game/src/main/kotlin/content/activity/evil_tree/EvilTree.kt index 52db62a491..65c4d517dc 100644 --- a/game/src/main/kotlin/content/activity/evil_tree/EvilTree.kt +++ b/game/src/main/kotlin/content/activity/evil_tree/EvilTree.kt @@ -109,7 +109,7 @@ class EvilTree : Script { } } - timerStart("evil_tree_buff") { TimeUnit.SECONDS.toTicks(1) } + timerStart("evil_tree_buff") { 1 } timerTick("evil_tree_buff") { if (dec("evil_tree_buff") <= 0) Timer.CANCEL else Timer.CONTINUE diff --git a/game/src/main/kotlin/content/activity/evil_tree/EvilTreeLeprechaun.kt b/game/src/main/kotlin/content/activity/evil_tree/EvilTreeLeprechaun.kt index 67a45784ef..0338dd314c 100644 --- a/game/src/main/kotlin/content/activity/evil_tree/EvilTreeLeprechaun.kt +++ b/game/src/main/kotlin/content/activity/evil_tree/EvilTreeLeprechaun.kt @@ -20,6 +20,7 @@ import world.gregs.voidps.engine.inv.inventory import world.gregs.voidps.engine.inv.transact.TransactionError import world.gregs.voidps.engine.inv.transact.operation.AddItem.add import world.gregs.voidps.engine.inv.transact.operation.RemoveItemLimit.removeToLimit +import world.gregs.voidps.engine.timer.toTicks import world.gregs.voidps.type.random import java.util.concurrent.TimeUnit @@ -117,11 +118,11 @@ class EvilTreeLeprechaun : Script { } private fun Player.grantMagic(minutes: Int, scale: Double) { - val seconds = (TimeUnit.MINUTES.toSeconds(minutes.toLong()) * scale).toInt() - if (seconds <= 0) { + val ticks = (TimeUnit.MINUTES.toTicks(minutes) * scale).toInt() + if (ticks <= 0) { return } - inc("evil_tree_buff", seconds) + inc("evil_tree_buff", ticks) timers.startIfAbsent("evil_tree_buff") message("You feel the evil tree's magic settle on you.") } diff --git a/game/src/main/kotlin/content/skill/woodcutting/Woodcutting.kt b/game/src/main/kotlin/content/skill/woodcutting/Woodcutting.kt index c690bd5a65..d15d3ea255 100644 --- a/game/src/main/kotlin/content/skill/woodcutting/Woodcutting.kt +++ b/game/src/main/kotlin/content/skill/woodcutting/Woodcutting.kt @@ -66,7 +66,7 @@ class Woodcutting(val drops: DropTables) : Script { break } - if (!ivy && player.inventory.isFull()) { + if (!ivy && player.inventory.isFull() && !banksLogs(player, log.rowId)) { player.message("Your inventory is too full to hold any more logs.") break } @@ -160,11 +160,16 @@ class Woodcutting(val drops: DropTables) : Script { return true } + /** + * Whether evil tree magic will escort [log] to the players bank rather than their inventory. + */ + private fun banksLogs(player: Player, log: String): Boolean = player.evilTreeMagic && !unbankableLogs.contains(log) && player.bank.spaces > 0 + /** * Evil tree magic escorts logs to the bank once the players inventory is full. */ private fun bankLog(player: Player, log: String): Boolean { - if (!player.evilTreeMagic || unbankableLogs.contains(log)) { + if (!banksLogs(player, log)) { return false } if (!player.bank.add(log)) { diff --git a/game/src/test/kotlin/content/activity/evil_tree/EvilTreeTest.kt b/game/src/test/kotlin/content/activity/evil_tree/EvilTreeTest.kt index 1d3ee06fa7..1075c85900 100644 --- a/game/src/test/kotlin/content/activity/evil_tree/EvilTreeTest.kt +++ b/game/src/test/kotlin/content/activity/evil_tree/EvilTreeTest.kt @@ -206,7 +206,7 @@ internal class EvilTreeTest : WorldTest() { assertEquals(185, player.inventory.count("coins")) // half of 370 assertEquals(12, player.inventory.count("logs_noted")) // half of 24 assertEquals(100, player["evil_tree_kindling_handed", 0]) - assertEquals(180, player["evil_tree_buff", 0]) // half of six minutes, in seconds + assertEquals(300, player["evil_tree_buff", 0]) // half of six minutes, in ticks } @Test diff --git a/game/src/test/kotlin/content/skill/woodcutting/WoodcuttingTest.kt b/game/src/test/kotlin/content/skill/woodcutting/WoodcuttingTest.kt index c72b6a77ba..2125033c23 100644 --- a/game/src/test/kotlin/content/skill/woodcutting/WoodcuttingTest.kt +++ b/game/src/test/kotlin/content/skill/woodcutting/WoodcuttingTest.kt @@ -1,7 +1,10 @@ package content.skill.woodcutting import WorldTest +import containsMessage +import content.entity.player.bank.bank import objectOption +import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertNotEquals import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test @@ -28,4 +31,36 @@ internal class WoodcuttingTest : WorldTest() { assertTrue(player.experience.get(Skill.Woodcutting) > 0) assertNotEquals(tree.id, GameObjects.getLayer(tile, ObjectLayer.GROUND)?.id) } + + @Test + fun `Evil tree magic escorts logs to the bank when the inventory is full`() { + val player = createPlayer(emptyTile) + player.levels.set(Skill.Woodcutting, 100) + player["evil_tree_buff"] = 600 + player.inventory.add("bronze_hatchet", 28) + val tile = emptyTile.addY(1) + val tree = createObject("tree", tile) + + player.objectOption(tree, "Chop down") + tickIf { player.bank.count("logs") == 0 } + + assertTrue(player.bank.count("logs") > 0) + assertTrue(player.inventory.isFull()) + assertTrue(player.containsMessage("The logs are magically escorted to your bank.")) + } + + @Test + fun `A full inventory still stops woodcutting without evil tree magic`() { + val player = createPlayer(emptyTile) + player.levels.set(Skill.Woodcutting, 100) + player.inventory.add("bronze_hatchet", 28) + val tile = emptyTile.addY(1) + val tree = createObject("tree", tile) + + player.objectOption(tree, "Chop down") + tick(10) + + assertEquals(0, player.bank.count("logs")) + assertTrue(player.containsMessage("Your inventory is too full to hold any more logs.")) + } } From 25a0d8f40e166576164ef3159e437d464891307e Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Tue, 1 Sep 2026 19:40:06 -0700 Subject: [PATCH 11/11] Add a command for granting evil tree magic Testing the reward buff's woodcutting effects meant growing a tree, felling it and handing in kindling first. `::evil_tree_magic [minutes]` grants it directly, defaulting to the thirty minutes an elder tree pays out, and takes it away again with 0. --- .../content/activity/evil_tree/EvilTree.kt | 26 +++++++++++++++++++ .../activity/evil_tree/EvilTreeTest.kt | 18 +++++++++++++ 2 files changed, 44 insertions(+) diff --git a/game/src/main/kotlin/content/activity/evil_tree/EvilTree.kt b/game/src/main/kotlin/content/activity/evil_tree/EvilTree.kt index 65c4d517dc..ea92d6e681 100644 --- a/game/src/main/kotlin/content/activity/evil_tree/EvilTree.kt +++ b/game/src/main/kotlin/content/activity/evil_tree/EvilTree.kt @@ -9,9 +9,11 @@ import net.pearx.kasechange.toLowerSpaceCase import world.gregs.voidps.engine.GameLoop import world.gregs.voidps.engine.Script import world.gregs.voidps.engine.client.command.adminCommand +import world.gregs.voidps.engine.client.command.intArg import world.gregs.voidps.engine.client.command.stringArg import world.gregs.voidps.engine.client.message import world.gregs.voidps.engine.client.ui.chat.Colours +import world.gregs.voidps.engine.client.ui.chat.plural import world.gregs.voidps.engine.client.ui.chat.toTag import world.gregs.voidps.engine.client.variable.remaining import world.gregs.voidps.engine.client.variable.start @@ -120,6 +122,29 @@ class EvilTree : Script { } adminCommand("eviltree", stringArg("minutes"), desc = "Start a new evil tree event in [minutes]", handler = ::command) + + adminCommand( + "evil_tree_magic", + intArg("minutes", "how long it should last, 0 to remove it (default $MAX_BUFF_MINUTES)", optional = true), + desc = "Give yourself the evil tree woodcutting reward buff", + handler = ::magicCommand, + ) + } + + /** + * Grants the reward buff without having to fell a tree for it, for testing the woodcutting + * effects it has - banked logs, fewer felled trees and more birds nests. + */ + private fun magicCommand(player: Player, args: List) { + val minutes = args.getOrNull(0)?.toIntOrNull() ?: MAX_BUFF_MINUTES + if (minutes <= 0) { + player["evil_tree_buff"] = 0 + player.timers.stop("evil_tree_buff") + return + } + player["evil_tree_buff"] = TimeUnit.MINUTES.toTicks(minutes) + player.timers.restart("evil_tree_buff") + player.message("Evil tree magic granted for $minutes ${"minute".plural(minutes)}.") } private fun command(player: Player, args: List) { @@ -647,6 +672,7 @@ class EvilTree : Script { const val DEATH_TICKS = 20 const val LIGHTNING_STRIKES = 3 const val LIGHTNING_INTERVAL_MINUTES = 10 + const val MAX_BUFF_MINUTES = 30 const val NOTICE_RADIUS = 8 val ROOT_LIFE = 3..7 } diff --git a/game/src/test/kotlin/content/activity/evil_tree/EvilTreeTest.kt b/game/src/test/kotlin/content/activity/evil_tree/EvilTreeTest.kt index 1075c85900..da3fef12f5 100644 --- a/game/src/test/kotlin/content/activity/evil_tree/EvilTreeTest.kt +++ b/game/src/test/kotlin/content/activity/evil_tree/EvilTreeTest.kt @@ -3,6 +3,7 @@ package content.activity.evil_tree import WorldTest import dialogueContinue import dialogueOption +import kotlinx.coroutines.test.runTest import npcOption import objectOption import org.junit.jupiter.api.AfterEach @@ -12,10 +13,13 @@ import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test import world.gregs.voidps.engine.GameLoop +import world.gregs.voidps.engine.client.command.Commands import world.gregs.voidps.engine.client.ui.dialogue import world.gregs.voidps.engine.data.definition.Tables import world.gregs.voidps.engine.entity.World import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.entity.character.player.PlayerRights +import world.gregs.voidps.engine.entity.character.player.rights import world.gregs.voidps.engine.entity.character.player.skill.Skill import world.gregs.voidps.engine.entity.obj.GameObject import world.gregs.voidps.engine.entity.obj.GameObjects @@ -255,6 +259,20 @@ internal class EvilTreeTest : WorldTest() { assertTrue(player.inventory.contains("tinderbox")) } + @Test + fun `The magic command grants and removes the reward buff`() { + val player = createPlayer(emptyTile) + player.rights = PlayerRights.Admin + + runTest { Commands.call(player, "evil_tree_magic 5") } + assertEquals(TimeUnit.MINUTES.toTicks(5), player["evil_tree_buff", 0]) + assertTrue(player.evilTreeMagic) + + runTest { Commands.call(player, "evil_tree_magic 0") } + assertEquals(0, player["evil_tree_buff", 0]) + assertFalse(player.evilTreeMagic) + } + @Test fun `Interacting with a third tree in a day is refused`() { val player = createPlayer(emptyTile)