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.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..5731059efc --- /dev/null +++ b/data/activity/evil_tree/evil_tree.objs.toml @@ -0,0 +1,229 @@ +[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] +id = 11425 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..d865c8e480 --- /dev/null +++ b/data/activity/evil_tree/evil_tree.tables.toml @@ -0,0 +1,346 @@ +[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] +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..9719735037 100644 --- a/data/activity/evil_tree/evil_tree.varbits.toml +++ b/data/activity/evil_tree/evil_tree.varbits.toml @@ -2,3 +2,8 @@ id = 1545 format = "boolean" persist = true + +[evil_tree_rewards] +id = 1542 +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/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..ea92d6e681 --- /dev/null +++ b/game/src/main/kotlin/content/activity/evil_tree/EvilTree.kt @@ -0,0 +1,679 @@ +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 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.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 +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.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.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 { + + private val logger = InlineLogger() + + init { + 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 (!EvilTreeState.active && !World.timers.contains("evil_tree_spawn")) { + schedule(STARTUP_MINUTES) + } + } + + worldTimerStart("evil_tree_spawn") { EvilTreeState.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") { + EvilTreeState.fires.values.remove(this) + } + + 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("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) { + 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 + } + } + 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 say(message: String) { + val state = EvilTreeState + state.leprechaun.say(message) + Players.forEachInRadius(state.centre, 15) { player -> + player.message("${Colours.DARK_RED.toTag()}$message") + } + } + + /* + * 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 (!state.isTree(target) || !state.sapling) { + return@weakQueue + } + exp(Skill.Farming, nurtureExperience()) + addGrowth() + nurture(state.tree) + } + } + + 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 + } + 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()) { + if (!state.alive || state.deathDelay > 0) { + break + } + if (!Hatchet.hasRequirements(this, hatchet, message = true)) { + break + } + if (!swing(hatchet.id)) { + continue + } + if (!chopSuccess(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 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 root = state.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 (!chopSuccess(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) { + 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 + } + 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 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 + } + if (first) { + message("You crouch to light the kindling.") + first = false + } + anim("light_fire") + start("action_delay", LIGHT_TICKS) + pause(LIGHT_TICKS) + if (!state.alive || state.deathDelay > 0 || state.fires.containsKey(spot.rowId)) { + continue + } + 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 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 + } + 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) + } + + 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)) + } + + /* + * Inspect + */ + + 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 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 MAX_BUFF_MINUTES = 30 + const val NOTICE_RADIUS = 8 + val ROOT_LIFE = 3..7 + } +} 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..0338dd314c --- /dev/null +++ b/game/src/main/kotlin/content/activity/evil_tree/EvilTreeLeprechaun.kt @@ -0,0 +1,166 @@ +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() { + 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 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") + } +} + +/** + * 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/quest/member/the_grand_tree/SpiritTree.kt b/game/src/main/kotlin/content/quest/member/the_grand_tree/SpiritTree.kt index bffc8a0c22..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,46 +1,76 @@ package content.quest.member.the_grand_tree -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 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 { + 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 (EvilTreeState.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.${EvilTreeState.place}.hint")}.") + } + } else { + option("Evil tree.") { + 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, 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!") + } + 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..d15d3ea255 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 } @@ -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,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 */ @@ -183,3 +190,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..da3fef12f5 --- /dev/null +++ b/game/src/test/kotlin/content/activity/evil_tree/EvilTreeTest.kt @@ -0,0 +1,369 @@ +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.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() { + + @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 `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`() { + 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") } + + 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 = EvilTreeState.roots.getValue("north").obj + + player.objectOption(root, "Chop") + tickIf(limit = 200) { EvilTreeState.roots.containsKey("north") } + + assertFalse(EvilTreeState.roots.containsKey("north")) + assertNull(GameObjects.getLayer(root.tile, ObjectLayer.GROUND)) + 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(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(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 `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 + 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 + } + + /** + * 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 + } + + /** + * 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 + } +} 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.")) + } }