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..d38e7638d9 --- /dev/null +++ b/data/activity/evil_tree/evil_tree.anims.toml @@ -0,0 +1,5 @@ +[nurture_sapling] +id = 1132 + +[evil_root_knock_back] +id = 6999 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 new file mode 100644 index 0000000000..b1d89fa538 --- /dev/null +++ b/data/activity/evil_tree/evil_tree.npcs.toml @@ -0,0 +1,5 @@ +[leprechaun_evil_tree] +id = 418 + +[leprechaun_panic] +id = 419 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..c48021d676 --- /dev/null +++ b/data/activity/evil_tree/evil_tree.objs.toml @@ -0,0 +1,250 @@ +[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 +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 = 14846 + +[evil_tree_normal_stump_blank] +id = 12713 +examine = "It looks better dead than alive." + +[evil_tree_normal_stump_reward] +id = 14839 +examine = "Looks like there's rewards to be had!" + +[evil_tree_oak_full] +id = 11437 +examine = "His bite looks tougher than his bark." + +[evil_tree_oak_half] +id = 11438 +examine = "His bite looks tougher than his bark." + +[evil_tree_oak_weak] +id = 11439 +examine = "His bite looks tougher than his bark." + +[evil_tree_oak_death] +id = 11926 + +[evil_tree_oak_stump] +id = 14847 + +[evil_tree_oak_stump_blank] +id = 12714 +examine = "It looks better dead than alive." + +[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_tree_willow_half] +id = 11441 +examine = "He'll weep when he's done being evil!" + +[evil_tree_willow_weak] +id = 11442 +examine = "He'll weep when he's done being evil!" + +[evil_tree_willow_death] +id = 11927 + +[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_tree_maple_half] +id = 11444 +examine = "Where did his red leaves go?" + +[evil_tree_maple_weak] +id = 11915 +examine = "Where did his red leaves go?" + +[evil_tree_maple_death] +id = 11928 + +[evil_tree_maple_stump] +id = 14849 + +[evil_tree_maple_stump_blank] +id = 14835 +examine = "It looks better dead than alive." + +[evil_tree_maple_stump_reward] +id = 14842 +examine = "Looks like there's rewards to be had!" + +[evil_tree_yew_full] +id = 11916 +examine = "A tough evil tree!" + +[evil_tree_yew_half] +id = 11917 +examine = "A tough evil tree!" + +[evil_tree_yew_weak] +id = 11918 +examine = "A tough evil tree!" + +[evil_tree_yew_death] +id = 11929 + +[evil_tree_yew_stump] +id = 14883 + +[evil_tree_yew_stump_blank] +id = 14836 +examine = "It looks better dead than alive." + +[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_tree_magic_half] +id = 11920 +examine = "An incredibly tough challenge!" + +[evil_tree_magic_weak] +id = 11921 +examine = "An incredibly tough challenge!" + +[evil_tree_magic_death] +id = 12711 + +[evil_tree_magic_stump] +id = 14884 + +[evil_tree_magic_stump_blank] +id = 14837 +examine = "It looks better dead than alive." + +[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_tree_elder_half] +id = 11923 +examine = "The most fearsome of evil trees!" + +[evil_tree_elder_weak] +id = 11924 +examine = "The most fearsome of evil trees!" + +[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] +id = 11426 + +[evil_branches_west_spawn] +id = 11427 + +[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_west] +id = 14887 + +[evil_tree_fire_north] +id = 15253 + +[evil_tree_fire_east] +id = 14888 + +[evil_tree_fire_south] +id = 15254 + +[evil_tree_fire_north_west] +id = 14889 + +[evil_tree_fire_south_east] +id = 15255 + +[evil_tree_fire_north_east] +id = 14890 + +[evil_tree_fire_south_west] +id = 15491 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..f15e17045e --- /dev/null +++ b/data/activity/evil_tree/evil_tree.tables.toml @@ -0,0 +1,353 @@ +[evil_tree_type] +woodcutting = "int" +firemaking = "int" +farming = "int" +nurture_xp = "int" +burn_xp = "int" +root_xp = "int" +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 = 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 = 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 = 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 = 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 = 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 = 3110 +burn_xp = 15175 +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 = 85 +firemaking = 85 +farming = 42 +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" +tiles = "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 }, +] + +[evil_branches] +deltaX = "int" +deltaY = "int" +spawn = "obj" +dir = "int" + +[.west] +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] +obj = "obj" +deltaX = "int" +deltaY = "int" +shape = "int" +dir = "int" +varbit = "string" + +[.west] +obj = "evil_tree_fire_west" +deltaX = -1 +deltaY = 1 +shape = 4 +dir = 2 +varbit = "evil_tree_fire_west" + +[.north] +obj = "evil_tree_fire_north" +deltaX = 1 +deltaY = 3 +shape = 4 +dir = 3 +varbit = "evil_tree_fire_north" + +[.east] +obj = "evil_tree_fire_east" +deltaX = 3 +deltaY = 1 +shape = 4 +dir = 0 +varbit = "evil_tree_fire_east" + +[.south] +obj = "evil_tree_fire_south" +deltaX = 1 +deltaY = -1 +shape = 4 +dir = 1 +varbit = "evil_tree_fire_south" + +[.north_west] +obj = "evil_tree_fire_north_west" +deltaX = 0 +deltaY = 2 +shape = 7 +dir = 0 +varbit = "evil_tree_fire_north_west" + +[.south_east] +obj = "evil_tree_fire_south_east" +deltaX = 2 +deltaY = 0 +shape = 7 +dir = 2 +varbit = "evil_tree_fire_south_east" + +[.north_east] +obj = "evil_tree_fire_north_east" +deltaX = 2 +deltaY = 2 +shape = 7 +dir = 1 +varbit = "evil_tree_fire_north_east" + +[.south_west] +obj = "evil_tree_fire_south_west" +deltaX = 0 +deltaY = 0 +shape = 7 +dir = 3 +varbit = "evil_tree_fire_south_west" diff --git a/data/activity/evil_tree/evil_tree.varbits.toml b/data/activity/evil_tree/evil_tree.varbits.toml index d756e014e3..47545c25ec 100644 --- a/data/activity/evil_tree/evil_tree.varbits.toml +++ b/data/activity/evil_tree/evil_tree.varbits.toml @@ -2,3 +2,40 @@ id = 1545 format = "boolean" persist = true + +[evil_tree_rewards] +id = 1542 +format = "boolean" +persist = true + +[evil_tree_fire_west] +id = 1624 +format = "boolean" + +[evil_tree_fire_north] +id = 1625 +format = "boolean" + +[evil_tree_fire_east] +id = 1626 +format = "boolean" + +[evil_tree_fire_south] +id = 1627 +format = "boolean" + +[evil_tree_fire_north_west] +id = 1771 +format = "boolean" + +[evil_tree_fire_south_east] +id = 1772 +format = "boolean" + +[evil_tree_fire_north_east] +id = 1773 +format = "boolean" + +[evil_tree_fire_south_west] +id = 1774 +format = "boolean" 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/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 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/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..03fdd3812b --- /dev/null +++ b/game/src/main/kotlin/content/activity/evil_tree/EvilTree.kt @@ -0,0 +1,856 @@ +package content.activity.evil_tree + +import com.github.michaelbull.logging.InlineLogger +import content.entity.effect.transform +import content.entity.gfx.areaGfx +import content.entity.player.dialogue.type.statement +import content.skill.woodcutting.Hatchet +import content.skill.woodcutting.Woodcutting +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.update.batch.ZoneBatchUpdates +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.World +import world.gregs.voidps.engine.entity.character.mode.EmptyMode +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.inv.add +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.engine.inv.remove +import world.gregs.voidps.engine.map.collision.blocked +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.Zone +import world.gregs.voidps.type.random +import java.util.concurrent.TimeUnit +import kotlin.math.sign + +class EvilTree : + Script, + ZoneBatchUpdates.Sender { + + private val logger = InlineLogger() + + internal var tree: GameObject = GameObject(0) + internal var leprechaun: NPC = NPC() + internal var health: Int = 0 + internal var maxHealth: Int = 0 + internal val roots = mutableMapOf() + internal val fires = mutableMapOf() + private var growth: Int = 0 + private var strikes: Int = 0 + private var growthDelay: Int = 0 + private var rootDelay: Int = 0 + private var deathDelay: Int = 0 + private var respawnTicks: Int = 0 + + init { + ZoneBatchUpdates.register(this) + + worldSpawn { + if (Settings["events.evilTree.enabled", false]) { + schedule(STARTUP_MINUTES) + } + } + + settingsReload { + if (!Settings["events.evilTree.enabled", false]) { + World.timers.clear("evil_tree_spawn") + clear() + return@settingsReload + } + if (!active && !World.timers.contains("evil_tree_spawn")) { + schedule(STARTUP_MINUTES) + } + } + + worldTimerStart("evil_tree_spawn") { respawnTicks } + + worldTimerTick("evil_tree_spawn") { + spawn() + Timer.CANCEL + } + + worldTimerStart("evil_tree") { TICK_INTERVAL } + + worldTimerTick("evil_tree") { tick() } + + objectOperate("Nurture", "evil_tree_seedling,evil_tree_sapling*,evil_tree_young*") { (target) -> + startNurture(target) + } + + objectOperate("Inspect", "evil_tree_*") { + inspect() + } + + objectOperate("Chop", "evil_tree_*") { (target) -> + chop(target) + } + + objectOperate("Chop", "evil_branches_*") { (target) -> + chopRoot(target) + } + + objectOperate("Light fire", "evil_tree_*") { (target) -> + light(target) + } + + objectDespawn("evil_branches_*_spawn") { + settle(this) + } + + objectDespawn("evil_tree_fire_*") { + val side = fires.entries.firstOrNull { it.value == this }?.key ?: return@objectDespawn + fires.remove(side) + updateFires() + } + + playerSpawn { + if (this["evil_tree_buff", 0] > 0) { + timers.restart("evil_tree_buff") + } + } + + timerStart("evil_tree_buff") { 1 } + + timerTick("evil_tree_buff") { + if (dec("evil_tree_buff") <= 0) Timer.CANCEL else Timer.CONTINUE + } + + timerStop("evil_tree_buff") { + message("${Colours.DARK_RED.toTag()}Your evil tree magic has worn off.") + } + + adminCommand("evil_tree", 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) { + clear() + schedule(args.getOrNull(0)?.toIntOrNull() ?: 0) + player.message("Evil tree event scheduled.") + } + + private fun schedule(minutes: Int) { + respawnTicks = TimeUnit.MINUTES.toTicks(minutes) + World.timers.clear("evil_tree_spawn") + World.timers.start("evil_tree_spawn") + } + + private fun spawn() { + clear() + val row = Tables.get("evil_tree_place").rows().random(random) + place = row.rowId + spawnTile = row.tileList("tiles").random(random) + type = Tables.get("evil_tree_type").rows().random(random).rowId + spawnId++ + tree = GameObjects.add("evil_tree_seedling", centre) + displace() + leprechaun = NPCs.add("leprechaun_evil_tree", spawnTile.add(-1, -1)) + World.timers.clear("evil_tree") + World.timers.start("evil_tree") + announce(row) + logger.info { "Evil tree event has started at: $place (${spawnTile.x}, ${spawnTile.y}) type $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() { + if (!active) { + return + } + World.timers.clear("evil_tree") + clearRoots() + clearFires() + GameObjects.remove(tree) + NPCs.remove(leprechaun) + reset() + } + + internal 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() + } + + private fun clearRoots() { + for (root in roots.values) { + GameObjects.remove(root.obj) + } + roots.clear() + } + + private fun clearFires() { + for (fire in fires.values.toList()) { + GameObjects.remove(fire) + } + fires.clear() + for (player in Players) { + player.syncFires() + } + } + + /* + * World tick + */ + + private fun tick(): Int { + if (!active) { + return Timer.CANCEL + } + if (deathDelay > 0) { + deathDelay -= TICK_INTERVAL + if (deathDelay <= 0) { + tree = tree.replace("evil_tree_${type}_stump", spawnTile) + return Timer.CANCEL + } + return Timer.CONTINUE + } + if (!grown) { + growthTick() + return Timer.CONTINUE + } + if (fires.isNotEmpty()) { + damage(fires.size) + if (deathDelay > 0) { + return Timer.CONTINUE + } + } + rootTick() + lightning() + return Timer.CONTINUE + } + + private fun growthTick() { + notice() + growthDelay += TICK_INTERVAL + if (growthDelay < Settings["events.evilTree.growthTicks", 100]) { + return + } + growthDelay = 0 + addGrowth() + } + + /** + * Roots briefly shoot out of the ground around a sprouting sapling, once per player per tree. + */ + private fun notice() { + Players.forEachInRadius(centre, NOTICE_RADIUS) { player -> + if (player["evil_tree_noticed", 0] == spawnId) { + return@forEachInRadius + } + player["evil_tree_noticed"] = spawnId + areaGfx("evil_root", player.tile) + player.say("What was that?") + } + } + + private fun rootTick() { + rootDelay += TICK_INTERVAL + if (rootDelay < ROOT_RESPAWN_TICKS) { + return + } + rootDelay = 0 + val row = Tables.get("evil_branches").rows().filterNot { roots.containsKey(it.rowId) }.randomOrNull(random) ?: return + spawnRoot(row) + } + + private fun lightning() { + if (strikes >= LIGHTNING_STRIKES) { + return + } + val minutes = (GameLoop.tick - grownTick) / TimeUnit.MINUTES.toTicks(1) + if (minutes < (strikes + 1) * LIGHTNING_INTERVAL_MINUTES) { + return + } + strikes++ + say("A bolt of lightning strikes the evil tree!") + if (strikes >= LIGHTNING_STRIKES) { + damage(health) + return + } + val cap = maxHealth shr strikes + if (health > cap) { + damage(health - cap) + } + } + + private fun say(message: String) { + leprechaun.say(message) + Players.forEachInRadius(centre, 15) { player -> + player.message("${Colours.DARK_RED.toTag()}$message") + } + } + + /* + * Growth + */ + + private fun Player.startNurture(target: GameObject) { + if (!isTree(target) || !sapling) { + return + } + val level = Tables.intOrNull("evil_tree_type.$type.farming") ?: return + if (!has(Skill.Farming, level, " to help this sapling grow")) { + return + } + if (!evilTreeInteract()) { + return + } + message("You begin tending to the sapling.", ChatType.Filter) + nurture(target) + } + + private fun Player.nurture(target: GameObject) { + if (!isTree(target) || !sapling) { + return + } + anim("nurture_sapling") + weakQueue("nurture_evil_sapling", 3) { + if (!isTree(target) || !sapling) { + return@weakQueue + } + exp(Skill.Farming, nurtureExperience()) + addGrowth() + nurture(tree) + } + } + + private fun nurtureExperience(): Double { + val row = Rows.get("evil_tree_type.$type") + return row.int("nurture_xp") / 10.0 / row.int("seed_health") + } + + private fun addGrowth() { + growth++ + if (growth < Tables.int("evil_tree_type.$type.seed_health")) { + return + } + growth = 0 + grow() + } + + private fun grow() { + tree = when (tree.id) { + "evil_tree_seedling" -> tree.replace("evil_tree_sapling", centre) + "evil_tree_sapling" -> tree.replace("evil_tree_sapling_large", centre) + "evil_tree_sapling_large" -> { + displace() + tree.replace("evil_tree_young", spawnTile) + } + "evil_tree_young" -> tree.replace("evil_tree_young_large", spawnTile) + "evil_tree_young_large" -> return mature() + else -> return + } + leprechaun.say("Whoa!") + } + + private fun mature() { + maxHealth = Tables.int("evil_tree_type.$type.health") + health = maxHealth + grownTick = GameLoop.tick.toLong() + tree = tree.replace("evil_tree_${type}_full", spawnTile) + leprechaun.transform("leprechaun_panic") + 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() { + for (tile in spawnTile.toCuboid(3, 3)) { + for (player in Players.at(tile)) { + push(player) + player.face(centre) + } + } + } + + /** + * Forces [player] one tile away from the tree, cancelling whatever they were doing to it. + */ + private fun push(player: Player) { + val delta = player.tile.delta(centre) + val away = if (delta.x == 0 && delta.y == 0) Direction.cardinal.random(random) else delta.toDirection() + val direction = listOf(away, Direction.of(away.delta.x, 0), Direction.of(0, away.delta.y)) + .firstOrNull { it != Direction.NONE && !player.blocked(it) } ?: return + player.suspension = null + player.mode = EmptyMode + player.exactMove(direction.delta, PUSH_DELAY, direction = direction) + } + + /** + * Roots throw [player] back from the tree and leave them stunned for a moment. + */ + private fun knockAway(player: Player) { + push(player) + player.anim("evil_root_knock_back") + player.gfx("stun_long") + player["delay"] = STUN_TICKS + player.start("stunned", STUN_TICKS) + player.start("movement_delay", STUN_TICKS) + } + + /** + * Whether a settled root is lashing out at the player for working on the tree right beside it. + * Chopping the root itself is safe. + */ + private fun Player.swept(): Boolean { + if (!tile.within(centre, SWEEP_RADIUS) || roots.values.none { tile.within(it.obj.tile, 1) }) { + return false + } + knockAway(this) + return true + } + + /* + * Roots + */ + + internal fun spawnRoot(row: RowDefinition) { + if (roots.containsKey(row.rowId)) { + return + } + val tile = 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) { + if (!alive) { + return + } + val row = Tables.get("evil_branches").rows().firstOrNull { it.obj("spawn") == spawn.id } ?: return + if (roots.containsKey(row.rowId)) { + return + } + val root = GameObjects.add(spawn.id.removeSuffix("_spawn"), spawn.tile, rotation = spawn.rotation) + roots[row.rowId] = 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.") + knockAway(player) + } + } + + private fun killRoot(row: String) { + val root = roots.remove(row) ?: return + GameObjects.remove(root.obj) + } + + /* + * Chopping + */ + + private suspend fun Player.chop(target: GameObject) { + if (!isTree(target) || !alive) { + return + } + val hatchet = hatchet() ?: return + val row = Rows.get("evil_tree_type.$type") + if (!has(Skill.Woodcutting, row.int("woodcutting"), message = true) || !evilTreeInteract()) { + return + } + val rates = Rows.get("logs.${row.itemList("reward_logs").first()}") + while (awaitDialogues()) { + if (!alive || deathDelay > 0) { + break + } + if (swept()) { + return + } + if (!Hatchet.hasRequirements(this, hatchet, message = true)) { + break + } + if (!swing(hatchet.id)) { + continue + } + if (!Woodcutting.success(levels.get(Skill.Woodcutting), hatchet, rates)) { + continue + } + exp(Skill.Woodcutting, row.int("tree_xp") / 10.0) + damage(1) + } + clearAnim() + } + + private suspend fun Player.chopRoot(target: GameObject) { + val side = roots.entries.firstOrNull { it.value.obj == target }?.key ?: return + val hatchet = hatchet() ?: return + val row = Rows.get("evil_tree_type.$type") + if (!has(Skill.Woodcutting, row.int("woodcutting"), message = true) || !evilTreeInteract()) { + return + } + val rates = Rows.get("logs.${row.itemList("reward_logs").first()}") + while (awaitDialogues()) { + val root = roots[side] + if (root == null || root.obj != target) { + break + } + if (!Hatchet.hasRequirements(this, hatchet, message = true)) { + break + } + if (inventory.isFull()) { + message("Your inventory is too full to hold any more kindling.") + break + } + if (!swing(hatchet.id)) { + continue + } + if (!Woodcutting.success(levels.get(Skill.Woodcutting), hatchet, rates)) { + continue + } + exp(Skill.Woodcutting, row.int("root_xp") / 10.0) + inventory.add("evil_tree_kindling") + if (--root.life <= 0) { + killRoot(side) + break + } + } + 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 + } + + /* + * Fires + */ + + private suspend fun Player.light(target: GameObject) { + if (!isTree(target) || !alive) { + return + } + val row = Rows.get("evil_tree_type.$type") + if (!has(Skill.Firemaking, row.int("firemaking"), message = " to set fire to this evil tree") || !evilTreeInteract()) { + return + } + val spot = fireSpot(tile) ?: run { + message("You can't light a fire here.") + return + } + var first = true + while (awaitDialogues()) { + if (!alive || deathDelay > 0) { + break + } + if (fires.containsKey(spot.rowId)) { + message("That part of the tree is already on fire!") + break + } + if (swept()) { + return + } + if (!inventory.contains("tinderbox")) { + message("You need a tinderbox in order to light a fire.") + break + } + if (!inventory.contains("evil_tree_kindling")) { + message("You don't have any kindling to burn.") + break + } + val remaining = remaining("action_delay") + if (remaining > 0) { + pause(remaining) + continue + } + if (first) { + message("You crouch to light the kindling.") + first = false + } + anim("light_fire") + start("action_delay", LIGHT_TICKS) + pause(LIGHT_TICKS) + if (!alive || deathDelay > 0 || fires.containsKey(spot.rowId)) { + continue + } + if (!inventory.remove("evil_tree_kindling")) { + break + } + exp(Skill.Firemaking, row.int("burn_xp") / 10.0) + val fireTile = spawnTile.add(spot.int("deltaX"), spot.int("deltaY")) + fires[spot.rowId] = GameObjects.add(spot.obj("obj"), fireTile, spot.int("shape"), spot.int("dir"), ticks = row.int("fire_life")) + updateFires() + break + } + clearAnim() + } + + /** + * The fire spot on the side of the tree [tile] is on: one of the four sides and four corners. + */ + private fun fireSpot(tile: Tile): RowDefinition? { + val delta = tile.delta(centre) + val direction = Direction.of(delta.x.sign, delta.y.sign) + return Rows.getOrNull("evil_fires.${direction.name.lowercase()}") + } + + /** + * The fires are varbit multilocs, the only way the client will animate them, so everyone + * nearby has their copy of the fire varbits kept in step with which fires are lit. + */ + private fun updateFires() { + Players.forEachInRadius(centre, FIRE_SYNC_RADIUS) { player -> + player.syncFires() + } + } + + private fun Player.syncFires() { + for (row in Tables.get("evil_fires").rows()) { + val varbit = row.string("varbit") + val lit = fires.containsKey(row.rowId) + if (this[varbit, false] != lit) { + this[varbit] = lit + } + } + } + + /** + * Players walking in after a fire was lit, or who left with one still burning, need their varbits caught up. + */ + override fun send(player: Player, zone: Zone) { + if (active && player.tile.within(centre, FIRE_SYNC_RADIUS)) { + player.syncFires() + } + } + + /* + * Damage and death + */ + + private fun damage(amount: Int) { + if (!alive || deathDelay > 0) { + return + } + health = (health - amount).coerceAtLeast(0) + if (health <= 0) { + kill() + return + } + val id = when { + health * 3 > maxHealth * 2 -> "evil_tree_${type}_full" + health * 3 > maxHealth -> "evil_tree_${type}_half" + else -> "evil_tree_${type}_weak" + } + if (tree.id == id) { + return + } + tree = tree.replace(id, spawnTile) + } + + private fun kill() { + dead = true + clearRoots() + clearFires() + leprechaun.transform("leprechaun_evil_tree") + tree = tree.replace("evil_tree_${type}_death", spawnTile) + deathDelay = DEATH_TICKS + val minutes = Settings["events.evilTree.minRespawnTimeMinutes", 120]..Settings["events.evilTree.maxRespawnTimeMinutes", 120] + schedule(minutes.random(random)) + } + + /* + * Inspect + */ + + private suspend fun Player.inspect() { + val level = Tables.intOrNull("evil_tree_type.$type.woodcutting") ?: return + val name = if (type == "normal") "Evil tree" else "Evil ${type.toLowerSpaceCase()} tree" + if (!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 percent = (health * 100) / 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.") + } + + internal class Root(var obj: GameObject, var life: Int) + + companion object { + var place: String = "" + var type: String = "normal" + var spawnTile: Tile = Tile.EMPTY + var spawnId: Int = 0 + var grownTick: Long = NOT_GROWN + var dead: Boolean = false + private set + + 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. + */ + private fun isTree(obj: GameObject): Boolean = active && (obj.tile == spawnTile || obj.tile == centre) + + const val NOT_GROWN = -1L + 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 PUSH_DELAY = 60 + const val SWEEP_RADIUS = 2 + const val FIRE_SYNC_RADIUS = 52 + 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 MAX_BUFF_MINUTES = 30 + const val NOTICE_RADIUS = 8 + val ROOT_LIFE = 3..7 + } +} + +/** + * 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 + +/** + * Rolls the players daily evil tree counters over when the day changes. + */ +fun Player.evilTreeDailyReset() { + 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.evilTreeInteract(): Boolean { + evilTreeDailyReset() + if (this["evil_tree_spawn_id", 0] == EvilTree.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"] = EvilTree.spawnId + inc("evil_tree_trees") + this["evil_tree_rewards"] = true + return true +} 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..10da41b354 --- /dev/null +++ b/game/src/main/kotlin/content/activity/evil_tree/EvilTreeLeprechaun.kt @@ -0,0 +1,132 @@ +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.engine.timer.toTicks +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() { + if (!EvilTree.active) { + npc("leprechaun_evil_tree", "There's no evil tree about just now.") + return + } + val row = Rows.get("evil_tree_type.${EvilTree.type}") + if (!has(Skill.Woodcutting, row.int("woodcutting"), message = true)) { + return + } + val limit = Settings["events.evilTree.dailyKindlingLimit", 200] + evilTreeDailyReset() + 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", EvilTree.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 ticks = (TimeUnit.MINUTES.toTicks(minutes) * scale).toInt() + if (ticks <= 0) { + return + } + inc("evil_tree_buff", ticks) + timers.startIfAbsent("evil_tree_buff") + message("You feel the evil tree's magic settle on you.") + } + + companion object { + val TOOLS = listOf("bronze_hatchet", "tinderbox") + } +} 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..689292628b 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,46 +1,76 @@ package content.quest.member.the_grand_tree -import content.entity.player.dialogue.Happy +import content.activity.evil_tree.EvilTree 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.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 { 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.sapling -> "interested in the strange sapling?" + EvilTree.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.sapling) { + option("Strange sapling.") { + 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.${EvilTree.place}.hint")}.") + } + } else { + option("Evil tree.") { + if (!EvilTree.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.${EvilTree.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) + } + 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.") + } } } @@ -86,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..438728f898 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) @@ -62,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 } @@ -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,26 @@ 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 (!banksLogs(player, 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 */ @@ -182,4 +189,26 @@ class Woodcutting(val drops: DropTables) : Script { } return ResourceRespawn.ticks(log.int("respawn_delay")) } + + companion object { + 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 + } } 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..c69cacdd4e --- /dev/null +++ b/game/src/test/kotlin/content/activity/evil_tree/EvilTreeTest.kt @@ -0,0 +1,405 @@ +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 +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.command.Commands +import world.gregs.voidps.engine.client.ui.dialogue +import world.gregs.voidps.engine.data.definition.Rows +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 +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() { + + private val evilTree: EvilTree + get() = scripts.filterIsInstance().first() + + @AfterEach + fun resetEvilTree() { + evilTree.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(EvilTree.centre, ObjectLayer.GROUND)?.id == "evil_tree_seedling" } + + assertEquals("evil_tree_sapling", evilTree.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", evilTree.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 { !EvilTree.grown } + + assertEquals("evil_tree_normal_full", evilTree.tree.id) + assertEquals(150, evilTree.health) + assertEquals("leprechaun_panic", evilTree.leprechaun.transformId) + // A root bursts out of each of the four sides + tickIf { evilTree.roots.size < 4 } + assertEquals(4, evilTree.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 { evilTree.health > 145 } + + assertTrue(player.experience.get(Skill.Woodcutting) > 0) + assertTrue(evilTree.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", evilTree.tree.id) + + burnTo(30) + assertEquals("evil_tree_normal_weak", evilTree.tree.id) + } + + @Test + fun `Roots burst out of the ground and settle into the world`() { + matureTree("normal") + + assertEquals(4, evilTree.roots.size) + for (root in evilTree.roots.values) { + assertEquals(root.obj, GameObjects.getLayer(root.obj.tile, ObjectLayer.GROUND)) + assertTrue(GameObjects.contains(root.obj)) + } + } + + @Test + fun `Chopping a root gives kindling`() { + matureTree("normal") + val player = chopper(emptyTile.add(1, 4)) + val root = evilTree.roots.getValue("north").obj + + 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`() { + matureTree("normal") + val player = chopper(emptyTile.add(1, 4)) + val root = evilTree.roots.getValue("north").obj + + player.objectOption(root, "Chop") + tickIf(limit = 200) { evilTree.roots.containsKey("north") } + + assertFalse(evilTree.roots.containsKey("north")) + assertNull(GameObjects.getLayer(root.tile, ObjectLayer.GROUND)) + assertEquals(3, player.inventory.count("evil_tree_kindling")) + } + + @Test + fun `Roots sweep away anyone chopping the tree beside them`() { + matureTree("normal") + // Next to the north root, which sits on the tile to the east + val player = chopper(emptyTile.add(0, 3)) + + player.objectOption(evilTree.tree, "Chop") + tickIf { player.tile == emptyTile.add(0, 3) } + + assertEquals(emptyTile.add(-1, 4), player.tile) + assertEquals(150, evilTree.health) + } + + @Test + fun `A bursting root throws back anyone lighting a fire on its tile`() { + 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") + tick(2) + evilTree.spawnRoot(Rows.get("evil_branches.north")) + tick(EvilTree.LIGHT_TICKS + 2) + + assertEquals(emptyTile.add(1, 4), player.tile) + assertEquals(2, player.inventory.count("evil_tree_kindling")) + assertTrue(evilTree.fires.isEmpty()) + } + + @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)) + assertTrue(evilTree.fires.containsKey("north")) + assertTrue(player["evil_tree_fire_north", false]) + } + + @Test + fun `Lit fires burn the tree down over time`() { + grownTree("normal") + evilTree.fires["west"] = createObject("evil_tree_fire_west", emptyTile.add(-1, 1)) + World.timers.start("evil_tree") + + tick(TICK) + + assertEquals(149, evilTree.health) + } + + @Test + fun `Lightning strikes cut the tree down after thirty minutes`() { + grownTree("normal") + World.timers.start("evil_tree") + + EvilTree.grownTick = GameLoop.tick.toLong() - TimeUnit.MINUTES.toTicks(10) + tick(TICK) + assertEquals(75, evilTree.health) + + EvilTree.grownTick = GameLoop.tick.toLong() - TimeUnit.MINUTES.toTicks(20) + tick(TICK) + assertEquals(37, evilTree.health) + + EvilTree.grownTick = GameLoop.tick.toLong() - TimeUnit.MINUTES.toTicks(30) + tick(TICK) + assertEquals(0, evilTree.health) + } + + @Test + fun `A dead tree leaves a stump and schedules the next spawn`() { + grownTree("normal") + + burnTo(0) + + assertEquals("evil_tree_normal_death", evilTree.tree.id) + assertTrue(World.timers.contains("evil_tree_spawn")) + + tick(EvilTree.DEATH_TICKS + TICK) + assertEquals("evil_tree_normal_stump", evilTree.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(300, player["evil_tree_buff", 0]) // half of six minutes, in ticks + } + + @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(evilTree.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 `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) + player["evil_tree_day"] = TimeUnit.MILLISECONDS.toDays(System.currentTimeMillis()) + player["evil_tree_trees"] = 2 + player["evil_tree_spawn_id"] = 0 + EvilTree.spawnId = 7 + + assertFalse(player.evilTreeInteract()) + assertEquals(2, player["evil_tree_trees", 0]) + } + + @Test + fun `Interacting twice with the same tree only counts once`() { + val player = createPlayer(emptyTile) + EvilTree.spawnId = 3 + + assertTrue(player.evilTreeInteract()) + assertTrue(player.evilTreeInteract()) + 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 { + evilTree.reset() + EvilTree.spawnTile = emptyTile + EvilTree.type = type + EvilTree.spawnId++ + evilTree.tree = createObject(id, tile) + evilTree.leprechaun = createNPC("leprechaun_evil_tree", emptyTile.add(-1, -1)) + return evilTree.tree + } + + private fun grownTree(type: String): GameObject { + sapling(type, "evil_tree_${type}_full", emptyTile) + evilTree.maxHealth = 150 + evilTree.health = 150 + EvilTree.grownTick = GameLoop.tick.toLong() + return evilTree.tree + } + + /** + * 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 [EvilTree] by hand. + */ + private fun matureTree(type: String): GameObject { + settings["events.evilTree.growthTicks"] = "10" + sapling(type, "evil_tree_${type}_full", emptyTile) + evilTree.maxHealth = 150 + evilTree.health = 150 + EvilTree.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) { evilTree.roots.size < 4 } + // Roots respawn on a timer, which would fight the tests that chop one down + World.timers.clear("evil_tree") + return evilTree.tree + } + + private fun chopper(tile: Tile): Player { + val player = createPlayer(tile) + player.levels.set(Skill.Woodcutting, 99) + player.inventory.add("dragon_hatchet") + return player + } + + /** + * 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) { + evilTree.health = health + 1 + evilTree.fires["west"] = createObject("evil_tree_fire_west", emptyTile.add(-1, 1)) + World.timers.startIfAbsent("evil_tree") + tick(TICK) + evilTree.fires.clear() + assertEquals(health, evilTree.health) + } + + private fun claim(player: Player) { + player.npcOption(evilTree.leprechaun, "Talk-to") + tickIf { player.dialogue == null } + player.dialogueContinue() + player.dialogueOption("line3") + player.dialogueContinue() + } + + companion object { + private const val TICK = EvilTree.TICK_INTERVAL + 1 + } +} 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.")) + } }