diff --git a/cache/src/main/kotlin/world/gregs/voidps/cache/definition/Params.kt b/cache/src/main/kotlin/world/gregs/voidps/cache/definition/Params.kt index 98254297e9..85febbf429 100644 --- a/cache/src/main/kotlin/world/gregs/voidps/cache/definition/Params.kt +++ b/cache/src/main/kotlin/world/gregs/voidps/cache/definition/Params.kt @@ -262,9 +262,11 @@ object Params { const val QUEST_INFO = 5262 const val VARIABLES = 5263 const val SUMMONING_BEAST_OF_BURDEN_ESSENCE = 5264 + const val SUMMONING_SPECIAL_COST = 5265 private fun custom(name: String) = when (name) { "summoning_beast_of_burden_essence" -> SUMMONING_BEAST_OF_BURDEN_ESSENCE + "summoning_special_cost" -> SUMMONING_SPECIAL_COST "variables" -> VARIABLES "quest_info" -> QUEST_INFO "skill_cape_t" -> SKILL_CAPE_T diff --git a/cache/src/main/kotlin/world/gregs/voidps/cache/definition/encoder/GraphicEncoder.kt b/cache/src/main/kotlin/world/gregs/voidps/cache/definition/encoder/GraphicEncoder.kt new file mode 100644 index 0000000000..bf00ae008e --- /dev/null +++ b/cache/src/main/kotlin/world/gregs/voidps/cache/definition/encoder/GraphicEncoder.kt @@ -0,0 +1,77 @@ +package world.gregs.voidps.cache.definition.encoder + +import world.gregs.voidps.buffer.write.Writer +import world.gregs.voidps.cache.DefinitionEncoder +import world.gregs.voidps.cache.definition.data.GraphicDefinition + +class GraphicEncoder : DefinitionEncoder { + + override fun Writer.encode(definition: GraphicDefinition, members: GraphicDefinition) { + if (definition.id == -1) { + return + } + + if (definition.modelId != 0) { + writeByte(1) + writeShort(definition.modelId) + } + + if (definition.animationId != -1) { + writeByte(2) + writeShort(definition.animationId) + } + + if (definition.sizeXY != 128) { + writeByte(4) + writeShort(definition.sizeXY) + } + + if (definition.sizeZ != 128) { + writeByte(5) + writeShort(definition.sizeZ) + } + + if (definition.rotation != 0) { + writeByte(6) + writeShort(definition.rotation) + } + + if (definition.ambience != 0) { + writeByte(7) + writeByte(definition.ambience) + } + + if (definition.contrast != 0) { + writeByte(8) + writeByte(definition.contrast) + } + + when (definition.aByte2381.toInt()) { + 1 -> writeByte(11) + 2 -> { + writeByte(14) + writeByte(definition.anInt2385 / 256) + } + 3 -> when { + definition.anInt2385 == 8224 -> writeByte(9) + definition.anInt2385 in 0..0xffff -> { + writeByte(15) + writeShort(definition.anInt2385) + } + else -> { + writeByte(16) + writeInt(definition.anInt2385) + } + } + 4 -> writeByte(12) + 5 -> writeByte(13) + } + + if (definition.aBoolean2402) { + writeByte(10) + } + + definition.writeColoursTextures(this) + writeByte(0) + } +} diff --git a/data/area/asgarnia/taverley/taverley.npc-spawns.toml b/data/area/asgarnia/taverley/taverley.npc-spawns.toml index 176030bee5..3a47f1896b 100644 --- a/data/area/asgarnia/taverley/taverley.npc-spawns.toml +++ b/data/area/asgarnia/taverley/taverley.npc-spawns.toml @@ -1,8 +1,7 @@ spawns = [ # 11573 { id = "sanfew", x = 2897, y = 3426, level = 1, members = true }, - { id = "pikkupstix_spawn", x = 2925, y = 3443 }, - { id = "pikkupstix_spawn_2", x = 2925, y = 3443 }, + { id = "pikkupstix", x = 2925, y = 3443 }, { id = "thaerisk_cemphier", x = 2916, y = 3449, level = 1, members = true }, { id = "ivy_sophista", x = 2917, y = 3449, members = true }, { id = "dalmatian", x = 2927, y = 3431, members = true }, diff --git a/data/client/all.enums.toml b/data/client/all.enums.toml index a05c23aa43..2e045d44cd 100644 --- a/data/client/all.enums.toml +++ b/data/client/all.enums.toml @@ -371,11 +371,11 @@ id = 1187 [summoning_scroll_ids_1] id = 1188 -# Used if varp 4282 > 50 +# Chathead animation from varbit 4282 minus 50, used by cs2 751 when the varbit is over 50 [pet_details_chathead_animations_sad] id = 1275 -# Used if varp 4282 <= 50 +# Chathead animation from varbit 4282, used by cs2 751 when the varbit is 50 or under [pet_details_chathead_animations_normal] id = 1276 diff --git a/data/entity/player/modal/toplevel/gameframe.ifaces.toml b/data/entity/player/modal/toplevel/gameframe.ifaces.toml index 79447a3bf1..dc354f245e 100644 --- a/data/entity/player/modal/toplevel/gameframe.ifaces.toml +++ b/data/entity/player/modal/toplevel/gameframe.ifaces.toml @@ -210,12 +210,11 @@ id = 12 [.renew_familiar] id = 13 -# Can't yet confirm that these 2 are cast / attack. -# Likely IDs 21 and 22 will be their left-click options as well -#[.n14] -#id = 14 -# -#[.n15] +[.attack] +id = 14 + +# id 15 is the (still unconfirmed) cast option; its left-click counterpart is likely 22. +#[.cast] #id = 15 [.leftclick_follower_details] @@ -233,6 +232,9 @@ id = 19 [.leftclick_renew_familiar] id = 20 +[.leftclick_attack] +id = 21 + [.cast_fetch_casket] id = 87 diff --git a/data/skill/summoning/familiar_overhead.tables.toml b/data/skill/summoning/familiar_overhead.tables.toml new file mode 100644 index 0000000000..83d81be7e8 --- /dev/null +++ b/data/skill/summoning/familiar_overhead.tables.toml @@ -0,0 +1,256 @@ +# Random overhead lines a summoned familiar periodically shouts (~every 30s, see +# SummoningTimers). Sourced from the RuneScape Wiki transcript "Overhead dialogue" +# sections. Keyed by familiar npc id; familiars without a row stay silent. + +[familiar_overhead] +lines = "list" + +[.spirit_wolf_familiar] +lines = ["Grrrrr...", "Whuff-whuff!"] + +[.dreadfowl_familiar] +lines = ["Bwaaak!", "Bwuk bwuk!"] + +[.vampire_bat_familiar] +lines = ["Squeak!", "Squeak squeak!"] + +[.spirit_terrorbird_familiar] +lines = ["Scree screee!", "Screee!"] + +[.granite_crab_familiar] +lines = ["Chitter!", "Click click!"] + +[.praying_mantis_familiar] +lines = ["Chitter!", "Clack clack!"] + +[.giant_ent_familiar] +lines = ["Groooooan!", "Creeeeak!"] + +[.spirit_cobra_familiar] +lines = ["Hssssssss!", "Hsss!"] + +[.spirit_dagannoth_familiar] +lines = ["Raaaw!", "Grrrowl!"] + +[.thorny_snail_familiar] +lines = ["Slither!", "Sclurp!"] + +[.beaver_familiar] +lines = ["Gnawgnaw...", "Gnaw ..."] + +[.karamthulhu_overlord_familiar] +lines = ["(Bow down!)", "(Obey!)"] + +[.hydra_familiar] +lines = ["Rassssssp!", "Raaaaasp!"] + +[.spirit_jelly_familiar] +lines = ["Jiggle!", "Flobble!"] + +[.bunyip_familiar] +lines = ["Glorp glorp!", "Glorp!"] + +[.war_tortoise_familiar] +lines = ["Hsss..."] + +[.fruit_bat_familiar] +lines = ["Squeeeak!", "Squeee!"] + +[.abyssal_parasite_familiar] +lines = ["Schlorp!", "Gurgle"] + +[.abyssal_lurker_familiar] +lines = ["Craaaw!", "Screee!"] + +[.unicorn_stallion_familiar] +lines = ["Whinney!", "Neigh!"] + +[.magpie_familiar] +lines = ["Chirrup!", "Twitter!"] + +[.stranger_plant_familiar] +lines = ["Rustle!", "Snapsnap!"] + +[.desert_wyrm_familiar] +lines = ["Ssssss!", "Hssssss!"] + +[.evil_turnip_familiar] +lines = ["Graaah!", "Hur hur hur!"] + +[.spirit_scorpion_familiar] +lines = ["Clatter!", "Clack clack!"] + +[.arctic_bear_familiar] +lines = ["Unf!", "Graaaowl!"] + +[.spirit_spider_familiar] +lines = ["Clitterclatter!", "Click!"] + +[.bloated_leech_familiar] +lines = ["Gurgle!", "Schlurp!"] + +[.spirit_kalphite_familiar] +lines = ["Click click!", "Hsssss!"] + +[.honey_badger_familiar] +lines = ["Raaaaar!", "Grooowl!"] + +[.albino_rat_familiar] +lines = ["Squeee!", "Squeak squeak!"] + +[.granite_lobster_familiar] +lines = ["Grind!", "Clonk clonk!"] + +[.macaw_familiar] +lines = ["Braaak!", "Caw!"] + +[.bronze_minotaur_familiar] +lines = ["Out of the way!", "Hey, no-horns!"] + +[.iron_minotaur_familiar] +lines = ["Out of the way!", "Hey, no-horns!"] + +[.steel_minotaur_familiar] +lines = ["Out of the way!", "Hey, no-horns!"] + +[.mithril_minotaur_familiar] +lines = ["Out of the way!", "Hey, no-horns!"] + +[.adamant_minotaur_familiar] +lines = ["Out of the way!", "Hey, no-horns!"] + +[.rune_minotaur_familiar] +lines = ["Out of the way!", "Hey, no-horns!"] + +[.smoke_devil_familiar] +lines = ["Hoot!", "Honk!"] + +[.bull_ant_familiar] +lines = ["Click click!", "Click!"] + +[.wolpertinger_familiar] +lines = ["Mew!", "Raawr!"] + +[.compost_mound_familiar] +lines = ["Splutter!", "Schlorp!"] + +[.pack_yak_familiar] +lines = ["Barooo barooo!", "Barooo!"] + +[.spirit_cockatrice_familiar] +lines = ["Bwwwark bwwwark!", "Bwwwark!"] + +[.spirit_guthatrice_familiar] +lines = ["Caaaw caaaw!", "Caaaw!"] + +[.spirit_saratrice_familiar] +lines = ["Hoooot hoooot!", "Hoooot!"] + +[.spirit_zamatrice_familiar] +lines = ["Screeee screeee!", "Screeee!"] + +[.spirit_pengatrice_familiar] +lines = ["Awk awk!", "Awk!"] + +[.spirit_coraxatrice_familiar] +lines = ["Craaw craaw!", "Craaw!"] + +[.spirit_vulatrice_familiar] +lines = ["Scraaaaw scraaaaw!", "Scraaaaw!"] + +[.barker_toad_familiar] +lines = ["Braaaaap!", "Craaaaaaak!"] + +[.ibis_familiar] +lines = ["Chirp!", "Twitter!"] + +[.swamp_titan_familiar] +lines = ["Something smells clean...", "The power of stench compels you!"] + +[.spirit_mosquito_familiar] +lines = ["Bzzzzzzzzz!", "Whiiiiiine!"] + +[.void_spinner_familiar] +lines = ["Whiiiiiir!", "Whiiiir!"] + +[.forge_regent_familiar] +lines = ["Sizzle!", "Crackle!"] + +[.spirit_larupia_familiar] +lines = ["Hssssss!", "Ssssss!"] + +[.geyser_titan_familiar] +lines = ["Pah!", "Flee from me!"] + +[.lava_titan_familiar] +lines = ["Sacred flames...", "By my fire..."] + +[.steel_titan_familiar] +lines = ["Stand back!", "I Am Legend!"] + +[.obsidian_golem_familiar] +lines = ["Onward to glory!", "Stand back!"] + +[.talon_beast_familiar] +lines = ["Growwwl!", "Mrowl!"] + +[.abyssal_titan_familiar] +lines = ["Mwanu b'ruum!", "T'verah hoshay!"] + +[.void_torcher_familiar] +lines = ["Scree!", "Squaa!"] + +[.giant_chinchompa_familiar] +lines = ["Squeek!", "Squeek squeek!"] + +[.fire_titan_familiar] +lines = ["Come on and fight!", "Hey, tiny!"] + +[.moss_titan_familiar] +lines = ["Don't get me mad!", "Little soft humies!"] + +[.ice_titan_familiar] +lines = ["I'm melting!", "It's so warm here!"] + +[.spirit_tz-kih_familiar] +lines = ["Squee!", "Squee squee!"] + +[.spirit_graahk_familiar] +lines = ["Howl!", "Rowr!"] + +[.spirit_kyatt_familiar] +lines = ["Grrrrrr!", "Mrrrowl!"] + +[.void_shifter_familiar] +lines = ["Chitter!", "Scree!"] + +[.pyrelord_familiar] +lines = ["Whoosh!", "Crackle!"] + +[.void_ravager_familiar] +lines = ["Rarf!", "Grrrrr!"] + +[.ravenous_locust_familiar] +lines = ["Whiiiiiine!", "Click!"] + +[.iron_titan_familiar] +lines = ["Victory!", "Crush them!"] + +[.meerkats_familiar] +lines = ["Squeak squeak!", "Simples!"] + +[.clay_familiar_class_1_familiar] +lines = ["Rumble!", "Rumble, rumble!"] + +[.clay_familiar_class_2_familiar] +lines = ["Rumble!", "Rumble, rumble!"] + +[.clay_familiar_class_3_familiar] +lines = ["Rumble!", "Rumble, rumble!"] + +[.clay_familiar_class_4_familiar] +lines = ["Rumble!", "Rumble, rumble!"] + +[.clay_familiar_class_5_familiar] +lines = ["Rumble!", "Rumble, rumble!"] + diff --git a/data/skill/summoning/summoning.anims.toml b/data/skill/summoning/summoning.anims.toml index 91163bfdfa..4265ed7bc1 100644 --- a/data/skill/summoning/summoning.anims.toml +++ b/data/skill/summoning/summoning.anims.toml @@ -1,38 +1,16 @@ [summoning_infuse] id = 8500 -[expression_wolf_happy] -id = 8372 - -[expression_wolf_angry] -id = 8373 - -[expression_wolf_sad] -id = 8374 - -[expression_wolf_quiz] -id = 8375 - -[expression_bird_sad] -id = 6824 - -[expression_bird_happy] -id = 7 - -[expression_bird_neutral] -id = 8 - -[expression_bird_quiz] -id = 9 - -[expression_leech_1] -id = 8411 - -[expression_leech_sad] -id = 8412 - -[expression_leech_neutral] -id = 8413 - -[expression_leech_angry] -id = 8414 \ No newline at end of file +[summoning_renew] +id = 8502 +walk = false +run = false + +[unicorn_stallion_cure] +id = 8267 + +[dreadfowl_special] +id = 5387 +# Fire-breathing familiars (pyrelord, forge regent) lighting logs beneath themselves. +[familiar_light_fire] +id = 8085 diff --git a/data/skill/summoning/summoning.combat.toml b/data/skill/summoning/summoning.combat.toml new file mode 100644 index 0000000000..e3b8582a2e --- /dev/null +++ b/data/skill/summoning/summoning.combat.toml @@ -0,0 +1,823 @@ +# Standard familiar attack speed is 8 ticks (4.8s); tune per-familiar as needed. +# Special/scroll attacks out of scope. + +[spirit_wolf_familiar] +attack_speed = 8 +retreat_range = 8 +defend_anim = "spirit_wolf_familiar_defend" +death_anim = "spirit_wolf_familiar_death" + +[spirit_wolf_familiar.melee] +range = 1 +anim = "spirit_wolf_familiar_attack" +target_hit = { offense = "slash", max = 40 } + +[dreadfowl_familiar] +attack_speed = 8 +retreat_range = 8 +defend_anim = "dreadfowl_familiar_defend" +death_anim = "dreadfowl_familiar_death" + +[dreadfowl_familiar.melee] +range = 1 +anim = "dreadfowl_familiar_attack" +target_hit = { offense = "crush", max = 40 } + +[spirit_spider_familiar] +attack_speed = 8 +retreat_range = 8 +defend_anim = "spirit_spider_familiar_defend" +death_anim = "spirit_spider_familiar_death" + +[spirit_spider_familiar.melee] +range = 1 +anim = "spirit_spider_familiar_attack" +target_hit = { offense = "crush", max = 30 } + +[thorny_snail_familiar] +attack_speed = 8 +retreat_range = 8 +defend_anim = "thorny_snail_familiar_defend" +death_anim = "thorny_snail_familiar_death" + +[thorny_snail_familiar.range] +range = 7 +anim = "thorny_snail_familiar_attack" +gfx = "thorny_snail_familiar_attack_gfx" +projectile = "thorny_snail_familiar_projectile" +target_hit = { offense = "range", max = 40 } + +[granite_crab_familiar] +attack_speed = 8 +retreat_range = 8 +defend_anim = "granite_crab_familiar_defend" +death_anim = "granite_crab_familiar_death" + +[granite_crab_familiar.melee] +range = 1 +anim = "granite_crab_familiar_attack" +target_hit = { offense = "crush", max = 40 } + +[spirit_mosquito_familiar] +attack_speed = 8 +retreat_range = 8 +defend_anim = "spirit_mosquito_familiar_defend" +death_anim = "spirit_mosquito_familiar_death" + +[spirit_mosquito_familiar.melee] +range = 1 +anim = "spirit_mosquito_familiar_attack" +target_hit = { offense = "crush", max = 42 } + +[desert_wyrm_familiar] +attack_speed = 8 +retreat_range = 8 +defend_anim = "desert_wyrm_familiar_defend" +death_anim = "desert_wyrm_familiar_death" + +[desert_wyrm_familiar.melee] +range = 1 +anim = "desert_wyrm_familiar_attack" +target_hit = { offense = "crush", max = 42 } + +[spirit_scorpion_familiar] +attack_speed = 8 +retreat_range = 8 +defend_anim = "spirit_scorpion_familiar_defend" +death_anim = "spirit_scorpion_familiar_death" + +[spirit_scorpion_familiar.melee] +range = 1 +anim = "spirit_scorpion_familiar_attack" +target_hit = { offense = "crush", max = 65 } + +[spirit_tz-kih_familiar] +retreat_range = 8 +defend_anim = "spirit_tz-kih_familiar_defend" +death_anim = "spirit_tz-kih_familiar_death" + +[spirit_tz-kih_familiar.magic] +range = 7 +anim = "spirit_tz-kih_familiar_attack" +gfx = "spirit_tz-kih_familiar_attack_gfx" +projectile = "spirit_tz-kih_familiar_projectile" +target_hit = { offense = "magic", max = 50 } + +[albino_rat_familiar] +attack_speed = 8 +retreat_range = 8 +defend_anim = "albino_rat_familiar_defend" +death_anim = "albino_rat_familiar_death" + +[albino_rat_familiar.melee] +range = 1 +anim = "albino_rat_familiar_attack" +target_hit = { offense = "crush", max = 50 } + +[spirit_kalphite_familiar] +attack_speed = 8 +retreat_range = 8 +defend_anim = "spirit_kalphite_familiar_defend" +death_anim = "spirit_kalphite_familiar_death" + +[spirit_kalphite_familiar.melee] +range = 1 +anim = "spirit_kalphite_familiar_attack" +target_hit = { offense = "crush", max = 50 } + +[compost_mound_familiar] +attack_speed = 8 +retreat_range = 8 +defend_anim = "compost_mound_familiar_defend" +death_anim = "compost_mound_familiar_death" + +[compost_mound_familiar.melee] +range = 1 +anim = "compost_mound_familiar_attack" +target_hit = { offense = "crush", max = 37 } + +[giant_chinchompa_familiar] +attack_speed = 8 +retreat_range = 8 +defend_anim = "giant_chinchompa_familiar_defend" +death_anim = "giant_chinchompa_familiar_death" + +[giant_chinchompa_familiar.melee] +range = 1 +anim = "giant_chinchompa_familiar_attack" +target_hit = { offense = "crush", max = 38 } + +[vampire_bat_familiar] +attack_speed = 8 +retreat_range = 8 +defend_anim = "vampire_bat_familiar_defend" +death_anim = "vampire_bat_familiar_death" + +[vampire_bat_familiar.melee] +range = 1 +anim = "vampire_bat_familiar_attack" +target_hit = { offense = "crush", max = 40 } + +[honey_badger_familiar] +attack_speed = 8 +retreat_range = 8 +defend_anim = "honey_badger_familiar_defend" +death_anim = "honey_badger_familiar_death" + +[honey_badger_familiar.melee] +range = 1 +anim = "honey_badger_familiar_attack" +target_hit = { offense = "crush", max = 41 } + +[void_ravager_familiar] +attack_speed = 8 +retreat_range = 8 +defend_anim = "void_ravager_familiar_defend" +death_anim = "void_ravager_familiar_death" + +[void_ravager_familiar.melee] +range = 1 +anim = "void_ravager_familiar_attack" +target_hit = { offense = "crush", max = 44 } + +[void_spinner_familiar] +attack_speed = 8 +retreat_range = 8 +defend_anim = "void_spinner_familiar_defend" +death_anim = "void_spinner_familiar_death" + +[void_spinner_familiar.melee] +range = 1 +anim = "void_spinner_familiar_attack" +target_hit = { offense = "crush", max = 44 } + +[void_torcher_familiar] +attack_speed = 8 +retreat_range = 8 +defend_anim = "void_torcher_familiar_defend" +death_anim = "void_torcher_familiar_death" + +[void_torcher_familiar.melee] +range = 1 +anim = "void_torcher_familiar_attack" +target_hit = { offense = "crush", max = 44 } + +[void_shifter_familiar] +attack_speed = 8 +retreat_range = 8 +defend_anim = "void_shifter_familiar_defend" +death_anim = "void_shifter_familiar_death" + +[void_shifter_familiar.melee] +range = 1 +anim = "void_shifter_familiar_attack" +target_hit = { offense = "crush", max = 44 } + +[bronze_minotaur_familiar] +attack_speed = 8 +retreat_range = 8 +defend_anim = "bronze_minotaur_familiar_defend" +death_anim = "bronze_minotaur_familiar_death" + +[bronze_minotaur_familiar.melee] +range = 1 +anim = "bronze_minotaur_familiar_attack" +gfx = "minotaur_axe_swing" +target_hit = { offense = "crush", max = 47 } + +[iron_minotaur_familiar] +attack_speed = 8 +retreat_range = 8 +defend_anim = "iron_minotaur_familiar_defend" +death_anim = "iron_minotaur_familiar_death" + +[iron_minotaur_familiar.melee] +range = 1 +anim = "iron_minotaur_familiar_attack" +gfx = "minotaur_axe_swing" +target_hit = { offense = "crush", max = 60 } + +[steel_minotaur_familiar] +attack_speed = 8 +retreat_range = 8 +defend_anim = "steel_minotaur_familiar_defend" +death_anim = "steel_minotaur_familiar_death" + +[steel_minotaur_familiar.melee] +range = 1 +anim = "steel_minotaur_familiar_attack" +gfx = "minotaur_axe_swing" +target_hit = { offense = "crush", max = 73 } + +[mithril_minotaur_familiar] +attack_speed = 8 +retreat_range = 8 +defend_anim = "mithril_minotaur_familiar_defend" +death_anim = "mithril_minotaur_familiar_death" + +[mithril_minotaur_familiar.melee] +range = 1 +anim = "mithril_minotaur_familiar_attack" +gfx = "minotaur_axe_swing" +target_hit = { offense = "crush", max = 110 } + +[adamant_minotaur_familiar] +attack_speed = 8 +retreat_range = 8 +defend_anim = "adamant_minotaur_familiar_defend" +death_anim = "adamant_minotaur_familiar_death" + +[adamant_minotaur_familiar.melee] +range = 1 +anim = "adamant_minotaur_familiar_attack" +gfx = "minotaur_axe_swing" +target_hit = { offense = "crush", max = 160 } + +[rune_minotaur_familiar] +attack_speed = 8 +retreat_range = 8 +defend_anim = "rune_minotaur_familiar_defend" +death_anim = "rune_minotaur_familiar_death" + +[rune_minotaur_familiar.melee] +range = 1 +anim = "rune_minotaur_familiar_attack" +gfx = "minotaur_axe_swing" +target_hit = { offense = "crush", max = 180 } + +[bull_ant_familiar] +attack_speed = 8 +retreat_range = 8 +defend_anim = "bull_ant_familiar_defend" +death_anim = "bull_ant_familiar_death" + +[bull_ant_familiar.melee] +range = 1 +anim = "bull_ant_familiar_attack" +target_hit = { offense = "crush", max = 52 } + +[evil_turnip_familiar] +attack_speed = 8 +retreat_range = 8 +defend_anim = "evil_turnip_familiar_defend" +death_anim = "evil_turnip_familiar_death" + +[evil_turnip_familiar.melee] +range = 1 +anim = "evil_turnip_familiar_attack" +target_hit = { offense = "crush", max = 58 } + +[spirit_cockatrice_familiar] +attack_speed = 8 +retreat_range = 8 +defend_anim = "spirit_cockatrice_familiar_defend" +death_anim = "spirit_cockatrice_familiar_death" + +[spirit_cockatrice_familiar.melee] +range = 1 +anim = "spirit_cockatrice_familiar_attack" +target_hit = { offense = "crush", max = 56 } + +[spirit_guthatrice_familiar] +attack_speed = 8 +retreat_range = 8 +defend_anim = "spirit_guthatrice_familiar_defend" +death_anim = "spirit_guthatrice_familiar_death" + +[spirit_guthatrice_familiar.melee] +range = 1 +anim = "spirit_guthatrice_familiar_attack" +target_hit = { offense = "crush", max = 56 } + +[spirit_saratrice_familiar] +attack_speed = 8 +retreat_range = 8 +defend_anim = "spirit_saratrice_familiar_defend" +death_anim = "spirit_saratrice_familiar_death" + +[spirit_saratrice_familiar.melee] +range = 1 +anim = "spirit_saratrice_familiar_attack" +target_hit = { offense = "crush", max = 56 } + +[spirit_zamatrice_familiar] +attack_speed = 8 +retreat_range = 8 +defend_anim = "spirit_zamatrice_familiar_defend" +death_anim = "spirit_zamatrice_familiar_death" + +[spirit_zamatrice_familiar.melee] +range = 1 +anim = "spirit_zamatrice_familiar_attack" +target_hit = { offense = "crush", max = 56 } + +[spirit_pengatrice_familiar] +attack_speed = 8 +retreat_range = 8 +defend_anim = "spirit_pengatrice_familiar_defend" +death_anim = "spirit_pengatrice_familiar_death" + +[spirit_pengatrice_familiar.melee] +range = 1 +anim = "spirit_pengatrice_familiar_attack" +target_hit = { offense = "crush", max = 56 } + +[spirit_coraxatrice_familiar] +attack_speed = 8 +retreat_range = 8 +defend_anim = "spirit_coraxatrice_familiar_defend" +death_anim = "spirit_coraxatrice_familiar_death" + +[spirit_coraxatrice_familiar.melee] +range = 1 +anim = "spirit_coraxatrice_familiar_attack" +target_hit = { offense = "crush", max = 56 } + +[spirit_vulatrice_familiar] +attack_speed = 8 +retreat_range = 8 +defend_anim = "spirit_vulatrice_familiar_defend" +death_anim = "spirit_vulatrice_familiar_death" + +[spirit_vulatrice_familiar.melee] +range = 1 +anim = "spirit_vulatrice_familiar_attack" +target_hit = { offense = "crush", max = 56 } + +[pyrelord_familiar] +attack_speed = 8 +retreat_range = 8 +defend_anim = "pyrelord_familiar_defend" +death_anim = "pyrelord_familiar_death" + +[pyrelord_familiar.melee] +range = 1 +anim = "pyrelord_familiar_attack" +target_hit = { offense = "crush", max = 60 } + +[bloated_leech_familiar] +attack_speed = 8 +retreat_range = 8 +defend_anim = "bloated_leech_familiar_defend" +death_anim = "bloated_leech_familiar_death" + +[bloated_leech_familiar.melee] +range = 1 +anim = "bloated_leech_familiar_attack" +target_hit = { offense = "crush", max = 65 } + +[spirit_terrorbird_familiar] +attack_speed = 8 +retreat_range = 8 +defend_anim = "spirit_terrorbird_familiar_defend" +death_anim = "spirit_terrorbird_familiar_death" + +[spirit_terrorbird_familiar.melee] +range = 1 +anim = "spirit_terrorbird_familiar_attack" +target_hit = { offense = "crush", max = 68 } + +[abyssal_parasite_familiar] +attack_speed = 8 +retreat_range = 8 +defend_anim = "abyssal_parasite_familiar_defend" +death_anim = "abyssal_parasite_familiar_death" + +[abyssal_parasite_familiar.melee] +range = 1 +anim = "abyssal_parasite_familiar_attack" +target_hit = { offense = "crush", max = 70 } + +[spirit_jelly_familiar] +attack_speed = 8 +retreat_range = 8 +defend_anim = "spirit_jelly_familiar_defend" +death_anim = "spirit_jelly_familiar_death" + +[spirit_jelly_familiar.melee] +range = 1 +anim = "spirit_jelly_familiar_attack" +target_hit = { offense = "crush", max = 72 } + +[spirit_kyatt_familiar] +attack_speed = 8 +retreat_range = 8 +defend_anim = "spirit_kyatt_familiar_defend" +death_anim = "spirit_kyatt_familiar_death" + +[spirit_kyatt_familiar.melee] +range = 1 +anim = "spirit_kyatt_familiar_attack" +target_hit = { offense = "crush", max = 75 } + +[spirit_larupia_familiar] +attack_speed = 8 +retreat_range = 8 +defend_anim = "spirit_larupia_familiar_defend" +death_anim = "spirit_larupia_familiar_death" + +[spirit_larupia_familiar.melee] +range = 1 +anim = "spirit_larupia_familiar_attack" +target_hit = { offense = "crush", max = 75 } + +[spirit_graahk_familiar] +attack_speed = 8 +retreat_range = 8 +defend_anim = "spirit_graahk_familiar_defend" +death_anim = "spirit_graahk_familiar_death" + +[spirit_graahk_familiar.melee] +range = 1 +anim = "spirit_graahk_familiar_attack" +target_hit = { offense = "crush", max = 75 } + +[karamthulhu_overlord_familiar] +attack_speed = 8 +retreat_range = 8 +defend_anim = "karamthulhu_overlord_familiar_defend" +death_anim = "karamthulhu_overlord_familiar_death" + +[karamthulhu_overlord_familiar.range] +range = 7 +anim = "karamthulhu_overlord_familiar_attack" +gfx = "karamthulhu_overlord_familiar_attack_gfx" +projectile = "karamthulhu_overlord_familiar_projectile" +target_hit = { offense = "range", max = 76 } + +[smoke_devil_familiar] +attack_speed = 8 +retreat_range = 8 +defend_anim = "smoke_devil_familiar_defend" +death_anim = "smoke_devil_familiar_death" + +[smoke_devil_familiar.magic] +range = 7 +anim = "smoke_devil_familiar_attack" +projectile = "smoke_devil_familiar_projectile" +target_hit = { offense = "magic", max = 150 } + +[abyssal_lurker_familiar] +attack_speed = 8 +retreat_range = 8 +defend_anim = "abyssal_lurker_familiar_defend" +death_anim = "abyssal_lurker_familiar_death" + +[abyssal_lurker_familiar.melee] +range = 1 +anim = "abyssal_lurker_familiar_attack" +target_hit = { offense = "crush", max = 80 } + +[spirit_cobra_familiar] +attack_speed = 8 +retreat_range = 8 +defend_anim = "spirit_cobra_familiar_defend" +death_anim = "spirit_cobra_familiar_death" + +[spirit_cobra_familiar.melee] +range = 1 +anim = "spirit_cobra_familiar_attack" +target_hit = { offense = "crush", max = 82 } + +[stranger_plant_familiar] +attack_speed = 8 +retreat_range = 8 +death_anim = "stranger_plant_familiar_death" + +[stranger_plant_familiar.melee] +range = 1 +anim = "stranger_plant_familiar_attack" +target_hit = { offense = "crush", max = 103 } + +[barker_toad_familiar] +attack_speed = 8 +retreat_range = 8 +defend_anim = "barker_toad_familiar_defend" +death_anim = "barker_toad_familiar_death" + +[barker_toad_familiar.melee] +range = 1 +anim = "barker_toad_familiar_attack" +target_hit = { offense = "crush", max = 86 } + +[war_tortoise_familiar] +attack_speed = 8 +retreat_range = 8 +defend_anim = "war_tortoise_familiar_defend" +death_anim = "war_tortoise_familiar_death" + +[war_tortoise_familiar.melee] +range = 1 +anim = "war_tortoise_familiar_attack" +target_hit = { offense = "crush", max = 87 } + +[bunyip_familiar] +attack_speed = 8 +retreat_range = 8 +defend_anim = "bunyip_familiar_defend" +death_anim = "bunyip_familiar_death" + +[bunyip_familiar.melee] +range = 1 +anim = "bunyip_familiar_attack" +target_hit = { offense = "crush", max = 89 } + +[ravenous_locust_familiar] +attack_speed = 8 +retreat_range = 8 +defend_anim = "ravenous_locust_familiar_defend" +death_anim = "ravenous_locust_familiar_death" + +[ravenous_locust_familiar.melee] +range = 1 +anim = "ravenous_locust_familiar_attack" +target_hit = { offense = "crush", max = 90 } + +[arctic_bear_familiar] +attack_speed = 8 +retreat_range = 8 +defend_anim = "arctic_bear_familiar_defend" +death_anim = "arctic_bear_familiar_death" + +[arctic_bear_familiar.melee] +range = 1 +anim = "arctic_bear_familiar_attack" +target_hit = { offense = "crush", max = 92 } + +[phoenix_familiar] +attack_speed = 8 +retreat_range = 8 +defend_anim = "phoenix_familiar_defend" +death_anim = "phoenix_familiar_death" + +[phoenix_familiar.magic] +range = 7 +anim = "phoenix_familiar_attack" +target_hit = { offense = "magic", max = 160 } + +[obsidian_golem_familiar] +attack_speed = 8 +retreat_range = 8 +defend_anim = "obsidian_golem_familiar_defend" +death_anim = "obsidian_golem_familiar_death" + +[obsidian_golem_familiar.melee] +range = 1 +anim = "obsidian_golem_familiar_attack" +target_hit = { offense = "crush", max = 120 } + +[granite_lobster_familiar] +attack_speed = 8 +retreat_range = 8 +defend_anim = "granite_lobster_familiar_defend" +death_anim = "granite_lobster_familiar_death" + +[granite_lobster_familiar.melee] +range = 1 +anim = "granite_lobster_familiar_attack" +target_hit = { offense = "crush", max = 96 } + +[praying_mantis_familiar] +attack_speed = 8 +retreat_range = 8 +defend_anim = "praying_mantis_familiar_defend" +death_anim = "praying_mantis_familiar_death" + +[praying_mantis_familiar.melee] +range = 1 +anim = "praying_mantis_familiar_attack" +target_hit = { offense = "crush", max = 120 } + +[forge_regent_familiar] +attack_speed = 8 +retreat_range = 8 +defend_anim = "forge_regent_familiar_defend" +death_anim = "forge_regent_familiar_death" + +[forge_regent_familiar.melee] +range = 1 +anim = "forge_regent_familiar_attack" +projectile = "forge_regent_familiar_projectile" +target_hit = { offense = "crush", max = 100 } + +[talon_beast_familiar] +attack_speed = 8 +retreat_range = 8 +defend_anim = "talon_beast_familiar_defend" +death_anim = "talon_beast_familiar_death" + +[talon_beast_familiar.melee] +range = 1 +anim = "talon_beast_familiar_attack" +target_hit = { offense = "crush", max = 100 } + +[giant_ent_familiar] +attack_speed = 8 +retreat_range = 8 +defend_anim = "giant_ent_familiar_defend" +death_anim = "giant_ent_familiar_death" + +[giant_ent_familiar.melee] +range = 1 +anim = "giant_ent_familiar_attack" +target_hit = { offense = "crush", max = 102 } + +[hydra_familiar] +attack_speed = 8 +retreat_range = 8 +defend_anim = "hydra_familiar_defend" +death_anim = "hydra_familiar_death" + +[hydra_familiar.range] +range = 7 +anim = "hydra_familiar_attack" +projectile = "hydra_familiar_projectile" +target_hit = { offense = "range", max = 103 } + +[spirit_dagannoth_familiar] +attack_speed = 8 +retreat_range = 8 +defend_anim = "spirit_dagannoth_familiar_defend" +death_anim = "spirit_dagannoth_familiar_death" + +[spirit_dagannoth_familiar.melee] +range = 1 +anim = "spirit_dagannoth_familiar_attack" +target_hit = { offense = "crush", max = 108 } + +[unicorn_stallion_familiar] +attack_speed = 8 +retreat_range = 8 +defend_anim = "unicorn_stallion_familiar_defend" +death_anim = "unicorn_stallion_familiar_death" + +[unicorn_stallion_familiar.melee] +range = 1 +anim = "unicorn_stallion_familiar_attack" +target_hit = { offense = "crush", max = 115 } + +[wolpertinger_familiar] +attack_speed = 8 +retreat_range = 8 +defend_anim = "wolpertinger_familiar_defend" +death_anim = "wolpertinger_familiar_death" + +[wolpertinger_familiar.magic] +range = 7 +anim = "wolpertinger_familiar_attack" +projectile = "wolpertinger_familiar_projectile" +target_hit = { offense = "magic", max = 224 } + +[pack_yak_familiar] +attack_speed = 8 +retreat_range = 8 +defend_anim = "pack_yak_familiar_defend" +death_anim = "pack_yak_familiar_death" + +[pack_yak_familiar.melee] +range = 1 +anim = "pack_yak_familiar_attack" +target_hit = { offense = "crush", max = 125 } + +[fire_titan_familiar] +attack_speed = 8 +retreat_range = 8 +defend_anim = "fire_titan_familiar_defend" +death_anim = "fire_titan_familiar_death" + +[fire_titan_familiar.melee] +range = 1 +anim = "fire_titan_familiar_attack" +target_hit = { offense = "crush", max = 152 } + +[moss_titan_familiar] +attack_speed = 8 +retreat_range = 8 +defend_anim = "moss_titan_familiar_defend" +death_anim = "moss_titan_familiar_death" + +[moss_titan_familiar.melee] +range = 1 +anim = "moss_titan_familiar_attack" +target_hit = { offense = "crush", max = 150 } + +[ice_titan_familiar] +attack_speed = 8 +retreat_range = 8 +defend_anim = "ice_titan_familiar_defend" +death_anim = "ice_titan_familiar_death" + +[ice_titan_familiar.melee] +range = 1 +anim = "ice_titan_familiar_attack" +target_hit = { offense = "crush", max = 152 } + +[lava_titan_familiar] +attack_speed = 8 +retreat_range = 8 +defend_anim = "lava_titan_familiar_defend" +death_anim = "lava_titan_familiar_death" + +[lava_titan_familiar.melee] +range = 1 +anim = "lava_titan_familiar_attack" +target_hit = { offense = "crush", max = 140 } + +[swamp_titan_familiar] +attack_speed = 8 +retreat_range = 8 +defend_anim = "swamp_titan_familiar_defend" +death_anim = "swamp_titan_familiar_death" + +[swamp_titan_familiar.melee] +range = 1 +anim = "swamp_titan_familiar_attack" +target_hit = { offense = "crush", max = 160 } + +[geyser_titan_familiar] +attack_speed = 8 +retreat_range = 8 +defend_anim = "geyser_titan_familiar_defend" +death_anim = "geyser_titan_familiar_death" + +[geyser_titan_familiar.range] +range = 7 +anim = "geyser_titan_familiar_attack" +gfx = "geyser_titan_familiar_attack_gfx" +projectile = "geyser_titan_familiar_projectile" +target_hit = { offense = "range", max = 190 } + +[abyssal_titan_familiar] +attack_speed = 8 +retreat_range = 8 +defend_anim = "abyssal_titan_familiar_defend" +death_anim = "abyssal_titan_familiar_death" + +[abyssal_titan_familiar.melee] +range = 1 +anim = "abyssal_titan_familiar_attack" +target_hit = { offense = "crush", max = 229 } + +[iron_titan_familiar] +attack_speed = 8 +retreat_range = 8 +defend_anim = "iron_titan_familiar_defend" +death_anim = "iron_titan_familiar_death" + +[iron_titan_familiar.melee] +range = 1 +anim = "iron_titan_familiar_attack" +target_hit = { offense = "crush", max = 244 } + +[steel_titan_familiar] +attack_speed = 8 +retreat_range = 8 +defend_anim = "steel_titan_familiar_defend" +death_anim = "steel_titan_familiar_death" + +[steel_titan_familiar.range] +range = 7 +anim = "steel_titan_familiar_attack" +gfx = "steel_titan_familiar_attack_gfx" +projectile = "steel_titan_familiar_projectile" +target_hit = { offense = "range", max = 244 } diff --git a/data/skill/summoning/summoning.drops.toml b/data/skill/summoning/summoning.drops.toml new file mode 100644 index 0000000000..f5aae8281b --- /dev/null +++ b/data/skill/summoning/summoning.drops.toml @@ -0,0 +1,191 @@ +# Forage tables for forager familiars. Rolled once into the familiar's inventory every ~30s +# while summoned; the player withdraws the loot from the familiar. +# +# Each `forage_` table is rolled per tick: a 1/ chance to produce a drop. For +# multi-item foragers the hit defers to a `_items` sub-table that picks one item uniformly +# (matching the reference's "1/outOf chance, then drop one"). A familiar is treated as a forager +# purely by the existence of its `forage_` table - no npc-def param is needed. + +# --- Magpie: gems and rings (1/9) --- +[forage_magpie] +roll = 9 +drops = [{ table = "forage_magpie_items", chance = 1 }] + +[forage_magpie_items] +roll = 12 +drops = [ + { id = "uncut_diamond" }, + { id = "uncut_ruby" }, + { id = "uncut_emerald" }, + { id = "uncut_sapphire" }, + { id = "uncut_opal" }, + { id = "uncut_jade" }, + { id = "uncut_red_topaz" }, + { id = "gold_ring" }, + { id = "sapphire_ring" }, + { id = "emerald_ring" }, + { id = "ruby_ring" }, + { id = "diamond_ring" } +] + +# --- Beaver: logs and planks (1/6) --- +[forage_beaver] +roll = 6 +drops = [{ table = "forage_beaver_items", chance = 1 }] + +[forage_beaver_items] +roll = 7 +drops = [ + { id = "logs" }, + { id = "oak_logs" }, + { id = "willow_logs" }, + { id = "maple_logs" }, + { id = "yew_logs" }, + { id = "oak_plank" }, + { id = "plank" } +] + +# --- Compost mound: compost and seeds (1/6) --- +[forage_compost_mound] +roll = 6 +drops = [{ table = "forage_compost_mound_items", chance = 1 }] + +[forage_compost_mound_items] +roll = 8 +drops = [ + { id = "compost" }, + { id = "supercompost" }, + { id = "guam_seed" }, + { id = "sweetcorn_seed" }, + { id = "nasturtium_seed" }, + { id = "wergali_seed" }, + { id = "limpwurt_seed" }, + { id = "redberry_seed" } +] + +# --- Fruit bat: fruit and seeds (1/12) --- +[forage_fruit_bat] +roll = 12 +drops = [{ table = "forage_fruit_bat_items", chance = 1 }] + +[forage_fruit_bat_items] +roll = 7 +drops = [ + { id = "strawberry" }, + { id = "watermelon" }, + { id = "pineapple" }, + { id = "papaya_fruit" }, + { id = "coconut" }, + { id = "strawberry_seed" }, + { id = "watermelon_seed" } +] + +# --- Granite crab: fish and seafood (1/5) --- +[forage_granite_crab] +roll = 5 +drops = [{ table = "forage_granite_crab_items", chance = 1 }] + +[forage_granite_crab_items] +roll = 4 +drops = [ + { id = "raw_cod" }, + { id = "raw_pike" }, + { id = "seaweed" }, + { id = "oyster" } +] + +# --- Granite lobster: swordfish and shark (1/12) --- +[forage_granite_lobster] +roll = 12 +drops = [{ table = "forage_granite_lobster_items", chance = 1 }] + +[forage_granite_lobster_items] +roll = 2 +drops = [ + { id = "raw_swordfish" }, + { id = "raw_shark" } +] + +# --- Ibis: tuna and swordfish (1/12) --- +[forage_ibis] +roll = 12 +drops = [{ table = "forage_ibis_items", chance = 1 }] + +[forage_ibis_items] +roll = 2 +drops = [ + { id = "raw_tuna" }, + { id = "raw_swordfish" } +] + +# --- Void ravager: ores, coal and clay (1/6) --- +[forage_void_ravager] +roll = 6 +drops = [{ table = "forage_void_ravager_items", chance = 1 }] + +[forage_void_ravager_items] +roll = 6 +drops = [ + { id = "clay" }, + { id = "iron_ore" }, + { id = "silver_ore" }, + { id = "gold_ore" }, + { id = "mithril_ore" }, + { id = "coal" } +] + +# --- Macaw: grimy herbs (1/20) --- +[forage_macaw] +roll = 20 +drops = [{ table = "herb_drop_table", chance = 1 }] + +# --- Single-drop foragers --- +[forage_spirit_spider] +roll = 6 +drops = [{ id = "red_spiders_eggs" }] + +[forage_giant_ent] +roll = 7 +drops = [{ id = "oak_logs" }] + +[forage_stranger_plant] +roll = 12 +drops = [{ id = "strange_fruit" }] + +[forage_evil_turnip] +roll = 12 +drops = [{ id = "evil_turnip_two_thirds" }] + +# --- Albino rat: cheese --- +[forage_albino_rat] +roll = 9 +drops = [{ id = "cheese", amount = 4 }] + +# --- Cockatrice family: each variant lays a cockatrice egg (1/12) --- +[forage_spirit_cockatrice] +roll = 12 +drops = [{ id = "cockatrice_egg" }] + +[forage_spirit_guthatrice] +roll = 12 +drops = [{ id = "cockatrice_egg" }] + +[forage_spirit_saratrice] +roll = 12 +drops = [{ id = "cockatrice_egg" }] + +[forage_spirit_zamatrice] +roll = 12 +drops = [{ id = "cockatrice_egg" }] + +[forage_spirit_pengatrice] +roll = 12 +drops = [{ id = "cockatrice_egg" }] + +[forage_spirit_coraxatrice] +roll = 12 +drops = [{ id = "cockatrice_egg" }] + +[forage_spirit_vulatrice] +roll = 12 +drops = [{ id = "cockatrice_egg" }] diff --git a/data/skill/summoning/summoning.gfx.toml b/data/skill/summoning/summoning.gfx.toml index 4fcf922db5..13b568a354 100644 --- a/data/skill/summoning/summoning.gfx.toml +++ b/data/skill/summoning/summoning.gfx.toml @@ -1,5 +1,27 @@ +[summoning_renew_obelisk] +id = 1516 + +[summoning_renew_player] +id = 1517 + +[unicorn_stallion_cure] +id = 1356 + +[dreadfowl_special] +id = 1317 + +[dreadfowl_special_player] +id = 1307 + [summon_familiar_size_1] id = 1314 [summon_familiar_size_2] -id = 1315 \ No newline at end of file +id = 1315 +# Desert wyrm "Burrow" npc option graphic. +[desert_wyrm_burrow] +id = 1412 + +# Bunyip's passive heal graphic, played on the owner as the 15s heal restores life points. +[bunyip_heal] +id = 1507 diff --git a/data/skill/summoning/summoning.ifaces.toml b/data/skill/summoning/summoning.ifaces.toml index a9e8f495f2..c8c246649b 100644 --- a/data/skill/summoning/summoning.ifaces.toml +++ b/data/skill/summoning/summoning.ifaces.toml @@ -67,6 +67,9 @@ id = 75 [.cast_dreadfowl_strike] id = 77 +[.cast_special_move] +id = 79 + [.cast_rise_from_the_ashes] id = 78 diff --git a/data/skill/summoning/summoning.items.toml b/data/skill/summoning/summoning.items.toml index 767df4a2ea..e0e9beeb95 100644 --- a/data/skill/summoning/summoning.items.toml +++ b/data/skill/summoning/summoning.items.toml @@ -1174,6 +1174,8 @@ id = 12205 [antlers_charged] id = 12206 +charges = 0 +charges_max = 40 limit = 100 tradeable = false bank_stacks = false @@ -1197,6 +1199,8 @@ id = 12208 [lizard_skull_charged] id = 12209 +charges = 0 +charges_max = 65 limit = 100 tradeable = false bank_stacks = false @@ -1220,6 +1224,8 @@ id = 12211 [feather_headdress_charged_blue] id = 12212 +charges = 0 +charges_max = 150 tradeable = false bank_stacks = false weight = 2.0 @@ -1242,6 +1248,8 @@ id = 12214 [feather_headdress_charged_yellow] id = 12215 +charges = 0 +charges_max = 150 tradeable = false bank_stacks = false weight = 2.0 @@ -1264,6 +1272,8 @@ id = 12217 [feather_headdress_charged_red] id = 12218 +charges = 0 +charges_max = 150 tradeable = false bank_stacks = false weight = 2.0 @@ -1286,6 +1296,8 @@ id = 12220 [feather_headdress_charged_stripy] id = 12221 +charges = 0 +charges_max = 150 tradeable = false bank_stacks = false weight = 2.0 @@ -1308,6 +1320,8 @@ id = 12223 [feather_headdress_charged_orange] id = 12224 +charges = 0 +charges_max = 150 tradeable = false bank_stacks = false weight = 2.0 @@ -2996,6 +3010,8 @@ kept = "Reclaim" [snakeskin_bandana_charged] id = 12661 +charges = 0 +charges_max = 50 limit = 100 tradeable = false bank_stacks = false @@ -3019,6 +3035,8 @@ kept = "Reclaim" [splitbark_helm_charged] id = 12663 +charges = 0 +charges_max = 50 limit = 100 tradeable = false bank_stacks = false @@ -3042,6 +3060,8 @@ kept = "Reclaim" [rune_full_helm_charged] id = 12665 +charges = 0 +charges_max = 60 limit = 100 tradeable = false bank_stacks = false @@ -3065,6 +3085,8 @@ kept = "Reclaim" [dragon_helm_charged] id = 12667 +charges = 0 +charges_max = 110 limit = 100 tradeable = false bank_stacks = false @@ -3087,6 +3109,8 @@ kept = "Reclaim" [lunar_helm_charged] id = 12669 +charges = 0 +charges_max = 110 tradeable = false bank_stacks = false weight = 0.907 @@ -3110,6 +3134,8 @@ kept = "Reclaim" [armadyl_helmet_charged] id = 12671 +charges = 0 +charges_max = 120 limit = 1 tradeable = false bank_stacks = false @@ -3134,6 +3160,8 @@ kept = "Reclaim" [archer_helm_charged] id = 12673 +charges = 0 +charges_max = 70 limit = 100 tradeable = false bank_stacks = false @@ -3157,6 +3185,8 @@ kept = "Reclaim" [berserker_helm_charged] id = 12675 +charges = 0 +charges_max = 70 limit = 100 tradeable = false bank_stacks = false @@ -3180,6 +3210,8 @@ kept = "Reclaim" [warrior_helm_charged] id = 12677 +charges = 0 +charges_max = 70 limit = 100 tradeable = false bank_stacks = false @@ -3203,6 +3235,8 @@ kept = "Reclaim" [farseer_helm_charged] id = 12679 +charges = 0 +charges_max = 70 limit = 100 tradeable = false bank_stacks = false @@ -3226,6 +3260,8 @@ kept = "Reclaim" [helm_of_neitiznot_charged] id = 12681 +charges = 0 +charges_max = 90 limit = 100 tradeable = false bank_stacks = false @@ -4467,3 +4503,82 @@ summon_points = 1 [meerkats_pouch_noted] id = 19623 + +# Enchanted (empty) middle-state headgear - Pikkupstix enchants the plain helm into these, then +# scrolls are stored to turn them into the _charged forms. Untradeable until disenchanted. +[rune_full_helm_enchanted] +id = 12664 +tradeable = false +bank_stacks = false +slot = "Hat" +kept = "Reclaim" + +[dragon_helm_enchanted] +id = 12666 +tradeable = false +bank_stacks = false +slot = "Hat" +kept = "Reclaim" + +[berserker_helm_enchanted] +id = 12674 +tradeable = false +bank_stacks = false +slot = "Hat" +kept = "Reclaim" + +[warrior_helm_enchanted] +id = 12676 +tradeable = false +bank_stacks = false +slot = "Hat" +kept = "Reclaim" + +[helm_of_neitiznot_enchanted] +id = 12680 +tradeable = false +bank_stacks = false +slot = "Hat" +kept = "Reclaim" + +[snakeskin_bandana_enchanted] +id = 12660 +tradeable = false +bank_stacks = false +slot = "Hat" +kept = "Reclaim" + +[archer_helm_enchanted] +id = 12672 +tradeable = false +bank_stacks = false +slot = "Hat" +kept = "Reclaim" + +[armadyl_helmet_enchanted] +id = 12670 +tradeable = false +bank_stacks = false +slot = "Hat" +kept = "Reclaim" + +[splitbark_helm_enchanted] +id = 12662 +tradeable = false +bank_stacks = false +slot = "Hat" +kept = "Reclaim" + +[farseer_helm_enchanted] +id = 12678 +tradeable = false +bank_stacks = false +slot = "Hat" +kept = "Reclaim" + +[lunar_helm_enchanted] +id = 12668 +tradeable = false +bank_stacks = false +slot = "Hat" +kept = "Reclaim" diff --git a/data/skill/summoning/summoning.npcs.toml b/data/skill/summoning/summoning.npcs.toml index c99deee8b0..7e2c05c4f3 100644 --- a/data/skill/summoning/summoning.npcs.toml +++ b/data/skill/summoning/summoning.npcs.toml @@ -1,447 +1,2022 @@ [spirit_terrorbird_familiar] +summoning_time_minutes = 36 id = 6794 +hitpoints = 2330 +att = 47 +str = 35 +def = 37 +range = 47 +mage = 47 +combat_def = "spirit_terrorbird_familiar" +interacts = false [granite_crab_familiar] +summoning_time_minutes = 18 id = 6796 +hitpoints = 160 +att = 15 +str = 0 +def = 15 +range = 0 +mage = 0 +combat_def = "granite_crab_familiar" +summoning_beast_of_burden = 1 +summoning_beast_of_burden_capacity = 30 +interacts = false [praying_mantis_familiar] +summoning_time_minutes = 69 id = 6798 +hitpoints = 4280 +att = 72 +str = 35 +def = 64 +range = 72 +mage = 72 +combat_def = "praying_mantis_familiar" +interacts = false [giant_ent_familiar] +summoning_time_minutes = 49 id = 6800 +hitpoints = 4670 +att = 72 +str = 35 +def = 64 +range = 72 +mage = 72 +combat_def = "giant_ent_familiar" +summoning_beast_of_burden = 1 +summoning_beast_of_burden_capacity = 30 +interacts = false [spirit_cobra_familiar] +summoning_time_minutes = 56 id = 6802 +hitpoints = 3140 +att = 57 +str = 35 +def = 57 +range = 57 +mage = 57 +combat_def = "spirit_cobra_familiar" +interacts = false [spirit_dagannoth_familiar] +summoning_time_minutes = 57 id = 6804 +hitpoints = 5280 +att = 75 +str = 35 +def = 67 +range = 75 +mage = 75 +combat_def = "spirit_dagannoth_familiar" +interacts = false [thorny_snail_familiar] +summoning_time_minutes = 16 id = 6806 +hitpoints = 280 +att = 1 +str = 1 +def = 15 +range = 15 +mage = 1 +combat_def = "thorny_snail_familiar" +interacts = false [beaver_familiar] +summoning_time_minutes = 27 id = 6808 +summoning_beast_of_burden = 1 +summoning_beast_of_burden_capacity = 30 +interacts = false [karamthulhu_overlord_familiar] +summoning_time_minutes = 44 id = 6809 +hitpoints = 2760 +att = 53 +str = 35 +def = 47 +range = 53 +mage = 53 +combat_def = "karamthulhu_overlord_familiar" +interacts = false [hydra_familiar] +summoning_time_minutes = 49 id = 6811 +hitpoints = 4900 +att = 72 +str = 35 +def = 64 +range = 72 +mage = 72 +combat_def = "hydra_familiar" +interacts = false [spirit_jelly_familiar] +summoning_time_minutes = 43 id = 6992 +hitpoints = 2550 +att = 50 +str = 35 +def = 44 +range = 50 +mage = 50 +combat_def = "spirit_jelly_familiar" +interacts = false [bunyip_familiar] +summoning_time_minutes = 44 id = 6813 +hitpoints = 400 +att = 72 +str = 35 +def = 64 +range = 72 +mage = 72 +combat_def = "bunyip_familiar" +interacts = false [war_tortoise_familiar] +summoning_time_minutes = 43 id = 6815 +hitpoints = 3480 +att = 72 +str = 35 +def = 64 +range = 72 +mage = 72 +combat_def = "war_tortoise_familiar" +interacts = false [fruit_bat_familiar] +summoning_time_minutes = 45 id = 6817 +summoning_beast_of_burden = 1 +summoning_beast_of_burden_capacity = 30 +interacts = false [abyssal_parasite_familiar] +summoning_time_minutes = 30 id = 6818 -dialogue = "leech" +hitpoints = 2340 +att = 49 +str = 35 +def = 44 +range = 49 +mage = 49 +combat_def = "abyssal_parasite_familiar" interacts = false summoning_beast_of_burden = 1 summoning_beast_of_burden_capacity = 7 summoning_beast_of_burden_essence = 1 [abyssal_lurker_familiar] +summoning_time_minutes = 41 id = 6820 -dialogue = "bird" +hitpoints = 3080 +att = 56 +str = 35 +def = 50 +range = 56 +mage = 56 +combat_def = "abyssal_lurker_familiar" interacts = false summoning_beast_of_burden = 1 summoning_beast_of_burden_capacity = 12 summoning_beast_of_burden_essence = 1 [unicorn_stallion_familiar] +summoning_time_minutes = 54 +summoning_special_cost = 20 id = 6822 +hitpoints = 1000 +att = 80 +str = 35 +def = 69 +range = 80 +mage = 80 +combat_def = "unicorn_stallion_familiar" +interacts = false [magpie_familiar] +summoning_time_minutes = 34 id = 6824 +interacts = false +summoning_beast_of_burden = 1 +summoning_beast_of_burden_capacity = 30 [dreadfowl_familiar] +summoning_time_minutes = 4 id = 6825 +hitpoints = 160 +att = 15 +str = 15 +def = 10 +range = 10 +mage = 15 +combat_def = "dreadfowl_familiar" +interacts = false [stranger_plant_familiar] +summoning_time_minutes = 49 id = 6827 +hitpoints = 3220 +att = 72 +str = 35 +def = 64 +range = 72 +mage = 72 +combat_def = "stranger_plant_familiar" +summoning_beast_of_burden = 1 +summoning_beast_of_burden_capacity = 30 +interacts = false [spirit_wolf_familiar] +summoning_time_minutes = 6 id = 6829 -dialogue = "wolf" +hitpoints = 150 +att = 10 +str = 10 +def = 10 +range = 10 +mage = 10 +combat_def = "spirit_wolf_familiar" interacts = false [desert_wyrm_familiar] +summoning_time_minutes = 19 id = 6831 +hitpoints = 470 +att = 20 +str = 20 +def = 20 +range = 20 +mage = 20 +combat_def = "desert_wyrm_familiar" +interacts = false [evil_turnip_familiar] +summoning_time_minutes = 30 id = 6833 +hitpoints = 1670 +att = 38 +str = 30 +def = 34 +range = 28 +mage = 34 +combat_def = "evil_turnip_familiar" +summoning_beast_of_burden = 1 +summoning_beast_of_burden_capacity = 30 +interacts = false [vampire_bat_familiar] +summoning_time_minutes = 33 id = 6835 +hitpoints = 1050 +att = 30 +str = 30 +def = 30 +range = 30 +mage = 30 +combat_def = "vampire_bat_familiar" +interacts = false [spirit_scorpion_familiar] +summoning_time_minutes = 17 id = 6837 +hitpoints = 670 +att = 20 +str = 20 +def = 20 +range = 20 +mage = 20 +combat_def = "spirit_scorpion_familiar" +interacts = false [arctic_bear_familiar] +summoning_time_minutes = 28 id = 6839 +hitpoints = 3810 +att = 72 +str = 35 +def = 64 +range = 72 +mage = 72 +combat_def = "arctic_bear_familiar" +interacts = false [spirit_spider_familiar] +summoning_time_minutes = 15 id = 6841 +hitpoints = 180 +att = 15 +str = 0 +def = 15 +range = 0 +mage = 0 +combat_def = "spirit_spider_familiar" +summoning_beast_of_burden = 1 +summoning_beast_of_burden_capacity = 30 +interacts = false [bloated_leech_familiar] +summoning_time_minutes = 34 id = 6843 +hitpoints = 2110 +att = 45 +str = 35 +def = 40 +range = 45 +mage = 45 +combat_def = "bloated_leech_familiar" +interacts = false [spirit_kalphite_familiar] +summoning_time_minutes = 22 id = 6994 +hitpoints = 770 +att = 25 +str = 25 +def = 25 +range = 25 +mage = 25 +combat_def = "spirit_kalphite_familiar" +interacts = false [honey_badger_familiar] +summoning_time_minutes = 25 id = 6845 +hitpoints = 1100 +att = 29 +str = 30 +def = 26 +range = 29 +mage = 29 +combat_def = "honey_badger_familiar" +interacts = false [albino_rat_familiar] +summoning_time_minutes = 22 id = 6847 +hitpoints = 680 +att = 22 +str = 22 +def = 22 +range = 22 +mage = 22 +combat_def = "albino_rat_familiar" +summoning_beast_of_burden = 1 +summoning_beast_of_burden_capacity = 30 +interacts = false [granite_lobster_familiar] +summoning_time_minutes = 47 id = 6849 +hitpoints = 4180 +att = 72 +str = 35 +def = 64 +range = 72 +mage = 72 +combat_def = "granite_lobster_familiar" +summoning_beast_of_burden = 1 +summoning_beast_of_burden_capacity = 30 +interacts = false [macaw_familiar] +summoning_time_minutes = 31 id = 6851 +summoning_beast_of_burden = 1 +summoning_beast_of_burden_capacity = 30 +interacts = false [bronze_minotaur_familiar] +summoning_time_minutes = 30 id = 6853 +hitpoints = 1330 +att = 33 +str = 30 +def = 28 +range = 33 +mage = 33 +combat_def = "bronze_minotaur_familiar" +interacts = false [iron_minotaur_familiar] +summoning_time_minutes = 37 id = 6855 +hitpoints = 1930 +att = 42 +str = 35 +def = 37 +range = 42 +mage = 42 +combat_def = "iron_minotaur_familiar" +interacts = false [steel_minotaur_familiar] +summoning_time_minutes = 46 id = 6857 +hitpoints = 2600 +att = 51 +str = 35 +def = 44 +range = 51 +mage = 51 +combat_def = "steel_minotaur_familiar" +interacts = false [mithril_minotaur_familiar] +summoning_time_minutes = 55 id = 6859 +hitpoints = 3400 +att = 60 +str = 35 +def = 53 +range = 60 +mage = 60 +combat_def = "mithril_minotaur_familiar" +interacts = false [adamant_minotaur_familiar] +summoning_time_minutes = 66 id = 6861 +hitpoints = 4410 +att = 72 +str = 35 +def = 64 +range = 72 +mage = 72 +combat_def = "adamant_minotaur_familiar" +interacts = false [rune_minotaur_familiar] +summoning_time_minutes = 151 id = 6863 +hitpoints = 5700 +att = 80 +str = 35 +def = 69 +range = 80 +mage = 80 +combat_def = "rune_minotaur_familiar" +interacts = false [smoke_devil_familiar] +summoning_time_minutes = 48 id = 6865 +hitpoints = 3000 +att = 55 +str = 35 +def = 49 +range = 55 +mage = 55 +combat_def = "smoke_devil_familiar" +interacts = false [bull_ant_familiar] +summoning_time_minutes = 30 id = 6867 +hitpoints = 1540 +att = 36 +str = 30 +def = 32 +range = 36 +mage = 36 +combat_def = "bull_ant_familiar" +interacts = false [wolpertinger_familiar] +summoning_time_minutes = 62 id = 6869 +hitpoints = 6510 +att = 85 +str = 35 +def = 72 +range = 85 +mage = 85 +combat_def = "wolpertinger_familiar" +interacts = false [compost_mound_familiar] +summoning_time_minutes = 24 id = 6871 +hitpoints = 930 +att = 25 +str = 25 +def = 25 +range = 25 +mage = 25 +combat_def = "compost_mound_familiar" +summoning_beast_of_burden = 1 +summoning_beast_of_burden_capacity = 30 +interacts = false [pack_yak_familiar] +summoning_time_minutes = 58 id = 6873 +hitpoints = 7100 +att = 87 +str = 87 +def = 77 +range = 87 +mage = 87 +combat_def = "pack_yak_familiar" +interacts = false [spirit_cockatrice_familiar] +summoning_time_minutes = 36 id = 6875 +hitpoints = 1730 +att = 39 +str = 35 +def = 35 +range = 39 +mage = 39 +combat_def = "spirit_cockatrice_familiar" +summoning_beast_of_burden = 1 +summoning_beast_of_burden_capacity = 30 +interacts = false [spirit_guthatrice_familiar] +summoning_time_minutes = 36 id = 6877 +hitpoints = 1730 +att = 39 +str = 35 +def = 35 +range = 39 +mage = 39 +combat_def = "spirit_guthatrice_familiar" +summoning_beast_of_burden = 1 +summoning_beast_of_burden_capacity = 30 +interacts = false [spirit_saratrice_familiar] +summoning_time_minutes = 36 id = 6879 +hitpoints = 1730 +att = 39 +str = 35 +def = 35 +range = 39 +mage = 39 +combat_def = "spirit_saratrice_familiar" +summoning_beast_of_burden = 1 +summoning_beast_of_burden_capacity = 30 +interacts = false [spirit_zamatrice_familiar] +summoning_time_minutes = 36 id = 6881 +hitpoints = 1730 +att = 39 +str = 35 +def = 35 +range = 39 +mage = 39 +combat_def = "spirit_zamatrice_familiar" +summoning_beast_of_burden = 1 +summoning_beast_of_burden_capacity = 30 +interacts = false [spirit_pengatrice_familiar] +summoning_time_minutes = 36 id = 6883 +hitpoints = 1730 +att = 39 +str = 35 +def = 35 +range = 39 +mage = 39 +combat_def = "spirit_pengatrice_familiar" +summoning_beast_of_burden = 1 +summoning_beast_of_burden_capacity = 30 +interacts = false [spirit_coraxatrice_familiar] +summoning_time_minutes = 36 id = 6885 +hitpoints = 1730 +att = 39 +str = 35 +def = 35 +range = 39 +mage = 39 +combat_def = "spirit_coraxatrice_familiar" +summoning_beast_of_burden = 1 +summoning_beast_of_burden_capacity = 30 +interacts = false [spirit_vulatrice_familiar] +summoning_time_minutes = 36 id = 6887 +hitpoints = 1730 +att = 39 +str = 35 +def = 35 +range = 39 +mage = 39 +combat_def = "spirit_vulatrice_familiar" +summoning_beast_of_burden = 1 +summoning_beast_of_burden_capacity = 30 +interacts = false [barker_toad_familiar] +summoning_time_minutes = 8 id = 6889 +hitpoints = 3400 +att = 60 +str = 35 +def = 53 +range = 60 +mage = 60 +combat_def = "barker_toad_familiar" +interacts = false [ibis_familiar] +summoning_time_minutes = 38 id = 6991 +summoning_beast_of_burden = 1 +summoning_beast_of_burden_capacity = 30 +interacts = false [swamp_titan_familiar] +summoning_time_minutes = 56 id = 7329 +hitpoints = 5660 +att = 77 +str = 35 +def = 67 +range = 77 +mage = 77 +combat_def = "swamp_titan_familiar" +interacts = false [spirit_mosquito_familiar] +summoning_time_minutes = 12 id = 7331 +hitpoints = 430 +att = 5 +str = 1 +def = 45 +range = 1 +mage = 1 +combat_def = "spirit_mosquito_familiar" +interacts = false [void_spinner_familiar] +summoning_time_minutes = 27 id = 7333 +hitpoints = 590 +att = 31 +str = 30 +def = 28 +range = 31 +mage = 31 +combat_def = "void_spinner_familiar" +interacts = false [forge_regent_familiar] +summoning_time_minutes = 45 id = 7335 +hitpoints = 4410 +att = 72 +str = 35 +def = 64 +range = 72 +mage = 72 +combat_def = "forge_regent_familiar" +interacts = false [spirit_larupia_familiar] +summoning_time_minutes = 49 id = 7337 +hitpoints = 2680 +att = 52 +str = 35 +def = 46 +range = 52 +mage = 52 +combat_def = "spirit_larupia_familiar" +interacts = false [geyser_titan_familiar] +summoning_time_minutes = 69 id = 7339 +hitpoints = 6100 +att = 85 +str = 35 +def = 72 +range = 85 +mage = 85 +combat_def = "geyser_titan_familiar" +interacts = false [lava_titan_familiar] +summoning_time_minutes = 61 id = 7341 +hitpoints = 5280 +att = 75 +str = 35 +def = 67 +range = 75 +mage = 75 +combat_def = "lava_titan_familiar" +interacts = false [steel_titan_familiar] +summoning_time_minutes = 64 id = 7343 +hitpoints = 7540 +att = 130 +str = 130 +def = 130 +range = 130 +mage = 130 +combat_def = "steel_titan_familiar" +interacts = false [obsidian_golem_familiar] +summoning_time_minutes = 55 id = 7345 +hitpoints = 4060 +att = 72 +str = 35 +def = 64 +range = 72 +mage = 72 +combat_def = "obsidian_golem_familiar" +interacts = false [talon_beast_familiar] +summoning_time_minutes = 49 id = 7347 +hitpoints = 4540 +att = 72 +str = 35 +def = 64 +range = 72 +mage = 72 +combat_def = "talon_beast_familiar" +interacts = false [abyssal_titan_familiar] +summoning_time_minutes = 93 id = 7349 +hitpoints = 6670 +att = 100 +str = 100 +def = 100 +range = 100 +mage = 100 +combat_def = "abyssal_titan_familiar" +interacts = false summoning_beast_of_burden = 1 summoning_beast_of_burden_capacity = 20 summoning_beast_of_burden_essence = 1 [void_torcher_familiar] +summoning_time_minutes = 94 id = 7351 +hitpoints = 1210 +att = 31 +str = 30 +def = 28 +range = 31 +mage = 31 +combat_def = "void_torcher_familiar" +interacts = false [giant_chinchompa_familiar] +summoning_time_minutes = 31 id = 7353 +hitpoints = 970 +att = 25 +str = 25 +def = 25 +range = 25 +mage = 25 +combat_def = "giant_chinchompa_familiar" +interacts = false [fire_titan_familiar] +summoning_time_minutes = 62 id = 7355 +hitpoints = 4760 +att = 72 +str = 35 +def = 64 +range = 72 +mage = 72 +combat_def = "fire_titan_familiar" +interacts = false [moss_titan_familiar] +summoning_time_minutes = 58 id = 7357 +hitpoints = 4760 +att = 72 +str = 35 +def = 64 +range = 72 +mage = 72 +combat_def = "moss_titan_familiar" +interacts = false [ice_titan_familiar] +summoning_time_minutes = 64 id = 7359 +hitpoints = 4760 +att = 72 +str = 35 +def = 64 +range = 72 +mage = 72 +combat_def = "ice_titan_familiar" +interacts = false [spirit_tz-kih_familiar] +summoning_time_minutes = 18 id = 7361 +hitpoints = 630 +att = 20 +str = 20 +def = 20 +range = 20 +mage = 20 +combat_def = "spirit_tz-kih_familiar" +interacts = false [spirit_graahk_familiar] +summoning_time_minutes = 57 id = 7363 +hitpoints = 2680 +att = 52 +str = 35 +def = 46 +range = 52 +mage = 52 +combat_def = "spirit_graahk_familiar" +interacts = false [spirit_kyatt_familiar] +summoning_time_minutes = 49 id = 7365 +hitpoints = 2680 +att = 52 +str = 35 +def = 46 +range = 52 +mage = 52 +combat_def = "spirit_kyatt_familiar" +interacts = false [void_shifter_familiar] +summoning_time_minutes = 94 id = 7367 +hitpoints = 1210 +att = 31 +str = 30 +def = 28 +range = 31 +mage = 31 +combat_def = "void_shifter_familiar" +interacts = false [pyrelord_familiar] +summoning_time_minutes = 32 id = 7377 +hitpoints = 1930 +att = 60 +str = 40 +def = 30 +range = 1 +mage = 1 +combat_def = "pyrelord_familiar" +interacts = false [void_ravager_familiar] +summoning_time_minutes = 27 id = 7370 +hitpoints = 1210 +att = 31 +str = 30 +def = 28 +range = 31 +mage = 31 +combat_def = "void_ravager_familiar" +summoning_beast_of_burden = 1 +summoning_beast_of_burden_capacity = 30 +interacts = false [ravenous_locust_familiar] +summoning_time_minutes = 24 id = 7372 +hitpoints = 3700 +att = 72 +str = 35 +def = 64 +range = 72 +mage = 72 +combat_def = "ravenous_locust_familiar" +interacts = false [iron_titan_familiar] +summoning_time_minutes = 60 id = 7375 +hitpoints = 6940 +att = 120 +str = 120 +def = 120 +range = 120 +mage = 120 +combat_def = "iron_titan_familiar" +interacts = false [clay_familiar_class_1_familiar] id = 8240 +interacts = false [clay_familiar_class_2_familiar] id = 8242 +interacts = false [clay_familiar_class_3_familiar] id = 8244 +interacts = false [clay_familiar_class_4_familiar] id = 8246 +interacts = false [clay_familiar_class_5_familiar] id = 8248 +interacts = false [phoenix_familiar] id = 8575 +hitpoints = 1530 +att = 72 +str = 35 +def = 64 +range = 72 +mage = 72 +combat_def = "phoenix_familiar" +interacts = false [cub_bloodrager_familiar] id = 11106 +interacts = false [little_bloodrager_familiar] id = 11108 +interacts = false [naïve_bloodrager_familiar] id = 11110 +interacts = false [keen_bloodrager_familiar] id = 11112 +interacts = false [brave_bloodrager_familiar] id = 11114 +interacts = false [brah_bloodrager_familiar] id = 11116 +interacts = false [naabe_bloodrager_familiar] id = 11118 +interacts = false [wise_bloodrager_familiar] id = 11120 +interacts = false [adept_bloodrager_familiar] id = 11122 +interacts = false [sachem_bloodrager_familiar] id = 11124 +interacts = false [cub_stormbringer_familiar] id = 11126 +interacts = false [little_stormbringer_familiar] id = 11128 +interacts = false [naïve_stormbringer_familiar] id = 11130 +interacts = false [keen_stormbringer_familiar] id = 11132 +interacts = false [brave_stormbringer_familiar] id = 11134 +interacts = false [brah_stormbringer_familiar] id = 11136 +interacts = false [naabe_stormbringer_familiar] id = 11138 +interacts = false [wise_stormbringer_familiar] id = 11140 +interacts = false [adept_stormbringer_familiar] id = 11142 +interacts = false [sachem_stormbringer_familiar] id = 11144 +interacts = false [cub_hoardstalker_familiar] id = 11146 +interacts = false [little_hoardstalker_familiar] id = 11148 +interacts = false [naïve_hoardstalker_familiar] id = 11150 +interacts = false [keen_hoardstalker_familiar] id = 11152 +interacts = false [brave_hoardstalker_familiar] id = 11154 +interacts = false [brah_hoardstalker_familiar] id = 11156 +interacts = false [naabe_hoardstalker_familiar] id = 11158 +interacts = false [wise_hoardstalker_familiar] id = 11160 +interacts = false [adept_hoardstalker_familiar] id = 11162 +interacts = false [sachem_hoardstalker_familiar] id = 11164 +interacts = false [cub_skinweaver_familiar] id = 11166 +interacts = false [little_skinweaver_familiar] id = 11168 +interacts = false [naïve_skinweaver_familiar] id = 11170 +interacts = false [keen_skinweaver_familiar] id = 11172 +interacts = false [brave_skinweaver_familiar] id = 11174 +interacts = false [brah_skinweaver_familiar] id = 11176 +interacts = false [naabe_skinweaver_familiar] id = 11178 +interacts = false [wise_skinweaver_familiar] id = 11180 +interacts = false [adept_skinweaver_familiar] id = 11182 +interacts = false [sachem_skinweaver_familiar] id = 11184 +interacts = false [cub_worldbearer_familiar] id = 11186 +interacts = false [little_worldbearer_familiar] id = 11188 +interacts = false [naïve_worldbearer_familiar] id = 11190 +interacts = false [keen_worldbearer_familiar] id = 11192 +interacts = false [brave_worldbearer_familiar] id = 11194 +interacts = false [brah_worldbearer_familiar] id = 11196 +interacts = false [naabe_worldbearer_familiar] id = 11198 +interacts = false [wise_worldbearer_familiar] id = 11200 +interacts = false [adept_worldbearer_familiar] id = 11202 +interacts = false [sachem_worldbearer_familiar] id = 11204 +interacts = false [cub_deathslinger_familiar] id = 11206 +interacts = false [little_deathslinger_familiar] id = 11208 +interacts = false [naive_deathslinger_familiar] id = 11210 +interacts = false [keen_deathslinger_familiar] id = 11212 +interacts = false [brave_deathslinger_familiar] id = 11214 +interacts = false [brah_deathslinger_familiar] id = 11216 +interacts = false [naabe_deathslinger_familiar] id = 11218 +interacts = false [wise_deathslinger_familiar] id = 11220 +interacts = false [adept_deathslinger_familiar] id = 11222 +interacts = false [sachem_deathslinger_familiar] id = 11224 +interacts = false [meerkats_familiar] +summoning_time_minutes = 40 id = 11640 -familiar_time = 40 \ No newline at end of file +interacts = false + + +# --- PvP combat variants (id+1, the 'Attack'-option form shown in PvP areas) --- + +[spirit_wolf_familiar_combat] +id = 6830 +interacts = false +hitpoints = 150 +att = 10 +str = 10 +def = 10 +range = 10 +mage = 10 +combat_def = "spirit_wolf_familiar" + +[dreadfowl_familiar_combat] +id = 6826 +interacts = false +hitpoints = 160 +att = 15 +str = 15 +def = 10 +range = 10 +mage = 15 +combat_def = "dreadfowl_familiar" + +[spirit_spider_familiar_combat] +id = 6842 +interacts = false +hitpoints = 180 +att = 15 +str = 0 +def = 15 +range = 0 +mage = 0 +combat_def = "spirit_spider_familiar" + +[thorny_snail_familiar_combat] +id = 6807 +interacts = false +hitpoints = 280 +att = 1 +str = 1 +def = 15 +range = 15 +mage = 1 +combat_def = "thorny_snail_familiar" + +[granite_crab_familiar_combat] +id = 6797 +interacts = false +hitpoints = 160 +att = 15 +str = 0 +def = 15 +range = 0 +mage = 0 +combat_def = "granite_crab_familiar" + +[spirit_mosquito_familiar_combat] +id = 7332 +interacts = false +hitpoints = 430 +att = 5 +str = 1 +def = 45 +range = 1 +mage = 1 +combat_def = "spirit_mosquito_familiar" + +[desert_wyrm_familiar_combat] +id = 6832 +interacts = false +hitpoints = 470 +att = 20 +str = 20 +def = 20 +range = 20 +mage = 20 +combat_def = "desert_wyrm_familiar" + +[spirit_scorpion_familiar_combat] +id = 6838 +interacts = false +hitpoints = 670 +att = 20 +str = 20 +def = 20 +range = 20 +mage = 20 +combat_def = "spirit_scorpion_familiar" + +[spirit_tz-kih_familiar_combat] +id = 7362 +interacts = false +hitpoints = 630 +att = 20 +str = 20 +def = 20 +range = 20 +mage = 20 +combat_def = "spirit_tz-kih_familiar" + +[albino_rat_familiar_combat] +id = 6848 +interacts = false +hitpoints = 680 +att = 22 +str = 22 +def = 22 +range = 22 +mage = 22 +combat_def = "albino_rat_familiar" + +[spirit_kalphite_familiar_combat] +id = 6995 +interacts = false +hitpoints = 770 +att = 25 +str = 25 +def = 25 +range = 25 +mage = 25 +combat_def = "spirit_kalphite_familiar" + +[compost_mound_familiar_combat] +id = 6872 +interacts = false +hitpoints = 930 +att = 25 +str = 25 +def = 25 +range = 25 +mage = 25 +combat_def = "compost_mound_familiar" + +[giant_chinchompa_familiar_combat] +id = 7354 +interacts = false +hitpoints = 970 +att = 25 +str = 25 +def = 25 +range = 25 +mage = 25 +combat_def = "giant_chinchompa_familiar" + +[vampire_bat_familiar_combat] +id = 6836 +interacts = false +hitpoints = 1050 +att = 30 +str = 30 +def = 30 +range = 30 +mage = 30 +combat_def = "vampire_bat_familiar" + +[honey_badger_familiar_combat] +id = 6846 +interacts = false +hitpoints = 1100 +att = 29 +str = 30 +def = 26 +range = 29 +mage = 29 +combat_def = "honey_badger_familiar" + +[void_ravager_familiar_combat] +id = 7371 +interacts = false +hitpoints = 1210 +att = 31 +str = 30 +def = 28 +range = 31 +mage = 31 +combat_def = "void_ravager_familiar" + +[void_spinner_familiar_combat] +id = 7334 +interacts = false +hitpoints = 590 +att = 31 +str = 30 +def = 28 +range = 31 +mage = 31 +combat_def = "void_spinner_familiar" + +[void_torcher_familiar_combat] +id = 7352 +interacts = false +hitpoints = 1210 +att = 31 +str = 30 +def = 28 +range = 31 +mage = 31 +combat_def = "void_torcher_familiar" + +[void_shifter_familiar_combat] +id = 7368 +interacts = false +hitpoints = 1210 +att = 31 +str = 30 +def = 28 +range = 31 +mage = 31 +combat_def = "void_shifter_familiar" + +[bronze_minotaur_familiar_combat] +id = 6854 +interacts = false +hitpoints = 1330 +att = 33 +str = 30 +def = 28 +range = 33 +mage = 33 +combat_def = "bronze_minotaur_familiar" + +[iron_minotaur_familiar_combat] +id = 6856 +interacts = false +hitpoints = 1930 +att = 42 +str = 35 +def = 37 +range = 42 +mage = 42 +combat_def = "iron_minotaur_familiar" + +[steel_minotaur_familiar_combat] +id = 6858 +interacts = false +hitpoints = 2600 +att = 51 +str = 35 +def = 44 +range = 51 +mage = 51 +combat_def = "steel_minotaur_familiar" + +[mithril_minotaur_familiar_combat] +id = 6860 +interacts = false +hitpoints = 3400 +att = 60 +str = 35 +def = 53 +range = 60 +mage = 60 +combat_def = "mithril_minotaur_familiar" + +[adamant_minotaur_familiar_combat] +id = 6862 +interacts = false +hitpoints = 4410 +att = 72 +str = 35 +def = 64 +range = 72 +mage = 72 +combat_def = "adamant_minotaur_familiar" + +[rune_minotaur_familiar_combat] +id = 6864 +interacts = false +hitpoints = 5700 +att = 80 +str = 35 +def = 69 +range = 80 +mage = 80 +combat_def = "rune_minotaur_familiar" + +[bull_ant_familiar_combat] +id = 6868 +interacts = false +hitpoints = 1540 +att = 36 +str = 30 +def = 32 +range = 36 +mage = 36 +combat_def = "bull_ant_familiar" + +[evil_turnip_familiar_combat] +id = 6834 +interacts = false +hitpoints = 1670 +att = 38 +str = 30 +def = 34 +range = 28 +mage = 34 +combat_def = "evil_turnip_familiar" + +[spirit_cockatrice_familiar_combat] +id = 6876 +interacts = false +hitpoints = 1730 +att = 39 +str = 35 +def = 35 +range = 39 +mage = 39 +combat_def = "spirit_cockatrice_familiar" + +[spirit_guthatrice_familiar_combat] +id = 6878 +interacts = false +hitpoints = 1730 +att = 39 +str = 35 +def = 35 +range = 39 +mage = 39 +combat_def = "spirit_guthatrice_familiar" + +[spirit_saratrice_familiar_combat] +id = 6880 +interacts = false +hitpoints = 1730 +att = 39 +str = 35 +def = 35 +range = 39 +mage = 39 +combat_def = "spirit_saratrice_familiar" + +[spirit_zamatrice_familiar_combat] +id = 6882 +interacts = false +hitpoints = 1730 +att = 39 +str = 35 +def = 35 +range = 39 +mage = 39 +combat_def = "spirit_zamatrice_familiar" + +[spirit_pengatrice_familiar_combat] +id = 6884 +interacts = false +hitpoints = 1730 +att = 39 +str = 35 +def = 35 +range = 39 +mage = 39 +combat_def = "spirit_pengatrice_familiar" + +[spirit_coraxatrice_familiar_combat] +id = 6886 +interacts = false +hitpoints = 1730 +att = 39 +str = 35 +def = 35 +range = 39 +mage = 39 +combat_def = "spirit_coraxatrice_familiar" + +[spirit_vulatrice_familiar_combat] +id = 6888 +interacts = false +hitpoints = 1730 +att = 39 +str = 35 +def = 35 +range = 39 +mage = 39 +combat_def = "spirit_vulatrice_familiar" + +[pyrelord_familiar_combat] +id = 7378 +interacts = false +hitpoints = 1930 +att = 60 +str = 40 +def = 30 +range = 1 +mage = 1 +combat_def = "pyrelord_familiar" + +[bloated_leech_familiar_combat] +id = 6844 +interacts = false +hitpoints = 2110 +att = 45 +str = 35 +def = 40 +range = 45 +mage = 45 +combat_def = "bloated_leech_familiar" + +[spirit_terrorbird_familiar_combat] +id = 6795 +interacts = false +hitpoints = 2330 +att = 47 +str = 35 +def = 37 +range = 47 +mage = 47 +combat_def = "spirit_terrorbird_familiar" + +[abyssal_parasite_familiar_combat] +id = 6819 +interacts = false +hitpoints = 2340 +att = 49 +str = 35 +def = 44 +range = 49 +mage = 49 +combat_def = "abyssal_parasite_familiar" + +[spirit_jelly_familiar_combat] +id = 6993 +interacts = false +hitpoints = 2550 +att = 50 +str = 35 +def = 44 +range = 50 +mage = 50 +combat_def = "spirit_jelly_familiar" + +[spirit_kyatt_familiar_combat] +id = 7366 +interacts = false +hitpoints = 2680 +att = 52 +str = 35 +def = 46 +range = 52 +mage = 52 +combat_def = "spirit_kyatt_familiar" + +[spirit_larupia_familiar_combat] +id = 7338 +interacts = false +hitpoints = 2680 +att = 52 +str = 35 +def = 46 +range = 52 +mage = 52 +combat_def = "spirit_larupia_familiar" + +[spirit_graahk_familiar_combat] +id = 7364 +interacts = false +hitpoints = 2680 +att = 52 +str = 35 +def = 46 +range = 52 +mage = 52 +combat_def = "spirit_graahk_familiar" + +[karamthulhu_overlord_familiar_combat] +id = 6810 +interacts = false +hitpoints = 2760 +att = 53 +str = 35 +def = 47 +range = 53 +mage = 53 +combat_def = "karamthulhu_overlord_familiar" + +[smoke_devil_familiar_combat] +id = 6866 +interacts = false +hitpoints = 3000 +att = 55 +str = 35 +def = 49 +range = 55 +mage = 55 +combat_def = "smoke_devil_familiar" + +[abyssal_lurker_familiar_combat] +id = 6821 +interacts = false +hitpoints = 3080 +att = 56 +str = 35 +def = 50 +range = 56 +mage = 56 +combat_def = "abyssal_lurker_familiar" + +[spirit_cobra_familiar_combat] +id = 6803 +interacts = false +hitpoints = 3140 +att = 57 +str = 35 +def = 57 +range = 57 +mage = 57 +combat_def = "spirit_cobra_familiar" + +[stranger_plant_familiar_combat] +id = 6828 +interacts = false +hitpoints = 3220 +att = 72 +str = 35 +def = 64 +range = 72 +mage = 72 +combat_def = "stranger_plant_familiar" + +[barker_toad_familiar_combat] +id = 6890 +interacts = false +hitpoints = 3400 +att = 60 +str = 35 +def = 53 +range = 60 +mage = 60 +combat_def = "barker_toad_familiar" + +[war_tortoise_familiar_combat] +id = 6816 +interacts = false +hitpoints = 3480 +att = 72 +str = 35 +def = 64 +range = 72 +mage = 72 +combat_def = "war_tortoise_familiar" + +[bunyip_familiar_combat] +id = 6814 +interacts = false +hitpoints = 400 +att = 72 +str = 35 +def = 64 +range = 72 +mage = 72 +combat_def = "bunyip_familiar" + +[ravenous_locust_familiar_combat] +id = 7373 +interacts = false +hitpoints = 3700 +att = 72 +str = 35 +def = 64 +range = 72 +mage = 72 +combat_def = "ravenous_locust_familiar" + +[arctic_bear_familiar_combat] +id = 6840 +interacts = false +hitpoints = 3810 +att = 72 +str = 35 +def = 64 +range = 72 +mage = 72 +combat_def = "arctic_bear_familiar" + +[phoenix_familiar_combat] +id = 8576 +interacts = false +hitpoints = 1530 +att = 72 +str = 35 +def = 64 +range = 72 +mage = 72 +combat_def = "phoenix_familiar" + +[obsidian_golem_familiar_combat] +id = 7346 +interacts = false +hitpoints = 4060 +att = 72 +str = 35 +def = 64 +range = 72 +mage = 72 +combat_def = "obsidian_golem_familiar" + +[granite_lobster_familiar_combat] +id = 6850 +interacts = false +hitpoints = 4180 +att = 72 +str = 35 +def = 64 +range = 72 +mage = 72 +combat_def = "granite_lobster_familiar" + +[praying_mantis_familiar_combat] +id = 6799 +interacts = false +hitpoints = 4280 +att = 72 +str = 35 +def = 64 +range = 72 +mage = 72 +combat_def = "praying_mantis_familiar" + +[forge_regent_familiar_combat] +id = 7336 +interacts = false +hitpoints = 4410 +att = 72 +str = 35 +def = 64 +range = 72 +mage = 72 +combat_def = "forge_regent_familiar" + +[talon_beast_familiar_combat] +id = 7348 +interacts = false +hitpoints = 4540 +att = 72 +str = 35 +def = 64 +range = 72 +mage = 72 +combat_def = "talon_beast_familiar" + +[giant_ent_familiar_combat] +id = 6801 +interacts = false +hitpoints = 4670 +att = 72 +str = 35 +def = 64 +range = 72 +mage = 72 +combat_def = "giant_ent_familiar" + +[hydra_familiar_combat] +id = 6812 +interacts = false +hitpoints = 4900 +att = 72 +str = 35 +def = 64 +range = 72 +mage = 72 +combat_def = "hydra_familiar" + +[spirit_dagannoth_familiar_combat] +id = 6805 +interacts = false +hitpoints = 5280 +att = 75 +str = 35 +def = 67 +range = 75 +mage = 75 +combat_def = "spirit_dagannoth_familiar" + +[unicorn_stallion_familiar_combat] +id = 6823 +interacts = false +hitpoints = 1000 +att = 80 +str = 35 +def = 69 +range = 80 +mage = 80 +combat_def = "unicorn_stallion_familiar" + +[wolpertinger_familiar_combat] +id = 6870 +interacts = false +hitpoints = 6510 +att = 85 +str = 35 +def = 72 +range = 85 +mage = 85 +combat_def = "wolpertinger_familiar" + +[pack_yak_familiar_combat] +id = 6874 +interacts = false +hitpoints = 7100 +att = 87 +str = 87 +def = 77 +range = 87 +mage = 87 +combat_def = "pack_yak_familiar" + +[fire_titan_familiar_combat] +id = 7356 +interacts = false +hitpoints = 4760 +att = 72 +str = 35 +def = 64 +range = 72 +mage = 72 +combat_def = "fire_titan_familiar" + +[moss_titan_familiar_combat] +id = 7358 +interacts = false +hitpoints = 4760 +att = 72 +str = 35 +def = 64 +range = 72 +mage = 72 +combat_def = "moss_titan_familiar" + +[ice_titan_familiar_combat] +id = 7360 +interacts = false +hitpoints = 4760 +att = 72 +str = 35 +def = 64 +range = 72 +mage = 72 +combat_def = "ice_titan_familiar" + +[lava_titan_familiar_combat] +id = 7342 +interacts = false +hitpoints = 5280 +att = 75 +str = 35 +def = 67 +range = 75 +mage = 75 +combat_def = "lava_titan_familiar" + +[swamp_titan_familiar_combat] +id = 7330 +interacts = false +hitpoints = 5660 +att = 77 +str = 35 +def = 67 +range = 77 +mage = 77 +combat_def = "swamp_titan_familiar" + +[geyser_titan_familiar_combat] +id = 7340 +interacts = false +hitpoints = 6100 +att = 85 +str = 35 +def = 72 +range = 85 +mage = 85 +combat_def = "geyser_titan_familiar" + +[abyssal_titan_familiar_combat] +id = 7350 +interacts = false +hitpoints = 6670 +att = 100 +str = 100 +def = 100 +range = 100 +mage = 100 +combat_def = "abyssal_titan_familiar" + +[iron_titan_familiar_combat] +id = 7376 +interacts = false +hitpoints = 6940 +att = 120 +str = 120 +def = 120 +range = 120 +mage = 120 +combat_def = "iron_titan_familiar" + +[steel_titan_familiar_combat] +id = 7344 +interacts = false +hitpoints = 7540 +att = 130 +str = 130 +def = 130 +range = 130 +mage = 130 +combat_def = "steel_titan_familiar" \ No newline at end of file diff --git a/data/skill/summoning/summoning.sounds.toml b/data/skill/summoning/summoning.sounds.toml new file mode 100644 index 0000000000..ee7a54520a --- /dev/null +++ b/data/skill/summoning/summoning.sounds.toml @@ -0,0 +1,5 @@ +[summoning_special_cast] +id = 4161 + +[summoning_renew] +id = 4214 diff --git a/data/skill/summoning/summoning_combat.anims.toml b/data/skill/summoning/summoning_combat.anims.toml new file mode 100644 index 0000000000..6940a27305 --- /dev/null +++ b/data/skill/summoning/summoning_combat.anims.toml @@ -0,0 +1,648 @@ +[spirit_wolf_familiar_attack] +id = 8292 + +[spirit_wolf_familiar_defend] +id = 6557 + +[spirit_wolf_familiar_death] +id = 8295 + +[dreadfowl_familiar_attack] +id = 7810 + +[dreadfowl_familiar_defend] +id = 5388 + +[dreadfowl_familiar_death] +id = 5389 + +[spirit_spider_familiar_attack] +id = 5327 + +[spirit_spider_familiar_defend] +id = 5328 + +[spirit_spider_familiar_death] +id = 5329 + +[thorny_snail_familiar_attack] +id = 8143 + +[thorny_snail_familiar_defend] +id = 8145 + +[thorny_snail_familiar_death] +id = 8143 + +[granite_crab_familiar_attack] +id = 8104 + +[granite_crab_familiar_defend] +id = 8105 + +[granite_crab_familiar_death] +id = 8106 + +[spirit_mosquito_familiar_attack] +id = 8032 + +[spirit_mosquito_familiar_defend] +id = 8034 + +[spirit_mosquito_familiar_death] +id = 8033 + +[desert_wyrm_familiar_attack] +id = 7795 + +[desert_wyrm_familiar_defend] +id = 7796 + +[desert_wyrm_familiar_death] +id = 7797 + +[spirit_scorpion_familiar_attack] +id = 6254 + +[spirit_scorpion_familiar_defend] +id = 6255 + +[spirit_scorpion_familiar_death] +id = 6256 + +[spirit_tz-kih_familiar_attack] +id = 8257 + +[spirit_tz-kih_familiar_defend] +id = 8256 + +[spirit_tz-kih_familiar_death] +id = 8258 + +[albino_rat_familiar_defend] +id = 14861 + +[spirit_kalphite_familiar_attack] +id = 8519 + +[spirit_kalphite_familiar_defend] +id = 8518 + +[spirit_kalphite_familiar_death] +id = 8517 + +[compost_mound_familiar_attack] +id = 7769 + +[compost_mound_familiar_defend] +id = 7771 + +[compost_mound_familiar_death] +id = 7770 + +[giant_chinchompa_familiar_attack] +id = 7755 + +[giant_chinchompa_familiar_defend] +id = 7753 + +[giant_chinchompa_familiar_death] +id = 7758 + +[vampire_bat_familiar_attack] +id = 4915 + +[vampire_bat_familiar_defend] +id = 4916 + +[vampire_bat_familiar_death] +id = 4917 + +[honey_badger_familiar_attack] +id = 7928 + +[honey_badger_familiar_defend] +id = 7927 + +[honey_badger_familiar_death] +id = 7925 + +[void_ravager_familiar_attack] +id = 8086 + +[void_ravager_familiar_defend] +id = 8088 + +[void_ravager_familiar_death] +id = 8087 + +[void_spinner_familiar_attack] +id = 8172 + +[void_spinner_familiar_defend] +id = 8173 + +[void_spinner_familiar_death] +id = 8176 + +[void_torcher_familiar_attack] +id = 8235 + +[void_torcher_familiar_defend] +id = 8237 + +[void_torcher_familiar_death] +id = 8236 + +[void_shifter_familiar_attack] +id = 8131 + +[void_shifter_familiar_defend] +id = 8132 + +[void_shifter_familiar_death] +id = 8133 + +[bronze_minotaur_familiar_attack] +id = 8024 + +[bronze_minotaur_familiar_defend] +id = 8023 + +[bronze_minotaur_familiar_death] +id = 8025 + +[iron_minotaur_familiar_attack] +id = 8024 + +[iron_minotaur_familiar_defend] +id = 8023 + +[iron_minotaur_familiar_death] +id = 8025 + +[steel_minotaur_familiar_attack] +id = 8024 + +[steel_minotaur_familiar_defend] +id = 8023 + +[steel_minotaur_familiar_death] +id = 8025 + +[mithril_minotaur_familiar_attack] +id = 8024 + +[mithril_minotaur_familiar_defend] +id = 8023 + +[mithril_minotaur_familiar_death] +id = 8025 + +[adamant_minotaur_familiar_attack] +id = 8024 + +[adamant_minotaur_familiar_defend] +id = 8023 + +[adamant_minotaur_familiar_death] +id = 8025 + +[rune_minotaur_familiar_attack] +id = 8024 + +[rune_minotaur_familiar_defend] +id = 8023 + +[rune_minotaur_familiar_death] +id = 8025 + +[bull_ant_familiar_attack] +id = 7896 + +[bull_ant_familiar_defend] +id = 7900 + +[bull_ant_familiar_death] +id = 7897 + +[evil_turnip_familiar_attack] +id = 8248 + +[evil_turnip_familiar_defend] +id = 8249 + +[evil_turnip_familiar_death] +id = 8250 + +[spirit_cockatrice_familiar_attack] +id = 7762 + +[spirit_cockatrice_familiar_defend] +id = 7761 + +[spirit_cockatrice_familiar_death] +id = 7763 + +[spirit_guthatrice_familiar_attack] +id = 7762 + +[spirit_guthatrice_familiar_defend] +id = 7761 + +[spirit_guthatrice_familiar_death] +id = 7763 + +[spirit_saratrice_familiar_attack] +id = 7762 + +[spirit_saratrice_familiar_defend] +id = 7761 + +[spirit_saratrice_familiar_death] +id = 7763 + +[spirit_zamatrice_familiar_attack] +id = 7762 + +[spirit_zamatrice_familiar_defend] +id = 7761 + +[spirit_zamatrice_familiar_death] +id = 7763 + +[spirit_pengatrice_familiar_attack] +id = 7762 + +[spirit_pengatrice_familiar_defend] +id = 7761 + +[spirit_pengatrice_familiar_death] +id = 7763 + +[spirit_coraxatrice_familiar_attack] +id = 7762 + +[spirit_coraxatrice_familiar_defend] +id = 7761 + +[spirit_coraxatrice_familiar_death] +id = 7763 + +[spirit_vulatrice_familiar_attack] +id = 7762 + +[spirit_vulatrice_familiar_defend] +id = 7761 + +[spirit_vulatrice_familiar_death] +id = 7763 + +[pyrelord_familiar_attack] +id = 8080 + +[pyrelord_familiar_defend] +id = 8079 + +[pyrelord_familiar_death] +id = 8078 + +[bloated_leech_familiar_attack] +id = 7657 + +[bloated_leech_familiar_defend] +id = 7655 + +[bloated_leech_familiar_death] +id = 7656 + +[spirit_terrorbird_familiar_attack] +id = 1010 + +[spirit_terrorbird_familiar_defend] +id = 1011 + +[spirit_terrorbird_familiar_death] +id = 1012 + +[abyssal_parasite_familiar_attack] +id = 8910 + +[abyssal_parasite_familiar_defend] +id = 7670 + +[abyssal_parasite_familiar_death] +id = 7671 + +[spirit_jelly_familiar_attack] +id = 8569 + +[spirit_jelly_familiar_defend] +id = 8571 + +[spirit_jelly_familiar_death] +id = 8570 + +[spirit_kyatt_familiar_attack] +id = 5229 + +[spirit_kyatt_familiar_defend] +id = 5227 + +[spirit_kyatt_familiar_death] +id = 5230 + +[spirit_larupia_familiar_attack] +id = 7018 + +[spirit_larupia_familiar_defend] +id = 7017 + +[spirit_larupia_familiar_death] +id = 7016 + +[spirit_graahk_familiar_attack] +id = 5229 + +[spirit_graahk_familiar_defend] +id = 5227 + +[spirit_graahk_familiar_death] +id = 5230 + +[karamthulhu_overlord_familiar_attack] +id = 7970 + +[karamthulhu_overlord_familiar_defend] +id = 7962 + +[karamthulhu_overlord_familiar_death] +id = 7964 + +[smoke_devil_familiar_attack] +id = 7816 + +[smoke_devil_familiar_defend] +id = 7817 + +[smoke_devil_familiar_death] +id = 7818 + +[abyssal_lurker_familiar_attack] +id = 2019 + +[abyssal_lurker_familiar_defend] +id = 2020 + +[abyssal_lurker_familiar_death] +id = 2021 + +[spirit_cobra_familiar_attack] +id = 8152 + +[spirit_cobra_familiar_defend] +id = 8154 + +[spirit_cobra_familiar_death] +id = 8153 + +[stranger_plant_familiar_attack] +id = 8208 + +[stranger_plant_familiar_death] +id = 8209 + +[barker_toad_familiar_attack] +id = 7260 + +[barker_toad_familiar_defend] +id = 7257 + +[barker_toad_familiar_death] +id = 7256 + +[war_tortoise_familiar_attack] +id = 8286 + +[war_tortoise_familiar_defend] +id = 8287 + +[war_tortoise_familiar_death] +id = 8285 + +[bunyip_familiar_attack] +id = 7741 + +[bunyip_familiar_defend] +id = 7739 + +[bunyip_familiar_death] +id = 7740 + +[ravenous_locust_familiar_attack] +id = 7994 + +[ravenous_locust_familiar_defend] +id = 7995 + +[ravenous_locust_familiar_death] +id = 7996 + +[arctic_bear_familiar_attack] +id = 4925 + +[arctic_bear_familiar_defend] +id = 4928 + +[arctic_bear_familiar_death] +id = 4929 + +[phoenix_familiar_attack] +id = 11093 + +[phoenix_familiar_defend] +id = 11107 + +[phoenix_familiar_death] +id = 11108 + +[obsidian_golem_familiar_attack] +id = 8050 + +[obsidian_golem_familiar_defend] +id = 8051 + +[obsidian_golem_familiar_death] +id = 8052 + +[granite_lobster_familiar_attack] +id = 8112 + +[granite_lobster_familiar_defend] +id = 8114 + +[granite_lobster_familiar_death] +id = 8113 + +[praying_mantis_familiar_attack] +id = 8069 + +[praying_mantis_familiar_defend] +id = 8066 + +[praying_mantis_familiar_death] +id = 8065 + +[forge_regent_familiar_attack] +id = 7866 + +[forge_regent_familiar_defend] +id = 7865 + +[forge_regent_familiar_death] +id = 7864 + +[talon_beast_familiar_attack] +id = 5989 + +[talon_beast_familiar_defend] +id = 5988 + +[talon_beast_familiar_death] +id = 5990 + +[giant_ent_familiar_attack] +id = 7853 + +[giant_ent_familiar_defend] +id = 7852 + +[giant_ent_familiar_death] +id = 7854 + +[hydra_familiar_attack] +id = 7935 + +[hydra_familiar_defend] +id = 7936 + +[hydra_familiar_death] +id = 7937 + +[spirit_dagannoth_familiar_attack] +id = 7786 + +[spirit_dagannoth_familiar_defend] +id = 7785 + +[spirit_dagannoth_familiar_death] +id = 7780 + +[unicorn_stallion_familiar_attack] +id = 6376 + +[unicorn_stallion_familiar_defend] +id = 6375 + +[unicorn_stallion_familiar_death] +id = 6377 + +[wolpertinger_familiar_attack] +id = 8303 + +[wolpertinger_familiar_defend] +id = 8304 + +[wolpertinger_familiar_death] +id = 8305 + +[pack_yak_familiar_attack] +id = 5782 + +[pack_yak_familiar_defend] +id = 5783 + +[pack_yak_familiar_death] +id = 852 + +[fire_titan_familiar_attack] +id = 7834 + +[fire_titan_familiar_defend] +id = 7832 + +[fire_titan_familiar_death] +id = 7833 + +[moss_titan_familiar_attack] +id = 7844 + +[moss_titan_familiar_defend] +id = 7842 + +[moss_titan_familiar_death] +id = 7843 + +[ice_titan_familiar_attack] +id = 8183 + +[ice_titan_familiar_defend] +id = 8185 + +[ice_titan_familiar_death] +id = 8184 + +[lava_titan_familiar_attack] +id = 7980 + +[lava_titan_familiar_defend] +id = 7981 + +[lava_titan_familiar_death] +id = 7692 + +[swamp_titan_familiar_attack] +id = 8222 + +[swamp_titan_familiar_defend] +id = 8224 + +[swamp_titan_familiar_death] +id = 8226 + +[geyser_titan_familiar_attack] +id = 7883 + +[geyser_titan_familiar_defend] +id = 7878 + +[geyser_titan_familiar_death] +id = 7880 + +[abyssal_titan_familiar_attack] +id = 7693 + +[abyssal_titan_familiar_defend] +id = 7691 + +[abyssal_titan_familiar_death] +id = 7979 + +[iron_titan_familiar_attack] +id = 7946 + +[iron_titan_familiar_defend] +id = 7948 + +[iron_titan_familiar_death] +id = 7947 + +[steel_titan_familiar_attack] +id = 8190 + +[steel_titan_familiar_defend] +id = 8185 + +[steel_titan_familiar_death] +id = 8184 + diff --git a/data/skill/summoning/summoning_combat.gfx.toml b/data/skill/summoning/summoning_combat.gfx.toml new file mode 100644 index 0000000000..92f292b27f --- /dev/null +++ b/data/skill/summoning/summoning_combat.gfx.toml @@ -0,0 +1,52 @@ +[dreadfowl_familiar_projectile] +id = 1318 + +[thorny_snail_familiar_projectile] +id = 1380 + +[thorny_snail_familiar_attack_gfx] +id = 1379 + +[smoke_devil_familiar_projectile] +id = 1376 + +[smoke_devil_familiar_flames_projectile] +id = 1473 + +[forge_regent_familiar_projectile] +id = 1330 + +[hydra_familiar_projectile] +id = 1489 + +[wolpertinger_familiar_projectile] +id = 2733 + +[steel_titan_familiar_attack_gfx] +id = 1444 + +[steel_titan_familiar_projectile] +id = 1445 + +[geyser_titan_familiar_attack_gfx] +id = 1375 + +[geyser_titan_familiar_projectile] +id = 1374 + +[karamthulhu_overlord_familiar_attack_gfx] +id = 1474 + +[karamthulhu_overlord_familiar_projectile] +id = 1477 + +[spirit_tz-kih_familiar_attack_gfx] +id = 1422 + +[spirit_tz-kih_familiar_projectile] +id = 1423 + +# Shared by every metal tier of minotaur familiar - the axe swing on a normal melee attack. +[minotaur_axe_swing] +id = 1498 + diff --git a/data/skill/summoning/summoning_spawn.anims.toml b/data/skill/summoning/summoning_spawn.anims.toml new file mode 100644 index 0000000000..8f2912920e --- /dev/null +++ b/data/skill/summoning/summoning_spawn.anims.toml @@ -0,0 +1,433 @@ +[abyssal_lurker_spawn] +id = 7683 + +[abyssal_parasite_spawn] +id = 7669 + +[abyssal_titan_spawn] +id = 8188 + +[adamant_minotaur_spawn] +id = 8029 + +[adept_bloodrager_spawn] +id = 13684 + +[adept_deathslinger_spawn] +id = 13684 + +[adept_hoardstalker_spawn] +id = 13684 + +[adept_skinweaver_spawn] +id = 13676 + +[adept_stormbringer_spawn] +id = 13676 + +[adept_worldbearer_spawn] +id = 13684 + +[arctic_bear_spawn] +id = 8522 + +[barker_toad_spawn] +id = 7702 + +[beaver_spawn] +id = 7721 + +[bloated_leech_spawn] +id = 7711 + +[brah_bloodrager_spawn] +id = 13684 + +[brah_deathslinger_spawn] +id = 13684 + +[brah_hoardstalker_spawn] +id = 13684 + +[brah_skinweaver_spawn] +id = 13676 + +[brah_stormbringer_spawn] +id = 13676 + +[brah_worldbearer_spawn] +id = 13684 + +[brave_bloodrager_spawn] +id = 13684 + +[brave_deathslinger_spawn] +id = 13684 + +[brave_hoardstalker_spawn] +id = 13684 + +[brave_skinweaver_spawn] +id = 13676 + +[brave_stormbringer_spawn] +id = 13676 + +[brave_worldbearer_spawn] +id = 13684 + +[bronze_minotaur_spawn] +id = 8029 + +[bull_ant_spawn] +id = 7894 + +[bunyip_spawn] +id = 7736 + +[clay_familiar_class_1_spawn] +id = 10595 + +[clay_familiar_class_2_spawn] +id = 10595 + +[clay_familiar_class_3_spawn] +id = 10595 + +[clay_familiar_class_4_spawn] +id = 10595 + +[clay_familiar_class_5_spawn] +id = 10595 + +[compost_mound_spawn] +id = 7773 + +[cub_bloodrager_spawn] +id = 13684 + +[cub_deathslinger_spawn] +id = 13684 + +[cub_hoardstalker_spawn] +id = 13684 + +[cub_skinweaver_spawn] +id = 13676 + +[cub_stormbringer_spawn] +id = 13676 + +[cub_worldbearer_spawn] +id = 13684 + +[desert_wyrm_spawn] +id = 7794 + +# Desert wyrm "Burrow" npc option - the wyrm digs underground to mine ore. +[desert_wyrm_burrow] +id = 7800 + +[dreadfowl_spawn] +id = 7807 + +[evil_turnip_spawn] +id = 8252 + +[fire_titan_spawn] +id = 7829 + +[forge_regent_spawn] +id = 7870 + +[fruit_bat_spawn] +id = 8279 + +[geyser_titan_spawn] +id = 7881 + +[giant_chinchompa_spawn] +id = 7754 + +[giant_ent_spawn] +id = 7850 + +[granite_crab_spawn] +id = 8108 + +[granite_lobster_spawn] +id = 8122 + +[honey_badger_spawn] +id = 7929 + +[hydra_spawn] +id = 7940 + +[ibis_spawn] +id = 8202 + +[ice_titan_spawn] +id = 8188 + +[iron_minotaur_spawn] +id = 8029 + +[iron_titan_spawn] +id = 8188 + +[karamthulhu_overlord_spawn] +id = 7969 + +[keen_bloodrager_spawn] +id = 13684 + +[keen_deathslinger_spawn] +id = 13684 + +[keen_hoardstalker_spawn] +id = 13684 + +[keen_skinweaver_spawn] +id = 13676 + +[keen_stormbringer_spawn] +id = 13676 + +[keen_worldbearer_spawn] +id = 13684 + +[lava_titan_spawn] +id = 7987 + +[little_bloodrager_spawn] +id = 13684 + +[little_deathslinger_spawn] +id = 13684 + +[little_hoardstalker_spawn] +id = 13684 + +[little_skinweaver_spawn] +id = 13676 + +[little_stormbringer_spawn] +id = 13676 + +[little_worldbearer_spawn] +id = 13684 + +[macaw_spawn] +id = 8005 + +[magpie_spawn] +id = 8005 + +[meerkats_spawn] +id = 14316 + +[mithril_minotaur_spawn] +id = 8029 + +[moss_titan_spawn] +id = 8188 + +[naabe_bloodrager_spawn] +id = 13684 + +[naabe_deathslinger_spawn] +id = 13684 + +[naabe_hoardstalker_spawn] +id = 13684 + +[naabe_skinweaver_spawn] +id = 13676 + +[naabe_stormbringer_spawn] +id = 13676 + +[naabe_worldbearer_spawn] +id = 13684 + +[naive_deathslinger_spawn] +id = 13684 + +[naïve_bloodrager_spawn] +id = 13684 + +[naïve_hoardstalker_spawn] +id = 13684 + +[naïve_skinweaver_spawn] +id = 13676 + +[naïve_stormbringer_spawn] +id = 13676 + +[naïve_worldbearer_spawn] +id = 13684 + +[obsidian_golem_spawn] +id = 8049 + +[pack_yak_spawn] +id = 8058 + +[phoenix_spawn] +id = 11095 + +[praying_mantis_spawn] +id = 8075 + +[pyrelord_spawn] +id = 8081 + +[ravenous_locust_spawn] +id = 7997 + +[rune_minotaur_spawn] +id = 8029 + +[sachem_bloodrager_spawn] +id = 13684 + +[sachem_deathslinger_spawn] +id = 13684 + +[sachem_hoardstalker_spawn] +id = 13684 + +[sachem_skinweaver_spawn] +id = 13676 + +[sachem_stormbringer_spawn] +id = 13676 + +[sachem_worldbearer_spawn] +id = 13684 + +[smoke_devil_spawn] +id = 7819 + +[spirit_cobra_spawn] +id = 8157 + +[spirit_cockatrice_spawn] +id = 7765 + +[spirit_coraxatrice_spawn] +id = 7765 + +[spirit_dagannoth_spawn] +id = 7783 + +[spirit_graahk_spawn] +id = 7909 + +[spirit_guthatrice_spawn] +id = 7765 + +[spirit_jelly_spawn] +id = 8513 + +[spirit_kalphite_spawn] +id = 8516 + +[spirit_kyatt_spawn] +id = 7909 + +[spirit_larupia_spawn] +id = 7909 + +[spirit_mosquito_spawn] +id = 8037 + +[spirit_pengatrice_spawn] +id = 7765 + +[spirit_saratrice_spawn] +id = 7765 + +[spirit_scorpion_spawn] +id = 8127 + +[spirit_spider_spawn] +id = 8163 + +[spirit_terrorbird_spawn] +id = 8231 + +[spirit_tz-kih_spawn] +id = 8260 + +[spirit_vulatrice_spawn] +id = 7765 + +[spirit_wolf_spawn] +id = 8298 + +[spirit_zamatrice_spawn] +id = 7765 + +[steel_minotaur_spawn] +id = 8029 + +[steel_titan_spawn] +id = 8188 + +[stranger_plant_spawn] +id = 8216 + +[swamp_titan_spawn] +id = 8225 + +[talon_beast_spawn] +id = 8045 + +[thorny_snail_spawn] +id = 8141 + +[unicorn_stallion_spawn] +id = 8266 + +[vampire_bat_spawn] +id = 8279 + +[void_ravager_spawn] +id = 8091 + +[void_shifter_spawn] +id = 8134 + +[void_spinner_spawn] +id = 8174 + +[void_torcher_spawn] +id = 8238 + +[war_tortoise_spawn] +id = 8282 + +[wise_bloodrager_spawn] +id = 13684 + +[wise_deathslinger_spawn] +id = 13684 + +[wise_hoardstalker_spawn] +id = 13684 + +[wise_skinweaver_spawn] +id = 13676 + +[wise_stormbringer_spawn] +id = 13676 + +[wise_worldbearer_spawn] +id = 13684 + +[wolpertinger_spawn] +id = 8309 + diff --git a/data/skill/summoning/summoning_special.anims.toml b/data/skill/summoning/summoning_special.anims.toml new file mode 100644 index 0000000000..b0cb015a59 --- /dev/null +++ b/data/skill/summoning/summoning_special.anims.toml @@ -0,0 +1,204 @@ +# Familiar special-move animations (cast-button specials). +# Combat specials +[spirit_wolf_howl] +id = 8293 + +[electric_lash] +id = 7795 + +[slime_spray] +id = 8148 + +[arctic_blast] +id = 4926 + +[crushing_claw] +id = 8118 + +[dreadfowl_strike] +id = 5387 + +[dissolve] +id = 8575 + +[rending] +id = 5229 + +[evil_flames] +id = 8251 + +[abyssal_drain] +id = 7672 + +[vampire_touch] +id = 8275 + +[petrifying_gaze] +id = 7762 + +[bull_rush] +id = 8026 + +[poisonous_blast] +id = 8211 + +[boil] +id = 7883 + +[venom_shot] +id = 6261 + +[fireball_assault] +id = 8257 + +[sandstorm] +id = 8517 + +[egg_spawn] +id = 8164 + +# Boost / heal specials +[stony_shell] +id = 8109 + +[testudo] +id = 8288 + +[magic_focus] +id = 8308 + +[abyssal_stealth] +id = 7682 + +[unburden] +id = 7896 + +[tireless_run] +id = 8229 + +[healing_aura] +id = 8267 + +[thieving_fingers] +id = 8020 + +[insane_ferocity] +id = 7928 + +# Player animation played while casting a special-move scroll. +[summoning_special_cast] +id = 7660 + +[cheese_feast] +id = 14858 + +[generate_compost] +id = 7775 + +# Macaw's Herbcall animation - the macaw flaps up and hovers as it searches out a herb. `ticks` is +# the animation's full length (263 client cycles / 30 ≈ 9 game ticks). +[herbcall] +id = 8013 +ticks = 9 + +[chinchompa_explode] +id = 7758 + +[beaver_multichop] +id = 7722 + +[call_to_arms] +id = 8136 + +[call_to_arms_land] +id = 8137 + +[doomsphere] +id = 7974 + +[spike_shot] +id = 7787 + +[swamp_plague] +id = 8223 + +[ebon_thunder] +id = 7986 + +[mantis_strike] +id = 8071 + +[acorn_missile] +id = 7858 + +[famine] +id = 7998 + +[dust_cloud] +id = 7820 + +[iron_within] +id = 7954 + +[steel_of_legends] +id = 8190 + +[regrowth] +id = 7945 + +[swallow_whole] +id = 7747 + +[ophidian_incubation] +id = 8159 + +[immense_heat] +id = 8081 + +[toad_bark] +id = 7703 + +[toad_bark_load] +id = 7704 + +[blood_drain] +id = 7715 + +[volcanic_strength] +id = 8053 + +[goad] +id = 7915 + +[ambush] +id = 7914 + +[fish_rain] +id = 8199 + +[fruitfall_ascend] +id = 8320 + +[fruitfall] +id = 8321 + +[essence_shipment] +id = 7698 + +[titans_constitution_fire] +id = 7835 + +[titans_constitution_ice] +id = 7837 + +[titans_constitution_moss] +id = 7837 + +[inferno] +id = 7871 + +[bunyip_transmute] +id = 2779 + +[ferocious] +id = 7791 diff --git a/data/skill/summoning/summoning_special.gfx.toml b/data/skill/summoning/summoning_special.gfx.toml new file mode 100644 index 0000000000..0cdb6aec13 --- /dev/null +++ b/data/skill/summoning/summoning_special.gfx.toml @@ -0,0 +1,457 @@ +# Familiar special-move graphics + projectiles (cast-button specials). +# Suffix conventions: bare name = the familiar's own gfx, _proj = projectile, _hit = target gfx. +# +# Projectile speed standard (units are client ticks, 30 per 0.6s game tick): delay = 30, +# time_offset = 10, multiplier = 10 gives a flight of 1 game tick + 1/3 of a tick per tile - +# the live game's "1 + floor(distance / 3)" hit delay. Deliberate exceptions (e.g. Bull Rush's +# constant 2s charge) note why inline. + +# Combat specials +[spirit_wolf_howl] +id = 1334 + +[spirit_wolf_howl_proj] +id = 1333 +delay = 30 +time_offset = 10 +multiplier = 10 +curve = 0 + +[electric_lash] +id = 1410 + +[electric_lash_proj] +id = 1411 +delay = 30 +time_offset = 10 +multiplier = 10 + +[toad_bark] +id = 1403 + +[toad_bark_hit] +id = 1404 + +[slime_spray] +id = 1385 + +[slime_spray_proj] +id = 1386 +delay = 30 +time_offset = 10 +multiplier = 10 + +[slime_spray_hit] +id = 1387 + +[arctic_blast] +id = 1405 + +[arctic_blast_proj] +id = 1406 +delay = 30 +time_offset = 10 +multiplier = 10 + +[arctic_blast_hit] +id = 1407 + +[crushing_claw] +id = 1351 + +[crushing_claw_proj] +id = 1352 +delay = 30 +time_offset = 10 +multiplier = 10 + +[dreadfowl_strike] +id = 1523 + +[dreadfowl_strike_proj] +id = 1318 +delay = 30 +time_offset = 10 +multiplier = 10 +curve = 0 + +[dissolve_proj] +id = 1360 +delay = 30 +time_offset = 10 +multiplier = 10 + +[rending] +id = 1370 + +[rending_proj] +id = 1371 +delay = 30 +time_offset = 10 +multiplier = 10 + +[evil_flames] +id = 1328 + +[evil_flames_proj] +id = 1330 +delay = 30 +time_offset = 10 +multiplier = 10 + +[evil_flames_hit] +id = 1329 + +[abyssal_drain] +id = 1422 + +[abyssal_drain_proj] +id = 1423 +delay = 30 +time_offset = 10 +multiplier = 10 + +[vampire_touch] +id = 1323 + +[petrifying_gaze] +id = 1467 + +[petrifying_gaze_proj] +id = 1468 +delay = 30 +time_offset = 10 +multiplier = 10 + +[petrifying_gaze_hit] +id = 1469 + +[bull_rush] +id = 1496 + +[bull_rush_proj] +id = 1497 +# The minotaur charges its target from range with a 2s flight (flight units are 20ms, so 100 = 2s); +# the hit and the on-impact stun land as the projectile arrives. Constant regardless of distance. +time_offset = 100 +multiplier = 0 + +[poisonous_blast_proj] +id = 1508 +delay = 30 +time_offset = 10 +multiplier = 10 + +[poisonous_blast_hit] +id = 1511 + +[boil] +id = 1373 + +[boil_proj] +id = 1376 +delay = 30 +time_offset = 10 +multiplier = 10 + +[boil_hit] +id = 1377 + +[venom_shot] +id = 1354 +ticks = 3 + +[venom_shot_proj] +id = 1355 + +[fireball_assault_hit] +id = 1329 + +[sandstorm] +id = 1350 + +[sandstorm_proj] +id = 1349 +delay = 30 +time_offset = 10 +multiplier = 10 + +[egg_spawn] +id = 1342 + +# Boost / heal specials +[stony_shell] +id = 1326 + +[testudo] +id = 1414 + +[volcanic_strength] +id = 1465 + +[magic_focus] +id = 1464 + +[unburden] +id = 1382 + +[tireless_run] +id = 1521 + +[healing_aura] +id = 1356 + +[thieving_fingers] +id = 1336 + +[insane_ferocity] +id = 1397 + +[insane_ferocity_owner] +id = 1399 + +# Player graphic played while casting a special-move scroll. +[summoning_special_cast] +id = 1316 + +# Forage specials +[cheese_feast] +id = 1384 + +[herbcall] +id = 1321 + +[generate_compost] +id = 1461 + +[generate_compost_proj] +id = 1462 + +[generate_compost_bin] +id = 1460 + +[chinchompa_explode] +id = 1364 + +[beaver_multichop] +id = 1459 + +[call_to_arms] +id = 1503 + +[call_to_arms_land] +id = 1502 + +[doomsphere] +id = 1478 + +[doomsphere_proj] +id = 1479 +delay = 30 +time_offset = 10 +multiplier = 10 +curve = 0 + +[doomsphere_hit] +id = 1480 + +[spike_shot_proj] +id = 1426 +delay = 30 +time_offset = 10 +multiplier = 10 +curve = 0 + +[spike_shot_hit] +id = 1428 + +[swamp_plague_proj] +id = 1462 +delay = 30 +time_offset = 10 +multiplier = 10 +curve = 0 + +[ebon_thunder] +id = 1492 + +[ebon_thunder_proj] +id = 1493 +delay = 30 +time_offset = 10 +multiplier = 10 +curve = 0 + +[ebon_thunder_hit] +id = 1494 + +[mantis_strike] +id = 1379 + +[mantis_strike_proj] +id = 1380 +delay = 30 +time_offset = 10 +multiplier = 10 +curve = 0 + +[mantis_strike_hit] +id = 1381 + +[acorn_missile_proj] +id = 1362 +delay = 30 +time_offset = 10 +multiplier = 10 +curve = 0 + +[acorn_missile_hit] +id = 1363 + +[famine] +id = 1346 + +[famine_proj] +id = 1347 +delay = 30 +time_offset = 10 +multiplier = 10 +curve = 0 + +[famine_hit] +id = 1348 + +[inferno] +id = 1394 + +[inferno_hit] +id = 1393 + +[dust_cloud] +id = 1375 + +[dust_cloud_proj] +id = 1376 +delay = 30 +time_offset = 10 +multiplier = 10 +curve = 0 + +[dust_cloud_hit] +id = 1377 + +[iron_within] +id = 1450 + +[steel_of_legends] +id = 1449 + +[regrowth] +id = 1487 + +[swallow_whole] +id = 1481 + +[ophidian_incubation] +id = 1388 + +[immense_heat] +id = 1463 + +[toad_bark_load] +id = 1400 + +[toad_bark_proj] +id = 1402 +delay = 30 +time_offset = 10 +multiplier = 10 +curve = 16 +height = -30 +end_height = -24 + +[thieving_fingers_owner] +id = 1300 + +[blood_drain] +id = 1419 + +[blood_drain_proj] +id = 1417 +delay = 30 +time_offset = 10 +multiplier = 10 +curve = 0 + +[blood_drain_owner] +id = 1420 + +[tireless_run_owner] +id = 1300 + +[dissolve] +id = 1361 + +[dissolve_hit] +id = 1360 + +[rending_hit] +id = 1372 + +[abyssal_stealth] +id = 1339 + +[abyssal_stealth_owner] +id = 1302 + +[ambush] +id = 1366 + +[fish_rain] +id = 1337 + +[essence_shipment] +id = 1457 + +[winter_storage] +id = 1358 + +[titans_constitution_fire] +id = 1514 + +[titans_constitution_fire_owner] +id = 1307 + +[titans_constitution_ice] +id = 1512 + +[titans_constitution_ice_owner] +id = 1306 + +[titans_constitution_moss] +id = 1513 + +[titans_constitution_moss_owner] +id = 1308 + +[healing_aura_owner] +id = 1300 + +[bunyip_transmute_proj] +id = 1435 +delay = 30 +time_offset = 10 +multiplier = 10 +curve = 0 + +[ferocious] +id = 1427 + +[testudo_owner] +id = 1308 + +[fruitfall_land] +id = 1331 + +[fruitfall_ascend] +id = 1332 diff --git a/engine/src/main/kotlin/world/gregs/voidps/engine/client/update/NPCTask.kt b/engine/src/main/kotlin/world/gregs/voidps/engine/client/update/NPCTask.kt index e1b8cc25a1..658a8e0b2e 100644 --- a/engine/src/main/kotlin/world/gregs/voidps/engine/client/update/NPCTask.kt +++ b/engine/src/main/kotlin/world/gregs/voidps/engine/client/update/NPCTask.kt @@ -4,11 +4,13 @@ import world.gregs.voidps.engine.client.update.iterator.TaskIterator import world.gregs.voidps.engine.client.variable.hasClock import world.gregs.voidps.engine.entity.Spawn import world.gregs.voidps.engine.entity.character.mode.EmptyMode +import world.gregs.voidps.engine.entity.character.mode.Follow import world.gregs.voidps.engine.entity.character.mode.Wander import world.gregs.voidps.engine.entity.character.mode.Wander.Companion.wanders import world.gregs.voidps.engine.entity.character.move.tele import world.gregs.voidps.engine.entity.character.npc.NPC import world.gregs.voidps.engine.entity.character.npc.NPCs +import world.gregs.voidps.engine.entity.character.player.Players import world.gregs.voidps.engine.entity.character.npc.flagTransform import world.gregs.voidps.engine.entity.character.player.skill.Skill import world.gregs.voidps.type.Direction @@ -22,8 +24,16 @@ class NPCTask( override fun run(character: NPC) { checkDelay(character) lifecycle(character) - if (character.mode == EmptyMode && wanders(character)) { - character.mode = Wander(character) + if (character.mode == EmptyMode) { + // An idle familiar (its owner still has it as their follower) resumes following its + // owner rather than wandering or standing still after a fight ends. + val ownerIndex = character["owner_index", -1] + val owner = if (ownerIndex != -1) Players.indexed(ownerIndex) else null + if (owner != null && owner.get("follower_index", -1) == character.index) { + character.mode = Follow(character, owner) + } else if (wanders(character)) { + character.mode = Wander(character) + } } healthRegen(character) character.softTimers.run() diff --git a/engine/src/main/kotlin/world/gregs/voidps/engine/entity/character/Character.kt b/engine/src/main/kotlin/world/gregs/voidps/engine/entity/character/Character.kt index b85bfbf461..3bcdda3922 100644 --- a/engine/src/main/kotlin/world/gregs/voidps/engine/entity/character/Character.kt +++ b/engine/src/main/kotlin/world/gregs/voidps/engine/entity/character/Character.kt @@ -113,7 +113,9 @@ interface Character : */ fun gfx(id: String, delay: Int? = null, height: Int? = null) { val definition = GraphicDefinitions.getOrNull(id) ?: return - val mask = if (this is Player) VisualMask.PLAYER_GRAPHIC_1_MASK else VisualMask.NPC_GRAPHIC_1_MASK + // Graphics fill the secondary slot first; a second graphic in the same tick spills into the + // primary slot so both render, rather than overwriting the first. + val mask = if (this is Player) VisualMask.PLAYER_GRAPHIC_2_MASK else VisualMask.NPC_GRAPHIC_2_MASK val graphic = if (visuals.flagged(mask)) visuals.primaryGraphic else visuals.secondaryGraphic graphic.id = definition.id graphic.delay = delay ?: definition["delay", 0] diff --git a/engine/src/main/kotlin/world/gregs/voidps/engine/entity/character/mode/Follow.kt b/engine/src/main/kotlin/world/gregs/voidps/engine/entity/character/mode/Follow.kt index 5dd66196d6..d4efdb0406 100644 --- a/engine/src/main/kotlin/world/gregs/voidps/engine/entity/character/mode/Follow.kt +++ b/engine/src/main/kotlin/world/gregs/voidps/engine/entity/character/mode/Follow.kt @@ -8,7 +8,10 @@ import world.gregs.voidps.engine.entity.character.move.tele import world.gregs.voidps.engine.entity.character.npc.NPC import world.gregs.voidps.engine.entity.character.player.Player import world.gregs.voidps.engine.entity.distanceTo +import world.gregs.voidps.engine.map.Overlap +import world.gregs.voidps.type.Direction import world.gregs.voidps.type.Tile +import world.gregs.voidps.type.random class Follow( character: Character, @@ -41,16 +44,50 @@ class Follow( return } character.walkTrigger() + // An npc already beside its target just faces it rather than pathing onto the follow tile + // (checked here as well as in recalculate - a freshly-set Follow calculates a full path on + // its first tick, which would otherwise bypass the recalculate guard). A target standing + // on top of the npc falls through so [stepOut] can move it to a free adjacent tile. + if (character is NPC && !underTarget() && target.tile.distanceTo(character) <= 1) { + character.steps.clearDestination() + return + } if (!smart) { character.steps.clearDestination() } super.tick() } + private fun underTarget(): Boolean = Overlap.isUnder(character.tile, character.size, character.size, target.tile, target.size, target.size) + + /** + * The follow target standing on top of the npc forces it out to a free adjacent tile, the same + * way combat movement steps npcs out from under their opponent. + */ + override fun stepOut(): Boolean { + val npc = character as? NPC ?: return false + if (npc.def["allowed_under", false] || !underTarget()) { + return false + } + clearSteps() + for (direction in Direction.cardinal.shuffled(random)) { + if (canStep(direction.delta.x, direction.delta.y)) { + character.steps.queueStep(npc.tile.add(direction)) + break + } + } + return true + } + override fun recalculate(): Boolean { if (character.steps.isEmpty()) { smart = false } + // An npc already beside its target just faces it rather than shuffling onto the follow + // tile; it only moves again once the target walks out of reach. + if (character is NPC && target.tile.distanceTo(character) <= 1) { + return false + } if (!equals(strategy.tile, character.steps.destination)) { character.steps.queueStep(strategy.tile) return true diff --git a/engine/src/main/kotlin/world/gregs/voidps/engine/entity/character/mode/combat/CombatMovement.kt b/engine/src/main/kotlin/world/gregs/voidps/engine/entity/character/mode/combat/CombatMovement.kt index 2364e9426a..3483ac7565 100644 --- a/engine/src/main/kotlin/world/gregs/voidps/engine/entity/character/mode/combat/CombatMovement.kt +++ b/engine/src/main/kotlin/world/gregs/voidps/engine/entity/character/mode/combat/CombatMovement.kt @@ -12,6 +12,7 @@ import world.gregs.voidps.engine.entity.character.mode.move.Movement import world.gregs.voidps.engine.entity.character.mode.move.target.TargetStrategy import world.gregs.voidps.engine.entity.character.npc.NPC import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.entity.character.player.Players import world.gregs.voidps.engine.entity.character.player.chat.cantReach import world.gregs.voidps.engine.entity.item.Item import world.gregs.voidps.engine.get @@ -30,6 +31,8 @@ class CombatMovement( var started = false + private var unreachableTicks = 0 + override fun start() { if (character is NPC) { character.steps.clear() @@ -51,7 +54,12 @@ class CombatMovement( character.mode = EmptyMode return } - if (character is NPC) { + if (character is NPC && character["owner_index", -1] == -1 && target["owner_index", -1] == -1) { + // Owned familiars aren't bound by the spawn/aggro leash: they chase whatever their + // owner directs them at (which can be further than their aggro range) and fall back to + // following the owner when the fight ends, so they never wander off permanently. An npc + // fighting a familiar is likewise exempt, so it keeps defending itself instead of + // de-aggroing and walking back to its spawn while the familiar is still on it. val spawn: Tile = character["spawn_tile"] ?: return val definition = get().get(character.transformDef["combat_def", character.id]) if (!withinAggro(this.target, spawn, definition)) { @@ -69,6 +77,20 @@ class CombatMovement( skip = recalculate() && wasEmpty } super.tick() + if (character is NPC && character["owner_index", -1] != -1) { + // An owned familiar that can't make progress towards a target it isn't yet close + // to (no path exists, e.g. the target fled somewhere unreachable) gives up after a + // grace period and falls back to following its owner (EmptyMode -> Follow in + // NPCTask) rather than freezing. Moving, or already being all but in range (just + // waiting on a free attack tile in a crowd), resets the grace period. + if (character.visuals.moved || arrived(attackRange() + 1)) { + unreachableTicks = 0 + } else if (++unreachableTicks >= UNREACHABLE_LIMIT) { + unreachableTicks = 0 + character.mode = EmptyMode + return + } + } if (skip || attack()) { return } @@ -89,6 +111,7 @@ class CombatMovement( val attackRange = attackRange() val melee = attackRange == 1 && character["weapon", Item.EMPTY].def["weapon_type", ""] != "salamander" if (arrived(if (melee) -1 else attackRange)) { + unreachableTicks = 0 combatReached?.invoke(character, target) return true } @@ -121,6 +144,12 @@ class CombatMovement( } companion object : AutoCloseable { + /** + * Ticks an owned familiar may make no progress towards its target before giving up and + * returning to follow its owner. + */ + private const val UNREACHABLE_LIMIT = 5 + /** * Emitted when within attack range of combat target. */ @@ -130,6 +159,19 @@ class CombatMovement( combatReached = null } + /** + * The tile a combat leash (aggro/retreat range) is measured from. Owned followers + * (familiars, identified by the [owner_index] attribute) anchor to their owner's current + * tile so they don't de-aggro when the owner moves; all other NPCs anchor to [spawn_tile]. + */ + fun NPC.leashAnchor(): Tile? { + val ownerIndex = this["owner_index", -1] + if (ownerIndex != -1) { + Players.indexed(ownerIndex)?.let { return it.tile } + } + return this["spawn_tile"] + } + fun withinAggro(target: Character, spawn: Tile, definition: CombatDefinition): Boolean { val aggroRange = definition.retreatRange + definition.attackRange val absX = abs(target.tile.x - spawn.x) diff --git a/engine/src/main/kotlin/world/gregs/voidps/engine/entity/character/mode/interact/ItemOnNPCInteract.kt b/engine/src/main/kotlin/world/gregs/voidps/engine/entity/character/mode/interact/ItemOnNPCInteract.kt index cab9615331..2bd062eb93 100644 --- a/engine/src/main/kotlin/world/gregs/voidps/engine/entity/character/mode/interact/ItemOnNPCInteract.kt +++ b/engine/src/main/kotlin/world/gregs/voidps/engine/entity/character/mode/interact/ItemOnNPCInteract.kt @@ -15,9 +15,13 @@ data class ItemOnNPCInteract( val id: String, val player: Player, ) : Interact(player, target) { - override fun hasOperate() = Operation.itemOnNpc.containsKey("${item.id}:*") || Operation.itemOnNpc.containsKey("${item.id}:${target.def(player).stringId}") || Operation.itemOnNpc.containsKey("*:${target.def(player).stringId}") + // A transformed npc (e.g. a familiar's wilderness "_combat" form) falls back to its base + // id's handlers when the transform has none of its own for the item. + override fun hasOperate() = Operation.itemOnNpc.containsKey("${item.id}:*") || Operation.itemOnNpc.containsKey("${item.id}:${target.def(player).stringId}") || Operation.itemOnNpc.containsKey("*:${target.def(player).stringId}") || + Operation.itemOnNpc.containsKey("${item.id}:${target.id}") || Operation.itemOnNpc.containsKey("*:${target.id}") - override fun hasApproach() = Approachable.itemOnNpc.containsKey("${item.id}:*") || Approachable.itemOnNpc.containsKey("${item.id}:${target.def(player).stringId}") || Approachable.itemOnNpc.containsKey("*:${target.def(player).stringId}") + override fun hasApproach() = Approachable.itemOnNpc.containsKey("${item.id}:*") || Approachable.itemOnNpc.containsKey("${item.id}:${target.def(player).stringId}") || Approachable.itemOnNpc.containsKey("*:${target.def(player).stringId}") || + Approachable.itemOnNpc.containsKey("${item.id}:${target.id}") || Approachable.itemOnNpc.containsKey("*:${target.id}") override fun operate() { invoke(Operation.itemOnNpc) @@ -29,7 +33,7 @@ data class ItemOnNPCInteract( private fun invoke(map: Map Unit>>) { Script.launch { - for (block in map["${item.id}:${target.def(player).stringId}"] ?: map["*:${target.def(player).stringId}"] ?: map["${item.id}:*"] ?: return@launch) { // Hack for spells + for (block in map["${item.id}:${target.def(player).stringId}"] ?: map["${item.id}:${target.id}"] ?: map["*:${target.def(player).stringId}"] ?: map["*:${target.id}"] ?: map["${item.id}:*"] ?: return@launch) { // Hack for spells block(player, this@ItemOnNPCInteract) } } diff --git a/engine/src/main/kotlin/world/gregs/voidps/engine/entity/character/mode/interact/PlayerOnNPCInteract.kt b/engine/src/main/kotlin/world/gregs/voidps/engine/entity/character/mode/interact/PlayerOnNPCInteract.kt index 0be60c24cc..3686efb8be 100644 --- a/engine/src/main/kotlin/world/gregs/voidps/engine/entity/character/mode/interact/PlayerOnNPCInteract.kt +++ b/engine/src/main/kotlin/world/gregs/voidps/engine/entity/character/mode/interact/PlayerOnNPCInteract.kt @@ -12,9 +12,11 @@ data class PlayerOnNPCInteract( override val option: String, val player: Player, ) : InteractOption(player, target) { - override fun hasOperate() = Operation.playerNpc.containsKey("$option:${target.def(player).stringId}") || Operation.playerNpc.containsKey("$option:*") + // A transformed npc (e.g. a familiar's wilderness "_combat" form) falls back to its base + // id's handlers when the transform has none of its own for the option. + override fun hasOperate() = Operation.playerNpc.containsKey("$option:${target.def(player).stringId}") || Operation.playerNpc.containsKey("$option:${target.id}") || Operation.playerNpc.containsKey("$option:*") - override fun hasApproach() = Approachable.playerNpc.containsKey("$option:${target.def(player).stringId}") || Approachable.playerNpc.containsKey("$option:*") + override fun hasApproach() = Approachable.playerNpc.containsKey("$option:${target.def(player).stringId}") || Approachable.playerNpc.containsKey("$option:${target.id}") || Approachable.playerNpc.containsKey("$option:*") override fun operate() { invoke(Operation.playerNpc) @@ -26,7 +28,7 @@ data class PlayerOnNPCInteract( private fun invoke(map: Map Unit>>) { Script.launch { - for (block in map["$option:${target.def(player).stringId}"] ?: map["$option:*"] ?: return@launch) { + for (block in map["$option:${target.def(player).stringId}"] ?: map["$option:${target.id}"] ?: map["$option:*"] ?: return@launch) { block(player, this@PlayerOnNPCInteract) } } diff --git a/engine/src/main/kotlin/world/gregs/voidps/engine/entity/character/mode/move/Movement.kt b/engine/src/main/kotlin/world/gregs/voidps/engine/entity/character/mode/move/Movement.kt index 87236323e0..e153cec21e 100644 --- a/engine/src/main/kotlin/world/gregs/voidps/engine/entity/character/mode/move/Movement.kt +++ b/engine/src/main/kotlin/world/gregs/voidps/engine/entity/character/mode/move/Movement.kt @@ -45,7 +45,10 @@ open class Movement( return } val tile = strategy.destination(character) - if (character is Player && !tile.noCollision) { + // Players, and owned familiars (which a player directs around the map), use full + // pathfinding so they route around obstacles. Other NPCs use cheap single-step movement. + val pathfinds = character is Player || (character is NPC && character["owner_index", -1] != -1) + if (pathfinds && !tile.noCollision) { val route = pathFinder.findPath(character, strategy, shape) character.steps.queueRoute(route, tile, tile.noCollision, tile.noRun) } else if (tile != Tile.EMPTY) { diff --git a/engine/src/main/kotlin/world/gregs/voidps/engine/entity/character/mode/move/target/NPCCharacterTargetStrategy.kt b/engine/src/main/kotlin/world/gregs/voidps/engine/entity/character/mode/move/target/NPCCharacterTargetStrategy.kt index 375c165376..3037313c2b 100644 --- a/engine/src/main/kotlin/world/gregs/voidps/engine/entity/character/mode/move/target/NPCCharacterTargetStrategy.kt +++ b/engine/src/main/kotlin/world/gregs/voidps/engine/entity/character/mode/move/target/NPCCharacterTargetStrategy.kt @@ -26,7 +26,9 @@ data class NPCCharacterTargetStrategy( get() = character.size override fun destination(source: Character): Tile { - if (source is NPC) { + // Owned familiars have an EMPTY walk mode (they don't wander) but must still chase a moving + // combat target, so they're exempt from the stationary-npc short-circuit below. + if (source is NPC && source["owner_index", -1] == -1) { val def = if (source.contains("transform_id")) { NPCDefinitions.get(source["transform_id", source.id]) } else { diff --git a/engine/src/main/kotlin/world/gregs/voidps/engine/entity/character/npc/NPC.kt b/engine/src/main/kotlin/world/gregs/voidps/engine/entity/character/npc/NPC.kt index ccfbdefcf3..c5a5134d30 100644 --- a/engine/src/main/kotlin/world/gregs/voidps/engine/entity/character/npc/NPC.kt +++ b/engine/src/main/kotlin/world/gregs/voidps/engine/entity/character/npc/NPC.kt @@ -33,7 +33,19 @@ data class NPC( var hide = false override val blockMove: Int - get() = if (transformDef["solid", true]) CollisionFlag.BLOCK_PLAYERS or CollisionFlag.BLOCK_NPCS else 0 + get() { + if (!transformDef["solid", true]) { + return 0 + } + // Owned followers (familiars/pets) phase through players - including their owner - so a + // player standing between them and their target can't block them. They still collide + // with other npcs (BLOCK_NPCS) and route around them. + return if (this["owner_index", -1] != -1) { + CollisionFlag.BLOCK_NPCS + } else { + CollisionFlag.BLOCK_PLAYERS or CollisionFlag.BLOCK_NPCS + } + } override val collisionFlag: Int get() = CollisionFlag.BLOCK_NPCS or if (transformDef["solid", false]) CollisionFlag.FLOOR else 0 diff --git a/game/src/main/kotlin/content/area/asgarnia/falador/TownCrier.kt b/game/src/main/kotlin/content/area/asgarnia/falador/TownCrier.kt index eb8fae9663..3ad6c20454 100644 --- a/game/src/main/kotlin/content/area/asgarnia/falador/TownCrier.kt +++ b/game/src/main/kotlin/content/area/asgarnia/falador/TownCrier.kt @@ -22,7 +22,7 @@ class TownCrier : Script { private val currentOverheads = listOf( "The Grand Exchange is now open! Buy and sell items with other players!", - "New content is being actively developed — stay tuned for updates!", + "New content is being actively developed - stay tuned for updates!", ) private val historicalOverheads = listOf( diff --git a/game/src/main/kotlin/content/area/asgarnia/taverley/Pikkupstix.kt b/game/src/main/kotlin/content/area/asgarnia/taverley/Pikkupstix.kt new file mode 100644 index 0000000000..922b72d3c3 --- /dev/null +++ b/game/src/main/kotlin/content/area/asgarnia/taverley/Pikkupstix.kt @@ -0,0 +1,125 @@ +package content.area.asgarnia.taverley + +import content.entity.npc.shop.openShop +import content.entity.player.dialogue.Disheartened +import content.entity.player.dialogue.Happy +import content.entity.player.dialogue.Idle +import content.entity.player.dialogue.Neutral +import content.entity.player.dialogue.Quiz +import content.entity.player.dialogue.Sad +import content.entity.player.dialogue.type.choice +import content.entity.player.dialogue.type.npc +import content.entity.player.dialogue.type.player +import content.skill.summoning.enchantHeadgear +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.entity.character.player.name +import world.gregs.voidps.engine.entity.character.player.skill.Skill +import world.gregs.voidps.engine.entity.character.player.skill.level.Level +import world.gregs.voidps.engine.entity.character.player.skill.level.Level.hasMax +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.RemoveItem.remove + +class Pikkupstix : Script { + init { + // The post-Wolf-Whistle tutor dialogue. The quest itself isn't + // implemented yet, so the dialogue isn't gated on it - gate here when the quest lands. + npcOperate("Talk-to", "pikkupstix") { + npc("Welcome to my humble abode. How can I help you?") + menu() + } + + // Enchant headgear to hold Summoning scrolls (see EnchantedHeadgear). Right-click Enchant, or + // use the helm on him. + npcOperate("Enchant", "pikkupstix") { + npc("Bring me a piece of headwear and I'll enchant it to hold Summoning scrolls, free of charge. Just use the helm on me.") + } + itemOnNPCOperate("*", "pikkupstix") { (_, item) -> + enchantHeadgear(item) + } + } + + private suspend fun Player.menu() { + choice { + option("So, what's Summoning all about, then?") { + npc("In general? Or did you have a specific topic in mind?") + topics(general = true) + } + option("Can I buy some Summoning supplies, please?") { + npc("If you like! It's good to see you training.") + openShop("summoning_supplies") + } + if (hasMax(Skill.Summoning, 99)) { + option("Can I buy a Summoning skillcape?") { + skillcape() + } + } else { + option("Please tell me about skillcapes.") { + npc("Of course. Skillcapes are a symbol of achievement. Only people who have mastered a skill and reached level 99 can get their hands on them and gain the benefits they carry.") + npc("Is there something else I can help you with, perhaps?") + menu() + } + } + } + } + + private suspend fun Player.topics(general: Boolean) { + choice { + if (general) { + option("In general.") { + npc("Effectively, the skill can be broken into two main parts: summoned familiars, and pets.") + npc("Summoned familiars are spiritual animals that can be called to you from the spirit plane, to serve you for a period of time.") + npc("These animals can also perform a special move, which is specific to the species. For example, a spirit wolf can perform the Howl special move if you are holding the correct Howl scroll.") + npc("The last part of Summoning: the pets. The more you practice the skill, the more you will comprehend the natural world around you.") + npc("This is reflected in your increased ability to raise animals as pets. It takes a skilled summoner to be able to raise some of the world's more exotic animals, such as the lizards of Karamja, or even dragons!") + npc("Now that I've given you this overview, do you want to know about anything specific?") + topics(general = false) + } + } + option("Tell me about summoning familiars.") { + npc("Summoned familiars are at the very core of Summoning. Each familiar is different, and the more powerful the summoner, the more powerful the familiar they can summon.") + } + option("Tell me about special moves.") { + npc("Well, if a Summoning pouch is split apart at an obelisk, then the energy it contained will reconstitute itself - transform - into a scroll. This scroll can then be used to make your familiar perform its special move.") + } + option("Tell me about pets.") { + npc("Well, these are not really an element of the skill, as such, but more like a side-effect of training.") + } + } + } + + private suspend fun Player.skillcape() { + if (inventory.spaces < 2) { + npc("Unfortunately all Skillcapes are only available with a free hood; it's part of a skill promotion deal - buy one get one free, you know. So you'll need to free up some inventory space before I can sell you one.") + return + } + if (!inventory.contains("coins", 99000)) { + npc("Most certainly, but I must ask for a donation of 99,000 coins to cover the expense of the cape.") + return + } + npc("It has been a pleasure to watch you grow as a summoner. I am privileged to have been instrumental in your learning, but I must ask for a donation of 99,000 coins to cover the expense of the cape.") + choice { + option("I'm afraid that's too much money for me.") { + npc("Not at all; there are many other adventurers who would love the opportunity to purchase such a prestigious item. You can find me here if you change your mind.") + } + option("Okay, here's 99,000 coins.") { + inventory.transaction { + val trimmed = Skill.entries.any { it != Skill.Summoning && levels.getMax(it) >= Level.MAX_LEVEL } + add("summoning_cape${if (trimmed) "_t" else ""}") + add("summoning_hood") + remove("coins", 99000) + } + when (inventory.transaction.error) { + TransactionError.None -> npc("Good luck to you, $name.") + is TransactionError.Deficient -> { + player("But, unfortunately, I was mistaken.") + npc("Well, come back and see me when you do.") + } + else -> npc("Unfortunately all Skillcapes are only available with a free hood, it's part of a skill promotion deal; buy one get one free, you know. So you'll need to free up some inventory space before I can sell you one.") + } + } + } + } +} diff --git a/game/src/main/kotlin/content/area/misthalin/wizards_tower/WizardMizgog.kt b/game/src/main/kotlin/content/area/misthalin/wizards_tower/WizardMizgog.kt index 9ee6b1e686..efb351ebdd 100644 --- a/game/src/main/kotlin/content/area/misthalin/wizards_tower/WizardMizgog.kt +++ b/game/src/main/kotlin/content/area/misthalin/wizards_tower/WizardMizgog.kt @@ -64,7 +64,7 @@ class WizardMizgog : Script { player("Well this is a surprising turn of events!") npc("What?") player("Well I just so happen to have all of those beads on me!") - npc("Are you saying that you stole my beads all this time and I’ve been blaming these imps!?") + npc("Are you saying that you stole my beads all this time and I've been blaming these imps!?") player("No, not at all! I just found them throughout my travels and presumed somebody would need them at some point.") npc("Bah! Fine.") cutscene(target) diff --git a/game/src/main/kotlin/content/area/wilderness/abyss/AbyssPrayerDrain.kt b/game/src/main/kotlin/content/area/wilderness/abyss/AbyssPrayerDrain.kt new file mode 100644 index 0000000000..2446ee5291 --- /dev/null +++ b/game/src/main/kotlin/content/area/wilderness/abyss/AbyssPrayerDrain.kt @@ -0,0 +1,46 @@ +package content.area.wilderness.abyss + +import content.skill.summoning.follower +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.entity.character.player.skill.Skill +import world.gregs.voidps.engine.timer.Timer +import world.gregs.voidps.type.random +import kotlin.random.nextInt + +/** + * The enchantments protecting the Abyss drain a player's Prayer points to zero on entry. An + * abyssal parasite familiar absorbs that drain, turning it into a gradual leak of 10 points + * every ~5-10 seconds for as long as the player stays in the Abyss and the parasite survives. + */ +val Player.hasAbyssalPrayerProtection: Boolean + get() = follower?.id == "abyssal_parasite_familiar" + +class AbyssPrayerDrain : Script { + + init { + entered("abyss_multi_area") { + if (hasAbyssalPrayerProtection) { + softTimers.start("abyss_prayer_leak") + } + } + + exited("abyss_multi_area") { + softTimers.stop("abyss_prayer_leak") + } + + timerStart("abyss_prayer_leak") { + random.nextInt(8..16) + } + + timerTick("abyss_prayer_leak") { + if (!hasAbyssalPrayerProtection) { + // The parasite was dismissed or killed; the Abyss reclaims the rest. + levels.drain(Skill.Prayer, levels.get(Skill.Prayer)) + return@timerTick Timer.CANCEL + } + levels.drain(Skill.Prayer, 10) + random.nextInt(8..16) + } + } +} diff --git a/game/src/main/kotlin/content/area/wilderness/abyss/MageOfZamorak.kt b/game/src/main/kotlin/content/area/wilderness/abyss/MageOfZamorak.kt index 360d649410..988b6d19c5 100644 --- a/game/src/main/kotlin/content/area/wilderness/abyss/MageOfZamorak.kt +++ b/game/src/main/kotlin/content/area/wilderness/abyss/MageOfZamorak.kt @@ -284,7 +284,9 @@ class MageOfZamorak : Script { tile = Areas["abyss_multi_area"].random(player) } player.tele(tile!!) - player.levels.drain(Skill.Prayer, player.levels.get(Skill.Prayer)) + if (!player.hasAbyssalPrayerProtection) { + player.levels.drain(Skill.Prayer, player.levels.get(Skill.Prayer)) + } player.clearAnim() } } diff --git a/game/src/main/kotlin/content/entity/combat/Combat.kt b/game/src/main/kotlin/content/entity/combat/Combat.kt index 92b9b25c44..9472f2d411 100644 --- a/game/src/main/kotlin/content/entity/combat/Combat.kt +++ b/game/src/main/kotlin/content/entity/combat/Combat.kt @@ -1,6 +1,7 @@ package content.entity.combat import content.area.wilderness.inSingleCombat +import content.entity.effect.stunned import content.entity.player.combat.special.specialAttack import content.skill.magic.Magic import content.skill.melee.weapon.* @@ -17,6 +18,7 @@ import world.gregs.voidps.engine.entity.character.mode.EmptyMode import world.gregs.voidps.engine.entity.character.mode.PauseMode import world.gregs.voidps.engine.entity.character.mode.Retreat import world.gregs.voidps.engine.entity.character.mode.combat.* +import world.gregs.voidps.engine.entity.character.mode.combat.CombatMovement.Companion.leashAnchor import world.gregs.voidps.engine.entity.character.npc.NPC import world.gregs.voidps.engine.entity.character.player.Player import world.gregs.voidps.engine.entity.character.player.name @@ -128,10 +130,14 @@ class Combat(val combatDefinitions: CombatDefinitions) : if (character is NPC && character.attacking && character.underAttack) { return } - if (character is NPC) { + // A familiar attacking an npc should always draw retaliation - the spawn/aggro leash (which + // keeps an npc from being dragged off its spawn) must not stop it defending itself, or an npc + // hit by a familiar away from its spawn just walks off without ever fighting back. + val sourceIsFamiliar = source is NPC && source["owner_index", -1] != -1 + if (character is NPC && !sourceIsFamiliar) { // Retreat val definition = combatDefinitions.getOrNull(character.transformDef["combat_def", character.id]) ?: return - val spawn: Tile = character["spawn_tile"]!! + val spawn: Tile = character.leashAnchor() ?: return if (!CombatMovement.withinAggro(source, spawn, definition)) { if (character.mode !is Retreat || (character.mode as Retreat).target != source) { character.mode = Retreat(character, source, spawn, definition.retreatRange) @@ -167,6 +173,13 @@ class Combat(val combatDefinitions: CombatDefinitions) : if (character.hasClock("action_delay")) { return } + // A stunned character can't swing. CombatMovement keeps ticking (and re-entering here) + // while stunned, so without this a stunned npc/player would still land its attacks - + // only its movement is otherwise gated by the stun's "delay". Skipping leaves the stun + // to expire and the fight resumes on the next tick. + if (character.stunned) { + return + } (character.mode as? CombatMovement)?.started = true val prepared = when (character) { is Player -> CombatApi.prepare(character, target, character.fightStyle) diff --git a/game/src/main/kotlin/content/entity/combat/Target.kt b/game/src/main/kotlin/content/entity/combat/Target.kt index 8f88fab7c5..3ef8092fae 100644 --- a/game/src/main/kotlin/content/entity/combat/Target.kt +++ b/game/src/main/kotlin/content/entity/combat/Target.kt @@ -40,6 +40,10 @@ object Target { if (source is Player && !CombatApi.canAttack(source, target)) { return false } + if (source is Player && target["owner_index", -1] == source.index) { + if (message) source.message("You can't attack your own familiar.") + return false + } if (source is Player && target.contains("owner")) { val owner = target.get("owner") if (source.accountName != owner) { @@ -50,12 +54,18 @@ object Target { if ((source.spell == "bind" || source.spell == "snare" || source.spell == "entangle") && target.id.endsWith("_impling")) { return true } - if (target.transform != "") { - if (!NPCDefinitions.get(target.transform).options.contains("Attack")) { + // The "Attack" option is a player-facing menu concern. A familiar's base (non-wilderness) + // form deliberately has no "Attack" option so its owner can't click it, but an NPC must + // still be able to retaliate against a familiar that attacks it, so skip the check there. + val retaliatingAgainstFamiliar = source is NPC && target["owner_index", -1] != -1 + if (!retaliatingAgainstFamiliar) { + if (target.transform != "") { + if (!NPCDefinitions.get(target.transform).options.contains("Attack")) { + return false + } + } else if (target.def.options[1] != "Attack") { return false } - } else if (target.def.options[1] != "Attack") { - return false } if (target.mode == PauseMode) { return false diff --git a/game/src/main/kotlin/content/entity/combat/hit/CombatHitsplats.kt b/game/src/main/kotlin/content/entity/combat/hit/CombatHitsplats.kt index d5bdd2f8e9..7a82b742c5 100644 --- a/game/src/main/kotlin/content/entity/combat/hit/CombatHitsplats.kt +++ b/game/src/main/kotlin/content/entity/combat/hit/CombatHitsplats.kt @@ -68,7 +68,12 @@ class CombatHitsplats : Script { fun Character.hit(source: Character, amount: Int, mark: HitSplat.Mark, delay: Int = 0, critical: Boolean = false, soak: Int = -1) { val after = (levels.get(Skill.Constitution) - amount).coerceAtLeast(0) val percentage = levels.getPercent(Skill.Constitution, after, 255.0).toInt() - visuals.hits.add(HitSplat(amount, mark, percentage, delay, critical, if (source is NPC) -source.index else source.index, soak)) + val sourceIndex = when { + this is NPC && this["owner_index", -1] != -1 -> this["owner_index", -1] + source is NPC -> source["owner_index", -1].takeIf { it != -1 } ?: -source.index + else -> source.index + } + visuals.hits.add(HitSplat(amount, mark, percentage, delay, critical, sourceIndex, soak)) flagHits() } } diff --git a/game/src/main/kotlin/content/entity/combat/hit/Hit.kt b/game/src/main/kotlin/content/entity/combat/hit/Hit.kt index 91dcb8cd90..b58b23c1bc 100644 --- a/game/src/main/kotlin/content/entity/combat/hit/Hit.kt +++ b/game/src/main/kotlin/content/entity/combat/hit/Hit.kt @@ -9,6 +9,7 @@ import content.skill.magic.spell.spell import content.skill.melee.weapon.Weapon import content.skill.melee.weapon.weapon import content.skill.prayer.Prayer +import content.skill.summoning.familiarDefenceMultiplier import world.gregs.voidps.engine.client.message import world.gregs.voidps.engine.entity.character.Character import world.gregs.voidps.engine.entity.character.mode.combat.CombatApi @@ -99,6 +100,9 @@ object Hit { if (offense) { rating = Bonus.slayerModifier(source, target, type, rating, damage = false) rating = Weapon.specialRatingModifiers(source, type, weapon, special, rating) + } else if (target is Player) { + // Iron/steel titan and wolpertinger passives make their owner harder to hit. + rating = (rating * target.familiarDefenceMultiplier(type, meleeType(type))).toInt() } if (source["debug", false]) { val message = "${if (offense) "Offensive" else "Defensive"} rating: $rating ($type)" diff --git a/game/src/main/kotlin/content/entity/death/NPCDeath.kt b/game/src/main/kotlin/content/entity/death/NPCDeath.kt index 0ce08f63c6..bd8b0856be 100644 --- a/game/src/main/kotlin/content/entity/death/NPCDeath.kt +++ b/game/src/main/kotlin/content/entity/death/NPCDeath.kt @@ -19,6 +19,7 @@ import world.gregs.voidps.engine.entity.character.Death import world.gregs.voidps.engine.entity.character.mode.PauseMode import world.gregs.voidps.engine.entity.character.npc.NPC import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.entity.character.player.Players import world.gregs.voidps.engine.entity.character.player.chat.ChatType import world.gregs.voidps.engine.entity.character.player.combatLevel import world.gregs.voidps.engine.entity.character.player.name @@ -56,7 +57,10 @@ class NPCDeath( val npc = this val onDeath = Death.killed(npc) queue(name = "death", 1) { - val killer = killer + // Credit a familiar's kill to its owner so loot, slayer and the kill log go to the + // player rather than the familiar npc. + val killer = (killer as? NPC)?.get("owner_index", -1)?.takeIf { it != -1 } + ?.let { Players.indexed(it) } ?: killer val tile = if (transformId == "wall_beast") tile.addY(-1) else tile npc["death_tile"] = tile val combat = combatDefinitions.get(transformDef["combat_def", transformId]) diff --git a/game/src/main/kotlin/content/entity/npc/combat/Attack.kt b/game/src/main/kotlin/content/entity/npc/combat/Attack.kt index 68c893de44..a8d0f76884 100644 --- a/game/src/main/kotlin/content/entity/npc/combat/Attack.kt +++ b/game/src/main/kotlin/content/entity/npc/combat/Attack.kt @@ -47,7 +47,7 @@ class Attack( val def = def(primaryTarget) def["combat_def", get("transform_id", def.stringId)] } else { - get("transform_id", id) + def["combat_def", get("transform_id", id)] } val definition = definitions.getOrNull(defId) ?: return@npcCombatSwing if (definition.attacks.isEmpty()) { diff --git a/game/src/main/kotlin/content/entity/player/combat/CombatExperience.kt b/game/src/main/kotlin/content/entity/player/combat/CombatExperience.kt index 3a70aa2754..0c0eb0bbdb 100644 --- a/game/src/main/kotlin/content/entity/player/combat/CombatExperience.kt +++ b/game/src/main/kotlin/content/entity/player/combat/CombatExperience.kt @@ -8,6 +8,7 @@ import world.gregs.voidps.engine.data.definition.Tables import world.gregs.voidps.engine.entity.character.Character import world.gregs.voidps.engine.entity.character.npc.NPC import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.entity.character.player.Players import world.gregs.voidps.engine.entity.character.player.combatLevel import world.gregs.voidps.engine.entity.character.player.skill.Skill import world.gregs.voidps.engine.entity.character.player.skill.exp.exp @@ -49,6 +50,30 @@ class CombatExperience : Script { } grant(this, target, Skill.Constitution, damage / 7.5) } + + // Combat familiars grant their owner combat xp for the damage they deal: the skill for + // the owner's chosen melee style (or the familiar's range/magic type), plus Constitution. + npcCombatAttack("*_familiar*") { (target, damage, type) -> + if (damage <= 0) { + return@npcCombatAttack + } + val owner = Players.indexed(this["owner_index", -1]) ?: return@npcCombatAttack + when { + type == "magic" || type == "blaze" -> grant(owner, target, Skill.Magic, damage / 5.0) + type == "range" -> grant(owner, target, Skill.Ranged, damage / 2.5) + Hit.meleeType(type) || type == "scorch" -> when (owner.attackStyle) { + "aggressive" -> grant(owner, target, Skill.Strength, damage / 2.5) + "controlled" -> { + grant(owner, target, Skill.Attack, damage / 7.5) + grant(owner, target, Skill.Strength, damage / 7.5) + grant(owner, target, Skill.Defence, damage / 7.5) + } + "defensive" -> grant(owner, target, Skill.Defence, damage / 2.5) + else -> grant(owner, target, Skill.Attack, damage / 2.5) + } + } + grant(owner, target, Skill.Constitution, damage / 7.5) + } } fun grant(player: Player, target: Character, skill: Skill, experience: Double) { diff --git a/game/src/main/kotlin/content/entity/player/dialogue/DialogueCommon.kt b/game/src/main/kotlin/content/entity/player/dialogue/DialogueCommon.kt index 52f0880dfa..b914e74a91 100644 --- a/game/src/main/kotlin/content/entity/player/dialogue/DialogueCommon.kt +++ b/game/src/main/kotlin/content/entity/player/dialogue/DialogueCommon.kt @@ -1,7 +1,10 @@ package content.entity.player.dialogue import world.gregs.voidps.engine.client.ui.Interfaces +import world.gregs.voidps.engine.client.variable.MapValues import world.gregs.voidps.engine.data.definition.AnimationDefinitions +import world.gregs.voidps.engine.data.definition.EnumDefinitions +import world.gregs.voidps.engine.data.definition.VariableDefinitions import world.gregs.voidps.engine.entity.character.player.Player import world.gregs.voidps.engine.suspend.Suspension @@ -19,11 +22,43 @@ fun Interfaces.sendChat( lines: List, ) { val definition = AnimationDefinitions.getOrNull("expression_$expression${lines.size}") ?: AnimationDefinitions.get("expression_$expression") - sendAnimation(id, component, definition.id) + sendChat(id, component, definition.id, title, lines) +} + +fun Interfaces.sendChat( + id: String, + component: String, + animation: Int, + title: String, + lines: List, +) { + sendAnimation(id, component, animation) sendText(id, "title", title) sendLines(id, lines) } +/** + * The chathead animation the familiar details interface (662) plays for [npcId], resolved the + * same way its client script (cs2 751) does: the follower_details_chathead_animation varbit map + * gives the familiar or pet's value, which keys enum 1276, or enum 1275 with 50 subtracted when + * over 50. Values without an enum entry (e.g. the map's zeroes) take the enum's default, a + * generic head-bob, exactly as the client's datamap lookup does. Null for npcs outside the map, + * which use the standard [Expression] animations. Gated to familiars and pets so a wild npc + * sharing a familiar's base name can't pick up its animation. + */ +fun familiarChatheadAnimation(npcId: String): Int? { + if (!npcId.endsWith("_familiar") && !npcId.startsWith("pet_")) { + return null + } + val values = (VariableDefinitions.get("follower_details_chathead_animation")?.values as? MapValues)?.values ?: return null + val value = values[npcId.removeSuffix("_familiar")] ?: return null + return if (value > 50) { + EnumDefinitions.int("pet_details_chathead_animations_sad", value - 50) + } else { + EnumDefinitions.int("pet_details_chathead_animations_normal", value) + } +} + fun Player.continueDialogue() { (suspension as? Suspension.Continue)?.resume() } diff --git a/game/src/main/kotlin/content/entity/player/dialogue/type/NPCDialogue.kt b/game/src/main/kotlin/content/entity/player/dialogue/type/NPCDialogue.kt index d600d280c4..0811b071f7 100644 --- a/game/src/main/kotlin/content/entity/player/dialogue/type/NPCDialogue.kt +++ b/game/src/main/kotlin/content/entity/player/dialogue/type/NPCDialogue.kt @@ -1,6 +1,7 @@ package content.entity.player.dialogue.type import content.entity.player.dialogue.Expression +import content.entity.player.dialogue.familiarChatheadAnimation import content.entity.player.dialogue.sendChat import net.pearx.kasechange.toSnakeCase import world.gregs.voidps.engine.client.ui.close @@ -58,7 +59,14 @@ private suspend fun Player.npc(lines: List, clickToContinue: Boolean, np val npcDef = NPCDefinitions.get(npcId) val head = getChatHeadComponentName(largeHead ?: npcDef["large_head", false]) sendNPCHead(this, id, head, npcDef.id) - interfaces.sendChat(id, head, if (npcDef.contains("dialogue")) "${npcDef["dialogue", ""]}_$expression" else expression, title ?: npcDef.name, lines) + val chathead = familiarChatheadAnimation(npcId) + when { + // Familiar chatheads play the same cache animation as the familiar details interface, + // regardless of expression - dialogue expressions only exist for humanoid heads. + chathead != null -> interfaces.sendChat(id, head, chathead, title ?: npcDef.name, lines) + npcDef.contains("dialogue") -> interfaces.sendChat(id, head, "${npcDef["dialogue", ""]}_$expression", title ?: npcDef.name, lines) + else -> interfaces.sendChat(id, head, expression, title ?: npcDef.name, lines) + } if (clickToContinue) { pauseButton() close(id) diff --git a/game/src/main/kotlin/content/entity/proj/ShootProjectile.kt b/game/src/main/kotlin/content/entity/proj/ShootProjectile.kt index 4270507b97..4ea2db6762 100644 --- a/game/src/main/kotlin/content/entity/proj/ShootProjectile.kt +++ b/game/src/main/kotlin/content/entity/proj/ShootProjectile.kt @@ -12,6 +12,7 @@ import world.gregs.voidps.engine.entity.character.player.Player import world.gregs.voidps.engine.get import world.gregs.voidps.network.login.protocol.encode.zone.ProjectileAddition import world.gregs.voidps.type.Delta +import world.gregs.voidps.type.Distance import world.gregs.voidps.type.Tile object ShootProjectile { @@ -130,21 +131,24 @@ fun Character.shoot( width: Int = size, tileOffsetX: Int = 0, tileOffsetY: Int = 0, -) = projectile( - id = id, - target = target, - flightTime = flightTime, - delay = delay, - startHeight = height, - endHeight = endHeight, - curve = curve, - offset = offset, - width = width, - sourceHeight = this.height, - targetHeight = target.height, - targetTile = target.tile, - sourceTile = tile.add(tileOffsetX, tileOffsetY), -) +): Int { + val explicitOrigin = tileOffsetX != 0 || tileOffsetY != 0 + return projectile( + id = id, + target = target, + flightTime = flightTime, + delay = delay, + startHeight = height, + endHeight = endHeight, + curve = curve, + offset = offset, + width = if (explicitOrigin) width else 1, + sourceHeight = this.height, + targetHeight = target.height, + targetTile = target.tile, + sourceTile = if (explicitOrigin) tile.add(tileOffsetX, tileOffsetY) else Distance.nearest(tile, size, size, target.tile), + ) +} /** * Tile dragon breath originates from. diff --git a/game/src/main/kotlin/content/skill/firemaking/Firemaking.kt b/game/src/main/kotlin/content/skill/firemaking/Firemaking.kt index fe9c0729ff..741234db1c 100644 --- a/game/src/main/kotlin/content/skill/firemaking/Firemaking.kt +++ b/game/src/main/kotlin/content/skill/firemaking/Firemaking.kt @@ -1,5 +1,6 @@ package content.skill.firemaking +import content.skill.summoning.familiarBoost import world.gregs.voidps.engine.Script import world.gregs.voidps.engine.client.instruction.handle.interactFloorItem import world.gregs.voidps.engine.client.message @@ -82,7 +83,7 @@ class Firemaking : Script { player.pause(remaining) } val chance = row.intRange("chance") - if (Level.success(player.levels.get(Skill.Firemaking), chance) && FloorItems.remove(floorItem)) { + if (Level.success(player.levels.get(Skill.Firemaking) + player.familiarBoost(Skill.Firemaking), chance) && FloorItems.remove(floorItem)) { player.message("The fire catches and the logs begin to burn.", ChatType.Filter) player.exp(Skill.Firemaking, row.int("xp") / 10.0) spawnFire(player, floorItem.tile, row) diff --git a/game/src/main/kotlin/content/skill/fishing/Fishing.kt b/game/src/main/kotlin/content/skill/fishing/Fishing.kt index a6b486e84b..4f173e9825 100644 --- a/game/src/main/kotlin/content/skill/fishing/Fishing.kt +++ b/game/src/main/kotlin/content/skill/fishing/Fishing.kt @@ -1,6 +1,8 @@ package content.skill.fishing import com.github.michaelbull.logging.InlineLogger +import content.skill.summoning.familiarActsAsHarpoon +import content.skill.summoning.familiarBoost import net.pearx.kasechange.toLowerSpaceCase import net.pearx.kasechange.toTitleCase import world.gregs.voidps.engine.Script @@ -76,7 +78,8 @@ class Fishing : Script { } val tackles = spot.itemList("${option.lowercase()}_tackle") - val tackle = tackles.firstOrNull { tackle -> player.carriesItem(tackle) } + // The ibis and granite lobster spear fish themselves, standing in for a harpoon. + val tackle = tackles.firstOrNull { tackle -> player.carriesItem(tackle) || (tackle == "harpoon" && player.familiarActsAsHarpoon()) } if (tackle == null) { player.message("You need a ${tackles.first().toTitleCase()} to catch these fish.") break@fishing @@ -122,7 +125,7 @@ class Fishing : Script { val chance = row.intRange("chance") val experience = row.int("xp") val level = player.levels.get(Skill.Fishing) - if (level >= requiredLevel && success(level, chance)) { + if (level >= requiredLevel && success(level + player.familiarBoost(Skill.Fishing), chance)) { if (bait != "empty_box_fish" && !player.inventory.remove(bait)) { break@fishing } diff --git a/game/src/main/kotlin/content/skill/fletching/FletchUnfinished.kt b/game/src/main/kotlin/content/skill/fletching/FletchUnfinished.kt index a59edb5efa..eef5a694fd 100644 --- a/game/src/main/kotlin/content/skill/fletching/FletchUnfinished.kt +++ b/game/src/main/kotlin/content/skill/fletching/FletchUnfinished.kt @@ -19,59 +19,83 @@ import world.gregs.voidps.engine.queue.weakQueue class FletchUnfinished : Script { init { - @Suppress("UNCHECKED_CAST") itemOnItem("knife", "*logs*") { _, toItem -> - val displayItems = Tables.itemListOrNull("fletchables.${toItem.id}.products") ?: return@itemOnItem - weakQueue("fletching_make_dialog") { - val (selected, amount) = makeAmount( - displayItems, - type = "Make", - maximum = 27, - text = "What would you like to fletch?", - ) - val unf = Rows.getOrNull("fletching_unf.$selected") ?: return@weakQueue - if (!has(Skill.Fletching, unf.int("level"), true)) { - return@weakQueue - } - fletch(selected, unf, toItem.id, amount) - } + fletchLogDialog(toItem.id) } } +} - fun Player.fletch(addItem: String, unf: RowDefinition, removeItem: String, amount: Int) { - if (amount <= 0) { - return +/** + * Opens the "What would you like to fletch?" make-menu for [logId] and produces the chosen unfinished + * item. [hasTool] is re-checked before every cut - a knife by default; the beaver additionally counts + * as a knife while it's actively cutting. [onMissingTool] fires (and fletching stops) when a cut can't + * proceed. [animate] plays the per-cut animation (the player's knife-cut by default). + */ +fun Player.fletchLogDialog( + logId: String, + animate: Player.() -> Unit = { anim("fletching_log") }, + hasTool: Player.() -> Boolean = { inventory.contains("knife") }, + onMissingTool: Player.() -> Unit = { message("You need a knife to do that.") }, +) { + val displayItems = Tables.itemListOrNull("fletchables.$logId.products") ?: return + weakQueue("fletching_make_dialog") { + val (selected, amount) = makeAmount( + displayItems, + type = "Make", + maximum = 27, + text = "What would you like to fletch?", + ) + val unf = Rows.getOrNull("fletching_unf.$selected") ?: return@weakQueue + if (!has(Skill.Fletching, unf.int("level"), true)) { + return@weakQueue } + fletchLog(selected, unf, logId, amount, animate, hasTool, onMissingTool) + } +} - if (!inventory.contains("knife") || !inventory.contains(removeItem)) { - return - } +fun Player.fletchLog( + addItem: String, + unf: RowDefinition, + removeItem: String, + amount: Int, + animate: Player.() -> Unit = { anim("fletching_log") }, + hasTool: Player.() -> Boolean = { inventory.contains("knife") }, + onMissingTool: Player.() -> Unit = { message("You need a knife to do that.") }, +) { + if (amount <= 0 || !inventory.contains(removeItem)) { + return + } - weakQueue("fletching", unf.int("ticks")) { - val makeAmount = unf.int("amount") - val success = inventory.transaction { - remove(removeItem) - add(addItem, makeAmount) - } + // Re-checked each cut: if the tool is gone (e.g. the beaver finished cutting mid-batch), stop. + if (!hasTool()) { + onMissingTool() + return + } - if (!success) { - return@weakQueue - } + weakQueue("fletching", unf.int("ticks")) { + val makeAmount = unf.int("amount") + val success = inventory.transaction { + remove(removeItem) + add(addItem, makeAmount) + } - val itemCreated = getFletched(addItem) - message("You carefully cut the wood into $itemCreated.", ChatType.Game) - val xp = unf.int("xp") / 10.0 - exp(Skill.Fletching, xp) - anim("fletching_log") - fletch(addItem, unf, removeItem, amount - 1) + if (!success) { + return@weakQueue } - } - fun getFletched(itemName: String): String = when { - itemName.contains("shortbow", ignoreCase = true) -> "a Shortbow" - itemName.contains("longbow", ignoreCase = true) -> "a Longbow" - itemName.contains("stock", ignoreCase = true) -> "a Stock" - itemName.contains("shaft", ignoreCase = true) -> "Shafts" - else -> "null" + val itemCreated = getFletched(addItem) + message("You carefully cut the wood into $itemCreated.", ChatType.Game) + val xp = unf.int("xp") / 10.0 + exp(Skill.Fletching, xp) + animate() + fletchLog(addItem, unf, removeItem, amount - 1, animate, hasTool, onMissingTool) } } + +fun getFletched(itemName: String): String = when { + itemName.contains("shortbow", ignoreCase = true) -> "a Shortbow" + itemName.contains("longbow", ignoreCase = true) -> "a Longbow" + itemName.contains("stock", ignoreCase = true) -> "a Stock" + itemName.contains("shaft", ignoreCase = true) -> "Shafts" + else -> "null" +} diff --git a/game/src/main/kotlin/content/skill/mining/Mining.kt b/game/src/main/kotlin/content/skill/mining/Mining.kt index cb307de785..b62ffe7505 100644 --- a/game/src/main/kotlin/content/skill/mining/Mining.kt +++ b/game/src/main/kotlin/content/skill/mining/Mining.kt @@ -4,6 +4,7 @@ import content.activity.shooting_star.ShootingStarHandler import content.entity.player.bank.bank import content.entity.player.bank.ownsItem import content.quest.questCompleted +import content.skill.summoning.familiarBoost import net.pearx.kasechange.toLowerSpaceCase import world.gregs.voidps.engine.Script import world.gregs.voidps.engine.client.message @@ -98,7 +99,7 @@ class Mining : Script { } if (ore.bool("gems")) { val glory = equipped(EquipSlot.Amulet).id.startsWith("amulet_of_glory_") - if (success(levels.get(Skill.Mining), if (glory) 3..3 else 1..1)) { + if (success(levels.get(Skill.Mining) + familiarBoost(Skill.Mining), if (glory) 3..3 else 1..1)) { addOre(this, gems.random(), target) continue } @@ -112,7 +113,7 @@ class Mining : Script { continue } val chance = ore.intRange("chance") - if (success(levels.get(Skill.Mining), chance)) { + if (success(levels.get(Skill.Mining) + familiarBoost(Skill.Mining), chance)) { val xp = ore.int("xp") / 10.0 ShootingStarHandler.extraOreHandler(this, item, xp) val added = addOre(this, item, target) diff --git a/game/src/main/kotlin/content/skill/summoning/BeastOfBurden.kt b/game/src/main/kotlin/content/skill/summoning/BeastOfBurden.kt index b2b2a8edbe..7653463b54 100644 --- a/game/src/main/kotlin/content/skill/summoning/BeastOfBurden.kt +++ b/game/src/main/kotlin/content/skill/summoning/BeastOfBurden.kt @@ -13,6 +13,8 @@ import world.gregs.voidps.engine.client.ui.open import world.gregs.voidps.engine.data.Settings import world.gregs.voidps.engine.entity.character.player.Player import world.gregs.voidps.engine.entity.character.player.chat.inventoryFull +import world.gregs.voidps.engine.entity.item.drop.DropTable +import world.gregs.voidps.engine.entity.item.drop.DropTables import world.gregs.voidps.engine.entity.item.floor.FloorItems import world.gregs.voidps.engine.inv.beastOfBurden import world.gregs.voidps.engine.inv.clear @@ -27,6 +29,15 @@ import java.util.concurrent.TimeUnit /** The only items the abyssal essence familiars carry; every other familiar refuses them. */ private val BEAST_OF_BURDEN_ESSENCE = setOf("rune_essence", "pure_essence") +/** + * Attribute set on a familiar while it's off performing a special (the beaver's Multichop), plus the + * message shown when the player tries to interact with it then. Only the beaver sets this, so the + * beaver-specific wording is safe. The log-fletch trick is deliberately *not* gated by it - the + * beaver's cutting is exactly the window in which it works as a knife. + */ +const val FAMILIAR_CHOPPING = "chopping_logs" +const val FAMILIAR_BUSY_MESSAGE = "The beaver is busy cutting logs, you cannot interact with it now." + val Player.beastOfBurdenCapacity: Int get() = follower?.def?.get("summoning_beast_of_burden_capacity", 0) ?: 0 @@ -36,6 +47,16 @@ val Player.beastOfBurdenEssenceOnly: Boolean fun Player.hasBeastOfBurden(): Boolean = follower?.def?.get("summoning_beast_of_burden", 0) == 1 +/** + * A forager familiar's loot table is named `forage_` (e.g. `forage_magpie`); returns + * null if the current follower isn't a forager. Detected by table existence so no npc-def param + * is needed. + */ +fun Player.forageTable(dropTables: DropTables): DropTable? { + val id = follower?.id ?: return null + return dropTables.get("forage_${id.removeSuffix("_familiar")}") +} + fun Player.ensureBeastOfBurdenInventory() { val capacity = beastOfBurdenCapacity if (capacity > 0 && beastOfBurden.size < capacity && beastOfBurden.isEmpty()) { @@ -124,7 +145,7 @@ fun Player.dropBeastOfBurdenItems() { message("Your familiar has dropped all the items it was holding.") } -class BeastOfBurden : Script { +class BeastOfBurden(private val dropTables: DropTables) : Script { init { npcOperate("Store", "*_familiar") { (target) -> @@ -132,6 +153,10 @@ class BeastOfBurden : Script { message("That's not your familiar.") return@npcOperate } + if (target[FAMILIAR_CHOPPING, false]) { + message(FAMILIAR_BUSY_MESSAGE) + return@npcOperate + } openBeastOfBurden() } @@ -140,6 +165,10 @@ class BeastOfBurden : Script { message("That's not your familiar.") return@itemOnNPCOperate } + if (target[FAMILIAR_CHOPPING, false]) { + message(FAMILIAR_BUSY_MESSAGE) + return@itemOnNPCOperate + } if (underAttack) { message("You can't do that in combat.") return@itemOnNPCOperate @@ -151,6 +180,10 @@ class BeastOfBurden : Script { if (target != follower) { return@npcOperate } + // Silent: the beaver's own Interact handler shows the busy message during Multichop. + if (target[FAMILIAR_CHOPPING, false]) { + return@npcOperate + } updateFamiliarInterface() } @@ -245,6 +278,10 @@ class BeastOfBurden : Script { player.message("Your follower can't carry any items.") return } + if (player.forageTable(dropTables) != null) { + player.message("Your familiar forages for its own items and won't carry yours.") + return + } val capacity = player.beastOfBurdenCapacity if (capacity <= 0) { player.message("Your follower can't carry any items.") diff --git a/game/src/main/kotlin/content/skill/summoning/EnchantedHeadgear.kt b/game/src/main/kotlin/content/skill/summoning/EnchantedHeadgear.kt new file mode 100644 index 0000000000..279cf9f0b7 --- /dev/null +++ b/game/src/main/kotlin/content/skill/summoning/EnchantedHeadgear.kt @@ -0,0 +1,221 @@ +package content.skill.summoning + +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.client.message +import world.gregs.voidps.engine.data.definition.EnumDefinitions +import world.gregs.voidps.engine.data.definition.ItemDefinitions +import world.gregs.voidps.engine.data.definition.NPCDefinitions +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.entity.character.player.chat.ChatType +import world.gregs.voidps.engine.entity.character.player.equip.equipped +import world.gregs.voidps.engine.entity.character.player.skill.Skill +import world.gregs.voidps.engine.entity.character.player.skill.level.Level.hasMax +import world.gregs.voidps.engine.entity.item.Item +import world.gregs.voidps.engine.inv.add +import world.gregs.voidps.engine.inv.equipment +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.engine.inv.remove +import world.gregs.voidps.engine.inv.replace +import world.gregs.voidps.engine.inv.transact.operation.SetCharge.setCharge +import world.gregs.voidps.network.login.protocol.visual.update.player.EquipSlot + +/** + * A helm that can be enchanted to hold combat Summoning scrolls: its plain, tradeable form ([base]), + * the empty [enchanted] form Pikkupstix converts it into, the [charged] form it takes while holding + * scrolls, the Summoning [level] to enchant it and the scroll [capacity]. For the hunter helms + * (antlers/lizard skull/feather headdress) the plain form is already the enchanted one - so + * [base] == [enchanted] and there's no separate Pikkupstix step, matching darkan. + */ +private data class Headgear(val base: String, val enchanted: String, val charged: String, val level: Int, val capacity: Int) + +private val HEADGEAR = listOf( + // Hunter helms - plain form is already scroll-capable. + Headgear("antlers", "antlers", "antlers_charged", 10, 40), + Headgear("lizard_skull", "lizard_skull", "lizard_skull_charged", 30, 65), + Headgear("feather_headdress_blue", "feather_headdress_blue", "feather_headdress_charged_blue", 50, 150), + Headgear("feather_headdress_yellow", "feather_headdress_yellow", "feather_headdress_charged_yellow", 50, 150), + Headgear("feather_headdress_red", "feather_headdress_red", "feather_headdress_charged_red", 50, 150), + Headgear("feather_headdress_stripy", "feather_headdress_stripy", "feather_headdress_charged_stripy", 50, 150), + Headgear("feather_headdress_orange", "feather_headdress_orange", "feather_headdress_charged_orange", 50, 150), + // Metal/god helms - Pikkupstix enchants the plain helm first (base -> enchanted). + Headgear("snakeskin_bandana", "snakeskin_bandana_enchanted", "snakeskin_bandana_charged", 20, 50), + Headgear("archer_helm", "archer_helm_enchanted", "archer_helm_charged", 30, 70), + Headgear("berserker_helm", "berserker_helm_enchanted", "berserker_helm_charged", 30, 70), + Headgear("warrior_helm", "warrior_helm_enchanted", "warrior_helm_charged", 30, 70), + Headgear("farseer_helm", "farseer_helm_enchanted", "farseer_helm_charged", 30, 70), + Headgear("rune_full_helm", "rune_full_helm_enchanted", "rune_full_helm_charged", 30, 60), + Headgear("splitbark_helm", "splitbark_helm_enchanted", "splitbark_helm_charged", 30, 50), + Headgear("helm_of_neitiznot", "helm_of_neitiznot_enchanted", "helm_of_neitiznot_charged", 45, 90), + Headgear("dragon_med_helm", "dragon_helm_enchanted", "dragon_helm_charged", 50, 110), + Headgear("lunar_helm", "lunar_helm_enchanted", "lunar_helm_charged", 55, 110), + Headgear("armadyl_helmet", "armadyl_helmet_enchanted", "armadyl_helmet_charged", 60, 120), +) + +private val BY_ID = HEADGEAR.flatMap { listOf(it.base to it, it.enchanted to it, it.charged to it) }.toMap() + +/** The scroll a charged enchanted helm currently holds (item id), and how many. Only one helm at a time. */ +private const val SCROLL_ID = "enchanted_headgear_scroll" +private const val SCROLL_COUNT = "enchanted_headgear_count" + +/** True if [scroll] is a combat special-move scroll (only these can be stored, per the live game). */ +private fun isCombatScroll(scroll: Item): Boolean { + if (scroll.def["special_points", 0] == 0) { + return false + } + val pouch = EnumDefinitions.get("summoning_scroll_ids_2").getKey(scroll.def.id) + val familiarNpc = EnumDefinitions.get("summoning_familiar_ids").intOrNull(pouch) ?: return false + val familiar = NPCDefinitions.getOrNull(familiarNpc)?.stringId ?: return false + return FamiliarSpecialMoves.npcTarget.containsKey(familiar) || FamiliarSpecialMoves.playerTarget.containsKey(familiar) +} + +/** + * Pikkupstix enchants a plain helm into its empty scroll-holding form, or disenchants it back to a + * tradeable helm. The hunter helms are already scroll-capable (base == enchanted), so he just says + * so; a charged helm must be emptied first. Returns true if [item] was a recognised piece of headgear. + */ +fun Player.enchantHeadgear(item: Item): Boolean { + val headgear = BY_ID[item.id] ?: return false + when (item.id) { + headgear.charged -> message("You need to remove the scrolls before I can work on that helmet.") + headgear.enchanted -> if (headgear.enchanted == headgear.base) { + message("That helmet is already able to hold Summoning scrolls - just use your combat scrolls on it.") + } else { + inventory.replace(headgear.enchanted, headgear.base) + message("Pikkupstix removes the enchantment from your headwear.") + } + else -> if (!hasMax(Skill.Summoning, headgear.level, message = true)) { + message("You need a Summoning level of ${headgear.level} to enchant that helmet.") + } else { + inventory.replace(headgear.base, headgear.enchanted) + message("Pikkupstix magically enchants your headwear.") + } + } + return true +} + +/** + * The scroll a worn enchanted helm can supply for the current familiar's special, or null. Called by + * the cast gate so a special can fire from the helm's store when the inventory has no scroll left. + */ +fun Player.enchantedHeadgearScroll(): String? { + val helm = equipped(EquipSlot.Hat).id + if (BY_ID[helm]?.charged != helm || get(SCROLL_COUNT, 0) <= 0) { + return null + } + return get(SCROLL_ID, "").takeIf { it.isNotEmpty() } +} + +/** Spends one scroll from the worn enchanted helm, emptying it back to its enchanted form when the last goes. */ +fun Player.spendEnchantedHeadgearScroll() { + val count = get(SCROLL_COUNT, 0) - 1 + set(SCROLL_COUNT, count) + val headgear = BY_ID[equipped(EquipSlot.Hat).id] ?: return + if (count <= 0) { + // Reset the charge to 1 so the item reverts as a clean single helm, not one carrying the + // old charge count. + syncHelmCharge(headgear, 1) + equipment.replace(EquipSlot.Hat.index, headgear.charged, headgear.enchanted) + clear(SCROLL_ID) + clear(SCROLL_COUNT) + } else { + syncHelmCharge(headgear, count) + } +} + +/** + * Mirrors the stored scroll [count] onto the charged helm's item charge, worn or in the pack, so the + * client shows it on the item and on the follower-details cast button. + */ +private fun Player.syncHelmCharge(headgear: Headgear, count: Int) { + if (equipped(EquipSlot.Hat).id == headgear.charged) { + equipment.transaction { setCharge(EquipSlot.Hat.index, count) } + return + } + val slot = inventory.indexOf(headgear.charged) + if (slot != -1) { + inventory.transaction { setCharge(slot, count) } + } +} + +class EnchantedHeadgear : Script { + init { + for (headgear in HEADGEAR) { + // Fill an enchanted helm by using combat scrolls on it - all matching scrolls in the + // pack, up to capacity, one scroll type at a time. The helm takes its charged form. + // Fill an empty enchanted helm or top up an already-charged one. + itemOnItem("*_scroll", headgear.enchanted) { fromItem, toItem -> + store(if (fromItem.id == headgear.enchanted) toItem else fromItem, headgear) + } + if (headgear.charged != headgear.enchanted) { + itemOnItem("*_scroll", headgear.charged) { fromItem, toItem -> + store(if (fromItem.id == headgear.charged) toItem else fromItem, headgear) + } + } + // Right-click the charged helm to check its contents or empty it back to the enchanted + // form, returning the scrolls to the pack. + itemOption("Commune", headgear.charged) { commune() } + itemOption("Commune", headgear.charged, "worn_equipment") { commune() } + itemOption("Uncharge", headgear.charged) { uncharge(headgear) } + itemOption("Uncharge", headgear.charged, "worn_equipment") { uncharge(headgear) } + } + } + + private fun Player.store(scroll: Item, headgear: Headgear) { + if (!isCombatScroll(scroll)) { + message("Only combat scrolls can be stored in headgear.") + return + } + val stored = get(SCROLL_COUNT, 0) + if (stored > 0 && get(SCROLL_ID, "") != scroll.id) { + message("This helmet already holds ${ItemDefinitions.get(get(SCROLL_ID, "")).name.lowercase()}s. Empty it first.") + return + } + val room = headgear.capacity - stored + if (room <= 0) { + message("The helmet is full.") + return + } + val amount = minOf(room, inventory.count(scroll.id)) + if (amount <= 0 || !inventory.remove(scroll.id, amount)) { + return + } + set(SCROLL_ID, scroll.id) + set(SCROLL_COUNT, stored + amount) + // Turn the empty enchanted helm into its charged form; a helm that's already charged (a + // top-up, or a worn one) just has its charge count updated. + if (equipped(EquipSlot.Hat).id != headgear.charged && inventory.contains(headgear.enchanted)) { + inventory.replace(headgear.enchanted, headgear.charged) + } + syncHelmCharge(headgear, stored + amount) + message("You store $amount ${scroll.def.name.lowercase()}${if (amount == 1) "" else "s"} in the helmet (${stored + amount}/${headgear.capacity}).", ChatType.Filter) + } + + private fun Player.commune() { + val count = get(SCROLL_COUNT, 0) + val scroll = get(SCROLL_ID, "") + if (count <= 0 || scroll.isEmpty()) { + message("The helmet holds no scrolls.") + return + } + message("The helmet holds $count ${ItemDefinitions.get(scroll).name.lowercase()}${if (count == 1) "" else "s"}.") + } + + private fun Player.uncharge(headgear: Headgear) { + val scroll = get(SCROLL_ID, "") + val count = get(SCROLL_COUNT, 0) + if (scroll.isNotEmpty() && count > 0) { + inventory.add(scroll, count) + message("You empty the helmet, recovering $count ${ItemDefinitions.get(scroll).name.lowercase()}${if (count == 1) "" else "s"}.", ChatType.Filter) + } + // Reset the charge to 1 so the helm reverts as a clean single item, then revert it to its + // empty enchanted form whether or not it held anything. + syncHelmCharge(headgear, 1) + if (equipped(EquipSlot.Hat).id == headgear.charged) { + equipment.replace(EquipSlot.Hat.index, headgear.charged, headgear.enchanted) + } else { + inventory.replace(headgear.charged, headgear.enchanted) + } + clear(SCROLL_ID) + clear(SCROLL_COUNT) + } +} diff --git a/game/src/main/kotlin/content/skill/summoning/FamiliarBoostSpecials.kt b/game/src/main/kotlin/content/skill/summoning/FamiliarBoostSpecials.kt new file mode 100644 index 0000000000..fe3eb9d4e0 --- /dev/null +++ b/game/src/main/kotlin/content/skill/summoning/FamiliarBoostSpecials.kt @@ -0,0 +1,158 @@ +package content.skill.summoning + +import content.entity.combat.hit.directHit +import content.entity.effect.toxin.curePoison +import content.entity.effect.toxin.poison +import content.entity.player.effect.energy.MAX_RUN_ENERGY +import content.entity.player.effect.energy.runEnergy +import content.entity.proj.shoot +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.client.message +import world.gregs.voidps.engine.data.definition.GraphicDefinitions +import world.gregs.voidps.engine.entity.character.mode.combat.CombatAttack +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.entity.character.player.skill.Skill +import world.gregs.voidps.engine.queue.queue +import kotlin.math.ceil + +/** Flight time (client ticks; 30 = 1 game tick) of the spirit scorpion's slow venom bolt to its owner. */ +private const val VENOM_SHOT_FLIGHT = 60 + +/** + * Instant self/owner boost and heal familiar specials (Stony Shell, Testudo, Magic Focus, Healing + * Aura, Blood Drain, ...). Each registers as an [FamiliarSpecialMoves.instant] move and runs through + * [castFamiliarSpecial], so a scroll + points are spent on use. + */ +class FamiliarBoostSpecials : Script { + init { + // Single-skill flat boosts. + FamiliarSpecialMoves.instant("granite_crab_familiar") { boost(Skill.Defence, 4, "stony_shell", "stony_shell") } + FamiliarSpecialMoves.instant("war_tortoise_familiar") { boost(Skill.Defence, 8, "testudo", "testudo", "testudo_owner") } + FamiliarSpecialMoves.instant("obsidian_golem_familiar") { boost(Skill.Strength, 9, "volcanic_strength", "volcanic_strength") } + FamiliarSpecialMoves.instant("wolpertinger_familiar") { boost(Skill.Magic, 7, "magic_focus", "magic_focus") } + + // Abyssal Stealth - two skills at once. + FamiliarSpecialMoves.instant("abyssal_lurker_familiar") { + familiarSelfSpecial(anim = "abyssal_stealth", sourceGfx = "abyssal_stealth", playerGfx = "abyssal_stealth_owner") { + levels.boost(Skill.Agility, 4) + levels.boost(Skill.Thieving, 4) + } + } + + // Unburden - restore run energy by ~half the Agility level. + FamiliarSpecialMoves.instant("bull_ant_familiar") { + familiarSelfSpecial(anim = "unburden", sourceGfx = "unburden") { + restoreRunEnergy() + } + } + + // Tireless Run - Agility +2 and restore run energy by half the (boosted) Agility level. + // Refuses, charging nothing, when run energy is already full. + FamiliarSpecialMoves.instant("spirit_terrorbird_familiar") { + if (runEnergy >= MAX_RUN_ENERGY) { + message("You're already full run energy.") + return@instant false + } + familiarSelfSpecial(anim = "tireless_run", sourceGfx = "tireless_run", playerGfx = "tireless_run_owner") { + levels.boost(Skill.Agility, 2) + runEnergy = (runEnergy + levels.get(Skill.Agility) * 50).coerceAtMost(MAX_RUN_ENERGY) + } + } + + // Elemental titans - Titan's Constitution: Defence +12.5% and 80 life points, boosting + // past maximum (an over-heal, not a capped restore), each titan with its own colours. + for (tier in listOf("fire", "ice", "moss")) { + FamiliarSpecialMoves.instant("${tier}_titan_familiar") { + familiarSelfSpecial(anim = "titans_constitution_$tier", sourceGfx = "titans_constitution_$tier", playerGfx = "titans_constitution_${tier}_owner") { + levels.boost(Skill.Defence, multiplier = 0.125) + levels.boost(Skill.Constitution, 80) + } + } + } + + // Healing Aura - heal the owner by 15% of their max life points. Refuses at full health. + FamiliarSpecialMoves.instant("unicorn_stallion_familiar") { + if (levels.get(Skill.Constitution) >= levels.getMax(Skill.Constitution)) { + message("You're already at full life points!") + return@instant false + } + familiarSelfSpecial(anim = "healing_aura", sourceGfx = "healing_aura", playerGfx = "healing_aura_owner") { + levels.restore(Skill.Constitution, ceil(levels.getMax(Skill.Constitution) * 0.15).toInt()) + } + } + + // Blood Drain - the leech draws the poison from its owner's blood, restoring every drained + // stat by 2 + 20% of its level, then takes a 25 life-point bite in payment. + FamiliarSpecialMoves.instant("bloated_leech_familiar") { + familiarSelfSpecial(anim = "blood_drain", sourceGfx = "blood_drain", playerGfx = "blood_drain_owner") { + follower?.shoot("blood_drain_proj", this, height = 15, endHeight = 16) + curePoison() + for (skill in Skill.values()) { + levels.restore(skill, 2, 0.2) + } + directHit(25, "damage") + } + } + + // Thieving Fingers - boosts Thieving by 2, on top of the magpie's passive invisible +3 + // (see FamiliarBoosts). + FamiliarSpecialMoves.instant("magpie_familiar") { + familiarSelfSpecial(anim = "thieving_fingers", sourceGfx = "thieving_fingers", playerGfx = "thieving_fingers_owner") { + levels.boost(Skill.Thieving, 2) + } + } + + // Insane Ferocity - the honey badger enrages the owner, boosting Attack and Strength (+5 and + // 15% each) at the cost of Ranged, Magic and Defence (-10% each). The stat changes decay + // naturally over time, and recasting simply refreshes them. + FamiliarSpecialMoves.instant("honey_badger_familiar") { + familiarSelfSpecial(anim = "insane_ferocity", sourceGfx = "insane_ferocity", playerGfx = "insane_ferocity_owner") { + levels.boost(Skill.Attack, amount = 5, multiplier = 0.15) + levels.boost(Skill.Strength, amount = 5, multiplier = 0.15) + levels.drain(Skill.Ranged, multiplier = 0.1) + levels.drain(Skill.Magic, multiplier = 0.1) + levels.drain(Skill.Defence, multiplier = 0.1) + } + } + + // Venom Shot - charge the next ranged attack to poison its target. The charge is set here and + // spent in [venomShot] on the owner's next damaging ranged hit. + FamiliarSpecialMoves.instant("spirit_scorpion_familiar") { + if (this["familiar_venom_shot_charged", false]) { + message("Your familiar's venom shot is already charged.") + return@instant false + } + val familiar = follower ?: return@instant false + // The scorpion charges the owner's next ranged shot: it plays its wind-up animation + + // graphic, then once the graphic finishes fires a slow venom bolt at the owner. Explicit + // start/end heights keep the bolt travelling from the scorpion to the owner's body. + familiar.watch(this) + familiar.anim("venom_shot") + familiar.gfx("venom_shot") + val windUp = GraphicDefinitions.get("venom_shot")["ticks", 0] + queue("venom_shot_charge", windUp) { + familiar.shoot("venom_shot_proj", this, flightTime = VENOM_SHOT_FLIGHT, height = 20, endHeight = 35) + } + set("familiar_venom_shot_charged", true) + true + } + + combatAttack(handler = ::venomShot) + } + + /** Consumes the spirit scorpion's charged Venom Shot: poison the target of the next ranged hit. */ + private fun venomShot(source: Player, attack: CombatAttack) { + if (attack.damage <= 0 || attack.type != "range" || !source["familiar_venom_shot_charged", false]) { + return + } + source.clear("familiar_venom_shot_charged") + source.poison(attack.target, 60) + } + + /** Boosts [skill] by [amount] above max with the familiar's [anim]/[sourceGfx] and owner [playerGfx] flourish. */ + private fun Player.boost(skill: Skill, amount: Int, anim: String? = null, sourceGfx: String? = null, playerGfx: String? = null): Boolean = familiarSelfSpecial(anim = anim, sourceGfx = sourceGfx, playerGfx = playerGfx) { levels.boost(skill, amount) } + + private fun Player.restoreRunEnergy() { + runEnergy = (runEnergy + levels.getMax(Skill.Agility) * 50).coerceAtMost(MAX_RUN_ENERGY) + } +} diff --git a/game/src/main/kotlin/content/skill/summoning/FamiliarBoosts.kt b/game/src/main/kotlin/content/skill/summoning/FamiliarBoosts.kt new file mode 100644 index 0000000000..39931a7b70 --- /dev/null +++ b/game/src/main/kotlin/content/skill/summoning/FamiliarBoosts.kt @@ -0,0 +1,66 @@ +package content.skill.summoning + +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.entity.character.player.skill.Skill + +/** + * Passive, invisible skill-level boosts a summoned familiar grants. These raise the *effective* + * level used in skill success/yield checks only - they aren't shown in the skill tab, don't decay, + * and never let the player perform an action above their real level. Because [familiarBoost] reads + * the boost live from the active follower, the boost disappears the moment the familiar is + * dismissed or killed (the follower is cleared in dismissFamiliar). + * + * Dreadfowl/compost mound's active "Special" Farming boost is a separate, visible mechanic. + */ +/** A familiar's invisible boost: a [flat] amount plus a [multiplier] of the player's base level. */ +private data class Boost(val flat: Int, val multiplier: Double = 0.0) + +private val FAMILIAR_BOOSTS: Map> = mapOf( + "beaver_familiar" to mapOf(Skill.Woodcutting to Boost(2)), + "granite_crab_familiar" to mapOf(Skill.Fishing to Boost(1)), + "ibis_familiar" to mapOf(Skill.Fishing to Boost(3)), + "granite_lobster_familiar" to mapOf(Skill.Fishing to Boost(4)), + "arctic_bear_familiar" to mapOf(Skill.Hunter to Boost(7)), + "wolpertinger_familiar" to mapOf(Skill.Hunter to Boost(5)), + "spirit_graahk_familiar" to mapOf(Skill.Hunter to Boost(5)), + "spirit_kyatt_familiar" to mapOf(Skill.Hunter to Boost(5)), + "spirit_larupia_familiar" to mapOf(Skill.Hunter to Boost(5)), + "desert_wyrm_familiar" to mapOf(Skill.Mining to Boost(1)), + "void_ravager_familiar" to mapOf(Skill.Mining to Boost(1)), + "obsidian_golem_familiar" to mapOf(Skill.Mining to Boost(7)), + "lava_titan_familiar" to mapOf(Skill.Mining to Boost(10), Skill.Firemaking to Boost(10)), + "pyrelord_familiar" to mapOf(Skill.Firemaking to Boost(3)), + "forge_regent_familiar" to mapOf(Skill.Firemaking to Boost(4)), + "magpie_familiar" to mapOf(Skill.Thieving to Boost(3)), + "stranger_plant_familiar" to mapOf(Skill.Farming to Boost(1, 0.04)), + "geyser_titan_familiar" to mapOf(Skill.Ranged to Boost(1, 0.03)), +) + +/** + * The invisible skill-level boost the player's currently-summoned familiar grants for [skill], + * or 0 if the player has no familiar or it doesn't boost that skill. Add this to the visible level + * in a skill's success check, e.g. `levels.get(Skill.Mining) + familiarBoost(Skill.Mining)`. + */ +fun Player.familiarBoost(skill: Skill): Int { + val id = follower?.id ?: return 0 + val boost = FAMILIAR_BOOSTS[id]?.get(skill) ?: return 0 + return boost.flat + (levels.getMax(skill) * boost.multiplier).toInt() +} + +/** + * The ibis and granite lobster spear fish for their owner, standing in for a harpoon when fishing + * tuna, swordfish or sharks. + */ +fun Player.familiarActsAsHarpoon(): Boolean = follower?.id == "ibis_familiar" || follower?.id == "granite_lobster_familiar" + +/** + * Style-gated passive defence: while summoned, the iron/steel titan make their owner 10%/15% + * harder to hit with melee, and the wolpertinger 5% harder to hit with magic. Applied as a + * multiplier to the owner's defensive rating for an incoming attack of [type]. + */ +fun Player.familiarDefenceMultiplier(type: String, melee: Boolean): Double = when (follower?.id) { + "iron_titan_familiar" -> if (melee) 1.10 else 1.0 + "steel_titan_familiar" -> if (melee) 1.15 else 1.0 + "wolpertinger_familiar" -> if (type == "magic") 1.05 else 1.0 + else -> 1.0 +} diff --git a/game/src/main/kotlin/content/skill/summoning/FamiliarCombat.kt b/game/src/main/kotlin/content/skill/summoning/FamiliarCombat.kt new file mode 100644 index 0000000000..aa987aa703 --- /dev/null +++ b/game/src/main/kotlin/content/skill/summoning/FamiliarCombat.kt @@ -0,0 +1,153 @@ +package content.skill.summoning + +import content.area.wilderness.inMultiCombat +import content.area.wilderness.inWilderness +import content.entity.combat.Target +import content.entity.combat.target +import content.entity.effect.clearTransform +import content.entity.effect.transform +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.client.message +import world.gregs.voidps.engine.data.definition.CombatDefinitions +import world.gregs.voidps.engine.data.definition.NPCDefinitions +import world.gregs.voidps.engine.entity.character.Character +import world.gregs.voidps.engine.entity.character.mode.combat.CombatApi +import world.gregs.voidps.engine.entity.character.mode.combat.CombatMovement +import world.gregs.voidps.engine.entity.character.npc.NPC +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.get + +/** + * True when this NPC has a combat definition with at least one attack, i.e. it is a combat + * familiar. Skill/passive familiars have no combat block and so cannot fight. + */ +fun NPC.canFight(): Boolean { + val definitions: CombatDefinitions = get() + return definitions.getOrNull(def["combat_def", id])?.attacks?.isNotEmpty() == true +} + +/** + * Directs the player's familiar to attack [target]. Player targets follow the owner's own PvP + * rules (wilderness or PvP area, combat-level bracket); against NPCs it fights solo (even in + * single-way) but, like any attacker, can't share a target someone else is already fighting - + * including its own owner - so single-combat rules in [Target.attackable] are pre-checked. + * [silent] suppresses the rejection messages (auto-assist). + */ +fun Player.commandFamiliarAttack(target: Character, silent: Boolean = false) { + val familiar = follower ?: return + if (familiar == target || this == target) { + return + } + if (!familiar.canFight()) { + if (!silent) message("Your familiar cannot fight.") + return + } + when (target) { + // The owner's own PvP rules (wilderness or PvP area, level range) decide who the familiar + // may be sent at. + is Player -> if (!Target.attackable(this, target, message = !silent)) { + return + } + // A familiar can't pile onto an NPC that's already under attack (e.g. one the owner is + // fighting in single-way); it must be sent at its own, separate target. + is NPC -> if (!Target.attackable(familiar, target, message = false)) { + if (!silent) message("Your familiar can't attack that right now.") + return + } + } + // Drive the familiar with CombatMovement directly rather than the interact-then-combat path: + // the interact approach gives up (cantReach -> EmptyMode) the moment a player or npc fully + // blocks the route, whereas CombatMovement re-paths every tick and keeps pursuing, so the + // familiar resumes as soon as the obstruction clears. + familiar.mode = CombatMovement(familiar, target) +} + +/** + * Sends an idle combat familiar at [target]. No-op when the familiar is already fighting or + * cannot fight; rejections are silent so auto-assist never spams the owner. + */ +fun Player.assistFamiliar(target: Character) { + val familiar = follower ?: return + // Familiars only auto-join the owner's fight in multi-combat. In single-way the owner's + // target is theirs alone, so the familiar must be ordered at a separate NPC instead. + if (!inMultiCombat) { + return + } + if (familiar.target != null || familiar.mode is CombatMovement) { + return + } + if (!familiar.canFight()) { + return + } + commandFamiliarAttack(target, silent = true) +} + +/** + * Transforms a combat familiar to its PvP variant (npc id + 1, the form carrying the "Attack" + * option) while in the wilderness so enemy players can target it, and reverts otherwise. Safe to + * call on summon, call, and on entering/leaving the wilderness. + */ +fun Player.updateFamiliarPvpForm() { + val familiar = follower ?: return + if (!familiar.canFight()) { + return + } + val variant = "${familiar.id}_combat" + if (inWilderness && NPCDefinitions.contains(variant)) { + if (familiar.transform != variant) { + familiar.transform(variant) + } + } else if (familiar.transform != "") { + familiar.clearTransform() + } +} + +class FamiliarCombat : + Script, + CombatApi { + + init { + // Explicit command (cast-on-target): pick the attack option from the follower details + // interface (662:65) or the summoning orb (component 14 / its left-click 21), then click an + // NPC or player to send the familiar at it. + onNPCApproach("familiar_details:attack", "*") { (target) -> + approachRange(16) + commandFamiliarAttack(target) + } + onNPCApproach("summoning_orb:*attack", "*") { (target) -> + approachRange(16) + commandFamiliarAttack(target) + } + onPlayerApproach("familiar_details:attack") { (target) -> + approachRange(16) + commandFamiliarAttack(target) + } + onPlayerApproach("summoning_orb:*attack") { (target) -> + approachRange(16) + commandFamiliarAttack(target) + } + + // Offensive assist: when the owner attacks something, an idle combat familiar joins in. + // Defensive assist: when the owner is attacked, the familiar turns on the attacker. + combatStart { target -> + assistFamiliar(target) + if (target is Player) { + target.assistFamiliar(this) + } + } + npcCombatStart { target -> + if (target is Player) { + target.assistFamiliar(this) + } + } + + // Re-acquire: each time the owner lands an attack, an idle familiar joins the owner's + // current (most recently attacked) target. assistFamiliar's idle guard leaves an actively + // fighting familiar alone, so it only picks a new target once its own is dead/gone. + combatAttack { assistFamiliar(it.target) } + + variableSet("in_wilderness") { _, _, _ -> + updateFamiliarPvpForm() + } + } +} diff --git a/game/src/main/kotlin/content/skill/summoning/FamiliarCombatSpecials.kt b/game/src/main/kotlin/content/skill/summoning/FamiliarCombatSpecials.kt new file mode 100644 index 0000000000..6414dfcd57 --- /dev/null +++ b/game/src/main/kotlin/content/skill/summoning/FamiliarCombatSpecials.kt @@ -0,0 +1,496 @@ +package content.skill.summoning + +import content.entity.combat.attackers +import content.entity.combat.hit.hit +import content.entity.combat.target +import content.entity.effect.freeze +import content.entity.effect.stun +import content.entity.effect.toxin.poison +import content.entity.player.combat.special.specialAttackEnergy +import content.entity.proj.shoot +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.client.message +import world.gregs.voidps.engine.client.variable.hasClock +import world.gregs.voidps.engine.client.variable.start +import world.gregs.voidps.engine.entity.character.Character +import world.gregs.voidps.engine.entity.character.mode.Retreat +import world.gregs.voidps.engine.entity.character.mode.combat.CombatDamage +import world.gregs.voidps.engine.entity.character.npc.NPC +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.entity.character.player.Players +import world.gregs.voidps.engine.entity.character.player.name +import world.gregs.voidps.engine.entity.character.player.skill.Skill +import world.gregs.voidps.engine.entity.distanceTo +import world.gregs.voidps.engine.inv.equipment +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.engine.inv.move +import world.gregs.voidps.engine.inv.replace +import world.gregs.voidps.engine.inv.transact.TransactionError +import world.gregs.voidps.engine.queue.queue +import world.gregs.voidps.engine.timer.CLIENT_TICKS +import world.gregs.voidps.network.login.protocol.visual.update.player.EquipSlot +import world.gregs.voidps.type.random + +/** How long (game ticks) a minotaur's Bull Rush stuns its target, keeping it from acting. */ +private const val BULL_RUSH_STUN_TICKS = 5 + +/** Bull Rush stuns on impact with a 1-in-[BULL_RUSH_STUN_CHANCE] chance (i.e. a third of the time). */ +private const val BULL_RUSH_STUN_CHANCE = 3 + +/** How long (game ticks) the arctic bear's Arctic Blast stuns a small target on impact. */ +private const val ARCTIC_BLAST_STUN_TICKS = 3 + +/** Arctic Blast stuns on impact with a 1-in-[ARCTIC_BLAST_STUN_CHANCE] chance, size-1 targets only. */ +private const val ARCTIC_BLAST_STUN_CHANCE = 5 + +/** How long (game ticks) the praying mantis' Mantis Strike binds a small target in place on impact. */ +private const val MANTIS_STRIKE_BIND_TICKS = 3 + +/** How long (game ticks) the spirit dagannoth's Spike Shot stuns its target on impact. */ +private const val SPIKE_SHOT_STUN_TICKS = 5 + +/** + * Combat familiar special moves - the cast-button specials that target an npc (or player). Each + * registers into [FamiliarSpecialMoves]; the dispatcher runs it through [castFamiliarSpecial] so a + * scroll + special-move points are spent only on a successful cast. + */ +class FamiliarCombatSpecials : Script { + init { + // Spirit wolf - Howl: no damage, the target flees from the familiar for a few tiles. + FamiliarSpecialMoves.npc("spirit_wolf_familiar") { target -> + if (!familiarCanSpecial(target) || target !is NPC) { + return@npc false + } + val familiar = follower ?: return@npc false + familiar.watch(target) + familiar.anim("spirit_wolf_howl") + familiar.gfx("spirit_wolf_howl") + val flight = familiar.shoot("spirit_wolf_howl_proj", target, height = 0, endHeight = 0) + // Flee only once the tornado actually reaches the npc, not the instant it's cast. + target.queue("howl_retreat", CLIENT_TICKS.toTicks(flight)) { + mode = Retreat(this, familiar) + } + true + } + + // Straightforward "fire a projectile, deal up to maxHit" combat specials. + FamiliarSpecialMoves.npc("desert_wyrm_familiar") { target -> + familiarSpecialHit(target, maxHit = 50, anim = "electric_lash", sourceGfx = "electric_lash", projectile = "electric_lash_proj") + } + // Toad Bark - the barker toad spits out the cannonball it was loaded with (see BarkerToad.kt), + // its hardest-hitting shot. Refuses to fire (charging nothing) while unloaded. + FamiliarSpecialMoves.npc("barker_toad_familiar") { target -> + val toad = follower ?: return@npc false + if (!toad["cannonball_loaded", false]) { + message("Your toad needs to be loaded with a cannonball first.") + return@npc false + } + val cast = familiarSpecialHit(target, maxHit = 300, type = "range", anim = "toad_bark", sourceGfx = "toad_bark", projectile = "toad_bark_proj", targetGfx = "toad_bark_hit") + if (cast) { + toad.clear("cannonball_loaded") + } + cast + } + FamiliarSpecialMoves.npc("thorny_snail_familiar") { target -> + familiarSpecialHit(target, maxHit = 80, anim = "slime_spray", sourceGfx = "slime_spray", projectile = "slime_spray_proj", targetGfx = "slime_spray_hit") + } + // Arctic Blast also stuns small targets on impact, one time in five. + FamiliarSpecialMoves.npc("arctic_bear_familiar") { target -> + familiarSpecialHit(target, maxHit = 130, anim = "arctic_blast", sourceGfx = "arctic_blast", projectile = "arctic_blast_proj", targetGfx = "arctic_blast_hit") { hit -> + if (hit.size <= 1 && random.nextInt(ARCTIC_BLAST_STUN_CHANCE) == 0) { + follower?.stun(hit, ARCTIC_BLAST_STUN_TICKS) + } + } + } + FamiliarSpecialMoves.npc("granite_lobster_familiar") { target -> + familiarSpecialHit(target, maxHit = 96, anim = "crushing_claw", sourceGfx = "crushing_claw", projectile = "crushing_claw_proj").alsoDrain(target, Skill.Defence, multiplier = 0.05) + } + FamiliarSpecialMoves.npc("dreadfowl_familiar") { target -> + familiarSpecialHit(target, maxHit = 30, anim = "dreadfowl_strike", sourceGfx = "dreadfowl_strike", projectile = "dreadfowl_strike_proj") + } + + // Combat specials that also drain one or more of the target's stats. + FamiliarSpecialMoves.npc("spirit_jelly_familiar") { target -> + familiarSpecialHit(target, maxHit = 120, anim = "dissolve", sourceGfx = "dissolve", projectile = "dissolve_proj", targetGfx = "dissolve_hit").alsoDrain(target, Skill.Attack, multiplier = 0.1) + } + FamiliarSpecialMoves.npc("spirit_larupia_familiar") { target -> + familiarSpecialHit(target, maxHit = 120, anim = "rending", sourceGfx = "rending", projectile = "rending_proj", targetGfx = "rending_hit").alsoDrain(target, Skill.Strength, multiplier = 0.1) + } + // Evil Flames - the evil turnip breathes a magic fireball, lowering the target's Magic by 1. + FamiliarSpecialMoves.npc("evil_turnip_familiar") { target -> + familiarSpecialHit(target, maxHit = 100, anim = "evil_flames", sourceGfx = "evil_flames", projectile = "evil_flames_proj", targetGfx = "evil_flames_hit").alsoDrain(target, Skill.Magic, 1) + } + // Abyssal Drain - saps a tenth of the target's Magic and Prayer. + FamiliarSpecialMoves.npc("abyssal_parasite_familiar") { target -> + val cast = familiarSpecialHit(target, maxHit = 95, anim = "abyssal_drain", sourceGfx = "abyssal_drain", projectile = "abyssal_drain_proj") + if (cast) { + target.levels.drain(Skill.Magic, multiplier = 0.1) + target.levels.drain(Skill.Prayer, multiplier = 0.1) + } + cast + } + + // Poisonous Blast - small hit with a 50% chance to poison. + FamiliarSpecialMoves.npc("stranger_plant_familiar") { target -> + val cast = familiarSpecialHit(target, maxHit = 120, anim = "poisonous_blast", projectile = "poisonous_blast_proj", targetGfx = "poisonous_blast_hit") + if (cast && random.nextInt(2) == 0) { + follower?.poison(target, 20) + } + cast + } + + // Boil - the geyser titan scalds the target for up to 240: melee up close, otherwise a + // steaming volley that is ranged or magic at even odds (approximating the live def-bonus + // formula with a flat max). + FamiliarSpecialMoves.npc("geyser_titan_familiar") { target -> + val familiar = follower ?: return@npc false + val type = when { + familiar.tile.distanceTo(target) <= 1 -> "melee" + random.nextInt(2) == 0 -> "range" + else -> "magic" + } + familiarSpecialHit(target, maxHit = 240, type = type, anim = "boil", sourceGfx = "boil", projectile = "boil_proj", targetGfx = "boil_hit") + } + + // Doomsphere - the karamthulhu overlord's watery blast, also washing away 5% of the target's Magic. + FamiliarSpecialMoves.npc("karamthulhu_overlord_familiar") { target -> + familiarSpecialHit(target, maxHit = 78, anim = "doomsphere", sourceGfx = "doomsphere", projectile = "doomsphere_proj", targetGfx = "doomsphere_hit").alsoDrain(target, Skill.Magic, multiplier = 0.05) + } + + // Spike Shot - the spirit dagannoth launches a spike, its hardest-hitting single shot, + // stunning the target as it lands. + FamiliarSpecialMoves.npc("spirit_dagannoth_familiar") { target -> + familiarSpecialHit(target, maxHit = 170, anim = "spike_shot", projectile = "spike_shot_proj", targetGfx = "spike_shot_hit") { hit -> + follower?.stun(hit, SPIKE_SHOT_STUN_TICKS) + } + } + + // Swamp Plague - the swamp titan's bog blast also poisons the target. + FamiliarSpecialMoves.npc("swamp_titan_familiar") { target -> + val cast = familiarSpecialHit(target, maxHit = 110, anim = "swamp_plague", projectile = "swamp_plague_proj") + if (cast) { + follower?.poison(target, 80) + } + cast + } + + // Ebon Thunder - the lava titan's bolt drains a tenth of a player target's remaining special + // attack energy on impact. + FamiliarSpecialMoves.npc("lava_titan_familiar") { target -> + familiarSpecialHit(target, maxHit = 140, anim = "ebon_thunder", sourceGfx = "ebon_thunder", projectile = "ebon_thunder_proj", targetGfx = "ebon_thunder_hit", onLand = ::drainSpecialEnergy) + } + FamiliarSpecialMoves.player("lava_titan_familiar") { target -> + familiarSpecialHit(target, maxHit = 140, anim = "ebon_thunder", sourceGfx = "ebon_thunder", projectile = "ebon_thunder_proj", targetGfx = "ebon_thunder_hit", onLand = ::drainSpecialEnergy) + } + + // Mantis Strike - the praying mantis' bolt binds small targets in place once it lands. + FamiliarSpecialMoves.npc("praying_mantis_familiar") { target -> + familiarSpecialHit(target, maxHit = 100, anim = "mantis_strike", sourceGfx = "mantis_strike", projectile = "mantis_strike_proj", targetGfx = "mantis_strike_hit") { hit -> + if (hit.size <= 1) { + follower?.freeze(hit, MANTIS_STRIKE_BIND_TICKS) + } + } + } + + // Deadly Claw - the talon beast rakes its target with three magic swipes in quick succession. + FamiliarSpecialMoves.npc("talon_beast_familiar") { target -> + if (!familiarCanSpecial(target)) { + return@npc false + } + val familiar = follower ?: return@npc false + familiar.watch(target) + familiar.anim("talon_beast_familiar_attack") + repeat(3) { swipe -> + familiar.hit(target, offensiveType = "magic", damage = random.nextInt(101), delay = swipe * 30) + } + if (familiar !in target.attackers) { + target.attackers.add(familiar) + } + commandFamiliarAttack(target, silent = true) + true + } + + // Acorn Missile - the giant ent lobs acorns that also pelt up to nine foes around the target. + FamiliarSpecialMoves.npc("giant_ent_familiar") { target -> + val cast = familiarSpecialHit(target, maxHit = 100, anim = "acorn_missile", projectile = "acorn_missile_proj", targetGfx = "acorn_missile_hit") + if (cast) { + familiarSplash(target, maxTargets = 9, maxHit = 100, radius = 1, projectile = "acorn_missile_proj", targetGfx = "acorn_missile_hit") + } + cast + } + + // Famine - the locust swarm eats a player target's food; the swarm itself does no damage. + FamiliarSpecialMoves.npc("ravenous_locust_familiar") { target -> famine(target) } + FamiliarSpecialMoves.player("ravenous_locust_familiar") { target -> famine(target) } + + // Inferno - a fiery bolt for up to 85 that, against another player, also burns their weapon + // and shield out of their hands (into their inventory, space allowing). + FamiliarSpecialMoves.npc("forge_regent_familiar") { target -> + familiarSpecialHit(target, maxHit = 85, anim = "inferno", sourceGfx = "evil_flames", projectile = "evil_flames_proj") + } + FamiliarSpecialMoves.player("forge_regent_familiar") { target -> + val cast = familiarSpecialHit(target, maxHit = 85, anim = "inferno", sourceGfx = "evil_flames", projectile = "evil_flames_proj") + if (cast) { + var disarmed = false + for (slot in intArrayOf(EquipSlot.Weapon.index, EquipSlot.Shield.index)) { + if (target.equipment[slot].isEmpty()) { + continue + } + target.equipment.move(slot, target.inventory) + if (target.equipment.transaction.error == TransactionError.None) { + disarmed = true + } + } + if (disarmed) { + target.gfx("inferno_hit") + target.message("$name's familiar burns the equipment from your hands!") + } + } + cast + } + + // Vampyre Touch: 120 max, 40% chance to heal the owner 20. + FamiliarSpecialMoves.npc("vampire_bat_familiar") { target -> + val cast = familiarSpecialHit(target, maxHit = 120, anim = "vampire_touch", sourceGfx = "vampire_touch") + if (cast && random.nextInt(100) < 40) { + levels.restore(Skill.Constitution, 20) + } + cast + } + + // Cockatrice family - Petrifying Gaze: drain a variant-specific stat by 3 then hit up to 10. + val petrifyingGaze = mapOf( + "spirit_cockatrice_familiar" to Skill.Defence, + "spirit_guthatrice_familiar" to Skill.Attack, + "spirit_saratrice_familiar" to Skill.Prayer, + "spirit_zamatrice_familiar" to Skill.Strength, + "spirit_pengatrice_familiar" to Skill.Magic, + "spirit_coraxatrice_familiar" to Skill.Summoning, + "spirit_vulatrice_familiar" to Skill.Ranged, + ) + for ((familiar, skill) in petrifyingGaze) { + FamiliarSpecialMoves.npc(familiar) { target -> + familiarSpecialHit(target, maxHit = 100, anim = "petrifying_gaze", sourceGfx = "petrifying_gaze", projectile = "petrifying_gaze_proj", targetGfx = "petrifying_gaze_hit") + .alsoDrain(target, skill, 3) + } + // The cockatrice's "Drain" right-click option casts Petrifying Gaze on the familiar's + // current combat target (falling back to the owner's), through the same cast gate - + // scroll, points, cooldown - as the summoning-orb button. + npcOperate("Drain", familiar) { (clicked) -> + if (clicked != follower) { + return@npcOperate + } + val enemy = (follower?.target ?: target) as? NPC + if (enemy == null) { + message("Your familiar has no target to drain.") + return@npcOperate + } + castFamiliarSpecial { FamiliarSpecialMoves.npcTarget.getValue(familiar)(enemy) } + } + } + + // Dust Cloud - a plain cast chokes everything around the smoke devil itself; cast on a + // specific foe it chokes them and up to six more around them instead. + FamiliarSpecialMoves.instant("smoke_devil_familiar") { + familiarAoeSpecial(maxTargets = 6, maxHit = 80, radius = 1, anim = "dust_cloud", sourceGfx = "dust_cloud", projectile = "dust_cloud_proj", targetGfx = "dust_cloud_hit") + } + FamiliarSpecialMoves.npc("smoke_devil_familiar") { target -> + val cast = familiarSpecialHit(target, maxHit = 80, anim = "dust_cloud", sourceGfx = "dust_cloud", projectile = "dust_cloud_proj", targetGfx = "dust_cloud_hit") + if (cast) { + familiarSplash(target, maxTargets = 6, maxHit = 80, radius = 7, projectile = "dust_cloud_proj", targetGfx = "dust_cloud_hit") + } + cast + } + + // Fireball Assault - the spirit Tz-Kih flings fire at up to two nearby foes. + FamiliarSpecialMoves.instant("spirit_tz-kih_familiar") { + familiarAoeSpecial(maxTargets = 2, maxHit = 70, radius = 3, anim = "fireball_assault", targetGfx = "fireball_assault_hit") + } + + // Sandstorm - the spirit kalphite blasts every foe around it (up to six, big max hit). + FamiliarSpecialMoves.instant("spirit_kalphite_familiar") { + familiarAoeSpecial(maxTargets = 6, maxHit = 200, radius = 6, anim = "sandstorm", sourceGfx = "sandstorm", projectile = "sandstorm_proj") + } + + FamiliarSpecialMoves.instant("giant_chinchompa_familiar") { + chinchompaExplode() + } + npcCombatDamage("giant_chinchompa_familiar", handler = ::autoExplode) + + // Minotaur family - Bull Rush: a ranged charge whose max hit scales with metal tier, stunning + // the target on a real cast so it can't act for a few ticks (as in the live game). + // Iron Within - the iron titan pounds its target three times on the spot: melee up close + // (max 230 each), magic bolts from afar (max 220 each). + FamiliarSpecialMoves.npc("iron_titan_familiar") { target -> + val cast = titanAssault(target, hits = 3, meleeMax = 230, farType = "magic", farMax = 220) + if (cast) { + follower?.anim("iron_within") + follower?.gfx("iron_within") + } + cast + } + + // Steel of Legends - the steel titan strikes four times: melee up close, ranged from afar, + // up to 244 each. The blade-storm graphic plays on the target alone. + FamiliarSpecialMoves.npc("steel_titan_familiar") { target -> + val cast = titanAssault(target, hits = 4, meleeMax = 244, farType = "range", farMax = 244) + if (cast) { + follower?.anim("steel_of_legends") + target.gfx("steel_of_legends") + } + cast + } + + val bullRush = mapOf( + "bronze_minotaur_familiar" to 80, + "iron_minotaur_familiar" to 100, + "steel_minotaur_familiar" to 120, + "mithril_minotaur_familiar" to 160, + "adamant_minotaur_familiar" to 200, + "rune_minotaur_familiar" to 240, + ) + for ((familiar, maxHit) in bullRush) { + FamiliarSpecialMoves.npc(familiar) { target -> + familiarSpecialHit(target, maxHit = maxHit, type = "range", anim = "bull_rush", sourceGfx = "bull_rush", projectile = "bull_rush_proj") { hit -> + // The charge only stuns about a third of the time, and only once it lands - the + // stun fires with the projectile's impact, not the instant the special is cast. + if (random.nextInt(BULL_RUSH_STUN_CHANCE) == 0) { + follower?.stun(hit, BULL_RUSH_STUN_TICKS) + } + } + } + } + } + + /** + * The Explode special: the giant chinchompa detonates around itself for up to nine hits, then is + * consumed by the blast a few ticks later (once the hits have landed). Returns false, charging + * nothing, when there is nothing nearby to hit. + */ + private fun Player.chinchompaExplode(): Boolean { + val cast = familiarAoeSpecial(maxTargets = 9, maxHit = 120, radius = 6, anim = "chinchompa_explode", sourceGfx = "chinchompa_explode") + if (cast) { + follower?.say("Squeak!") + // Let the hits land (they self-delay ~3 ticks) before the familiar vanishes. + queue("chinchompa_explode", 4) { dismissFamiliar() } + } + return cast + } + + /** + * ~1 in 10 of the attacks landed on the giant chinchompa make it auto-fire Explode. Unlike the + * cast button this is free: it detonates directly, bypassing the scroll/points gate, so no scroll, + * points, or owner scroll-throw flourish are involved. + */ + private fun autoExplode(familiar: NPC, damage: CombatDamage) { + if (random.nextInt(10) != 0) { + return + } + val owner = Players.indexed(familiar["owner_index", -1]) ?: return + if (owner.follower?.index != familiar.index) { + return + } + // Several hits can land on the familiar in quick succession; the special cooldown clock keeps + // those from each firing their own detonation, as it does for the cast-button path. + if (owner.hasClock("familiar_special_delay")) { + return + } + owner.start("familiar_special_delay", 3) + owner.chinchompaExplode() + } + + /** Drains [skill] on [target] by [amount] (and/or [multiplier]) but only on a real cast (`this` true). */ + private fun Boolean.alsoDrain(target: Character, skill: Skill, amount: Int = 0, multiplier: Double = 0.0): Boolean { + if (this) { + target.levels.drain(skill, amount, multiplier) + } + return this + } + + /** Ebon Thunder's on-impact effect: saps a tenth of a player target's remaining special energy. */ + private fun drainSpecialEnergy(target: Character) { + if (target is Player) { + target.specialAttackEnergy -= target.specialAttackEnergy / 10 + } + } + + /** Famine's food theft: turns a random edible item in [target]'s inventory rotten. */ + private fun rotFood(target: Player): Boolean { + val slots = target.inventory.items.withIndex().filter { !it.value.isEmpty() && it.value.def.options.contains("Eat") }.map { it.index } + if (slots.isEmpty()) { + return false + } + val slot = slots[random.nextInt(slots.size)] + target.inventory.replace(slot, target.inventory[slot].id, "rotten_food") + return true + } + + /** + * Famine: the locust swarm devours a player target's food (turning a random piece rotten). The + * swarm itself does no damage - the familiar just engages the target afterwards. + */ + private fun Player.famine(target: Character): Boolean { + if (!familiarCanSpecial(target)) { + return false + } + val familiar = follower ?: return false + familiar.watch(target) + familiar.anim("famine") + familiar.gfx("famine") + val flight = familiar.shoot("famine_proj", target) + target.queue("famine_gfx", CLIENT_TICKS.toTicks(flight)) { target.gfx("famine_hit") } + if (target is Player && rotFood(target)) { + message("Your locust devours some of ${target.name}'s food.") + target.message("$name's locust devours some of your food!") + } + if (familiar !in target.attackers) { + target.attackers.add(familiar) + } + commandFamiliarAttack(target, silent = true) + return true + } + + /** + * Hits up to [maxTargets] other attackable npcs within [radius] tiles of [target] with their own + * projectile and impact graphic (Dust Cloud, Acorn Missile). + */ + private fun Player.familiarSplash(target: Character, maxTargets: Int, maxHit: Int, radius: Int, projectile: String? = null, targetGfx: String? = null) { + val familiar = follower ?: return + for (splashed in nearbyAttackableNpcs(target.tile, radius).filter { it != target }.take(maxTargets)) { + val flight = projectile?.let { familiar.shoot(it, splashed) } + val damage = random.nextInt(maxHit + 1) + if (flight != null) { + familiar.hit(splashed, offensiveType = "magic", damage = damage, delay = flight) + targetGfx?.let { gfx -> splashed.queue("familiar_special_gfx", CLIENT_TICKS.toTicks(flight)) { splashed.gfx(gfx) } } + } else { + familiar.hit(splashed, offensiveType = "magic", damage = damage) + targetGfx?.let { splashed.gfx(it) } + } + } + } + + /** + * Iron Within / Steel of Legends: the titan batters its target with a volley of [hits] on the + * spot - melee up close (max [meleeMax]) or [farType] from afar (max [farMax]) - then keeps + * fighting. Two hits land per swing, the melee volley a tick sooner than the ranged/magic one. + */ + private fun Player.titanAssault(target: Character, hits: Int, meleeMax: Int, farType: String, farMax: Int): Boolean { + if (!familiarCanSpecial(target)) { + return false + } + val familiar = follower ?: return false + familiar.watch(target) + val melee = familiar.tile.distanceTo(target) <= 1 + val type = if (melee) "melee" else farType + val maxHit = if (melee) meleeMax else farMax + val baseDelay = if (melee) 30 else 60 + repeat(hits) { swing -> + familiar.hit(target, offensiveType = type, damage = random.nextInt(maxHit + 1), delay = baseDelay + (swing / 2) * 30) + } + if (familiar !in target.attackers) { + target.attackers.add(familiar) + } + commandFamiliarAttack(target, silent = true) + return true + } +} diff --git a/game/src/main/kotlin/content/skill/summoning/FamiliarHeal.kt b/game/src/main/kotlin/content/skill/summoning/FamiliarHeal.kt new file mode 100644 index 0000000000..48145dc9ab --- /dev/null +++ b/game/src/main/kotlin/content/skill/summoning/FamiliarHeal.kt @@ -0,0 +1,44 @@ +package content.skill.summoning + +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.entity.character.player.skill.Skill +import world.gregs.voidps.engine.timer.Timer +import world.gregs.voidps.engine.timer.toTicks +import java.util.concurrent.TimeUnit + +/** + * Life points each healing familiar restores to its owner every 15 seconds while summoned. Read live + * from the active follower, so the heal follows whichever familiar is out and stops the moment it's + * dismissed (matching how [FAMILIAR_BOOSTS] gates its passive boosts). + */ +val FAMILIAR_HEAL_LIFEPOINTS: Map = mapOf( + "void_spinner_familiar" to 100, + "bunyip_familiar" to 20, +) + +/** Graphic played on the owner when the familiar's passive heal actually restores life points. */ +private val FAMILIAR_HEAL_GFX: Map = mapOf( + "bunyip_familiar" to "bunyip_heal", +) + +/** + * Healing familiars (Void spinner, ...) restore their owner's life points at a fixed 15s interval. + * [levels.restore] no-ops once the owner is at full life points, so the timer keeps running + * harmlessly until they take damage again. + * + * The heal timer is started on summon in [summonFamiliar] (when the familiar heals) and stopped on + * dismiss. + */ +class FamiliarHeal : Script { + init { + timerStart("familiar_heal") { TimeUnit.SECONDS.toTicks(15) } + + timerTick("familiar_heal") { + val amount = FAMILIAR_HEAL_LIFEPOINTS[follower?.id] ?: return@timerTick Timer.CANCEL + if (levels.restore(Skill.Constitution, amount) > 0) { + FAMILIAR_HEAL_GFX[follower?.id]?.let { gfx(it) } + } + Timer.CONTINUE + } + } +} diff --git a/game/src/main/kotlin/content/skill/summoning/FamiliarSpecialHelpers.kt b/game/src/main/kotlin/content/skill/summoning/FamiliarSpecialHelpers.kt new file mode 100644 index 0000000000..a39c5f206f --- /dev/null +++ b/game/src/main/kotlin/content/skill/summoning/FamiliarSpecialHelpers.kt @@ -0,0 +1,185 @@ +package content.skill.summoning + +import content.area.wilderness.inSingleCombat +import content.entity.combat.Target +import content.entity.combat.attacker +import content.entity.combat.attackers +import content.entity.combat.hit.hit +import content.entity.proj.shoot +import world.gregs.voidps.engine.client.message +import world.gregs.voidps.engine.entity.character.Character +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.distanceTo +import world.gregs.voidps.engine.map.spiral +import world.gregs.voidps.engine.queue.queue +import world.gregs.voidps.engine.timer.CLIENT_TICKS +import world.gregs.voidps.type.Tile +import world.gregs.voidps.type.random + +/** + * Shared building blocks for the scroll-driven familiar special moves (the cast-button specials). + * Each is meant to be called from inside a [castFamiliarSpecial] block and to return whether the + * move actually happened, so the cast helper only charges a scroll/points on a real cast. + * + * Visual ids ([anim]/[sourceGfx]/[projectile]/[targetGfx]) are the named entries added to + * `summoning_special.anims.toml` / `summoning_special.gfx.toml`, which in turn map to the numeric cache ids. + */ + +/** + * Validates [target] as a thing the current follower may attack (PvP rules for players, + * single-combat rules for npcs), mirroring [commandFamiliarAttack]'s pre-checks. Returns false and + * (unless [silent]) messages when the target is invalid. + */ +fun Player.familiarCanSpecial(target: Character, silent: Boolean = false): Boolean { + val familiar = follower ?: return false + if (familiar == target || this == target) { + return false + } + if (!familiar.canFight()) { + if (!silent) message("Your familiar cannot fight.") + return false + } + when (target) { + // The owner's own PvP rules (wilderness or PvP area, level range) decide who the familiar + // may be sent at. + is Player -> if (!Target.attackable(this, target, message = !silent)) { + return false + } + is NPC -> if (!Target.attackable(familiar, target, message = !silent)) { + return false + } + } + return true +} + +/** + * The standard combat special: the follower faces [target], plays its special visuals, optionally + * fires a [projectile], deals a single hit of `random(0..maxHit)` of [type], then keeps fighting + * the target (so the special doubles as an opening attack, as in the live game). Returns true on a + * real cast; false (charging nothing) if the target is invalid. + * + * [onLand] runs once the hit reaches the target - queued to the projectile's flight time when there + * is a [projectile], or immediately otherwise - for on-impact effects (e.g. a stun) that must wait + * for the shot to arrive rather than firing the instant the special is cast. + */ +fun Player.familiarSpecialHit( + target: Character, + maxHit: Int, + type: String = "magic", + anim: String? = null, + sourceGfx: String? = null, + projectile: String? = null, + targetGfx: String? = null, + engage: Boolean = true, + onLand: ((Character) -> Unit)? = null, +): Boolean { + if (!familiarCanSpecial(target)) { + return false + } + val familiar = follower ?: return false + familiar.watch(target) + anim?.let { familiar.anim(it) } + sourceGfx?.let { familiar.gfx(it) } + // shoot returns the projectile's flight time (client ticks); land the hit and impact gfx when + // it arrives rather than on hit()'s fixed magic delay, so damage lands with the projectile. + val flight = projectile?.let { familiar.shoot(it, target) } + val damage = random.nextInt(maxHit + 1) + if (flight != null) { + familiar.hit(target, offensiveType = type, damage = damage, delay = flight) + val landDelay = CLIENT_TICKS.toTicks(flight) + targetGfx?.let { gfx -> target.queue("familiar_special_gfx", landDelay) { target.gfx(gfx) } } + // hit() lands its damage a tick after the projectile's flight; run onLand on that same tick + // (queued after the hit) so an on-impact stun - which sets the target's "delay" and would + // otherwise block the still-pending hit's queue - only fires once the damage has landed. + onLand?.let { land -> target.queue("familiar_special_land", landDelay + 1) { land(target) } } + } else { + familiar.hit(target, offensiveType = type, damage = damage) + targetGfx?.let { target.gfx(it) } + onLand?.invoke(target) + } + if (target.inSingleCombat) { + target.attackers.clear() + target.attacker = familiar + } + if (familiar !in target.attackers) { + target.attackers.add(familiar) + } + if (engage) { + commandFamiliarAttack(target, silent = true) + } + return true +} + +/** + * An area special with no picked target: the follower hits up to [maxTargets] attackable npcs within + * [radius] tiles of itself (Fireball Assault, Sandstorm). Each takes a single `random(0..maxHit)` hit + * of [type], timed to the projectile flight when a [projectile] is given, and the familiar engages the + * first of them. Returns false (charging nothing) when there is nothing valid nearby. + */ +fun Player.familiarAoeSpecial( + maxTargets: Int, + maxHit: Int, + radius: Int = 1, + type: String = "magic", + anim: String? = null, + sourceGfx: String? = null, + projectile: String? = null, + targetGfx: String? = null, +): Boolean { + val familiar = follower ?: return false + val targets = nearbyAttackableNpcs(familiar.tile, radius).take(maxTargets) + if (targets.isEmpty()) { + message("There is nothing nearby for your familiar to attack.") + return false + } + anim?.let { familiar.anim(it) } + sourceGfx?.let { familiar.gfx(it) } + for (target in targets) { + val flight = projectile?.let { familiar.shoot(it, target) } + val damage = random.nextInt(maxHit + 1) + if (flight != null) { + familiar.hit(target, offensiveType = type, damage = damage, delay = flight) + targetGfx?.let { gfx -> target.queue("familiar_special_gfx", CLIENT_TICKS.toTicks(flight)) { target.gfx(gfx) } } + } else { + familiar.hit(target, offensiveType = type, damage = damage) + targetGfx?.let { target.gfx(it) } + } + } + commandFamiliarAttack(targets.first(), silent = true) + return true +} + +/** + * All npcs the follower may attack whose bounds are within [radius] tiles of [center], nearest + * first. Scanned per zone (not per tile) so multi-tile npcs are found by their bodies, not just + * their anchor tile - a large monster standing beside the familiar still counts. + */ +fun Player.nearbyAttackableNpcs(center: Tile, radius: Int): List { + val targets = mutableListOf() + // Zones are 8x8; a large npc's anchor can sit up to its size outside the radius, so over-scan. + for (zone in center.zone.spiral((radius + 15) / 8)) { + for (character in NPCs.at(zone)) { + if (character in targets || center.distanceTo(character) > radius || !familiarCanSpecial(character, silent = true)) { + continue + } + targets.add(character) + } + } + targets.sortBy { center.distanceTo(it) } + return targets +} + +/** + * A self/owner buff special with no target: plays the follower's special visuals and runs [effect]. + * Always counts as a successful cast. + */ +fun Player.familiarSelfSpecial(anim: String? = null, sourceGfx: String? = null, playerGfx: String? = null, effect: Player.() -> Unit): Boolean { + val familiar = follower ?: return false + anim?.let { familiar.anim(it) } + sourceGfx?.let { familiar.gfx(it) } + playerGfx?.let { gfx(it) } + effect() + return true +} diff --git a/game/src/main/kotlin/content/skill/summoning/FamiliarSpecialMove.kt b/game/src/main/kotlin/content/skill/summoning/FamiliarSpecialMove.kt new file mode 100644 index 0000000000..0b51a6a84f --- /dev/null +++ b/game/src/main/kotlin/content/skill/summoning/FamiliarSpecialMove.kt @@ -0,0 +1,65 @@ +package content.skill.summoning + +import content.entity.player.dialogue.Happy +import content.entity.player.dialogue.Quiz +import content.entity.player.dialogue.type.npc +import content.entity.player.dialogue.type.player +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.client.message +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.entity.character.player.skill.Skill +import kotlin.math.ceil + +/** + * Familiar special-move effects, triggered from the familiar's "Special" npc right-click option. + * + * - Dreadfowl - asks to fight, plays its special animation/graphics and boosts Farming by 1. + * - Compost mound - boosts Farming by ceil(1 + level * 0.02). + * + * The Farming boost doesn't stack with any other Farming boost (e.g. a garden pie) and is removed + * when the familiar is dismissed or dies (see [dismissFamiliar]). + */ +class FamiliarSpecialMove : Script { + init { + npcOperate("Special", "dreadfowl_familiar") { (target) -> + if (target != follower) { + return@npcOperate + } + player("Can you boost my Farming stat please?") + npc("Bwuck cluck ckuck?
(If I do, will you let me fight?)") + if (farmingAlreadyBoosted()) { + return@npcOperate + } + gfx("dreadfowl_special_player") + follower?.anim("dreadfowl_special") + follower?.gfx("dreadfowl_special") + boostFarming(1) + } + + npcOperate("Special", "compost_mound_familiar") { (target) -> + if (target != follower) { + return@npcOperate + } + if (farmingAlreadyBoosted()) { + return@npcOperate + } + boostFarming(ceil(1 + levels.getMax(Skill.Farming) * 0.02).toInt()) + } + } + + /** Familiar Farming boosts don't stack with any other Farming boost (e.g. a garden pie). */ + private fun Player.farmingAlreadyBoosted(): Boolean { + if (levels.get(Skill.Farming) > levels.getMax(Skill.Farming)) { + message("Your Farming stat cannot be boosted this way right now.") + return true + } + return false + } + + private fun Player.boostFarming(amount: Int) { + levels.boost(Skill.Farming, amount) + // Record the boosted level so dismissFamiliar can strip the boost when the familiar + // leaves - only if the level hasn't since changed (decayed or replaced). + set("familiar_farming_boost", levels.get(Skill.Farming)) + } +} diff --git a/game/src/main/kotlin/content/skill/summoning/FamiliarSpecialMoves.kt b/game/src/main/kotlin/content/skill/summoning/FamiliarSpecialMoves.kt new file mode 100644 index 0000000000..2429c856db --- /dev/null +++ b/game/src/main/kotlin/content/skill/summoning/FamiliarSpecialMoves.kt @@ -0,0 +1,179 @@ +package content.skill.summoning + +import content.entity.combat.target +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.client.message +import world.gregs.voidps.engine.entity.character.npc.NPC +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.entity.item.Item +import world.gregs.voidps.engine.entity.item.floor.FloorItem +import world.gregs.voidps.engine.entity.obj.GameObject + +/** + * Registry + dispatcher for the scroll-driven familiar special moves triggered from the cast button + * on the `familiar_details` interface. + * + * The interface defines a distinct `cast_` component per move, but several familiars share a + * move name (Bull Rush x6, Petrifying Gaze x7, Titan's Constitution x3, Call to Arms x4), so moves + * are keyed on the **follower's npc id** rather than the component - the follower unambiguously + * identifies the familiar and therefore its special. The four maps mirror the engine's interaction + * kinds; a familiar registers in exactly the one matching how its special is triggered: + * - [instant] : self/AoE specials sent as a plain interface option + * - [npcTarget] : combat specials where the player then clicks an npc + * - [playerTarget] : combat specials where the player then clicks another player + * - [objectTarget] : specials where the player then clicks scenery (chop a tree, fill a bin, ...) + * - [itemTarget] : specials where the player uses the cast button on an inventory item + * - [floorItemTarget] : specials where the player then clicks a ground item (phoenix's ashes) + * + * Item-target specials (a fish/egg/bar) can also be triggered by using the item on the familiar, + * wired separately with `itemOnNPCOperate` in their own scripts (operate, not approach, so their + * exact item:npc key beats the beast-of-burden store's wildcard). Both surfaces run the same block. + * + * Each block returns whether the move actually happened so [castFamiliarSpecial] only spends a + * scroll + points on a real cast. + */ +object FamiliarSpecialMoves { + val instant = HashMap Boolean>() + val npcTarget = HashMap Boolean>() + val playerTarget = HashMap Boolean>() + val objectTarget = HashMap Boolean>() + val itemTarget = HashMap Boolean>() + val floorItemTarget = HashMap Boolean>() + + fun instant(vararg familiars: String, block: Player.() -> Boolean) { + for (familiar in familiars) instant[familiar] = block + } + + fun npc(vararg familiars: String, block: Player.(NPC) -> Boolean) { + for (familiar in familiars) npcTarget[familiar] = block + } + + fun player(vararg familiars: String, block: Player.(Player) -> Boolean) { + for (familiar in familiars) playerTarget[familiar] = block + } + + fun obj(vararg familiars: String, block: Player.(GameObject) -> Boolean) { + for (familiar in familiars) objectTarget[familiar] = block + } + + fun item(vararg familiars: String, block: Player.(Item) -> Boolean) { + for (familiar in familiars) itemTarget[familiar] = block + } + + fun floorItem(vararg familiars: String, block: Player.(FloorItem) -> Boolean) { + for (familiar in familiars) floorItemTarget[familiar] = block + } +} + +/** + * Single dispatcher routing every special-move cast to the move registered for the current follower. + * The move can be cast from two surfaces - the follower-details tab's cast button + * (`familiar_details:cast_*`) and the minimap summoning orb's "Cast " right/left-click option + * (`summoning_orb:*cast`) - so both are wired. Combat/scenery casts come through the approach hooks + * (the player picks a world target after clicking the button); self/AoE casts come through the plain + * interface option. + */ +class FamiliarSpecialMovesDispatch : Script { + init { + // Follower-details tab cast button. + interfaceOption("*", "familiar_details:cast_*") { + castButton() + } + + onNPCApproach("familiar_details:cast_*", "*") { (target) -> + approachRange(16, update = false) + val id = follower?.id ?: return@onNPCApproach + val block = FamiliarSpecialMoves.npcTarget[id] ?: return@onNPCApproach + castFamiliarSpecial { block(target) } + } + + onPlayerApproach("familiar_details:cast_*") { (target) -> + approachRange(16, update = false) + val id = follower?.id ?: return@onPlayerApproach + val block = FamiliarSpecialMoves.playerTarget[id] ?: return@onPlayerApproach + castFamiliarSpecial { block(target) } + } + + onObjectApproach("familiar_details:cast_*", "*") { (target) -> + approachRange(16, update = false) + val id = follower?.id ?: return@onObjectApproach + val block = FamiliarSpecialMoves.objectTarget[id] ?: return@onObjectApproach + castFamiliarSpecial { block(target) } + } + + // Cast button used on an inventory item (Winter Storage, Immense Heat, ...). + onItem("familiar_details:cast_*") { item, _ -> + val id = follower?.id ?: return@onItem + val block = FamiliarSpecialMoves.itemTarget[id] ?: return@onItem + castFamiliarSpecial { block(item) } + } + + // Cast button used on a ground item (Rise from the Ashes' ashes). + onFloorItemApproach("familiar_details:cast_*") { (target) -> + approachRange(16, update = false) + val id = follower?.id ?: return@onFloorItemApproach + val block = FamiliarSpecialMoves.floorItemTarget[id] ?: return@onFloorItemApproach + castFamiliarSpecial { block(target) } + } + + // Minimap summoning orb "Cast " option - one `cast_` component per move. + interfaceOption("*", "summoning_orb:cast_*") { + castButton() + } + + onNPCApproach("summoning_orb:cast_*", "*") { (target) -> + approachRange(16, update = false) + val id = follower?.id ?: return@onNPCApproach + val block = FamiliarSpecialMoves.npcTarget[id] ?: return@onNPCApproach + castFamiliarSpecial { block(target) } + } + + onPlayerApproach("summoning_orb:cast_*") { (target) -> + approachRange(16, update = false) + val id = follower?.id ?: return@onPlayerApproach + val block = FamiliarSpecialMoves.playerTarget[id] ?: return@onPlayerApproach + castFamiliarSpecial { block(target) } + } + + onObjectApproach("summoning_orb:cast_*", "*") { (target) -> + approachRange(16, update = false) + val id = follower?.id ?: return@onObjectApproach + val block = FamiliarSpecialMoves.objectTarget[id] ?: return@onObjectApproach + castFamiliarSpecial { block(target) } + } + + onItem("summoning_orb:cast_*") { item, _ -> + val id = follower?.id ?: return@onItem + val block = FamiliarSpecialMoves.itemTarget[id] ?: return@onItem + castFamiliarSpecial { block(item) } + } + + onFloorItemApproach("summoning_orb:cast_*") { (target) -> + approachRange(16, update = false) + val id = follower?.id ?: return@onFloorItemApproach + val block = FamiliarSpecialMoves.floorItemTarget[id] ?: return@onFloorItemApproach + castFamiliarSpecial { block(target) } + } + } + + /** + * A plain click on the cast button: instant specials fire directly; combat specials auto-fire + * at the familiar's current foe (or the owner's), as in the live game - clicking a target + * after the button still works through the approach hooks. + */ + private fun Player.castButton() { + val id = follower?.id ?: return + val instant = FamiliarSpecialMoves.instant[id] + if (instant != null) { + castFamiliarSpecial { instant() } + return + } + val combat = FamiliarSpecialMoves.npcTarget[id] ?: return + val enemy = (follower?.target ?: target) as? NPC + if (enemy == null) { + message("Your familiar has no target to attack.") + return + } + castFamiliarSpecial { combat(enemy) } + } +} diff --git a/game/src/main/kotlin/content/skill/summoning/FamiliarTeleport.kt b/game/src/main/kotlin/content/skill/summoning/FamiliarTeleport.kt new file mode 100644 index 0000000000..75f78973aa --- /dev/null +++ b/game/src/main/kotlin/content/skill/summoning/FamiliarTeleport.kt @@ -0,0 +1,22 @@ +package content.skill.summoning + +import content.area.wilderness.wildernessLevel +import world.gregs.voidps.engine.client.message +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.entity.character.player.Teleport +import world.gregs.voidps.type.Tile + +/** Familiar teleports (hunting cats, lava titan) refuse above this wilderness level, like jewellery. */ +private const val FAMILIAR_TELEPORT_WILDERNESS_CAP = 20 + +/** + * Teleports the owner to a familiar's home hunting ground ([tile]), refusing above level-20 + * wilderness. [name] is the familiar as it reads in the refusal message ("the graahk", ...). + */ +fun Player.familiarTeleport(tile: Tile, name: String): Boolean { + if (wildernessLevel > FAMILIAR_TELEPORT_WILDERNESS_CAP) { + message("You cannot teleport with $name above level $FAMILIAR_TELEPORT_WILDERNESS_CAP wilderness.") + return false + } + return Teleport.teleport(this, tile, "jewellery") +} diff --git a/game/src/main/kotlin/content/skill/summoning/FamiliarUtilitySpecials.kt b/game/src/main/kotlin/content/skill/summoning/FamiliarUtilitySpecials.kt new file mode 100644 index 0000000000..3b3d21ea64 --- /dev/null +++ b/game/src/main/kotlin/content/skill/summoning/FamiliarUtilitySpecials.kt @@ -0,0 +1,324 @@ +package content.skill.summoning + +import content.entity.combat.attackers +import content.entity.combat.hit.hit +import content.entity.gfx.areaGfx +import content.entity.player.bank.bank +import content.entity.proj.shoot +import org.rsmod.game.pathfinder.StepValidator +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.client.message +import world.gregs.voidps.engine.client.variable.hasClock +import world.gregs.voidps.engine.client.variable.start +import world.gregs.voidps.engine.entity.character.move.tele +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.entity.item.floor.FloorItems +import world.gregs.voidps.engine.get +import world.gregs.voidps.engine.inv.add +import world.gregs.voidps.engine.inv.beastOfBurden +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.engine.inv.remove +import world.gregs.voidps.engine.map.collision.canFit +import world.gregs.voidps.engine.map.spiral +import world.gregs.voidps.engine.queue.queue +import world.gregs.voidps.engine.timer.CLIENT_TICKS +import world.gregs.voidps.type.random + +// A queued action fires initialDelay + 1 ticks after it is added (and a dropped floor item becomes +// visible the tick after that), so these delays sit one under the intended tick counts. The macaw +// searches for ~3 seconds (a 5-tick wait) before its drop graphic plays; the herb lands soon after. +/** Delay before the Herbcall drop graphic plays - fires 5 ticks (~3 seconds) after the cast. */ +private const val HERBCALL_SEARCH_DELAY = 4 + +/** Delay before the herb lands - fires 6 ticks after the cast, part-way through the drop graphic. */ +private const val HERBCALL_DROP_DELAY = 5 + +/** Delay before the fruit bat's drop animation - it flies up into the trees for ~4 ticks first. */ +private const val FRUITFALL_DROP_DELAY = 3 + +/** Delay before the fruits land - a tick into the drop animation and its tile splashes. */ +private const val FRUITFALL_LAND_DELAY = 4 + +/** + * Utility familiar specials: send-the-familiar-to-fight, call/ambush, ground-drop foragers, and + * banking. Combat-engage moves register as [FamiliarSpecialMoves.npc]; the rest are instant casts. + * Pack Yak's Winter Storage picks an inventory item, so it registers as [FamiliarSpecialMoves.item] + * (the cast button used on the item to bank). + */ +class FamiliarUtilitySpecials : Script { + + private val herbcallHerbs = listOf( + "grimy_guam", + "grimy_marrentill", + "grimy_tarromin", + "grimy_harralander", + "grimy_ranarr", + "grimy_toadflax", + "grimy_spirit_weed", + "grimy_irit", + "grimy_avantoe", + "grimy_kwuarm", + "grimy_snapdragon", + "grimy_cadantine", + "grimy_lantadyme", + "grimy_dwarf_weed", + "grimy_torstol", + ) + private val fruit = listOf("orange", "banana", "lemon", "lime", "pineapple") + private val rawFish = listOf("raw_shrimps", "raw_cod", "raw_bass", "raw_mackerel") + + init { + // Pester - the special simply sends the mosquito at the target. + FamiliarSpecialMoves.npc("spirit_mosquito_familiar") { target -> + if (!familiarCanSpecial(target)) { + return@npc false + } + commandFamiliarAttack(target, silent = true) + true + } + + // Goad - the graahk gores its target with two heavy melee strikes, then keeps up the fight. + FamiliarSpecialMoves.npc("spirit_graahk_familiar") { target -> + if (!familiarCanSpecial(target)) { + return@npc false + } + val graahk = follower ?: return@npc false + graahk.watch(target) + graahk.anim("goad") + repeat(2) { + graahk.hit(target, offensiveType = "melee", damage = random.nextInt(121), delay = 0) + } + if (graahk !in target.attackers) { + target.attackers.add(graahk) + } + commandFamiliarAttack(target, silent = true) + true + } + + // Ambush - the kyatt pounces from nowhere onto its target, landing one heavy strike. + FamiliarSpecialMoves.npc("spirit_kyatt_familiar") { target -> + if (!familiarCanSpecial(target)) { + return@npc false + } + val kyatt = follower ?: return@npc false + val validator: StepValidator = get() + val landing = target.tile.spiral(kyatt.size).asSequence().firstOrNull { + it != target.tile && validator.canFit(it, kyatt.collision, kyatt.size, kyatt.blockMove) + } + if (landing == null) { + message("Your kyatt can't find a place to land on that target right now.") + return@npc false + } + kyatt.tele(landing, clearMode = false) + kyatt.watch(target) + kyatt.anim("ambush") + kyatt.gfx("ambush") + kyatt.hit(target, offensiveType = "melee", damage = random.nextInt(225), delay = 0) + if (kyatt !in target.attackers) { + target.attackers.add(kyatt) + } + commandFamiliarAttack(target, silent = true) + true + } + + // Call to Arms - the Void familiars teleport their owner to the Void Knights' Outpost. + FamiliarSpecialMoves.instant( + "void_ravager_familiar", + "void_shifter_familiar", + "void_spinner_familiar", + "void_torcher_familiar", + ) { + queue("call_to_arms", 1) { + anim("call_to_arms") + gfx("call_to_arms") + } + queue("call_to_arms_land", 3) { + tele(2659, 2658, 0) + anim("call_to_arms_land") + gfx("call_to_arms_land") + } + true + } + + // Cheese Feast - the albino rat produces 4 cheese into its own inventory + FamiliarSpecialMoves.instant("albino_rat_familiar") { + ensureBeastOfBurdenInventory() + val free = beastOfBurdenCapacity - beastOfBurden.items.count { it.isNotEmpty() } + if (free < 4) { + message("Your familiar is too full to collect items.") + return@instant false + } + familiarSelfSpecial(anim = "cheese_feast", sourceGfx = "cheese_feast") { + beastOfBurden.add("cheese", 4) + if (interfaces.contains("beast_of_burden")) { + syncBeastOfBurdenInterface() + } + } + } + + // Egg Spawn - the spirit spider scatters red spider eggs onto free tiles around the player + FamiliarSpecialMoves.instant("spirit_spider_familiar") { + val steps: StepValidator = get() + val eggTiles = tile.spiral(1).asSequence() + .filter { it != tile && steps.canFit(it, collision, 1, blockMove) } + .toList() + .shuffled() + .take(random.nextInt(5) + 1) + familiarSelfSpecial(anim = "egg_spawn") { + for (eggTile in eggTiles) { + areaGfx("egg_spawn", eggTile) + } + queue("egg_spawn", 1) { + for (eggTile in eggTiles) { + FloorItems.add(eggTile, "red_spiders_eggs", revealTicks = 120, disappearTicks = 30, owner = this) + } + } + } + } + + // Generate Compost - cast on an empty compost bin to fill it with compost (10% supercompost). + FamiliarSpecialMoves.obj("compost_mound_familiar") { obj -> + if (!obj.id.startsWith("farming_compost_bin_")) { + message("This scroll can only be used on an empty compost bin.") + return@obj false + } + val variable = obj.id.removePrefix("farming_") + if (get(variable, "empty") != "empty") { + message("This scroll can only be used on an empty compost bin.") + return@obj false + } + val familiar = follower ?: return@obj false + familiar.face(obj.tile) + familiar.anim("generate_compost") + familiar.gfx("generate_compost") + val flight = familiar.shoot("generate_compost_proj", obj.tile) + queue("generate_compost", CLIENT_TICKS.toTicks(flight)) { + areaGfx("generate_compost_bin", obj.tile) + val superCompost = random.nextInt(10) == 0 + this[variable] = if (superCompost) "supercompost_15" else "compost_15" + } + true + } + + // Herbcall - the macaw flaps up and searches for a herb; when the fly-up animation finishes it + // drops a random grimy herb where it hovered and is called back down to its owner. + FamiliarSpecialMoves.instant("macaw_familiar") { + val familiar = follower ?: return@instant false + if (hasClock("herbcall_delay")) { + message("You must wait one minute until using the macaw's special again.") + return@instant false + } + familiar.anim("herbcall") + val herb = herbcallHerbs[random.nextInt(herbcallHerbs.size)] + // Three seconds into the search the drop graphic bursts where the macaw hovers (an area + // gfx so the follow-up recall teleport doesn't drag it away)... + queue("herbcall_search", HERBCALL_SEARCH_DELAY) { + areaGfx("herbcall", familiar.tile) + } + // ...and a second later the herb lands where it hovered and the macaw is called back down. + queue("herbcall_drop", HERBCALL_DROP_DELAY) { + FloorItems.add(familiar.tile, herb, disappearTicks = 300, owner = this@instant) + callFollower() + } + start("herbcall_delay", 100) + true + } + + // Fruitfall - the bat flies up into the trees, then swoops down shaking loose up to six + // fruits, each landing with a splash on its own free tile around the owner, the first + // always a papaya. An unlucky cast (about 1 in 7) shakes down nothing at all but counts. + FamiliarSpecialMoves.instant("fruit_bat_familiar") { + val bat = follower ?: return@instant false + bat.anim("fruitfall_ascend") + this.gfx("fruitfall_ascend") + val validator: StepValidator = get() + val fruitTiles = tile.spiral(1).asSequence() + .filter { it != tile && validator.canFit(it, collision, 1, blockMove) } + .toList() + .shuffled() + .take(random.nextInt(7)) + queue("fruitfall_drop", FRUITFALL_DROP_DELAY) { + bat.anim("fruitfall") + for (fruitTile in fruitTiles) { + areaGfx("fruitfall_land", fruitTile) + } + } + queue("fruitfall_land", FRUITFALL_LAND_DELAY) { + for ((index, fruitTile) in fruitTiles.withIndex()) { + FloorItems.add(fruitTile, if (index == 0) "papaya_fruit" else fruit[random.nextInt(fruit.size)], disappearTicks = 120, owner = this) + } + } + true + } + + // Fish Rain - the ibis calls down a single low-level fish beside itself. + FamiliarSpecialMoves.instant("ibis_familiar") { + val ibis = follower ?: return@instant false + ibis.anim("fish_rain") + ibis.gfx("fish_rain") + queue("fish_rain", 2) { + FloorItems.add(ibis.tile, rawFish[random.nextInt(rawFish.size)], disappearTicks = 300, owner = this) + } + true + } + + // Essence Shipment - bank all rune and pure essence carried, the familiar's pack included. + FamiliarSpecialMoves.instant("abyssal_titan_familiar") { + val titan = follower ?: return@instant false + ensureBeastOfBurdenInventory() + val moved = bankAll("rune_essence") + bankAll("pure_essence") + + bankAllCarried("rune_essence") + bankAllCarried("pure_essence") + if (moved == 0) { + message("You have no essence for your familiar to bank.") + return@instant false + } + titan.anim("essence_shipment") + titan.gfx("essence_shipment") + message("Your familiar banks your essence.") + true + } + + // Winter Storage - bank the inventory item the player uses the Cast option on. Using an + // item directly on the yak stores it in its pack (the beast-of-burden handler always wins + // that click), so the special lives on the cast button alone. + FamiliarSpecialMoves.instant("pack_yak_familiar") { + message("To cast Winter Storage, use the Cast option on the item you wish to bank.") + false + } + FamiliarSpecialMoves.item("pack_yak_familiar") { item -> + if (item.id == "winter_storage_scroll") { + message("The yak refuses to bank the scroll powering its special.") + return@item false + } + if (!inventory.contains(item.id)) { + return@item false + } + inventory.remove(item.id, 1) + bank.add(item.id, 1) + follower?.gfx("winter_storage") + follower?.say("Baroo!") + message("Your pack yak sends the ${item.def.name.lowercase()} to your bank.") + true + } + } + + /** Moves every [id] from the inventory to the bank, returning how many were moved. */ + private fun Player.bankAll(id: String): Int { + val count = inventory.count(id) + if (count > 0) { + inventory.remove(id, count) + bank.add(id, count) + } + return count + } + + /** Moves every [id] from the familiar's pack to the bank, returning how many were moved. */ + private fun Player.bankAllCarried(id: String): Int { + val count = beastOfBurden.count(id) + if (count > 0) { + beastOfBurden.remove(id, count) + bank.add(id, count) + } + return count + } +} diff --git a/game/src/main/kotlin/content/skill/summoning/Forager.kt b/game/src/main/kotlin/content/skill/summoning/Forager.kt new file mode 100644 index 0000000000..e2df373b1f --- /dev/null +++ b/game/src/main/kotlin/content/skill/summoning/Forager.kt @@ -0,0 +1,99 @@ +package content.skill.summoning + +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.client.message +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.entity.character.player.skill.Skill +import world.gregs.voidps.engine.entity.character.player.skill.exp.exp +import world.gregs.voidps.engine.entity.item.drop.DropTables +import world.gregs.voidps.engine.inv.add +import world.gregs.voidps.engine.inv.beastOfBurden +import world.gregs.voidps.engine.timer.Timer +import world.gregs.voidps.engine.timer.toTicks +import java.util.concurrent.TimeUnit + +/** + * Fishing xp granted per fish the fishing foragers pull out of the water (a tenth of the catch xp, + * as in the live game). + */ +private val FORAGE_FISHING_XP: Map> = mapOf( + "granite_lobster_familiar" to mapOf("raw_swordfish" to 10.0, "raw_shark" to 11.0), + "ibis_familiar" to mapOf("raw_tuna" to 8.0, "raw_swordfish" to 10.0), +) + +/** + * Foragers that only roll while their owner is busy with a matching action - the ibis and granite + * lobster spear their fish alongside the owner, so they only produce while the owner is fishing. + */ +private val FORAGE_CONDITIONS: Map Boolean> = mapOf( + "ibis_familiar" to { softTimers.contains("fishing") }, + "granite_lobster_familiar" to { softTimers.contains("fishing") }, +) + +/** + * Forager familiars (magpie, ...) periodically gather loot from their own `forage_` + * drop table into the familiar's inventory while summoned. The player retrieves it with the + * familiar's Withdraw/Take option (reusing the beast-of-burden inventory + interface); they can't + * deposit their own items into a forager. + * + * The forage timer is started on summon in [summonFamiliar] and stopped on dismiss. + */ +class Forager(private val dropTables: DropTables) : Script { + init { + timerStart("forage") { TimeUnit.SECONDS.toTicks(30) } + + timerTick("forage") { + if (forageTable(dropTables) == null) { + return@timerTick Timer.CANCEL + } + if (FORAGE_CONDITIONS[follower?.id]?.invoke(this) != false) { + forage() + } + Timer.CONTINUE + } + + npcOperate("Withdraw", "*_familiar") { (target) -> + if (target == follower && forageTable(dropTables) != null) { + if (target[FAMILIAR_CHOPPING, false]) { + message(FAMILIAR_BUSY_MESSAGE) + return@npcOperate + } + openBeastOfBurden() + } + } + + npcOperate("Take", "*_familiar") { (target) -> + if (target == follower && forageTable(dropTables) != null) { + if (target[FAMILIAR_CHOPPING, false]) { + message(FAMILIAR_BUSY_MESSAGE) + return@npcOperate + } + openBeastOfBurden() + } + } + } + + private fun Player.forage() { + val table = forageTable(dropTables) ?: return + ensureBeastOfBurdenInventory() + if (beastOfBurden.items.count { it.isNotEmpty() } >= beastOfBurdenCapacity) { + return + } + var produced = false + for (drop in table.roll(player = this)) { + val item = drop.toItem() + if (item.isEmpty()) { + continue + } + beastOfBurden.add(item.id, item.amount) + FORAGE_FISHING_XP[follower?.id]?.get(item.id)?.let { exp(Skill.Fishing, it) } + produced = true + } + if (produced) { + message("Your familiar has produced an item.") + } + if (interfaces.contains("beast_of_burden")) { + syncBeastOfBurdenInterface() + } + } +} diff --git a/game/src/main/kotlin/content/skill/summoning/Summoning.kt b/game/src/main/kotlin/content/skill/summoning/Summoning.kt index 5d17496f68..3e529d7187 100644 --- a/game/src/main/kotlin/content/skill/summoning/Summoning.kt +++ b/game/src/main/kotlin/content/skill/summoning/Summoning.kt @@ -1,5 +1,6 @@ package content.skill.summoning +import content.entity.gfx.areaGfx import content.entity.player.dialogue.type.choice import content.skill.summoning.pet.callPet import content.skill.summoning.pet.dismissPet @@ -11,6 +12,8 @@ import world.gregs.voidps.cache.definition.data.NPCDefinition import world.gregs.voidps.engine.Script import world.gregs.voidps.engine.client.message import world.gregs.voidps.engine.client.sendScript +import world.gregs.voidps.engine.client.variable.hasClock +import world.gregs.voidps.engine.client.variable.start import world.gregs.voidps.engine.data.definition.EnumDefinitions import world.gregs.voidps.engine.data.definition.ItemDefinitions import world.gregs.voidps.engine.data.definition.NPCDefinitions @@ -23,8 +26,11 @@ 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.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.character.player.skill.level.Level.hasMax +import world.gregs.voidps.engine.entity.character.sound import world.gregs.voidps.engine.entity.item.Item +import world.gregs.voidps.engine.entity.item.drop.DropTables +import world.gregs.voidps.engine.entity.obj.GameObject import world.gregs.voidps.engine.get import world.gregs.voidps.engine.inv.inventory import world.gregs.voidps.engine.inv.remove @@ -61,18 +67,26 @@ fun Player.summonFamiliar(familiar: NPCDefinition, restart: Boolean) { return } - // TODO summoning energy - // message("You don't have enough summoning energy to summon this familiar.") - val familiarNpc = NPCs.add(familiar.stringId, tile) familiarNpc.mode = Follow(familiarNpc, this) queue("summon_familiar", 2) { follower = familiarNpc familiarNpc["owner_index"] = index + familiarNpc.anim("${familiarNpc.id.removeSuffix("_familiar")}_spawn") familiarNpc.gfx("summon_familiar_size_${familiarNpc.size}") + // Tells the cast button on familiar_details how many points this familiar's special costs. + set("summoning_special_points_needed", followerScrollId()?.let { ItemDefinitions.get(it)["special_points", 0] } ?: 0) + updateFamiliarPvpForm() updateFamiliarInterface() if (!restart) { timers.start("familiar_timer") + timers.start("summoning_drain") + } + if (get().get("forage_${familiarNpc.id.removeSuffix("_familiar")}") != null) { + timers.start("forage") + } + if (familiarNpc.id in FAMILIAR_HEAL_LIFEPOINTS) { + timers.start("familiar_heal") } } } @@ -83,6 +97,7 @@ fun Player.summonFamiliar(familiar: NPCDefinition, restart: Boolean) { */ fun Player.dismissFamiliar(removeNpc: Boolean = true) { dropBeastOfBurdenItems() + removeFamiliarFarmingBoost() if (removeNpc) { NPCs.remove(follower) } @@ -98,8 +113,27 @@ fun Player.dismissFamiliar(removeNpc: Boolean = true) { set("follower_details_chathead", 0) set("familiar_details_minutes_remaining", 0) set("familiar_details_seconds_remaining", 0) + set("summoning_special_points_needed", 0) } timers.stop("familiar_timer") + timers.stop("summoning_drain") + timers.stop("forage") + timers.stop("familiar_heal") +} + +/** + * Removes a familiar's Farming boost (dreadfowl/compost mound special) when the familiar leaves, + * but only if it's still the active boost - a decayed or later (e.g. garden pie) boost is left be. + */ +fun Player.removeFamiliarFarmingBoost() { + val boostedTo = get("familiar_farming_boost", 0) + if (boostedTo <= 0) { + return + } + if (levels.get(Skill.Farming) == boostedTo && boostedTo > levels.getMax(Skill.Farming)) { + levels.set(Skill.Farming, levels.getMax(Skill.Farming)) + } + clear("familiar_farming_boost") } /** @@ -110,7 +144,8 @@ fun Player.updateFamiliarInterface() { interfaces.open("familiar_details") set("follower_details_name", EnumDefinitions.get("summoning_familiar_ids").getKey(follower.def.id)) set("follower_details_chathead", follower.def.id) - set("follower_details_chathead_animation", follower.id) + // The varbit's map keys are the familiars' base names; an unmapped value transmits as -1. + set("follower_details_chathead_animation", follower.id.removeSuffix("_familiar")) } /** @@ -155,25 +190,121 @@ fun Player.callFollower() { } follower.tele(target, clearMode = false) follower.watch(this) + follower.anim("${follower.id.removeSuffix("_familiar")}_spawn") follower.gfx("summon_familiar_size_${follower.size}") if (follower.mode !is Follow) { follower.mode = Follow(follower, this) } + updateFamiliarPvpForm() } +/** Ticks the obelisk charge-up graphic plays for before the player performs the infuse animation. */ +private const val OBELISK_RENEW_GRAPHIC_TICKS = 2 + /** - * Restores summoning points at an obelisk, mirroring prayer altar behaviour. + * Restores summoning points at an obelisk, mirroring prayer altar behaviour. The obelisk charges + * up first ([summoning_renew_obelisk] graphic), then once it finishes the player performs the + * infuse animation and graphic together. */ -fun Player.renewSummoningPoints() { +suspend fun Player.renewSummoningPoints(obelisk: GameObject) { if (levels.getOffset(Skill.Summoning) >= 0) { message("You already have full summoning points.") } else { + areaGfx("summoning_renew_obelisk", obelisk.tile) + delay(OBELISK_RENEW_GRAPHIC_TICKS) levels.set(Skill.Summoning, levels.getMax(Skill.Summoning)) - anim("summoning_infuse") + anim("summoning_renew") + areaGfx("summoning_renew_player", tile) + sound("summoning_renew") message("You renew your summoning points at the obelisk.") } } +/** + * Runs a familiar special move costing [cost] special-move points. Special moves draw from the + * 0-60 special-move-points pool (regenerated each 30s by [SummoningTimers]) rather than from the + * player's summoning points. Warns and does nothing if the pool is too low; only spends if [action] + * runs, so callers should perform any other preconditions (ownership, situational checks) first. + */ +fun Player.useFamiliarSpecial(cost: Int, action: () -> Unit) { + val points = get("summoning_special_points_remaining", 0) + if (points < cost) { + message("Your familiar does not have enough special move points left.") + return + } + action() + set("summoning_special_points_remaining", (points - cost).coerceAtLeast(0)) +} + +/** + * The item id of the scroll matching the player's current follower, or null if the player has no + * familiar or no scroll is mapped for it. Resolved follower npc -> pouch (`summoning_familiar_ids`) + * -> scroll (`summoning_scroll_ids_2`). + */ +fun Player.followerScrollId(): Int? { + val familiar = follower ?: return null + val pouchId = EnumDefinitions.get("summoning_familiar_ids").getKey(familiar.def.id) + if (pouchId == -1) { + return null + } + return EnumDefinitions.get("summoning_scroll_ids_2").intOrNull(pouchId) +} + +/** + * Runs a scroll-driven familiar special move - the moves triggered from the cast button on the + * `familiar_details` interface. + * validates a 3-tick cooldown, sufficient special-move points, the familiar's scroll in the + * inventory, and that the familiar is within 15 tiles. [effect] performs the move and returns true + * on success; only then is one scroll removed, the points drained, the cooldown set, and the + * scroll's `use_experience` Summoning xp awarded. Returns false (consuming nothing) when [effect] + * soft-fails, e.g. an invalid target. + */ +fun Player.castFamiliarSpecial(effect: () -> Boolean) { + val familiar = follower ?: return + if (hasClock("familiar_special_delay")) { + return + } + val scrollId = followerScrollId() + if (scrollId == null) { + message("Your familiar doesn't have a special move.") + return + } + val scrollDef = ItemDefinitions.get(scrollId) + val cost = scrollDef["special_points", 0] + if (get("summoning_special_points_remaining", 0) < cost) { + message("Your familiar does not have enough special move points left.") + return + } + // A worn enchanted helm can supply the scroll when the inventory has run out. + val fromHelm = !inventory.contains(scrollDef.stringId) && enchantedHeadgearScroll() == scrollDef.stringId + if (!inventory.contains(scrollDef.stringId) && !fromHelm) { + message("You do not have enough scrolls left to do this special move.") + return + } + if (tile.distanceTo(familiar.tile) > 15) { + message("Your familiar is too far away to use that scroll. Call it closer or move nearer to it.") + return + } + // Set the cooldown before running the effect so a re-entrant dispatch in the same tick (see the + // approach handlers in FamiliarSpecialMovesDispatch) can't fire the special twice. Scroll/points + // are still only spent on a successful effect() below. + start("familiar_special_delay", 3) + if (!effect()) { + return + } + anim("summoning_special_cast") + gfx("summoning_special_cast") + sound("summoning_special_cast") + if (fromHelm) { + spendEnchantedHeadgearScroll() + } else { + inventory.remove(scrollDef.stringId, 1) + } + val points = get("summoning_special_points_remaining", 0) + set("summoning_special_points_remaining", (points - cost).coerceAtLeast(0)) + exp(Skill.Summoning, scrollDef["use_experience", 0.0]) +} + /** * Resets the familiar back to its maximum remaining time based on the summoned familiar. Removes the pouch from the player's * inventory and rewards xp. @@ -203,8 +334,8 @@ fun Player.renewFamiliar() { class Summoning : Script { init { - objectOperate("Renew-points") { - renewSummoningPoints() + objectOperate("Renew-points") { (target) -> + renewSummoningPoints(target) } itemOption("Summon", "*_pouch") { option -> @@ -212,7 +343,10 @@ class Summoning : Script { val familiarId = EnumDefinitions.get("summoning_familiar_ids").int(option.item.def.id) val summoningXp = option.item.def["summon_experience", 0.0] val familiar = NPCDefinitions.get(familiarId) - if (!has(Skill.Summoning, familiarLevel)) { + val summonCost = option.item.def["summon_points", 0] + // hasMax: the requirement is the player's real level - the current level is the + // summoning points pool, which drains as points are spent. + if (!hasMax(Skill.Summoning, familiarLevel)) { message("You are not high enough level to use this pouch.") return@itemOption } @@ -220,8 +354,14 @@ class Summoning : Script { message("You already have a follower.") return@itemOption } + if (levels.get(Skill.Summoning) < summonCost) { + message("You do not have enough summoning points to summon this familiar.") + return@itemOption + } summonFamiliar(familiar, false) inventory.remove(option.item.id) + levels.drain(Skill.Summoning, summonCost) + set("summoning_special_points_remaining", 60) exp(Skill.Summoning, summoningXp) } @@ -331,6 +471,7 @@ class Summoning : Script { variables.send("familiar_details_seconds_remaining") variables.send("follower_details_chathead_animation") timers.restart("familiar_timer") + timers.restart("summoning_drain") summonFamiliar(familiarDef, true) } diff --git a/game/src/main/kotlin/content/skill/summoning/SummoningDrain.kt b/game/src/main/kotlin/content/skill/summoning/SummoningDrain.kt new file mode 100644 index 0000000000..6e5c7a4d20 --- /dev/null +++ b/game/src/main/kotlin/content/skill/summoning/SummoningDrain.kt @@ -0,0 +1,58 @@ +package content.skill.summoning + +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.client.message +import world.gregs.voidps.engine.data.definition.EnumDefinitions +import world.gregs.voidps.engine.data.definition.ItemDefinitions +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.entity.character.player.skill.Skill +import world.gregs.voidps.engine.timer.Timer + +/** + * Drains the player's summoning points over their familiar's lifetime. Over the whole life the + * total drained equals `level required - summon cost + 1`, spread evenly across the familiar's + * `summoning_time_minutes` (100 ticks per minute), mirroring the live game. When points hit zero + * the familiar is dismissed early. + * + * Mirrors [content.skill.prayer.active.PrayerDrain]: a per-tick timer with an integer accumulator, + * draining a point each time the accumulator crosses the familiar's lifetime in ticks. + */ +class SummoningDrain : Script { + + init { + timerStart("summoning_drain") { 1 } + timerTick("summoning_drain", ::drain) + timerStop("summoning_drain") { + clear("summoning_drain_counter") + } + } + + fun drain(player: Player): Int { + val familiar = player.follower ?: return Timer.CANCEL + val maxTicks = familiar.def["summoning_time_minutes", 0] * 100 + if (maxTicks <= 0) { + return Timer.CONTINUE + } + val pouchId = EnumDefinitions.get("summoning_familiar_ids").getKey(familiar.def.id) + val levelRequired = EnumDefinitions.get("summoning_pouch_levels").int(pouchId) + val summonCost = ItemDefinitions.get(pouchId)["summon_points", 0] + val drainTotal = levelRequired - summonCost + 1 + if (drainTotal <= 0) { + return Timer.CONTINUE + } + + var counter = player["summoning_drain_counter", 0] + drainTotal + while (counter >= maxTicks) { + player.levels.drain(Skill.Summoning, 1) + counter -= maxTicks + if (player.levels.get(Skill.Summoning) == 0) { + player.clear("summoning_drain_counter") + player.message("You have run out of summoning points and your familiar vanishes.") + player.dismissFamiliar() + return Timer.CANCEL + } + } + player["summoning_drain_counter"] = counter + return Timer.CONTINUE + } +} diff --git a/game/src/main/kotlin/content/skill/summoning/SummoningTimers.kt b/game/src/main/kotlin/content/skill/summoning/SummoningTimers.kt index 9e3561b164..1f15f3ed1d 100644 --- a/game/src/main/kotlin/content/skill/summoning/SummoningTimers.kt +++ b/game/src/main/kotlin/content/skill/summoning/SummoningTimers.kt @@ -2,9 +2,11 @@ package content.skill.summoning import world.gregs.voidps.engine.Script import world.gregs.voidps.engine.client.message +import world.gregs.voidps.engine.data.definition.Rows import world.gregs.voidps.engine.entity.character.npc.NPCs import world.gregs.voidps.engine.timer.Timer import world.gregs.voidps.engine.timer.toTicks +import world.gregs.voidps.type.random import java.util.concurrent.TimeUnit class SummoningTimers : Script { @@ -19,6 +21,17 @@ class SummoningTimers : Script { } timerTick("familiar_timer") { + // Special-move points regenerate +15 (capped at 60) every 30s, matching the live game. + val specialPoints = get("summoning_special_points_remaining", 0) + if (specialPoints < 60) { + set("summoning_special_points_remaining", (specialPoints + 15).coerceAtMost(60)) + } + follower?.let { familiar -> + val lines = Rows.getOrNull("familiar_overhead.${familiar.id}")?.stringListOrNull("lines") + if (!lines.isNullOrEmpty()) { + familiar.say(lines[random.nextInt(lines.size)]) + } + } var seconds = get("familiar_details_seconds_remaining", 0) if (seconds == 0) { dec("familiar_details_minutes_remaining") diff --git a/game/src/main/kotlin/content/skill/summoning/familiar/AbyssalTitan.kt b/game/src/main/kotlin/content/skill/summoning/familiar/AbyssalTitan.kt new file mode 100644 index 0000000000..7ca4360942 --- /dev/null +++ b/game/src/main/kotlin/content/skill/summoning/familiar/AbyssalTitan.kt @@ -0,0 +1,13 @@ +package content.skill.summoning.familiar + +import content.entity.player.dialogue.Neutral +import content.entity.player.dialogue.type.npc +import world.gregs.voidps.engine.Script + +class AbyssalTitan : Script { + init { + npcOperate("Interact", "abyssal_titan_familiar") { + npc("Scruunt, scraaan.....") + } + } +} diff --git a/game/src/main/kotlin/content/skill/summoning/familiar/AlbinoRat.kt b/game/src/main/kotlin/content/skill/summoning/familiar/AlbinoRat.kt new file mode 100644 index 0000000000..270803720f --- /dev/null +++ b/game/src/main/kotlin/content/skill/summoning/familiar/AlbinoRat.kt @@ -0,0 +1,47 @@ +package content.skill.summoning.familiar + +import content.entity.player.dialogue.Happy +import content.entity.player.dialogue.Neutral +import content.entity.player.dialogue.type.npc +import content.entity.player.dialogue.type.player +import world.gregs.voidps.engine.Script +import world.gregs.voidps.type.random + +class AlbinoRat : Script { + init { + npcOperate("Interact", "albino_rat_familiar") { + when (random.nextInt(4)) { + 0 -> { + npc("Hey boss, we going to do anything wicked today?") + player("Well, I don't know why we would: I tend not to go around being wicked.") + npc("Not even a little?") + player("Well there was that one time... I'm sorry, no wickedness today.") + npc("Awwwwww...") + } + 1 -> { + npc("Hey boss, can we go and loot something now?") + player("Well, what did you have in mind?") + npc("I dunno - where are we headed?") + player("I hadn't decided yet.") + npc("When we get there, let's loot something nearby!") + player("Sounds like a plan, certainly.") + } + 2 -> { + npc("So what we up to today, boss?") + player("Oh I'm sure we'll find something to occupy our time.") + npc("Let's go robbin' graves again!") + player("What do you mean 'again'?") + npc("Nuffin'...") + } + 3 -> { + npc("You know, boss, I don't think you're totally into this whole 'evil' thing.") + player("I wonder what gave you that impression?") + npc("Well, I worked with a lot of evil people; some of the best.") + player("Such as?") + npc("I'm not telling! I've got my principles to uphold.") + player("There is honour amongst thieves, it would seem.") + } + } + } + } +} diff --git a/game/src/main/kotlin/content/skill/summoning/familiar/ArcticBear.kt b/game/src/main/kotlin/content/skill/summoning/familiar/ArcticBear.kt new file mode 100644 index 0000000000..67890f6a21 --- /dev/null +++ b/game/src/main/kotlin/content/skill/summoning/familiar/ArcticBear.kt @@ -0,0 +1,43 @@ +package content.skill.summoning.familiar + +import content.entity.player.dialogue.Frustrated +import content.entity.player.dialogue.Happy +import content.entity.player.dialogue.Neutral +import content.entity.player.dialogue.type.npc +import content.entity.player.dialogue.type.player +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.entity.character.player.name +import world.gregs.voidps.type.random + +class ArcticBear : Script { + init { + npcOperate("Interact", "arctic_bear_familiar") { + when (random.nextInt(4)) { + 0 -> { + npc("Crikey! We're tracking ourselves a real live one here. I call 'em 'Brighteyes'.") + player("Will you stop stalking me like that?") + npc("Lookit that! Something's riled this one up good and proper.") + player("Who are you talking to anyway?") + npc("Looks like I've been spotted.") + player("Did you think you didn't stand out here or something?") + } + 1 -> { + npc("Crikey! Something seems to have startled Brighteyes, here.") + player("What? What's happening?") + npc("Maybe he's scented a rival.") + player("I smell something, but it's not a rival.") + } + 2 -> { + npc("We're tracking Brighteyes here as he goes about his daily routine.") + player("My name is $name, not Brighteyes!") + npc("Looks like the little critter's upset about something.") + player("I wonder if he'd be quiet if I just did really boring stuff.") + } + 3 -> { + npc("These little guys get riled up real easy.") + player("Who wouldn't be upset with a huge bear tracking along behind them, commenting on everything they do?") + } + } + } + } +} diff --git a/game/src/main/kotlin/content/skill/summoning/familiar/BarkerToad.kt b/game/src/main/kotlin/content/skill/summoning/familiar/BarkerToad.kt new file mode 100644 index 0000000000..4e7a250fba --- /dev/null +++ b/game/src/main/kotlin/content/skill/summoning/familiar/BarkerToad.kt @@ -0,0 +1,80 @@ +package content.skill.summoning.familiar + +import content.entity.player.dialogue.Happy +import content.entity.player.dialogue.Neutral +import content.entity.player.dialogue.type.npc +import content.entity.player.dialogue.type.player +import content.skill.summoning.follower +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.client.message +import world.gregs.voidps.engine.entity.character.player.chat.ChatType +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.engine.inv.remove +import world.gregs.voidps.type.random + +class BarkerToad : Script { + init { + // Loading a cannonball arms the toad's Toad Bark special (see FamiliarCombatSpecials) - + // the special refuses to fire until the toad is loaded, and the shot spends the cannonball. + itemOnNPCOperate("cannonball", "barker_toad_familiar") { (target) -> + if (target != follower) { + message("That's not your familiar.") + return@itemOnNPCOperate + } + if (target["cannonball_loaded", false]) { + message("Your toad is already loaded.") + return@itemOnNPCOperate + } + if (!inventory.remove("cannonball")) { + return@itemOnNPCOperate + } + target["cannonball_loaded"] = true + target.anim("toad_bark_load") + target.gfx("toad_bark_load") + message("You load the cannonball into the toad's gaping mouth.", ChatType.Filter) + } + + npcOperate("Interact", "barker_toad_familiar") { + if (inventory.contains("swamp_toad")) { + npc("Bwaaarp graaaawk? (What's that croaking in your inventory?)") + player("Ah, you mean that toad?") + player("Oh, I'm guessing you're not going to like me carrying a toad about.") + npc("Craaawk, croak. (I might not be all that happy, no.)") + player("I'm not going to eat it.") + npc("Craaaaawk braaap croak. (Weeeeell, I'd hope not! Reminds me of my mama toad. She was inflated and fed to a jubbly, you know. A sad, demeaning way to die.)") + return@npcOperate + } + when (random.nextInt(4)) { + 0 -> { + npc("Ladies and gentlemen, for my next trick, I shall swallow this fly!") + player("Seen it.") + npc("Ah, but last time was the frog...on fire?") + player("No! That would be a good trick.") + npc("Well, it won't be this time either.") + player("Awwwww...") + } + 1 -> { + npc("Roll up, roll up, roll up! See the greatest show on Gielinor!") + player("Where?") + npc("Well, it's kind of...you.") + player("Me?") + npc("Roll up, roll up, roll up! See the greatest freakshow on Gielinor!") + player("Don't make me smack you, slimy.") + } + 2 -> { + npc("We need to set up the big top somewhere near here. The locals look friendly enough.") + player("Are you kidding?") + npc("Your problem is that you never see opportunities.") + } + 3 -> { + npc("Braaaaaaaaaaaaaaaaaaaaaaap! (*Burp!*)") + player("That's disgusting behaviour!") + npc("Braap craaaaawk craaaawk. (That, my dear boy, was my world-renowned belching.)") + player("I got that part. Why are you so happy about it?") + npc("Braaaaaaap craaaaaawk craaaaaaaawk. (My displays have bedazzled the crowned heads of Gielinor.)") + player("I'd give you a standing ovation, but I have my hands full.") + } + } + } + } +} diff --git a/game/src/main/kotlin/content/skill/summoning/familiar/Beaver.kt b/game/src/main/kotlin/content/skill/summoning/familiar/Beaver.kt new file mode 100644 index 0000000000..29f28c084d --- /dev/null +++ b/game/src/main/kotlin/content/skill/summoning/familiar/Beaver.kt @@ -0,0 +1,155 @@ +package content.skill.summoning.familiar + +import content.entity.player.dialogue.Happy +import content.entity.player.dialogue.Neutral +import content.entity.player.dialogue.type.npc +import content.entity.player.dialogue.type.player +import content.skill.fletching.fletchLogDialog +import content.skill.summoning.FAMILIAR_BUSY_MESSAGE +import content.skill.summoning.FAMILIAR_CHOPPING +import content.skill.summoning.FamiliarSpecialMoves +import content.skill.summoning.beastOfBurdenCapacity +import content.skill.summoning.ensureBeastOfBurdenInventory +import content.skill.summoning.follower +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.client.message +import world.gregs.voidps.engine.data.definition.ItemDefinitions +import world.gregs.voidps.engine.data.definition.Rows +import world.gregs.voidps.engine.data.definition.Tables +import world.gregs.voidps.engine.entity.character.mode.Follow +import world.gregs.voidps.engine.entity.character.mode.PauseMode +import world.gregs.voidps.engine.entity.character.mode.move.Movement +import world.gregs.voidps.engine.entity.character.mode.move.target.TargetStrategy +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.inv.add +import world.gregs.voidps.engine.inv.beastOfBurden +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.engine.queue.queue +import world.gregs.voidps.type.random + +/** + * Logs the beaver's Multichop can hand over, lowest tier first. On a cast it may give any of these at + * or below the targeted tree's tier - so the player can end up with a lower-level log than intended. + */ +private val MULTICHOP_LOGS = listOf("logs", "oak_logs", "willow_logs", "maple_logs", "yew_logs", "magic_logs") + +/** How long the beaver chops the tree (ticks), and how often its chop animation is replayed. */ +private const val CHOP_TICKS = 10 +private const val CHOP_ANIM_INTERVAL = 4 + +private fun Player.beastOfBurdenFull(): Boolean { + ensureBeastOfBurdenInventory() + return beastOfBurden.items.count { it.isNotEmpty() } >= beastOfBurdenCapacity +} + +class Beaver : Script { + init { + // Multichop - the beaver chops the targeted tree and hands over a log, possibly from a lower- + // level tree than the one aimed at. Object-target special, so it runs through the scroll + + // points gate; returns false (charging nothing) when it can't produce a log. + FamiliarSpecialMoves.obj("beaver_familiar") { tree -> + val treeLog = Tables.itemOrNull("trees.${tree.def(this).stringId}.logs") + if (treeLog == null) { + message("Your beaver can only chop naturally growing trees.") + return@obj false + } + val beaver = follower ?: return@obj false + if (beastOfBurdenFull()) { + message("Your beaver's pack is too full to store any more logs.") + return@obj false + } + val targetLevel = Rows.getOrNull("logs.$treeLog")?.int("level") ?: 0 + val candidates = MULTICHOP_LOGS.filter { (Rows.getOrNull("logs.$it")?.int("level") ?: 0) <= targetLevel } + val log = if (candidates.isEmpty()) treeLog else candidates[random.nextInt(candidates.size)] + val owner = this + // Send the beaver over to the tree first, then have it chop and stash the log in its pack. + // Stop tracking the owner up front so the beaver doesn't keep facing the player. + beaver[FAMILIAR_CHOPPING] = true + beaver.clearWatch() + beaver.mode = Movement(beaver, TargetStrategy(beaver, tree)) + beaver.queue("beaver_multichop") { + // Wait until it's actually next to the tree (the strict reach check can stay false when + // adjacent to a blocked object, which would leave it idling and facing the owner). + var ticks = 0 + while (tile.distanceTo(tree.nearestTo(tile)) > 1 && ticks++ < 15) { + delay() + } + // Chop for a spell, replaying the animation and holding its facing on the tree - this is + // the window in which the beaver doubles as a fletching knife (see the fletch handler). + // PauseMode (not EmptyMode) keeps the idle-familiar watchdog from re-following the owner. + repeat(CHOP_TICKS) { tick -> + mode = PauseMode + clearWatch() + if (tick % CHOP_ANIM_INTERVAL == 0) { + anim("beaver_multichop") + gfx("beaver_multichop") + } + face(tree.tile) + delay() + } + if (!owner.beastOfBurdenFull()) { + owner.beastOfBurden.add(log, 1) + owner.message("Your beaver chops the tree and stashes some ${ItemDefinitions.get(log).name.lowercase()} in its pack.") + } + clear(FAMILIAR_CHOPPING) + mode = Follow(this, owner) + } + true + } + + // Use a log on the beaver to fletch it into a bow, no knife needed - the beaver does the cutting. + // Registered as an operate on the specific ":beaver_familiar" key so it takes precedence + // over the beast-of-burden store handler's "*:beaver_familiar" (which would otherwise refuse + // the log with "won't carry yours"). + itemOnNPCOperate("*logs*", "beaver_familiar") { (npc, item) -> + if (npc != follower) { + return@itemOnNPCOperate + } + // The beaver only doubles as a fletching knife while it's cutting with the Multichop scroll + // (or the player still holds a real knife). If it stops cutting mid-batch, fletching halts + // with the default "You need a knife to do that." The beaver plays its own cutting animation. + fletchLogDialog( + item.id, + animate = { follower?.anim("beaver_multichop") }, + hasTool = { inventory.contains("knife") || follower?.get(FAMILIAR_CHOPPING, false) == true }, + ) + } + + npcOperate("Interact", "beaver_familiar") { + if (follower?.get(FAMILIAR_CHOPPING, false) == true) { + message(FAMILIAR_BUSY_MESSAGE) + return@npcOperate + } + if (inventory.items.any { it.id.endsWith("logs") }) { + npc("'Ere, you 'ave ze logs, now form zem into a mighty dam!") + player("Well, I was thinking of burning, selling, or fletching them.") + npc("Sacre bleu! Such a waste.") + return@npcOperate + } + when (random.nextInt(4)) { + 0 -> { + npc("Vot are you doing 'ere when we could be logging and building mighty dams, alors?") + player("Why would I want to build a dam again?") + npc("Why vouldn't you want to build a dam again?") + player("I can't argue with that logic.") + } + 1 -> { + npc("Pardonnez-moi - you call yourself a lumberjack?") + player("No") + npc("Carry on zen.") + } + 2 -> { + npc("Paul Bunyan 'as nothing on moi!") + player("Except several feet in height, a better beard, and opposable thumbs.") + npc("What was zat?") + player("Nothing.") + } + 3 -> { + npc("Zis is a fine day make some lumber.") + player("That it is!") + npc("So why are you talking to moi? Get chopping!") + } + } + } + } +} diff --git a/game/src/main/kotlin/content/skill/summoning/familiar/BloatedLeech.kt b/game/src/main/kotlin/content/skill/summoning/familiar/BloatedLeech.kt new file mode 100644 index 0000000000..f7695a996e --- /dev/null +++ b/game/src/main/kotlin/content/skill/summoning/familiar/BloatedLeech.kt @@ -0,0 +1,44 @@ +package content.skill.summoning.familiar + +import content.entity.player.dialogue.Happy +import content.entity.player.dialogue.Neutral +import content.entity.player.dialogue.type.npc +import content.entity.player.dialogue.type.player +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.entity.character.player.name +import world.gregs.voidps.type.random + +class BloatedLeech : Script { + init { + npcOperate("Interact", "bloated_leech_familiar") { + when (random.nextInt(4)) { + 0 -> { + npc("I'm afraid it's going to have to come off, $name.") + player("What is?") + npc("Never mind. Trust me, I'm almost a doctor.") + player("I think I'll get a second opinion.") + } + 1 -> { + npc("You're in a critical condition.") + player("Is it terminal?") + npc("Not yet. Let me get a better look and I'll see what I can do about it.") + player("There are two ways to take that...and I think I'll err on the side of caution.") + } + 2 -> { + npc("Let's get a look at that brain of yours.") + player("What? My brains stay inside my head, thanks.") + npc("That's ok, I can just drill a hole.") + player("How about you don't and pretend you did?") + } + 3 -> { + npc("I think we're going to need to operate.") + player("I think we can skip that for now.") + npc("Who's the doctor here?") + player("Not you.") + npc("I may not be a doctor, but I'm keen. Does that not count?") + player("In most other fields, yes; in medicine, no.") + } + } + } + } +} diff --git a/game/src/main/kotlin/content/skill/summoning/familiar/Bloodrager.kt b/game/src/main/kotlin/content/skill/summoning/familiar/Bloodrager.kt new file mode 100644 index 0000000000..e8b8902c17 --- /dev/null +++ b/game/src/main/kotlin/content/skill/summoning/familiar/Bloodrager.kt @@ -0,0 +1,39 @@ +package content.skill.summoning.familiar + +import content.entity.player.dialogue.Frustrated +import content.entity.player.dialogue.Happy +import content.entity.player.dialogue.type.npc +import content.entity.player.dialogue.type.player +import world.gregs.voidps.engine.Script +import world.gregs.voidps.type.random + +class Bloodrager : Script { + init { + npcOperate("Interact", "*_bloodrager_familiar") { + if (!this["talked_to_bloodrager", false]) { + this["talked_to_bloodrager"] = true + npc("Brother, you are always welcome to talk with me.") + return@npcOperate + } + when (random.nextInt(3)) { + 0 -> { + player("Are all gorajo as cheery as you?") + npc("Come to the gorajo plane and find out, brother! In the clan fringes, you will find bloodragers, and there are none more welcoming. You would be treated like a sachem.") + player("I would love to! Are the other gorajo as friendly?") + npc("Their lives are more complicated, brother. They must bear burdens, teach, guide and lead. Although we must protect the clan and serve Challem, we have nothing else to cloud our minds.") + player("Well, I'll hold you to that invite. If we ever get out of here, of course.") + } + 1 -> { + player("How do you like it in Daemonheim?") + npc("It is a place, as any other. I am just happy to be alive, taking sharp rukhs full of air-") + player("Im not sure I have a rukh.") + npc("Sure you do! Or how else would you grebbit? I am just happy to be alive, breathing the air and completing the task that has been asked of me. Challem be praised.") + } + 2 -> { + player("I don't have any more questions.") + npc("Shame. I feel that we are pollen on the same wind, friend.") + } + } + } + } +} diff --git a/game/src/main/kotlin/content/skill/summoning/familiar/BullAnt.kt b/game/src/main/kotlin/content/skill/summoning/familiar/BullAnt.kt new file mode 100644 index 0000000000..5c4c0daaba --- /dev/null +++ b/game/src/main/kotlin/content/skill/summoning/familiar/BullAnt.kt @@ -0,0 +1,51 @@ +package content.skill.summoning.familiar + +import content.entity.player.dialogue.Happy +import content.entity.player.dialogue.Neutral +import content.entity.player.dialogue.type.npc +import content.entity.player.dialogue.type.player +import content.entity.player.effect.energy.energyPercent +import world.gregs.voidps.engine.Script +import world.gregs.voidps.type.random + +class BullAnt : Script { + init { + npcOperate("Interact", "bull_ant_familiar") { + if (energyPercent() < 12) { + npc("What's the matter, Private? Not enjoying the run?") + player("Sir...wheeze...yes Sir!") + npc("Not enjoying the run? You need more training biped?") + player("Sir, no Sir! Sir, I'm enjoying the run a great deal, Sir!") + npc("Then hop to, Private!") + player("Sir, yes Sir!") + return@npcOperate + } + when (random.nextInt(4)) { + 0 -> { + npc("All right you worthless biped, fall in!") + player("Sir, yes Sir!") + npc("We're going to work you so hard your boots fall off, understood?") + player("Sir, yes Sir!") + npc("Carry on Private!") + } + 1 -> { + npc("Aten...hut!") + player("Sir, Private Player reporting for immediate active duty, Sir!") + npc("As you were, Private!") + } + 2 -> { + npc("I can't believe they stuck me with you...") + player("Buck up, Sir, it's not that bad.") + npc("Stow that, Private, and get back to work!") + player("Sir, yes Sir!") + } + 3 -> { + npc("What in the name of all the layers of the abyss do you think you're doing, biped?") + player("Sir, nothing Sir!") + npc("Well double-time it, Private, whatever it is!") + player("Sir, yes Sir!") + } + } + } + } +} diff --git a/game/src/main/kotlin/content/skill/summoning/familiar/Bunyip.kt b/game/src/main/kotlin/content/skill/summoning/familiar/Bunyip.kt new file mode 100644 index 0000000000..b090ad791a --- /dev/null +++ b/game/src/main/kotlin/content/skill/summoning/familiar/Bunyip.kt @@ -0,0 +1,133 @@ +package content.skill.summoning.familiar + +import content.entity.player.dialogue.Happy +import content.entity.player.dialogue.Neutral +import content.entity.player.dialogue.type.npc +import content.entity.player.dialogue.type.player +import content.entity.proj.shoot +import content.skill.summoning.FamiliarSpecialMoves +import content.skill.summoning.follower +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.client.message +import world.gregs.voidps.engine.data.definition.ItemDefinitions +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.chat.ChatType +import world.gregs.voidps.engine.entity.character.player.skill.Skill +import world.gregs.voidps.engine.entity.character.player.skill.level.Level.has +import world.gregs.voidps.engine.entity.item.Item +import world.gregs.voidps.engine.inv.add +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.engine.inv.remove +import world.gregs.voidps.type.random + +class Bunyip : Script { + init { + // A plain click on the cast button has no item to work on - point at the real trigger. + FamiliarSpecialMoves.instant("bunyip_familiar") { + message("To cast Swallow Whole, use the Cast option on a raw fish.") + false + } + + // Swallow Whole - the bunyip gulps down a raw fish the owner could cook, healing them for + // the cooked fish's worth with no eat delay. Cast on a raw fish through the scroll + + // points gate; using the fish directly on the bunyip transmutes it instead (below). + FamiliarSpecialMoves.item("bunyip_familiar") { item -> swallowWhole(item) } + + // Using a raw fish on the bunyip transmutes it into water runes - one up to a tenth of the + // cooked fish's healing - free of any scroll. + itemOnNPCOperate("raw_*", "bunyip_familiar") { (npc, item) -> + if (npc != follower) { + return@itemOnNPCOperate + } + val row = Rows.getOrNull("cooking.${item.id}") + val cookedId = row?.item("cooked")?.ifEmpty { item.id.replace("raw", "cooked") } + val heals: IntRange? = cookedId?.let { ItemDefinitions.getOrNull(it)?.getOrNull("heals") } + if (heals == null) { + message("Your bunyip only transmutes raw fish.") + return@itemOnNPCOperate + } + if (!inventory.remove(item.id)) { + return@itemOnNPCOperate + } + val runes = random.nextInt((heals.last / 10).coerceAtLeast(1)) + 1 + inventory.add("water_rune", runes) + anim("bunyip_transmute") + // The tossed fish flies from the player to the bunyip's mouth. + follower?.let { bunyip -> shoot("bunyip_transmute_proj", bunyip) } + message("Your bunyip transmutes the ${item.def.name.lowercase()} into some water runes.", ChatType.Filter) + } + + npcOperate("Interact", "bunyip_familiar") { + val fish = listOf( + "raw_crayfish", "raw_shrimps", "raw_anchovies", "raw_sardine", "raw_giant_carp", + "raw_herring", "raw_trout", "raw_salmon", "raw_pike", "raw_tuna", "raw_swordfish", + "raw_lobster", "raw_mackerel", "raw_cod", "raw_bass", "leaping_trout", "raw_lava_eel", + "leaping_salmon", "leaping_sturgeon", "raw_monkfish", "raw_shark", "raw_cavefish", "raw_rocktail", + ) + if (fish.any { inventory.contains(it) }) { + npc("I see you've got some fish there, mate.") + player("Yeah, but I might cook them up before I give them to you!") + npc("Humans...always ruining good fishes.") + player("You know, some people prefer them cooked.") + npc("Yeah. We call 'em freaks.") + return@npcOperate + } + when (random.nextInt(4)) { + 0 -> { + npc("Where are we going and why is it not to the beach?") + player("Well, we have a fair few places to go, but I suppose we could go to the beach if we get time.") + npc("Bonza! I'll get my board ready!") + player("Well, even if we do go to the beach I don't know if we'll have time for that.") + npc("Awww, that's a drag...") + } + 1 -> { + npc("Hey Bruce, can we go down to the beach t'day?") + player("Well, I have a lot of things to do today but maybe later.") + npc("Bonza!") + } + 2 -> { + npc("Pass me another bunch of shrimps, mate!") + player("I don't know if I want any more water runes.") + npc("Righty, but I do know that I want some shrimps!") + player("A fair point.") + } + 3 -> { + npc("Sigh...") + player("What's the matter?") + npc("I'm dryin' out in this sun, mate.") + player("Well, what can I do to help?") + npc("Well, fish oil is bonza for the skin, ya know.") + player("Oh, right, I think I see where this is going.") + } + } + } + } + + /** The Swallow Whole effect: gulp a cookable raw fish, healing the cooked value instantly. */ + private fun Player.swallowWhole(item: Item): Boolean { + val row = Rows.getOrNull("cooking.${item.id}") + if (row == null || !item.id.startsWith("raw_")) { + message("Your bunyip only swallows raw fish.") + return false + } + val cookedId = row.item("cooked").ifEmpty { item.id.replace("raw", "cooked") } + val heals: IntRange? = ItemDefinitions.getOrNull(cookedId)?.getOrNull("heals") + if (heals == null) { + message("Your bunyip only swallows raw fish.") + return false + } + if (!has(Skill.Cooking, row.int("level"), message = true)) { + return false + } + if (!inventory.remove(item.id)) { + return false + } + val bunyip = follower ?: return false + bunyip.anim("swallow_whole") + bunyip.gfx("swallow_whole") + levels.restore(Skill.Constitution, heals.random()) + message("Your bunyip swallows the ${item.def.name.lowercase()} whole, and you feel reinvigorated.", ChatType.Filter) + return true + } +} diff --git a/game/src/main/kotlin/content/skill/summoning/familiar/Clay.kt b/game/src/main/kotlin/content/skill/summoning/familiar/Clay.kt new file mode 100644 index 0000000000..c03b8b6fca --- /dev/null +++ b/game/src/main/kotlin/content/skill/summoning/familiar/Clay.kt @@ -0,0 +1,42 @@ +package content.skill.summoning.familiar + +import content.entity.player.dialogue.Happy +import content.entity.player.dialogue.Neutral +import content.entity.player.dialogue.type.npc +import content.entity.player.dialogue.type.player +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.inv.beastOfBurden +import world.gregs.voidps.type.random + +class Clay : Script { + init { + npcOperate("Interact", "clay_familiar_class_*_familiar") { + if (beastOfBurden.items.count { it.isNotEmpty() } > 0) { + player("How are you getting on with the load?") + npc("Rumble. (Just fine, master.)") + player("Don't go dropping it, okay?") + npc("Rumble. (I'll try my very best, master.)") + return@npcOperate + } + when (random.nextInt(3)) { + 0 -> { + player("What is it like to be made out of sacred clay?") + npc("Rumble... (I do not understand the question...)") + player("Can you at least tell me how you feel?") + npc("Rumble! (I am happy as long as I can serve you, master!)") + } + 1 -> { + player("They're attacking!") + npc("Rumble! (Fear not, master, for I'll protect you!)") + player("I'm glad you're here!") + } + 2 -> { + player("Hey!") + npc("Rumble? (Yes, master?)") + player("Actually, I probably don't want to be talking to you. It's kind of dangerous here...") + npc("Rumble... (As You wish...)") + } + } + } + } +} diff --git a/game/src/main/kotlin/content/skill/summoning/familiar/Cockatrice.kt b/game/src/main/kotlin/content/skill/summoning/familiar/Cockatrice.kt new file mode 100644 index 0000000000..c48f479d89 --- /dev/null +++ b/game/src/main/kotlin/content/skill/summoning/familiar/Cockatrice.kt @@ -0,0 +1,64 @@ +package content.skill.summoning.familiar + +import content.entity.player.dialogue.Happy +import content.entity.player.dialogue.Neutral +import content.entity.player.dialogue.type.npc +import content.entity.player.dialogue.type.player +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.entity.character.player.equip.equipped +import world.gregs.voidps.network.login.protocol.visual.update.player.EquipSlot +import world.gregs.voidps.type.random + +class Cockatrice : Script { + init { + npcOperate("Interact", "*atrice_familiar") { + if (equipped(EquipSlot.Shield).id == "mirror_shield") { + npc("You know, I'm sensing some trust issues here.") + player("I'm not sure I know what you are talking about.") + npc("What are you holding?") + player("A mirror shield.") + npc("And what do those do?") + player("Mumblemumble...") + npc("What was that?") + player("It protects me from your gaze attack.") + npc("See! Why would you need one unless you didn't trust me?") + player("Who keeps demanding that we stop and have staring contests?") + npc("How about we drop this and call it even?") + player("Fine by me.") + return@npcOperate + } + when (random.nextInt(4)) { + 0 -> { + npc("Is this what you do for fun?") + player("Sometimes. Why, what do you do for fun?") + npc("I find things and glare at them until they die!") + player("Well...everyone needs a hobby, I suppose.") + } + 1 -> { + npc("You know, I think I might train as a hypnotist.") + player("Isn't that an odd profession for a cockatrice?") + npc("Not at all! I've already been practicing!") + player("Oh, really? How is that going?") + npc("Not good. I tell them to look in my eyes and that they are feeling sleepy.") + player("I think I can see where this is headed.") + npc("And then they just lie there and stop moving.") + player("I hate being right sometimes.") + } + 2 -> { + npc("Come on, lets have a staring contest!") + player("You win!") + npc("Yay! I win again!") + player("Oh, it's no contest alright.") + } + 3 -> { + npc("You know, sometimes I don't think we're good friends.") + player("What do you mean?") + npc("Well, you never make eye contact with me for a start.") + player("What happened the last time someone made eye contact with you?") + npc("Oh, I petrified them really good! Ooooh...okay, point taken.") + player("I'm glad we had this chat.") + } + } + } + } +} diff --git a/game/src/main/kotlin/content/skill/summoning/familiar/CompostMound.kt b/game/src/main/kotlin/content/skill/summoning/familiar/CompostMound.kt new file mode 100644 index 0000000000..7ee3b1d3de --- /dev/null +++ b/game/src/main/kotlin/content/skill/summoning/familiar/CompostMound.kt @@ -0,0 +1,56 @@ +package content.skill.summoning.familiar + +import content.entity.player.dialogue.Happy +import content.entity.player.dialogue.Neutral +import content.entity.player.dialogue.type.npc +import content.entity.player.dialogue.type.player +import world.gregs.voidps.engine.Script +import world.gregs.voidps.type.random + +class CompostMound : Script { + init { + npcOperate("Interact", "compost_mound_familiar") { + when (random.nextInt(5)) { + 0 -> { + npc("Oi've gotta braand new comboine 'aarvester!") + player("A what?") + npc("Well, it's a flat bit a metal wi' a 'andle that I can use ta 'aarvest all combinations o' plaants.") + player("You mean a spade?") + npc("Aye, 'aat'll be it.") + } + 1 -> { + npc("What we be doin' 'ere, zur?") + player("Oh, I have a few things to take care of here, is all.") + npc("Aye, right ye are, zur. Oi'll be roight there.") + } + 2 -> { + npc("Errr...are ye gonna eat that?") + player("Eat what?") + npc("Y've got summat on yer, goin' wastin'.") + player("Ewwww!") + npc("So ye don' want it then?") + player("No I do not want it! Nor do I want to put my boot in your mouth for you to clean it off.") + npc("An' why not?") + player("It'll likely come out dirtier than when I put it in!") + } + 3 -> { + npc("Sigh...") + player("What's the matter?") + npc("Oi'm not 'appy carryin' round these young'uns where we're going.") + player("Young'uns? Oh, the buckets of compost! Well, those wooden containers will keep them safe.") + npc("'Aah, that be a mighty good point, zur.") + } + 4 -> { + npc("Oi wus just a-wonderin'...") + player("Oh! What have you been eating! Your breath is making my eyes water!") + npc("Oi! Oi'm 'urt by thaat.") + player("Sorry.") + npc("Oi mean, oi even et some mints earlier.") + player("You did?") + npc("'At's roight. Oi found some mint plaants in a big pile o' muck, and oi 'ad 'em fer me breakfast.") + player("The mystery resolves itself.") + } + } + } + } +} diff --git a/game/src/main/kotlin/content/skill/summoning/familiar/Deathslinger.kt b/game/src/main/kotlin/content/skill/summoning/familiar/Deathslinger.kt new file mode 100644 index 0000000000..46cb474e04 --- /dev/null +++ b/game/src/main/kotlin/content/skill/summoning/familiar/Deathslinger.kt @@ -0,0 +1,49 @@ +package content.skill.summoning.familiar + +import content.entity.player.dialogue.Frustrated +import content.entity.player.dialogue.Happy +import content.entity.player.dialogue.type.npc +import content.entity.player.dialogue.type.player +import world.gregs.voidps.engine.Script +import world.gregs.voidps.type.random + +class Deathslinger : Script { + init { + npcOperate("Interact", "*_deathslinger_familiar") { + if (!this["talked_to_deathslinger", false]) { + this["talked_to_deathslinger"] = true + npc("We have much work to do, but I could stop for a moment.") + return@npcOperate + } + when (random.nextInt(4)) { + 0 -> { + player("What is the biggest creature you have killed?") + npc("Haha, you sound like my cubs! What did you kill, mama? Did it have ten heads, mama? Did it fire magic bolts from its eyes?") + player("Alright, I get it. I was just making conversation.") + npc("Do not sulk, naabe. You simply reminded me of better times. To answer your question, it was most likely a sinkhole. They are huge and flat, shaped something like an open palm. ") + npc("They burrow beneath the ground, and then fold themselves into a fist, storing the land and people within them to be digested when required.") + player("That's horrible!") + npc("And so much worse to be inside one. This place is nothing in comparison to a sinkhole, naabe. I relish every day outside of that thing.") + } + 1 -> { + player("How much do you know about Daemonheim?") + npc("I know that it has been here for far longer than you or I have been alive, beyond the lifetimes of our parents, grandparents and any relatives they knew.") + npc("Many, from so many different races, have been born here. And many have died here, filling the holes they helped to dig. It is not a life they deserved, but they knew no other.") + player("It must have been a terrible life.") + npc("It is best to not consider it a life, naabe. They would have burrowed without question, knowing no life better than this. Like blind moles, churra. ") + npc("They believed that they were burrowing to an exit. It is hateful to think that their leader may have played upon this fact, encouraging them downward to their escape.") + } + 2 -> { + player("Why do the gorajo have only one role? You can't be a deathslinger all the time, can you?") + npc("A gorajo needs but one role. How do you humans say it? We...specialise.") + player("I guess that would make you a pure. I mean, adventurers who specialise in one skill are often called pures.") + npc("A pure? I like this. The goraju are pure of action, pure of purpose... Yes, I will accept this term.") + } + 3 -> { + player("I don't have any more questions.") + npc("Fly fast on the wind, young naabe.") + } + } + } + } +} diff --git a/game/src/main/kotlin/content/skill/summoning/familiar/DesertWyrm.kt b/game/src/main/kotlin/content/skill/summoning/familiar/DesertWyrm.kt new file mode 100644 index 0000000000..24962d742d --- /dev/null +++ b/game/src/main/kotlin/content/skill/summoning/familiar/DesertWyrm.kt @@ -0,0 +1,113 @@ +package content.skill.summoning.familiar + +import content.entity.player.dialogue.Happy +import content.entity.player.dialogue.Neutral +import content.entity.player.dialogue.type.npc +import content.entity.player.dialogue.type.player +import content.skill.summoning.callFollower +import content.skill.summoning.follower +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.client.message +import world.gregs.voidps.engine.data.definition.Tables +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.entity.character.player.equip.equipped +import world.gregs.voidps.engine.entity.character.player.name +import world.gregs.voidps.engine.entity.item.floor.FloorItems +import world.gregs.voidps.engine.entity.obj.GameObjects +import world.gregs.voidps.engine.map.spiral +import world.gregs.voidps.engine.timer.toTicks +import world.gregs.voidps.network.login.protocol.visual.update.player.EquipSlot +import world.gregs.voidps.type.random +import java.util.concurrent.TimeUnit + +class DesertWyrm : Script { + init { + // Burrow - the wyrm digs underground to the nearest ore rock and pops back up with the ore. + npcOperate("Burrow", "desert_wyrm_familiar") { (target) -> + val familiar = follower + if (familiar == null || target != familiar) { + return@npcOperate + } + val ore = bestBurrowOre() + if (ore == null) { + message("There are no rocks around here for the desert wyrm to mine from!") + return@npcOperate + } + familiar.anim("desert_wyrm_burrow") + familiar.gfx("desert_wyrm_burrow") + delay(8) + callFollower() + follower?.let { FloorItems.add(it.tile, ore, disappearTicks = TimeUnit.MINUTES.toTicks(3), owner = this) } + } + + npcOperate("Interact", "desert_wyrm_familiar") { + if (equipped(EquipSlot.Weapon).id.endsWith("pickaxe")) { + npc("If you have that pick, why make me dig?") + player("Because it's a little quicker and easier on my arms.") + npc("I should take industrial action over this...") + player("You mean you won't work for me any more?") + npc("No. It means me and the lads feed you legs-first into some industrial machinery, maybe the Blast Furnace.") + player("I'll just be over here, digging.") + npc("That's the spirit, lad!") + return@npcOperate + } + when (random.nextInt(4)) { + 0 -> { + npc("This is so unsafe...I should have a hard hat for this work...") + player("Well, I could get you a rune helm if you like - those are pretty hard.") + npc("Keep that up and you'll have the union on your back!") + } + 1 -> { + npc("You can't touch me, I'm part of the union!") + player("Is that some official \"no-touching\" policy or something?") + npc("You really don't get it, do you $name?") + } + 2 -> { + npc("You know, you might want to register with the union.") + player("What are the benefits?") + npc("I stop bugging you to join the union.") + player("Ask that again later; I'll have to consider that generous proposal.") + } + 3 -> { + npc("Why are you ignoring that good ore seam, mister?") + player("Which ore seam?") + npc("There's a good ore seam right underneath us at this very moment.") + player("Great! How long will it take for you to get to it?") + npc("Five years, give or take.") + player("Five years!") + npc("That's if we go opencast, mind. I could probably reach it in three if I just dug.") + player("Right. I see. I think I'll skip it thanks.") + } + } + } + } + + /** + * The best ore the wyrm can burrow for among the rocks within [radius] tiles, or null if there + * are none. Like the live game the wyrm only mines the low tiers below silver, and of those picks + * the highest tier available ([burrowOres], worst to best). A rock is any object with a + * `rocks..ores` table entry; depleted rocks have no entry and are skipped. + */ + private fun Player.bestBurrowOre(radius: Int = 7): String? { + var best: String? = null + var bestRank = -1 + for (tile in tile.spiral(radius)) { + for (obj in GameObjects.at(tile)) { + val ores = Tables.itemListOrNull("rocks.${obj.id}.ores") ?: continue + for (ore in ores) { + val rank = burrowOres.indexOf(ore) + if (rank > bestRank) { + bestRank = rank + best = ore + } + } + } + } + return best + } + + private companion object { + /** Ores the desert wyrm can burrow for - the tiers below silver, worst to best. */ + val burrowOres = listOf("copper_ore", "tin_ore", "clay", "blurite_ore", "iron_ore") + } +} diff --git a/game/src/main/kotlin/content/skill/summoning/familiar/Dreadfowl.kt b/game/src/main/kotlin/content/skill/summoning/familiar/Dreadfowl.kt new file mode 100644 index 0000000000..23363a6e3c --- /dev/null +++ b/game/src/main/kotlin/content/skill/summoning/familiar/Dreadfowl.kt @@ -0,0 +1,29 @@ +package content.skill.summoning.familiar + +import content.entity.player.dialogue.Happy +import content.entity.player.dialogue.Neutral +import content.entity.player.dialogue.type.npc +import content.entity.player.dialogue.type.player +import world.gregs.voidps.engine.Script +import world.gregs.voidps.type.random + +class Dreadfowl : Script { + init { + npcOperate("Interact", "dreadfowl_familiar") { + when (random.nextInt(3)) { + 0 -> { + npc("Attack! Fight! Annihilate!") + player("It always worries me when you're so happy saying that.") + } + 1 -> { + npc("Can it be fightin' time, please?") + player("Look, I'll find something for you to fight, just give me a second.") + } + 2 -> { + npc("I want to fight something.") + player("I'll find something for you in a minute - just be patient.") + } + } + } + } +} diff --git a/game/src/main/kotlin/content/skill/summoning/familiar/EvilTurnip.kt b/game/src/main/kotlin/content/skill/summoning/familiar/EvilTurnip.kt new file mode 100644 index 0000000000..45e68d6d46 --- /dev/null +++ b/game/src/main/kotlin/content/skill/summoning/familiar/EvilTurnip.kt @@ -0,0 +1,36 @@ +package content.skill.summoning.familiar + +import content.entity.player.dialogue.Happy +import content.entity.player.dialogue.Neutral +import content.entity.player.dialogue.type.npc +import content.entity.player.dialogue.type.player +import world.gregs.voidps.engine.Script +import world.gregs.voidps.type.random + +class EvilTurnip : Script { + init { + npcOperate("Interact", "evil_turnip_familiar") { + when (random.nextInt(4)) { + 0 -> { + player("So, how are you feeling?") + npc("My roots feel hurty. I thinking it be someone I eated.") + player("You mean someTHING you ate?") + npc("Hur hur hur. Yah, sure, why not.") + } + 1 -> { + npc("Hur hur hur...") + player("Well, as sinister as it's chuckling is, at least it's happy. That's a good thing, right?") + } + 2 -> { + npc("When we gonna fighting things, boss?") + player("Soon enough.") + npc("Hur hur hur. I gets the fighting.") + } + 3 -> { + npc("I are turnip hear me roar! I too deadly to ignore.") + player("I'm glad it's on my side... and not behind me.") + } + } + } + } +} diff --git a/game/src/main/kotlin/content/skill/summoning/familiar/FireTitan.kt b/game/src/main/kotlin/content/skill/summoning/familiar/FireTitan.kt new file mode 100644 index 0000000000..1f256e8996 --- /dev/null +++ b/game/src/main/kotlin/content/skill/summoning/familiar/FireTitan.kt @@ -0,0 +1,61 @@ +package content.skill.summoning.familiar + +import content.entity.player.dialogue.Happy +import content.entity.player.dialogue.Neutral +import content.entity.player.dialogue.type.npc +import content.entity.player.dialogue.type.player +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.type.random + +class FireTitan : Script { + init { + npcOperate("Interact", "fire_titan_familiar") { + if (inventory.contains("tinderbox")) { + npc("Relight my fire.") + npc("A tinderbox is my only desire.") + player("What are you singing?") + npc("Just a song I heard a while ago.") + player("A tinderbox is my only desire.") + npc("You're just jealous of my singing voice.") + player("Where did you hear this again?") + npc("Oh, you know, just with some other fire titans. Out for a night on the pyres.") + player("Hmm. Come on then. We have stuff to do.") + return@npcOperate + } + when (random.nextInt(5)) { + 0 -> { + npc("Pick flax.") + npc("Jump to it.") + npc("If you want to get to Fletching level 99.") + player("That song...is terrible.") + npc("Sorry.") + } + 1 -> { + npc("You're fanning my flame with your wind spells.") + npc("I'm singeing the curtains with my heat.") + player("Oooh, very mellow.") + } + 2 -> { + npc("I'm burning up.") + npc("I want the world to know.") + npc("I got to let it show.") + player("Catchy.") + } + 3 -> { + npc("It's raining flame!") + npc("Huzzah!") + player("You have a...powerful voice.") + npc("Thanks") + } + 4 -> { + npc("Let's go fireside.") + npc("I think I've roasted the sofa.") + npc("I think I've burnt down the hall.") + player("Can't you sing quietly?") + npc("Sorry.") + } + } + } + } +} diff --git a/game/src/main/kotlin/content/skill/summoning/familiar/ForgeRegent.kt b/game/src/main/kotlin/content/skill/summoning/familiar/ForgeRegent.kt new file mode 100644 index 0000000000..9b6e238a05 --- /dev/null +++ b/game/src/main/kotlin/content/skill/summoning/familiar/ForgeRegent.kt @@ -0,0 +1,62 @@ +package content.skill.summoning.familiar + +import content.entity.player.dialogue.Happy +import content.entity.player.dialogue.Neutral +import content.entity.player.dialogue.type.npc +import content.entity.player.dialogue.type.player +import world.gregs.voidps.engine.Script +import world.gregs.voidps.type.random + +class ForgeRegent : Script { + init { + npcOperate("Interact", "forge_regent_familiar") { + when (random.nextInt(4)) { + 0 -> { + npc("Crackley spit crack sizzle? (Can we go Smithing?)") + player("Maybe.") + npc("Hiss? (Can we go smelt something?)") + player("Maybe.") + npc("Flicker crackle sizzle? (Can we go mine something to smelt?)") + player("Maybe.") + npc("Sizzle flicker! (Yay! I like doing that!)") + player("...") + } + 1 -> { + npc("Hiss. (I'm happy.)") + player("Good.") + npc("Crackle. (Now I'm sad.)") + player("Oh dear, why?") + npc("Hiss-hiss. (Happy again.)") + player("Glad to hear it.") + npc("Crackley-crick. (Sad now.)") + player("Um.") + npc("Hiss. (Happy.)") + player("Right...") + npc("Crackle. (Sad.)") + player("You're very strange.") + npc("Sizzle hiss? (What makes you say that?)") + player("Oh...nothing in particular.") + } + 2 -> { + npc("Sizzle! (I like logs.)") + player("They are useful for making planks.") + npc("Sizzley crack hiss spit. (No, I just like walking on them. They burst into flames.)") + player("It's a good job I can use you as a firelighter really!") + } + 3 -> { + npc("Sizzle... (I'm bored.)") + player("Are you not enjoying what we're doing?") + npc("Crackley crickle sizzle. (Oh yes, but I'm still bored.)") + player("Oh, I see.") + npc("Sizzle hiss? (What's that over there?)") + player("I don't know. Should we go and look?") + npc("Hiss crackle spit sizzle crack? (Nah, that's old news - I'm bored of it now.)") + npc("Sizzle hiss? (What's that over there?)") + player("But...wha...where now?") + npc("Sizzle crack crickle. (Oh no matter, it no longer interests me.)") + player("You're hard work.") + } + } + } + } +} diff --git a/game/src/main/kotlin/content/skill/summoning/familiar/FruitBat.kt b/game/src/main/kotlin/content/skill/summoning/familiar/FruitBat.kt new file mode 100644 index 0000000000..f1aefffb60 --- /dev/null +++ b/game/src/main/kotlin/content/skill/summoning/familiar/FruitBat.kt @@ -0,0 +1,42 @@ +package content.skill.summoning.familiar + +import content.entity.player.dialogue.Happy +import content.entity.player.dialogue.Neutral +import content.entity.player.dialogue.type.npc +import content.entity.player.dialogue.type.player +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.type.random + +class FruitBat : Script { + init { + npcOperate("Interact", "fruit_bat_familiar") { + if (inventory.count("papaya_fruit") > 3) { + npc("Squeek squeek-a-squeek squeeek? (Can I have a papaya?)") + player("No, I have a very specific plan for them.") + npc("Squeek? (What?)") + player("I was just going to grate it over some other vegetables and eat it. Yum.") + return@npcOperate + } + when (random.nextInt(4)) { + 0 -> { + npc("Squeek-a-squeek squeek? (How much longer do you want me for?)") + player("I don't really know at the moment, it all depends what I want to do today.") + } + 1 -> { + npc("Squeak squeek-a-squeak. (This place is fun!)") + player("Glad you think so!") + } + 2 -> { + npc("Squeek squeek squeek-a-squeek? (Where are we going?)") + player("Oh, we're likely to go to a lot of places today.") + } + 3 -> { + npc("Squeek squeek-a-squeek squeek? (Can you smell lemons?)") + player("No, why do you ask?") + npc("Squeak-a-squeak squeek. (Must just be thinking about them.)") + } + } + } + } +} diff --git a/game/src/main/kotlin/content/skill/summoning/familiar/GeyserTitan.kt b/game/src/main/kotlin/content/skill/summoning/familiar/GeyserTitan.kt new file mode 100644 index 0000000000..b67b293d38 --- /dev/null +++ b/game/src/main/kotlin/content/skill/summoning/familiar/GeyserTitan.kt @@ -0,0 +1,45 @@ +package content.skill.summoning.familiar + +import content.entity.player.dialogue.Happy +import content.entity.player.dialogue.Neutral +import content.entity.player.dialogue.type.npc +import content.entity.player.dialogue.type.player +import content.skill.summoning.follower +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.client.message +import world.gregs.voidps.engine.entity.character.player.chat.ChatType +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.engine.inv.replace + +class GeyserTitan : Script { + init { + // A bowl held up to the geyser fills with steaming water. + itemOnNPCOperate("bowl", "geyser_titan_familiar") { (npc) -> + if (npc != follower) { + message("That's not your familiar.") + return@itemOnNPCOperate + } + if (inventory.replace("bowl", "bowl_of_hot_water")) { + message("You hold the bowl up to the geyser titan and it fills with boiling water.", ChatType.Filter) + } + } + + // The titan's waters recharge amulets of glory, as the Fountain of Heroes does. + for (glory in listOf("amulet_of_glory", "amulet_of_glory_1", "amulet_of_glory_2", "amulet_of_glory_3")) { + itemOnNPCOperate(glory, "geyser_titan_familiar") { (npc, item) -> + if (npc != follower) { + message("That's not your familiar.") + return@itemOnNPCOperate + } + if (inventory.replace(item.id, "amulet_of_glory_4")) { + message("You feel a power emanating from the geyser titan as it recharges your amulet.", ChatType.Filter) + } + } + } + + npcOperate("Interact", "geyser_titan_familiar") { + npc("Did you know a snail can sleep up to three years?") + player("I wish I could do that. Ah...sleep.") + } + } +} diff --git a/game/src/main/kotlin/content/skill/summoning/familiar/GiantChinchompa.kt b/game/src/main/kotlin/content/skill/summoning/familiar/GiantChinchompa.kt new file mode 100644 index 0000000000..acc09fd621 --- /dev/null +++ b/game/src/main/kotlin/content/skill/summoning/familiar/GiantChinchompa.kt @@ -0,0 +1,60 @@ +package content.skill.summoning.familiar + +import content.entity.player.dialogue.Happy +import content.entity.player.dialogue.Neutral +import content.entity.player.dialogue.type.npc +import content.entity.player.dialogue.type.player +import content.entity.player.dialogue.type.statement +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.type.random + +class GiantChinchompa : Script { + init { + npcOperate("Interact", "giant_chinchompa_familiar") { + if (inventory.items.any { it.id.contains("chinchompa") }) { + npc("Woah, woah, woah - hold up there.") + player("What is it, ratty?") + npc("You got something in your backpack that you'd like to tell me about?") + player("I was wondering when you were going to bring up the chinchompa. I'm sure they like it in my inventory.") + npc("Did they not teach you anything in school? Chinchompas die in hot bags. You know what happens when chinchompas die. Are you attached to your back?") + player("Medically, yes. And I kind of like it too. I get the point.") + return@npcOperate + } + when (random.nextInt(4)) { + 0 -> { + npc("Half a pound of tuppenny rice, half a pound of treacle...") + player("I hate it when you sing that song.") + npc("...that's the way the money goes...") + player("Couldn't you sing 'Kumbaya' or something?") + npc("...BANG, goes the chinchompa!") + player("Sheesh.") + } + 1 -> { + npc("What's small, brown and blows up?") + player("A brown balloon?") + npc("A chinchompa! Pull my finger.") + player("I'm not pulling your finger.") + npc("Nothing will happen. Truuuuust meeeeee.") + player("Oh, go away.") + } + 2 -> { + npc("I spy, with my little eye, something beginning with 'B'.") + player("Bomb? Bang? Boom? Blowing-up-little-chipmunk?") + npc("No. Body odour. You should wash a bit more.") + player("Well, that was pleasant. You don't smell all that great either, you know.") + npc("Stop talking, stop talking! Your breath stinks!") + player("We're never going to get on, are we?") + } + 3 -> { + npc("I seem to have found a paper bag.") + player("Well done. Anything in it?") + npc("Hmmm. Let me see. It seems to be full of some highly sought after, very expensive...chinchompa breath!") + player("No, don't pop it!") + statement("*BANG!!*") + player("You just cannot help yourself, can you?") + } + } + } + } +} diff --git a/game/src/main/kotlin/content/skill/summoning/familiar/GiantEnt.kt b/game/src/main/kotlin/content/skill/summoning/familiar/GiantEnt.kt new file mode 100644 index 0000000000..8a05e65711 --- /dev/null +++ b/game/src/main/kotlin/content/skill/summoning/familiar/GiantEnt.kt @@ -0,0 +1,84 @@ +package content.skill.summoning.familiar + +import content.entity.player.dialogue.Happy +import content.entity.player.dialogue.Neutral +import content.entity.player.dialogue.type.npc +import content.entity.player.dialogue.type.player +import content.entity.player.dialogue.type.statement +import content.skill.summoning.follower +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.client.message +import world.gregs.voidps.engine.entity.character.player.chat.ChatType +import world.gregs.voidps.engine.inv.add +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.engine.inv.remove +import world.gregs.voidps.type.random + +class GiantEnt : Script { + init { + // The giant ent transmutes pure essence used on it into an earth or (more rarely) nature rune. + itemOnNPCOperate("pure_essence", "giant_ent_familiar") { (target) -> + if (target != follower) { + message("That's not your familiar.") + return@itemOnNPCOperate + } + val rune = if (random.nextInt(9) < 4) "earth_rune" else "nature_rune" + // Remove-then-add rather than replace, so the rune joins an existing stack. + if (inventory.remove("pure_essence")) { + inventory.add(rune) + message("Your giant ent transmutes the essence into a rune.", ChatType.Filter) + } + } + + npcOperate("Interact", "giant_ent_familiar") { + val stage = this["ent_fam_dial", 0] + if (stage == 0) { + this["ent_fam_dial"] = 1 + npc("Creeeeeeeeeeeak..... (I.....)") + player("Yes?") + npc(".....") + statement("After a while you realise that the ent has finished speaking for the moment.") + return@npcOperate + } + if (stage == 1) { + this["ent_fam_dial"] = 2 + npc("Creak..... Creaaaaaaaaak..... (Am.....)") + player("Yes?") + npc(".....") + statement("After a while you realise that the ent has finished speaking for the moment.") + return@npcOperate + } + if (stage == 2) { + this["ent_fam_dial"] = 3 + npc("Grooooooooan..... (Feeling.....)") + player("Yes? We almost have a full sentence now - the suspense is killing me!") + npc(".....") + statement("After a while you realise that the ent has finished speaking for the moment.") + return@npcOperate + } + this["ent_fam_dial"] = 0 + when (random.nextInt(5)) { + 0 -> { + npc("Groooooooooan..... (Sleepy.....)") + player("I'm not sure if that was worth all the waiting.") + } + 1 -> { + npc("Grooooooan.....creeeeeeeak (Restful.....)") + player("I'm not sure if that was worth all the waiting.") + } + 2 -> { + npc("Grrrrooooooooooooooan..... (Achey.....)") + player("I'm not sure if that was worth all the waiting.") + } + 3 -> { + npc("Creeeeeeeegroooooooan..... (Goood.....)") + player("I'm not sure if that was worth all the waiting.") + } + 4 -> { + npc("Creeeeeeeeeeeeeaaaaaak..... (Tired.....)") + player("I'm not sure if that was worth all the waiting.") + } + } + } + } +} diff --git a/game/src/main/kotlin/content/skill/summoning/familiar/GraniteCrab.kt b/game/src/main/kotlin/content/skill/summoning/familiar/GraniteCrab.kt new file mode 100644 index 0000000000..1c33bd0ca4 --- /dev/null +++ b/game/src/main/kotlin/content/skill/summoning/familiar/GraniteCrab.kt @@ -0,0 +1,40 @@ +package content.skill.summoning.familiar + +import content.entity.player.dialogue.Frustrated +import content.entity.player.dialogue.Happy +import content.entity.player.dialogue.Neutral +import content.entity.player.dialogue.type.npc +import content.entity.player.dialogue.type.player +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.type.random + +class GraniteCrab : Script { + init { + npcOperate("Interact", "granite_crab_familiar") { + if (inventory.items.any { it.id.startsWith("raw_") || it.id.startsWith("leaping_") }) { + npc("Can I have some fish?") + player("No, I have to cook these for later.") + npc("Free fish, please?") + player("No...I already told you you can't.") + npc("Can it be fish time soon?") + player("Great...I get stuck with the only granite crab in existence that can't take no for an answer...") + return@npcOperate + } + when (random.nextInt(3)) { + 0 -> { + npc("Rock fish now, please?") + player("Not right now. I don't have any rock fish.") + } + 1 -> { + npc("When can we go fishing? I want rock fish.") + player("When I need some fish. It's not that hard to work out, right?") + } + 2 -> { + npc("I'm stealthy!") + player("Errr... of course you are.") + } + } + } + } +} diff --git a/game/src/main/kotlin/content/skill/summoning/familiar/GraniteLobster.kt b/game/src/main/kotlin/content/skill/summoning/familiar/GraniteLobster.kt new file mode 100644 index 0000000000..d760432ca4 --- /dev/null +++ b/game/src/main/kotlin/content/skill/summoning/familiar/GraniteLobster.kt @@ -0,0 +1,34 @@ +package content.skill.summoning.familiar + +import content.entity.player.dialogue.Happy +import content.entity.player.dialogue.Neutral +import content.entity.player.dialogue.type.npc +import content.entity.player.dialogue.type.player +import content.quest.questCompleted +import world.gregs.voidps.engine.Script +import world.gregs.voidps.type.random + +class GraniteLobster : Script { + init { + npcOperate("Interact", "granite_lobster_familiar") { + if (questCompleted("fremennik_trials")) { + when (random.nextInt(2)) { + 0 -> { + npc("Ho, my Fremennik brother, shall we go raiding?") + player("Well, I suppose we could when I'm done with this.") + npc("Yes! To the looting and the plunder!") + } + 1 -> { + npc("We shall heap the helmets of the fallen into a mountain!") + player("The outerlanders have insulted our heritage for the last time!") + npc("The longhall will resound with our celebration!") + } + } + return@npcOperate + } + npc("Clonkclonk clonk grind clonk. (Keep walking, outerlander. We have nothing to discuss.)") + player("Fair enough.") + npc("Clonkclonkclonk grind clonk grind? (It's nothing personal, you're just an outerlander, you know?)") + } + } +} diff --git a/game/src/main/kotlin/content/skill/summoning/familiar/Hoardstalker.kt b/game/src/main/kotlin/content/skill/summoning/familiar/Hoardstalker.kt new file mode 100644 index 0000000000..e5ce1674c2 --- /dev/null +++ b/game/src/main/kotlin/content/skill/summoning/familiar/Hoardstalker.kt @@ -0,0 +1,55 @@ +package content.skill.summoning.familiar + +import content.entity.player.dialogue.Frustrated +import content.entity.player.dialogue.Happy +import content.entity.player.dialogue.type.choice +import content.entity.player.dialogue.type.npc +import content.entity.player.dialogue.type.player +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.entity.character.player.name +import world.gregs.voidps.type.random + +class Hoardstalker : Script { + init { + npcOperate("Interact", "*_hoardstalker_familiar") { + if (!this["talked_to_hoardstalker", false]) { + this["talked_to_hoardstalker"] = true + npc("Are you sure we can stop, naabe? Aren't there creatures about?") + return@npcOperate + } + when (random.nextInt(4)) { + 0 -> { + player("You're a little timid for a gorajo, aren't you?") + npc("Naabe, a hoardstalker has little or no experience of combat. I am told that I fight like a cat on hind legs.") + choice("Choose an option:") { + option("Don't worry, I have your back.") { + npc("Not just my back, I hope. I find a foe more terrifying when they run at me from the front. Although we do not die in our spirit form, we must rest for a week due to our spirit wounds, so others would have to perform my role for me.") + npc("They would be like a woodpecket attempting to feed from a tortoise. I'd come back to find the crops irrigated with milk, weapons dipped in water, and babies drinking poison. Churra, it is too terrible to think on.") + } + option("Then what use are you?") { + npc("Churra! You have a closed mind on those shoulders. I may not be a towering bloodrager or a graceful deathslinger, but I can be useful where they cannot") + npc("Let me scavenge in these ruins, naabe. I will bring you such items that you would never question my place here.") + } + } + } + 1 -> { + player("Do you like it in Daemonheim? I can't imagine how anyone could.") + npc("I see this place in a way that you do not, naabe. It amazes me how something can go down so deep, yet still be strong and broad. I cannot help but applaud the mind behind these dungeons.") + npc("The workmanship, too...it makes me want to put down my tools and reincarnate as a bloodrager. I feel like a sparrowhawk who has been chased off his kill by a dragon.") + player("But this place is evil, and thousands died building it. I doubt you've murdered anyone to make a dagger, you know.") + npc("And that is some comfort. I feel I must be careful about what I learn and study on this plane. There are poisoned thorns among the flowering wonders") + } + 2 -> { + player("Why are you called a hoardstalker? It seems a strange choice for a...blacksmith and scavenger, I guess.") + npc("We are not just required to make the tools of our clansmen, naabe, We must protect the tools from those who would take them.") + player("Still, hoardstalker is a silly name.") + npc("Naabe, I have held this back from you until now, but the term $name, in our tongue, means 'One-Who-Juggles-Piglets'. A less-mature gorajo would find that amusing.") + } + 3 -> { + player("I don't have any more questions.") + npc("No problem, naabe. Just make sure nothing sneaks past and hurts me.") + } + } + } + } +} diff --git a/game/src/main/kotlin/content/skill/summoning/familiar/HoneyBadger.kt b/game/src/main/kotlin/content/skill/summoning/familiar/HoneyBadger.kt new file mode 100644 index 0000000000..df82758862 --- /dev/null +++ b/game/src/main/kotlin/content/skill/summoning/familiar/HoneyBadger.kt @@ -0,0 +1,37 @@ +package content.skill.summoning.familiar + +import content.entity.player.dialogue.Happy +import content.entity.player.dialogue.Neutral +import content.entity.player.dialogue.type.npc +import content.entity.player.dialogue.type.player +import world.gregs.voidps.engine.Script +import world.gregs.voidps.type.random + +class HoneyBadger : Script { + init { + npcOperate("Interact", "honey_badger_familiar") { + when (random.nextInt(5)) { + 0 -> { + npc("*An outpouring of sanity-straining abuse*") + player("Why do I talk to you again?") + } + 1 -> { + npc("*An outpouring of spittal-flecked insults.*") + player("Why do I talk to you again?") + } + 2 -> { + npc("*A lambasting of visibly illustrated obscenities.*") + player("Why do I talk to you again?") + } + 3 -> { + npc("*A tirade of biologically questionable threats*") + player("Why do I talk to you again?") + } + 4 -> { + npc("*A stream of eye-watering crudities*") + player("Why do I talk to you again?") + } + } + } + } +} diff --git a/game/src/main/kotlin/content/skill/summoning/familiar/Hydra.kt b/game/src/main/kotlin/content/skill/summoning/familiar/Hydra.kt new file mode 100644 index 0000000000..7386ef5574 --- /dev/null +++ b/game/src/main/kotlin/content/skill/summoning/familiar/Hydra.kt @@ -0,0 +1,68 @@ +package content.skill.summoning.familiar + +import content.entity.player.dialogue.Happy +import content.entity.player.dialogue.Neutral +import content.entity.player.dialogue.type.npc +import content.entity.player.dialogue.type.player +import content.skill.summoning.FamiliarSpecialMoves +import content.skill.summoning.follower +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.client.message +import world.gregs.voidps.engine.entity.obj.GameObjects +import world.gregs.voidps.type.random + +class Hydra : Script { + init { + // Regrowth - the hydra regrows a felled tree from its stump. Object-target special through + // the scroll + points gate, so it only charges on a real regrow. Farming-patch trees are + // player-side variables rather than world stumps, so they're beyond the hydra's power. + FamiliarSpecialMoves.obj("hydra_familiar") { stump -> + if (!stump.id.endsWith("_stump") || stump.id.startsWith("farming_")) { + message("Your familiar can only regrow the stumps of felled trees.") + return@obj false + } + val hydra = follower ?: return@obj false + hydra.anim("regrowth") + hydra.gfx("regrowth") + // Fire the stump's pending regrow timer to bring the tree back at once; a stump with no + // timer is itself a replacement, so removing it restores the original tree. + if (!GameObjects.timers.execute(stump)) { + GameObjects.remove(stump) + } + true + } + + npcOperate("Interact", "hydra_familiar") { + when (random.nextInt(4)) { + 0 -> { + npc("Raaaspraaasp? (Isn't it hard to get things done with just one head?)") + player("Not really!") + npc("Raaasp raaaaap raaaasp? (Well I suppose you work with what you got, right?") + npc("Raaaaaasp raaaasp raaaasp. (At least he doesn't have someone whittering in their ear all the time.)") + npc("Raaaaaaasp! (Quiet, you!)") + } + 1 -> { + npc("Raaaasp raaaasp! (Man, I feel good!)") + npc("Raaasp ssssss raaaasp. (That's easy for you to say.)") + player("What's up?") + npc("Raaa.... (well...)") + npc("Raaaaasp sss rassssp. (Don't pay any attention, they are just feeling whiny.)") + player("But they're you, aren't they?") + npc("Raaaasp raasp rasssp! (Don't remind me!)") + } + 2 -> { + npc("Rassssp rasssssp! (You know, two heads are better than one!)") + npc("Raaaasp rassssp sssssp.... (Unless you're the one doing all the heavy thinking....)") + player("I think I'll stick to one for now, thanks.") + } + 3 -> { + npc("Raaaaaaasp. (Siiiigh.)") + npc("Raasp raasp raaaaasp? (What's up this time?)") + player("Can I help?") + npc("Rasssp ssssssp? raaaaasp raaaasp. (Do you mind? This is a private conversation.)") + player("Well, excu-u-use me.") + } + } + } + } +} diff --git a/game/src/main/kotlin/content/skill/summoning/familiar/Ibis.kt b/game/src/main/kotlin/content/skill/summoning/familiar/Ibis.kt new file mode 100644 index 0000000000..4b6efe6bca --- /dev/null +++ b/game/src/main/kotlin/content/skill/summoning/familiar/Ibis.kt @@ -0,0 +1,45 @@ +package content.skill.summoning.familiar + +import content.entity.player.dialogue.Happy +import content.entity.player.dialogue.Neutral +import content.entity.player.dialogue.type.npc +import content.entity.player.dialogue.type.player +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.type.random + +class Ibis : Script { + init { + npcOperate("Interact", "ibis_familiar") { + if (inventory.contains("raw_shark", 2)) { + npc("Can I look after those sharks for you?") + player("I don't know. Would you eat them?") + npc("Yes! Ooops...") + player("I think I'll hang onto them myself for now.") + return@npcOperate + } + when (random.nextInt(4)) { + 0 -> { + npc("I'm the best fisherman ever!") + player("Where is your skillcape to prove it, then?") + npc("At home...") + player("I'll bet it is.") + } + 1 -> { + npc("I'll bet it is.") + player("I like to fish!") + } + 2 -> { + npc("I'll bet it is.") + player("I like to fish!") + } + 3 -> { + npc("Hey, where are we?") + player("What do you mean?") + npc("I just noticed we weren't fishing.") + player("Well, we can't fish all the time.") + } + } + } + } +} diff --git a/game/src/main/kotlin/content/skill/summoning/familiar/IceTitan.kt b/game/src/main/kotlin/content/skill/summoning/familiar/IceTitan.kt new file mode 100644 index 0000000000..d135347495 --- /dev/null +++ b/game/src/main/kotlin/content/skill/summoning/familiar/IceTitan.kt @@ -0,0 +1,20 @@ +package content.skill.summoning.familiar + +import content.entity.player.dialogue.Happy +import content.entity.player.dialogue.Neutral +import content.entity.player.dialogue.type.npc +import content.entity.player.dialogue.type.player +import world.gregs.voidps.engine.Script + +class IceTitan : Script { + init { + npcOperate("Interact", "ice_titan_familiar") { + npc("It's too hot here.") + player("It's really not that hot. I think it's rather pleasant.") + npc("Well, it's alright for some. Some of us don't like the heat. I burn easily - well, okay, melt.") + player("Well, at least I know where to get a nice cold drink if I need one.") + npc("What was that?") + player("Nothing. Hehehehe") + } + } +} diff --git a/game/src/main/kotlin/content/skill/summoning/familiar/IronTitan.kt b/game/src/main/kotlin/content/skill/summoning/familiar/IronTitan.kt new file mode 100644 index 0000000000..820392600d --- /dev/null +++ b/game/src/main/kotlin/content/skill/summoning/familiar/IronTitan.kt @@ -0,0 +1,56 @@ +package content.skill.summoning.familiar + +import content.entity.player.dialogue.Happy +import content.entity.player.dialogue.Neutral +import content.entity.player.dialogue.Quiz +import content.entity.player.dialogue.type.npc +import content.entity.player.dialogue.type.player +import world.gregs.voidps.engine.Script +import world.gregs.voidps.type.random + +class IronTitan : Script { + init { + npcOperate("Interact", "iron_titan_familiar") { + when (random.nextInt(4)) { + 0 -> { + player("Titan?") + npc("Yes, boss?") + player("What's that in your hand?") + npc("I'm glad you asked that, boss.") + npc("This is the prototype for the Iron Titan (tm) action figure. You just pull this string here and he fights crime with real action sounds.") + player("Titan?") + npc("Yes, boss?") + player("Never mind.") + } + 1 -> { + npc("Boss!") + player("What?") + npc("I've just had a vision of the future.") + player("I didn't know you were a fortune teller. Let's hear it then.") + npc("Just imagine, boss, an Iron Titan (tm) on every desk.") + player("That doesn't even make sense.") + npc("Hmm. It was a bit blurry, perhaps the future is having technical issues at the moment.") + player("Riiight.") + } + 2 -> { + player("Boss?") + npc("Yes, titan?") + player("You know how you're the boss and I'm the titan?") + npc("Yes?") + player("Do you think we could swap for a bit?") + npc("No, titan!") + player("Aww...") + } + 3 -> { + player("How are you today, titan?") + npc("I'm very happy.") + player("That's marvellous, why are you so happy?") + npc("Because I love the great taste of Iron Titan (tm) cereal!") + player("?") + player("You're supposed to be working for me, not promoting yourself.") + npc("Sorry, boss.") + } + } + } + } +} diff --git a/game/src/main/kotlin/content/skill/summoning/familiar/KaramthulhuOverlord.kt b/game/src/main/kotlin/content/skill/summoning/familiar/KaramthulhuOverlord.kt new file mode 100644 index 0000000000..ad811b9920 --- /dev/null +++ b/game/src/main/kotlin/content/skill/summoning/familiar/KaramthulhuOverlord.kt @@ -0,0 +1,100 @@ +package content.skill.summoning.familiar + +import content.entity.combat.target +import content.entity.player.dialogue.Frustrated +import content.entity.player.dialogue.Happy +import content.entity.player.dialogue.Neutral +import content.entity.player.dialogue.Scared +import content.entity.player.dialogue.Shifty +import content.entity.player.dialogue.type.npc +import content.entity.player.dialogue.type.player +import content.entity.player.dialogue.type.statement +import content.skill.summoning.familiarSpecialHit +import content.skill.summoning.follower +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.client.message +import world.gregs.voidps.engine.entity.character.npc.NPC +import world.gregs.voidps.engine.entity.character.player.skill.Skill +import world.gregs.voidps.type.random + +class KaramthulhuOverlord : Script { + init { + // "Drown" makes the overlord instantly blast the owner's current foe with its magic-based + // water attack, at the cost of two summoning points. + npcOperate("Drown", "karamthulhu_overlord_familiar") { (clicked) -> + if (clicked != follower) { + return@npcOperate + } + val enemy = (follower?.target ?: target) as? NPC + if (enemy == null) { + message("Your familiar has no target to attack.") + return@npcOperate + } + if (levels.get(Skill.Summoning) < 2) { + message("You do not have enough summoning points to do that.") + return@npcOperate + } + val cast = familiarSpecialHit( + enemy, + maxHit = 76, + anim = "karamthulhu_overlord_familiar_attack", + sourceGfx = "karamthulhu_overlord_familiar_attack_gfx", + projectile = "karamthulhu_overlord_familiar_projectile", + ) + if (cast) { + levels.drain(Skill.Summoning, 2) + } + } + + npcOperate("Interact", "karamthulhu_overlord_familiar") { + when (random.nextInt(4)) { + 0 -> { + player("Do you want-") + npc("Silence!") + player("But I only...") + npc("Silence!") + player("Now, listen here...") + npc("SIIIIIILLLLLEEEEENCE!") + player("Fine!") + npc("Good!") + player("Maybe I'll be so silent you'll think I never existed") + npc("Oh, how I long for that day...") + } + 1 -> { + npc("Kneel before my awesome might!") + player("I would, but I have a bad knee you see...") + npc("Your feeble prattlings matter not, air-breather! Kneel or face my wrath!") + player("I'm not afraid of you. You're only a squid in a bowl!") + npc("Only? I, radiant in my awesomeness, am 'only' a squid in a bowl? Clearly you need to be shown in your place, lung-user!") + statement("*The Karamthulhu overlord narrows its eye and you find yourself unable to breathe!") + player("Gaak! Wheeeze!") + npc("Who rules?") + player("You rule!") + npc("And don't forget it!") + } + 2 -> { + player("...") + npc("The answer 'be silent'!") + player("You have no idea what I was going to ask you.") + npc("Yes I do; I know all!") + player("Then you will not be surprised to know I was going to ask you what you wanted to do today.") + npc("You dare doubt me!") + npc("The answer 'be silent' because your puny compressed brain could not even begin to comprehend my needs!") + player("Well, how about I dismiss you so you can go and do what you like?") + npc("Well, how about I topple your nations into the ocean and dance my tentacle-waving victory dance upon your watery graves?") + player("Yeah...well...") + npc("Silence! Your burbling vexes me greatly!") + } + 3 -> { + player("Errr...Have you calmed down yet?") + npc("Calmed down? Why would I need to calm down?") + player("Well there is that whole 'god complex' thing...") + npc("Complex? What 'complex' are you drooling about this time, minion?") + npc("It is a sad thing indeed when a god as powerful as I cannot gain recognition from the foolish mewling sheep of this 'surface' place.") + player("I don't really think sheep really make mewling noises...") + npc("Silence!") + } + } + } + } +} diff --git a/game/src/main/kotlin/content/skill/summoning/familiar/LavaTitan.kt b/game/src/main/kotlin/content/skill/summoning/familiar/LavaTitan.kt new file mode 100644 index 0000000000..421330e7a3 --- /dev/null +++ b/game/src/main/kotlin/content/skill/summoning/familiar/LavaTitan.kt @@ -0,0 +1,46 @@ +package content.skill.summoning.familiar + +import content.entity.player.dialogue.Happy +import content.entity.player.dialogue.Neutral +import content.entity.player.dialogue.type.choice +import content.entity.player.dialogue.type.npc +import content.entity.player.dialogue.type.player +import content.skill.summoning.familiarTeleport +import content.skill.summoning.follower +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.type.Tile + +class LavaTitan : Script { + init { + npcOperate("Interact", "lava_titan_familiar") { (target) -> + if (target != follower) { + return@npcOperate + } + choice { + option("Talk-to") { + talk() + } + option("Teleport to Lava Maze") { + choice("Are you sure you want to teleport here? It's very high wilderness.") { + option("Yes. I'm sure.") { + familiarTeleport(Tile(3030, 3838), "the lava titan") + } + option("Nevermind. That sounds dangerous.") + } + } + } + } + } + + private suspend fun Player.talk() { + player("Isn't it a lovely day, Titan?") + npc("It is quite beautiful. The perfect sort of day for a limerick. Perhaps, I could tell you one?") + player("That sounds splendid.") + npc("There once was a bard of Edgeville,") + npc("Whose limericks were quite a thrill,") + npc("He wrote this one here,") + npc("His best? Nowhere near,") + npc("But at least half a page it did fill.") + } +} diff --git a/game/src/main/kotlin/content/skill/summoning/familiar/Macaw.kt b/game/src/main/kotlin/content/skill/summoning/familiar/Macaw.kt new file mode 100644 index 0000000000..584355e5ad --- /dev/null +++ b/game/src/main/kotlin/content/skill/summoning/familiar/Macaw.kt @@ -0,0 +1,29 @@ +package content.skill.summoning.familiar + +import content.entity.player.dialogue.Happy +import content.entity.player.dialogue.Neutral +import content.entity.player.dialogue.type.npc +import content.entity.player.dialogue.type.player +import world.gregs.voidps.engine.Script +import world.gregs.voidps.type.random + +class Macaw : Script { + init { + npcOperate("Interact", "macaw_familiar") { + when (random.nextInt(3)) { + 0 -> { + npc("Awk! Gimme the rum! Gimme the rum!") + player("I don't think you'll like the stuff. Besides, I think there is a law about feeding birds alcohol.") + } + 1 -> { + npc("Awk! I'm a pirate! Awk! Yo, ho ho!") + player("I'd best not keep you around any customs officers!") + } + 2 -> { + npc("Awk! Caw! Shiver me timbers!") + player("I wonder where you picked up all these phrases?") + } + } + } + } +} diff --git a/game/src/main/kotlin/content/skill/summoning/familiar/Magpie.kt b/game/src/main/kotlin/content/skill/summoning/familiar/Magpie.kt new file mode 100644 index 0000000000..1c27bb9394 --- /dev/null +++ b/game/src/main/kotlin/content/skill/summoning/familiar/Magpie.kt @@ -0,0 +1,34 @@ +package content.skill.summoning.familiar + +import content.entity.player.dialogue.Happy +import content.entity.player.dialogue.Neutral +import content.entity.player.dialogue.type.npc +import content.entity.player.dialogue.type.player +import world.gregs.voidps.engine.Script +import world.gregs.voidps.type.random + +class Magpie : Script { + init { + npcOperate("Interact", "magpie_familiar") { + when (random.nextInt(4)) { + 0 -> { + npc("There's nowt gannin on here...") + player("Err...sure? Maybe?") + player("It seems upset, but what is it saying?") + } + 1 -> { + npc("Howway, let's gaan see what's happenin' in toon.") + player("What? I can't understand what you're saying.") + } + 2 -> { + npc("Are we gaan oot soon? I'm up fer a good walk me.") + player("That...that was just noise. What does that mean?") + } + 3 -> { + npc("Ye' been plowdin' i' the claarts aall day.") + player("What? That made no sense.") + } + } + } + } +} diff --git a/game/src/main/kotlin/content/skill/summoning/familiar/Meerkats.kt b/game/src/main/kotlin/content/skill/summoning/familiar/Meerkats.kt new file mode 100644 index 0000000000..c4f7df7881 --- /dev/null +++ b/game/src/main/kotlin/content/skill/summoning/familiar/Meerkats.kt @@ -0,0 +1,20 @@ +package content.skill.summoning.familiar + +import content.entity.player.dialogue.Happy +import content.entity.player.dialogue.Neutral +import content.entity.player.dialogue.type.npc +import content.entity.player.dialogue.type.player +import world.gregs.voidps.engine.Script + +class Meerkats : Script { + init { + npcOperate("Interact", "meerkats_familiar") { + npc("Chatter Chatter. Chatter chatter chatter chatter chatter. Chatter! (We're pretty unlucky. Often, we hit a box when we try to burrow where you tell us. Very suspicious!)") + player("Well, if we remove all the boxes, you'll be able to burrow anywhere!") + npc("Chatter chatter chatter! (Then the boxes must be removed!)") + npc("Chatter! (Agreed!)") + npc("Chatter chatter chatter! (Let's dig out those boxes!)") + player("That's the spirit!") + } + } +} diff --git a/game/src/main/kotlin/content/skill/summoning/familiar/Minotaur.kt b/game/src/main/kotlin/content/skill/summoning/familiar/Minotaur.kt new file mode 100644 index 0000000000..0d617ea344 --- /dev/null +++ b/game/src/main/kotlin/content/skill/summoning/familiar/Minotaur.kt @@ -0,0 +1,58 @@ +package content.skill.summoning.familiar + +import content.entity.player.dialogue.Happy +import content.entity.player.dialogue.Neutral +import content.entity.player.dialogue.type.npc +import content.entity.player.dialogue.type.player +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.entity.character.player.equip.equipped +import world.gregs.voidps.network.login.protocol.visual.update.player.EquipSlot +import world.gregs.voidps.type.random + +class Minotaur : Script { + init { + npcOperate("Interact", "*_minotaur_familiar") { + val hat = equipped(EquipSlot.Hat).id + if (hat.contains("guthan") || hat.contains("berserker") || hat.contains("archer_helm") || hat.contains("dragon_med") || hat.contains("bandos")) { + npc("...") + player("What?") + npc("Are you having a laugh?") + player("I'm not sure I know what you-") + npc("Listen, no-horns, you have two choices: take off the horns yourself or I'll headbutt you until they fall off.") + player("Yessir.") + npc("Good, no-horns. Let's not have this conversation again.") + return@npcOperate + } + when (random.nextInt(4)) { + 0 -> { + npc("All this walking about is making me angry.") + player("You seem to be quite happy about that.") + npc("Yeah! There's nothing like getting a good rage on and then working it out on some no-horns.") + player("I can't say I know what you mean.") + npc("Well I didn't think a no-horns like you would get it!") + } + 1 -> { + npc("Can you tell me why we're not fighting yet?") + player("Buck up; I'll find you something to hit soon.") + npc("You'd better, no-horns, because that round head of yours is looking mighty axeable.") + } + 2 -> { + npc("Hey, no-horns?") + player("Why do you keep calling me no-horns?") + npc("Do I really have to explain that?") + player("No, thinking about it, it's pretty self-evident.") + npc("Glad we're on the same page, no-horns.") + player("So, what did you want?") + npc("I've forgotten, now. I'm sure it'll come to me later.") + } + 3 -> { + npc("Hey no-horns!") + player("Yes?") + npc("Oh, I don't have anything to say, I was just yelling at you.") + player("Why?") + npc("No reason. I do like to mess with the no-horns, though.") + } + } + } + } +} diff --git a/game/src/main/kotlin/content/skill/summoning/familiar/MossTitan.kt b/game/src/main/kotlin/content/skill/summoning/familiar/MossTitan.kt new file mode 100644 index 0000000000..b8465e5882 --- /dev/null +++ b/game/src/main/kotlin/content/skill/summoning/familiar/MossTitan.kt @@ -0,0 +1,25 @@ +package content.skill.summoning.familiar + +import content.entity.player.dialogue.Happy +import content.entity.player.dialogue.Neutral +import content.entity.player.dialogue.type.npc +import content.entity.player.dialogue.type.player +import content.entity.player.dialogue.type.statement +import world.gregs.voidps.engine.Script + +class MossTitan : Script { + init { + npcOperate("Interact", "moss_titan_familiar") { + npc("Oh, look! A bug!") + player("It's quite a large bug.") + npc("He's so cute! I wanna keep him.") + player("Well, be careful.") + npc("I'm gonna call him Buggie and I'm gonna keep him in a box.") + player("Don't get overexcited.") + npc("I'm gonna feed him and we're gonna be so happy together!") + statement("The Moss titan begins to bounce up and down.") + npc("Aww...Buggie went squish.") + player("Sigh.") + } + } +} diff --git a/game/src/main/kotlin/content/skill/summoning/familiar/ObsidianGolem.kt b/game/src/main/kotlin/content/skill/summoning/familiar/ObsidianGolem.kt new file mode 100644 index 0000000000..fd35231ec7 --- /dev/null +++ b/game/src/main/kotlin/content/skill/summoning/familiar/ObsidianGolem.kt @@ -0,0 +1,57 @@ +package content.skill.summoning.familiar + +import content.entity.player.dialogue.Happy +import content.entity.player.dialogue.Neutral +import content.entity.player.dialogue.type.npc +import content.entity.player.dialogue.type.player +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.inv.equipment +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.type.random + +class ObsidianGolem : Script { + init { + npcOperate("Interact", "obsidian_golem_familiar") { + if (inventory.contains("fire_cape") || equipment.contains("fire_cape")) { + npc("Truly, you are a powerful warrior, Master!") + player("I'm pleased you think so.") + npc("It is my duty to respect you, Master.") + player("Oh, So you're just saying that to make me happy...") + npc("I obey all orders, Master.") + return@npcOperate + } + when (random.nextInt(4)) { + 0 -> { + npc("Let us go forth and prove our strength, Master!") + player("Where would you like to prove it?") + npc("The caves of the TzHaar are filled with monsters for us to defeat, Master! TzTok-Jad shall quake in his slippers!") + player("Have you ever met TzTok-Jad?") + npc("Alas, Master, I have not. No Master has ever taken me to see him.") + } + 1 -> { + npc("How many foes have you defeated, Master?") + player("Quite a few, I should think.") + npc("Was your first foe as mighty as the volcano, Master?") + player("Um, not quite.") + npc("I am sure it must have been a deadly opponent, Master!") + player("*Cough* It might have been a chicken. *Cough*") + } + 2 -> { + npc("Master! We are truly a mighty duo!") + player("Do you think so?") + npc("Of course, Master! I am programmed to believe so.") + player("Do you do anything you're not programmed to?") + npc("No, Master.") + player("I guess that makes things simple for you...") + } + 3 -> { + npc("Do you ever doubt your programming, Master?") + player("I don't have programming. I can think about whatever I like.") + npc("What do you think about, Master?") + player("Oh, simple things: the sound of one hand clapping, where the gods come from...Simple things.") + npc("Paradox check = positive. Error. Reboot.") + } + } + } + } +} diff --git a/game/src/main/kotlin/content/skill/summoning/familiar/PackYak.kt b/game/src/main/kotlin/content/skill/summoning/familiar/PackYak.kt new file mode 100644 index 0000000000..0a3eb24f58 --- /dev/null +++ b/game/src/main/kotlin/content/skill/summoning/familiar/PackYak.kt @@ -0,0 +1,13 @@ +package content.skill.summoning.familiar + +import content.entity.player.dialogue.Neutral +import content.entity.player.dialogue.type.npc +import world.gregs.voidps.engine.Script + +class PackYak : Script { + init { + npcOperate("Interact", "pack_yak_familiar") { + npc("Barroobaroooo baaaaaaaaarooo!") + } + } +} diff --git a/game/src/main/kotlin/content/skill/summoning/familiar/Phoenix.kt b/game/src/main/kotlin/content/skill/summoning/familiar/Phoenix.kt new file mode 100644 index 0000000000..1b1f3537bb --- /dev/null +++ b/game/src/main/kotlin/content/skill/summoning/familiar/Phoenix.kt @@ -0,0 +1,106 @@ +package content.skill.summoning.familiar + +import content.entity.combat.hit.hit +import content.entity.player.dialogue.Happy +import content.entity.player.dialogue.Neutral +import content.entity.player.dialogue.type.npc +import content.entity.player.dialogue.type.player +import content.skill.summoning.FamiliarSpecialMoves +import content.skill.summoning.follower +import content.skill.summoning.nearbyAttackableNpcs +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.client.message +import world.gregs.voidps.engine.entity.character.mode.Follow +import world.gregs.voidps.engine.entity.character.mode.PauseMode +import world.gregs.voidps.engine.entity.character.move.tele +import world.gregs.voidps.engine.entity.character.player.chat.ChatType +import world.gregs.voidps.engine.entity.character.player.skill.Skill +import world.gregs.voidps.engine.entity.item.floor.FloorItems +import world.gregs.voidps.engine.queue.queue +import world.gregs.voidps.type.random + +/** How long the phoenix drops in place and glows before reappearing atop the ashes. */ +private const val RISE_REBIRTH_DELAY = 3 + +/** How many enemies around the ashes the rebirth flare can scorch. */ +private const val RISE_MAX_TARGETS = 5 + +/** How long the reborn phoenix stands over the spent ashes before falling back in behind its owner. */ +private const val RISE_STAND_TICKS = 4 + +class Phoenix : Script { + init { + // A plain click on the cast button has no target - point at the trigger. + FamiliarSpecialMoves.instant("phoenix_familiar") { + message("Cast Rise from the Ashes on a pile of ashes on the ground.") + false + } + + FamiliarSpecialMoves.floorItem("phoenix_familiar") { ashes -> + if (ashes.id != "ashes") { + message("The phoenix can only rise from ashes.") + return@floorItem false + } + val phoenix = follower ?: return@floorItem false + // The rebirth's fury: a quarter of the life points the phoenix was missing. + val maxHit = (phoenix.levels.getMax(Skill.Constitution) - phoenix.levels.get(Skill.Constitution)) / 4 + phoenix.anim("phoenix_familiar_death") + phoenix.gfx("summon_familiar_size_${phoenix.size}") + queue("rise_from_the_ashes", RISE_REBIRTH_DELAY) { + FloorItems.remove(ashes) + phoenix.tele(ashes.tile, clearMode = false) + // Stand over the spent ashes for a moment - the follow would otherwise drag the + // reborn phoenix straight back to its owner mid-flare. + phoenix.mode = PauseMode + phoenix.watch(this) + phoenix.anim("phoenix_spawn") + phoenix.levels.restore(Skill.Constitution, phoenix.levels.getMax(Skill.Constitution)) + for (scorched in nearbyAttackableNpcs(ashes.tile, radius = 1).take(RISE_MAX_TARGETS)) { + phoenix.hit(scorched, offensiveType = "magic", damage = random.nextInt(maxHit + 1)) + } + message("Your phoenix rises from the ashes, its wounds burning away.", ChatType.Filter) + } + queue("rise_from_the_ashes_return", RISE_REBIRTH_DELAY + RISE_STAND_TICKS) { + if (follower == phoenix && phoenix.mode is PauseMode) { + phoenix.mode = Follow(phoenix, this) + } + } + true + } + + npcOperate("Interact", "phoenix_familiar") { + when (random.nextInt(4)) { + 0 -> { + npc("Skreee skree skrooo skrooooouuu. (I want to burn something.)") + player("Why are you looking at me like that?") + npc("Skeeeeooouoou! Skree skrooo, skrooouuee skreee! (Please! It won't hurt that much, and I'll bring you back straight away!)") + player("Maybe later. Much later. When I'm dead from natural causes already. And medicine has failed to bring me back.") + npc("Skreee skreeeooouu skroou! (I'll hold you to it!)") + } + 1 -> { + npc("May I ask you a question?") + player("Skreeoooouuu, skreeee skreeeeoooo. (Yes, but you have already asked me a question.)") + player("Skreeeooo, skreee skreeeeee skreeoooo. (You should have said 'May I ask you two questions?'.)") + npc("Erm, may I ask you two questions?") + player("Skroo. (No.)") + npc("...") + } + 2 -> { + player("May I ask you... TWO questions?") + npc("Skree ree ree! Skree, skreee skrooou skreeeoou. (Heh heh heh. The answer to your first is yes. You may ask your second.)") + player("What was RuneScape like in the distant past?") + npc("Skreee skreeeeout skreeou. Skreee skree. (It was like it is now, only younger.)") + player("...") + player("You, madam, are the most pestiferous poultry I have ever met.") + npc("Skree ree ree! (Heh heh heh!)") + } + 3 -> { + player("Skreeee, skree skrooo. Skrooooou skreee!") + npc("Skreee skroooue, skreeee skreeeeeeeou. (Either you need to practice your phoenixspeak, or I should burn you where you stand.)") + player("So that didn't mean 'How are you feeling today?'") + npc("Skroo. Skroo, skreee skreou. (No, it didn't.") + } + } + } + } +} diff --git a/game/src/main/kotlin/content/skill/summoning/familiar/PrayingMantis.kt b/game/src/main/kotlin/content/skill/summoning/familiar/PrayingMantis.kt new file mode 100644 index 0000000000..490ffef87e --- /dev/null +++ b/game/src/main/kotlin/content/skill/summoning/familiar/PrayingMantis.kt @@ -0,0 +1,57 @@ +package content.skill.summoning.familiar + +import content.entity.player.dialogue.Happy +import content.entity.player.dialogue.Neutral +import content.entity.player.dialogue.type.npc +import content.entity.player.dialogue.type.player +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.inv.equipment +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.type.random + +class PrayingMantis : Script { + init { + npcOperate("Interact", "praying_mantis_familiar") { + if (inventory.contains("butterfly_net") || equipment.contains("butterfly_net")) { + npc("Clatter click chitter click? (Wouldn't you learn focus better if you used chopsticks?)") + player("Huh?") + npc("Clicker chirrpchirrup. (For catching the butterflies, grasshopper.)") + player("Oh, right! Well, if I use anything but the net I squash them.") + npc("Chirrupchirrup click! (Then, I could have them!)") + return@npcOperate + } + when (random.nextInt(4)) { + 0 -> { + npc("Chitter chirrup chirrup? (Have you been following your training, grasshopper?)") + player("Yes, almost every day.") + npc("Chirrupchirrup chirrup. ('Almost' is not good enough.)") + player("Well, I'm trying as hard as I can.") + npc("Chirrup chitter chitter chirrup? (How do you expect to achieve enlightenment at this rate, grasshopper?)") + player("Spontaneously.") + } + 1 -> { + npc("Chitterchitter chirrup clatter. (Today, grasshopper, I will teach you to walk on rice paper.)") + player("What if I can't find any?") + npc("Clatter chitter click chitter... (Then we will wander about and punch monsters in the head...)") + player("I could do in an enlightened way if you want?") + npc("Chirrupchitter! (That will do!)") + } + 2 -> { + npc("Clatter chirrup chirp chirrup clatter clatter. (A wise man once said; 'Feed your mantis and it will be happy'.)") + player("Is there any point to that saying?") + npc("Clatter chirrupchirrup chirp. (I find that a happy mantis is its own point.)") + } + 3 -> { + npc("Clatter chirrupchirp- (Today, grasshopper, we will-)") + player("You know, I'd rather you call me something other than grasshopper.") + npc("Clitterchirp? (Is there a reason for this?)") + player("You drool when you say it.") + npc("Clickclatter! Chirrup chirpchirp click chitter... (I do not! Why would I drool when I cann you a juicy...)") + npc("...clickclick chitter clickchitter click... (...succulent, nourishing, crunchy...)") + npc("*Drooool*") + player("You're doing it again!") + } + } + } + } +} diff --git a/game/src/main/kotlin/content/skill/summoning/familiar/Pyrelord.kt b/game/src/main/kotlin/content/skill/summoning/familiar/Pyrelord.kt new file mode 100644 index 0000000000..7709c6af53 --- /dev/null +++ b/game/src/main/kotlin/content/skill/summoning/familiar/Pyrelord.kt @@ -0,0 +1,169 @@ +package content.skill.summoning.familiar + +import content.entity.player.dialogue.Happy +import content.entity.player.dialogue.Neutral +import content.entity.player.dialogue.type.npc +import content.entity.player.dialogue.type.player +import content.skill.summoning.FamiliarSpecialMoves +import content.skill.summoning.castFamiliarSpecial +import content.skill.summoning.follower +import org.rsmod.game.pathfinder.StepValidator +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.client.message +import world.gregs.voidps.engine.client.ui.open +import world.gregs.voidps.engine.data.definition.Rows +import world.gregs.voidps.engine.entity.World +import world.gregs.voidps.engine.entity.character.mode.Follow +import world.gregs.voidps.engine.entity.character.mode.PauseMode +import world.gregs.voidps.engine.entity.character.mode.move.canTravel +import world.gregs.voidps.engine.entity.character.player.Player +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.item.floor.FloorItems +import world.gregs.voidps.engine.entity.obj.GameObjects +import world.gregs.voidps.engine.entity.obj.ObjectLayer +import world.gregs.voidps.engine.entity.obj.ObjectShape +import world.gregs.voidps.engine.get +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.engine.inv.remove +import world.gregs.voidps.engine.queue.queue +import world.gregs.voidps.type.random + +class Pyrelord : Script { + init { + npcOperate("Interact", "pyrelord_familiar") { (target) -> + if (target != follower) { + return@npcOperate + } + when (random.nextInt(4)) { + 0 -> { + npc("What are we doing here?") + player("Whatever I feel like doing.") + npc("I was summoned by a greater demon once you know.") + npc("He said we'd see the world...") + player("What happened?") + npc("He was slain; it was hilarious!") + } + 1 -> { + npc("I used to be feared across five planes...") + player("Oh dear, now you're going to be sad all day!") + npc("At least I won't be the only one.") + } + 2 -> { + npc("I could teach you to smite your enemies with flames.") + player("You're not the only one: we have runes to do that.") + npc("Runes? Oh, that's so cute!") + player("Cute?") + npc("Well, not cute so much as tragic. I could teach you to do it without runes.") + player("Really?") + npc("No.") + } + 3 -> { + npc("Have you never been on fire?") + player("You say that like it's a bad thing.") + npc("Isn't it? It gives me the heebie-jeebies!") + player("You're afraid of something?") + npc("Yes: I'm afraid of being you.") + player("I don't think he likes me...") + } + } + } + + // A plain click on the cast button has no item to work on - point at the real triggers. + FamiliarSpecialMoves.instant("pyrelord_familiar") { + message("To cast Immense Heat, use the Cast option or a gold bar on the pyrelord.") + false + } + + // Immense Heat - the pyrelord's flames stand in for a furnace, letting the owner craft + // gold jewellery on the spot. Cast on a gold bar, or use the bar on the familiar - both + // run through the scroll + points gate. + FamiliarSpecialMoves.item("pyrelord_familiar") { item -> immenseHeat(item.id) } + + itemOnNPCOperate("gold_bar", "pyrelord_familiar") { (npc, item) -> + if (npc != follower) { + return@itemOnNPCOperate + } + castFamiliarSpecial { immenseHeat(item.id) } + } + + // The pyrelord and forge regent act as portable fire sources - the demon breathes fire over + // the logs, lighting them beneath itself, granting the log's xp plus a bonus for its help. + for (familiar in listOf("pyrelord_familiar", "forge_regent_familiar")) { + itemOnNPCOperate("*logs*", familiar) { (target, item) -> + if (target != follower) { + message("That's not your familiar.") + return@itemOnNPCOperate + } + val row = Rows.getOrNull("firemaking.${item.id}") + if (row == null) { + message("The ${target.def.name.lowercase()} only burns logs.") + return@itemOnNPCOperate + } + val level = row.int("level") + if (!has(Skill.Firemaking, level, true)) { + return@itemOnNPCOperate + } + if (GameObjects.getLayer(target.tile, ObjectLayer.GROUND) != null) { + message("You can't light a fire here.") + return@itemOnNPCOperate + } + if (!inventory.remove(item.id)) { + return@itemOnNPCOperate + } + // The logs land at the familiar's feet first, as with a tinderbox, then it breathes + // fire over them a moment later. Aborts, fireless, if they're gone by then. + val logs = FloorItems.add(target.tile, item.id, disappearTicks = 300, owner = this) + target.anim("familiar_light_fire") + pause(2) + if (!FloorItems.remove(logs)) { + return@itemOnNPCOperate + } + exp(Skill.Firemaking, row.int("xp") / 10.0 + 10) + val colour = row.string("colour") + val life = row.int("life") + GameObjects.add("fire_$colour", target.tile, shape = ObjectShape.CENTRE_PIECE_STRAIGHT, rotation = 0, ticks = life) + FloorItems.add(target.tile, "ashes", revealTicks = life, disappearTicks = 60, owner = "") + message("The ${target.def.name.lowercase()} breathes fire and the logs begin to burn.", ChatType.Filter) + // The familiar steps off its new fire westward, as players do, then waits beside it + // facing its owner - resuming the follow straight away would walk it right back onto + // the fire, so it stands by (PauseMode, like the beaver's chop) until the owner moves. + val steps: StepValidator = get() + if (steps.canTravel(target, -1, 0)) { + val owner = this + target.queue("familiar_step_west") { + val dest = target.tile.addX(-1) + target.walkTo(dest) + var ticks = 0 + while (target.tile != dest && ticks++ < 5) { + delay() + } + target.mode = PauseMode + target.watch(owner) + val stand = owner.tile + while (owner.tile == stand && owner.follower == target && target.mode is PauseMode) { + delay() + } + if (owner.follower == target && target.mode is PauseMode) { + target.mode = Follow(target, owner) + } + } + } + } + } + } + + /** The Immense Heat effect: melt a gold bar into the jewellery mould interface, no furnace needed. */ + private fun Player.immenseHeat(itemId: String): Boolean { + if (itemId != "gold_bar") { + message("The pyrelord can only work its heat on gold bars.") + return false + } + anim("immense_heat") + gfx("immense_heat") + open("make_mould${if (World.members) "_slayer" else ""}") + return true + } +} diff --git a/game/src/main/kotlin/content/skill/summoning/familiar/RavenousLocust.kt b/game/src/main/kotlin/content/skill/summoning/familiar/RavenousLocust.kt new file mode 100644 index 0000000000..5d71e76404 --- /dev/null +++ b/game/src/main/kotlin/content/skill/summoning/familiar/RavenousLocust.kt @@ -0,0 +1,46 @@ +package content.skill.summoning.familiar + +import content.entity.player.dialogue.Happy +import content.entity.player.dialogue.Neutral +import content.entity.player.dialogue.type.npc +import content.entity.player.dialogue.type.player +import world.gregs.voidps.engine.Script +import world.gregs.voidps.type.random + +class RavenousLocust : Script { + init { + npcOperate("Interact", "ravenous_locust_familiar") { + when (random.nextInt(4)) { + 0 -> { + npc("Click whiiine whiiiiine click click? (Hey, man, can you spare some lentils?)") + player("What would you want with lentils?") + npc("Whiiiiiinewhiiiiiiine click whiiiiiiiine. (I was going to make a casserole.)") + player("How? You don't have a fire, pans or thumbs.") + npc("Whiiiiiiiiiiiiine! (Stop hassling me, man.)") + } + 1 -> { + npc("Whiiiiiine click click! (Man, it's a totally groovy day.)") + player("That it is.") + npc("Whiiiiine whiiiiine whinewhiiiine. (Now, if only I wasn't being held down by 'The Man'.)") + player("Which man?") + npc("Clickclack whiiiiiine whiiiiinewhiiine. ('The Man'; the one that keeps harshing my mellow.)") + player("'Harshing your mellow'? Okay, I don't want to know any more.") + } + 2 -> { + npc("Whiiiiiine... (Siiiiigh...)") + player("What's the matter?") + npc("Whiiiine whiiiineclickwhiiiiine whine... (I was just thinking about how meat is murder...)") + player("But it isn't. Killing someone is murder.") + npc("Click click! (Good point.)") + } + 3 -> { + npc("Whiiiiine whinewhiiiine? (Man, how about time?)") + player("I think it's about midday.") + npc("Clickwhiiiiine whiiiiiiiiiiiiine... (No, man. Isn't time, like, massive?)") + player("I don't think an abstract concept can have mass...") + npc("Whineclick click! (Oh, man, that's heavy.)") + } + } + } + } +} diff --git a/game/src/main/kotlin/content/skill/summoning/familiar/Skinweaver.kt b/game/src/main/kotlin/content/skill/summoning/familiar/Skinweaver.kt new file mode 100644 index 0000000000..9f900ae00f --- /dev/null +++ b/game/src/main/kotlin/content/skill/summoning/familiar/Skinweaver.kt @@ -0,0 +1,49 @@ +package content.skill.summoning.familiar + +import content.entity.player.dialogue.Frustrated +import content.entity.player.dialogue.Happy +import content.entity.player.dialogue.type.npc +import content.entity.player.dialogue.type.player +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.entity.character.player.name +import world.gregs.voidps.type.random + +class Skinweaver : Script { + init { + npcOperate("Interact", "*_skinweaver_familiar") { + if (!this["talked_to_skinweaver", false]) { + this["talked_to_skinweaver"] = true + npc("This is my first time on this plane, naabe. I hope I can serve you well.") + return@npcOperate + } + when (random.nextInt(4)) { + 0 -> { + player("What is your name? My name is $name, by the way.") + npc("I get confused with these titles you humans take pride in. Fremennik names, first names, last names... Gorajo have no need to be individual - to have a name that no-one else has.") + player("How can you not have a name?") + npc("Everything we do is for the clan. If a problem arises, a role will be required, not an individual. The individual has no place among the gorajo.") + npc("Please do not take it as rudeness, but I cannot understand how your world functions with names and individuals, naabe.") + } + 1 -> { + player("What does a skinweaver do, exactly?") + npc("We are healers of livestock, crops and other gorajo. Which reminds me, naabe, do you mind if I ask you a question?") + player("Sure.") + npc("Do you find that human organs feel like slippery fish? And that your skin is stretchy like the dried sap of an utuku?") + player("Uh. I'm feeling a little faint.") + npc("Don't worry! I have cat spittle for your head should you fall. And I am on hand to suck any blood clots from your brain.") + } + 2 -> { + player("How do you like it in Daemonheim?.") + npc("Naabe, let me tell you something. When I was a few years younger than I am now, I helped to heal a nustukh: a creature as big as three floors of this place.") + npc("The nustukh do not benefit the gorajo in any way, but they are the reincarnations of our greatest leaders. They have great significance to our people.") + npc("This nustukh was ravaged by a corruption that ate at every one of its organs. A skinweaver was required to crawl in through an open lesion and heal it: I volunteered immediately. I spent two weeks inside.") + npc("I cannot help but be reminded of the nustukh in Daemonheim. The dungeons are as rank and unwholesome, and I feel that my powers are just as ineffective inside.") + } + 3 -> { + player("I don't have any more questions.") + npc("I can understand your curiosity, naabe. Feel free to talk whenever you need.") + } + } + } + } +} diff --git a/game/src/main/kotlin/content/skill/summoning/familiar/SmokeDevil.kt b/game/src/main/kotlin/content/skill/summoning/familiar/SmokeDevil.kt new file mode 100644 index 0000000000..8652fee274 --- /dev/null +++ b/game/src/main/kotlin/content/skill/summoning/familiar/SmokeDevil.kt @@ -0,0 +1,91 @@ +package content.skill.summoning.familiar + +import content.entity.combat.target +import content.entity.player.dialogue.Angry +import content.entity.player.dialogue.Frustrated +import content.entity.player.dialogue.Happy +import content.entity.player.dialogue.Neutral +import content.entity.player.dialogue.type.npc +import content.entity.player.dialogue.type.player +import content.skill.summoning.familiarSpecialHit +import content.skill.summoning.follower +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.client.message +import world.gregs.voidps.engine.entity.character.npc.NPC +import world.gregs.voidps.engine.entity.character.player.skill.Skill +import world.gregs.voidps.type.random + +class SmokeDevil : Script { + init { + // "Flames" makes the devil instantly scorch the owner's current foe with its magic-based + // fire attack, at the cost of two summoning points. + npcOperate("Flames", "smoke_devil_familiar") { (clicked) -> + if (clicked != follower) { + return@npcOperate + } + val enemy = (follower?.target ?: target) as? NPC + if (enemy == null) { + message("Your familiar has no target to attack.") + return@npcOperate + } + if (levels.get(Skill.Summoning) < 2) { + message("You do not have enough summoning points to do that.") + return@npcOperate + } + val cast = familiarSpecialHit( + enemy, + maxHit = 150, + anim = "smoke_devil_familiar_attack", + projectile = "smoke_devil_familiar_flames_projectile", + ) + if (cast) { + levels.drain(Skill.Summoning, 2) + } + } + + npcOperate("Interact", "smoke_devil_familiar") { + when (random.nextInt(4)) { + 0 -> { + npc("When are you going to be done with that?") + player("Soon, I hope.") + npc("Good, because this place is too breezy.") + player("What do you mean?") + npc("I mean, it's tricky to keep hovering in this draft.") + player("Ok, we'll move around a little if you like.") + npc("Yes please!") + } + 1 -> { + npc("Hey!") + player("Yes?") + npc("Where are we going again?") + player("Well, I have a lot of things to do today, so we might go a lot of places.") + npc("Are we there yet?") + player("No, not yet.") + npc("How about now?") + player("No.") + npc("Are we still not there?") + player("NO!") + npc("Okay, just checking.") + } + 2 -> { + npc("Ah, this is the life!") + player("Having a good time up there?") + npc("Yeah! It's great to feel the wind in your tentacles.") + player("Sadly, I don't know what that feels like.") + npc("Why not?") + player("No tentacles for a start.") + npc("Well, nobody's perfect.") + } + 3 -> { + npc("Why is it always so cold here?") + player("I don't think it's that cold.") + npc("It is compared to back home.") + player("How hot is it where you are from?") + npc("I can never remember. What is the vaporisation point of steel again?") + player("Pretty high.") + player("No wonder you feel cold here...") + } + } + } + } +} diff --git a/game/src/main/kotlin/content/skill/summoning/familiar/SpiritCobra.kt b/game/src/main/kotlin/content/skill/summoning/familiar/SpiritCobra.kt new file mode 100644 index 0000000000..97c7235285 --- /dev/null +++ b/game/src/main/kotlin/content/skill/summoning/familiar/SpiritCobra.kt @@ -0,0 +1,120 @@ +package content.skill.summoning.familiar + +import content.entity.player.dialogue.Happy +import content.entity.player.dialogue.Neutral +import content.entity.player.dialogue.type.npc +import content.entity.player.dialogue.type.player +import content.skill.summoning.FamiliarSpecialMoves +import content.skill.summoning.castFamiliarSpecial +import content.skill.summoning.follower +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.client.message +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.entity.character.player.chat.ChatType +import world.gregs.voidps.engine.entity.character.player.equip.equipped +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.engine.inv.replace +import world.gregs.voidps.network.login.protocol.visual.update.player.EquipSlot +import world.gregs.voidps.type.random + +/** The eggs Ophidian Incubation can hatch into their spirit cockatrice-family counterparts. */ +private val INCUBATION_EGGS = mapOf( + "egg" to "cockatrice_egg", + "birds_egg_green" to "guthatrice_egg", + "birds_egg_blue" to "saratrice_egg", + "birds_egg_red" to "zamatrice_egg", + "penguin_egg" to "pengatrice_egg", + "raven_egg" to "coraxatrice_egg", + "vulture_egg" to "vulatrice_egg", +) + +class SpiritCobra : Script { + init { + // A plain click on the cast button has no item to work on - point at the real triggers. + FamiliarSpecialMoves.instant("spirit_cobra_familiar") { + message("To cast Ophidian Incubation, use the Cast option or an egg on the spirit cobra.") + false + } + + // Ophidian Incubation - the spirit cobra transmutes an egg into the cockatrice-family egg of + // the matching god bird. Cast on an egg, or use the egg on the familiar - both run through + // the scroll + points gate. + FamiliarSpecialMoves.item("spirit_cobra_familiar") { item -> incubate(item.id) } + + for (egg in INCUBATION_EGGS.keys) { + itemOnNPCOperate(egg, "spirit_cobra_familiar") { (npc, item) -> + if (npc != follower) { + return@itemOnNPCOperate + } + castFamiliarSpecial { incubate(item.id) } + } + } + + npcOperate("Interact", "spirit_cobra_familiar") { + if (equipped(EquipSlot.Ring).id in setOf("ring_of_charos", "ring_of_charos_a", "ring_of_charos_ai")) { + npc("You are under my power!") + player("No, you are under my power!") + npc("No, you are under my power!") + player("No, my power is greater!") + npc("Your power is the greater...") + player("Your powers are no match for mine!") + npc("You are convinced you have won this argument...") + player("I won the argument...yay!") + npc("*Manic serpentine laughter*") + return@npcOperate + } + when (random.nextInt(4)) { + 0 -> { + npc("Do we have to do thissss right now?") + player("Yes, I'm afraid so.") + npc("You are under my sssspell...") + player("I will do as you ask...") + npc("Do we have to do thissss right now?") + player("Not at all, I had just finished!") + } + 1 -> { + npc("You are feeling ssssleepy...") + player("I am feeling sssso ssssleepy...") + npc("You will bring me lotssss of sssstuff!") + player("What ssssort of sssstuff?") + npc("What ssssort of sssstuff have you got?") + player("All kindsss of sssstuff.") + npc("Then just keep bringing sssstuff until I'm ssssatissssfied!") + } + 2 -> { + npc("I'm bored, do ssssomething to entertain me...") + player("Errr, I'm not here to entertain you, you know.") + npc("You will do as I assssk...") + player("Your will is my command...") + npc("I'm bored, do ssssomething to entertain me...") + player("I'll dance for you!") + } + 3 -> { + npc("I am king of the world!") + player("You know, I think there is a law against snakes being the king.") + npc("My will is your command...") + player("I am yours to command...") + npc("I am king of the world!") + player("All hail King Serpentor!") + } + } + } + } + + /** The Ophidian Incubation effect: transmute a god-bird egg into its cockatrice counterpart. */ + private fun Player.incubate(itemId: String): Boolean { + val product = INCUBATION_EGGS[itemId] + if (product == null) { + message("Your spirit cobra can only incubate eggs.") + return false + } + if (!inventory.replace(itemId, product)) { + return false + } + val cobra = follower ?: return false + cobra.anim("ophidian_incubation") + cobra.gfx("ophidian_incubation") + message("Your spirit cobra incubates the egg with its gaze.", ChatType.Filter) + return true + } +} diff --git a/game/src/main/kotlin/content/skill/summoning/familiar/SpiritDagannoth.kt b/game/src/main/kotlin/content/skill/summoning/familiar/SpiritDagannoth.kt new file mode 100644 index 0000000000..7fb3725932 --- /dev/null +++ b/game/src/main/kotlin/content/skill/summoning/familiar/SpiritDagannoth.kt @@ -0,0 +1,65 @@ +package content.skill.summoning.familiar + +import content.entity.combat.hit.hit +import content.entity.player.dialogue.Happy +import content.entity.player.dialogue.Neutral +import content.entity.player.dialogue.type.npc +import content.entity.player.dialogue.type.player +import world.gregs.voidps.engine.Script +import world.gregs.voidps.type.random + +/** One in [FEROCIOUS_CHANCE] of the spirit dagannoth's melee attacks strikes twice. */ +private const val FEROCIOUS_CHANCE = 5 + +/** The extra ferocious strike rolls up to the dagannoth's own melee max. */ +private const val FEROCIOUS_MAX_HIT = 108 + +class SpiritDagannoth : Script { + init { + // Ferocious - about a fifth of the dagannoth's melee attacks lash out a second time. + npcCombatAttack("spirit_dagannoth_familiar") { attack -> + if (attack.type != "melee" || this["ferocious", false] || random.nextInt(FEROCIOUS_CHANCE) != 0) { + return@npcCombatAttack + } + // The extra hit re-fires this handler - the flag keeps it from chaining. + this["ferocious"] = true + anim("ferocious") + gfx("ferocious") + hit(attack.target, offensiveType = "melee", damage = random.nextInt(FEROCIOUS_MAX_HIT + 1), delay = attack.delay + 30) + clear("ferocious") + } + + npcOperate("Interact", "spirit_dagannoth_familiar") { + when (random.nextInt(4)) { + 0 -> { + npc("Grooooooowl graaaaawl raaaawl? (Are you ready to surrender to the power of the Deep Waters?)") + player("Err, not really.") + npc("Rooooowl? (How about now?)") + player("No, sorry.") + npc("Rooooowl? (How about now?)") + player("No, sorry. You might want to try again a little later.") + } + 1 -> { + npc("Groooooowl. Hsssssssssssssss! (The Deeps will swallow the lands. None will stand before us!)") + player("What if we build boats?") + npc("Hsssssssss groooooowl? Hssssshsss grrooooooowl? (What are boats? The tasty wooden containers full of meat?)") + player("I suppose they could be described as such, yes.") + } + 2 -> { + npc("Hssssss graaaawl grooooowl, growwwwwwwwwl! (Oh how the bleak gulfs hunger for the Day of Rising.)") + player("My brain hurts when I listen to you talk...") + npc("Raaaaawl groooowl grrrrawl! (That's the truth biting into your clouded mind!)") + player("Could you try using a little less truth please?") + } + 3 -> { + npc("Raaaawl! (Submit!)") + player("Submit to what?") + npc("Hssssssssss rawwwwwl graaaawl! (To the inevitable defeat of all life on the Surface!)") + player("I think I'll wait a little longer before I just keep over and submit, thanks") + npc("Hsssss, grooooowl, raaaaawl. (Well, it's your choice, but those that submit first will be eaten first.)") + player("I'll pass on that one, thanks.") + } + } + } + } +} diff --git a/game/src/main/kotlin/content/skill/summoning/familiar/SpiritGraahk.kt b/game/src/main/kotlin/content/skill/summoning/familiar/SpiritGraahk.kt new file mode 100644 index 0000000000..14ed5c4de1 --- /dev/null +++ b/game/src/main/kotlin/content/skill/summoning/familiar/SpiritGraahk.kt @@ -0,0 +1,65 @@ +package content.skill.summoning.familiar + +import content.entity.player.dialogue.Happy +import content.entity.player.dialogue.Neutral +import content.entity.player.dialogue.type.choice +import content.entity.player.dialogue.type.npc +import content.entity.player.dialogue.type.player +import content.skill.summoning.familiarTeleport +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.type.Tile +import world.gregs.voidps.type.random + +class SpiritGraahk : Script { + init { + npcOperate("Interact", "spirit_graahk_familiar") { + choice { + option("Chat") { + chat() + } + // The graahk carries its owner home to the Karamja hunter area, by the nature altar. + option("Teleport") { + familiarTeleport(Tile(2786, 3002), "the graahk") + } + } + } + } + + private suspend fun Player.chat() { + when (random.nextInt(4)) { + 0 -> { + player("Your spikes are looking particularly spiky today.") + npc("Really, you think so?") + player("Yes. Most pointy, indeed.") + npc("That's really kind of you to say. I was going to spike you but I won't now...") + player("Thanks?") + npc("...I'll do it later instead.") + player("*sigh!*") + } + 1 -> { + npc("My spikes hurt, could you pet them for me?") + player("Aww, of course I can I'll just... Oww! I think you drew blood that time.") + } + 2 -> { + npc("Hi!") + player("Hello. Are you going to spike me again?") + npc("No, I got a present to apologise for last time.") + player("That's really sweet, thank you.") + npc("Here you go, it's a special cushion to make you comfortable.") + player("It's made of spikes!") + npc("Yes, but they're therapeutic spikes.") + player("...") + } + 3 -> { + player("How's your day going?") + npc("It's great! Actually I've got something to show you!") + player("Oh? What's that?") + npc("You'll need to get closer!") + player("I can't see anything...") + npc("It's really small - even closer.") + player("Oww! I'm going to have your spikes trimmed!") + } + } + } +} diff --git a/game/src/main/kotlin/content/skill/summoning/familiar/SpiritJelly.kt b/game/src/main/kotlin/content/skill/summoning/familiar/SpiritJelly.kt new file mode 100644 index 0000000000..cb20f52c39 --- /dev/null +++ b/game/src/main/kotlin/content/skill/summoning/familiar/SpiritJelly.kt @@ -0,0 +1,49 @@ +package content.skill.summoning.familiar + +import content.entity.player.dialogue.Happy +import content.entity.player.dialogue.Neutral +import content.entity.player.dialogue.type.npc +import content.entity.player.dialogue.type.player +import world.gregs.voidps.engine.Script +import world.gregs.voidps.type.random + +class SpiritJelly : Script { + init { + npcOperate("Interact", "spirit_jelly_familiar") { + when (random.nextInt(4)) { + 0 -> { + npc("Play play play play!") + player("The only game I have time to play is the 'Staying Very Still' game.") + npc("But that game is soooooo booooooring...") + player("How about we use the extra house rule, that makes it the 'Staying Very Still and Very Quiet' game.") + npc("Happy happy! I love new games!") + } + 1 -> { + npc("It's playtime now!") + player("Okay, how about we play the 'Staying Very Still' game.") + npc("But that game is booooooring...") + player("If you win then you can pick the next game, how about that?") + npc("Happy happy!") + } + 2 -> { + npc("Can we go over there now, please please please pleeeeeease?") + player("Go over where?") + npc("I dunno, someplace fun, please please please!") + player("Okay, but first, let's play the 'Sitting Very Still' game.") + npc("But that game is booooooring...") + player("Well, if you win we can go somewhere else, okay?") + npc("Happy happy!") + } + 3 -> { + npc("What game are we playing now?") + player("It's called the 'Staying Very Still' game.") + npc("This game is booooooring...") + player("Hey, all that moping doesn't look very still to me.") + npc("I never win at this game...") + player("You know what? I think I'll not count it this one time") + npc("Happy happy! You're the best friend ever!") + } + } + } + } +} diff --git a/game/src/main/kotlin/content/skill/summoning/familiar/SpiritKalphite.kt b/game/src/main/kotlin/content/skill/summoning/familiar/SpiritKalphite.kt new file mode 100644 index 0000000000..18bb28a706 --- /dev/null +++ b/game/src/main/kotlin/content/skill/summoning/familiar/SpiritKalphite.kt @@ -0,0 +1,51 @@ +package content.skill.summoning.familiar + +import content.entity.player.dialogue.Happy +import content.entity.player.dialogue.Neutral +import content.entity.player.dialogue.type.npc +import content.entity.player.dialogue.type.player +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.entity.character.player.equip.equipped +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.network.login.protocol.visual.update.player.EquipSlot +import world.gregs.voidps.type.random + +class SpiritKalphite : Script { + init { + npcOperate("Interact", "spirit_kalphite_familiar") { + if (equipped(EquipSlot.Weapon).id.startsWith("keris") || inventory.items.any { it.id.startsWith("keris") }) { + npc("How dare you!") + player("How dare I what?") + npc("That weapon offends us!") + player("How dare you!") + player("What weapon?") + player("That weapon offends us!") + return@npcOperate + } + when (random.nextInt(4)) { + 0 -> { + npc("This activity is not optimal for us.") + player("Well, you'll just have to put up with it for now.") + npc("We would not have to 'put up' with this in the hive.") + } + 1 -> { + npc("We are growing infuriated. What is our goal?") + player("Well, I haven't quite decided yet.") + npc("There is no indecision in the hive.") + player("Or a sense of humour or patience, it seems.") + } + 2 -> { + npc("We find this to be wasteful of our time.") + player("Maybe I find you wasteful...") + npc("We would not face this form of abuse in the hive.") + } + 3 -> { + npc("We grow tired of your antics, biped.") + player("What antics? I'm just getting on with my day.") + npc("In an inefficient way. In the hive, you would be replaced.") + player("In the hive this, in the hive that...") + } + } + } + } +} diff --git a/game/src/main/kotlin/content/skill/summoning/familiar/SpiritKyatt.kt b/game/src/main/kotlin/content/skill/summoning/familiar/SpiritKyatt.kt new file mode 100644 index 0000000000..d87daf6857 --- /dev/null +++ b/game/src/main/kotlin/content/skill/summoning/familiar/SpiritKyatt.kt @@ -0,0 +1,83 @@ +package content.skill.summoning.familiar + +import content.entity.player.dialogue.Happy +import content.entity.player.dialogue.Neutral +import content.entity.player.dialogue.type.choice +import content.entity.player.dialogue.type.npc +import content.entity.player.dialogue.type.player +import content.entity.player.dialogue.type.statement +import content.skill.summoning.familiarTeleport +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.type.Tile +import world.gregs.voidps.type.random + +class SpiritKyatt : Script { + init { + npcOperate("Interact", "spirit_kyatt_familiar") { + choice { + option("Chat") { + chat() + } + // The kyatt carries its owner home to the Piscatoris hunter area. + option("Teleport") { + familiarTeleport(Tile(2326, 3636), "the kyatt") + } + } + } + } + + private suspend fun Player.chat() { + if (inventory.contains("ball_of_wool") || inventory.contains("ball_of_black_wool")) { + npc("Human, hand me that ball of wool.") + player("Aww...do you want to play with it?") + npc("I do not 'play', human.") + player("If you say so, kitty! Alright, you can have it.") + npc("Aha! Ball of wool: you are mine now. I will destroy you!") + player("Well I'm not giving it to you, now! I'll never get it back.") + npc("Then you leave me no choice but to destroy YOU, human!") + player("Bad kitty!") + return + } + when (random.nextInt(4)) { + 0 -> { + npc("Guess who wants a belly rub, human.") + player("Umm...is it me?") + npc("No, human, it is not you. Guess again.") + player("Is it the Duke of Lumbridge?") + npc("You try my patience, human!") + player("Is it Zamorak? That would explain why he's so cranky.") + npc("Please do not make me destroy you before I get my belly rub!") + } + 1 -> { + player("Here, kitty!") + npc("What do you want, human?") + player("I just thought I would see how you were.") + npc("I do not have time for your distractions. Leave me be!") + player("Well, sorry! Would a ball of wool cheer you up?") + npc("How dare you insult my intelli- what colour wool?") + player("Umm...white?") + npc("I will end you!") + } + 2 -> { + player("Hello, kitty cat!") + npc("Human, leave me be. I'm far too busy to deal with your nonsense.") + player("What are you up to?") + npc("I am engaged in an intricate dirt-purging operation!") + player("Aww, kitty's cleaning his paws! How cute!") + npc("Know this, human. Once I finish cleaning my paws...") + npc("I will destroy you!") + } + 3 -> { + player("Here, kitty!") + npc("Do not toy with me, human!") + player("What about under your chin?") + npc("I am not one of your playful kittens, human. I eat playful kittens for breakfast!") + player("Not even behind your ears?") + statement("You lean down and tickle the kyatt behind the ears.") + npc("I will...purrrrr...ooh that's quite nice...destroy...purrrrrrr...you.") + } + } + } +} diff --git a/game/src/main/kotlin/content/skill/summoning/familiar/SpiritLarupia.kt b/game/src/main/kotlin/content/skill/summoning/familiar/SpiritLarupia.kt new file mode 100644 index 0000000000..6c848fe6be --- /dev/null +++ b/game/src/main/kotlin/content/skill/summoning/familiar/SpiritLarupia.kt @@ -0,0 +1,65 @@ +package content.skill.summoning.familiar + +import content.entity.player.dialogue.Happy +import content.entity.player.dialogue.Neutral +import content.entity.player.dialogue.Scared +import content.entity.player.dialogue.type.choice +import content.entity.player.dialogue.type.npc +import content.entity.player.dialogue.type.player +import content.skill.summoning.familiarTeleport +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.type.Tile +import world.gregs.voidps.type.random + +class SpiritLarupia : Script { + init { + npcOperate("Interact", "spirit_larupia_familiar") { + choice { + option("Chat") { + chat() + } + // The larupia carries its owner home to the Feldip Hills hunter area. + option("Teleport") { + familiarTeleport(Tile(2556, 2913), "the larupia") + } + } + } + } + + private suspend fun Player.chat() { + when (random.nextInt(4)) { + 0 -> { + player("Kitty cat!") + npc("What is your wish master?") + player("Have you ever thought about doing something other than hunting and serving me?") + npc("You mean, like stand-up comedy, master?") + player("Umm...yes, like that.") + npc("No, master.") + } + 1 -> { + player("Hello friend!") + npc("'Friend', master? I do not understand this word.") + player("Friends are people, or animals, who like one another. I think we are friends.") + npc("Ah, I think I understand friends, master.") + player("Great!") + npc("A friend is someone who looks tasty, but you don't eat.") + player("!") + } + 2 -> { + npc("What are we doing today, master?") + player("I don't know, what do you want to do?") + npc("I desire only to hunt and to serve my master.") + player("Err...great! I guess I'll decide then.") + } + 3 -> { + npc("Master, do you ever worry that I might eat you?") + player("No, of course not! We're pals.") + npc("That is good, master.") + player("Should I?") + npc("Of course not, master.") + player("Oh. Good.") + } + } + } +} diff --git a/game/src/main/kotlin/content/skill/summoning/familiar/SpiritMosquito.kt b/game/src/main/kotlin/content/skill/summoning/familiar/SpiritMosquito.kt new file mode 100644 index 0000000000..f4bd4c591f --- /dev/null +++ b/game/src/main/kotlin/content/skill/summoning/familiar/SpiritMosquito.kt @@ -0,0 +1,46 @@ +package content.skill.summoning.familiar + +import content.entity.player.dialogue.Happy +import content.entity.player.dialogue.Neutral +import content.entity.player.dialogue.type.npc +import content.entity.player.dialogue.type.player +import world.gregs.voidps.engine.Script +import world.gregs.voidps.type.random + +class SpiritMosquito : Script { + init { + npcOperate("Interact", "spirit_mosquito_familiar") { + when (random.nextInt(4)) { + 0 -> { + npc("You have lovely ankles.") + player("Am I meant to be pleased by that?") + npc("Thin skin. Your delicious blood is easier to get too.") + player("I knew I couldn't trust you.") + npc("Oh come on, you won't feel a thing...") + } + 1 -> { + npc("How about that local sports team?") + player("Which one? The gnomeball team?") + npc("I must confess: I have no idea.") + player("Why did you ask, then?") + npc("I was just trying to be friendly.") + player("Just trying to get to my veins, more like!") + } + 2 -> { + npc("Have you ever tasted pirate blood?") + player("Why would I drink pirate blood?") + npc("How about dwarf blood?") + player("I don't think you quite understand...") + npc("Gnome blood, then?") + } + 3 -> { + npc("I'm soooo hungry!") + player("What would you like to eat?") + npc("Well, if you're not too attached to your elbow...") + player("You can't eat my elbow! You don't have teeth!") + npc("Tell me about it. Cousin Nigel always makes fun of me. Calls me 'No-teeth'.") + } + } + } + } +} diff --git a/game/src/main/kotlin/content/skill/summoning/familiar/SpiritScorpion.kt b/game/src/main/kotlin/content/skill/summoning/familiar/SpiritScorpion.kt new file mode 100644 index 0000000000..2c555646ff --- /dev/null +++ b/game/src/main/kotlin/content/skill/summoning/familiar/SpiritScorpion.kt @@ -0,0 +1,51 @@ +package content.skill.summoning.familiar + +import content.entity.player.dialogue.Happy +import content.entity.player.dialogue.Neutral +import content.entity.player.dialogue.type.npc +import content.entity.player.dialogue.type.player +import world.gregs.voidps.engine.Script +import world.gregs.voidps.type.random + +class SpiritScorpion : Script { + init { + npcOperate("Interact", "spirit_scorpion_familiar") { + when (random.nextInt(4)) { + 0 -> { + npc("Hey, boss, how about we go to the bank?") + player("And do what?") + npc("Well, we could open by shouting, 'Stand and deliver!'") + player("Why does everything with you end with something getting held up?") + npc("That isn't true! Give me one example.") + player("How about the post office?") + npc("How about another?") + player("Those junior White Knights? The ones selling the gnome crunchies?") + npc("That was self defence.") + player("No! No more hold-ups, stick-ups, thefts, or heists, you got that?") + } + 1 -> { + player("Say hello to my little friend!") + npc("What?") + player("My little friend: you ignored him last time you met him.") + npc("So, who is your friend?") + player("If I tell you, what is the point?") + } + 2 -> { + npc("Hey, boss, I've been thinking.") + player("That's never a good sign.") + npc("See, I heard about this railway...") + player("We are not robbing it!") + npc("I might not have wanted to suggest that, boss...") + player("Then what were you going to suggest?") + npc("That isn't important right now.") + player("I thought as much.") + } + 3 -> { + npc("Why do we never go to crossroads and rob travelers?") + player("There are already highwaymen at the good spots.") + npc("Maybe we need to think bigger.") + } + } + } + } +} diff --git a/game/src/main/kotlin/content/skill/summoning/familiar/SpiritSpider.kt b/game/src/main/kotlin/content/skill/summoning/familiar/SpiritSpider.kt new file mode 100644 index 0000000000..a9643fad5e --- /dev/null +++ b/game/src/main/kotlin/content/skill/summoning/familiar/SpiritSpider.kt @@ -0,0 +1,63 @@ +package content.skill.summoning.familiar + +import content.entity.player.dialogue.Frustrated +import content.entity.player.dialogue.Happy +import content.entity.player.dialogue.Neutral +import content.entity.player.dialogue.Shifty +import content.entity.player.dialogue.type.npc +import content.entity.player.dialogue.type.player +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.type.random + +class SpiritSpider : Script { + init { + npcOperate("Interact", "spirit_spider_familiar") { + if (inventory.contains("flies")) { + npc("So, do I get any of those flies?") + player("I don't know, I was saving these for a pet.") + npc("I see...") + player("Look, you can have some if you want.") + npc("Oh, don't do me any favours.") + player("Look, here, have some!") + npc("Don't want them now.") + player("Siiiigh...spiders.") + return@npcOperate + } + when (random.nextInt(4)) { + 0 -> { + npc("Where are we going?") + player("I've not decided yet.") + npc("Fine, don't tell me...") + player("Oh, okay, well, we are going...") + npc("Don't want to know now.") + player("Siiiigh...spiders.") + } + 1 -> { + npc("Who is that?") + player("Who?") + npc("The two-legs over there.") + player("I can't see who you mean...") + npc("Never mind...") + player("Can you describe them a little better...") + npc("It doesn't matter now.") + player("Siiiigh...spiders.") + } + 2 -> { + npc("What are you doing?") + player("Nothing that you should concern yourself with.") + npc("I see, you don't think I'm smart enough to understand...") + player("That's not it at all! Look, I was...") + npc("Don't wanna know now.") + player("Siiiigh...spiders.") + } + 3 -> { + npc("Sigh...") + player("What is it now?") + npc("Nothing really.") + player("Oh, well that's a relief.") + } + } + } + } +} diff --git a/game/src/main/kotlin/content/skill/summoning/familiar/SpiritTerrorbird.kt b/game/src/main/kotlin/content/skill/summoning/familiar/SpiritTerrorbird.kt new file mode 100644 index 0000000000..c8580042f8 --- /dev/null +++ b/game/src/main/kotlin/content/skill/summoning/familiar/SpiritTerrorbird.kt @@ -0,0 +1,45 @@ +package content.skill.summoning.familiar + +import content.entity.player.dialogue.Happy +import content.entity.player.dialogue.Neutral +import content.entity.player.dialogue.type.npc +import content.entity.player.dialogue.type.player +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.inv.beastOfBurden + +class SpiritTerrorbird : Script { + init { + npcOperate("Interact", "spirit_terrorbird_familiar") { + val used = beastOfBurden.items.count { it.isNotEmpty() } + if (used <= 8) { + npc("This is a fun little walk.") + player("Why do I get the feeling you'll change your tune when I start loading you up with items?") + return@npcOperate + } + if (used == 9) { + npc("I can keep this up for hours.") + player("I'm glad, as we still have plenty of time to go.") + return@npcOperate + } + if (used == 10) { + npc("Are we going to visit a bank soon?") + player("I'm not sure, you still have plenty of room for more stuff.") + npc("Just don't leave it too long, okay?") + return@npcOperate + } + if (used == 11) { + npc("Can we go to a bank now?") + player("Just give me a little longer, okay?") + npc("That's what you said last time!") + player("Did I?") + npc("Yes!") + player("Well, I mean it this time, promise.") + return@npcOperate + } + npc("So...heavy...") + player("I knew you'd change your tune once you started carrying things.") + npc("Can we go bank this stuff now?") + player("Sure. You do look like you're about to collapse.") + } + } +} diff --git a/game/src/main/kotlin/content/skill/summoning/familiar/SpiritTzKih.kt b/game/src/main/kotlin/content/skill/summoning/familiar/SpiritTzKih.kt new file mode 100644 index 0000000000..e49c6ca8cd --- /dev/null +++ b/game/src/main/kotlin/content/skill/summoning/familiar/SpiritTzKih.kt @@ -0,0 +1,54 @@ +package content.skill.summoning.familiar + +import content.entity.player.dialogue.Happy +import content.entity.player.dialogue.Neutral +import content.entity.player.dialogue.type.npc +import content.entity.player.dialogue.type.player +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.type.random + +class SpiritTzKih : Script { + init { + npcOperate("Interact", "spirit_tz-kih_familiar") { + if (inventory.items.any { it.id.startsWith("prayer_potion") }) { + npc("You drink pray, me drink pray.") + player("What's that, Tz-Kih?") + npc("You got pray pray pot. Tz-Kih drink pray pray you, you drink pray pray pot.") + player("You want to drink my Prayer points?") + npc("Yes. Pray pray.") + player("Err, not right now, Tz-Kih. I, er, need them myself.") + player("Sorry.") + npc("But, pray praaaay...?") + return@npcOperate + } + when (random.nextInt(4)) { + 0 -> { + npc("How's it going, Tz-kih?") + player("Pray pray?") + npc("Don't start with all that again.") + player("Hmph, silly JalYt.") + } + 1 -> { + npc("Does JalYt think Tz-kih as strong as Jad Jad?") + player("Are you as strong as TzTok-Jad? Yeah, sure, why not.") + npc("Really? Thanks, JalYt. Tz-Kih strong and happy.") + } + 2 -> { + npc("Have you heard of blood bat, JalYt?") + player("Blood bats? You mean vampire bats?") + npc("Yes. Blood bat.") + player("Yes, I've heard of them. What about them?") + npc("Tz-Kih like blood bat, but drink pray pray not blood blood. Blood blood is yuck.") + player("Thanks, Tz-Kih, that's nice to know.") + } + 3 -> { + npc("Pray pray pray pray pray pray pray pray!") + player("Calm down, Tz-Kih, we'll find you something to drink soon.") + npc("Pray praaaaaaaaaaaaaay!") + player("Okay, okay. Calm down!") + } + } + } + } +} diff --git a/game/src/main/kotlin/content/skill/summoning/familiar/SteelTitan.kt b/game/src/main/kotlin/content/skill/summoning/familiar/SteelTitan.kt new file mode 100644 index 0000000000..d5a4e20c09 --- /dev/null +++ b/game/src/main/kotlin/content/skill/summoning/familiar/SteelTitan.kt @@ -0,0 +1,51 @@ +package content.skill.summoning.familiar + +import content.entity.player.dialogue.Happy +import content.entity.player.dialogue.Neutral +import content.entity.player.dialogue.Quiz +import content.entity.player.dialogue.type.npc +import content.entity.player.dialogue.type.player +import world.gregs.voidps.engine.Script +import world.gregs.voidps.type.random + +class SteelTitan : Script { + init { + npcOperate("Interact", "steel_titan_familiar") { + when (random.nextInt(5)) { + 0 -> { + npc("Forward, master, to a battle that will waken the gods!") + player("I'd rather not, if it's all the same to you.") + npc("I shall never meet my end at this rate...") + } + 1 -> { + npc("How do you wish to meet your end, master?") + player("Hopefully not for a very long time.") + npc("You do not wish to be torn asunder by the thousand limbs of a horde of demons?") + player("No! I'm quite happy picking flax and turning unstrung bows into gold...") + } + 2 -> { + npc("Why must we dawdle when glory awaits?") + player("I'm beginning to think you just want me to die horribly...") + npc("We could have deaths that bards sing of for a thousand years.") + player("That's not much compensation.") + } + 3 -> { + npc("Master, we should be marching into glorious battle!") + player("You know, I think you're onto something.") + npc("We could find a death befitting such heroes of RuneScape!") + player("Ah. You know, I'd prefer not to die...") + npc("Beneath the claws of a mighty foe shall I be sent into the embrace of death!") + } + 4 -> { + npc("Let us go forth to battle, my lord!") + player("Why do you like fighting so much? It's not very nice to kill things.") + npc("It is the most honourable thing in life.") + player("But I summoned you, I'm not sure I can even say that you're alive...") + npc("Alas, you have discovered the woe of all summoned creatures' existence.") + player("Really? I was right?") + npc("Oh, woe...woe!") + } + } + } + } +} diff --git a/game/src/main/kotlin/content/skill/summoning/familiar/Stormbringer.kt b/game/src/main/kotlin/content/skill/summoning/familiar/Stormbringer.kt new file mode 100644 index 0000000000..506d2b25da --- /dev/null +++ b/game/src/main/kotlin/content/skill/summoning/familiar/Stormbringer.kt @@ -0,0 +1,50 @@ +package content.skill.summoning.familiar + +import content.entity.player.dialogue.Frustrated +import content.entity.player.dialogue.Happy +import content.entity.player.dialogue.type.npc +import content.entity.player.dialogue.type.player +import world.gregs.voidps.engine.Script +import world.gregs.voidps.type.random + +class Stormbringer : Script { + init { + npcOperate("Interact", "*_stormbringer_familiar") { + if (!this["talked_to_stormbringer", false]) { + this["talked_to_stormbringer"] = true + npc("Little cub, I see you have questions for me.") + return@npcOperate + } + when (random.nextInt(4)) { + 0 -> { + player("How do you cast magic? You don't seem to carry any runes.") + npc("So, how can I possibly form magic?") + player("Yes, I guess.") + npc("You are right, we have no runes, but some of us are born with currents of magic rippling over our bodies. Such is the power generated by a stormbringer that we render our mothers blind and infertile during childbirth.") + npc("One day I hope to be a Blind Mother. They are highly esteemed and carried on the back of their own worldbearer.") + } + 1 -> { + player("How powerful is a stormbringer? Have you killed a dragon?") + npc("A drah-gon? Is that like the triple-bellied wolflok?") + player("Probably not. Twenty-feet high, firebreathing?") + npc("Oh, it sounds much like! Seven heads? Large bellows on its sides, pumping out acid?") + player("Bellows? No, definitely no bellows.") + npc("Then it is a shame. The creatures on our plane sound far more dangerous.") + } + 2 -> { + player("How do you like it in Daemonheim?") + npc("I like it very little. I grow tired of this place.") + player("Then why don't you just leave? If you're so tired of floating about behind me, why don't you just go?") + npc("Do not tempt me. You are like a tiny pup, nibbling and barking at my heel. I am here because the gorajos greatest duty is to hinder the one who created this place.") + npc("We sense that great headway has been made; that we have dropped over a waterfall and risen, coughing and spluttering to the surface. Yet, there are many turns and falls ahead.") + npc("I follow you because you have shown a remarkable habit of stumbling into trouble, young fawn. When that trouble comes, we shall push you aside and finish this.") + player("Charming.") + } + 3 -> { + player("I don't have any more questions.") + npc("That is a good thing. We must prepare for the next battle.") + } + } + } + } +} diff --git a/game/src/main/kotlin/content/skill/summoning/familiar/StrangerPlant.kt b/game/src/main/kotlin/content/skill/summoning/familiar/StrangerPlant.kt new file mode 100644 index 0000000000..8c36c3bf63 --- /dev/null +++ b/game/src/main/kotlin/content/skill/summoning/familiar/StrangerPlant.kt @@ -0,0 +1,59 @@ +package content.skill.summoning.familiar + +import content.entity.player.dialogue.Happy +import content.entity.player.dialogue.Neutral +import content.entity.player.dialogue.type.npc +import content.entity.player.dialogue.type.player +import world.gregs.voidps.engine.Script +import world.gregs.voidps.type.random + +class StrangerPlant : Script { + init { + npcOperate("Interact", "stranger_plant_familiar") { + when (random.nextInt(4)) { + 0 -> { + npc("I'M STRANGER PLANT!") + player("I know you are.") + npc("I KNOW! I'M JUST SAYING!") + player("Do you have to shout like that all of the time?") + npc("WHO'S SHOUTING?") + player("If this is you speaking normally, I'd hate to hear you shouting.") + npc("OH, SNAP!") + } + 1 -> { + npc("WILL WE HAVE TO BE HERE LONG?") + player("We'll be here until I am finished.") + npc("BUT THERE'S NO DRAMA HERE!") + player("Well, how about you pretend to be an undercover agent.") + npc("WONDERFUL! WHAT'S MY MOTIVATION?") + player("You're trying to remain stealthy and secretive, while looking out for clues.") + npc("I'LL JUST GET INTO CHARACTER! AHEM!") + npc("PAPER! PAPER! VARROCK HERALD FOR SALE!") + player("What kind of spy yells loudly like that?") + npc("ONE WHOSE COVER IDENTITY IS A PAPER-SELLER, OF COURSE!") + player("Ask a silly question...") + } + 2 -> { + npc("DIIIIVE!") + player("What? Help! Why dive?") + npc("OH, DON'T WORRY! I JUST LIKE TO YELL THAT FROM TIME TO TIME!") + player("Well, can you give me a little warning next time?") + npc("WHAT, AND TAKE ALL THE FUN OUT OF LIFE?") + player("If by 'fun' you mean 'sudden heart attacks', then yes, please take them out of my life!") + } + 3 -> { + npc("I THINK I'M WILTING!") + player("Do you need some water?") + npc("DON'T BE SILLY! I CAN PULL THAT OUT OF THE GROUND!") + player("Then why are you wilting?") + npc("IT'S SIMPLE: THERE'S A DISTINCT LACK OF DRAMA!") + player("Drama?") + npc("YES, DRAMA!") + player("Okay...") + player("Let's see if we can find some for you.") + npc("LEAD ON!") + } + } + } + } +} diff --git a/game/src/main/kotlin/content/skill/summoning/familiar/SwampTitan.kt b/game/src/main/kotlin/content/skill/summoning/familiar/SwampTitan.kt new file mode 100644 index 0000000000..3623a78855 --- /dev/null +++ b/game/src/main/kotlin/content/skill/summoning/familiar/SwampTitan.kt @@ -0,0 +1,22 @@ +package content.skill.summoning.familiar + +import content.entity.player.dialogue.Happy +import content.entity.player.dialogue.Neutral +import content.entity.player.dialogue.type.npc +import content.entity.player.dialogue.type.player +import world.gregs.voidps.engine.Script + +class SwampTitan : Script { + init { + npcOperate("Interact", "swamp_titan_familiar") { + npc("I'm alone, all alone I say.") + player("Oh, stop being so melodramatic.") + npc("It's not easy being greenery...well, decomposing greenery.") + player("Surely, you're not the only swamp...thing in the world? What about the other swamp titans?") + npc("They're not my friends...they pick on me...they're so mean...") + player("Why would they do that?") + npc("They think I DON'T smell.") + player("Oh, yes. That is, er, mean...") + } + } +} diff --git a/game/src/main/kotlin/content/skill/summoning/familiar/TalonBeast.kt b/game/src/main/kotlin/content/skill/summoning/familiar/TalonBeast.kt new file mode 100644 index 0000000000..829ee1206c --- /dev/null +++ b/game/src/main/kotlin/content/skill/summoning/familiar/TalonBeast.kt @@ -0,0 +1,45 @@ +package content.skill.summoning.familiar + +import content.entity.player.dialogue.Happy +import content.entity.player.dialogue.Neutral +import content.entity.player.dialogue.type.npc +import content.entity.player.dialogue.type.player +import world.gregs.voidps.engine.Script +import world.gregs.voidps.type.random + +class TalonBeast : Script { + init { + npcOperate("Interact", "talon_beast_familiar") { + when (random.nextInt(4)) { + 0 -> { + npc("Is this all you apes do all day, then?") + player("Well, we do a lot of other things, too.") + npc("That's dull. Lets go find something and bite it.") + player("I wouldn't want to spoil my dinner.") + npc("So, I have to watch you trudge about again? Talk about boring.") + } + 1 -> { + npc("This place smells odd...") + player("Odd?") + npc("Yes, not enough is rotting...") + player("For which I am extremely grateful.") + } + 2 -> { + npc("Hey!") + player("Aaaargh!") + npc("Why d'you always do that?") + player("I don't think I'll ever get used to having a huge, ravenous feline sneaking around behind me all the time.") + npc("That's okay, I doubt I'll get used to following an edible, furless monkey prancing in front of me all the time either.") + } + 3 -> { + npc("C'mon! Lets go fight stuff!") + player("What sort of stuff?") + npc("I dunno? Giants, monsters, vaguely-defined philosophical concepts. You know: stuff.") + player("How are we supposed to fight a philosophical concept?") + npc("With subtle arguments and pointy sticks!") + player("Well, I can see you're going to go far in debates.") + } + } + } + } +} diff --git a/game/src/main/kotlin/content/skill/summoning/familiar/ThornySnail.kt b/game/src/main/kotlin/content/skill/summoning/familiar/ThornySnail.kt new file mode 100644 index 0000000000..9c29945426 --- /dev/null +++ b/game/src/main/kotlin/content/skill/summoning/familiar/ThornySnail.kt @@ -0,0 +1,57 @@ +package content.skill.summoning.familiar + +import content.entity.player.dialogue.Happy +import content.entity.player.dialogue.Neutral +import content.entity.player.dialogue.Shifty +import content.entity.player.dialogue.type.npc +import content.entity.player.dialogue.type.player +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.entity.character.player.equip.equipped +import world.gregs.voidps.network.login.protocol.visual.update.player.EquipSlot +import world.gregs.voidps.type.random + +class ThornySnail : Script { + init { + npcOperate("Interact", "thorny_snail_familiar") { + if (equipped(EquipSlot.Hat).id.contains("snelm")) { + npc("...") + player("What's the matter?") + npc("Check your head...") + player("What about it... Oh, wait! Oh, this is pretty awkward...") + npc("You're wearing the spine of one of my relatives as a hat...") + player("Well more of a faux-pas, then.") + npc("Just a bit...") + return@npcOperate + } + when (random.nextInt(4)) { + 0 -> { + npc("All this running around the place is fun!") + player("I'll be it's a step up from your usually sedentary lifestyle!") + npc("True, but it's mostly seeing the sort of sights you don't get at home.") + player("Such as?") + npc("Living things for a start.") + player("Those are in short supply in Mort Myre, I admit.") + } + 1 -> { + npc("I think my stomach is drying out...") + player("Your stomach? How do you know how it's feeling?") + npc("I am walking on it, you know...") + player("Urrgh...") + } + 2 -> { + npc("Okay, I have to ask, what are those things you people totter about on?") + player("You mean my legs?") + npc("Yes, those. How are you supposed to eat anything through them?") + player("Well, we don't. That's what our mouths are for.") + npc("Oh, right! I thought those were for expelling waste gas and hot air!") + player("Well, for a lot of people they are.") + } + 3 -> { + player("Can you slow down?") + npc("Are we going too fast for you?") + player("I bet if you had to run on your internal organs you'd want a break now and then!") + } + } + } + } +} diff --git a/game/src/main/kotlin/content/skill/summoning/familiar/UnicornStallion.kt b/game/src/main/kotlin/content/skill/summoning/familiar/UnicornStallion.kt new file mode 100644 index 0000000000..22779aac63 --- /dev/null +++ b/game/src/main/kotlin/content/skill/summoning/familiar/UnicornStallion.kt @@ -0,0 +1,77 @@ +package content.skill.summoning.familiar + +import content.entity.effect.toxin.curePoison +import content.entity.effect.toxin.poisoned +import content.entity.player.dialogue.Happy +import content.entity.player.dialogue.Neutral +import content.entity.player.dialogue.type.npc +import content.entity.player.dialogue.type.player +import content.skill.summoning.follower +import content.skill.summoning.useFamiliarSpecial +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.client.message +import world.gregs.voidps.engine.entity.character.player.name +import world.gregs.voidps.engine.entity.character.player.skill.Skill +import world.gregs.voidps.type.random + +/** Special-move points the unicorn stallion's right-click Cure option drains. */ +private const val CURE_COST = 2 + +class UnicornStallion : Script { + init { + npcOperate("Interact", "unicorn_stallion_familiar") { (target) -> + if (target != follower) { + return@npcOperate + } + if (levels.get(Skill.Constitution) < levels.getMax(Skill.Constitution) * 0.6) { + npc("Whicker snort! Whinny whinny whinny. (You're hurt! Let me try to heal you.)") + player("Yes, please do!") + npc("Snuffle whicker whicker neigh neigh... (Okay, we'll begin with acupuncture and some reiki, then I'll get my crystals...)") + player("Or you could use some sort of magic...like the other unicorns...") + npc("Whicker whinny whinny neigh. (Yes, but I believe in alternative medicine.)") + player("Riiight. Don't worry about it, then; I'll be fine.") + return@npcOperate + } + when (random.nextInt(4)) { + 0 -> { + npc("Neigh neigh neighneigh snort? (Isn't everything so awesomely wonderful?)") + player("Err...yes?") + npc("Whicker whicker snuffle. (I can see you're not tuning in, $name.)") + player("No, no, I'm completely at one with...you know...everything.") + npc("Whicker! (Cosmic.)") + } + 1 -> { + npc("Whicker whicker. Neigh, neigh, whinny. (I feel so, like, enlightened. Let's meditate and enhance our auras.)") + player("I can't do that! I barely even know you.") + npc("Whicker... (Bipeds...)") + } + 2 -> { + npc("Whinny whinny whinny. (I think I'm astrally projecting.)") + player("Okay... Hang on. Seeing as I summoned you here, wouldn't that mean you are physically projecting instead?") + npc("Whicker whicker whicker. (You're, like, no fun at all, man.)") + } + 3 -> { + npc("Whinny, neigh! (Oh, happy day!)") + player("Happy day? Is that some sort of holiday or something?") + npc("Snuggle whicker (Man, you're totally, like, uncosmic, $name.)") + } + } + } + + npcOperate("Cure", "unicorn_stallion_familiar") { (target) -> + if (target != follower) { + message("This isn't your familiar.") + return@npcOperate + } + if (!poisoned) { + message("You're not suffering from poison!") + return@npcOperate + } + useFamiliarSpecial(CURE_COST) { + follower?.anim("unicorn_stallion_cure") + follower?.gfx("unicorn_stallion_cure") + curePoison() + } + } + } +} diff --git a/game/src/main/kotlin/content/skill/summoning/familiar/VampireBat.kt b/game/src/main/kotlin/content/skill/summoning/familiar/VampireBat.kt new file mode 100644 index 0000000000..00d7ee6514 --- /dev/null +++ b/game/src/main/kotlin/content/skill/summoning/familiar/VampireBat.kt @@ -0,0 +1,35 @@ +package content.skill.summoning.familiar + +import content.entity.player.dialogue.Happy +import content.entity.player.dialogue.Neutral +import content.entity.player.dialogue.type.npc +import content.entity.player.dialogue.type.player +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.entity.character.player.skill.Skill +import world.gregs.voidps.type.random + +class VampireBat : Script { + init { + npcOperate("Interact", "vampire_bat_familiar") { + if (levels.get(Skill.Constitution) < levels.getMax(Skill.Constitution) / 2) { + npc("You're vasting all that blood, can I have some?") + player("No!") + return@npcOperate + } + when (random.nextInt(3)) { + 0 -> { + npc("Ven are you going to feed me?") + player("Well for a start, I'm not giving you any of my blood.") + } + 1 -> { + npc("I want to eat something.") + player("I'm sure you do; let's go see what we can find.") + } + 2 -> { + npc("Ven can I eat something?") + player("Just as soon as I find something to attack.") + } + } + } + } +} diff --git a/game/src/main/kotlin/content/skill/summoning/familiar/VoidRavager.kt b/game/src/main/kotlin/content/skill/summoning/familiar/VoidRavager.kt new file mode 100644 index 0000000000..b9750961b9 --- /dev/null +++ b/game/src/main/kotlin/content/skill/summoning/familiar/VoidRavager.kt @@ -0,0 +1,32 @@ +package content.skill.summoning.familiar + +import content.entity.player.dialogue.Happy +import content.entity.player.dialogue.Neutral +import content.entity.player.dialogue.type.npc +import content.entity.player.dialogue.type.player +import world.gregs.voidps.engine.Script +import world.gregs.voidps.type.random + +class VoidRavager : Script { + init { + npcOperate("Interact", "void_ravager_familiar") { + when (random.nextInt(3)) { + 0 -> { + npc("You look delicious!") + player("Don't make me dismiss you!") + } + 1 -> { + npc("Take me to the rift!") + player("I'm not taking you there! Goodness knows what you'd get up to.") + npc("I promise not to destroy your world...") + player("If only I could believe you...") + } + 2 -> { + npc("How do you bear life without ravaging?") + player("It's not always easy.") + npc("I could show you how to ravage, if you like...") + } + } + } + } +} diff --git a/game/src/main/kotlin/content/skill/summoning/familiar/VoidShifter.kt b/game/src/main/kotlin/content/skill/summoning/familiar/VoidShifter.kt new file mode 100644 index 0000000000..e8a582cd88 --- /dev/null +++ b/game/src/main/kotlin/content/skill/summoning/familiar/VoidShifter.kt @@ -0,0 +1,56 @@ +package content.skill.summoning.familiar + +import content.entity.player.dialogue.Happy +import content.entity.player.dialogue.Neutral +import content.entity.player.dialogue.type.npc +import content.entity.player.dialogue.type.player +import content.skill.summoning.follower +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.client.message +import world.gregs.voidps.engine.entity.character.move.tele +import world.gregs.voidps.engine.entity.character.player.skill.Skill +import world.gregs.voidps.type.random + +class VoidShifter : Script { + init { + // Emergency shift - once the owner's life points drop below 10% the shifter yanks them to the + // safety of the Void Knights' Outpost (the same courtyard its Call to Arms teleports to). + levelChanged(Skill.Constitution) { skill, _, to -> + if (follower?.id == "void_shifter_familiar" && to > 0 && to < levels.getMax(skill) / 10) { + tele(2659, 2658, 0) + message("Your void shifter teleports you to safety.") + } + } + + npcOperate("Interact", "void_shifter_familiar") { + when (random.nextInt(4)) { + 0 -> { + npc("What a splendid day, sir!") + player("Yes, it is!") + npc("It could only be marginally improved, perhaps, by tea and biscuits.") + player("What a marvellous idea!") + } + 1 -> { + npc("I'm sorry to bother you, but could you assist me briefly?") + player("I suppose so.") + npc("I was wondering, briefly, if perchance you might care to dance?") + player("Dance? With a pest?") + npc("Well, you see, I'm dreadfully out of practice and now I can barely leap, let alone teleport.") + player("I'm not going to help you remember how to destroy the world!") + npc("What a beastly world we live in where one gentleman/lady will not aid a pest in need...") + } + 2 -> { + npc("How do you do?") + player("Okay, I suppose.") + npc("Marvellous, simply marvellous!") + } + 3 -> { + npc("Lets go and see to those cads and bounders!") + player("Which 'cads and bounders' did you mean, exactly?") + npc("Why, the ones with no honour, of course.") + player("I don't think he knows what pests do...") + } + } + } + } +} diff --git a/game/src/main/kotlin/content/skill/summoning/familiar/VoidSpinner.kt b/game/src/main/kotlin/content/skill/summoning/familiar/VoidSpinner.kt new file mode 100644 index 0000000000..1c60682dba --- /dev/null +++ b/game/src/main/kotlin/content/skill/summoning/familiar/VoidSpinner.kt @@ -0,0 +1,41 @@ +package content.skill.summoning.familiar + +import content.entity.player.dialogue.Happy +import content.entity.player.dialogue.Neutral +import content.entity.player.dialogue.type.npc +import content.entity.player.dialogue.type.player +import world.gregs.voidps.engine.Script +import world.gregs.voidps.type.random + +class VoidSpinner : Script { + init { + npcOperate("Interact", "void_spinner_familiar") { + when (random.nextInt(4)) { + 0 -> { + npc("Let's go play hide an' seek!") + player("Okay, you hide and I'll come find you.") + npc("You'll never find me!") + player("What a disaster that would be...") + } + 1 -> { + npc("My mummy told me I was clever.") + player("Aren't you meant to be the essence of a spinner? How do you have a mother?") + npc("What you mean, 'essence'?") + player("Never mind, I don't think it matters.") + npc("My logimical powers has proved me smarterer than you!") + } + 2 -> { + npc("I'm coming to tickle you!") + player("No! You've got so many tentacles!") + npc("I'm coming to tickle you!") + player("Aieee!") + } + 3 -> { + npc("Where's the sweeties?") + player("They are wherever good spinners go.") + npc("Yay for me!") + } + } + } + } +} diff --git a/game/src/main/kotlin/content/skill/summoning/familiar/VoidTorcher.kt b/game/src/main/kotlin/content/skill/summoning/familiar/VoidTorcher.kt new file mode 100644 index 0000000000..df5edb3a19 --- /dev/null +++ b/game/src/main/kotlin/content/skill/summoning/familiar/VoidTorcher.kt @@ -0,0 +1,41 @@ +package content.skill.summoning.familiar + +import content.entity.player.dialogue.Happy +import content.entity.player.dialogue.Neutral +import content.entity.player.dialogue.type.npc +import content.entity.player.dialogue.type.player +import world.gregs.voidps.engine.Script +import world.gregs.voidps.type.random + +class VoidTorcher : Script { + init { + npcOperate("Interact", "void_torcher_familiar") { + when (random.nextInt(4)) { + 0 -> { + player("You okay there, spinner?") + npc("I not spinner!") + player("Sorry, splatter?") + npc("I not splatter either!") + player("No, wait, I meant defiler.") + npc("I torcher!") + player("Hehe, I know. I was just messing with you.") + npc("Grr. Don't be such a pest.") + } + 1 -> { + npc("'T' is for torcher, that's good enough for me... 'T' is for torcher, I'm happy you can see.") + player("You're just a bit weird, aren't you?") + } + 2 -> { + npc("Burn, baby, burn! Torcher inferno!") + player("*Wibble*") + } + 3 -> { + npc("So hungry... must devour...") + player("*Gulp* Er, yeah, I'll find you something to eat in a minute.") + npc("Is flesh-bag scared of torcher?") + player("No, no. I, er, always look like this... honest.") + } + } + } + } +} diff --git a/game/src/main/kotlin/content/skill/summoning/familiar/WarTortoise.kt b/game/src/main/kotlin/content/skill/summoning/familiar/WarTortoise.kt new file mode 100644 index 0000000000..2d574747d8 --- /dev/null +++ b/game/src/main/kotlin/content/skill/summoning/familiar/WarTortoise.kt @@ -0,0 +1,60 @@ +package content.skill.summoning.familiar + +import content.entity.player.dialogue.Happy +import content.entity.player.dialogue.Neutral +import content.entity.player.dialogue.type.npc +import content.entity.player.dialogue.type.player +import content.entity.player.dialogue.type.statement +import world.gregs.voidps.engine.Script +import world.gregs.voidps.type.random + +class WarTortoise : Script { + init { + npcOperate("Interact", "war_tortoise_familiar") { + when (random.nextInt(4)) { + 0 -> { + statement("*The tortoise waggles its head about*") + npc("What are we doing in this dump?") + player("Well, I was just going to take care of a few things.") + statement("*The tortoise shakes its head*") + npc("I don't believe it. Stuck here with this young whippersnapper running around having fun.") + player("You know, I'm sure you would enjoy it if you gave it a chance.") + npc("Oh, you would say that, wouldn't you?") + } + 1 -> { + npc("Hold up a minute, there.") + player("What do you want?") + statement("*The tortoise bobs its head*") + npc("For you to slow down!") + player("Well, I've stopped now.") + npc("Yes, but you'll soon start up again, won't you?") + player("Probably.") + statement("* The tortoise waggles its head despondently.*") + npc(" I don't believe it....") + } + 2 -> { + statement("* The tortoise bobs its head around energetically.*") + npc("Oh, so now you're paying attention to me, are you?") + player("I pay you plenty of attention!") + npc("Only when you want me to carry those heavy things of yours.") + player("I don't ask you to carry anything heavy.") + npc("What about those lead ingots?") + player("What lead ingots?") + statement("*The tortoise droops its head*") + npc("Well, that's what it felt like....") + npc("*grumble grumble*") + } + 3 -> { + statement("*The tortoise exudes an air of reproach*") + npc("Are you going to keep rushing around all day?") + player("Only for as long as I have the energy to.") + npc("Oh. I'm glad that my not being able to keep up with you brings you such great amusement.") + player("I didn't mean it like that.") + statement("*The tortoise waggles its head disapprovingly.*") + npc("Well, when you are QUITE finished laughing at my expense, how about you pick up a rock larger than your body and go crawling about with it?") + npc("We'll see how energetic you are after an hour or two of that.") + } + } + } + } +} diff --git a/game/src/main/kotlin/content/skill/summoning/familiar/Wolpertinger.kt b/game/src/main/kotlin/content/skill/summoning/familiar/Wolpertinger.kt new file mode 100644 index 0000000000..f65f92ca23 --- /dev/null +++ b/game/src/main/kotlin/content/skill/summoning/familiar/Wolpertinger.kt @@ -0,0 +1,13 @@ +package content.skill.summoning.familiar + +import content.entity.player.dialogue.Neutral +import content.entity.player.dialogue.type.npc +import world.gregs.voidps.engine.Script + +class Wolpertinger : Script { + init { + npcOperate("Interact", "wolpertinger_familiar") { + npc("Rawr!") + } + } +} diff --git a/game/src/main/kotlin/content/skill/summoning/familiar/Worldbearer.kt b/game/src/main/kotlin/content/skill/summoning/familiar/Worldbearer.kt new file mode 100644 index 0000000000..d203444c75 --- /dev/null +++ b/game/src/main/kotlin/content/skill/summoning/familiar/Worldbearer.kt @@ -0,0 +1,51 @@ +package content.skill.summoning.familiar + +import content.entity.player.dialogue.Frustrated +import content.entity.player.dialogue.Happy +import content.entity.player.dialogue.type.npc +import content.entity.player.dialogue.type.player +import world.gregs.voidps.engine.Script +import world.gregs.voidps.type.random + +class Worldbearer : Script { + init { + npcOperate("Interact", "*_worldbearer_familiar") { + if (!this["talked_to_worldbearer", false]) { + this["talked_to_worldbearer"] = true + npc("I am not a great talker, little cub. For my sake, make it quick.") + return@npcOperate + } + when (random.nextInt(4)) { + 0 -> { + player("Do you need help carrying?") + npc("Little cub, there is no greater insult to a worldbearer. Shall I rub your belly so you can digest? Shall I move your feet so you can walk?") + npc("Garra! I know your culture: you call us pack mules and servants. There is no indignity in what I do! There is only honour in bearing another's burdens.") + player("I didn't mean to insult you.") + npc("Bah, you are right. I must have woken on the wrong side of the stream today, little cub. Ignore me.") + player("You're carrying a few burdens of your own.") + npc("Grrrr! Although my role is to carry - and I carry for you now - I like it as much as I like your stale odour. Can we press on before I do something that I will get exiled for?") + } + 1 -> { + player("You shouldn't complain so much.") + npc("Do not pretend that we are in the servant and master role, little cub! Our alliance is a delicate one and will end some day, through good means or bad.") + npc("When that day comes, you may not find that I am so helpful. Our goal is to blast this dungeon apart, but I have no issue with leaving you behind.") + } + 2 -> { + player("What is it like to be a worldbearer?") + npc("The worldbearers are the legs and back of the gorajo; we bear the provisions, tents and tools from one destination to another.") + npc("And when the gorajo are not moving, the worldbearers prepare and serve the food. It is an important role, and one that is esteemed among my clansmen.") + player("It sounds like hard work. There can't be much time to enjoy yourself.") + npc("It is rewarding in its own way. When a worldbearer is put to rest, they are stripped of all belongings, to be reincarnated as a creature without burden: a sparrowhawk or a wildcat, perhaps.") + npc("Although we face trials in this life, our next is free and joyful.") + player("You carry everything and have to serve it too?") + npc("The clan sees the worldbearer as the nurturing mother wolf: proudly defending her pack while carrying the food and delivering it to her pups.") + npc("I would prefer a more masculine comparison; never mind, it has been so for centuries.") + } + 3 -> { + player("I don't have any more questions.") + npc("That is good to hear. I am not one to talk.") + } + } + } + } +} diff --git a/game/src/main/kotlin/content/skill/summoning/pet/Incubator.kt b/game/src/main/kotlin/content/skill/summoning/pet/Incubator.kt index 32193ba1e5..1b9a57bb4e 100644 --- a/game/src/main/kotlin/content/skill/summoning/pet/Incubator.kt +++ b/game/src/main/kotlin/content/skill/summoning/pet/Incubator.kt @@ -9,7 +9,7 @@ import world.gregs.voidps.engine.data.definition.Rows import world.gregs.voidps.engine.data.definition.Tables import world.gregs.voidps.engine.entity.character.player.Player import world.gregs.voidps.engine.entity.character.player.skill.Skill -import world.gregs.voidps.engine.entity.character.player.skill.level.Level.has +import world.gregs.voidps.engine.entity.character.player.skill.level.Level.hasMax import world.gregs.voidps.engine.inv.add import world.gregs.voidps.engine.inv.inventory import world.gregs.voidps.engine.inv.remove @@ -68,7 +68,7 @@ class Incubator : Script { return@itemOnObjectOperate } val level = def.int("summoning_level") - if (!has(Skill.Summoning, level)) { + if (!hasMax(Skill.Summoning, level)) { message("You need a Summoning level of $level to incubate this egg.") return@itemOnObjectOperate } diff --git a/game/src/main/kotlin/content/skill/summoning/pet/Pet.kt b/game/src/main/kotlin/content/skill/summoning/pet/Pet.kt index d6261fdd68..10c22a4f5d 100644 --- a/game/src/main/kotlin/content/skill/summoning/pet/Pet.kt +++ b/game/src/main/kotlin/content/skill/summoning/pet/Pet.kt @@ -12,7 +12,6 @@ import world.gregs.voidps.engine.client.sendScript import world.gregs.voidps.engine.client.variable.MapValues import world.gregs.voidps.engine.data.config.RowDefinition import world.gregs.voidps.engine.data.definition.AnimationDefinitions -import world.gregs.voidps.engine.data.definition.EnumDefinitions import world.gregs.voidps.engine.data.definition.ItemDefinitions import world.gregs.voidps.engine.data.definition.Tables import world.gregs.voidps.engine.data.definition.VariableDefinitions @@ -23,7 +22,7 @@ import world.gregs.voidps.engine.entity.character.npc.NPCs import world.gregs.voidps.engine.entity.character.player.Player import world.gregs.voidps.engine.entity.character.player.name import world.gregs.voidps.engine.entity.character.player.skill.Skill -import world.gregs.voidps.engine.entity.character.player.skill.level.Level.has +import world.gregs.voidps.engine.entity.character.player.skill.level.Level.hasMax import world.gregs.voidps.engine.get import world.gregs.voidps.engine.inv.add import world.gregs.voidps.engine.inv.inventory @@ -68,7 +67,9 @@ fun Player.summonPet(row: RowDefinition, itemId: String, restart: Boolean = fals // gate on Slayer and sneakerpeeper on Dungeoneering — so the skill is // row-driven rather than hardcoded. val skill = row.skillOrNull("skill") ?: Skill.Summoning - if (!has(skill, level)) { + // hasMax: requirements check the player's real level, not the current one - for Summoning + // that's the points pool, which drains as points are spent. + if (!hasMax(skill, level)) { message("You need a ${skill.name} level of $level to raise this pet.") return false } @@ -76,7 +77,7 @@ fun Player.summonPet(row: RowDefinition, itemId: String, restart: Boolean = fals // its primary Dungeoneering 80 check. val secondarySkill = row.skillOrNull("secondary_skill") val secondaryLevel = row.intOrNull("secondary_level") ?: 0 - if (secondarySkill != null && secondaryLevel > 0 && !has(secondarySkill, secondaryLevel)) { + if (secondarySkill != null && secondaryLevel > 0 && !hasMax(secondarySkill, secondaryLevel)) { message("You also need a ${secondarySkill.name} level of $secondaryLevel to raise this pet.") return false } @@ -194,15 +195,13 @@ suspend fun Player.talkToPet(row: RowDefinition, pet: NPC) { row.ambientPhrases().randomOrNull()?.let { pet.say(it) } return } - val rowAnim = row.animOrNull("chathead_anim") // TODO no pet dialogues are added, "pet_details_chathead_animations_normal" is only for pouch familiars - val fallbackAnim = when { - rowAnim != null -> rowAnim - else -> AnimationDefinitions.get(EnumDefinitions.int("pet_details_chathead_animations_normal", pet.def.id)).stringId - } + // Mapped pets (sneakerpeeper) resolve their chathead animation through + // familiarChatheadAnimation like familiars do; the expression is only a + // fallback for pets outside the varbit map. for (line in chosen.stringList("lines")) { val rendered = substitutePlayerName(line, name) when { - rendered.startsWith("npc:") -> npc(npcId = pet.id, expression = fallbackAnim, text = breakParenTranslation(rendered.removePrefix("npc:").trim())) + rendered.startsWith("npc:") -> npc(npcId = pet.id, expression = "neutral", text = breakParenTranslation(rendered.removePrefix("npc:").trim())) rendered.startsWith("player:") -> player(rendered.removePrefix("player:").trim()) rendered.startsWith("overhead:") -> pet.say(rendered.removePrefix("overhead:").trim()) rendered.startsWith("[") && rendered.endsWith("]") -> statement(rendered.removePrefix("[").removeSuffix("]").trim()) @@ -282,7 +281,7 @@ private fun Player.canSummonPet(row: RowDefinition): Boolean { // Mirror summonPet: the gating skill is row-driven (Slayer for Soul Wars // pets, Dungeoneering for sneakerpeeper) and only defaults to Summoning. val skill = row.skillOrNull("skill") ?: Skill.Summoning - return has(skill, row.int("summoning_level")) + return hasMax(skill, row.int("summoning_level")) } private suspend fun Player.dropPet(row: RowDefinition, itemId: String) { diff --git a/game/src/main/kotlin/content/skill/thieving/Pickpocketing.kt b/game/src/main/kotlin/content/skill/thieving/Pickpocketing.kt index 7f2d1590cc..1d46ec9864 100644 --- a/game/src/main/kotlin/content/skill/thieving/Pickpocketing.kt +++ b/game/src/main/kotlin/content/skill/thieving/Pickpocketing.kt @@ -3,6 +3,7 @@ package content.skill.thieving import com.github.michaelbull.logging.InlineLogger import content.entity.effect.stun import content.skill.slayer.categories +import content.skill.summoning.familiarBoost import world.gregs.voidps.engine.Script import world.gregs.voidps.engine.client.message import world.gregs.voidps.engine.client.variable.hasClock @@ -63,7 +64,7 @@ class Pickpocketing(val combatDefinitions: CombatDefinitions, val dropTables: Dr if (equipped(EquipSlot.Hands).id == "gloves_of_silence" && equipment.discharge(this, EquipSlot.Hands.index)) { chances = (chances.first + (chances.first / 20)).coerceAtMost(255)..(chances.last + (chances.last / 20)).coerceAtMost(255) } - val success = success(levels.get(Skill.Thieving), chances) + val success = success(levels.get(Skill.Thieving) + familiarBoost(Skill.Thieving), chances) val table = pickpocket.string("table") val drops = getLoot(target, table) ?: emptyList() if (success && !canLoot(this, drops)) { diff --git a/game/src/main/kotlin/content/skill/thieving/Stalls.kt b/game/src/main/kotlin/content/skill/thieving/Stalls.kt index a74d15befb..52cf11a568 100644 --- a/game/src/main/kotlin/content/skill/thieving/Stalls.kt +++ b/game/src/main/kotlin/content/skill/thieving/Stalls.kt @@ -1,6 +1,7 @@ package content.skill.thieving import content.entity.combat.underAttack +import content.skill.summoning.familiarBoost import world.gregs.voidps.engine.Script import world.gregs.voidps.engine.client.message import world.gregs.voidps.engine.client.ui.chat.toIntRange @@ -31,7 +32,7 @@ class Stalls(val drops: DropTables) : Script { return@objectOperate } if (equipped(EquipSlot.Weapon).id.endsWith("_greegree")) { - message("You wouldn’t like to blow your cover by getting caught stealing.") + message("You wouldn't like to blow your cover by getting caught stealing.") return@objectOperate } val def = target.def(this) @@ -50,7 +51,7 @@ class Stalls(val drops: DropTables) : Script { val chance: String? = def.getOrNull("chance") if (chance != null) { val range = chance.toIntRange(inclusive = true) - if (!Level.success(levels.get(Skill.Thieving), range)) { + if (!Level.success(levels.get(Skill.Thieving) + familiarBoost(Skill.Thieving), range)) { message("You attempt to steal from the stall but you miss your chance...") return@objectOperate } diff --git a/game/src/main/kotlin/content/skill/woodcutting/Woodcutting.kt b/game/src/main/kotlin/content/skill/woodcutting/Woodcutting.kt index c52046d3a4..f21e430e72 100644 --- a/game/src/main/kotlin/content/skill/woodcutting/Woodcutting.kt +++ b/game/src/main/kotlin/content/skill/woodcutting/Woodcutting.kt @@ -1,5 +1,6 @@ package content.skill.woodcutting +import content.skill.summoning.familiarBoost import net.pearx.kasechange.toLowerSpaceCase import world.gregs.voidps.engine.Script import world.gregs.voidps.engine.client.message @@ -83,7 +84,7 @@ class Woodcutting(val drops: DropTables) : Script { if (!GameObjects.contains(target)) { break } - if (success(player.levels.get(Skill.Woodcutting), hatchet, log)) { + if (success(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) diff --git a/game/src/test/kotlin/content/TypographicCharactersTest.kt b/game/src/test/kotlin/content/TypographicCharactersTest.kt new file mode 100644 index 0000000000..f01dcbb10b --- /dev/null +++ b/game/src/test/kotlin/content/TypographicCharactersTest.kt @@ -0,0 +1,63 @@ +package content + +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.io.File + +class TypographicCharactersTest { + + /** + * Interface option matchers compare against cache strings, which may legitimately + * contain non-ASCII characters - they are never written to the client. + */ + private val allowed = setOf( + "TradeLending.kt", + ) + + /** + * Writer.writeString sends each character as a single byte, so anything outside the + * client's single-byte charset (smart quotes, ellipses, em-dashes) truncates to a + * control byte the client's font handling can't render, crashing the game. + */ + @Test + fun `Client-bound strings only use single-byte characters`() { + val root = sequenceOf(File("src/main/kotlin/content"), File("game/src/main/kotlin/content")).firstOrNull { it.isDirectory } + assertTrue(root != null, "Unable to locate the content source directory.") + val offences = mutableListOf() + for (file in root!!.walkTopDown().filter { it.extension == "kt" && it.name !in allowed }) { + var blockComment = false + for ((index, raw) in file.readLines().withIndex()) { + var line = raw + if (blockComment) { + val end = line.indexOf("*/") + if (end == -1) { + continue + } + line = line.substring(end + 2) + blockComment = false + } + while (true) { + val start = line.indexOf("/*") + if (start == -1) { + break + } + val end = line.indexOf("*/", start + 2) + if (end == -1) { + line = line.substring(0, start) + blockComment = true + break + } + line = line.substring(0, start) + line.substring(end + 2) + } + val comment = line.indexOf("//") + if (comment != -1) { + line = line.substring(0, comment) + } + if (line.any { it.code > 0xFF }) { + offences.add("${file.relativeTo(root)}:${index + 1}: ${raw.trim()}") + } + } + } + assertTrue(offences.isEmpty(), "Multi-byte characters found in client-bound strings:\n${offences.joinToString("\n")}") + } +} diff --git a/game/src/test/kotlin/content/area/asgarnia/taverley/PikkupstixTest.kt b/game/src/test/kotlin/content/area/asgarnia/taverley/PikkupstixTest.kt new file mode 100644 index 0000000000..348bb7fa5c --- /dev/null +++ b/game/src/test/kotlin/content/area/asgarnia/taverley/PikkupstixTest.kt @@ -0,0 +1,33 @@ +package content.area.asgarnia.taverley + +import WorldTest +import npcOption +import org.junit.jupiter.api.Test +import world.gregs.voidps.engine.client.ui.dialogue +import world.gregs.voidps.type.Tile +import kotlin.test.assertTrue + +class PikkupstixTest : WorldTest() { + + @Test + fun `Talk-to opens the summoning tutor menu`() { + val player = createPlayer(Tile(2523, 3056)) + val pikkupstix = createNPC("pikkupstix", player.tile.addY(1)) + + player.npcOption(pikkupstix, "Talk-to") + tick(1) + + assertTrue(player.dialogue != null, "the tutor dialogue opens") + } + + @Test + fun `Enchant explains how to fill headgear with scrolls`() { + val player = createPlayer(Tile(2523, 3056)) + val pikkupstix = createNPC("pikkupstix", player.tile.addY(1)) + + player.npcOption(pikkupstix, "Enchant") + tick(1) + + assertTrue(player.dialogue != null, "he explains what to do") + } +} diff --git a/game/src/test/kotlin/content/area/wilderness/abyss/AbyssPrayerDrainTest.kt b/game/src/test/kotlin/content/area/wilderness/abyss/AbyssPrayerDrainTest.kt new file mode 100644 index 0000000000..c6cad94aa7 --- /dev/null +++ b/game/src/test/kotlin/content/area/wilderness/abyss/AbyssPrayerDrainTest.kt @@ -0,0 +1,108 @@ +package content.area.wilderness.abyss + +import FakeRandom +import WorldTest +import content.skill.summoning.dismissFamiliar +import content.skill.summoning.summonFamiliar +import npcOption +import org.junit.jupiter.api.Test +import world.gregs.voidps.engine.data.definition.Areas +import world.gregs.voidps.engine.data.definition.NPCDefinitions +import world.gregs.voidps.engine.entity.character.move.tele +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.entity.character.player.skill.Skill +import world.gregs.voidps.type.Tile +import world.gregs.voidps.type.setRandom +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +internal class AbyssPrayerDrainTest : WorldTest() { + + private fun createPlayerWithPrayer(tile: Tile): Player { + val player = createPlayer(tile) + player.levels.set(Skill.Prayer, 99) + return player + } + + private fun Player.summonParasite() { + levels.set(Skill.Summoning, 99) + summonFamiliar(NPCDefinitions.get("abyssal_parasite_familiar"), restart = false) + tick(2) // let the summon queue assign the follower + } + + private fun Player.mageTeleport() { + this["enter_the_abyss"] = "completed" + val mage = createNPC("mage_of_zamorak_wilderness", tile.addY(1)) + // Force the random abyss landing spot onto a known walkable outer-ring tile + setRandom(object : FakeRandom() { + override fun nextInt(from: Int, until: Int): Int = when (from) { + 3008 -> 3026 + 4800 -> 4812 + else -> from + } + }) + npcOption(mage, "Teleport") + tick(8) + } + + @Test + fun `Entering the Abyss drains all prayer points`() { + val player = createPlayerWithPrayer(Tile(3102, 3556)) + + player.mageTeleport() + + assertTrue(player.tile in Areas["abyss_multi_area"]) + assertEquals(0, player.levels.get(Skill.Prayer)) + } + + @Test + fun `Abyssal parasite prevents the full drain on entry`() { + val player = createPlayerWithPrayer(Tile(3102, 3556)) + player.summonParasite() + + player.mageTeleport() + + assertTrue(player.tile in Areas["abyss_multi_area"]) + assertEquals(99, player.levels.get(Skill.Prayer)) + } + + @Test + fun `Parasite turns the drain into a gradual leak`() { + val player = createPlayerWithPrayer(Tile(3102, 3556)) + player.summonParasite() + + player.tele(Tile(3026, 4812)) + tick(8) + assertEquals(89, player.levels.get(Skill.Prayer)) + tick(8) + assertEquals(79, player.levels.get(Skill.Prayer)) + } + + @Test + fun `Leak stops after leaving the Abyss`() { + val player = createPlayerWithPrayer(Tile(3102, 3556)) + player.summonParasite() + + player.tele(Tile(3026, 4812)) + tick(8) + assertEquals(89, player.levels.get(Skill.Prayer)) + + player.tele(Tile(3102, 3556)) + tick(16) + assertEquals(89, player.levels.get(Skill.Prayer)) + } + + @Test + fun `Losing the parasite inside the Abyss drains the rest`() { + val player = createPlayerWithPrayer(Tile(3102, 3556)) + player.summonParasite() + + player.tele(Tile(3026, 4812)) + tick(8) + assertEquals(89, player.levels.get(Skill.Prayer)) + + player.dismissFamiliar() + tick(8) + assertEquals(0, player.levels.get(Skill.Prayer)) + } +} diff --git a/game/src/test/kotlin/content/entity/combat/CombatMovementTest.kt b/game/src/test/kotlin/content/entity/combat/CombatMovementTest.kt index a911d14b8b..4c14e011d2 100644 --- a/game/src/test/kotlin/content/entity/combat/CombatMovementTest.kt +++ b/game/src/test/kotlin/content/entity/combat/CombatMovementTest.kt @@ -1,14 +1,22 @@ package content.entity.combat import WorldTest +import content.skill.melee.weapon.attackSpeed +import content.skill.summoning.assistFamiliar +import content.skill.summoning.commandFamiliarAttack +import content.skill.summoning.follower import npcOption import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test +import org.rsmod.game.pathfinder.flag.CollisionFlag import world.gregs.voidps.engine.client.instruction.handle.interactNpc import world.gregs.voidps.engine.client.instruction.handle.interactPlayer +import world.gregs.voidps.engine.client.variable.start import world.gregs.voidps.engine.entity.character.mode.EmptyMode +import world.gregs.voidps.engine.entity.character.mode.Follow import world.gregs.voidps.engine.entity.character.mode.Retreat import world.gregs.voidps.engine.entity.character.mode.combat.CombatMovement import world.gregs.voidps.engine.entity.character.move.running @@ -139,6 +147,281 @@ internal class CombatMovementTest : WorldTest() { assertTrue(npc.mode is CombatMovement) } + @Test + fun `Familiar follower leash anchors to owner not spawn tile`() { + val owner = createPlayer(Tile(3032, 3352)) + val familiar = createNPC("spirit_wolf_familiar", Tile(3032, 3352)) + familiar["owner_index"] = owner.index + // A spawn far from the fight would de-aggro a normal NPC; the familiar anchors to its owner instead. + familiar["spawn_tile"] = Tile(3100, 3100) + val target = createNPC("guard_falador", Tile(3032, 3351)) + familiar.mode = CombatMovement(familiar, target) + // Owner stays beside the fight, so the owner-anchored leash keeps the familiar engaged + // even though its spawn tile is far away (a spawn-anchored NPC would drop out here). + tick(2) + assertTrue(familiar.mode is CombatMovement) + } + + @Test + fun `Familiar attack speed is 8 ticks`() { + assertEquals(8, createNPC("spirit_wolf_familiar", Tile(3032, 3352)).attackSpeed) + assertEquals(8, createNPC("spirit_spider_familiar", Tile(3033, 3352)).attackSpeed) + } + + @Test + fun `Owned familiar does not block-move through players`() { + val familiar = createNPC("spirit_wolf_familiar", Tile(3032, 3352)) + familiar["owner_index"] = 5 + // Phases through players (no BLOCK_PLAYERS) but still collides with npcs (BLOCK_NPCS). + assertEquals(0, familiar.blockMove and CollisionFlag.BLOCK_PLAYERS) + assertTrue(familiar.blockMove and CollisionFlag.BLOCK_NPCS != 0) + } + + @Test + fun `A regular npc still block-moves through players and npcs`() { + val npc = createNPC("guard_falador", Tile(3032, 3352)) + assertTrue(npc.blockMove and CollisionFlag.BLOCK_PLAYERS != 0) + assertTrue(npc.blockMove and CollisionFlag.BLOCK_NPCS != 0) + } + + @Test + fun `Familiar can be ordered to attack an npc in single-way`() { + val owner = createPlayer(Tile(3032, 3352)) + val familiar = createNPC("spirit_wolf_familiar", Tile(3033, 3352)) + familiar["owner_index"] = owner.index + familiar["spawn_tile"] = familiar.tile + owner.follower = familiar + // Single-way zone, unengaged NPC: the familiar can be ordered to fight it solo. + val target = createNPC("guard_falador", Tile(3032, 3351)) + + owner.commandFamiliarAttack(target) + tick(8) + + assertTrue(familiar.mode is CombatMovement) + assertEquals(target, (familiar.mode as CombatMovement).target) + } + + @Test + fun `Familiar returns to following its owner when it cannot reach its target`() { + val owner = createPlayer(emptyTile.add(0, 10)) + val familiar = createNPC("spirit_wolf_familiar", emptyTile) + familiar["owner_index"] = owner.index + familiar["spawn_tile"] = familiar.tile + owner.follower = familiar + val target = createNPC("guard_falador", emptyTile.add(10, 0)) + familiar.mode = CombatMovement(familiar, target) + // Familiar is stuck (can't move), so it can never close the distance to the far target; + // after the grace period it should give up and fall back to following its owner. + familiar.start("movement_delay", 100) + + tick(8) + + assertTrue(familiar.mode is Follow, "mode=${familiar.mode}") + } + + @Test + fun `Familiar chases its target when it retreats out of range`() { + val owner = createPlayer(Tile(3032, 3352)) + val familiar = createNPC("spirit_wolf_familiar", Tile(3033, 3352)) + familiar["owner_index"] = owner.index + familiar["spawn_tile"] = familiar.tile + val target = createNPC("guard_falador", Tile(3034, 3352)) + familiar.mode = CombatMovement(familiar, target) + tick(2) + + // The target retreats well out of attack range. + target.tele(Tile(3044, 3352)) + val beforeX = familiar.tile.x + tick(4) + + assertTrue(familiar.mode is CombatMovement) + assertTrue(familiar.tile.x > beforeX) + } + + @Test + fun `Familiar keeps pathing to its target when an obstruction blocks then clears`() { + val owner = createPlayer(Tile(3032, 3352)) + val familiar = createNPC("spirit_wolf_familiar", Tile(3032, 3352)) + familiar["owner_index"] = owner.index + familiar["spawn_tile"] = familiar.tile + owner.follower = familiar + val target = createNPC("guard_falador", Tile(3046, 3352)) + // A wall of blockers directly between the familiar and its target. + val blockers = listOf( + createNPC("guard_falador", Tile(3033, 3351)), + createNPC("guard_falador", Tile(3033, 3352)), + createNPC("guard_falador", Tile(3033, 3353)), + ) + + owner.commandFamiliarAttack(target) + tick(3) + // It's still trying to reach the target (CombatMovement persists) rather than giving up. + assertTrue(familiar.mode is CombatMovement) + + // Clear the wall; the familiar must resume closing the distance, not stay frozen. + blockers.forEach { it.tele(Tile(3033, 3360)) } + val beforeX = familiar.tile.x + tick(4) + + assertTrue(familiar.mode is CombatMovement) + assertTrue(familiar.tile.x > beforeX) + } + + @Test + fun `Familiar walks to a distant ordered target in single-way`() { + val owner = createPlayer(Tile(3032, 3352)) + val familiar = createNPC("spirit_wolf_familiar", Tile(3032, 3352)) + familiar["owner_index"] = owner.index + familiar["spawn_tile"] = familiar.tile + owner.follower = familiar + // 14 tiles east (beyond the ~10-tile approach range, so the interact phase must walk it + // before combat starts), unobstructed: the familiar must close the distance to attack. + val target = createNPC("guard_falador", Tile(3046, 3352)) + val startX = familiar.tile.x + + owner.commandFamiliarAttack(target) + tick(6) + + assertTrue(familiar.tile.x > startX) + } + + @Test + fun `Player cannot attack a monster its familiar is fighting in single-way`() { + val owner = createPlayer(Tile(3032, 3352)) + val familiar = createNPC("spirit_wolf_familiar", Tile(3033, 3352)) + familiar["owner_index"] = owner.index + val target = createNPC("guard_falador", Tile(3034, 3352)) + // The familiar is already fighting the target (single-combat tracks one attacker per + // target), so the owner - a separate attacker - can't also attack it in a single-way zone. + target.attacker = familiar + target.start("under_attack", 8) + + assertFalse(Target.attackable(owner, target, message = false)) + } + + @Test + fun `Familiar does not assist owner in single-way combat`() { + val owner = createPlayer(Tile(3032, 3352)) + owner.equipment.set(EquipSlot.Weapon.index, "dragon_longsword") + val familiar = createNPC("spirit_wolf_familiar", Tile(3033, 3352)) + familiar["owner_index"] = owner.index + familiar["spawn_tile"] = familiar.tile + owner.follower = familiar + // Single-way zone (no in_multi_combat flag): the familiar can still be used, but combat + // familiars only fight in multi-combat zones, so it must not join the owner's fight. + val target = createNPC("guard_falador", Tile(3032, 3351)) + + owner.npcOption(target, "Attack") + tick(8) + + assertFalse(familiar.mode is CombatMovement) + } + + @Test + fun `Familiar approaches a commanded target beyond its aggro range`() { + val owner = createPlayer(Tile(3032, 3352)) + val familiar = createNPC("spirit_wolf_familiar", Tile(3032, 3352)) + familiar["owner_index"] = owner.index + familiar["spawn_tile"] = familiar.tile + // 12 tiles away: beyond the familiar's retreat_range(8) + attackRange(1) = 9 aggro range. + val target = createNPC("guard_falador", Tile(3044, 3352)) + familiar.mode = CombatMovement(familiar, target) + val startX = familiar.tile.x + tick(3) + // The familiar isn't bound by the aggro leash, so it walks towards the target instead of + // freezing (a spawn/owner-leashed NPC would drop to EmptyMode here). + assertTrue(familiar.mode is CombatMovement) + assertTrue(familiar.tile.x > startX) + } + + @Test + fun `Familiar auto-assists owner against multi-combat npc`() { + val owner = createPlayer(Tile(3032, 3352)) + owner.equipment.set(EquipSlot.Weapon.index, "dragon_longsword") + owner["in_multi_combat"] = true + val familiar = createNPC("spirit_wolf_familiar", Tile(3033, 3352)) + familiar["owner_index"] = owner.index + familiar["spawn_tile"] = familiar.tile + owner.follower = familiar + val target = createNPC("guard_falador", Tile(3032, 3351)) + target["in_multi_combat"] = true + + owner.npcOption(target, "Attack") + tick(8) + + assertTrue(familiar.mode is CombatMovement) + assertEquals(target, (familiar.mode as CombatMovement).target) + } + + @Test + fun `Familiar keeps its target when the owner switches targets`() { + val owner = createPlayer(Tile(3032, 3352)) + owner["in_multi_combat"] = true + val familiar = createNPC("spirit_wolf_familiar", Tile(3033, 3352)) + familiar["owner_index"] = owner.index + familiar["spawn_tile"] = familiar.tile + owner.follower = familiar + val first = createNPC("guard_falador", Tile(3034, 3352)) + first["in_multi_combat"] = true + val second = createNPC("guard_falador", Tile(3032, 3351)) + second["in_multi_combat"] = true + familiar.mode = CombatMovement(familiar, first) + + // The owner now attacks a different enemy; the familiar must stay on its original target. + owner.assistFamiliar(second) + tick() + + assertTrue(familiar.mode is CombatMovement) + assertEquals(first, (familiar.mode as CombatMovement).target) + } + + @Test + fun `Familiar re-acquires the owner's target after being freed mid-fight`() { + val owner = createPlayer(Tile(3032, 3352)) + owner["in_multi_combat"] = true + owner.equipment.set(EquipSlot.Weapon.index, "dragon_longsword") + val familiar = createNPC("spirit_wolf_familiar", Tile(3033, 3352)) + familiar["owner_index"] = owner.index + familiar["spawn_tile"] = familiar.tile + owner.follower = familiar + val target = createNPC("guard_falador", Tile(3032, 3351)) + target["in_multi_combat"] = true + val dummy = createNPC("guard_falador", Tile(3040, 3352)) + dummy["in_multi_combat"] = true + // Busy elsewhere when the owner starts the fight, so the initial combatStart assist is missed. + familiar.mode = CombatMovement(familiar, dummy) + owner.npcOption(target, "Attack") + tick(2) + + // Free the familiar; while the owner keeps attacking, it should re-acquire the owner's target. + familiar.mode = EmptyMode + tick(6) + + assertTrue(familiar.mode is CombatMovement) + assertEquals(target, (familiar.mode as CombatMovement).target) + } + + @Test + fun `Player cannot attack their own familiar`() { + val owner = createPlayer(Tile(3032, 3352)) + // PvP combat variant carries the "Attack" option, so ownership is the only blocker here. + val familiar = createNPC("spirit_wolf_familiar_combat", Tile(3033, 3352)) + familiar["owner_index"] = owner.index + assertFalse(Target.attackable(owner, familiar, message = false)) + } + + @Test + fun `Idle familiar resumes following its owner after combat`() { + val owner = createPlayer(Tile(3032, 3352)) + val familiar = createNPC("spirit_wolf_familiar", Tile(3034, 3352)) + familiar["owner_index"] = owner.index + owner.follower = familiar + // Combat ending leaves the familiar idle; it should fall back to following, not stand still. + familiar.mode = EmptyMode + tick() + assertTrue(familiar.mode is Follow) + } + companion object { private const val MAX_EXP = 14000000.0 } diff --git a/game/src/test/kotlin/content/entity/combat/CombatTest.kt b/game/src/test/kotlin/content/entity/combat/CombatTest.kt index 920fd8804a..10fdea6bc4 100644 --- a/game/src/test/kotlin/content/entity/combat/CombatTest.kt +++ b/game/src/test/kotlin/content/entity/combat/CombatTest.kt @@ -2,6 +2,10 @@ package content.entity.combat import FakeRandom import WorldTest +import content.entity.combat.damageDealers +import content.entity.combat.hit.hit +import content.entity.effect.stun +import content.entity.effect.stunned import content.entity.player.effect.skull import equipItem import interfaceOption @@ -12,7 +16,9 @@ import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertNotNull import playerOption +import world.gregs.voidps.engine.entity.character.mode.combat.CombatMovement import world.gregs.voidps.engine.entity.character.player.appearance +import world.gregs.voidps.engine.entity.character.player.name import world.gregs.voidps.engine.entity.character.player.skill.Skill import world.gregs.voidps.engine.entity.character.player.skill.level.Level import world.gregs.voidps.engine.entity.character.player.skill.level.Levels @@ -85,6 +91,54 @@ internal class CombatTest : WorldTest() { assertNotNull(FloorItems.firstOrNull(tile, "bones")) } + @Test + fun `Familiar kill drops loot for its owner`() { + val owner = createPlayer(emptyTile) + val familiar = createNPC("spirit_wolf_familiar", emptyTile.addX(1)) + familiar["owner_index"] = owner.index + val npc = createNPC("giant_rat", emptyTile.addY(4)) + // The familiar dealt the killing damage. + npc.damageDealers[familiar] = 100 + val tile = npc.tile + npc.levels.set(Skill.Constitution, 0) + tick(8) // npc death + drop + + val bones = FloorItems.firstOrNull(npc["death_tile", tile], "bones") + assertNotNull(bones) + assertEquals(owner.name, bones!!.owner) + } + + @Test + fun `A 0 hit on a familiar is attributed to its owner so the owner sees the blue splat`() { + val owner = createPlayer(emptyTile) + val familiar = createNPC("spirit_wolf_familiar", emptyTile.addX(1)) + familiar["owner_index"] = owner.index + val attacker = createNPC("giant_rat", emptyTile.addY(4)) + + // An npc lands a 0 (blocked) hit on the familiar. + attacker.hit(familiar, offensiveType = "melee", damage = 0) + tick(1) // resolve the hit; read the splat this tick, before the next sync clears it + + val splat = familiar.visuals.hits.splats.firstOrNull { it != null } + assertNotNull(splat) + // Owner index (positive) rather than the attacker npc, so the client shows the owner the splat. + assertEquals(owner.index, splat!!.source) + } + + @Test + fun `Npc killed by a non-familiar npc does not crash resolving an owner`() { + val attacker = createNPC("giant_rat", emptyTile.addX(1)) + val npc = createNPC("giant_rat", emptyTile.addY(4)) + // A plain npc killer has no owner_index (-1); resolving it must not throw. + npc.damageDealers[attacker] = 100 + val tile = npc.tile + npc.levels.set(Skill.Constitution, 0) + tick(8) + + // The killer isn't a player, so no loot is dropped (original behaviour) - and no crash. + assertNull(FloorItems.firstOrNull(npc["death_tile", tile], "bones")) + } + @Test fun `Kill rat with range`() { setRandom(object : FakeRandom() { @@ -276,6 +330,32 @@ internal class CombatTest : WorldTest() { assertEquals(npc.tile, npc["death_tile"]) } + @Test + fun `Stunned npc cannot land attacks until the stun expires`() { + val player = createPlayer(emptyTile) + val npc = createNPC("rat", emptyTile.addY(1)) + npc.mode = CombatMovement(npc, player) + npc.target = player + + // Baseline: an un-stunned rat reliably damages the adjacent player. + val healthy = player.levels.get(Skill.Constitution) + tick(6) + assertTrue(player.levels.get(Skill.Constitution) < healthy, "an un-stunned rat lands hits") + + // Stun the rat - it must not swing for the stun's duration. + player.stun(npc, 10) + assertTrue(npc.stunned) + val stunned = player.levels.get(Skill.Constitution) + tick(6) + // No hits land: health only holds or naturally regenerates, it never drops. + val duringStun = player.levels.get(Skill.Constitution) + assertTrue(duringStun >= stunned, "a stunned rat lands no hits") + + // Attacks resume once the stun lapses. + tick(8) + assertTrue(player.levels.get(Skill.Constitution) < duringStun, "the rat attacks again after the stun") + } + companion object { private const val EXPERIENCE = 14000000.0 } diff --git a/game/src/test/kotlin/content/skill/summoning/CompostMoundGenerateTest.kt b/game/src/test/kotlin/content/skill/summoning/CompostMoundGenerateTest.kt new file mode 100644 index 0000000000..3c8e5c6bdf --- /dev/null +++ b/game/src/test/kotlin/content/skill/summoning/CompostMoundGenerateTest.kt @@ -0,0 +1,66 @@ +package content.skill.summoning + +import FakeRandom +import WorldTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import world.gregs.voidps.engine.data.definition.NPCDefinitions +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.entity.character.player.skill.Skill +import world.gregs.voidps.engine.entity.obj.GameObjects +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.engine.inv.transact.operation.AddItem.add +import world.gregs.voidps.type.Tile +import world.gregs.voidps.type.setRandom + +internal class CompostMoundGenerateTest : WorldTest() { + + private fun castOn(binVar: String, superRoll: Int): Player { + setRandom(object : FakeRandom() { + override fun nextInt(until: Int) = superRoll + }) + val player = createPlayer(Tile(3057, 3311)) + player.levels.set(Skill.Summoning, 99) + player.summonFamiliar(NPCDefinitions.get("compost_mound_familiar"), restart = false) + tick(2) + player.set("summoning_special_points_remaining", 60) + player.inventory.transaction { add("generate_compost_scroll", 2) } + val bin = GameObjects.find(Tile(3056, 3312), "farming_compost_bin_falador")!! + + player.castFamiliarSpecial { FamiliarSpecialMoves.objectTarget.getValue("compost_mound_familiar").invoke(player, bin) } + tick(5) // projectile flight then the queued fill + return player + } + + @Test + fun `Generate Compost fills an empty bin with compost`() { + val player = castOn("compost_bin_falador", superRoll = 1) // not 0 -> normal compost + assertEquals("compost_15", player["compost_bin_falador", "empty"]) + assertEquals(1, player.inventory.count("generate_compost_scroll"), "one scroll spent") + } + + @Test + fun `Generate Compost can yield supercompost`() { + val player = castOn("compost_bin_falador", superRoll = 0) // 0 -> supercompost + assertEquals("supercompost_15", player["compost_bin_falador", "empty"]) + } + + @Test + fun `Generate Compost rejects a non-empty bin`() { + setRandom(FakeRandom()) + val player = createPlayer(Tile(3057, 3311)) + player.levels.set(Skill.Summoning, 99) + player.summonFamiliar(NPCDefinitions.get("compost_mound_familiar"), restart = false) + tick(2) + player.set("summoning_special_points_remaining", 60) + player.inventory.transaction { add("generate_compost_scroll", 2) } + player["compost_bin_falador"] = "compostable_5" + val bin = GameObjects.find(Tile(3056, 3312), "farming_compost_bin_falador")!! + + player.castFamiliarSpecial { FamiliarSpecialMoves.objectTarget.getValue("compost_mound_familiar").invoke(player, bin) } + tick(5) + + assertEquals("compostable_5", player["compost_bin_falador", "empty"], "unchanged") + assertEquals(2, player.inventory.count("generate_compost_scroll"), "no scroll spent on a bad target") + } +} diff --git a/game/src/test/kotlin/content/skill/summoning/EnchantedHeadgearTest.kt b/game/src/test/kotlin/content/skill/summoning/EnchantedHeadgearTest.kt new file mode 100644 index 0000000000..f4ffea7231 --- /dev/null +++ b/game/src/test/kotlin/content/skill/summoning/EnchantedHeadgearTest.kt @@ -0,0 +1,151 @@ +package content.skill.summoning + +import WorldTest +import containsMessage +import itemOnItem +import itemOnNpc +import itemOption +import org.junit.jupiter.api.Test +import world.gregs.voidps.engine.data.definition.NPCDefinitions +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.entity.character.player.skill.Skill +import world.gregs.voidps.engine.inv.equipment +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.engine.inv.remove +import world.gregs.voidps.engine.inv.transact.operation.AddItem.add +import world.gregs.voidps.network.login.protocol.visual.update.player.EquipSlot +import world.gregs.voidps.type.Tile +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class EnchantedHeadgearTest : WorldTest() { + + private fun summoner(tile: Tile = Tile(2523, 3056)): Player { + val player = createPlayer(tile) + player.experience.set(Skill.Summoning, 14_000_000.0) // level requirements check the real level + player.levels.set(Skill.Summoning, 99) + return player + } + + @Test + fun `Using combat scrolls on a helm charges it`() { + val player = summoner() + player.inventory.transaction { + add("antlers", 1) + add("iron_bull_rush_scroll", 20) + } + + player.itemOnItem(1, 0) // scrolls onto the antlers + tick(1) + + assertTrue(player.inventory.indexOf("antlers_charged") != -1, "the antlers become charged") + assertEquals(0, player.inventory.count("antlers")) + assertEquals(0, player.inventory.count("iron_bull_rush_scroll"), "the scrolls are stored inside") + } + + @Test + fun `Non-combat scrolls cannot be stored`() { + val player = summoner() + player.inventory.transaction { + add("antlers", 1) + add("healing_aura_scroll", 5) // the unicorn's heal - not a combat scroll + } + + player.itemOnItem(1, 0) + tick(1) + + assertEquals(1, player.inventory.count("antlers"), "the helm stays plain") + assertEquals(5, player.inventory.count("healing_aura_scroll"), "the scrolls are refused") + } + + @Test + fun `Uncharge empties a charged helm back to its enchanted form`() { + val player = summoner() + player.inventory.transaction { + add("antlers", 1) + add("iron_bull_rush_scroll", 15) + } + player.itemOnItem(1, 0) + tick(1) + + player.itemOption("Uncharge", "antlers_charged") + tick(1) + + assertEquals(15, player.inventory.count("iron_bull_rush_scroll"), "the scrolls come back") + assertEquals(1, player.inventory.count("antlers"), "the antlers revert (their enchanted form is the plain helm)") + assertEquals(0, player.inventory.count("antlers_charged")) + } + + @Test + fun `Pikkupstix enchants a metal helm through its base, enchanted and charged states`() { + val player = summoner() + val pikkupstix = createNPC("pikkupstix", player.tile.addY(1)) + player.inventory.transaction { + add("helm_of_neitiznot", 1) + add("iron_bull_rush_scroll", 10) + } + + player.itemOnNpc(pikkupstix, 0) + tick(1) + assertEquals(1, player.inventory.count("helm_of_neitiznot_enchanted"), "the plain helm is enchanted") + + player.itemOnItem(1, player.inventory.indexOf("helm_of_neitiznot_enchanted")) + tick(1) + assertTrue(player.inventory.indexOf("helm_of_neitiznot_charged") != -1, "scrolls charge it") + assertEquals(0, player.inventory.count("iron_bull_rush_scroll")) + } + + @Test + fun `The stored count is mirrored onto the charged helm's item charge`() { + val player = summoner() + player.inventory.transaction { + add("antlers", 1) + add("iron_bull_rush_scroll", 12) + } + + player.itemOnItem(1, 0) + tick(1) + + val slot = player.inventory.indexOf("antlers_charged") + assertEquals(12, player.inventory[slot].amount, "the helm's charge shows the scroll count") + } + + @Test + fun `Commune reports the stored scrolls`() { + val player = summoner() + player.inventory.transaction { + add("antlers", 1) + add("iron_bull_rush_scroll", 7) + } + player.itemOnItem(1, 0) + tick(1) + + player.itemOption("Commune", "antlers_charged") + + assertTrue(player.containsMessage("7"), "it reports the count") + } + + @Test + fun `A worn enchanted helm supplies scrolls to a special move`() { + val player = summoner() + player.inventory.transaction { + add("antlers", 1) + add("iron_bull_rush_scroll", 3) + } + // Charge the helm, then wear it with no scrolls left in the pack. + player.itemOnItem(1, 0) + tick(1) + player.inventory.remove("antlers_charged") + player.equipment.set(EquipSlot.Hat.index, "antlers_charged") + + player.summonFamiliar(NPCDefinitions.get("iron_minotaur_familiar"), restart = false) + tick(2) + player.set("summoning_special_points_remaining", 60) + val target = createNPC("giant_rat", player.tile.addY(4)) + + assertEquals(3, player.get("enchanted_headgear_count", 0), "the helm holds three scrolls") + player.castFamiliarSpecial { FamiliarSpecialMoves.npcTarget.getValue("iron_minotaur_familiar").invoke(player, target) } + + assertEquals(2, player.get("enchanted_headgear_count", 0), "the helm supplied one scroll for the cast") + } +} diff --git a/game/src/test/kotlin/content/skill/summoning/FamiliarCombatSpecialEffectTest.kt b/game/src/test/kotlin/content/skill/summoning/FamiliarCombatSpecialEffectTest.kt new file mode 100644 index 0000000000..958780589b --- /dev/null +++ b/game/src/test/kotlin/content/skill/summoning/FamiliarCombatSpecialEffectTest.kt @@ -0,0 +1,471 @@ +package content.skill.summoning + +import FakeRandom +import WorldTest +import content.entity.combat.target +import content.entity.effect.frozen +import content.entity.effect.stunned +import content.entity.effect.toxin.poisoned +import content.entity.player.combat.special.specialAttackEnergy +import interfaceOption +import itemOnNpc +import org.junit.jupiter.api.Test +import world.gregs.voidps.engine.data.definition.NPCDefinitions +import world.gregs.voidps.engine.entity.character.npc.NPC +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.entity.character.player.combatLevel +import world.gregs.voidps.engine.entity.character.player.skill.Skill +import world.gregs.voidps.engine.entity.distanceTo +import world.gregs.voidps.engine.entity.item.floor.FloorItems +import world.gregs.voidps.engine.inv.equipment +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.engine.inv.transact.operation.AddItem.add +import world.gregs.voidps.network.login.protocol.visual.update.player.EquipSlot +import world.gregs.voidps.type.Tile +import world.gregs.voidps.type.setRandom +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * Covers the combat-flavoured familiar specials by invoking the registered blocks directly - the + * scroll/points gate itself is covered by [FamiliarSpecialMoveTest]. + */ +class FamiliarCombatSpecialEffectTest : WorldTest() { + + private fun summon(familiar: String, tile: Tile = Tile(2523, 3056)): Player { + val player = createPlayer(tile) + player.levels.set(Skill.Summoning, 99) + player.summonFamiliar(NPCDefinitions.get(familiar), restart = false) + tick(2) // let the summon queue assign the follower + player.set("summoning_special_points_remaining", 60) + return player + } + + private fun Player.runNpcSpecial(familiar: String, target: NPC): Boolean = FamiliarSpecialMoves.npcTarget.getValue(familiar).invoke(this, target) + + private fun Player.runPlayerSpecial(familiar: String, target: Player): Boolean = FamiliarSpecialMoves.playerTarget.getValue(familiar).invoke(this, target) + + /** Max out damage rolls so every hit is observable. */ + private fun maxRolls() = setRandom(object : FakeRandom() { + override fun nextInt(until: Int) = until - 1 + }) + + private fun tankyRat(player: Player, tile: Tile = player.tile.addY(4)): NPC { + val rat = createNPC("giant_rat", tile) + rat.levels.set(Skill.Constitution, 2000) + return rat + } + + /** The giant rat is 2x2 - size-gated effects (stun, bind) need this 1x1 rat instead. */ + private fun smallRat(player: Player, tile: Tile = player.tile.addY(4)): NPC { + val rat = createNPC("rat", tile) + rat.levels.set(Skill.Constitution, 2000) + return rat + } + + @Test + fun `Doomsphere damages the target and washes away some of its Magic`() { + maxRolls() + val player = summon("karamthulhu_overlord_familiar") + val target = tankyRat(player) + target.levels.set(Skill.Magic, 20) + val before = target.levels.get(Skill.Constitution) + + assertTrue(player.runNpcSpecial("karamthulhu_overlord_familiar", target)) + assertTrue(target.levels.get(Skill.Magic) < 20, "Doomsphere lowers the target's Magic") + + tick(6) + assertTrue(target.levels.get(Skill.Constitution) < before, "Doomsphere damaged the target") + } + + @Test + fun `Spike Shot lands a heavy single hit and stuns on impact`() { + maxRolls() + val player = summon("spirit_dagannoth_familiar") + val target = tankyRat(player) + val before = target.levels.get(Skill.Constitution) + + assertTrue(player.runNpcSpecial("spirit_dagannoth_familiar", target)) + + var sawStun = false + repeat(8) { + tick() + sawStun = sawStun || target.stunned + } + assertTrue(before - target.levels.get(Skill.Constitution) >= 170, "the maxed spike hits for 170") + assertTrue(sawStun, "the spike stuns the target as it lands") + } + + @Test + fun `Swamp Plague poisons the target as it lands`() { + maxRolls() + val player = summon("swamp_titan_familiar") + val target = tankyRat(player) + + assertTrue(player.runNpcSpecial("swamp_titan_familiar", target)) + tick(6) + + assertTrue(target.poisoned, "the swamp titan's plague poisons") + } + + @Test + fun `Ebon Thunder drains a player target's special attack energy on impact`() { + maxRolls() + val player = summon("lava_titan_familiar") + val target = createPlayer(player.tile.addY(3)) + target.levels.set(Skill.Constitution, 2000) + player.set("in_pvp", true) + target.set("in_pvp", true) + assertEquals(1000, target.specialAttackEnergy) + + assertTrue(player.runPlayerSpecial("lava_titan_familiar", target)) + tick(8) + + assertEquals(900, target.specialAttackEnergy, "the bolt saps a tenth of the special energy") + } + + @Test + fun `Mantis Strike binds a small target in place without stunning it`() { + maxRolls() + val player = summon("praying_mantis_familiar") + val target = smallRat(player) + + assertTrue(player.runNpcSpecial("praying_mantis_familiar", target)) + + var bound = false + repeat(8) { + tick() + bound = bound || target.frozen + assertFalse(target.stunned, "the mantis binds movement, it never stuns") + } + assertTrue(bound, "the strike froze the target once it landed") + } + + @Test + fun `Deadly Claw rakes the target three times`() { + maxRolls() + val player = summon("talon_beast_familiar") + val target = tankyRat(player) + val before = target.levels.get(Skill.Constitution) + + assertTrue(player.runNpcSpecial("talon_beast_familiar", target)) + tick(8) + + // A single maxed swipe is 100 - anything above 200 proves all three landed. + assertTrue(before - target.levels.get(Skill.Constitution) >= 300, "three raking hits landed") + } + + @Test + fun `Acorn Missile pelts targets adjacent to the one aimed at`() { + maxRolls() + val player = summon("giant_ent_familiar") + val target = tankyRat(player) + val splashed = tankyRat(player, target.tile.addY(1)) + val before = splashed.levels.get(Skill.Constitution) + + assertTrue(player.runNpcSpecial("giant_ent_familiar", target)) + tick(8) + + assertTrue(splashed.levels.get(Skill.Constitution) < before, "the acorns splash the adjacent npc") + } + + @Test + fun `Famine works on players in the wilderness, not just PvP areas`() { + val player = summon("ravenous_locust_familiar") + val target = createPlayer(player.tile.addY(3)) + target.levels.set(Skill.Constitution, 2000) + target.inventory.transaction { add("shark", 1) } + // The wilderness flags players with in_wilderness, not in_pvp - the special must accept both. + player.set("in_wilderness", true) + target.set("in_wilderness", true) + // Within each other's wilderness combat-level bracket. + player.combatLevel = 100 + target.combatLevel = 100 + + assertTrue(player.runPlayerSpecial("ravenous_locust_familiar", target), "the swarm flies in the wilderness too") + assertEquals(1, target.inventory.count("rotten_food")) + } + + @Test + fun `Famine turns a player target's food rotten`() { + maxRolls() + val player = summon("ravenous_locust_familiar") + val target = createPlayer(player.tile.addY(3)) + target.levels.set(Skill.Constitution, 2000) + target.inventory.transaction { add("shark", 1) } + player.set("in_pvp", true) + target.set("in_pvp", true) + + assertTrue(player.runPlayerSpecial("ravenous_locust_familiar", target)) + + assertEquals(0, target.inventory.count("shark"), "the shark was devoured") + assertEquals(1, target.inventory.count("rotten_food"), "leaving rotten food behind") + } + + @Test + fun `Inferno hits and burns another player's weapon and shield out of their hands`() { + maxRolls() + val player = summon("forge_regent_familiar") + val target = createPlayer(player.tile.addY(3)) + target.levels.set(Skill.Constitution, 2000) + target.equipment.set(EquipSlot.Weapon.index, "bronze_sword") + target.equipment.set(EquipSlot.Shield.index, "wooden_shield") + player.set("in_pvp", true) + target.set("in_pvp", true) + val before = target.levels.get(Skill.Constitution) + + assertTrue(player.runPlayerSpecial("forge_regent_familiar", target)) + + assertTrue(target.equipment[EquipSlot.Weapon.index].isEmpty(), "the weapon is knocked loose") + assertTrue(target.equipment[EquipSlot.Shield.index].isEmpty(), "the shield is knocked loose") + assertEquals(1, target.inventory.count("bronze_sword")) + assertEquals(1, target.inventory.count("wooden_shield")) + + tick(6) + assertTrue(target.levels.get(Skill.Constitution) < before, "the fiery bolt also damages") + } + + @Test + fun `Inferno still casts against a player with nothing to disarm`() { + maxRolls() + val player = summon("forge_regent_familiar") + val target = createPlayer(player.tile.addY(3)) + target.levels.set(Skill.Constitution, 2000) + player.set("in_pvp", true) + target.set("in_pvp", true) + + assertTrue(player.runPlayerSpecial("forge_regent_familiar", target), "the bolt flies regardless") + } + + @Test + fun `A plain Dust Cloud cast chokes the foes around the smoke devil`() { + maxRolls() + val player = summon("smoke_devil_familiar") + val familiar = player.follower!! + val target = tankyRat(player, familiar.tile.addX(1)) + val before = target.levels.get(Skill.Constitution) + + assertTrue(player.runSpecial("smoke_devil_familiar")) + tick(8) + + assertTrue(target.levels.get(Skill.Constitution) < before, "the adjacent rat chokes on the dust") + } + + @Test + fun `Dust Cloud finds a large npc standing beside the devil`() { + maxRolls() + val player = summon("smoke_devil_familiar") + val familiar = player.follower!! + // The giant rat is 2x2: anchored two tiles west, its body still touches the devil - the + // scan must match npc bounds, not just anchor tiles. + val target = tankyRat(player, familiar.tile.addX(-2)) + val before = target.levels.get(Skill.Constitution) + + assertTrue(player.runSpecial("smoke_devil_familiar")) + tick(8) + + assertTrue(target.levels.get(Skill.Constitution) < before, "the big rat chokes on the dust") + } + + @Test + fun `Dust Cloud chokes the target and the foes around it`() { + maxRolls() + val player = summon("smoke_devil_familiar") + val target = tankyRat(player) + val other = tankyRat(player, target.tile.addX(1)) + val before = target.levels.get(Skill.Constitution) + val otherBefore = other.levels.get(Skill.Constitution) + + assertTrue(player.runNpcSpecial("smoke_devil_familiar", target)) + tick(8) + + assertTrue(target.levels.get(Skill.Constitution) < before, "the picked target chokes on the dust") + assertTrue(other.levels.get(Skill.Constitution) < otherBefore, "so does its neighbour") + } + + @Test + fun `Iron Within batters the target with three magic bolts from afar`() { + maxRolls() + val player = summon("iron_titan_familiar") + val target = tankyRat(player) + val before = target.levels.get(Skill.Constitution) + + assertTrue(player.runNpcSpecial("iron_titan_familiar", target)) + tick(8) + + // A single maxed magic bolt is 220 - beyond 440 proves all three landed. + assertTrue(before - target.levels.get(Skill.Constitution) >= 600, "three bolts of up to 220 landed") + } + + @Test + fun `A plain click on the cast button fires Iron Within at the familiar's foe`() { + maxRolls() + val player = summon("iron_titan_familiar") + player.inventory.transaction { add("iron_within_scroll", 1) } + val target = tankyRat(player) + player.follower!!.target = target // the titan is mid-fight + val before = target.levels.get(Skill.Constitution) + + player.interfaceOption("summoning_orb", "cast_iron_within", "Cast") + tick(8) + + assertEquals(0, player.inventory.count("iron_within_scroll"), "one scroll spent") + assertTrue(before - target.levels.get(Skill.Constitution) >= 600, "the volley fired at its current target") + } + + @Test + fun `Steel of Legends strikes the target four times from afar`() { + maxRolls() + val player = summon("steel_titan_familiar") + val target = tankyRat(player) + val before = target.levels.get(Skill.Constitution) + + assertTrue(player.runNpcSpecial("steel_titan_familiar", target)) + tick(8) + + assertTrue(before - target.levels.get(Skill.Constitution) >= 900, "four ranged strikes of up to 244 landed") + } + + @Test + fun `Goad gores the target twice on the spot`() { + maxRolls() + val player = summon("spirit_graahk_familiar") + val target = tankyRat(player) + val before = target.levels.get(Skill.Constitution) + + assertTrue(FamiliarSpecialMoves.npcTarget.getValue("spirit_graahk_familiar").invoke(player, target)) + tick(4) + + assertTrue(before - target.levels.get(Skill.Constitution) >= 240, "both 120-max gores landed") + } + + @Test + fun `Ambush pounces the kyatt onto its target for one heavy strike`() { + maxRolls() + val player = summon("spirit_kyatt_familiar") + val target = tankyRat(player, player.tile.addY(6)) + val before = target.levels.get(Skill.Constitution) + + assertTrue(FamiliarSpecialMoves.npcTarget.getValue("spirit_kyatt_familiar").invoke(player, target)) + // Measured from the target to the kyatt's bounds - its 2x2 body lands flush against the prey. + assertTrue(target.tile.distanceTo(player.follower!!) <= 1, "the kyatt lands beside its prey") + + tick(4) + assertTrue(before - target.levels.get(Skill.Constitution) >= 200, "the maxed pounce hits close to 224") + } + + @Test + fun `Toad Bark refuses to fire while the toad is unloaded`() { + val player = summon("barker_toad_familiar") + val target = tankyRat(player) + + assertFalse(player.runNpcSpecial("barker_toad_familiar", target), "no cannonball - no shot, nothing charged") + } + + @Test + fun `A loaded toad barks its cannonball at the target and unloads`() { + maxRolls() + val player = summon("barker_toad_familiar") + val toad = player.follower!! + toad["cannonball_loaded"] = true + val target = tankyRat(player) + val before = target.levels.get(Skill.Constitution) + + assertTrue(player.runNpcSpecial("barker_toad_familiar", target)) + tick(10) + + assertFalse(toad["cannonball_loaded", false], "the shot spends the loaded cannonball") + // A maxed roll is 300, well beyond the old 80 bark; damage modifiers may shave a little off. + assertTrue(before - target.levels.get(Skill.Constitution) >= 250, "the maxed cannonball hits close to 300") + } + + @Test + fun `Loading a cannonball arms the toad`() { + val player = summon("barker_toad_familiar") + player.inventory.transaction { add("cannonball", 1) } + + player.itemOnNpc(player.follower!!, 0) + tick(2) + + assertTrue(player.follower!!["cannonball_loaded", false], "the toad is armed") + assertEquals(0, player.inventory.count("cannonball"), "the cannonball is swallowed") + } + + @Test + fun `Arctic Blast can stun a small target once the shot lands`() { + // Max the damage roll but win the 1-in-5 stun roll (nextInt(5) == 0). + setRandom(object : FakeRandom() { + override fun nextInt(until: Int) = if (until == 5) 0 else until - 1 + }) + val player = summon("arctic_bear_familiar") + val target = smallRat(player) + + assertTrue(player.runNpcSpecial("arctic_bear_familiar", target)) + + var sawStun = false + repeat(8) { + tick() + sawStun = sawStun || target.stunned + } + assertTrue(sawStun, "the blast stunned the rat on impact") + } + + @Test + fun `Arctic Blast does not always stun`() { + // Lose the 1-in-5 stun roll - the blast still hits but leaves no stun. + setRandom(object : FakeRandom() { + override fun nextInt(until: Int) = if (until == 5) 1 else until - 1 + }) + val player = summon("arctic_bear_familiar") + val target = smallRat(player) + + assertTrue(player.runNpcSpecial("arctic_bear_familiar", target)) + + repeat(8) { + tick() + assertFalse(target.stunned, "a lost roll leaves the target free to act") + } + } + + @Test + fun `Rise from the Ashes reincarnates the phoenix atop the ashes, scorching foes beside them`() { + maxRolls() + val player = summon("phoenix_familiar") + val phoenix = player.follower!! + phoenix.levels.drain(Skill.Constitution, 800) // wounded, so the rebirth flare has fury + val ashes = FloorItems.add(player.tile.addY(3), "ashes", disappearTicks = 300, owner = player) + val target = tankyRat(player, ashes.tile.addX(1)) + val before = target.levels.get(Skill.Constitution) + + assertTrue(FamiliarSpecialMoves.floorItemTarget.getValue("phoenix_familiar").invoke(player, ashes)) + tick(8) // the drop-and-glow, the rebirth, then the flare's magic hits landing + + assertEquals(ashes.tile, player.follower!!.tile, "the phoenix is reborn atop the ashes") + assertEquals(phoenix.levels.getMax(Skill.Constitution), phoenix.levels.get(Skill.Constitution), "at full life points") + assertTrue(FloorItems.at(ashes.tile).none { it.id == "ashes" }, "the ashes burn away") + assertTrue(target.levels.get(Skill.Constitution) < before, "the rebirth flare scorched the foe beside the ashes") + } + + @Test + fun `Rise from the Ashes refuses anything that is not ashes`() { + val player = summon("phoenix_familiar") + val bones = FloorItems.add(player.tile.addY(2), "bones", disappearTicks = 300, owner = player) + + assertFalse(FamiliarSpecialMoves.floorItemTarget.getValue("phoenix_familiar").invoke(player, bones)) + } + + @Test + fun `Crushing Claw drains a twentieth of the target's Defence`() { + maxRolls() + val player = summon("granite_lobster_familiar") + val target = tankyRat(player) + target.levels.set(Skill.Defence, 20) + + assertTrue(player.runNpcSpecial("granite_lobster_familiar", target)) + + assertTrue(target.levels.get(Skill.Defence) < 20, "Crushing Claw chips the target's Defence") + } + + private fun Player.runSpecial(familiar: String): Boolean = FamiliarSpecialMoves.instant.getValue(familiar).invoke(this) +} diff --git a/game/src/test/kotlin/content/skill/summoning/FamiliarDialogueExpressionsTest.kt b/game/src/test/kotlin/content/skill/summoning/FamiliarDialogueExpressionsTest.kt new file mode 100644 index 0000000000..aab9240957 --- /dev/null +++ b/game/src/test/kotlin/content/skill/summoning/FamiliarDialogueExpressionsTest.kt @@ -0,0 +1,50 @@ +package content.skill.summoning + +import WorldTest +import content.entity.player.dialogue.familiarChatheadAnimation +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test + +class FamiliarDialogueExpressionsTest : WorldTest() { + + @Test + fun `Familiar dialogue chatheads resolve the animation interface 662 plays`() { + // Values <= 50 key enum 1276, matching the known expression sets: + // birds (value 2) play anim 8, leeches (14) play 8413, dogs/wolves (7) play 6551. + assertEquals(8, familiarChatheadAnimation("macaw_familiar")) + assertEquals(8, familiarChatheadAnimation("dreadfowl_familiar")) + assertEquals(8413, familiarChatheadAnimation("abyssal_parasite_familiar")) + assertEquals(6551, familiarChatheadAnimation("spirit_wolf_familiar")) + assertEquals(8463, familiarChatheadAnimation("smoke_devil_familiar")) + assertEquals(8421, familiarChatheadAnimation("karamthulhu_overlord_familiar")) + } + + @Test + fun `Values over 50 key enum 1275 with 50 subtracted`() { + assertEquals(6824, familiarChatheadAnimation("spirit_terrorbird_familiar")) + assertEquals(6824, familiarChatheadAnimation("ibis_familiar")) + assertEquals(8469, familiarChatheadAnimation("forge_regent_familiar")) + assertEquals(8488, familiarChatheadAnimation("void_spinner_familiar")) + } + + @Test + fun `Mapped pets resolve their chathead animation like familiars`() { + assertEquals(13322, familiarChatheadAnimation("pet_sneakerpeeper")) + assertEquals(13322, familiarChatheadAnimation("pet_sneakerpeeper_baby")) + } + + @Test + fun `Npcs without a chathead mapping fall back to expression animations`() { + assertNull(familiarChatheadAnimation("hans")) + assertNull(familiarChatheadAnimation("phoenix_familiar")) + assertNull(familiarChatheadAnimation("pet_cat"), "unmapped pets keep the expression fallback") + } + + @Test + fun `Familiars without an enum entry play the enum's default head-bob like the client`() { + assertEquals(8373, familiarChatheadAnimation("arctic_bear_familiar"), "value 0 takes enum 1276's default") + assertEquals(8373, familiarChatheadAnimation("desert_wyrm_familiar"), "value 0 takes enum 1276's default") + assertEquals(8374, familiarChatheadAnimation("unicorn_stallion_familiar"), "missing key 39 takes enum 1275's default") + } +} diff --git a/game/src/test/kotlin/content/skill/summoning/FamiliarRetaliationLeashTest.kt b/game/src/test/kotlin/content/skill/summoning/FamiliarRetaliationLeashTest.kt new file mode 100644 index 0000000000..6d1a6d02e7 --- /dev/null +++ b/game/src/test/kotlin/content/skill/summoning/FamiliarRetaliationLeashTest.kt @@ -0,0 +1,45 @@ +package content.skill.summoning + +import FakeRandom +import WorldTest +import content.entity.combat.underAttack +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import world.gregs.voidps.engine.data.definition.NPCDefinitions +import world.gregs.voidps.engine.entity.character.move.tele +import world.gregs.voidps.engine.entity.character.player.skill.Skill +import world.gregs.voidps.type.setRandom + +/** + * An npc away from its spawn (beyond its aggro/leash range) must still retaliate against a familiar + * attacking it, rather than de-aggroing and walking back to its spawn without fighting. + */ +internal class FamiliarRetaliationLeashTest : WorldTest() { + + @BeforeEach + fun setup() { + setRandom(FakeRandom()) + } + + @Test + fun `Npc far from its spawn still fights back against a familiar`() { + val player = createPlayer(emptyTile) + player.levels.set(Skill.Summoning, 99) + player.summonFamiliar(NPCDefinitions.get("spirit_wolf_familiar"), restart = false) + tick(2) + val familiar = player.follower!! + // Npc created at its spawn, then moved 15 tiles away (beyond its aggro range of 9) - as if it + // had wandered - before the familiar engages it there. + val npc = createNPC("giant_rat", emptyTile) + npc.tele(emptyTile.addX(15)) + player.tele(emptyTile.addX(13)) + tick() + + player.commandFamiliarAttack(npc) + tickIf(25) { !familiar.underAttack } + + assertTrue(npc.underAttack, "familiar attacked the npc") + assertTrue(familiar.underAttack, "npc retaliated despite being away from its spawn") + } +} diff --git a/game/src/test/kotlin/content/skill/summoning/FamiliarRetaliationTest.kt b/game/src/test/kotlin/content/skill/summoning/FamiliarRetaliationTest.kt new file mode 100644 index 0000000000..39d852b13e --- /dev/null +++ b/game/src/test/kotlin/content/skill/summoning/FamiliarRetaliationTest.kt @@ -0,0 +1,42 @@ +package content.skill.summoning + +import FakeRandom +import WorldTest +import content.entity.combat.underAttack +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import world.gregs.voidps.engine.data.definition.NPCDefinitions +import world.gregs.voidps.engine.entity.character.player.skill.Skill +import world.gregs.voidps.type.Tile +import world.gregs.voidps.type.setRandom + +/** + * A familiar fighting an npc outside a PvP area has no player-facing "Attack" option (that only + * appears on its wilderness `_combat` form), but the npc must still be able to retaliate against it. + */ +internal class FamiliarRetaliationTest : WorldTest() { + + @BeforeEach + fun setup() { + // Deterministic rolls (nextBits = 0): the familiar always lands its swing and pathing's + // shuffled() stays in-bounds. + setRandom(FakeRandom()) + } + + @Test + fun `Npc fights back against an attacking familiar in a non-pvp area`() { + val player = createPlayer(Tile(2523, 3056)) + player.levels.set(Skill.Summoning, 99) + player.summonFamiliar(NPCDefinitions.get("spirit_wolf_familiar"), restart = false) + tick(2) + val familiar = player.follower!! + val npc = createNPC("giant_rat", player.tile.addX(3)) + + player.commandFamiliarAttack(npc) + tickIf(20) { !familiar.underAttack } + + assertTrue(npc.underAttack, "familiar attacked the npc") + assertTrue(familiar.underAttack, "npc retaliated against the familiar") + } +} diff --git a/game/src/test/kotlin/content/skill/summoning/FamiliarSpecialEffectTest.kt b/game/src/test/kotlin/content/skill/summoning/FamiliarSpecialEffectTest.kt new file mode 100644 index 0000000000..843d13058a --- /dev/null +++ b/game/src/test/kotlin/content/skill/summoning/FamiliarSpecialEffectTest.kt @@ -0,0 +1,411 @@ +package content.skill.summoning + +import FakeRandom +import WorldTest +import content.entity.combat.hit.hit +import content.entity.combat.target +import content.entity.effect.stunned +import content.entity.effect.toxin.poisoned +import content.skill.fletching.fletchLog +import interfaceOption +import npcOption +import org.junit.jupiter.api.Test +import world.gregs.voidps.engine.client.variable.hasClock +import world.gregs.voidps.engine.data.definition.NPCDefinitions +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.item.floor.FloorItems +import world.gregs.voidps.engine.entity.obj.GameObject +import world.gregs.voidps.engine.inv.beastOfBurden +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.engine.inv.transact.operation.AddItem.add +import world.gregs.voidps.type.Tile +import world.gregs.voidps.type.setRandom +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * Covers the effects of the four newly-ported familiar specials (Spirit spider Egg Spawn, Spirit + * scorpion Venom Shot, Spirit Tz-Kih Fireball Assault, Spirit Kalphite Sandstorm) by invoking the + * registered blocks directly - the scroll/points gate itself is covered by [FamiliarSpecialMoveTest]. + */ +class FamiliarSpecialEffectTest : WorldTest() { + + private fun summon(familiar: String, tile: Tile = Tile(2523, 3056)): Player { + val player = createPlayer(tile) + player.levels.set(Skill.Summoning, 99) + player.summonFamiliar(NPCDefinitions.get(familiar), restart = false) + tick(2) // let the summon queue assign the follower + player.set("summoning_special_points_remaining", 60) + return player + } + + private fun Player.runSpecial(familiar: String): Boolean = FamiliarSpecialMoves.instant.getValue(familiar).invoke(this) + + private fun Player.runObjectSpecial(familiar: String, obj: GameObject): Boolean = FamiliarSpecialMoves.objectTarget.getValue(familiar).invoke(this, obj) + + @Test + fun `Multichop chops the targeted tree for a log, possibly a lower tier`() { + // Force the log roll to index 0 (the lowest tier) - proving a yew tree can hand over plain logs. + setRandom(object : FakeRandom() { + override fun nextInt(until: Int) = 0 + }) + val player = summon("beaver_familiar") + val tree = createObject("yew", player.tile.addX(2)) + + val cast = player.runObjectSpecial("beaver_familiar", tree) + assertTrue(cast) + assertTrue(player.follower!!.get("chopping_logs", false), "the beaver is busy while chopping") + + tick(6) // walk to the tree and start chopping + assertEquals(-1, player.follower!!.visuals.watch.index, "the beaver faces the tree, not the owner, while chopping") + + tick(10) // finish the chop and its animation + + assertEquals(1, player.beastOfBurden.count("logs"), "the beaver stashed a (lower-tier) log in its pack") + assertFalse(player.follower!!.get("chopping_logs", false), "the busy flag clears once it's done") + } + + @Test + fun `Multichop does nothing when the target is not a tree`() { + val player = summon("beaver_familiar") + val notATree = createObject("bank_booth", player.tile.addX(1)) + + assertFalse(player.runObjectSpecial("beaver_familiar", notATree)) + assertEquals(0, player.inventory.count("logs"), "no log handed over for a non-tree") + } + + @Test + fun `Using a log on the beaver routes to fletching, not beast-of-burden storage`() { + // The fletch handler registers on the specific ":beaver_familiar" operate key so it beats + // the beast-of-burden store handler's "*:beaver_familiar" (invoke picks item:npc before *:npc), + // otherwise a log gets refused with "your familiar won't carry yours" instead of fletching. + assertTrue(world.gregs.voidps.engine.entity.Operation.itemOnNpc.containsKey("logs:beaver_familiar")) + } + + @Test + fun `Beaver fletches a log into a bow, no knife, while it is cutting`() { + val player = summon("beaver_familiar") + player.experience.set(Skill.Fletching, 14_000_000.0) + player.follower!!["chopping_logs"] = true + player.inventory.transaction { add("logs", 1) } + val unf = Rows.getOrNull("fletching_unf.shortbow_u")!! + + player.fletchLog("shortbow_u", unf, "logs", 1, animate = {}, hasTool = { follower?.get("chopping_logs", false) == true }) + tick(4) // let the fletch queue produce the bow + + assertEquals(1, player.inventory.count("shortbow_u"), "the beaver acts as a knife while cutting") + assertEquals(0, player.inventory.count("logs"), "consumed the log") + } + + @Test + fun `Beaver won't fletch when it isn't cutting and there is no knife`() { + val player = summon("beaver_familiar") + player.experience.set(Skill.Fletching, 14_000_000.0) + player.inventory.transaction { add("logs", 1) } + val unf = Rows.getOrNull("fletching_unf.shortbow_u")!! + + // Not cutting and no knife -> the tool check fails and nothing is made. + player.fletchLog("shortbow_u", unf, "logs", 1, animate = {}, hasTool = { inventory.contains("knife") || follower?.get("chopping_logs", false) == true }) + tick(4) + + assertEquals(0, player.inventory.count("shortbow_u"), "no knife and not cutting - nothing made") + assertEquals(1, player.inventory.count("logs"), "the log is untouched") + } + + @Test + fun `Summoning orb Cast option fires the special through the dispatcher`() { + val player = summon("spirit_spider_familiar") + player.inventory.transaction { add("egg_spawn_scroll", 2) } + + // The minimap orb's right-click "Cast " (one cast_ component per move, each + // with a "Cast" option) goes through the shared cast dispatcher. + player.interfaceOption("summoning_orb", "cast_egg_spawn", "Cast") + tick(2) // eggs drop a tick after the cast, then the floor-item queue flushes the tick after + + assertEquals(1, player.inventory.count("egg_spawn_scroll"), "one scroll spent") + val eggs = (-1..1).flatMap { dx -> + (-1..1).flatMap { dy -> FloorItems.at(player.tile.add(dx, dy)).filter { it.id == "red_spiders_eggs" } } + } + assertTrue(eggs.isNotEmpty()) + } + + @Test + fun `Egg Spawn drops red spider eggs on free tiles around the player`() { + val player = summon("spirit_spider_familiar") + + val cast = player.runSpecial("spirit_spider_familiar") + tick(2) // eggs drop a tick after the cast, then the floor-item queue flushes the tick after + + assertTrue(cast) + // Eggs scatter across free tiles in the 3x3 centred on the player. + val eggs = (-1..1).flatMap { dx -> + (-1..1).flatMap { dy -> FloorItems.at(player.tile.add(dx, dy)).filter { it.id == "red_spiders_eggs" } } + } + assertTrue(eggs.isNotEmpty()) + } + + @Test + fun `Venom Shot charges the next attack and blocks a second charge`() { + val player = summon("spirit_scorpion_familiar") + + assertTrue(player.runSpecial("spirit_scorpion_familiar")) + assertTrue(player.get("familiar_venom_shot_charged", false)) + + // Already charged - the second cast does nothing (no scroll should be spent). + assertFalse(player.runSpecial("spirit_scorpion_familiar")) + } + + @Test + fun `Venom Shot poisons the target of the next ranged hit then clears`() { + val player = summon("spirit_scorpion_familiar") + val target = createNPC("giant_rat", player.tile.addY(4)) + player.set("familiar_venom_shot_charged", true) + + player.hit(target, offensiveType = "range", damage = 5) + + assertTrue(target.poisoned) + assertFalse(player.get("familiar_venom_shot_charged", false)) + + // The poison must actually deal damage over time, not just set the flag (a value <= ~10 is + // cured before it ever lands a hit). + val before = target.levels.get(Skill.Constitution) + tick(60) + assertTrue(target.levels.get(Skill.Constitution) < before, "poison ticked damage onto the target") + } + + @Test + fun `Venom Shot is not consumed by a melee hit`() { + val player = summon("spirit_scorpion_familiar") + val target = createNPC("giant_rat", player.tile.addY(4)) + player.set("familiar_venom_shot_charged", true) + + player.hit(target, offensiveType = "melee", damage = 5) + + assertFalse(target.poisoned) + assertTrue(player.get("familiar_venom_shot_charged", false)) + } + + @Test + fun `Explode hits a nearby foe and consumes the familiar`() { + // Force the random damage roll off zero so the hit is observable. + setRandom(object : FakeRandom() { + override fun nextInt(until: Int) = until - 1 + }) + val player = summon("giant_chinchompa_familiar") + val target = createNPC("giant_rat", player.tile.addX(1)) + val before = target.levels.get(Skill.Constitution) + + val cast = player.runSpecial("giant_chinchompa_familiar") + tick(5) // let the blast land and the familiar dismiss + + assertTrue(cast) + assertTrue(target.levels.get(Skill.Constitution) < before, "the blast damaged the nearby npc") + assertEquals(null, player.follower, "the familiar is consumed by the explosion") + } + + @Test + fun `Explode auto-fires for free when the familiar is attacked`() { + // Force the 1/10 auto-trigger (nextInt(10) == 0) while keeping the damage roll observable. + setRandom(object : FakeRandom() { + override fun nextInt(until: Int) = if (until == 10) 0 else until - 1 + }) + val player = summon("giant_chinchompa_familiar") + // A scroll present only so we can prove the free auto-explode leaves it untouched. + player.inventory.transaction { add("explode_scroll", 2) } + val attacker = createNPC("giant_rat", player.tile.addX(1)) + val before = attacker.levels.get(Skill.Constitution) + + // Something hitting the familiar rolls the auto-explode. + attacker.hit(player.follower!!, offensiveType = "melee", damage = 1) + tick(6) // let the blast land and the familiar dismiss + + assertTrue(attacker.levels.get(Skill.Constitution) < before, "the auto-explosion damaged the attacker") + assertEquals(2, player.inventory.count("explode_scroll"), "the free auto-explosion spends no scroll") + assertEquals(null, player.follower, "the familiar is consumed by the auto-explosion") + } + + @Test + fun `Explode does nothing with no target nearby`() { + val player = summon("giant_chinchompa_familiar") + + val cast = player.runSpecial("giant_chinchompa_familiar") + tick(5) + + assertFalse(cast, "no cast, so no scroll or points are spent") + assertTrue(player.follower != null, "the familiar is not dismissed on a failed cast") + } + + @Test + fun `Insane Ferocity boosts Attack and Strength and drains Ranged, Magic and Defence`() { + val player = summon("honey_badger_familiar") + val skills = listOf(Skill.Attack, Skill.Strength, Skill.Ranged, Skill.Magic, Skill.Defence) + for (skill in skills) { + player.experience.set(skill, 14_000_000.0) // level 99, so boosts/drains have headroom + player.levels.set(skill, 50) + } + + assertTrue(player.runSpecial("honey_badger_familiar")) + + assertTrue(player.levels.get(Skill.Attack) > 50, "Attack is boosted") + assertTrue(player.levels.get(Skill.Strength) > 50, "Strength is boosted") + assertTrue(player.levels.get(Skill.Ranged) < 50, "Ranged is drained") + assertTrue(player.levels.get(Skill.Magic) < 50, "Magic is drained") + assertTrue(player.levels.get(Skill.Defence) < 50, "Defence is drained") + } + + @Test + fun `Call to Arms teleports the owner to the Void Knights' Outpost`() { + val player = summon("void_ravager_familiar") + + val cast = player.runSpecial("void_ravager_familiar") + assertTrue(cast) + tick(4) // the teleport lands (and movement applies) four ticks after the cast + + assertEquals(Tile(2659, 2658, 0), player.tile) + } + + @Test + fun `Call to Arms is shared by all four Void familiars`() { + for (familiar in listOf("void_ravager_familiar", "void_shifter_familiar", "void_spinner_familiar", "void_torcher_familiar")) { + val player = summon(familiar) + + assertTrue(player.runSpecial(familiar), "$familiar casts Call to Arms") + tick(4) + + assertEquals(Tile(2659, 2658, 0), player.tile, "$familiar teleports to the outpost") + } + } + + @Test + fun `Fireball Assault hits a nearby foe`() { + // Force the random damage roll off zero so the hit is observable. + setRandom(object : FakeRandom() { + override fun nextInt(until: Int) = until - 1 + }) + val player = summon("spirit_tz-kih_familiar") + val target = createNPC("giant_rat", player.tile.addX(1)) + val before = target.levels.get(Skill.Constitution) + + val cast = player.runSpecial("spirit_tz-kih_familiar") + tick(5) // let the magic hit land + + assertTrue(cast) + assertTrue(target.levels.get(Skill.Constitution) < before) + } + + @Test + fun `Bull Rush stuns and damages the target once the charge lands`() { + // Max the damage roll (until - 1) so the hit is observable, and win the 1-in-3 stun roll + // (nextInt(3) == 0) so the stun path fires. + setRandom(object : FakeRandom() { + override fun nextInt(until: Int) = if (until == 3) 0 else until - 1 + }) + val player = summon("rune_minotaur_familiar") + val target = createNPC("giant_rat", player.tile.addY(4)) + // Enough life points to survive a max 240 charge - a dead npc can't be stunned. + target.levels.set(Skill.Constitution, 500) + val before = target.levels.get(Skill.Constitution) + + val cast = FamiliarSpecialMoves.npcTarget.getValue("rune_minotaur_familiar").invoke(player, target) + assertTrue(cast) + assertFalse(target.stunned, "the stun waits for the projectile to reach the target") + + tick(5) // ~2s projectile flight, then the hit and stun land together + assertTrue(target.stunned, "the charge stuns the target on impact") + assertTrue(target.levels.get(Skill.Constitution) < before, "bull rush damaged the target") + } + + @Test + fun `Bull Rush does not always stun`() { + // Lose the 1-in-3 stun roll (nextInt(3) != 0) - the charge still hits but leaves no stun. + setRandom(object : FakeRandom() { + override fun nextInt(until: Int) = if (until == 3) 1 else until - 1 + }) + val player = summon("rune_minotaur_familiar") + val target = createNPC("giant_rat", player.tile.addY(4)) + target.levels.set(Skill.Constitution, 500) + val before = target.levels.get(Skill.Constitution) + + assertTrue(FamiliarSpecialMoves.npcTarget.getValue("rune_minotaur_familiar").invoke(player, target)) + + tick(5) + assertFalse(target.stunned, "the stun only lands about a third of the time") + assertTrue(target.levels.get(Skill.Constitution) < before, "bull rush still damaged the target") + } + + @Test + fun `Evil Flames lowers the target's Magic, damages it, and heals no one`() { + // Max the damage roll so the hit is observable. + setRandom(object : FakeRandom() { + override fun nextInt(until: Int) = until - 1 + }) + val player = summon("evil_turnip_familiar") + val target = createNPC("giant_rat", player.tile.addY(4)) + target.levels.set(Skill.Magic, 5) + // Hurt the owner so the removed self-heal would be visible if it fired. + player.levels.drain(Skill.Constitution, 5) + val ownerHp = player.levels.get(Skill.Constitution) + val targetHp = target.levels.get(Skill.Constitution) + + assertTrue(FamiliarSpecialMoves.npcTarget.getValue("evil_turnip_familiar").invoke(player, target)) + // Drain and (formerly) heal are synchronous with the cast - check before any regen ticks. + assertEquals(4, target.levels.get(Skill.Magic), "Evil Flames lowers the target's Magic by 1") + assertEquals(ownerHp, player.levels.get(Skill.Constitution), "Evil Flames heals no one") + + tick(5) // let the fireball land + assertTrue(target.levels.get(Skill.Constitution) < targetHp, "Evil Flames damages the target") + } + + @Test + fun `Cockatrice Drain option casts Petrifying Gaze on the familiar's target`() { + setRandom(object : FakeRandom() { + override fun nextInt(until: Int) = until - 1 + }) + val player = summon("spirit_cockatrice_familiar") + val familiar = player.follower!! + player.inventory.transaction { add("petrifying_gaze_scroll", 2) } + val target = createNPC("giant_rat", player.tile.addY(4)) + target.levels.set(Skill.Defence, 20) + familiar.target = target // the familiar is engaged with the rat + val defenceBefore = target.levels.get(Skill.Defence) + val hpBefore = target.levels.get(Skill.Constitution) + + player.npcOption(familiar, "Drain") + tick(1) // the interaction resolves and the special casts + // The drain and scroll cost are synchronous with the cast. + assertEquals(defenceBefore - 3, target.levels.get(Skill.Defence), "Petrifying Gaze drains Defence by 3") + assertEquals(1, player.inventory.count("petrifying_gaze_scroll"), "the Drain option spends a scroll") + + tick(5) // the fireball lands + assertTrue(target.levels.get(Skill.Constitution) < hpBefore, "Petrifying Gaze damages the target") + } + + @Test + fun `Herbcall drops a grimy herb during the drop graphic, then recalls the macaw`() { + // Pick the first herb in the pool (grimy guam) deterministically. + setRandom(object : FakeRandom() { + override fun nextInt(until: Int) = 0 + }) + val player = summon("macaw_familiar") + val familiar = player.follower!! + // The macaw hovers where it started while it searches; the herb drops on that tile. + val searchTile = familiar.tile + + assertTrue(player.runSpecial("macaw_familiar")) + assertTrue(player.hasClock("herbcall_delay"), "Herbcall starts its one-minute cooldown") + + tick(5) // ~3s in the drop graphic plays, but the herb hasn't landed yet + assertTrue(FloorItems.at(searchTile).none { it.id == "grimy_guam" }, "the herb waits during the search") + + tick(2) // a second later the herb lands and becomes visible + assertTrue(FloorItems.at(searchTile).any { it.id == "grimy_guam" }, "the herb appears where the macaw searched") + assertTrue(player.tile.distanceTo(familiar.tile) <= 1, "the macaw is called back down beside its owner") + + // Still on cooldown - a second cast is refused and produces nothing more. + assertFalse(player.runSpecial("macaw_familiar")) + assertEquals(1, FloorItems.at(searchTile).count { it.id == "grimy_guam" }, "no extra herb while on cooldown") + } +} diff --git a/game/src/test/kotlin/content/skill/summoning/FamiliarSpecialMoveTest.kt b/game/src/test/kotlin/content/skill/summoning/FamiliarSpecialMoveTest.kt new file mode 100644 index 0000000000..7692545966 --- /dev/null +++ b/game/src/test/kotlin/content/skill/summoning/FamiliarSpecialMoveTest.kt @@ -0,0 +1,112 @@ +package content.skill.summoning + +import WorldTest +import org.junit.jupiter.api.Test +import world.gregs.voidps.engine.data.definition.NPCDefinitions +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.entity.character.player.skill.Skill +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.engine.inv.transact.add +import world.gregs.voidps.engine.inv.transact.operation.AddItem.add +import world.gregs.voidps.engine.inv.transact.operation.RemoveItem.remove +import world.gregs.voidps.type.Tile +import kotlin.test.assertEquals +import kotlin.test.assertFalse + +/** + * Covers [castFamiliarSpecial]'s validation + resource-consumption chain: a successful cast spends + * exactly one scroll and the scroll's points, while a blocked cast (no scroll, too few points, + * soft-failed effect, or on cooldown) spends nothing. Spirit wolf is used as the fixture; its scroll + * is `howl_scroll` (cost 3). + */ +class FamiliarSpecialMoveTest : WorldTest() { + + private fun summonSpiritWolf(): Player { + val player = createPlayer(Tile(2523, 3056)) + player.levels.set(Skill.Summoning, 99) + player.summonFamiliar(NPCDefinitions.get("spirit_wolf_familiar"), restart = false) + tick(2) // let the summon queue assign the follower + player.set("summoning_special_points_remaining", 60) + player.inventory.transaction { add("howl_scroll", 5) } + return player + } + + @Test + fun `Successful cast spends one scroll and the points`() { + val player = summonSpiritWolf() + + player.castFamiliarSpecial { true } + + assertEquals(4, player.inventory.count("howl_scroll")) + assertEquals(57, player.get("summoning_special_points_remaining", 0)) + } + + @Test + fun `Soft-failed effect spends nothing`() { + val player = summonSpiritWolf() + + player.castFamiliarSpecial { false } + + assertEquals(5, player.inventory.count("howl_scroll")) + assertEquals(60, player.get("summoning_special_points_remaining", 0)) + } + + @Test + fun `Cast blocked without enough points`() { + val player = summonSpiritWolf() + player.set("summoning_special_points_remaining", 2) + + player.castFamiliarSpecial { true } + + assertEquals(5, player.inventory.count("howl_scroll")) + assertEquals(2, player.get("summoning_special_points_remaining", 0)) + } + + @Test + fun `Cast blocked without a scroll`() { + val player = summonSpiritWolf() + player.inventory.transaction { remove("howl_scroll", 5) } + + var ran = false + player.castFamiliarSpecial { + ran = true + true + } + + assertFalse(ran) + assertEquals(60, player.get("summoning_special_points_remaining", 0)) + } + + @Test + fun `Second cast is blocked by the cooldown`() { + val player = summonSpiritWolf() + + player.castFamiliarSpecial { true } + player.castFamiliarSpecial { true } + + // Only the first cast was allowed through the 3-tick cooldown. + assertEquals(4, player.inventory.count("howl_scroll")) + assertEquals(57, player.get("summoning_special_points_remaining", 0)) + } + + @Test + fun `Re-entrant cast in the same tick runs the effect only once`() { + val player = summonSpiritWolf() + + // Simulates the interaction layer dispatching the special twice in one tick (the double + // projectile bug): the cooldown is set before the effect, so the second effect never runs. + var effects = 0 + player.castFamiliarSpecial { + effects++ + true + } + player.castFamiliarSpecial { + effects++ + true + } + + assertEquals(1, effects) + assertEquals(4, player.inventory.count("howl_scroll")) + assertEquals(57, player.get("summoning_special_points_remaining", 0)) + } +} diff --git a/game/src/test/kotlin/content/skill/summoning/FamiliarUtilitySpecialEffectTest.kt b/game/src/test/kotlin/content/skill/summoning/FamiliarUtilitySpecialEffectTest.kt new file mode 100644 index 0000000000..4b9e11bbb3 --- /dev/null +++ b/game/src/test/kotlin/content/skill/summoning/FamiliarUtilitySpecialEffectTest.kt @@ -0,0 +1,655 @@ +package content.skill.summoning + +import FakeRandom +import WorldTest +import containsMessage +import content.entity.combat.hit.Hit +import content.entity.combat.target +import content.entity.effect.toxin.poison +import content.entity.effect.toxin.poisoned +import content.entity.effect.transform +import content.entity.player.bank.bank +import content.entity.player.effect.energy.MAX_RUN_ENERGY +import content.entity.player.effect.energy.runEnergy +import dialogueOption +import interfaceOption +import itemOnNpc +import kotlinx.coroutines.test.runTest +import npcOption +import org.junit.jupiter.api.Test +import world.gregs.voidps.engine.client.ui.InterfaceApi +import world.gregs.voidps.engine.client.ui.dialogue +import world.gregs.voidps.engine.client.variable.hasClock +import world.gregs.voidps.engine.data.definition.NPCDefinitions +import world.gregs.voidps.engine.entity.character.move.tele +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.entity.character.player.skill.Skill +import world.gregs.voidps.engine.entity.distanceTo +import world.gregs.voidps.engine.entity.item.Item +import world.gregs.voidps.engine.entity.item.floor.FloorItems +import world.gregs.voidps.engine.entity.obj.GameObjects +import world.gregs.voidps.engine.entity.obj.ObjectLayer +import world.gregs.voidps.engine.inv.beastOfBurden +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.engine.inv.transact.operation.AddItem.add +import world.gregs.voidps.engine.inv.transact.operation.RemoveItem.remove +import world.gregs.voidps.type.Tile +import world.gregs.voidps.type.setRandom +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * Covers the boost, heal, item-target and object-target familiar specials, plus the passive + * familiar features added alongside them. Specials are invoked through the registered blocks or the + * item-on-npc interaction; the scroll/points gate itself is covered by [FamiliarSpecialMoveTest]. + */ +class FamiliarUtilitySpecialEffectTest : WorldTest() { + + private fun summon(familiar: String, tile: Tile = Tile(2523, 3056)): Player { + val player = createPlayer(tile) + player.levels.set(Skill.Summoning, 99) + player.summonFamiliar(NPCDefinitions.get(familiar), restart = false) + tick(2) // let the summon queue assign the follower + player.set("summoning_special_points_remaining", 60) + return player + } + + private fun Player.runSpecial(familiar: String): Boolean = FamiliarSpecialMoves.instant.getValue(familiar).invoke(this) + + @Test + fun `Testudo boosts Defence by 8`() { + val player = summon("war_tortoise_familiar") + val before = player.levels.get(Skill.Defence) + + assertTrue(player.runSpecial("war_tortoise_familiar")) + assertEquals(before + 8, player.levels.get(Skill.Defence)) + } + + @Test + fun `Volcanic Strength boosts Strength by 9`() { + val player = summon("obsidian_golem_familiar") + val before = player.levels.get(Skill.Strength) + + assertTrue(player.runSpecial("obsidian_golem_familiar")) + assertEquals(before + 9, player.levels.get(Skill.Strength)) + } + + @Test + fun `Magic Focus boosts Magic by 7`() { + val player = summon("wolpertinger_familiar") + val before = player.levels.get(Skill.Magic) + + assertTrue(player.runSpecial("wolpertinger_familiar")) + assertEquals(before + 7, player.levels.get(Skill.Magic)) + } + + @Test + fun `Abyssal Stealth boosts Agility and Thieving by 4`() { + val player = summon("abyssal_lurker_familiar") + val agility = player.levels.get(Skill.Agility) + val thieving = player.levels.get(Skill.Thieving) + + assertTrue(player.runSpecial("abyssal_lurker_familiar")) + assertEquals(agility + 4, player.levels.get(Skill.Agility)) + assertEquals(thieving + 4, player.levels.get(Skill.Thieving)) + } + + @Test + fun `Tireless Run boosts Agility and restores run energy`() { + val player = summon("spirit_terrorbird_familiar") + player.runEnergy = 0 + val agility = player.levels.get(Skill.Agility) + + assertTrue(player.runSpecial("spirit_terrorbird_familiar")) + assertEquals(agility + 2, player.levels.get(Skill.Agility)) + assertEquals((agility + 2) * 50, player.runEnergy, "restores half the boosted Agility level") + } + + @Test + fun `Tireless Run refuses when run energy is already full, charging nothing`() { + val player = summon("spirit_terrorbird_familiar") + player.runEnergy = MAX_RUN_ENERGY + + assertFalse(player.runSpecial("spirit_terrorbird_familiar")) + } + + @Test + fun `Titan's Constitution raises Defence and boosts 80 life points`() { + val player = summon("fire_titan_familiar") + player.experience.set(Skill.Defence, 14_000_000.0) // level 99 so the multiplier has headroom + player.experience.set(Skill.Constitution, 14_000_000.0) + player.levels.drain(Skill.Constitution, 500) + val defence = player.levels.get(Skill.Defence) + val lifePoints = player.levels.get(Skill.Constitution) + + assertTrue(player.runSpecial("fire_titan_familiar")) + assertTrue(player.levels.get(Skill.Defence) > defence, "Defence rises by an eighth") + assertEquals(lifePoints + 80, player.levels.get(Skill.Constitution)) + } + + @Test + fun `Titan's Constitution over-heals past maximum life points`() { + val player = summon("moss_titan_familiar") + player.experience.set(Skill.Constitution, 14_000_000.0) // 990 life points, at full health + + assertTrue(player.runSpecial("moss_titan_familiar")) + assertEquals(1070, player.levels.get(Skill.Constitution), "80 life points above the 990 maximum") + } + + @Test + fun `Healing Aura restores 15 percent of maximum life points`() { + val player = summon("unicorn_stallion_familiar") + player.experience.set(Skill.Constitution, 14_000_000.0) // 990 life points + player.levels.drain(Skill.Constitution, 500) + val before = player.levels.get(Skill.Constitution) + + assertTrue(player.runSpecial("unicorn_stallion_familiar")) + assertEquals(before + 149, player.levels.get(Skill.Constitution), "15% of 990, rounded up") + } + + @Test + fun `Healing Aura refuses at full life points, charging nothing`() { + val player = summon("unicorn_stallion_familiar") + + assertFalse(player.runSpecial("unicorn_stallion_familiar")) + } + + @Test + fun `Fish Rain calls down a fish beside the ibis`() { + val player = summon("ibis_familiar") + val ibisTile = player.follower!!.tile + + assertTrue(player.runSpecial("ibis_familiar")) + tick(4) + + assertTrue(FloorItems.at(ibisTile).any { it.id.startsWith("raw_") }, "a raw fish lands at the ibis' feet") + } + + @Test + fun `Fruitfall scatters a papaya and more fruit onto the tiles around the owner`() { + // Max the count roll (nextInt(7) = 6) so the drop is observable. + setRandom(object : FakeRandom() { + override fun nextInt(until: Int) = until - 1 + }) + val player = summon("fruit_bat_familiar") + + assertTrue(player.runSpecial("fruit_bat_familiar")) + tick(7) // the bat flies up into the trees before swooping down to drop the fruit + + val landed = (-1..1).flatMap { dx -> (-1..1).flatMap { dy -> FloorItems.at(player.tile.add(dx, dy)) } } + assertTrue(landed.count { it.id == "papaya_fruit" } == 1, "the first fruit down is always a papaya") + assertTrue(landed.any { it.id == "pineapple" }, "with more fruit alongside") + assertTrue(FloorItems.at(player.tile).none { it.id == "papaya_fruit" || it.id == "pineapple" }, "each fruit lands on its own free tile, not underfoot") + } + + @Test + fun `Essence Shipment banks essence from the inventory and the familiar's pack`() { + val player = summon("abyssal_titan_familiar") + player.ensureBeastOfBurdenInventory() + player.inventory.transaction { add("pure_essence", 5) } + player.beastOfBurden.transaction { add("rune_essence", 3) } + + assertTrue(player.runSpecial("abyssal_titan_familiar")) + + assertEquals(5, player.bank.count("pure_essence"), "the carried essence is banked") + assertEquals(3, player.bank.count("rune_essence"), "so is the familiar's load") + assertEquals(0, player.inventory.count("pure_essence")) + assertEquals(0, player.beastOfBurden.count("rune_essence")) + } + + @Test + fun `Blood Drain cures poison, restores drained stats and bites for 25`() { + val player = summon("bloated_leech_familiar") + player.experience.set(Skill.Constitution, 14_000_000.0) + player.poison(player, 20) + player.levels.drain(Skill.Attack, 3) + val lifePoints = player.levels.get(Skill.Constitution) + + assertTrue(player.runSpecial("bloated_leech_familiar")) + tick(1) + + assertFalse(player.poisoned, "the leech sucks out the poison") + assertEquals(player.levels.getMax(Skill.Attack), player.levels.get(Skill.Attack), "drained Attack is restored") + assertEquals(lifePoints - 25, player.levels.get(Skill.Constitution), "the leech takes its 25-point bite") + } + + @Test + fun `Thieving Fingers boosts Thieving by 2`() { + val player = summon("magpie_familiar") + val before = player.levels.get(Skill.Thieving) + + assertTrue(player.runSpecial("magpie_familiar")) + assertEquals(before + 2, player.levels.get(Skill.Thieving)) + } + + @Test + fun `Thieving Fingers owner sparkle plays alongside the scroll-cast graphic`() { + val player = summon("magpie_familiar") + player.inventory.transaction { add("thieving_fingers_scroll", 1) } + + // Through the real cast gate, which plays the shared scroll-cast graphic (1316) on top of + // the special's own owner sparkle (1300) - both must land in a graphic slot, not overwrite. + player.interfaceOption("summoning_orb", "cast_thieving_fingers", "Cast") + + val playing = listOf(player.visuals.primaryGraphic.id, player.visuals.secondaryGraphic.id) + assertTrue(1300 in playing, "the owner sparkle plays") + assertTrue(1316 in playing, "alongside the scroll-cast graphic") + } + + @Test + fun `Swallow Whole heals the cooked fish's worth with no eating delay`() { + val player = summon("bunyip_familiar") + player.experience.set(Skill.Constitution, 14_000_000.0) + player.levels.set(Skill.Cooking, 80) + player.levels.drain(Skill.Constitution, 500) + player.inventory.transaction { + add("swallow_whole_scroll", 1) + add("raw_shark", 1) + } + val before = player.levels.get(Skill.Constitution) + + runTest { InterfaceApi.onItem(player, "familiar_details:cast_swallow_whole", player.inventory[1]) } + tick(2) + + assertEquals(0, player.inventory.count("raw_shark"), "the bunyip gulped the shark") + assertEquals(0, player.inventory.count("swallow_whole_scroll"), "one scroll spent") + assertEquals(before + 200, player.levels.get(Skill.Constitution), "healed the cooked shark's worth") + assertFalse(player.hasClock("food_delay"), "swallowing bypasses the eating delay") + } + + @Test + fun `Swallow Whole refuses a fish above the owner's Cooking level`() { + val player = summon("bunyip_familiar") + player.levels.set(Skill.Cooking, 1) + player.inventory.transaction { + add("swallow_whole_scroll", 1) + add("raw_shark", 1) + } + + runTest { InterfaceApi.onItem(player, "familiar_details:cast_swallow_whole", player.inventory[1]) } + tick(2) + + assertEquals(1, player.inventory.count("raw_shark"), "the shark is refused") + assertEquals(1, player.inventory.count("swallow_whole_scroll"), "and nothing is charged") + } + + @Test + fun `Using a raw fish on the bunyip transmutes it into water runes`() { + val player = summon("bunyip_familiar") + player.inventory.transaction { add("raw_shark", 1) } + + player.itemOnNpc(player.follower!!, 0) + tick(2) + + assertEquals(0, player.inventory.count("raw_shark"), "the shark is transmuted") + assertTrue(player.inventory.count("water_rune") in 1..20, "into one to twenty water runes") + } + + @Test + fun `The geyser titan boils a bowl of water and recharges amulets of glory`() { + val player = summon("geyser_titan_familiar") + player.inventory.transaction { + add("bowl", 1) + add("amulet_of_glory", 1) + } + + player.itemOnNpc(player.follower!!, 0) + tick(2) + assertEquals(1, player.inventory.count("bowl_of_hot_water"), "the bowl fills with boiling water") + + player.itemOnNpc(player.follower!!, 1) + tick(2) + assertEquals(1, player.inventory.count("amulet_of_glory_4"), "the amulet is fully recharged") + } + + @Test + fun `The granite lobster forages fish into its pack while its owner fishes`() { + // Win every roll so the 1-in-12 forage fires on the first 30s window. + setRandom(object : FakeRandom() { + override fun nextInt(until: Int) = 0 + }) + val player = summon("granite_lobster_familiar") + player.softTimers.start("fishing") + + tick(51) // the 30 second forage timer + + assertTrue(player.beastOfBurden.count("raw_swordfish") + player.beastOfBurden.count("raw_shark") > 0, "a fish was foraged into the pack") + assertTrue(player.experience.get(Skill.Fishing) > 0.0, "with a tenth of the catch xp") + } + + @Test + fun `The granite lobster forages nothing while its owner idles`() { + setRandom(object : FakeRandom() { + override fun nextInt(until: Int) = 0 + }) + val player = summon("granite_lobster_familiar") + + tick(51) + + assertEquals(0, player.beastOfBurden.count("raw_swordfish") + player.beastOfBurden.count("raw_shark"), "the lobster only spears fish alongside a fishing owner") + } + + @Test + fun `The steel titan makes its owner harder to hit with melee but not magic`() { + val player = summon("steel_titan_familiar") + val attacker = createNPC("giant_rat", player.tile.addY(1)) + val meleeWith = Hit.chance(attacker, player, "crush", Item.EMPTY) + val magicWith = Hit.chance(attacker, player, "magic", Item.EMPTY) + + player.dismissFamiliar() + tick(2) // let the dismissal clear the follower + val meleeWithout = Hit.chance(attacker, player, "crush", Item.EMPTY) + val magicWithout = Hit.chance(attacker, player, "magic", Item.EMPTY) + + assertTrue(meleeWith < meleeWithout, "melee attackers find the owner harder to hit") + assertEquals(magicWithout, magicWith, "magic accuracy is untouched") + } + + @Test + fun `The wolpertinger makes its owner harder to hit with magic but not melee`() { + val player = summon("wolpertinger_familiar") + val attacker = createNPC("giant_rat", player.tile.addY(1)) + val magicWith = Hit.chance(attacker, player, "magic", Item.EMPTY) + val meleeWith = Hit.chance(attacker, player, "crush", Item.EMPTY) + + player.dismissFamiliar() + tick(2) // let the dismissal clear the follower + val magicWithout = Hit.chance(attacker, player, "magic", Item.EMPTY) + val meleeWithout = Hit.chance(attacker, player, "crush", Item.EMPTY) + + assertTrue(magicWith < magicWithout, "magic attackers find the owner harder to hit") + assertEquals(meleeWithout, meleeWith, "melee accuracy is untouched") + } + + @Test + fun `Drown orders the overlord at its owner's target`() { + val player = summon("karamthulhu_overlord_familiar") + val rat = createNPC("rat", player.tile.addY(2)) + player.target = rat + val points = player.levels.get(Skill.Summoning) + + player.npcOption(player.follower!!, "Drown") + tick(2) + + assertEquals(rat, player.follower!!.target, "the overlord turns its water spell on the owner's foe") + assertEquals(points - 2, player.levels.get(Skill.Summoning), "the instant blast costs two summoning points") + } + + @Test + fun `Drown needs two summoning points`() { + val player = summon("karamthulhu_overlord_familiar") + val rat = createNPC("rat", player.tile.addY(2)) + player.target = rat + player.levels.set(Skill.Summoning, 1) + + player.npcOption(player.follower!!, "Drown") + tick(2) + + assertEquals(1, player.levels.get(Skill.Summoning), "no points are spent on a refused cast") + assertTrue(player.containsMessage("enough summoning points"), "the owner is told why nothing happened") + } + + @Test + fun `Flames orders the smoke devil at its owner's target`() { + val player = summon("smoke_devil_familiar") + val rat = createNPC("rat", player.tile.addY(2)) + player.target = rat + val points = player.levels.get(Skill.Summoning) + + player.npcOption(player.follower!!, "Flames") + tick(2) + + assertEquals(rat, player.follower!!.target, "the devil turns its fire on the owner's foe") + assertEquals(points - 2, player.levels.get(Skill.Summoning), "the instant fire spell costs two summoning points") + } + + @Test + fun `Flames needs two summoning points`() { + val player = summon("smoke_devil_familiar") + val rat = createNPC("rat", player.tile.addY(2)) + player.target = rat + player.levels.set(Skill.Summoning, 1) + + player.npcOption(player.follower!!, "Flames") + tick(2) + + assertEquals(1, player.levels.get(Skill.Summoning), "no points are spent on a refused cast") + assertTrue(player.containsMessage("enough summoning points"), "the owner is told why nothing happened") + } + + @Test + fun `A wilderness combat-form familiar still answers its Interact option`() { + val player = summon("spirit_graahk_familiar") + val familiar = player.follower!! + familiar.transform("spirit_graahk_familiar_combat") + tick(1) + + player.npcOption(familiar, "Interact") + tick(1) + + assertTrue(player.dialogue != null, "the graahk's chat/teleport choice still opens") + } + + @Test + fun `The hunting cats teleport their owner home`() { + val player = summon("spirit_graahk_familiar") + + player.npcOption(player.follower!!, "Interact") + tick(1) + player.dialogueOption("line2") // Teleport + tick(10) // the teleport take-off and landing + + assertEquals(Tile(2786, 3002), player.tile, "the graahk carries its owner to Karamja") + } + + @Test + fun `Ophidian Incubation turns each god-bird egg into its cockatrice counterpart`() { + val eggs = mapOf( + "egg" to "cockatrice_egg", + "birds_egg_green" to "guthatrice_egg", + "birds_egg_blue" to "saratrice_egg", + "birds_egg_red" to "zamatrice_egg", + "penguin_egg" to "pengatrice_egg", + "raven_egg" to "coraxatrice_egg", + "vulture_egg" to "vulatrice_egg", + ) + val player = summon("spirit_cobra_familiar") + player.inventory.transaction { add("ophidian_incubation_scroll", eggs.size) } + + for ((egg, product) in eggs) { + player.set("summoning_special_points_remaining", 60) + player.inventory.transaction { add(egg, 1) } + + player.itemOnNpc(player.follower!!, 1) + tick(3) + + assertEquals(1, player.inventory.count(product), "$egg incubates into $product") + player.inventory.transaction { remove(product, 1) } + } + } + + @Test + fun `Winter Storage banks the item the Cast option is used on`() { + val player = summon("pack_yak_familiar") + player.inventory.transaction { + add("winter_storage_scroll", 1) + add("bronze_sword", 1) + } + + runTest { InterfaceApi.onItem(player, "familiar_details:cast_winter_storage", player.inventory[1]) } + tick(1) + + assertEquals(1, player.bank.count("bronze_sword"), "the yak banked the sword") + assertEquals(0, player.inventory.count("bronze_sword")) + assertEquals(0, player.inventory.count("winter_storage_scroll"), "one scroll spent") + } + + @Test + fun `Winter Storage refuses to bank its own scroll`() { + val player = summon("pack_yak_familiar") + player.inventory.transaction { add("winter_storage_scroll", 1) } + + runTest { InterfaceApi.onItem(player, "familiar_details:cast_winter_storage", player.inventory[0]) } + tick(1) + + assertEquals(1, player.inventory.count("winter_storage_scroll"), "nothing banked, nothing spent") + assertEquals(0, player.bank.count("winter_storage_scroll")) + } + + @Test + fun `Immense Heat can be cast on a gold bar in the inventory`() { + val player = summon("pyrelord_familiar") + player.inventory.transaction { + add("immense_heat_scroll", 1) + add("gold_bar", 1) + } + + runTest { InterfaceApi.onItem(player, "familiar_details:cast_immense_heat", player.inventory[1]) } + tick(1) + + assertTrue(player.interfaces.contains("make_mould_slayer"), "the mould interface opens") + assertEquals(0, player.inventory.count("immense_heat_scroll"), "one scroll spent") + } + + @Test + fun `The cast button explains how to trigger an item-target special, charging nothing`() { + val player = summon("pyrelord_familiar") + player.inventory.transaction { add("immense_heat_scroll", 1) } + + assertFalse(player.runSpecial("pyrelord_familiar"), "the hint is not a cast") + assertEquals(1, player.inventory.count("immense_heat_scroll"), "no scroll is spent on the hint") + } + + @Test + fun `Immense Heat opens the jewellery mould interface from a gold bar`() { + val player = summon("pyrelord_familiar") + player.inventory.transaction { + add("immense_heat_scroll", 1) + add("gold_bar", 1) + } + + player.itemOnNpc(player.follower!!, 1) + tick(2) + + assertTrue(player.interfaces.contains("make_mould_slayer"), "the mould interface opens without a furnace") + assertEquals(0, player.inventory.count("immense_heat_scroll"), "one scroll spent") + } + + @Test + fun `Regrowth revives a felled tree from its stump`() { + val player = summon("hydra_familiar") + val tree = createObject("yew", player.tile.addX(2)) + GameObjects.replace(tree, "yew_stump", ticks = 100) + val stump = GameObjects.find(tree.tile) { it.id == "yew_stump" }!! + + assertTrue(FamiliarSpecialMoves.objectTarget.getValue("hydra_familiar").invoke(player, stump)) + + assertTrue(GameObjects.find(tree.tile) { it.id == "yew" } != null, "the yew is back") + } + + @Test + fun `Regrowth refuses anything that is not a stump`() { + val player = summon("hydra_familiar") + val booth = createObject("bank_booth", player.tile.addX(1)) + + assertFalse(FamiliarSpecialMoves.objectTarget.getValue("hydra_familiar").invoke(player, booth)) + } + + @Test + fun `The bunyip passively heals its owner 20 life points every 15 seconds`() { + val player = summon("bunyip_familiar") + player.experience.set(Skill.Constitution, 14_000_000.0) + player.levels.drain(Skill.Constitution, 500) + val before = player.levels.get(Skill.Constitution) + + tick(25) // 15 seconds + + assertTrue(player.levels.get(Skill.Constitution) >= before + 20, "the bunyip's heal fired") + } + + @Test + fun `The unicorn stallion's Cure option cures poison for 2 special points`() { + val player = summon("unicorn_stallion_familiar") + player.poison(player, 20) + assertTrue(player.poisoned) + + player.npcOption(player.follower!!, "Cure") + tick(2) + + assertFalse(player.poisoned, "the unicorn cured the poison") + assertEquals(58, player.get("summoning_special_points_remaining", 0), "the cure costs 2 points") + } + + @Test + fun `The giant ent transmutes pure essence into a rune that joins its stack`() { + val player = summon("giant_ent_familiar") + player.inventory.transaction { add("pure_essence", 1) } + // Force the earth-or-nature roll to nature so the rune must merge into this stack. + setRandom(object : FakeRandom() { + override fun nextInt(until: Int) = until - 1 + }) + player.inventory.transaction { add("nature_rune", 5) } + + player.itemOnNpc(player.follower!!, 0) + tick(2) + + assertEquals(0, player.inventory.count("pure_essence")) + assertEquals(6, player.inventory.count("nature_rune"), "the new rune stacks with the rest") + assertEquals(1, player.inventory.items.count { it.id == "nature_rune" }, "one stack, not scattered slots") + } + + @Test + fun `The forge regent burns logs like the pyrelord, with a helper bonus`() { + // An open plain, so the westward step off the fire is never blocked by map collision. + val player = summon("forge_regent_familiar", Tile(3200, 3200)) + player.inventory.transaction { add("logs", 1) } + val familiarTile = player.follower!!.tile + + player.itemOnNpc(player.follower!!, 0) + tick(2) + + // The logs land at the familiar's feet before it breathes fire over them. + assertEquals(0, player.inventory.count("logs"), "the log left the inventory") + assertTrue(FloorItems.at(familiarTile).any { it.id == "logs" }, "the logs lie at the familiar's feet") + assertTrue(GameObjects.getLayer(familiarTile, ObjectLayer.GROUND) == null, "not yet alight") + + tick(3) + assertTrue(FloorItems.at(familiarTile).none { it.id == "logs" }, "the logs are consumed by the flames") + assertEquals(50.0, player.experience.get(Skill.Firemaking), "the log's 40 xp plus the 10 helper bonus") + assertTrue(GameObjects.find(familiarTile) { it.id.startsWith("fire_") } != null, "the fire burns beneath the familiar") + + tick(3) + assertEquals(familiarTile.addX(-1), player.follower!!.tile, "the familiar steps west off its fire") + + // The owner stepping up next to the familiar shouldn't send it shuffling behind them - + // adjacent already, it just faces them. + val step = player.tile.addX(-1) + player.walkTo(step) + tick(5) + assertEquals(step, player.tile, "the owner stepped up beside the familiar") + assertEquals(familiarTile.addX(-1), player.follower!!.tile, "the familiar faces its adjacent owner instead of tucking in behind") + } + + @Test + fun `An adjacent familiar faces its idle owner instead of shuffling behind them`() { + val player = summon("bunyip_familiar") + val before = player.follower!!.tile + + tick(10) + + assertEquals(before, player.follower!!.tile, "the familiar stays put while its owner stands still") + } + + @Test + fun `A familiar steps out to a free tile when its owner stands on it`() { + val player = summon("bunyip_familiar", Tile(3200, 3200)) + val familiar = player.follower!! + + player.tele(familiar.tile) + tick(3) + + assertTrue(player.tile != player.follower!!.tile, "the familiar stepped out from under its owner") + assertTrue(player.tile.distanceTo(player.follower!!) <= 1, "onto an adjacent tile") + } +} diff --git a/game/src/test/kotlin/content/skill/summoning/SummoningLevelGateTest.kt b/game/src/test/kotlin/content/skill/summoning/SummoningLevelGateTest.kt new file mode 100644 index 0000000000..fea3f8f03f --- /dev/null +++ b/game/src/test/kotlin/content/skill/summoning/SummoningLevelGateTest.kt @@ -0,0 +1,44 @@ +package content.skill.summoning + +import WorldTest +import containsMessage +import itemOption +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNotNull +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.entity.character.player.skill.Skill +import world.gregs.voidps.engine.inv.add +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.type.Tile + +class SummoningLevelGateTest : WorldTest() { + + @Test + fun `Drained summoning points don't block summoning a familiar`() { + val player = createPlayer(Tile(2523, 3056)) + player.experience.set(Skill.Summoning, 14_000_000.0) // real level 99 + player.levels.set(Skill.Summoning, 30) // points pool drained below the level-95 requirement + player.inventory.add("iron_titan_pouch") + + player.itemOption("Summon", "iron_titan_pouch") + tick(3) + + assertFalse(player.containsMessage("not high enough"), "the requirement checks the real level, not remaining points") + assertNotNull(player.follower, "the familiar is summoned") + } + + @Test + fun `The real summoning level still gates the pouch`() { + val player = createPlayer(Tile(2523, 3056)) + player.levels.set(Skill.Summoning, 99) // a full points pool can't stand in for the level + player.inventory.add("iron_titan_pouch") + + player.itemOption("Summon", "iron_titan_pouch") + tick(3) + + assertTrue(player.containsMessage("not high enough"), "the owner is told why nothing happened") + assertNull(player.follower, "no familiar is summoned") + } +} diff --git a/game/src/test/kotlin/content/skill/summoning/SummoningObeliskTest.kt b/game/src/test/kotlin/content/skill/summoning/SummoningObeliskTest.kt index c383e8d5ae..15f179fc1f 100644 --- a/game/src/test/kotlin/content/skill/summoning/SummoningObeliskTest.kt +++ b/game/src/test/kotlin/content/skill/summoning/SummoningObeliskTest.kt @@ -20,11 +20,53 @@ class SummoningObeliskTest : WorldTest() { val obelisk = GameObjects.find(Tile(2521, 3055), "obelisk") player.objectOption(obelisk, "Renew-points") - tick() + tickIf(limit = 10) { player.levels.get(Skill.Summoning) != 99 } + + assertEquals(99, player.levels.get(Skill.Summoning)) + } + + @Test + fun `Renew animation only plays after walking to obelisk`() { + val player = createPlayer(Tile(2527, 3059)) + player.experience.set(Skill.Summoning, Level.experience(99)) + player.levels.set(Skill.Summoning, 99) + player.levels.drain(Skill.Summoning, 10) + + val obelisk = GameObjects.find(Tile(2521, 3055), "obelisk") + player.objectOption(obelisk, "Renew-points") + + // Walk until the points are renewed, asserting the animation never plays mid-walk. + tickIf(limit = 30) { + if (player.steps.isNotEmpty()) { + assertEquals(-1, player.visuals.animation.stand) + } + player.levels.get(Skill.Summoning) != 99 + } + assertEquals(0, player.steps.size) assertEquals(99, player.levels.get(Skill.Summoning)) } + @Test + fun `Obelisk charges before the player animates`() { + val player = createPlayer(Tile(2523, 3056)) + player.experience.set(Skill.Summoning, Level.experience(99)) + player.levels.set(Skill.Summoning, 99) + player.levels.drain(Skill.Summoning, 10) + + val obelisk = GameObjects.find(Tile(2521, 3055), "obelisk") + player.objectOption(obelisk, "Renew-points") + + // While the obelisk graphic charges, the player has not yet animated or renewed points. + tick() + assertEquals(-1, player.visuals.animation.stand) + assertEquals(89, player.levels.get(Skill.Summoning)) + + // Once the charge finishes the player animates and points are renewed together. + tickIf(limit = 10) { player.levels.get(Skill.Summoning) != 99 } + assertEquals(8502, player.visuals.animation.stand) + } + @Test fun `Can't renew points when already full`() { val player = createPlayer(Tile(2523, 3056)) diff --git a/game/src/test/kotlin/content/skill/summoning/VoidSpinnerHealTest.kt b/game/src/test/kotlin/content/skill/summoning/VoidSpinnerHealTest.kt new file mode 100644 index 0000000000..6466f46a4a --- /dev/null +++ b/game/src/test/kotlin/content/skill/summoning/VoidSpinnerHealTest.kt @@ -0,0 +1,61 @@ +package content.skill.summoning + +import WorldTest +import org.junit.jupiter.api.Test +import world.gregs.voidps.engine.data.definition.NPCDefinitions +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.entity.character.player.skill.Skill +import world.gregs.voidps.type.Tile +import kotlin.test.assertTrue + +/** + * The Void spinner passively restores 100 of its owner's life points every 15 seconds while + * summoned (driven by the `heal_lifepoints` npc-def property and the shared `familiar_heal` timer). + */ +class VoidSpinnerHealTest : WorldTest() { + + private fun summon(familiar: String): Player { + val player = createPlayer(Tile(2523, 3056)) + player.levels.set(Skill.Summoning, 99) + player.experience.set(Skill.Constitution, 14_000_000.0) // level 99, so there is headroom to heal + player.summonFamiliar(NPCDefinitions.get(familiar), restart = false) + tick(2) // let the summon queue assign the follower + return player + } + + @Test + fun `Void spinner heals its owner 100 life points every 15 seconds`() { + val player = summon("void_spinner_familiar") + player.levels.drain(Skill.Constitution, 500) + val before = player.levels.get(Skill.Constitution) + + tick(25) // 15 seconds + + // Only the spinner restores a 100-life-point jump in a single 15s window - natural regen heals + // just a few points over that time - so a >= +100 gain proves the spinner's heal fired. + assertTrue(player.levels.get(Skill.Constitution) >= before + 100, "the spinner healed ~100 life points") + } + + @Test + fun `A non-healing familiar leaves its owner to the slow natural regen`() { + val player = summon("spirit_wolf_familiar") + player.levels.drain(Skill.Constitution, 500) + val before = player.levels.get(Skill.Constitution) + + tick(25) + + assertTrue(player.levels.get(Skill.Constitution) < before + 100, "no familiar heal for a spirit wolf") + } + + @Test + fun `The heal stops when the spinner is dismissed`() { + val player = summon("void_spinner_familiar") + player.dismissFamiliar() + player.levels.drain(Skill.Constitution, 500) + val before = player.levels.get(Skill.Constitution) + + tick(25) + + assertTrue(player.levels.get(Skill.Constitution) < before + 100, "a dismissed spinner no longer heals") + } +} diff --git a/game/src/test/kotlin/content/skill/summoning/familiar/DesertWyrmBurrowTest.kt b/game/src/test/kotlin/content/skill/summoning/familiar/DesertWyrmBurrowTest.kt new file mode 100644 index 0000000000..ea82b47294 --- /dev/null +++ b/game/src/test/kotlin/content/skill/summoning/familiar/DesertWyrmBurrowTest.kt @@ -0,0 +1,80 @@ +package content.skill.summoning.familiar + +import WorldTest +import content.skill.summoning.follower +import content.skill.summoning.summonFamiliar +import npcOption +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import world.gregs.voidps.engine.data.definition.NPCDefinitions +import world.gregs.voidps.engine.entity.character.player.skill.Skill +import world.gregs.voidps.engine.entity.item.floor.FloorItems +import world.gregs.voidps.type.Tile + +internal class DesertWyrmBurrowTest : WorldTest() { + + @Test + fun `Burrow drops the nearest rock's ore`() { + val player = createPlayer(Tile(3228, 3229)) + player.levels.set(Skill.Summoning, 99) + player.summonFamiliar(NPCDefinitions.get("desert_wyrm_familiar"), restart = false) + tick(2) + createObject("copper_rocks_falador_mine_1", player.tile.addX(2)) + + player.npcOption(player.follower!!, "Burrow") + tick(12) // 8-tick burrow, call-back, then the floor-item queue flush + + val ore = (-2..2).flatMap { dx -> + (-2..2).flatMap { dy -> FloorItems.at(player.tile.add(dx, dy)).filter { it.id == "copper_ore" } } + } + assertTrue(ore.isNotEmpty(), "burrow dropped copper ore near the player") + } + + @Test + fun `Burrow with no rocks nearby does nothing`() { + val player = createPlayer(Tile(3228, 3229)) + player.levels.set(Skill.Summoning, 99) + player.summonFamiliar(NPCDefinitions.get("desert_wyrm_familiar"), restart = false) + tick(2) + + player.npcOption(player.follower!!, "Burrow") + tick(12) + + val ore = floorItemsNear(player, "copper_ore") + assertTrue(ore.isEmpty(), "no ore dropped when there are no rocks") + } + + @Test + fun `Burrow ignores silver rocks (above its tier)`() { + val player = createPlayer(Tile(3228, 3229)) + player.levels.set(Skill.Summoning, 99) + player.summonFamiliar(NPCDefinitions.get("desert_wyrm_familiar"), restart = false) + tick(2) + createObject("silver_rocks_dirt_1", player.tile.addX(2)) + + player.npcOption(player.follower!!, "Burrow") + tick(12) + + assertTrue(floorItemsNear(player, "silver_ore").isEmpty(), "the wyrm can't mine silver") + } + + @Test + fun `Burrow prefers the higher tier ore`() { + val player = createPlayer(Tile(3228, 3229)) + player.levels.set(Skill.Summoning, 99) + player.summonFamiliar(NPCDefinitions.get("desert_wyrm_familiar"), restart = false) + tick(2) + createObject("copper_rocks_falador_mine_1", player.tile.addX(1)) + createObject("iron_rocks_falador_mine_1", player.tile.addX(2)) + + player.npcOption(player.follower!!, "Burrow") + tick(12) + + assertTrue(floorItemsNear(player, "iron_ore").isNotEmpty(), "iron is preferred over copper") + assertTrue(floorItemsNear(player, "copper_ore").isEmpty()) + } + + private fun floorItemsNear(player: world.gregs.voidps.engine.entity.character.player.Player, id: String) = (-2..2).flatMap { dx -> + (-2..2).flatMap { dy -> FloorItems.at(player.tile.add(dx, dy)).filter { it.id == id } } + } +} diff --git a/game/src/test/kotlin/content/skill/summoning/familiar/VoidShifterTest.kt b/game/src/test/kotlin/content/skill/summoning/familiar/VoidShifterTest.kt new file mode 100644 index 0000000000..bbfddf568a --- /dev/null +++ b/game/src/test/kotlin/content/skill/summoning/familiar/VoidShifterTest.kt @@ -0,0 +1,82 @@ +package content.skill.summoning.familiar + +import WorldTest +import content.skill.summoning.summonFamiliar +import org.junit.jupiter.api.Test +import world.gregs.voidps.engine.data.definition.NPCDefinitions +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.entity.character.player.skill.Skill +import world.gregs.voidps.type.Tile +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals + +/** + * The Void shifter yanks its owner to the Void Knights' Outpost the moment their life points drop + * below 10% of their maximum. + */ +class VoidShifterTest : WorldTest() { + + private val outpost = Tile(2659, 2658, 0) + + private fun summon(familiar: String): Player { + val player = createPlayer(Tile(2523, 3056)) + player.levels.set(Skill.Summoning, 99) + player.experience.set(Skill.Constitution, 14_000_000.0) // level 99, max ~990 life points + player.summonFamiliar(NPCDefinitions.get(familiar), restart = false) + tick(2) // let the summon queue assign the follower + return player + } + + private fun Player.dropTo(lifePoints: Int) { + levels.drain(Skill.Constitution, levels.get(Skill.Constitution) - lifePoints) + } + + @Test + fun `Void shifter teleports the owner to the outpost below 10% life points`() { + val player = summon("void_shifter_familiar") + + player.dropTo(50) // below 10% of ~990 + tick(2) // let the teleport's movement apply + + assertEquals(outpost, player.tile) + } + + @Test + fun `Void shifter does not teleport while life points stay above 10%`() { + val player = summon("void_shifter_familiar") + + player.dropTo(500) // still above 10% + tick(2) + + assertNotEquals(outpost, player.tile) + } + + @Test + fun `The 10 percent threshold is measured in life points (level 10 = 100 life points)`() { + // A default player is level 10 Constitution, which is 100 life points, so 10% is 10 life + // points - proving the threshold scales with the life-point value, not the raw skill level. + val player = createPlayer(Tile(2523, 3056)) + player.levels.set(Skill.Summoning, 99) + player.summonFamiliar(NPCDefinitions.get("void_shifter_familiar"), restart = false) + tick(2) + assertEquals(100, player.levels.getMax(Skill.Constitution)) + + player.dropTo(15) // above the 10-life-point threshold + tick(2) + assertNotEquals(outpost, player.tile) + + player.dropTo(5) // below it + tick(2) + assertEquals(outpost, player.tile) + } + + @Test + fun `A different familiar does not teleport the owner`() { + val player = summon("spirit_wolf_familiar") + + player.dropTo(50) + tick(2) + + assertNotEquals(outpost, player.tile) + } +} diff --git a/game/src/test/kotlin/content/skill/summoning/pet/IncubatorUseEggTest.kt b/game/src/test/kotlin/content/skill/summoning/pet/IncubatorUseEggTest.kt index e187bc73b2..a6dec4eff5 100644 --- a/game/src/test/kotlin/content/skill/summoning/pet/IncubatorUseEggTest.kt +++ b/game/src/test/kotlin/content/skill/summoning/pet/IncubatorUseEggTest.kt @@ -21,6 +21,7 @@ internal class IncubatorUseEggTest : WorldTest() { fun `using penguin egg on incubator places it`() { val incubator = createObject("incubator_taverley", taverleyIncubatorTile) val player = createPlayer(Tile(taverleyIncubatorTile.x + 1, taverleyIncubatorTile.y, 0)) + player.experience.set(Skill.Summoning, 14_000_000.0) // level requirements check the real level player.levels.set(Skill.Summoning, 99) player.inventory.add("penguin_egg") diff --git a/game/src/test/kotlin/content/skill/summoning/pet/PetExclusivityTest.kt b/game/src/test/kotlin/content/skill/summoning/pet/PetExclusivityTest.kt index e267f878ce..dd80066bb1 100644 --- a/game/src/test/kotlin/content/skill/summoning/pet/PetExclusivityTest.kt +++ b/game/src/test/kotlin/content/skill/summoning/pet/PetExclusivityTest.kt @@ -37,6 +37,7 @@ internal class PetExclusivityTest : WorldTest() { // stale slot lookups after NPC index reuse; mirror real summon // state so the guard treats this player as actually pet-following. player["pet_active_item"] = "pet_kitten" + player.experience.set(Skill.Summoning, 14_000_000.0) // level requirements check the real level player.levels.set(Skill.Summoning, 99) player.inventory.add("spirit_wolf_pouch") diff --git a/tools/build.gradle.kts b/tools/build.gradle.kts index 4ddfcff2cb..84ae1ef925 100644 --- a/tools/build.gradle.kts +++ b/tools/build.gradle.kts @@ -69,3 +69,9 @@ tasks.register("renderPhotoBooth") { args = cliArgs } + +tasks.register("fixGraphics") { + classpath = sourceSets["main"].runtimeClasspath + mainClass.set("world.gregs.voidps.tools.cache.FixGraphics") + workingDir = rootDir +} diff --git a/tools/src/main/kotlin/world/gregs/voidps/tools/cache/CacheBuilder.kt b/tools/src/main/kotlin/world/gregs/voidps/tools/cache/CacheBuilder.kt index b4d823783b..44566bf64b 100644 --- a/tools/src/main/kotlin/world/gregs/voidps/tools/cache/CacheBuilder.kt +++ b/tools/src/main/kotlin/world/gregs/voidps/tools/cache/CacheBuilder.kt @@ -65,6 +65,7 @@ object CacheBuilder { FixStructs.fix(library) FixItems.fix(library) FixEnums.fix(library) + FixGraphics.fix(library) println("Rebuilding cache.") library.rebuild(target) addEmptyIndexFiles(target, library.last()?.id ?: 0) diff --git a/tools/src/main/kotlin/world/gregs/voidps/tools/cache/FixGraphics.kt b/tools/src/main/kotlin/world/gregs/voidps/tools/cache/FixGraphics.kt new file mode 100644 index 0000000000..2c37fa7a16 --- /dev/null +++ b/tools/src/main/kotlin/world/gregs/voidps/tools/cache/FixGraphics.kt @@ -0,0 +1,65 @@ +package world.gregs.voidps.tools.cache + +import com.displee.cache.CacheLibrary +import world.gregs.voidps.buffer.read.ArrayReader +import world.gregs.voidps.buffer.write.ArrayWriter +import world.gregs.voidps.cache.CacheDelegate +import world.gregs.voidps.cache.Index +import world.gregs.voidps.cache.definition.data.GraphicDefinition +import world.gregs.voidps.cache.definition.decoder.GraphicDecoder +import world.gregs.voidps.cache.definition.encoder.GraphicEncoder + +object FixGraphics { + + /** The barker toad's Toad Bark cannonball, whose model faces backwards in this revision. */ + private const val TOAD_BARK_CANNONBALL = 1402 + + fun fix(library: CacheLibrary) { + println("Fixing graphic definitions...") + val indexId = Index.GRAPHICS + + /* + Rotate graphic models whose projectiles fly backwards. Rotation is in degrees + (existing cache values are only ever 90/180/270). + */ + val rotationFixes = mapOf( + TOAD_BARK_CANNONBALL to 180, + ) + + val cache = CacheDelegate(library) + val decoder = GraphicDecoder() + val encoder = GraphicEncoder() + + val fixed = mutableListOf() + for ((id, rotation) in rotationFixes) { + val definition = GraphicDefinition(id) + val data = library.data(indexId, decoder.getArchive(id), decoder.getFile(id)) ?: continue + decoder.readLoop(definition, ArrayReader(data)) + definition.rotation = (definition.rotation + rotation) % 360 + fixed.add(definition) + } + + for (definition in fixed) { + val writer = ArrayWriter(500) + with(encoder) { + writer.encode(definition) + } + val out = writer.toArray() + val actual = GraphicDefinition() + decoder.readLoop(actual, ArrayReader(out)) + check(actual.copy(id = definition.id) == definition) { "Re-encoded graphic ${definition.id} doesn't round-trip." } + library.put(indexId, decoder.getArchive(definition.id), decoder.getFile(definition.id), out) + } + library.index(indexId).flag() + cache.update() + println("Fixed ${fixed.size} graphic definitions.") + } + + @JvmStatic + fun main(args: Array) { + val path = "./data/cache/" + val lib = CacheLibrary(path) + + fix(lib) + } +}