From f673a43bdadd1a47a3b4ad815e84feaef0088522 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Sun, 28 Jun 2026 15:49:11 -0700 Subject: [PATCH 001/136] Add familiar interactions and dialogues for summoning Port talk dialogue and special-op interactions for ~62 summoning familiars from a reference implementation, matching the existing per-familiar Script pattern (SpiritWolf/AbyssalLurker/AbyssalParasite). - Per-familiar Interact dialogue scripts (random + context-aware: inventory/equipment/stat/BoB-slot/run-energy/staged-state checks), grouped via wildcard ids for minotaurs, cockatrices, titan tiers, and gorajo/clay dungeoneering familiars. - Special ops: unicorn Cure (gated; inert until poison exists), pyrelord logs->firemaking, lava titan Lava Maze teleport, dreadfowl/compost mound Boost Farming. - Generic expression_familiar_* chathead animation family + unicorn cure anim/gfx; dialogue="familiar" + interacts=false on all scriptable familiars. --- data/skill/summoning/summoning.anims.toml | 44 ++- data/skill/summoning/summoning.gfx.toml | 3 + data/skill/summoning/summoning.npcs.toml | 282 ++++++++++++++++++ .../skill/summoning/familiar/AbyssalTitan.kt | 13 + .../skill/summoning/familiar/AlbinoRat.kt | 47 +++ .../skill/summoning/familiar/ArcticBear.kt | 43 +++ .../skill/summoning/familiar/BarkerToad.kt | 56 ++++ .../skill/summoning/familiar/Beaver.kt | 46 +++ .../skill/summoning/familiar/BloatedLeech.kt | 44 +++ .../skill/summoning/familiar/Bloodrager.kt | 39 +++ .../skill/summoning/familiar/BullAnt.kt | 51 ++++ .../skill/summoning/familiar/Bunyip.kt | 58 ++++ .../content/skill/summoning/familiar/Clay.kt | 42 +++ .../skill/summoning/familiar/Cockatrice.kt | 64 ++++ .../skill/summoning/familiar/CompostMound.kt | 78 +++++ .../skill/summoning/familiar/Deathslinger.kt | 49 +++ .../skill/summoning/familiar/DesertWyrm.kt | 56 ++++ .../skill/summoning/familiar/Dreadfowl.kt | 49 +++ .../skill/summoning/familiar/EvilTurnip.kt | 36 +++ .../skill/summoning/familiar/FireTitan.kt | 61 ++++ .../skill/summoning/familiar/ForgeRegent.kt | 62 ++++ .../skill/summoning/familiar/FruitBat.kt | 42 +++ .../skill/summoning/familiar/GeyserTitan.kt | 16 + .../summoning/familiar/GiantChinchompa.kt | 60 ++++ .../skill/summoning/familiar/GiantEnt.kt | 64 ++++ .../skill/summoning/familiar/GraniteCrab.kt | 40 +++ .../summoning/familiar/GraniteLobster.kt | 34 +++ .../skill/summoning/familiar/Hoardstalker.kt | 55 ++++ .../skill/summoning/familiar/HoneyBadger.kt | 37 +++ .../content/skill/summoning/familiar/Hydra.kt | 45 +++ .../content/skill/summoning/familiar/Ibis.kt | 45 +++ .../skill/summoning/familiar/IceTitan.kt | 20 ++ .../summoning/familiar/IronSteelTitan.kt | 13 + .../summoning/familiar/KaramthulhuOverlord.kt | 67 +++++ .../skill/summoning/familiar/LavaTitan.kt | 46 +++ .../content/skill/summoning/familiar/Macaw.kt | 29 ++ .../skill/summoning/familiar/Magpie.kt | 34 +++ .../skill/summoning/familiar/Meerkats.kt | 20 ++ .../skill/summoning/familiar/Minotaur.kt | 58 ++++ .../skill/summoning/familiar/MossTitan.kt | 25 ++ .../skill/summoning/familiar/ObsidianGolem.kt | 57 ++++ .../skill/summoning/familiar/PackYak.kt | 13 + .../skill/summoning/familiar/Phoenix.kt | 47 +++ .../skill/summoning/familiar/PrayingMantis.kt | 57 ++++ .../skill/summoning/familiar/Pyrelord.kt | 94 ++++++ .../summoning/familiar/RavenousLocust.kt | 46 +++ .../skill/summoning/familiar/Skinweaver.kt | 49 +++ .../skill/summoning/familiar/SmokeDevil.kt | 59 ++++ .../skill/summoning/familiar/SpiritCobra.kt | 64 ++++ .../summoning/familiar/SpiritDagannoth.kt | 45 +++ .../skill/summoning/familiar/SpiritGraahk.kt | 49 +++ .../skill/summoning/familiar/SpiritJelly.kt | 49 +++ .../summoning/familiar/SpiritKalphite.kt | 51 ++++ .../skill/summoning/familiar/SpiritKyatt.kt | 67 +++++ .../skill/summoning/familiar/SpiritLarupia.kt | 49 +++ .../summoning/familiar/SpiritMosquito.kt | 46 +++ .../summoning/familiar/SpiritScorpion.kt | 51 ++++ .../skill/summoning/familiar/SpiritSpider.kt | 63 ++++ .../summoning/familiar/SpiritTerrorbird.kt | 45 +++ .../skill/summoning/familiar/SpiritTzKih.kt | 54 ++++ .../skill/summoning/familiar/Stormbringer.kt | 50 ++++ .../skill/summoning/familiar/StrangerPlant.kt | 59 ++++ .../skill/summoning/familiar/SwampTitan.kt | 22 ++ .../skill/summoning/familiar/TalonBeast.kt | 45 +++ .../skill/summoning/familiar/ThornySnail.kt | 57 ++++ .../summoning/familiar/UnicornStallion.kt | 74 +++++ .../skill/summoning/familiar/VampireBat.kt | 35 +++ .../skill/summoning/familiar/VoidRavager.kt | 32 ++ .../skill/summoning/familiar/VoidShifter.kt | 43 +++ .../skill/summoning/familiar/VoidSpinner.kt | 41 +++ .../skill/summoning/familiar/VoidTorcher.kt | 41 +++ .../skill/summoning/familiar/WarTortoise.kt | 60 ++++ .../skill/summoning/familiar/Wolpertinger.kt | 13 + .../skill/summoning/familiar/Worldbearer.kt | 51 ++++ 74 files changed, 3650 insertions(+), 1 deletion(-) create mode 100644 game/src/main/kotlin/content/skill/summoning/familiar/AbyssalTitan.kt create mode 100644 game/src/main/kotlin/content/skill/summoning/familiar/AlbinoRat.kt create mode 100644 game/src/main/kotlin/content/skill/summoning/familiar/ArcticBear.kt create mode 100644 game/src/main/kotlin/content/skill/summoning/familiar/BarkerToad.kt create mode 100644 game/src/main/kotlin/content/skill/summoning/familiar/Beaver.kt create mode 100644 game/src/main/kotlin/content/skill/summoning/familiar/BloatedLeech.kt create mode 100644 game/src/main/kotlin/content/skill/summoning/familiar/Bloodrager.kt create mode 100644 game/src/main/kotlin/content/skill/summoning/familiar/BullAnt.kt create mode 100644 game/src/main/kotlin/content/skill/summoning/familiar/Bunyip.kt create mode 100644 game/src/main/kotlin/content/skill/summoning/familiar/Clay.kt create mode 100644 game/src/main/kotlin/content/skill/summoning/familiar/Cockatrice.kt create mode 100644 game/src/main/kotlin/content/skill/summoning/familiar/CompostMound.kt create mode 100644 game/src/main/kotlin/content/skill/summoning/familiar/Deathslinger.kt create mode 100644 game/src/main/kotlin/content/skill/summoning/familiar/DesertWyrm.kt create mode 100644 game/src/main/kotlin/content/skill/summoning/familiar/Dreadfowl.kt create mode 100644 game/src/main/kotlin/content/skill/summoning/familiar/EvilTurnip.kt create mode 100644 game/src/main/kotlin/content/skill/summoning/familiar/FireTitan.kt create mode 100644 game/src/main/kotlin/content/skill/summoning/familiar/ForgeRegent.kt create mode 100644 game/src/main/kotlin/content/skill/summoning/familiar/FruitBat.kt create mode 100644 game/src/main/kotlin/content/skill/summoning/familiar/GeyserTitan.kt create mode 100644 game/src/main/kotlin/content/skill/summoning/familiar/GiantChinchompa.kt create mode 100644 game/src/main/kotlin/content/skill/summoning/familiar/GiantEnt.kt create mode 100644 game/src/main/kotlin/content/skill/summoning/familiar/GraniteCrab.kt create mode 100644 game/src/main/kotlin/content/skill/summoning/familiar/GraniteLobster.kt create mode 100644 game/src/main/kotlin/content/skill/summoning/familiar/Hoardstalker.kt create mode 100644 game/src/main/kotlin/content/skill/summoning/familiar/HoneyBadger.kt create mode 100644 game/src/main/kotlin/content/skill/summoning/familiar/Hydra.kt create mode 100644 game/src/main/kotlin/content/skill/summoning/familiar/Ibis.kt create mode 100644 game/src/main/kotlin/content/skill/summoning/familiar/IceTitan.kt create mode 100644 game/src/main/kotlin/content/skill/summoning/familiar/IronSteelTitan.kt create mode 100644 game/src/main/kotlin/content/skill/summoning/familiar/KaramthulhuOverlord.kt create mode 100644 game/src/main/kotlin/content/skill/summoning/familiar/LavaTitan.kt create mode 100644 game/src/main/kotlin/content/skill/summoning/familiar/Macaw.kt create mode 100644 game/src/main/kotlin/content/skill/summoning/familiar/Magpie.kt create mode 100644 game/src/main/kotlin/content/skill/summoning/familiar/Meerkats.kt create mode 100644 game/src/main/kotlin/content/skill/summoning/familiar/Minotaur.kt create mode 100644 game/src/main/kotlin/content/skill/summoning/familiar/MossTitan.kt create mode 100644 game/src/main/kotlin/content/skill/summoning/familiar/ObsidianGolem.kt create mode 100644 game/src/main/kotlin/content/skill/summoning/familiar/PackYak.kt create mode 100644 game/src/main/kotlin/content/skill/summoning/familiar/Phoenix.kt create mode 100644 game/src/main/kotlin/content/skill/summoning/familiar/PrayingMantis.kt create mode 100644 game/src/main/kotlin/content/skill/summoning/familiar/Pyrelord.kt create mode 100644 game/src/main/kotlin/content/skill/summoning/familiar/RavenousLocust.kt create mode 100644 game/src/main/kotlin/content/skill/summoning/familiar/Skinweaver.kt create mode 100644 game/src/main/kotlin/content/skill/summoning/familiar/SmokeDevil.kt create mode 100644 game/src/main/kotlin/content/skill/summoning/familiar/SpiritCobra.kt create mode 100644 game/src/main/kotlin/content/skill/summoning/familiar/SpiritDagannoth.kt create mode 100644 game/src/main/kotlin/content/skill/summoning/familiar/SpiritGraahk.kt create mode 100644 game/src/main/kotlin/content/skill/summoning/familiar/SpiritJelly.kt create mode 100644 game/src/main/kotlin/content/skill/summoning/familiar/SpiritKalphite.kt create mode 100644 game/src/main/kotlin/content/skill/summoning/familiar/SpiritKyatt.kt create mode 100644 game/src/main/kotlin/content/skill/summoning/familiar/SpiritLarupia.kt create mode 100644 game/src/main/kotlin/content/skill/summoning/familiar/SpiritMosquito.kt create mode 100644 game/src/main/kotlin/content/skill/summoning/familiar/SpiritScorpion.kt create mode 100644 game/src/main/kotlin/content/skill/summoning/familiar/SpiritSpider.kt create mode 100644 game/src/main/kotlin/content/skill/summoning/familiar/SpiritTerrorbird.kt create mode 100644 game/src/main/kotlin/content/skill/summoning/familiar/SpiritTzKih.kt create mode 100644 game/src/main/kotlin/content/skill/summoning/familiar/Stormbringer.kt create mode 100644 game/src/main/kotlin/content/skill/summoning/familiar/StrangerPlant.kt create mode 100644 game/src/main/kotlin/content/skill/summoning/familiar/SwampTitan.kt create mode 100644 game/src/main/kotlin/content/skill/summoning/familiar/TalonBeast.kt create mode 100644 game/src/main/kotlin/content/skill/summoning/familiar/ThornySnail.kt create mode 100644 game/src/main/kotlin/content/skill/summoning/familiar/UnicornStallion.kt create mode 100644 game/src/main/kotlin/content/skill/summoning/familiar/VampireBat.kt create mode 100644 game/src/main/kotlin/content/skill/summoning/familiar/VoidRavager.kt create mode 100644 game/src/main/kotlin/content/skill/summoning/familiar/VoidShifter.kt create mode 100644 game/src/main/kotlin/content/skill/summoning/familiar/VoidSpinner.kt create mode 100644 game/src/main/kotlin/content/skill/summoning/familiar/VoidTorcher.kt create mode 100644 game/src/main/kotlin/content/skill/summoning/familiar/WarTortoise.kt create mode 100644 game/src/main/kotlin/content/skill/summoning/familiar/Wolpertinger.kt create mode 100644 game/src/main/kotlin/content/skill/summoning/familiar/Worldbearer.kt diff --git a/data/skill/summoning/summoning.anims.toml b/data/skill/summoning/summoning.anims.toml index 91163bfdfa..3cc11ad5e5 100644 --- a/data/skill/summoning/summoning.anims.toml +++ b/data/skill/summoning/summoning.anims.toml @@ -35,4 +35,46 @@ id = 8412 id = 8413 [expression_leech_angry] -id = 8414 \ No newline at end of file +id = 8414 + +# Generic familiar chathead expressions (reused across all summoned familiars, +# rendered on each familiar's own chathead model). Ids are the shared "cat" head +# talk animations plus a couple of generic human heads. +[expression_familiar_neutral] +id = 9178 + +[expression_familiar_happy] +id = 9192 + +[expression_familiar_quiz] +id = 9180 + +[expression_familiar_shock] +id = 9181 + +[expression_familiar_sad] +id = 6552 + +[expression_familiar_angry] +id = 9185 + +[expression_familiar_frustrated] +id = 9225 + +[expression_familiar_laugh] +id = 1518 + +[expression_familiar_shifty] +id = 6553 + +[expression_familiar_scared] +id = 9190 + +[expression_familiar_drunk] +id = 9835 + +[expression_familiar_evil] +id = 10522 + +[unicorn_stallion_cure] +id = 8267 \ No newline at end of file diff --git a/data/skill/summoning/summoning.gfx.toml b/data/skill/summoning/summoning.gfx.toml index 4fcf922db5..f3d27b1de3 100644 --- a/data/skill/summoning/summoning.gfx.toml +++ b/data/skill/summoning/summoning.gfx.toml @@ -1,3 +1,6 @@ +[unicorn_stallion_cure] +id = 1356 + [summon_familiar_size_1] id = 1314 diff --git a/data/skill/summoning/summoning.npcs.toml b/data/skill/summoning/summoning.npcs.toml index c99deee8b0..57ad660cff 100644 --- a/data/skill/summoning/summoning.npcs.toml +++ b/data/skill/summoning/summoning.npcs.toml @@ -1,44 +1,72 @@ [spirit_terrorbird_familiar] id = 6794 +dialogue = "familiar" +interacts = false [granite_crab_familiar] id = 6796 +dialogue = "familiar" +interacts = false [praying_mantis_familiar] id = 6798 +dialogue = "familiar" +interacts = false [giant_ent_familiar] id = 6800 +dialogue = "familiar" +interacts = false [spirit_cobra_familiar] id = 6802 +dialogue = "familiar" +interacts = false [spirit_dagannoth_familiar] id = 6804 +dialogue = "familiar" +interacts = false [thorny_snail_familiar] id = 6806 +dialogue = "familiar" +interacts = false [beaver_familiar] id = 6808 +dialogue = "familiar" +interacts = false [karamthulhu_overlord_familiar] id = 6809 +dialogue = "familiar" +interacts = false [hydra_familiar] id = 6811 +dialogue = "familiar" +interacts = false [spirit_jelly_familiar] id = 6992 +dialogue = "familiar" +interacts = false [bunyip_familiar] id = 6813 +dialogue = "familiar" +interacts = false [war_tortoise_familiar] id = 6815 +dialogue = "familiar" +interacts = false [fruit_bat_familiar] id = 6817 +dialogue = "familiar" +interacts = false [abyssal_parasite_familiar] id = 6818 @@ -58,15 +86,23 @@ summoning_beast_of_burden_essence = 1 [unicorn_stallion_familiar] id = 6822 +dialogue = "familiar" +interacts = false [magpie_familiar] id = 6824 +dialogue = "familiar" +interacts = false [dreadfowl_familiar] id = 6825 +dialogue = "familiar" +interacts = false [stranger_plant_familiar] id = 6827 +dialogue = "familiar" +interacts = false [spirit_wolf_familiar] id = 6829 @@ -75,373 +111,619 @@ interacts = false [desert_wyrm_familiar] id = 6831 +dialogue = "familiar" +interacts = false [evil_turnip_familiar] id = 6833 +dialogue = "familiar" +interacts = false [vampire_bat_familiar] id = 6835 +dialogue = "familiar" +interacts = false [spirit_scorpion_familiar] id = 6837 +dialogue = "familiar" +interacts = false [arctic_bear_familiar] id = 6839 +dialogue = "familiar" +interacts = false [spirit_spider_familiar] id = 6841 +dialogue = "familiar" +interacts = false [bloated_leech_familiar] id = 6843 +dialogue = "familiar" +interacts = false [spirit_kalphite_familiar] id = 6994 +dialogue = "familiar" +interacts = false [honey_badger_familiar] id = 6845 +dialogue = "familiar" +interacts = false [albino_rat_familiar] id = 6847 +dialogue = "familiar" +interacts = false [granite_lobster_familiar] id = 6849 +dialogue = "familiar" +interacts = false [macaw_familiar] id = 6851 +dialogue = "familiar" +interacts = false [bronze_minotaur_familiar] id = 6853 +dialogue = "familiar" +interacts = false [iron_minotaur_familiar] id = 6855 +dialogue = "familiar" +interacts = false [steel_minotaur_familiar] id = 6857 +dialogue = "familiar" +interacts = false [mithril_minotaur_familiar] id = 6859 +dialogue = "familiar" +interacts = false [adamant_minotaur_familiar] id = 6861 +dialogue = "familiar" +interacts = false [rune_minotaur_familiar] id = 6863 +dialogue = "familiar" +interacts = false [smoke_devil_familiar] id = 6865 +dialogue = "familiar" +interacts = false [bull_ant_familiar] id = 6867 +dialogue = "familiar" +interacts = false [wolpertinger_familiar] id = 6869 +dialogue = "familiar" +interacts = false [compost_mound_familiar] id = 6871 +dialogue = "familiar" +interacts = false [pack_yak_familiar] id = 6873 +dialogue = "familiar" +interacts = false [spirit_cockatrice_familiar] id = 6875 +dialogue = "familiar" +interacts = false [spirit_guthatrice_familiar] id = 6877 +dialogue = "familiar" +interacts = false [spirit_saratrice_familiar] id = 6879 +dialogue = "familiar" +interacts = false [spirit_zamatrice_familiar] id = 6881 +dialogue = "familiar" +interacts = false [spirit_pengatrice_familiar] id = 6883 +dialogue = "familiar" +interacts = false [spirit_coraxatrice_familiar] id = 6885 +dialogue = "familiar" +interacts = false [spirit_vulatrice_familiar] id = 6887 +dialogue = "familiar" +interacts = false [barker_toad_familiar] id = 6889 +dialogue = "familiar" +interacts = false [ibis_familiar] id = 6991 +dialogue = "familiar" +interacts = false [swamp_titan_familiar] id = 7329 +dialogue = "familiar" +interacts = false [spirit_mosquito_familiar] id = 7331 +dialogue = "familiar" +interacts = false [void_spinner_familiar] id = 7333 +dialogue = "familiar" +interacts = false [forge_regent_familiar] id = 7335 +dialogue = "familiar" +interacts = false [spirit_larupia_familiar] id = 7337 +dialogue = "familiar" +interacts = false [geyser_titan_familiar] id = 7339 +dialogue = "familiar" +interacts = false [lava_titan_familiar] id = 7341 +dialogue = "familiar" +interacts = false [steel_titan_familiar] id = 7343 +dialogue = "familiar" +interacts = false [obsidian_golem_familiar] id = 7345 +dialogue = "familiar" +interacts = false [talon_beast_familiar] id = 7347 +dialogue = "familiar" +interacts = false [abyssal_titan_familiar] id = 7349 +dialogue = "familiar" +interacts = false summoning_beast_of_burden = 1 summoning_beast_of_burden_capacity = 20 summoning_beast_of_burden_essence = 1 [void_torcher_familiar] id = 7351 +dialogue = "familiar" +interacts = false [giant_chinchompa_familiar] id = 7353 +dialogue = "familiar" +interacts = false [fire_titan_familiar] id = 7355 +dialogue = "familiar" +interacts = false [moss_titan_familiar] id = 7357 +dialogue = "familiar" +interacts = false [ice_titan_familiar] id = 7359 +dialogue = "familiar" +interacts = false [spirit_tz-kih_familiar] id = 7361 +dialogue = "familiar" +interacts = false [spirit_graahk_familiar] id = 7363 +dialogue = "familiar" +interacts = false [spirit_kyatt_familiar] id = 7365 +dialogue = "familiar" +interacts = false [void_shifter_familiar] id = 7367 +dialogue = "familiar" +interacts = false [pyrelord_familiar] id = 7377 +dialogue = "familiar" +interacts = false [void_ravager_familiar] id = 7370 +dialogue = "familiar" +interacts = false [ravenous_locust_familiar] id = 7372 +dialogue = "familiar" +interacts = false [iron_titan_familiar] id = 7375 +dialogue = "familiar" +interacts = false [clay_familiar_class_1_familiar] id = 8240 +dialogue = "familiar" +interacts = false [clay_familiar_class_2_familiar] id = 8242 +dialogue = "familiar" +interacts = false [clay_familiar_class_3_familiar] id = 8244 +dialogue = "familiar" +interacts = false [clay_familiar_class_4_familiar] id = 8246 +dialogue = "familiar" +interacts = false [clay_familiar_class_5_familiar] id = 8248 +dialogue = "familiar" +interacts = false [phoenix_familiar] id = 8575 +dialogue = "familiar" +interacts = false [cub_bloodrager_familiar] id = 11106 +dialogue = "familiar" +interacts = false [little_bloodrager_familiar] id = 11108 +dialogue = "familiar" +interacts = false [naïve_bloodrager_familiar] id = 11110 +dialogue = "familiar" +interacts = false [keen_bloodrager_familiar] id = 11112 +dialogue = "familiar" +interacts = false [brave_bloodrager_familiar] id = 11114 +dialogue = "familiar" +interacts = false [brah_bloodrager_familiar] id = 11116 +dialogue = "familiar" +interacts = false [naabe_bloodrager_familiar] id = 11118 +dialogue = "familiar" +interacts = false [wise_bloodrager_familiar] id = 11120 +dialogue = "familiar" +interacts = false [adept_bloodrager_familiar] id = 11122 +dialogue = "familiar" +interacts = false [sachem_bloodrager_familiar] id = 11124 +dialogue = "familiar" +interacts = false [cub_stormbringer_familiar] id = 11126 +dialogue = "familiar" +interacts = false [little_stormbringer_familiar] id = 11128 +dialogue = "familiar" +interacts = false [naïve_stormbringer_familiar] id = 11130 +dialogue = "familiar" +interacts = false [keen_stormbringer_familiar] id = 11132 +dialogue = "familiar" +interacts = false [brave_stormbringer_familiar] id = 11134 +dialogue = "familiar" +interacts = false [brah_stormbringer_familiar] id = 11136 +dialogue = "familiar" +interacts = false [naabe_stormbringer_familiar] id = 11138 +dialogue = "familiar" +interacts = false [wise_stormbringer_familiar] id = 11140 +dialogue = "familiar" +interacts = false [adept_stormbringer_familiar] id = 11142 +dialogue = "familiar" +interacts = false [sachem_stormbringer_familiar] id = 11144 +dialogue = "familiar" +interacts = false [cub_hoardstalker_familiar] id = 11146 +dialogue = "familiar" +interacts = false [little_hoardstalker_familiar] id = 11148 +dialogue = "familiar" +interacts = false [naïve_hoardstalker_familiar] id = 11150 +dialogue = "familiar" +interacts = false [keen_hoardstalker_familiar] id = 11152 +dialogue = "familiar" +interacts = false [brave_hoardstalker_familiar] id = 11154 +dialogue = "familiar" +interacts = false [brah_hoardstalker_familiar] id = 11156 +dialogue = "familiar" +interacts = false [naabe_hoardstalker_familiar] id = 11158 +dialogue = "familiar" +interacts = false [wise_hoardstalker_familiar] id = 11160 +dialogue = "familiar" +interacts = false [adept_hoardstalker_familiar] id = 11162 +dialogue = "familiar" +interacts = false [sachem_hoardstalker_familiar] id = 11164 +dialogue = "familiar" +interacts = false [cub_skinweaver_familiar] id = 11166 +dialogue = "familiar" +interacts = false [little_skinweaver_familiar] id = 11168 +dialogue = "familiar" +interacts = false [naïve_skinweaver_familiar] id = 11170 +dialogue = "familiar" +interacts = false [keen_skinweaver_familiar] id = 11172 +dialogue = "familiar" +interacts = false [brave_skinweaver_familiar] id = 11174 +dialogue = "familiar" +interacts = false [brah_skinweaver_familiar] id = 11176 +dialogue = "familiar" +interacts = false [naabe_skinweaver_familiar] id = 11178 +dialogue = "familiar" +interacts = false [wise_skinweaver_familiar] id = 11180 +dialogue = "familiar" +interacts = false [adept_skinweaver_familiar] id = 11182 +dialogue = "familiar" +interacts = false [sachem_skinweaver_familiar] id = 11184 +dialogue = "familiar" +interacts = false [cub_worldbearer_familiar] id = 11186 +dialogue = "familiar" +interacts = false [little_worldbearer_familiar] id = 11188 +dialogue = "familiar" +interacts = false [naïve_worldbearer_familiar] id = 11190 +dialogue = "familiar" +interacts = false [keen_worldbearer_familiar] id = 11192 +dialogue = "familiar" +interacts = false [brave_worldbearer_familiar] id = 11194 +dialogue = "familiar" +interacts = false [brah_worldbearer_familiar] id = 11196 +dialogue = "familiar" +interacts = false [naabe_worldbearer_familiar] id = 11198 +dialogue = "familiar" +interacts = false [wise_worldbearer_familiar] id = 11200 +dialogue = "familiar" +interacts = false [adept_worldbearer_familiar] id = 11202 +dialogue = "familiar" +interacts = false [sachem_worldbearer_familiar] id = 11204 +dialogue = "familiar" +interacts = false [cub_deathslinger_familiar] id = 11206 +dialogue = "familiar" +interacts = false [little_deathslinger_familiar] id = 11208 +dialogue = "familiar" +interacts = false [naive_deathslinger_familiar] id = 11210 +dialogue = "familiar" +interacts = false [keen_deathslinger_familiar] id = 11212 +dialogue = "familiar" +interacts = false [brave_deathslinger_familiar] id = 11214 +dialogue = "familiar" +interacts = false [brah_deathslinger_familiar] id = 11216 +dialogue = "familiar" +interacts = false [naabe_deathslinger_familiar] id = 11218 +dialogue = "familiar" +interacts = false [wise_deathslinger_familiar] id = 11220 +dialogue = "familiar" +interacts = false [adept_deathslinger_familiar] id = 11222 +dialogue = "familiar" +interacts = false [sachem_deathslinger_familiar] id = 11224 +dialogue = "familiar" +interacts = false [meerkats_familiar] id = 11640 +dialogue = "familiar" +interacts = false familiar_time = 40 \ No newline at end of file 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..5018cad84c --- /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..29beb7035b --- /dev/null +++ b/game/src/main/kotlin/content/skill/summoning/familiar/BarkerToad.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.engine.inv.inventory +import world.gregs.voidps.type.random + +class BarkerToad : Script { + init { + 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..2a7be629b2 --- /dev/null +++ b/game/src/main/kotlin/content/skill/summoning/familiar/Beaver.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.engine.inv.inventory +import world.gregs.voidps.type.random + +class Beaver : Script { + init { + npcOperate("Interact", "beaver_familiar") { + 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..d638655a61 --- /dev/null +++ b/game/src/main/kotlin/content/skill/summoning/familiar/Bunyip.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.inv.inventory +import world.gregs.voidps.type.random + +class Bunyip : Script { + init { + 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.") + } + } + } + } +} 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..7a826ed60c --- /dev/null +++ b/game/src/main/kotlin/content/skill/summoning/familiar/CompostMound.kt @@ -0,0 +1,78 @@ +package content.skill.summoning.familiar + +import content.skill.summoning.follower +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 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.type.random +import kotlin.math.ceil + +class CompostMound : Script { + init { + npcOperate("Interact", "compost_mound_familiar") { (target) -> + if (target != follower) { + return@npcOperate + } + choice { + option("Talk-to") { + talk() + } + option("Boost Farming") { + if (levels.get(Skill.Farming) <= levels.getMax(Skill.Farming)) { + val boost = ceil(1 + levels.getMax(Skill.Farming) * 0.02).toInt() + levels.boost(Skill.Farming, boost) + } + } + } + } + } + + private suspend fun Player.talk() { + 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..484bab191c --- /dev/null +++ b/game/src/main/kotlin/content/skill/summoning/familiar/DesertWyrm.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.engine.entity.character.player.equip.equipped +import world.gregs.voidps.engine.entity.character.player.name +import world.gregs.voidps.network.login.protocol.visual.update.player.EquipSlot +import world.gregs.voidps.type.random + +class DesertWyrm : Script { + init { + 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.") + } + } + } + } +} 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..8297e47112 --- /dev/null +++ b/game/src/main/kotlin/content/skill/summoning/familiar/Dreadfowl.kt @@ -0,0 +1,49 @@ +package content.skill.summoning.familiar + +import content.skill.summoning.follower +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 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.type.random + +class Dreadfowl : Script { + init { + npcOperate("Interact", "dreadfowl_familiar") { (target) -> + if (target != follower) { + return@npcOperate + } + choice { + option("Talk-to") { + talk() + } + option("Boost Farming") { + if (levels.get(Skill.Farming) <= levels.getMax(Skill.Farming)) { + levels.boost(Skill.Farming, 1) + } + } + } + } + } + + private suspend fun Player.talk() { + 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..b99027144c --- /dev/null +++ b/game/src/main/kotlin/content/skill/summoning/familiar/GeyserTitan.kt @@ -0,0 +1,16 @@ +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 GeyserTitan : Script { + init { + 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..5c0b74f43c --- /dev/null +++ b/game/src/main/kotlin/content/skill/summoning/familiar/GiantEnt.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 content.entity.player.dialogue.type.statement +import world.gregs.voidps.engine.Script +import world.gregs.voidps.type.random + +class GiantEnt : Script { + init { + 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..f19a54cec2 --- /dev/null +++ b/game/src/main/kotlin/content/skill/summoning/familiar/Hydra.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 Hydra : Script { + init { + 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/IronSteelTitan.kt b/game/src/main/kotlin/content/skill/summoning/familiar/IronSteelTitan.kt new file mode 100644 index 0000000000..274218a439 --- /dev/null +++ b/game/src/main/kotlin/content/skill/summoning/familiar/IronSteelTitan.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 IronSteelTitan : Script { + init { + npcOperate("Interact", "iron_titan_familiar,steel_titan_familiar") { + npc("Brrrrrr...") + } + } +} 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..603522601c --- /dev/null +++ b/game/src/main/kotlin/content/skill/summoning/familiar/KaramthulhuOverlord.kt @@ -0,0 +1,67 @@ +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.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 world.gregs.voidps.engine.Script +import world.gregs.voidps.type.random + +class KaramthulhuOverlord : Script { + init { + 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..886b25e76e --- /dev/null +++ b/game/src/main/kotlin/content/skill/summoning/familiar/LavaTitan.kt @@ -0,0 +1,46 @@ +package content.skill.summoning.familiar + +import content.skill.summoning.follower +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 world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.entity.character.move.tele +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.") { + tele(Tile(3030, 3838, 0)) + } + 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..138df9348b --- /dev/null +++ b/game/src/main/kotlin/content/skill/summoning/familiar/Phoenix.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 Phoenix : Script { + init { + 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..598db91be1 --- /dev/null +++ b/game/src/main/kotlin/content/skill/summoning/familiar/Pyrelord.kt @@ -0,0 +1,94 @@ +package content.skill.summoning.familiar + +import content.skill.summoning.follower +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.client.message +import world.gregs.voidps.engine.data.definition.Rows +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.inv.inventory +import world.gregs.voidps.engine.inv.remove +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...") + } + } + } + + // The pyrelord acts as a portable fire source - it burns logs without a tinderbox. + itemOnNPCOperate("*logs*", "pyrelord_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 pyrelord only burns logs.") + return@itemOnNPCOperate + } + val level = row.int("level") + if (!has(Skill.Firemaking, level, true)) { + return@itemOnNPCOperate + } + if (GameObjects.getLayer(tile, ObjectLayer.GROUND) != null) { + message("You can't light a fire here.") + return@itemOnNPCOperate + } + if (!inventory.remove(item.id)) { + return@itemOnNPCOperate + } + anim("light_fire") + exp(Skill.Firemaking, row.int("xp") / 10.0) + val colour = row.string("colour") + val life = row.int("life") + GameObjects.add("fire_$colour", tile, shape = ObjectShape.CENTRE_PIECE_STRAIGHT, rotation = 0, ticks = life) + FloorItems.add(tile, "ashes", revealTicks = life, disappearTicks = 60, owner = "") + message("The pyrelord breathes fire and the logs begin to burn.", ChatType.Filter) + } + } +} 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..1897b31fc9 --- /dev/null +++ b/game/src/main/kotlin/content/skill/summoning/familiar/SmokeDevil.kt @@ -0,0 +1,59 @@ +package content.skill.summoning.familiar + +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 world.gregs.voidps.engine.Script +import world.gregs.voidps.type.random + +class SmokeDevil : Script { + init { + 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..c58bb1508f --- /dev/null +++ b/game/src/main/kotlin/content/skill/summoning/familiar/SpiritCobra.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 SpiritCobra : Script { + init { + 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!") + } + } + } + } +} 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..d59140b927 --- /dev/null +++ b/game/src/main/kotlin/content/skill/summoning/familiar/SpiritDagannoth.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 SpiritDagannoth : Script { + init { + 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..52e488e617 --- /dev/null +++ b/game/src/main/kotlin/content/skill/summoning/familiar/SpiritGraahk.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 SpiritGraahk : Script { + init { + npcOperate("Interact", "spirit_graahk_familiar") { + 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..43cc583afc --- /dev/null +++ b/game/src/main/kotlin/content/skill/summoning/familiar/SpiritKyatt.kt @@ -0,0 +1,67 @@ +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 SpiritKyatt : Script { + init { + npcOperate("Interact", "spirit_kyatt_familiar") { + 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@npcOperate + } + 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..2113fe95c8 --- /dev/null +++ b/game/src/main/kotlin/content/skill/summoning/familiar/SpiritLarupia.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.Scared +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 SpiritLarupia : Script { + init { + npcOperate("Interact", "spirit_larupia_familiar") { + 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/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..3e97a3b563 --- /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..3505dfbc7f --- /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..1fab5fe1b1 --- /dev/null +++ b/game/src/main/kotlin/content/skill/summoning/familiar/UnicornStallion.kt @@ -0,0 +1,74 @@ +package content.skill.summoning.familiar + +import content.skill.summoning.follower +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.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 + +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 (levels.get(Skill.Summoning) < 2) { + message("You do not have enough summoning points to do this.") + return@npcOperate + } + if (!this["poisoned", false]) { + message("You're not suffering from poison!") + return@npcOperate + } + follower?.anim("unicorn_stallion_cure") + follower?.gfx("unicorn_stallion_cure") + clear("poisoned") + levels.drain(Skill.Summoning, 2) + } + } +} 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..017b20c949 --- /dev/null +++ b/game/src/main/kotlin/content/skill/summoning/familiar/VoidShifter.kt @@ -0,0 +1,43 @@ +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 VoidShifter : Script { + init { + 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.") + } + } + } + } +} From 1ab7a08eb00a5bb0829897cd2fa0970fd695e82b Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Sun, 28 Jun 2026 15:58:40 -0700 Subject: [PATCH 002/136] Use static familiar chatheads instead of generic cat anims The generic expression_familiar_* family applied the shared cat head talk animations to every familiar's chathead model, which animates incorrectly for non-feline familiars. Drop the dialogue="familiar" override and the generic family so each familiar renders its own chathead model with no forced (wrong) animation. Correct per-familiar talk anims can be added later as expression__* families (via ::expr_scan), like the existing wolf/bird/leech sets. --- data/skill/summoning/summoning.anims.toml | 39 ------ data/skill/summoning/summoning.npcs.toml | 141 ---------------------- 2 files changed, 180 deletions(-) diff --git a/data/skill/summoning/summoning.anims.toml b/data/skill/summoning/summoning.anims.toml index 3cc11ad5e5..3a30ff09ef 100644 --- a/data/skill/summoning/summoning.anims.toml +++ b/data/skill/summoning/summoning.anims.toml @@ -37,44 +37,5 @@ id = 8413 [expression_leech_angry] id = 8414 -# Generic familiar chathead expressions (reused across all summoned familiars, -# rendered on each familiar's own chathead model). Ids are the shared "cat" head -# talk animations plus a couple of generic human heads. -[expression_familiar_neutral] -id = 9178 - -[expression_familiar_happy] -id = 9192 - -[expression_familiar_quiz] -id = 9180 - -[expression_familiar_shock] -id = 9181 - -[expression_familiar_sad] -id = 6552 - -[expression_familiar_angry] -id = 9185 - -[expression_familiar_frustrated] -id = 9225 - -[expression_familiar_laugh] -id = 1518 - -[expression_familiar_shifty] -id = 6553 - -[expression_familiar_scared] -id = 9190 - -[expression_familiar_drunk] -id = 9835 - -[expression_familiar_evil] -id = 10522 - [unicorn_stallion_cure] id = 8267 \ No newline at end of file diff --git a/data/skill/summoning/summoning.npcs.toml b/data/skill/summoning/summoning.npcs.toml index 57ad660cff..68c86eb6e1 100644 --- a/data/skill/summoning/summoning.npcs.toml +++ b/data/skill/summoning/summoning.npcs.toml @@ -1,71 +1,57 @@ [spirit_terrorbird_familiar] id = 6794 -dialogue = "familiar" interacts = false [granite_crab_familiar] id = 6796 -dialogue = "familiar" interacts = false [praying_mantis_familiar] id = 6798 -dialogue = "familiar" interacts = false [giant_ent_familiar] id = 6800 -dialogue = "familiar" interacts = false [spirit_cobra_familiar] id = 6802 -dialogue = "familiar" interacts = false [spirit_dagannoth_familiar] id = 6804 -dialogue = "familiar" interacts = false [thorny_snail_familiar] id = 6806 -dialogue = "familiar" interacts = false [beaver_familiar] id = 6808 -dialogue = "familiar" interacts = false [karamthulhu_overlord_familiar] id = 6809 -dialogue = "familiar" interacts = false [hydra_familiar] id = 6811 -dialogue = "familiar" interacts = false [spirit_jelly_familiar] id = 6992 -dialogue = "familiar" interacts = false [bunyip_familiar] id = 6813 -dialogue = "familiar" interacts = false [war_tortoise_familiar] id = 6815 -dialogue = "familiar" interacts = false [fruit_bat_familiar] id = 6817 -dialogue = "familiar" interacts = false [abyssal_parasite_familiar] @@ -86,22 +72,18 @@ summoning_beast_of_burden_essence = 1 [unicorn_stallion_familiar] id = 6822 -dialogue = "familiar" interacts = false [magpie_familiar] id = 6824 -dialogue = "familiar" interacts = false [dreadfowl_familiar] id = 6825 -dialogue = "familiar" interacts = false [stranger_plant_familiar] id = 6827 -dialogue = "familiar" interacts = false [spirit_wolf_familiar] @@ -111,217 +93,174 @@ interacts = false [desert_wyrm_familiar] id = 6831 -dialogue = "familiar" interacts = false [evil_turnip_familiar] id = 6833 -dialogue = "familiar" interacts = false [vampire_bat_familiar] id = 6835 -dialogue = "familiar" interacts = false [spirit_scorpion_familiar] id = 6837 -dialogue = "familiar" interacts = false [arctic_bear_familiar] id = 6839 -dialogue = "familiar" interacts = false [spirit_spider_familiar] id = 6841 -dialogue = "familiar" interacts = false [bloated_leech_familiar] id = 6843 -dialogue = "familiar" interacts = false [spirit_kalphite_familiar] id = 6994 -dialogue = "familiar" interacts = false [honey_badger_familiar] id = 6845 -dialogue = "familiar" interacts = false [albino_rat_familiar] id = 6847 -dialogue = "familiar" interacts = false [granite_lobster_familiar] id = 6849 -dialogue = "familiar" interacts = false [macaw_familiar] id = 6851 -dialogue = "familiar" interacts = false [bronze_minotaur_familiar] id = 6853 -dialogue = "familiar" interacts = false [iron_minotaur_familiar] id = 6855 -dialogue = "familiar" interacts = false [steel_minotaur_familiar] id = 6857 -dialogue = "familiar" interacts = false [mithril_minotaur_familiar] id = 6859 -dialogue = "familiar" interacts = false [adamant_minotaur_familiar] id = 6861 -dialogue = "familiar" interacts = false [rune_minotaur_familiar] id = 6863 -dialogue = "familiar" interacts = false [smoke_devil_familiar] id = 6865 -dialogue = "familiar" interacts = false [bull_ant_familiar] id = 6867 -dialogue = "familiar" interacts = false [wolpertinger_familiar] id = 6869 -dialogue = "familiar" interacts = false [compost_mound_familiar] id = 6871 -dialogue = "familiar" interacts = false [pack_yak_familiar] id = 6873 -dialogue = "familiar" interacts = false [spirit_cockatrice_familiar] id = 6875 -dialogue = "familiar" interacts = false [spirit_guthatrice_familiar] id = 6877 -dialogue = "familiar" interacts = false [spirit_saratrice_familiar] id = 6879 -dialogue = "familiar" interacts = false [spirit_zamatrice_familiar] id = 6881 -dialogue = "familiar" interacts = false [spirit_pengatrice_familiar] id = 6883 -dialogue = "familiar" interacts = false [spirit_coraxatrice_familiar] id = 6885 -dialogue = "familiar" interacts = false [spirit_vulatrice_familiar] id = 6887 -dialogue = "familiar" interacts = false [barker_toad_familiar] id = 6889 -dialogue = "familiar" interacts = false [ibis_familiar] id = 6991 -dialogue = "familiar" interacts = false [swamp_titan_familiar] id = 7329 -dialogue = "familiar" interacts = false [spirit_mosquito_familiar] id = 7331 -dialogue = "familiar" interacts = false [void_spinner_familiar] id = 7333 -dialogue = "familiar" interacts = false [forge_regent_familiar] id = 7335 -dialogue = "familiar" interacts = false [spirit_larupia_familiar] id = 7337 -dialogue = "familiar" interacts = false [geyser_titan_familiar] id = 7339 -dialogue = "familiar" interacts = false [lava_titan_familiar] id = 7341 -dialogue = "familiar" interacts = false [steel_titan_familiar] id = 7343 -dialogue = "familiar" interacts = false [obsidian_golem_familiar] id = 7345 -dialogue = "familiar" interacts = false [talon_beast_familiar] id = 7347 -dialogue = "familiar" interacts = false [abyssal_titan_familiar] id = 7349 -dialogue = "familiar" interacts = false summoning_beast_of_burden = 1 summoning_beast_of_burden_capacity = 20 @@ -329,401 +268,321 @@ summoning_beast_of_burden_essence = 1 [void_torcher_familiar] id = 7351 -dialogue = "familiar" interacts = false [giant_chinchompa_familiar] id = 7353 -dialogue = "familiar" interacts = false [fire_titan_familiar] id = 7355 -dialogue = "familiar" interacts = false [moss_titan_familiar] id = 7357 -dialogue = "familiar" interacts = false [ice_titan_familiar] id = 7359 -dialogue = "familiar" interacts = false [spirit_tz-kih_familiar] id = 7361 -dialogue = "familiar" interacts = false [spirit_graahk_familiar] id = 7363 -dialogue = "familiar" interacts = false [spirit_kyatt_familiar] id = 7365 -dialogue = "familiar" interacts = false [void_shifter_familiar] id = 7367 -dialogue = "familiar" interacts = false [pyrelord_familiar] id = 7377 -dialogue = "familiar" interacts = false [void_ravager_familiar] id = 7370 -dialogue = "familiar" interacts = false [ravenous_locust_familiar] id = 7372 -dialogue = "familiar" interacts = false [iron_titan_familiar] id = 7375 -dialogue = "familiar" interacts = false [clay_familiar_class_1_familiar] id = 8240 -dialogue = "familiar" interacts = false [clay_familiar_class_2_familiar] id = 8242 -dialogue = "familiar" interacts = false [clay_familiar_class_3_familiar] id = 8244 -dialogue = "familiar" interacts = false [clay_familiar_class_4_familiar] id = 8246 -dialogue = "familiar" interacts = false [clay_familiar_class_5_familiar] id = 8248 -dialogue = "familiar" interacts = false [phoenix_familiar] id = 8575 -dialogue = "familiar" interacts = false [cub_bloodrager_familiar] id = 11106 -dialogue = "familiar" interacts = false [little_bloodrager_familiar] id = 11108 -dialogue = "familiar" interacts = false [naïve_bloodrager_familiar] id = 11110 -dialogue = "familiar" interacts = false [keen_bloodrager_familiar] id = 11112 -dialogue = "familiar" interacts = false [brave_bloodrager_familiar] id = 11114 -dialogue = "familiar" interacts = false [brah_bloodrager_familiar] id = 11116 -dialogue = "familiar" interacts = false [naabe_bloodrager_familiar] id = 11118 -dialogue = "familiar" interacts = false [wise_bloodrager_familiar] id = 11120 -dialogue = "familiar" interacts = false [adept_bloodrager_familiar] id = 11122 -dialogue = "familiar" interacts = false [sachem_bloodrager_familiar] id = 11124 -dialogue = "familiar" interacts = false [cub_stormbringer_familiar] id = 11126 -dialogue = "familiar" interacts = false [little_stormbringer_familiar] id = 11128 -dialogue = "familiar" interacts = false [naïve_stormbringer_familiar] id = 11130 -dialogue = "familiar" interacts = false [keen_stormbringer_familiar] id = 11132 -dialogue = "familiar" interacts = false [brave_stormbringer_familiar] id = 11134 -dialogue = "familiar" interacts = false [brah_stormbringer_familiar] id = 11136 -dialogue = "familiar" interacts = false [naabe_stormbringer_familiar] id = 11138 -dialogue = "familiar" interacts = false [wise_stormbringer_familiar] id = 11140 -dialogue = "familiar" interacts = false [adept_stormbringer_familiar] id = 11142 -dialogue = "familiar" interacts = false [sachem_stormbringer_familiar] id = 11144 -dialogue = "familiar" interacts = false [cub_hoardstalker_familiar] id = 11146 -dialogue = "familiar" interacts = false [little_hoardstalker_familiar] id = 11148 -dialogue = "familiar" interacts = false [naïve_hoardstalker_familiar] id = 11150 -dialogue = "familiar" interacts = false [keen_hoardstalker_familiar] id = 11152 -dialogue = "familiar" interacts = false [brave_hoardstalker_familiar] id = 11154 -dialogue = "familiar" interacts = false [brah_hoardstalker_familiar] id = 11156 -dialogue = "familiar" interacts = false [naabe_hoardstalker_familiar] id = 11158 -dialogue = "familiar" interacts = false [wise_hoardstalker_familiar] id = 11160 -dialogue = "familiar" interacts = false [adept_hoardstalker_familiar] id = 11162 -dialogue = "familiar" interacts = false [sachem_hoardstalker_familiar] id = 11164 -dialogue = "familiar" interacts = false [cub_skinweaver_familiar] id = 11166 -dialogue = "familiar" interacts = false [little_skinweaver_familiar] id = 11168 -dialogue = "familiar" interacts = false [naïve_skinweaver_familiar] id = 11170 -dialogue = "familiar" interacts = false [keen_skinweaver_familiar] id = 11172 -dialogue = "familiar" interacts = false [brave_skinweaver_familiar] id = 11174 -dialogue = "familiar" interacts = false [brah_skinweaver_familiar] id = 11176 -dialogue = "familiar" interacts = false [naabe_skinweaver_familiar] id = 11178 -dialogue = "familiar" interacts = false [wise_skinweaver_familiar] id = 11180 -dialogue = "familiar" interacts = false [adept_skinweaver_familiar] id = 11182 -dialogue = "familiar" interacts = false [sachem_skinweaver_familiar] id = 11184 -dialogue = "familiar" interacts = false [cub_worldbearer_familiar] id = 11186 -dialogue = "familiar" interacts = false [little_worldbearer_familiar] id = 11188 -dialogue = "familiar" interacts = false [naïve_worldbearer_familiar] id = 11190 -dialogue = "familiar" interacts = false [keen_worldbearer_familiar] id = 11192 -dialogue = "familiar" interacts = false [brave_worldbearer_familiar] id = 11194 -dialogue = "familiar" interacts = false [brah_worldbearer_familiar] id = 11196 -dialogue = "familiar" interacts = false [naabe_worldbearer_familiar] id = 11198 -dialogue = "familiar" interacts = false [wise_worldbearer_familiar] id = 11200 -dialogue = "familiar" interacts = false [adept_worldbearer_familiar] id = 11202 -dialogue = "familiar" interacts = false [sachem_worldbearer_familiar] id = 11204 -dialogue = "familiar" interacts = false [cub_deathslinger_familiar] id = 11206 -dialogue = "familiar" interacts = false [little_deathslinger_familiar] id = 11208 -dialogue = "familiar" interacts = false [naive_deathslinger_familiar] id = 11210 -dialogue = "familiar" interacts = false [keen_deathslinger_familiar] id = 11212 -dialogue = "familiar" interacts = false [brave_deathslinger_familiar] id = 11214 -dialogue = "familiar" interacts = false [brah_deathslinger_familiar] id = 11216 -dialogue = "familiar" interacts = false [naabe_deathslinger_familiar] id = 11218 -dialogue = "familiar" interacts = false [wise_deathslinger_familiar] id = 11220 -dialogue = "familiar" interacts = false [adept_deathslinger_familiar] id = 11222 -dialogue = "familiar" interacts = false [sachem_deathslinger_familiar] id = 11224 -dialogue = "familiar" interacts = false [meerkats_familiar] id = 11640 -dialogue = "familiar" interacts = false familiar_time = 40 \ No newline at end of file From a03ec1696f335a1933b980cdeb4ac1e60e142b1a Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Sun, 28 Jun 2026 16:01:12 -0700 Subject: [PATCH 003/136] Use large chatheads for summoning familiars Familiars are creatures; add large_head = true to every familiar npc def so their dialogue renders the bigger chathead (as cows, camels and other creature npcs already do). --- data/skill/summoning/summoning.npcs.toml | 144 +++++++++++++++++++++++ 1 file changed, 144 insertions(+) diff --git a/data/skill/summoning/summoning.npcs.toml b/data/skill/summoning/summoning.npcs.toml index 68c86eb6e1..e9c61939b5 100644 --- a/data/skill/summoning/summoning.npcs.toml +++ b/data/skill/summoning/summoning.npcs.toml @@ -1,61 +1,76 @@ [spirit_terrorbird_familiar] id = 6794 +large_head = true interacts = false [granite_crab_familiar] id = 6796 +large_head = true interacts = false [praying_mantis_familiar] id = 6798 +large_head = true interacts = false [giant_ent_familiar] id = 6800 +large_head = true interacts = false [spirit_cobra_familiar] id = 6802 +large_head = true interacts = false [spirit_dagannoth_familiar] id = 6804 +large_head = true interacts = false [thorny_snail_familiar] id = 6806 +large_head = true interacts = false [beaver_familiar] id = 6808 +large_head = true interacts = false [karamthulhu_overlord_familiar] id = 6809 +large_head = true interacts = false [hydra_familiar] id = 6811 +large_head = true interacts = false [spirit_jelly_familiar] id = 6992 +large_head = true interacts = false [bunyip_familiar] id = 6813 +large_head = true interacts = false [war_tortoise_familiar] id = 6815 +large_head = true interacts = false [fruit_bat_familiar] id = 6817 +large_head = true interacts = false [abyssal_parasite_familiar] id = 6818 +large_head = true dialogue = "leech" interacts = false summoning_beast_of_burden = 1 @@ -64,6 +79,7 @@ summoning_beast_of_burden_essence = 1 [abyssal_lurker_familiar] id = 6820 +large_head = true dialogue = "bird" interacts = false summoning_beast_of_burden = 1 @@ -72,195 +88,243 @@ summoning_beast_of_burden_essence = 1 [unicorn_stallion_familiar] id = 6822 +large_head = true interacts = false [magpie_familiar] id = 6824 +large_head = true interacts = false [dreadfowl_familiar] id = 6825 +large_head = true interacts = false [stranger_plant_familiar] id = 6827 +large_head = true interacts = false [spirit_wolf_familiar] id = 6829 +large_head = true dialogue = "wolf" interacts = false [desert_wyrm_familiar] id = 6831 +large_head = true interacts = false [evil_turnip_familiar] id = 6833 +large_head = true interacts = false [vampire_bat_familiar] id = 6835 +large_head = true interacts = false [spirit_scorpion_familiar] id = 6837 +large_head = true interacts = false [arctic_bear_familiar] id = 6839 +large_head = true interacts = false [spirit_spider_familiar] id = 6841 +large_head = true interacts = false [bloated_leech_familiar] id = 6843 +large_head = true interacts = false [spirit_kalphite_familiar] id = 6994 +large_head = true interacts = false [honey_badger_familiar] id = 6845 +large_head = true interacts = false [albino_rat_familiar] id = 6847 +large_head = true interacts = false [granite_lobster_familiar] id = 6849 +large_head = true interacts = false [macaw_familiar] id = 6851 +large_head = true interacts = false [bronze_minotaur_familiar] id = 6853 +large_head = true interacts = false [iron_minotaur_familiar] id = 6855 +large_head = true interacts = false [steel_minotaur_familiar] id = 6857 +large_head = true interacts = false [mithril_minotaur_familiar] id = 6859 +large_head = true interacts = false [adamant_minotaur_familiar] id = 6861 +large_head = true interacts = false [rune_minotaur_familiar] id = 6863 +large_head = true interacts = false [smoke_devil_familiar] id = 6865 +large_head = true interacts = false [bull_ant_familiar] id = 6867 +large_head = true interacts = false [wolpertinger_familiar] id = 6869 +large_head = true interacts = false [compost_mound_familiar] id = 6871 +large_head = true interacts = false [pack_yak_familiar] id = 6873 +large_head = true interacts = false [spirit_cockatrice_familiar] id = 6875 +large_head = true interacts = false [spirit_guthatrice_familiar] id = 6877 +large_head = true interacts = false [spirit_saratrice_familiar] id = 6879 +large_head = true interacts = false [spirit_zamatrice_familiar] id = 6881 +large_head = true interacts = false [spirit_pengatrice_familiar] id = 6883 +large_head = true interacts = false [spirit_coraxatrice_familiar] id = 6885 +large_head = true interacts = false [spirit_vulatrice_familiar] id = 6887 +large_head = true interacts = false [barker_toad_familiar] id = 6889 +large_head = true interacts = false [ibis_familiar] id = 6991 +large_head = true interacts = false [swamp_titan_familiar] id = 7329 +large_head = true interacts = false [spirit_mosquito_familiar] id = 7331 +large_head = true interacts = false [void_spinner_familiar] id = 7333 +large_head = true interacts = false [forge_regent_familiar] id = 7335 +large_head = true interacts = false [spirit_larupia_familiar] id = 7337 +large_head = true interacts = false [geyser_titan_familiar] id = 7339 +large_head = true interacts = false [lava_titan_familiar] id = 7341 +large_head = true interacts = false [steel_titan_familiar] id = 7343 +large_head = true interacts = false [obsidian_golem_familiar] id = 7345 +large_head = true interacts = false [talon_beast_familiar] id = 7347 +large_head = true interacts = false [abyssal_titan_familiar] id = 7349 +large_head = true interacts = false summoning_beast_of_burden = 1 summoning_beast_of_burden_capacity = 20 @@ -268,321 +332,401 @@ summoning_beast_of_burden_essence = 1 [void_torcher_familiar] id = 7351 +large_head = true interacts = false [giant_chinchompa_familiar] id = 7353 +large_head = true interacts = false [fire_titan_familiar] id = 7355 +large_head = true interacts = false [moss_titan_familiar] id = 7357 +large_head = true interacts = false [ice_titan_familiar] id = 7359 +large_head = true interacts = false [spirit_tz-kih_familiar] id = 7361 +large_head = true interacts = false [spirit_graahk_familiar] id = 7363 +large_head = true interacts = false [spirit_kyatt_familiar] id = 7365 +large_head = true interacts = false [void_shifter_familiar] id = 7367 +large_head = true interacts = false [pyrelord_familiar] id = 7377 +large_head = true interacts = false [void_ravager_familiar] id = 7370 +large_head = true interacts = false [ravenous_locust_familiar] id = 7372 +large_head = true interacts = false [iron_titan_familiar] id = 7375 +large_head = true interacts = false [clay_familiar_class_1_familiar] id = 8240 +large_head = true interacts = false [clay_familiar_class_2_familiar] id = 8242 +large_head = true interacts = false [clay_familiar_class_3_familiar] id = 8244 +large_head = true interacts = false [clay_familiar_class_4_familiar] id = 8246 +large_head = true interacts = false [clay_familiar_class_5_familiar] id = 8248 +large_head = true interacts = false [phoenix_familiar] id = 8575 +large_head = true interacts = false [cub_bloodrager_familiar] id = 11106 +large_head = true interacts = false [little_bloodrager_familiar] id = 11108 +large_head = true interacts = false [naïve_bloodrager_familiar] id = 11110 +large_head = true interacts = false [keen_bloodrager_familiar] id = 11112 +large_head = true interacts = false [brave_bloodrager_familiar] id = 11114 +large_head = true interacts = false [brah_bloodrager_familiar] id = 11116 +large_head = true interacts = false [naabe_bloodrager_familiar] id = 11118 +large_head = true interacts = false [wise_bloodrager_familiar] id = 11120 +large_head = true interacts = false [adept_bloodrager_familiar] id = 11122 +large_head = true interacts = false [sachem_bloodrager_familiar] id = 11124 +large_head = true interacts = false [cub_stormbringer_familiar] id = 11126 +large_head = true interacts = false [little_stormbringer_familiar] id = 11128 +large_head = true interacts = false [naïve_stormbringer_familiar] id = 11130 +large_head = true interacts = false [keen_stormbringer_familiar] id = 11132 +large_head = true interacts = false [brave_stormbringer_familiar] id = 11134 +large_head = true interacts = false [brah_stormbringer_familiar] id = 11136 +large_head = true interacts = false [naabe_stormbringer_familiar] id = 11138 +large_head = true interacts = false [wise_stormbringer_familiar] id = 11140 +large_head = true interacts = false [adept_stormbringer_familiar] id = 11142 +large_head = true interacts = false [sachem_stormbringer_familiar] id = 11144 +large_head = true interacts = false [cub_hoardstalker_familiar] id = 11146 +large_head = true interacts = false [little_hoardstalker_familiar] id = 11148 +large_head = true interacts = false [naïve_hoardstalker_familiar] id = 11150 +large_head = true interacts = false [keen_hoardstalker_familiar] id = 11152 +large_head = true interacts = false [brave_hoardstalker_familiar] id = 11154 +large_head = true interacts = false [brah_hoardstalker_familiar] id = 11156 +large_head = true interacts = false [naabe_hoardstalker_familiar] id = 11158 +large_head = true interacts = false [wise_hoardstalker_familiar] id = 11160 +large_head = true interacts = false [adept_hoardstalker_familiar] id = 11162 +large_head = true interacts = false [sachem_hoardstalker_familiar] id = 11164 +large_head = true interacts = false [cub_skinweaver_familiar] id = 11166 +large_head = true interacts = false [little_skinweaver_familiar] id = 11168 +large_head = true interacts = false [naïve_skinweaver_familiar] id = 11170 +large_head = true interacts = false [keen_skinweaver_familiar] id = 11172 +large_head = true interacts = false [brave_skinweaver_familiar] id = 11174 +large_head = true interacts = false [brah_skinweaver_familiar] id = 11176 +large_head = true interacts = false [naabe_skinweaver_familiar] id = 11178 +large_head = true interacts = false [wise_skinweaver_familiar] id = 11180 +large_head = true interacts = false [adept_skinweaver_familiar] id = 11182 +large_head = true interacts = false [sachem_skinweaver_familiar] id = 11184 +large_head = true interacts = false [cub_worldbearer_familiar] id = 11186 +large_head = true interacts = false [little_worldbearer_familiar] id = 11188 +large_head = true interacts = false [naïve_worldbearer_familiar] id = 11190 +large_head = true interacts = false [keen_worldbearer_familiar] id = 11192 +large_head = true interacts = false [brave_worldbearer_familiar] id = 11194 +large_head = true interacts = false [brah_worldbearer_familiar] id = 11196 +large_head = true interacts = false [naabe_worldbearer_familiar] id = 11198 +large_head = true interacts = false [wise_worldbearer_familiar] id = 11200 +large_head = true interacts = false [adept_worldbearer_familiar] id = 11202 +large_head = true interacts = false [sachem_worldbearer_familiar] id = 11204 +large_head = true interacts = false [cub_deathslinger_familiar] id = 11206 +large_head = true interacts = false [little_deathslinger_familiar] id = 11208 +large_head = true interacts = false [naive_deathslinger_familiar] id = 11210 +large_head = true interacts = false [keen_deathslinger_familiar] id = 11212 +large_head = true interacts = false [brave_deathslinger_familiar] id = 11214 +large_head = true interacts = false [brah_deathslinger_familiar] id = 11216 +large_head = true interacts = false [naabe_deathslinger_familiar] id = 11218 +large_head = true interacts = false [wise_deathslinger_familiar] id = 11220 +large_head = true interacts = false [adept_deathslinger_familiar] id = 11222 +large_head = true interacts = false [sachem_deathslinger_familiar] id = 11224 +large_head = true interacts = false [meerkats_familiar] id = 11640 +large_head = true interacts = false familiar_time = 40 \ No newline at end of file From 7d4c51cddd74e0afa27ee3a122102596d8aed000 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Sun, 28 Jun 2026 16:34:46 -0700 Subject: [PATCH 004/136] Add forager familiar system with Magpie Withdraw Foragers (starting with Magpie) periodically gather loot from a forage_ drop table into the familiar's inventory while summoned, and the player retrieves it via the familiar's Withdraw/Take option (reusing the beast-of-burden inventory + interface). Players can't deposit their own items into a forager. - Forager script: 30s forage timer rolling the drop table into the familiar inventory, plus Withdraw/Take open handlers. Foragers are detected data-drivenly by the existence of a forage_ drop table (no new npc-def param, which must map to a real cache param). - forage_magpie drop table (gems/rings) in summoning.drops.toml. - Magpie marked as a beast-of-burden (cap 30) to reuse the storage + withdraw UI; store() rejects deposits into foragers. - Start/stop the forage timer on summon/dismiss. Other forager familiars are now just data: add a forage_ table. --- data/skill/summoning/summoning.drops.toml | 19 ++++++ data/skill/summoning/summoning.npcs.toml | 2 + .../content/skill/summoning/BeastOfBurden.kt | 18 +++++- .../kotlin/content/skill/summoning/Forager.kt | 62 +++++++++++++++++++ .../content/skill/summoning/Summoning.kt | 5 ++ 5 files changed, 105 insertions(+), 1 deletion(-) create mode 100644 data/skill/summoning/summoning.drops.toml create mode 100644 game/src/main/kotlin/content/skill/summoning/Forager.kt diff --git a/data/skill/summoning/summoning.drops.toml b/data/skill/summoning/summoning.drops.toml new file mode 100644 index 0000000000..1be0ed037d --- /dev/null +++ b/data/skill/summoning/summoning.drops.toml @@ -0,0 +1,19 @@ +# Forage tables for forager familiars. Rolled into the familiar's inventory +# periodically while summoned; the player withdraws the loot from the familiar. +# One item is picked per roll (roll == number of drops, each weight 1). +[forage_magpie] +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" } +] diff --git a/data/skill/summoning/summoning.npcs.toml b/data/skill/summoning/summoning.npcs.toml index e9c61939b5..246a24b71a 100644 --- a/data/skill/summoning/summoning.npcs.toml +++ b/data/skill/summoning/summoning.npcs.toml @@ -95,6 +95,8 @@ interacts = false id = 6824 large_head = true interacts = false +summoning_beast_of_burden = 1 +summoning_beast_of_burden_capacity = 30 [dreadfowl_familiar] id = 6825 diff --git a/game/src/main/kotlin/content/skill/summoning/BeastOfBurden.kt b/game/src/main/kotlin/content/skill/summoning/BeastOfBurden.kt index b2b2a8edbe..bb0051c97c 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 @@ -36,6 +38,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 +136,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) -> @@ -245,6 +257,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/Forager.kt b/game/src/main/kotlin/content/skill/summoning/Forager.kt new file mode 100644 index 0000000000..cacf3584df --- /dev/null +++ b/game/src/main/kotlin/content/skill/summoning/Forager.kt @@ -0,0 +1,62 @@ +package content.skill.summoning + +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.entity.character.player.Player +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 + +/** + * 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 + } + forage() + Timer.CONTINUE + } + + npcOperate("Withdraw", "*_familiar") { (target) -> + if (target == follower && forageTable(dropTables) != null) { + openBeastOfBurden() + } + } + + npcOperate("Take", "*_familiar") { (target) -> + if (target == follower && forageTable(dropTables) != null) { + openBeastOfBurden() + } + } + } + + private fun Player.forage() { + val table = forageTable(dropTables) ?: return + ensureBeastOfBurdenInventory() + if (beastOfBurden.items.count { it.isNotEmpty() } >= beastOfBurdenCapacity) { + return + } + for (drop in table.roll(player = this)) { + val item = drop.toItem() + if (item.isEmpty()) { + continue + } + beastOfBurden.add(item.id, item.amount) + } + 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..0bad86763c 100644 --- a/game/src/main/kotlin/content/skill/summoning/Summoning.kt +++ b/game/src/main/kotlin/content/skill/summoning/Summoning.kt @@ -25,6 +25,7 @@ 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.Item +import world.gregs.voidps.engine.entity.item.drop.DropTables import world.gregs.voidps.engine.get import world.gregs.voidps.engine.inv.inventory import world.gregs.voidps.engine.inv.remove @@ -74,6 +75,9 @@ fun Player.summonFamiliar(familiar: NPCDefinition, restart: Boolean) { if (!restart) { timers.start("familiar_timer") } + if (get().get("forage_${familiarNpc.id.removeSuffix("_familiar")}") != null) { + timers.start("forage") + } } } @@ -100,6 +104,7 @@ fun Player.dismissFamiliar(removeNpc: Boolean = true) { set("familiar_details_seconds_remaining", 0) } timers.stop("familiar_timer") + timers.stop("forage") } /** From ee7e10b137028a6cc47d5c1e003654ee57269c29 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Sun, 28 Jun 2026 16:44:52 -0700 Subject: [PATCH 005/136] Add foraging to the remaining overworld forager familiars Port the forage drop tables for beaver, spirit spider, granite crab, granite lobster, ibis, macaw (grimy herbs), compost mound, evil turnip, fruit bat, giant ent, stranger plant, void ravager and the cockatrice family, and mark each as a beast of burden (cap 30) so they reuse the forage inventory + Withdraw/Take interaction. Tables model the reference's "1/outOf chance, then drop one" via a 1/ outer roll deferring to an `_items` sub-table for multi-item foragers (also corrects magpie, which previously dropped every tick). Item ids resolved to void string ids. Dungeoneering hoardstalker foragers (tier t1-t10) are deferred. --- data/skill/summoning/summoning.drops.toml | 173 +++++++++++++++++++++- data/skill/summoning/summoning.npcs.toml | 38 +++++ 2 files changed, 208 insertions(+), 3 deletions(-) diff --git a/data/skill/summoning/summoning.drops.toml b/data/skill/summoning/summoning.drops.toml index 1be0ed037d..cbaffc5656 100644 --- a/data/skill/summoning/summoning.drops.toml +++ b/data/skill/summoning/summoning.drops.toml @@ -1,7 +1,17 @@ -# Forage tables for forager familiars. Rolled into the familiar's inventory -# periodically while summoned; the player withdraws the loot from the familiar. -# One item is picked per roll (roll == number of drops, each weight 1). +# 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" }, @@ -17,3 +27,160 @@ drops = [ { 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" }] + +# --- 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.npcs.toml b/data/skill/summoning/summoning.npcs.toml index 246a24b71a..42d847213e 100644 --- a/data/skill/summoning/summoning.npcs.toml +++ b/data/skill/summoning/summoning.npcs.toml @@ -5,6 +5,8 @@ interacts = false [granite_crab_familiar] id = 6796 +summoning_beast_of_burden = 1 +summoning_beast_of_burden_capacity = 30 large_head = true interacts = false @@ -15,6 +17,8 @@ interacts = false [giant_ent_familiar] id = 6800 +summoning_beast_of_burden = 1 +summoning_beast_of_burden_capacity = 30 large_head = true interacts = false @@ -35,6 +39,8 @@ interacts = false [beaver_familiar] id = 6808 +summoning_beast_of_burden = 1 +summoning_beast_of_burden_capacity = 30 large_head = true interacts = false @@ -65,6 +71,8 @@ interacts = false [fruit_bat_familiar] id = 6817 +summoning_beast_of_burden = 1 +summoning_beast_of_burden_capacity = 30 large_head = true interacts = false @@ -105,6 +113,8 @@ interacts = false [stranger_plant_familiar] id = 6827 +summoning_beast_of_burden = 1 +summoning_beast_of_burden_capacity = 30 large_head = true interacts = false @@ -121,6 +131,8 @@ interacts = false [evil_turnip_familiar] id = 6833 +summoning_beast_of_burden = 1 +summoning_beast_of_burden_capacity = 30 large_head = true interacts = false @@ -141,6 +153,8 @@ interacts = false [spirit_spider_familiar] id = 6841 +summoning_beast_of_burden = 1 +summoning_beast_of_burden_capacity = 30 large_head = true interacts = false @@ -166,11 +180,15 @@ interacts = false [granite_lobster_familiar] id = 6849 +summoning_beast_of_burden = 1 +summoning_beast_of_burden_capacity = 30 large_head = true interacts = false [macaw_familiar] id = 6851 +summoning_beast_of_burden = 1 +summoning_beast_of_burden_capacity = 30 large_head = true interacts = false @@ -221,6 +239,8 @@ interacts = false [compost_mound_familiar] id = 6871 +summoning_beast_of_burden = 1 +summoning_beast_of_burden_capacity = 30 large_head = true interacts = false @@ -231,36 +251,50 @@ interacts = false [spirit_cockatrice_familiar] id = 6875 +summoning_beast_of_burden = 1 +summoning_beast_of_burden_capacity = 30 large_head = true interacts = false [spirit_guthatrice_familiar] id = 6877 +summoning_beast_of_burden = 1 +summoning_beast_of_burden_capacity = 30 large_head = true interacts = false [spirit_saratrice_familiar] id = 6879 +summoning_beast_of_burden = 1 +summoning_beast_of_burden_capacity = 30 large_head = true interacts = false [spirit_zamatrice_familiar] id = 6881 +summoning_beast_of_burden = 1 +summoning_beast_of_burden_capacity = 30 large_head = true interacts = false [spirit_pengatrice_familiar] id = 6883 +summoning_beast_of_burden = 1 +summoning_beast_of_burden_capacity = 30 large_head = true interacts = false [spirit_coraxatrice_familiar] id = 6885 +summoning_beast_of_burden = 1 +summoning_beast_of_burden_capacity = 30 large_head = true interacts = false [spirit_vulatrice_familiar] id = 6887 +summoning_beast_of_burden = 1 +summoning_beast_of_burden_capacity = 30 large_head = true interacts = false @@ -271,6 +305,8 @@ interacts = false [ibis_familiar] id = 6991 +summoning_beast_of_burden = 1 +summoning_beast_of_burden_capacity = 30 large_head = true interacts = false @@ -384,6 +420,8 @@ interacts = false [void_ravager_familiar] id = 7370 +summoning_beast_of_burden = 1 +summoning_beast_of_burden_capacity = 30 large_head = true interacts = false From 2e244800f9dfe3184140a44abeda31d45616d59d Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Sun, 28 Jun 2026 17:20:18 -0700 Subject: [PATCH 006/136] Move dreadfowl/compost farming boost to the special-move option The farming boost was wrongly offered as a "Boost Farming" entry in a Talk-to/Boost choice on Interact. Make Interact talk-only again and trigger the boost from the familiar's special-move ("Special") option instead, via the cast_dreadfowl_strike / cast_generate_compost buttons on the summoning orb and familiar details tab. --- .../skill/summoning/FamiliarSpecialMove.kt | 44 ++++++++ .../skill/summoning/familiar/CompostMound.kt | 100 +++++++----------- .../skill/summoning/familiar/Dreadfowl.kt | 44 +++----- 3 files changed, 95 insertions(+), 93 deletions(-) create mode 100644 game/src/main/kotlin/content/skill/summoning/FamiliarSpecialMove.kt 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..cab5e0e2b1 --- /dev/null +++ b/game/src/main/kotlin/content/skill/summoning/FamiliarSpecialMove.kt @@ -0,0 +1,44 @@ +package content.skill.summoning + +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 kotlin.math.ceil + +/** + * Familiar special-move effects, triggered from the familiar's special-move ("Special") option on + * the summoning orb or the familiar details tab. Each special is its own `cast_` interface + * component shown only while the matching familiar is summoned. + * + * Currently the two farming-boost specials: + * - Dreadfowl (Dreadfowl Strike) - boosts Farming by 1. + * - Compost mound (Generate Compost) - boosts Farming by ceil(1 + level * 0.02). + */ +class FamiliarSpecialMove : Script { + init { + interfaceOption(id = "summoning_orb:*cast_dreadfowl_strike") { dreadfowlBoost() } + interfaceOption(id = "familiar_details:cast_dreadfowl_strike") { dreadfowlBoost() } + + interfaceOption(id = "summoning_orb:*cast_generate_compost") { compostBoost() } + interfaceOption(id = "familiar_details:cast_generate_compost") { compostBoost() } + } + + private fun Player.dreadfowlBoost() { + if (follower?.id != "dreadfowl_familiar") { + return + } + if (levels.get(Skill.Farming) <= levels.getMax(Skill.Farming)) { + levels.boost(Skill.Farming, 1) + } + } + + private fun Player.compostBoost() { + if (follower?.id != "compost_mound_familiar") { + return + } + if (levels.get(Skill.Farming) <= levels.getMax(Skill.Farming)) { + val boost = ceil(1 + levels.getMax(Skill.Farming) * 0.02).toInt() + levels.boost(Skill.Farming, boost) + } + } +} diff --git a/game/src/main/kotlin/content/skill/summoning/familiar/CompostMound.kt b/game/src/main/kotlin/content/skill/summoning/familiar/CompostMound.kt index 7a826ed60c..7ee3b1d3de 100644 --- a/game/src/main/kotlin/content/skill/summoning/familiar/CompostMound.kt +++ b/game/src/main/kotlin/content/skill/summoning/familiar/CompostMound.kt @@ -1,77 +1,55 @@ package content.skill.summoning.familiar -import content.skill.summoning.follower 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 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.type.random -import kotlin.math.ceil class CompostMound : Script { init { - npcOperate("Interact", "compost_mound_familiar") { (target) -> - if (target != follower) { - return@npcOperate - } - choice { - option("Talk-to") { - talk() + 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.") } - option("Boost Farming") { - if (levels.get(Skill.Farming) <= levels.getMax(Skill.Farming)) { - val boost = ceil(1 + levels.getMax(Skill.Farming) * 0.02).toInt() - levels.boost(Skill.Farming, boost) - } + 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.") } - } - } - } - - private suspend fun Player.talk() { - 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/Dreadfowl.kt b/game/src/main/kotlin/content/skill/summoning/familiar/Dreadfowl.kt index 8297e47112..23363a6e3c 100644 --- a/game/src/main/kotlin/content/skill/summoning/familiar/Dreadfowl.kt +++ b/game/src/main/kotlin/content/skill/summoning/familiar/Dreadfowl.kt @@ -1,48 +1,28 @@ package content.skill.summoning.familiar -import content.skill.summoning.follower 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 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.type.random class Dreadfowl : Script { init { - npcOperate("Interact", "dreadfowl_familiar") { (target) -> - if (target != follower) { - return@npcOperate - } - choice { - option("Talk-to") { - talk() + 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.") } - option("Boost Farming") { - if (levels.get(Skill.Farming) <= levels.getMax(Skill.Farming)) { - levels.boost(Skill.Farming, 1) - } + 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.") } - } - } - } - - private suspend fun Player.talk() { - 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.") } } } From f3d304b813cb6a45a89d192cf3206b40811fcb97 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Sun, 28 Jun 2026 17:39:50 -0700 Subject: [PATCH 007/136] Trigger dreadfowl/compost farming boost from the Special npc option The boost is on the familiar's "Special" right-click option, not the special-move interface button. Use npcOperate("Special", ...) guarded to the player's own familiar. --- .../skill/summoning/FamiliarSpecialMove.kt | 37 +++++++------------ 1 file changed, 14 insertions(+), 23 deletions(-) diff --git a/game/src/main/kotlin/content/skill/summoning/FamiliarSpecialMove.kt b/game/src/main/kotlin/content/skill/summoning/FamiliarSpecialMove.kt index cab5e0e2b1..86924754ea 100644 --- a/game/src/main/kotlin/content/skill/summoning/FamiliarSpecialMove.kt +++ b/game/src/main/kotlin/content/skill/summoning/FamiliarSpecialMove.kt @@ -6,39 +6,30 @@ import world.gregs.voidps.engine.entity.character.player.skill.Skill import kotlin.math.ceil /** - * Familiar special-move effects, triggered from the familiar's special-move ("Special") option on - * the summoning orb or the familiar details tab. Each special is its own `cast_` interface - * component shown only while the matching familiar is summoned. + * Familiar special-move effects, triggered from the familiar's "Special" npc right-click option. * * Currently the two farming-boost specials: - * - Dreadfowl (Dreadfowl Strike) - boosts Farming by 1. - * - Compost mound (Generate Compost) - boosts Farming by ceil(1 + level * 0.02). + * - Dreadfowl - boosts Farming by 1. + * - Compost mound - boosts Farming by ceil(1 + level * 0.02). */ class FamiliarSpecialMove : Script { init { - interfaceOption(id = "summoning_orb:*cast_dreadfowl_strike") { dreadfowlBoost() } - interfaceOption(id = "familiar_details:cast_dreadfowl_strike") { dreadfowlBoost() } - - interfaceOption(id = "summoning_orb:*cast_generate_compost") { compostBoost() } - interfaceOption(id = "familiar_details:cast_generate_compost") { compostBoost() } - } - - private fun Player.dreadfowlBoost() { - if (follower?.id != "dreadfowl_familiar") { - return + npcOperate("Special", "dreadfowl_familiar") { (target) -> + if (target == follower) { + boostFarming(1) + } } - if (levels.get(Skill.Farming) <= levels.getMax(Skill.Farming)) { - levels.boost(Skill.Farming, 1) + + npcOperate("Special", "compost_mound_familiar") { (target) -> + if (target == follower) { + boostFarming(ceil(1 + levels.getMax(Skill.Farming) * 0.02).toInt()) + } } } - private fun Player.compostBoost() { - if (follower?.id != "compost_mound_familiar") { - return - } + private fun Player.boostFarming(amount: Int) { if (levels.get(Skill.Farming) <= levels.getMax(Skill.Farming)) { - val boost = ceil(1 + levels.getMax(Skill.Farming) * 0.02).toInt() - levels.boost(Skill.Farming, boost) + levels.boost(Skill.Farming, amount) } } } From acca60d59a7a930b5ebf4a55eb022b58b44c0d63 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Sun, 28 Jun 2026 23:21:54 -0700 Subject: [PATCH 008/136] Add dreadfowl Special dialogue and animation Clicking the dreadfowl's "Special" option now plays the asking-to-fight dialogue, the dreadfowl's special animation (7810) + gfx (1523, from darkanrs' Dreadfowl Strike sync), then boosts Farming by one. --- data/skill/summoning/summoning.anims.toml | 5 ++++- data/skill/summoning/summoning.gfx.toml | 3 +++ .../skill/summoning/FamiliarSpecialMove.kt | 16 ++++++++++++---- 3 files changed, 19 insertions(+), 5 deletions(-) diff --git a/data/skill/summoning/summoning.anims.toml b/data/skill/summoning/summoning.anims.toml index 3a30ff09ef..fa5e720b17 100644 --- a/data/skill/summoning/summoning.anims.toml +++ b/data/skill/summoning/summoning.anims.toml @@ -38,4 +38,7 @@ id = 8413 id = 8414 [unicorn_stallion_cure] -id = 8267 \ No newline at end of file +id = 8267 + +[dreadfowl_special] +id = 7810 \ No newline at end of file diff --git a/data/skill/summoning/summoning.gfx.toml b/data/skill/summoning/summoning.gfx.toml index f3d27b1de3..695cab2245 100644 --- a/data/skill/summoning/summoning.gfx.toml +++ b/data/skill/summoning/summoning.gfx.toml @@ -1,6 +1,9 @@ [unicorn_stallion_cure] id = 1356 +[dreadfowl_special] +id = 1523 + [summon_familiar_size_1] id = 1314 diff --git a/game/src/main/kotlin/content/skill/summoning/FamiliarSpecialMove.kt b/game/src/main/kotlin/content/skill/summoning/FamiliarSpecialMove.kt index 86924754ea..d4b028aabd 100644 --- a/game/src/main/kotlin/content/skill/summoning/FamiliarSpecialMove.kt +++ b/game/src/main/kotlin/content/skill/summoning/FamiliarSpecialMove.kt @@ -1,5 +1,9 @@ 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.entity.character.player.Player import world.gregs.voidps.engine.entity.character.player.skill.Skill @@ -8,16 +12,20 @@ import kotlin.math.ceil /** * Familiar special-move effects, triggered from the familiar's "Special" npc right-click option. * - * Currently the two farming-boost specials: - * - Dreadfowl - boosts Farming by 1. + * - Dreadfowl - asks to fight, plays its special animation and boosts Farming by 1. * - Compost mound - boosts Farming by ceil(1 + level * 0.02). */ class FamiliarSpecialMove : Script { init { npcOperate("Special", "dreadfowl_familiar") { (target) -> - if (target == follower) { - boostFarming(1) + 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?)") + follower?.anim("dreadfowl_special") + follower?.gfx("dreadfowl_special") + boostFarming(1) } npcOperate("Special", "compost_mound_familiar") { (target) -> From f735f8b250263f1e94766f8a0f214226477e3ef6 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Sun, 28 Jun 2026 23:25:09 -0700 Subject: [PATCH 009/136] Use dreadfowl special animation (5387) --- data/skill/summoning/summoning.anims.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/data/skill/summoning/summoning.anims.toml b/data/skill/summoning/summoning.anims.toml index fa5e720b17..650c7150ce 100644 --- a/data/skill/summoning/summoning.anims.toml +++ b/data/skill/summoning/summoning.anims.toml @@ -41,4 +41,4 @@ id = 8414 id = 8267 [dreadfowl_special] -id = 7810 \ No newline at end of file +id = 5387 \ No newline at end of file From 39c5c4d47ed818a0c0c330ec32e7a1b43fd51f0f Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Sun, 28 Jun 2026 23:38:44 -0700 Subject: [PATCH 010/136] Add dreadfowl farming-boost graphics Player plays gfx 1307 and the dreadfowl plays gfx 1317 alongside its special-strike animation (5387) when boosting Farming via the Special option. --- data/skill/summoning/summoning.gfx.toml | 5 ++++- .../kotlin/content/skill/summoning/FamiliarSpecialMove.kt | 1 + 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/data/skill/summoning/summoning.gfx.toml b/data/skill/summoning/summoning.gfx.toml index 695cab2245..b5563b2c27 100644 --- a/data/skill/summoning/summoning.gfx.toml +++ b/data/skill/summoning/summoning.gfx.toml @@ -2,7 +2,10 @@ id = 1356 [dreadfowl_special] -id = 1523 +id = 1317 + +[dreadfowl_special_player] +id = 1307 [summon_familiar_size_1] id = 1314 diff --git a/game/src/main/kotlin/content/skill/summoning/FamiliarSpecialMove.kt b/game/src/main/kotlin/content/skill/summoning/FamiliarSpecialMove.kt index d4b028aabd..f98513728d 100644 --- a/game/src/main/kotlin/content/skill/summoning/FamiliarSpecialMove.kt +++ b/game/src/main/kotlin/content/skill/summoning/FamiliarSpecialMove.kt @@ -23,6 +23,7 @@ class FamiliarSpecialMove : Script { } player("Can you boost my Farming stat please?") npc("Bwuck cluck ckuck?
(If I do, will you let me fight?)") + gfx("dreadfowl_special_player") follower?.anim("dreadfowl_special") follower?.gfx("dreadfowl_special") boostFarming(1) From bc7c923bcaaa12e753f977a40cdea609c75725fa Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Sun, 28 Jun 2026 23:47:28 -0700 Subject: [PATCH 011/136] Make familiar Farming boost non-stacking and removed on dismiss The dreadfowl/compost-mound Special Farming boost now refuses to apply when Farming is already boosted (e.g. by a garden pie), and is stripped when the familiar is dismissed or dies (both route through dismissFamiliar) - only if it's still the active, unchanged boost. --- .../skill/summoning/FamiliarSpecialMove.kt | 33 +++++++++++++++---- .../content/skill/summoning/Summoning.kt | 16 +++++++++ 2 files changed, 43 insertions(+), 6 deletions(-) diff --git a/game/src/main/kotlin/content/skill/summoning/FamiliarSpecialMove.kt b/game/src/main/kotlin/content/skill/summoning/FamiliarSpecialMove.kt index f98513728d..0b51a6a84f 100644 --- a/game/src/main/kotlin/content/skill/summoning/FamiliarSpecialMove.kt +++ b/game/src/main/kotlin/content/skill/summoning/FamiliarSpecialMove.kt @@ -5,6 +5,7 @@ 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 @@ -12,8 +13,11 @@ 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 and boosts Farming by 1. + * - 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 { @@ -23,6 +27,9 @@ class FamiliarSpecialMove : Script { } 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") @@ -30,15 +37,29 @@ class FamiliarSpecialMove : Script { } npcOperate("Special", "compost_mound_familiar") { (target) -> - if (target == follower) { - boostFarming(ceil(1 + levels.getMax(Skill.Farming) * 0.02).toInt()) + if (target != follower) { + return@npcOperate } + if (farmingAlreadyBoosted()) { + return@npcOperate + } + boostFarming(ceil(1 + levels.getMax(Skill.Farming) * 0.02).toInt()) } } - private fun Player.boostFarming(amount: Int) { - if (levels.get(Skill.Farming) <= levels.getMax(Skill.Farming)) { - levels.boost(Skill.Farming, amount) + /** 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/Summoning.kt b/game/src/main/kotlin/content/skill/summoning/Summoning.kt index 0bad86763c..48921e90b4 100644 --- a/game/src/main/kotlin/content/skill/summoning/Summoning.kt +++ b/game/src/main/kotlin/content/skill/summoning/Summoning.kt @@ -87,6 +87,7 @@ fun Player.summonFamiliar(familiar: NPCDefinition, restart: Boolean) { */ fun Player.dismissFamiliar(removeNpc: Boolean = true) { dropBeastOfBurdenItems() + removeFamiliarFarmingBoost() if (removeNpc) { NPCs.remove(follower) } @@ -107,6 +108,21 @@ fun Player.dismissFamiliar(removeNpc: Boolean = true) { timers.stop("forage") } +/** + * 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") +} + /** * Updates the familiar interface (663) with the details of the player's current follower */ From f8416be3bb76443d668e1ab62ffc2b6f4211c93e Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Mon, 29 Jun 2026 00:02:01 -0700 Subject: [PATCH 012/136] Add passive invisible familiar skill boosts Introduce a data-driven registry of the passive skill-level boosts a summoned familiar grants (beaver +2 Woodcutting, granite lobster +4 Fishing, obsidian golem +7 Mining, etc.) and a Player.familiarBoost function. The boost is read live from the active follower and added to the effective level in skill success checks - so it's invisible (not in the skill tab), never lets the player act above their real level, and disappears the moment the familiar is dismissed or killed. Wired into woodcutting, fishing, mining, firemaking and thieving. Hunter boosts (arctic bear, wolpertinger, kyatt, larupia) are in the registry but inert until the Hunter skill is implemented. --- .../content/skill/firemaking/Firemaking.kt | 3 +- .../kotlin/content/skill/fishing/Fishing.kt | 3 +- .../kotlin/content/skill/mining/Mining.kt | 5 ++- .../content/skill/summoning/FamiliarBoosts.kt | 41 +++++++++++++++++++ .../content/skill/thieving/Pickpocketing.kt | 3 +- .../kotlin/content/skill/thieving/Stalls.kt | 3 +- .../content/skill/woodcutting/Woodcutting.kt | 3 +- 7 files changed, 54 insertions(+), 7 deletions(-) create mode 100644 game/src/main/kotlin/content/skill/summoning/FamiliarBoosts.kt diff --git a/game/src/main/kotlin/content/skill/firemaking/Firemaking.kt b/game/src/main/kotlin/content/skill/firemaking/Firemaking.kt index 4aa7e24a0c..fa887cf013 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 @@ -85,7 +86,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..fad8fa2e00 100644 --- a/game/src/main/kotlin/content/skill/fishing/Fishing.kt +++ b/game/src/main/kotlin/content/skill/fishing/Fishing.kt @@ -1,5 +1,6 @@ package content.skill.fishing +import content.skill.summoning.familiarBoost import com.github.michaelbull.logging.InlineLogger import net.pearx.kasechange.toLowerSpaceCase import net.pearx.kasechange.toTitleCase @@ -122,7 +123,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/mining/Mining.kt b/game/src/main/kotlin/content/skill/mining/Mining.kt index cb307de785..79333f7f49 100644 --- a/game/src/main/kotlin/content/skill/mining/Mining.kt +++ b/game/src/main/kotlin/content/skill/mining/Mining.kt @@ -1,6 +1,7 @@ package content.skill.mining import content.activity.shooting_star.ShootingStarHandler +import content.skill.summoning.familiarBoost import content.entity.player.bank.bank import content.entity.player.bank.ownsItem import content.quest.questCompleted @@ -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/FamiliarBoosts.kt b/game/src/main/kotlin/content/skill/summoning/FamiliarBoosts.kt new file mode 100644 index 0000000000..98c3d26ad9 --- /dev/null +++ b/game/src/main/kotlin/content/skill/summoning/FamiliarBoosts.kt @@ -0,0 +1,41 @@ +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. + */ +private val FAMILIAR_BOOSTS: Map> = mapOf( + "beaver_familiar" to mapOf(Skill.Woodcutting to 2), + "granite_crab_familiar" to mapOf(Skill.Fishing to 1), + "ibis_familiar" to mapOf(Skill.Fishing to 3), + "granite_lobster_familiar" to mapOf(Skill.Fishing to 4), + "arctic_bear_familiar" to mapOf(Skill.Hunter to 7), + "wolpertinger_familiar" to mapOf(Skill.Hunter to 5), + "spirit_kyatt_familiar" to mapOf(Skill.Hunter to 5), + "spirit_larupia_familiar" to mapOf(Skill.Hunter to 5), + "desert_wyrm_familiar" to mapOf(Skill.Mining to 1), + "void_ravager_familiar" to mapOf(Skill.Mining to 1), + "obsidian_golem_familiar" to mapOf(Skill.Mining to 7), + "lava_titan_familiar" to mapOf(Skill.Mining to 10, Skill.Firemaking to 10), + "pyrelord_familiar" to mapOf(Skill.Firemaking to 3), + "forge_regent_familiar" to mapOf(Skill.Firemaking to 4), + "magpie_familiar" to mapOf(Skill.Thieving to 3), +) + +/** + * 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 + return FAMILIAR_BOOSTS[id]?.get(skill) ?: 0 +} diff --git a/game/src/main/kotlin/content/skill/thieving/Pickpocketing.kt b/game/src/main/kotlin/content/skill/thieving/Pickpocketing.kt index 7f2d1590cc..95a3749ae2 100644 --- a/game/src/main/kotlin/content/skill/thieving/Pickpocketing.kt +++ b/game/src/main/kotlin/content/skill/thieving/Pickpocketing.kt @@ -1,5 +1,6 @@ package content.skill.thieving +import content.skill.summoning.familiarBoost import com.github.michaelbull.logging.InlineLogger import content.entity.effect.stun import content.skill.slayer.categories @@ -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..6d08e2c418 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 @@ -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) From 93c10365a78f787e01d2814583084972ce28f3b9 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Mon, 29 Jun 2026 00:22:06 -0700 Subject: [PATCH 013/136] Add random familiar overhead dialogue Summoned familiars now periodically shout a random overhead line every ~30s (driven by the existing familiar timer), matching RS. Lines are a data-driven map keyed by familiar id, sourced from the RuneScape Wiki transcript "Overhead dialogue" sections - 83 familiars covered. Phoenix and the gorajo (bloodrager/stormbringer/etc.) tiers have no overhead on the wiki and stay silent. --- .../skill/summoning/FamiliarOverhead.kt | 95 +++++++++++++++++++ .../skill/summoning/SummoningTimers.kt | 7 ++ 2 files changed, 102 insertions(+) create mode 100644 game/src/main/kotlin/content/skill/summoning/FamiliarOverhead.kt diff --git a/game/src/main/kotlin/content/skill/summoning/FamiliarOverhead.kt b/game/src/main/kotlin/content/skill/summoning/FamiliarOverhead.kt new file mode 100644 index 0000000000..be376ad163 --- /dev/null +++ b/game/src/main/kotlin/content/skill/summoning/FamiliarOverhead.kt @@ -0,0 +1,95 @@ +package content.skill.summoning + +/** + * Random overhead lines a summoned familiar periodically shouts (every ~30s, driven by the + * familiar timer in [SummoningTimers]). Sourced from the RuneScape Wiki transcript "Overhead + * dialogue" sections (https://runescape.wiki/w/Transcript:#Overhead_dialogue). + * + * Keyed by familiar npc id; familiars without an entry (e.g. phoenix, the gorajo bloodrager/ + * stormbringer/hoardstalker/skinweaver/worldbearer/deathslinger lines) simply stay silent. + */ +val FAMILIAR_OVERHEAD: Map> = mapOf( + "spirit_wolf_familiar" to listOf("Grrrrr...", "Whuff-whuff!"), + "dreadfowl_familiar" to listOf("Bwaaak!", "Bwuk bwuk!"), + "vampire_bat_familiar" to listOf("Squeak!", "Squeak squeak!"), + "spirit_terrorbird_familiar" to listOf("Scree screee!", "Screee!"), + "granite_crab_familiar" to listOf("Chitter!", "Click click!"), + "praying_mantis_familiar" to listOf("Chitter!", "Clack clack!"), + "giant_ent_familiar" to listOf("Groooooan!", "Creeeeak!"), + "spirit_cobra_familiar" to listOf("Hssssssss!", "Hsss!"), + "spirit_dagannoth_familiar" to listOf("Raaaw!", "Grrrowl!"), + "thorny_snail_familiar" to listOf("Slither!", "Sclurp!"), + "beaver_familiar" to listOf("Gnawgnaw...", "Gnaw ..."), + "karamthulhu_overlord_familiar" to listOf("(Bow down!)", "(Obey!)"), + "hydra_familiar" to listOf("Rassssssp!", "Raaaaasp!"), + "spirit_jelly_familiar" to listOf("Jiggle!", "Flobble!"), + "bunyip_familiar" to listOf("Glorp glorp!", "Glorp!"), + "war_tortoise_familiar" to listOf("Hsss..."), + "fruit_bat_familiar" to listOf("Squeeeak!", "Squeee!"), + "abyssal_parasite_familiar" to listOf("Schlorp!", "Gurgle"), + "abyssal_lurker_familiar" to listOf("Craaaw!", "Screee!"), + "unicorn_stallion_familiar" to listOf("Whinney!", "Neigh!"), + "magpie_familiar" to listOf("Chirrup!", "Twitter!"), + "stranger_plant_familiar" to listOf("Rustle!", "Snapsnap!"), + "desert_wyrm_familiar" to listOf("Ssssss!", "Hssssss!"), + "evil_turnip_familiar" to listOf("Graaah!", "Hur hur hur!"), + "spirit_scorpion_familiar" to listOf("Clatter!", "Clack clack!"), + "arctic_bear_familiar" to listOf("Unf!", "Graaaowl!"), + "spirit_spider_familiar" to listOf("Clitterclatter!", "Click!"), + "bloated_leech_familiar" to listOf("Gurgle!", "Schlurp!"), + "spirit_kalphite_familiar" to listOf("Click click!", "Hsssss!"), + "honey_badger_familiar" to listOf("Raaaaar!", "Grooowl!"), + "albino_rat_familiar" to listOf("Squeee!", "Squeak squeak!"), + "granite_lobster_familiar" to listOf("Grind!", "Clonk clonk!"), + "macaw_familiar" to listOf("Braaak!", "Caw!"), + "bronze_minotaur_familiar" to listOf("Out of the way!", "Hey, no-horns!"), + "iron_minotaur_familiar" to listOf("Out of the way!", "Hey, no-horns!"), + "steel_minotaur_familiar" to listOf("Out of the way!", "Hey, no-horns!"), + "mithril_minotaur_familiar" to listOf("Out of the way!", "Hey, no-horns!"), + "adamant_minotaur_familiar" to listOf("Out of the way!", "Hey, no-horns!"), + "rune_minotaur_familiar" to listOf("Out of the way!", "Hey, no-horns!"), + "smoke_devil_familiar" to listOf("Hoot!", "Honk!"), + "bull_ant_familiar" to listOf("Click click!", "Click!"), + "wolpertinger_familiar" to listOf("Mew!", "Raawr!"), + "compost_mound_familiar" to listOf("Splutter!", "Schlorp!"), + "pack_yak_familiar" to listOf("Barooo barooo!", "Barooo!"), + "spirit_cockatrice_familiar" to listOf("Bwwwark bwwwark!", "Bwwwark!"), + "spirit_guthatrice_familiar" to listOf("Caaaw caaaw!", "Caaaw!"), + "spirit_saratrice_familiar" to listOf("Hoooot hoooot!", "Hoooot!"), + "spirit_zamatrice_familiar" to listOf("Screeee screeee!", "Screeee!"), + "spirit_pengatrice_familiar" to listOf("Awk awk!", "Awk!"), + "spirit_coraxatrice_familiar" to listOf("Craaw craaw!", "Craaw!"), + "spirit_vulatrice_familiar" to listOf("Scraaaaw scraaaaw!", "Scraaaaw!"), + "barker_toad_familiar" to listOf("Braaaaap!", "Craaaaaaak!"), + "ibis_familiar" to listOf("Chirp!", "Twitter!"), + "swamp_titan_familiar" to listOf("Something smells clean...", "The power of stench compels you!"), + "spirit_mosquito_familiar" to listOf("Bzzzzzzzzz!", "Whiiiiiine!"), + "void_spinner_familiar" to listOf("Whiiiiiir!", "Whiiiir!"), + "forge_regent_familiar" to listOf("Sizzle!", "Crackle!"), + "spirit_larupia_familiar" to listOf("Hssssss!", "Ssssss!"), + "geyser_titan_familiar" to listOf("Pah!", "Flee from me!"), + "lava_titan_familiar" to listOf("Sacred flames...", "By my fire..."), + "steel_titan_familiar" to listOf("Stand back!", "I Am Legend!"), + "obsidian_golem_familiar" to listOf("Onward to glory!", "Stand back!"), + "talon_beast_familiar" to listOf("Growwwl!", "Mrowl!"), + "abyssal_titan_familiar" to listOf("Mwanu b'ruum!", "T'verah hoshay!"), + "void_torcher_familiar" to listOf("Scree!", "Squaa!"), + "giant_chinchompa_familiar" to listOf("Squeek!", "Squeek squeek!"), + "fire_titan_familiar" to listOf("Come on and fight!", "Hey, tiny!"), + "moss_titan_familiar" to listOf("Don't get me mad!", "Little soft humies!"), + "ice_titan_familiar" to listOf("I'm melting!", "It's so warm here!"), + "spirit_tz-kih_familiar" to listOf("Squee!", "Squee squee!"), + "spirit_graahk_familiar" to listOf("Howl!", "Rowr!"), + "spirit_kyatt_familiar" to listOf("Grrrrrr!", "Mrrrowl!"), + "void_shifter_familiar" to listOf("Chitter!", "Scree!"), + "pyrelord_familiar" to listOf("Whoosh!", "Crackle!"), + "void_ravager_familiar" to listOf("Rarf!", "Grrrrr!"), + "ravenous_locust_familiar" to listOf("Whiiiiiine!", "Click!"), + "iron_titan_familiar" to listOf("Victory!", "Crush them!"), + "meerkats_familiar" to listOf("Squeak squeak!", "Simples!"), + "clay_familiar_class_1_familiar" to listOf("Rumble!", "Rumble, rumble!"), + "clay_familiar_class_2_familiar" to listOf("Rumble!", "Rumble, rumble!"), + "clay_familiar_class_3_familiar" to listOf("Rumble!", "Rumble, rumble!"), + "clay_familiar_class_4_familiar" to listOf("Rumble!", "Rumble, rumble!"), + "clay_familiar_class_5_familiar" to listOf("Rumble!", "Rumble, rumble!"), +) diff --git a/game/src/main/kotlin/content/skill/summoning/SummoningTimers.kt b/game/src/main/kotlin/content/skill/summoning/SummoningTimers.kt index 9e3561b164..8eb8008a78 100644 --- a/game/src/main/kotlin/content/skill/summoning/SummoningTimers.kt +++ b/game/src/main/kotlin/content/skill/summoning/SummoningTimers.kt @@ -5,6 +5,7 @@ import world.gregs.voidps.engine.client.message 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 +20,12 @@ class SummoningTimers : Script { } timerTick("familiar_timer") { + follower?.let { familiar -> + val lines = FAMILIAR_OVERHEAD[familiar.id] + 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") From 2375c59f428c3a175f676c5460cb601cbcfbb04c Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Mon, 29 Jun 2026 00:38:59 -0700 Subject: [PATCH 014/136] Move familiar overhead dialogue into a table definition Replace the hardcoded Kotlin FAMILIAR_OVERHEAD map with a data-driven familiar_overhead table (list "lines" column, keyed by familiar id), matching how the rest of the project stores config (e.g. the pet talk tables). The familiar timer reads it via Rows/Tables, so lines can be edited as data without touching code. --- .../summoning/familiar_overhead.tables.toml | 256 ++++++++++++++++++ .../skill/summoning/FamiliarOverhead.kt | 95 ------- .../skill/summoning/SummoningTimers.kt | 3 +- 3 files changed, 258 insertions(+), 96 deletions(-) create mode 100644 data/skill/summoning/familiar_overhead.tables.toml delete mode 100644 game/src/main/kotlin/content/skill/summoning/FamiliarOverhead.kt 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/game/src/main/kotlin/content/skill/summoning/FamiliarOverhead.kt b/game/src/main/kotlin/content/skill/summoning/FamiliarOverhead.kt deleted file mode 100644 index be376ad163..0000000000 --- a/game/src/main/kotlin/content/skill/summoning/FamiliarOverhead.kt +++ /dev/null @@ -1,95 +0,0 @@ -package content.skill.summoning - -/** - * Random overhead lines a summoned familiar periodically shouts (every ~30s, driven by the - * familiar timer in [SummoningTimers]). Sourced from the RuneScape Wiki transcript "Overhead - * dialogue" sections (https://runescape.wiki/w/Transcript:#Overhead_dialogue). - * - * Keyed by familiar npc id; familiars without an entry (e.g. phoenix, the gorajo bloodrager/ - * stormbringer/hoardstalker/skinweaver/worldbearer/deathslinger lines) simply stay silent. - */ -val FAMILIAR_OVERHEAD: Map> = mapOf( - "spirit_wolf_familiar" to listOf("Grrrrr...", "Whuff-whuff!"), - "dreadfowl_familiar" to listOf("Bwaaak!", "Bwuk bwuk!"), - "vampire_bat_familiar" to listOf("Squeak!", "Squeak squeak!"), - "spirit_terrorbird_familiar" to listOf("Scree screee!", "Screee!"), - "granite_crab_familiar" to listOf("Chitter!", "Click click!"), - "praying_mantis_familiar" to listOf("Chitter!", "Clack clack!"), - "giant_ent_familiar" to listOf("Groooooan!", "Creeeeak!"), - "spirit_cobra_familiar" to listOf("Hssssssss!", "Hsss!"), - "spirit_dagannoth_familiar" to listOf("Raaaw!", "Grrrowl!"), - "thorny_snail_familiar" to listOf("Slither!", "Sclurp!"), - "beaver_familiar" to listOf("Gnawgnaw...", "Gnaw ..."), - "karamthulhu_overlord_familiar" to listOf("(Bow down!)", "(Obey!)"), - "hydra_familiar" to listOf("Rassssssp!", "Raaaaasp!"), - "spirit_jelly_familiar" to listOf("Jiggle!", "Flobble!"), - "bunyip_familiar" to listOf("Glorp glorp!", "Glorp!"), - "war_tortoise_familiar" to listOf("Hsss..."), - "fruit_bat_familiar" to listOf("Squeeeak!", "Squeee!"), - "abyssal_parasite_familiar" to listOf("Schlorp!", "Gurgle"), - "abyssal_lurker_familiar" to listOf("Craaaw!", "Screee!"), - "unicorn_stallion_familiar" to listOf("Whinney!", "Neigh!"), - "magpie_familiar" to listOf("Chirrup!", "Twitter!"), - "stranger_plant_familiar" to listOf("Rustle!", "Snapsnap!"), - "desert_wyrm_familiar" to listOf("Ssssss!", "Hssssss!"), - "evil_turnip_familiar" to listOf("Graaah!", "Hur hur hur!"), - "spirit_scorpion_familiar" to listOf("Clatter!", "Clack clack!"), - "arctic_bear_familiar" to listOf("Unf!", "Graaaowl!"), - "spirit_spider_familiar" to listOf("Clitterclatter!", "Click!"), - "bloated_leech_familiar" to listOf("Gurgle!", "Schlurp!"), - "spirit_kalphite_familiar" to listOf("Click click!", "Hsssss!"), - "honey_badger_familiar" to listOf("Raaaaar!", "Grooowl!"), - "albino_rat_familiar" to listOf("Squeee!", "Squeak squeak!"), - "granite_lobster_familiar" to listOf("Grind!", "Clonk clonk!"), - "macaw_familiar" to listOf("Braaak!", "Caw!"), - "bronze_minotaur_familiar" to listOf("Out of the way!", "Hey, no-horns!"), - "iron_minotaur_familiar" to listOf("Out of the way!", "Hey, no-horns!"), - "steel_minotaur_familiar" to listOf("Out of the way!", "Hey, no-horns!"), - "mithril_minotaur_familiar" to listOf("Out of the way!", "Hey, no-horns!"), - "adamant_minotaur_familiar" to listOf("Out of the way!", "Hey, no-horns!"), - "rune_minotaur_familiar" to listOf("Out of the way!", "Hey, no-horns!"), - "smoke_devil_familiar" to listOf("Hoot!", "Honk!"), - "bull_ant_familiar" to listOf("Click click!", "Click!"), - "wolpertinger_familiar" to listOf("Mew!", "Raawr!"), - "compost_mound_familiar" to listOf("Splutter!", "Schlorp!"), - "pack_yak_familiar" to listOf("Barooo barooo!", "Barooo!"), - "spirit_cockatrice_familiar" to listOf("Bwwwark bwwwark!", "Bwwwark!"), - "spirit_guthatrice_familiar" to listOf("Caaaw caaaw!", "Caaaw!"), - "spirit_saratrice_familiar" to listOf("Hoooot hoooot!", "Hoooot!"), - "spirit_zamatrice_familiar" to listOf("Screeee screeee!", "Screeee!"), - "spirit_pengatrice_familiar" to listOf("Awk awk!", "Awk!"), - "spirit_coraxatrice_familiar" to listOf("Craaw craaw!", "Craaw!"), - "spirit_vulatrice_familiar" to listOf("Scraaaaw scraaaaw!", "Scraaaaw!"), - "barker_toad_familiar" to listOf("Braaaaap!", "Craaaaaaak!"), - "ibis_familiar" to listOf("Chirp!", "Twitter!"), - "swamp_titan_familiar" to listOf("Something smells clean...", "The power of stench compels you!"), - "spirit_mosquito_familiar" to listOf("Bzzzzzzzzz!", "Whiiiiiine!"), - "void_spinner_familiar" to listOf("Whiiiiiir!", "Whiiiir!"), - "forge_regent_familiar" to listOf("Sizzle!", "Crackle!"), - "spirit_larupia_familiar" to listOf("Hssssss!", "Ssssss!"), - "geyser_titan_familiar" to listOf("Pah!", "Flee from me!"), - "lava_titan_familiar" to listOf("Sacred flames...", "By my fire..."), - "steel_titan_familiar" to listOf("Stand back!", "I Am Legend!"), - "obsidian_golem_familiar" to listOf("Onward to glory!", "Stand back!"), - "talon_beast_familiar" to listOf("Growwwl!", "Mrowl!"), - "abyssal_titan_familiar" to listOf("Mwanu b'ruum!", "T'verah hoshay!"), - "void_torcher_familiar" to listOf("Scree!", "Squaa!"), - "giant_chinchompa_familiar" to listOf("Squeek!", "Squeek squeek!"), - "fire_titan_familiar" to listOf("Come on and fight!", "Hey, tiny!"), - "moss_titan_familiar" to listOf("Don't get me mad!", "Little soft humies!"), - "ice_titan_familiar" to listOf("I'm melting!", "It's so warm here!"), - "spirit_tz-kih_familiar" to listOf("Squee!", "Squee squee!"), - "spirit_graahk_familiar" to listOf("Howl!", "Rowr!"), - "spirit_kyatt_familiar" to listOf("Grrrrrr!", "Mrrrowl!"), - "void_shifter_familiar" to listOf("Chitter!", "Scree!"), - "pyrelord_familiar" to listOf("Whoosh!", "Crackle!"), - "void_ravager_familiar" to listOf("Rarf!", "Grrrrr!"), - "ravenous_locust_familiar" to listOf("Whiiiiiine!", "Click!"), - "iron_titan_familiar" to listOf("Victory!", "Crush them!"), - "meerkats_familiar" to listOf("Squeak squeak!", "Simples!"), - "clay_familiar_class_1_familiar" to listOf("Rumble!", "Rumble, rumble!"), - "clay_familiar_class_2_familiar" to listOf("Rumble!", "Rumble, rumble!"), - "clay_familiar_class_3_familiar" to listOf("Rumble!", "Rumble, rumble!"), - "clay_familiar_class_4_familiar" to listOf("Rumble!", "Rumble, rumble!"), - "clay_familiar_class_5_familiar" to listOf("Rumble!", "Rumble, rumble!"), -) diff --git a/game/src/main/kotlin/content/skill/summoning/SummoningTimers.kt b/game/src/main/kotlin/content/skill/summoning/SummoningTimers.kt index 8eb8008a78..dfe6a933b9 100644 --- a/game/src/main/kotlin/content/skill/summoning/SummoningTimers.kt +++ b/game/src/main/kotlin/content/skill/summoning/SummoningTimers.kt @@ -2,6 +2,7 @@ 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 @@ -21,7 +22,7 @@ class SummoningTimers : Script { timerTick("familiar_timer") { follower?.let { familiar -> - val lines = FAMILIAR_OVERHEAD[familiar.id] + val lines = Rows.getOrNull("familiar_overhead.${familiar.id}")?.stringListOrNull("lines") if (!lines.isNullOrEmpty()) { familiar.say(lines[random.nextInt(lines.size)]) } From c52f6340853d02a172275ac364f80635fead45fd Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Mon, 29 Jun 2026 07:02:09 -0700 Subject: [PATCH 015/136] Play per-familiar spawn animation on summon and call Familiars now play their own "fade into existence" animation (alongside the existing summon graphic) when summoned and when called. The per-familiar spawn anims are ported from darkanrs' Pouch spawnAnim values, mapped by npc id, as named _spawn animation defs. --- .../summoning/summoning_spawn.anims.toml | 429 ++++++++++++++++++ .../content/skill/summoning/Summoning.kt | 2 + 2 files changed, 431 insertions(+) create mode 100644 data/skill/summoning/summoning_spawn.anims.toml diff --git a/data/skill/summoning/summoning_spawn.anims.toml b/data/skill/summoning/summoning_spawn.anims.toml new file mode 100644 index 0000000000..6aca8c698f --- /dev/null +++ b/data/skill/summoning/summoning_spawn.anims.toml @@ -0,0 +1,429 @@ +[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 + +[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/game/src/main/kotlin/content/skill/summoning/Summoning.kt b/game/src/main/kotlin/content/skill/summoning/Summoning.kt index 48921e90b4..2d381cc576 100644 --- a/game/src/main/kotlin/content/skill/summoning/Summoning.kt +++ b/game/src/main/kotlin/content/skill/summoning/Summoning.kt @@ -70,6 +70,7 @@ fun Player.summonFamiliar(familiar: NPCDefinition, restart: Boolean) { queue("summon_familiar", 2) { follower = familiarNpc familiarNpc["owner_index"] = index + familiarNpc.anim("${familiarNpc.id.removeSuffix("_familiar")}_spawn") familiarNpc.gfx("summon_familiar_size_${familiarNpc.size}") updateFamiliarInterface() if (!restart) { @@ -176,6 +177,7 @@ 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) From 1e17631c566807ef75ddd5c0f799a19904f88426 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Mon, 29 Jun 2026 07:30:38 -0700 Subject: [PATCH 016/136] Add iron and steel titan talk dialogue Split the grouped IronSteelTitan stub (a single grunt) into separate IronTitan and SteelTitan scripts with their full random talk dialogue, ported from the RuneScape Wiki transcripts (iron titan: 4 conversations; steel titan: 5). --- .../summoning/familiar/IronSteelTitan.kt | 13 ----- .../skill/summoning/familiar/IronTitan.kt | 56 +++++++++++++++++++ .../skill/summoning/familiar/SteelTitan.kt | 51 +++++++++++++++++ 3 files changed, 107 insertions(+), 13 deletions(-) delete mode 100644 game/src/main/kotlin/content/skill/summoning/familiar/IronSteelTitan.kt create mode 100644 game/src/main/kotlin/content/skill/summoning/familiar/IronTitan.kt create mode 100644 game/src/main/kotlin/content/skill/summoning/familiar/SteelTitan.kt diff --git a/game/src/main/kotlin/content/skill/summoning/familiar/IronSteelTitan.kt b/game/src/main/kotlin/content/skill/summoning/familiar/IronSteelTitan.kt deleted file mode 100644 index 274218a439..0000000000 --- a/game/src/main/kotlin/content/skill/summoning/familiar/IronSteelTitan.kt +++ /dev/null @@ -1,13 +0,0 @@ -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 IronSteelTitan : Script { - init { - npcOperate("Interact", "iron_titan_familiar,steel_titan_familiar") { - npc("Brrrrrr...") - } - } -} 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/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!") + } + } + } + } +} From b5c77b5e712113da296cf40a5173f221d625df9f Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Mon, 29 Jun 2026 08:58:36 -0700 Subject: [PATCH 017/136] Add albino rat spawn animation (best-guess 8545) darkanrs' albino rat spawn anim (16080) is outside void's animation cache. The albino rat reuses the generic rat skeleton (Base 1152) for its render anims; that skeleton's summoning-era animation is 8545 - matching how arctic bear (8522, bear skeleton) and the bats (8279, bat skeleton) take their spawn anim from their skeleton's list. 8545 is in cache range, so all 144 familiars now have a spawn animation. --- data/skill/summoning/summoning_spawn.anims.toml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/data/skill/summoning/summoning_spawn.anims.toml b/data/skill/summoning/summoning_spawn.anims.toml index 6aca8c698f..d50048b3cd 100644 --- a/data/skill/summoning/summoning_spawn.anims.toml +++ b/data/skill/summoning/summoning_spawn.anims.toml @@ -28,6 +28,11 @@ id = 13676 [adept_worldbearer_spawn] id = 13684 +# Best-guess: the rat skeleton's summoning-era animation (8545), since albino rat +# reuses the generic rat render anims. darkanrs' value (16080) is outside void's cache. +[albino_rat_spawn] +id = 8545 + [arctic_bear_spawn] id = 8522 From 048c17e8563feae2fa87963a03d077e64fd70e9a Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Mon, 29 Jun 2026 08:58:36 -0700 Subject: [PATCH 018/136] Add albino rat spawn animation (best-guess 8545) albino rat spawn anim (16080) is outside 634 cache animation boundry. The albino rat reuses the generic rat skeleton (Base 1152) for its render anims; that skeleton's summoning-era animation is 8545 - matching how arctic bear (8522, bear skeleton) and the bats (8279, bat skeleton) take their spawn anim from their skeleton's list. 8545 is in cache range, so all 144 familiars now have a spawn animation. --- data/skill/summoning/summoning_spawn.anims.toml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/data/skill/summoning/summoning_spawn.anims.toml b/data/skill/summoning/summoning_spawn.anims.toml index 6aca8c698f..d50048b3cd 100644 --- a/data/skill/summoning/summoning_spawn.anims.toml +++ b/data/skill/summoning/summoning_spawn.anims.toml @@ -28,6 +28,11 @@ id = 13676 [adept_worldbearer_spawn] id = 13684 +# Best-guess: the rat skeleton's summoning-era animation (8545), since albino rat +# reuses the generic rat render anims. darkanrs' value (16080) is outside void's cache. +[albino_rat_spawn] +id = 8545 + [arctic_bear_spawn] id = 8522 From 8e71d1265f56a7ba705932926663839cd08e9248 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Mon, 29 Jun 2026 10:32:49 -0700 Subject: [PATCH 019/136] Remove incorrect Albino rat familiar spawn animation. --- data/skill/summoning/summoning_spawn.anims.toml | 5 ----- 1 file changed, 5 deletions(-) diff --git a/data/skill/summoning/summoning_spawn.anims.toml b/data/skill/summoning/summoning_spawn.anims.toml index d50048b3cd..6aca8c698f 100644 --- a/data/skill/summoning/summoning_spawn.anims.toml +++ b/data/skill/summoning/summoning_spawn.anims.toml @@ -28,11 +28,6 @@ id = 13676 [adept_worldbearer_spawn] id = 13684 -# Best-guess: the rat skeleton's summoning-era animation (8545), since albino rat -# reuses the generic rat render anims. darkanrs' value (16080) is outside void's cache. -[albino_rat_spawn] -id = 8545 - [arctic_bear_spawn] id = 8522 From e3389937c2d81119a3214782f5739f77544f793c Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Mon, 29 Jun 2026 16:49:45 -0700 Subject: [PATCH 020/136] Let familiars assist in single-way combat and grant owner xp A player and its familiar count as one side, so they may both attack the same target in single-way combat: - Target.attackable exempts the owner<->familiar pair from the single-combat gate. - FamiliarCombat lets the familiar be sent at an NPC outside multi-combat when it's the owner's own target (offensive/defensive assist). Combat familiars now also grant their owner combat xp for the damage they deal - the owner's chosen melee style skill (or the familiar's range/magic type) plus Constitution - mirroring CombatExperience. --- .../kotlin/content/entity/combat/Target.kt | 22 ++- .../entity/player/combat/CombatExperience.kt | 25 +++ .../content/skill/summoning/FamiliarCombat.kt | 142 ++++++++++++++++++ 3 files changed, 188 insertions(+), 1 deletion(-) create mode 100644 game/src/main/kotlin/content/skill/summoning/FamiliarCombat.kt diff --git a/game/src/main/kotlin/content/entity/combat/Target.kt b/game/src/main/kotlin/content/entity/combat/Target.kt index f80f3fee7f..6c2db62af5 100644 --- a/game/src/main/kotlin/content/entity/combat/Target.kt +++ b/game/src/main/kotlin/content/entity/combat/Target.kt @@ -39,6 +39,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) { @@ -93,7 +97,9 @@ object Target { return true } // If the target I'm trying to attack is already in combat and I am not the attacker - if (target.inSingleCombat && target.underAttack && target.attacker != source) { + // (or its attacker's familiar / the familiar's owner - a player and its familiar fight + // as one side, so they may both attack the same target in single-way combat). + if (target.inSingleCombat && target.underAttack && target.attacker != source && !alliedOwnerFamiliar(source, target.attacker)) { if (message) { if (target is NPC) { (source as? Player)?.message("Someone else is fighting that.") @@ -112,6 +118,20 @@ object Target { return true } + /** + * Whether [a] and [b] are a player and its own summoning familiar (in either order). Such a + * pair counts as a single side for single-way combat, so both may attack the same target. + */ + private fun alliedOwnerFamiliar(a: Character?, b: Character?): Boolean { + if (a == null || b == null) { + return false + } + if (a is NPC && b is Player && a["owner_index", -1] == b.index) { + return true + } + return b is NPC && a is Player && b["owner_index", -1] == a.index + } + fun isDemon(target: Character) = target is NPC && target.categories.contains("demons") fun isVampyre(target: Character) = target is NPC && target.categories.contains("vampyres") 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 200ce227ff..88af9796bf 100644 --- a/game/src/main/kotlin/content/entity/player/combat/CombatExperience.kt +++ b/game/src/main/kotlin/content/entity/player/combat/CombatExperience.kt @@ -9,6 +9,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 @@ -53,6 +54,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/skill/summoning/FamiliarCombat.kt b/game/src/main/kotlin/content/skill/summoning/FamiliarCombat.kt new file mode 100644 index 0000000000..5c7d6dd1b8 --- /dev/null +++ b/game/src/main/kotlin/content/skill/summoning/FamiliarCombat.kt @@ -0,0 +1,142 @@ +package content.skill.summoning + +import content.area.wilderness.inMultiCombat +import content.area.wilderness.inPvp +import content.area.wilderness.inWilderness +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.client.instruction.handle.interactNpc +import world.gregs.voidps.engine.client.instruction.handle.interactPlayer +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], enforcing authentic combat rules: familiars + * may only attack players in PvP areas and NPCs in multi-combat zones. [silent] suppresses the + * rejection messages (used by 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) { + is Player -> if (!inPvp || !target.inPvp) { + if (!silent) message("You can only attack players in a player-vs-player area.") + return + } + is NPC -> { + // Familiars attack freely in multi-combat zones. In single-way combat they may only + // assist: attack the same NPC the owner is fighting, or one already fighting the owner. + val assisting = this.target == target || target.target == this + if (!assisting && (!inMultiCombat || !target.inMultiCombat)) { + if (!silent) message("You can only use your familiar in a multi-zone area.") + return + } + } + } + when (target) { + is NPC -> familiar.interactNpc(target, "Attack") + is Player -> familiar.interactPlayer(target, "Attack") + } +} + +/** + * 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 + 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) + } + } + + variableSet("in_wilderness") { _, _, _ -> + updateFamiliarPvpForm() + } + } +} From d3c7afbb7d295ad4101081133c0fb4a4abd4df04 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Mon, 29 Jun 2026 17:18:42 -0700 Subject: [PATCH 021/136] Make familiars path to and chase their combat target Familiars wouldn't approach a target they were sent at - they stood frozen and only attacked if it was already in range. Two causes: - Movement only ran pathfinding for players; NPCs used cheap single-step line movement that can't route around obstacles. Owned familiars now pathfind like players so they actually walk to the target. - CombatMovement applied the spawn/aggro leash to familiars, so a target beyond their ~9-tile aggro range (e.g. one commanded up to 16 tiles away) dropped them straight to EmptyMode. Owned familiars are now exempt from the leash - they chase what their owner directs and fall back to following when the fight ends. --- .../character/mode/combat/CombatMovement.kt | 19 ++++- .../entity/character/mode/move/Movement.kt | 5 +- .../entity/combat/CombatMovementTest.kt | 75 +++++++++++++++++++ 3 files changed, 97 insertions(+), 2 deletions(-) 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..4f6ceeb9e4 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 @@ -51,7 +52,10 @@ class CombatMovement( character.mode = EmptyMode return } - if (character is NPC) { + if (character is NPC && character["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. val spawn: Tile = character["spawn_tile"] ?: return val definition = get().get(character.transformDef["combat_def", character.id]) if (!withinAggro(this.target, spawn, definition)) { @@ -130,6 +134,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/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/game/src/test/kotlin/content/entity/combat/CombatMovementTest.kt b/game/src/test/kotlin/content/entity/combat/CombatMovementTest.kt index a911d14b8b..a2affeee63 100644 --- a/game/src/test/kotlin/content/entity/combat/CombatMovementTest.kt +++ b/game/src/test/kotlin/content/entity/combat/CombatMovementTest.kt @@ -1,14 +1,17 @@ package content.entity.combat import WorldTest +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 world.gregs.voidps.engine.client.instruction.handle.interactNpc import world.gregs.voidps.engine.client.instruction.handle.interactPlayer 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 +142,78 @@ 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 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 `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 } From 07748a58fa11c006d207ecbba08d7b0d52eeb2d8 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Mon, 29 Jun 2026 17:27:28 -0700 Subject: [PATCH 022/136] Test familiar assists owner in single-way combat Locks in the Target.attackable exemption: in a single-way zone (one attacker per target) the owner and its familiar count as one side, so the familiar may still join the owner's fight. --- .../entity/combat/CombatMovementTest.kt | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/game/src/test/kotlin/content/entity/combat/CombatMovementTest.kt b/game/src/test/kotlin/content/entity/combat/CombatMovementTest.kt index a2affeee63..6acdb09c3b 100644 --- a/game/src/test/kotlin/content/entity/combat/CombatMovementTest.kt +++ b/game/src/test/kotlin/content/entity/combat/CombatMovementTest.kt @@ -157,6 +157,25 @@ internal class CombatMovementTest : WorldTest() { assertTrue(familiar.mode is CombatMovement) } + @Test + fun `Familiar assists owner against a single-way npc`() { + 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 + // No in_multi_combat flag: a single-way zone, which normally permits one attacker per + // target. The owner and its familiar count as one side, so the familiar may still join. + val target = createNPC("guard_falador", Tile(3032, 3351)) + + owner.npcOption(target, "Attack") + tick(8) + + assertTrue(familiar.mode is CombatMovement) + assertEquals(target, (familiar.mode as CombatMovement).target) + } + @Test fun `Familiar approaches a commanded target beyond its aggro range`() { val owner = createPlayer(Tile(3032, 3352)) From 56ed27bfd61b5af36b4207824917232f88ce9397 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Mon, 29 Jun 2026 17:36:44 -0700 Subject: [PATCH 023/136] Familiars only fight in multi-combat, not single-way Reverts the single-way assist allowance: combat familiars can only attack in multi-combat zones. In single-way combat the player can still use the familiar (storage, foraging, special moves) but it won't assist in the fight. Removes the owner/familiar single-combat exemption from Target.attackable and the single-way assist path in FamiliarCombat. --- .../kotlin/content/entity/combat/Target.kt | 18 +----------------- .../content/skill/summoning/FamiliarCombat.kt | 13 +++++-------- .../entity/combat/CombatMovementTest.kt | 9 ++++----- 3 files changed, 10 insertions(+), 30 deletions(-) diff --git a/game/src/main/kotlin/content/entity/combat/Target.kt b/game/src/main/kotlin/content/entity/combat/Target.kt index 6c2db62af5..28807d5b29 100644 --- a/game/src/main/kotlin/content/entity/combat/Target.kt +++ b/game/src/main/kotlin/content/entity/combat/Target.kt @@ -97,9 +97,7 @@ object Target { return true } // If the target I'm trying to attack is already in combat and I am not the attacker - // (or its attacker's familiar / the familiar's owner - a player and its familiar fight - // as one side, so they may both attack the same target in single-way combat). - if (target.inSingleCombat && target.underAttack && target.attacker != source && !alliedOwnerFamiliar(source, target.attacker)) { + if (target.inSingleCombat && target.underAttack && target.attacker != source) { if (message) { if (target is NPC) { (source as? Player)?.message("Someone else is fighting that.") @@ -118,20 +116,6 @@ object Target { return true } - /** - * Whether [a] and [b] are a player and its own summoning familiar (in either order). Such a - * pair counts as a single side for single-way combat, so both may attack the same target. - */ - private fun alliedOwnerFamiliar(a: Character?, b: Character?): Boolean { - if (a == null || b == null) { - return false - } - if (a is NPC && b is Player && a["owner_index", -1] == b.index) { - return true - } - return b is NPC && a is Player && b["owner_index", -1] == a.index - } - fun isDemon(target: Character) = target is NPC && target.categories.contains("demons") fun isVampyre(target: Character) = target is NPC && target.categories.contains("vampyres") diff --git a/game/src/main/kotlin/content/skill/summoning/FamiliarCombat.kt b/game/src/main/kotlin/content/skill/summoning/FamiliarCombat.kt index 5c7d6dd1b8..e91cc85053 100644 --- a/game/src/main/kotlin/content/skill/summoning/FamiliarCombat.kt +++ b/game/src/main/kotlin/content/skill/summoning/FamiliarCombat.kt @@ -47,14 +47,11 @@ fun Player.commandFamiliarAttack(target: Character, silent: Boolean = false) { if (!silent) message("You can only attack players in a player-vs-player area.") return } - is NPC -> { - // Familiars attack freely in multi-combat zones. In single-way combat they may only - // assist: attack the same NPC the owner is fighting, or one already fighting the owner. - val assisting = this.target == target || target.target == this - if (!assisting && (!inMultiCombat || !target.inMultiCombat)) { - if (!silent) message("You can only use your familiar in a multi-zone area.") - return - } + // Familiars can only fight in multi-combat zones; in single-way combat the player can + // still use the familiar (storage, foraging, specials) but it won't assist in the fight. + is NPC -> if (!inMultiCombat || !target.inMultiCombat) { + if (!silent) message("You can only use your familiar in a multi-zone area.") + return } } when (target) { diff --git a/game/src/test/kotlin/content/entity/combat/CombatMovementTest.kt b/game/src/test/kotlin/content/entity/combat/CombatMovementTest.kt index 6acdb09c3b..c5ffcd1ccb 100644 --- a/game/src/test/kotlin/content/entity/combat/CombatMovementTest.kt +++ b/game/src/test/kotlin/content/entity/combat/CombatMovementTest.kt @@ -158,22 +158,21 @@ internal class CombatMovementTest : WorldTest() { } @Test - fun `Familiar assists owner against a single-way npc`() { + 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 - // No in_multi_combat flag: a single-way zone, which normally permits one attacker per - // target. The owner and its familiar count as one side, so the familiar may still join. + // 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) - assertTrue(familiar.mode is CombatMovement) - assertEquals(target, (familiar.mode as CombatMovement).target) + assertFalse(familiar.mode is CombatMovement) } @Test From 77ed45bfa79296c0fe9f993133f828d28f819bae Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Mon, 29 Jun 2026 18:40:20 -0700 Subject: [PATCH 024/136] Familiars fight solo in single-way, can't share a target Player and familiar are separate attackers under the single-way "one attacker per target" rule: - commandFamiliarAttack: drop the multi-combat-only gate so a familiar can be ordered to attack in single-way, but pre-check Target.attackable so it can't pile onto an NPC already under attack (e.g. one the owner is fighting), rejecting with a message instead. - assistFamiliar: auto-join only in multi-combat; in single-way the owner's target is theirs alone, so the familiar must be ordered at a separate NPC. Tests cover: ordered solo attack in single-way, no sharing from the player's side, no auto-assist in single-way, auto-assist in multi. --- .../content/skill/summoning/FamiliarCombat.kt | 21 ++++++++---- .../entity/combat/CombatMovementTest.kt | 33 +++++++++++++++++++ 2 files changed, 47 insertions(+), 7 deletions(-) diff --git a/game/src/main/kotlin/content/skill/summoning/FamiliarCombat.kt b/game/src/main/kotlin/content/skill/summoning/FamiliarCombat.kt index e91cc85053..84b7f259ed 100644 --- a/game/src/main/kotlin/content/skill/summoning/FamiliarCombat.kt +++ b/game/src/main/kotlin/content/skill/summoning/FamiliarCombat.kt @@ -3,6 +3,7 @@ package content.skill.summoning import content.area.wilderness.inMultiCombat import content.area.wilderness.inPvp 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 @@ -29,9 +30,10 @@ fun NPC.canFight(): Boolean { } /** - * Directs the player's familiar to attack [target], enforcing authentic combat rules: familiars - * may only attack players in PvP areas and NPCs in multi-combat zones. [silent] suppresses the - * rejection messages (used by auto-assist). + * Directs the player's familiar to attack [target]. A familiar may only attack players in PvP + * areas; 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 @@ -47,10 +49,10 @@ fun Player.commandFamiliarAttack(target: Character, silent: Boolean = false) { if (!silent) message("You can only attack players in a player-vs-player area.") return } - // Familiars can only fight in multi-combat zones; in single-way combat the player can - // still use the familiar (storage, foraging, specials) but it won't assist in the fight. - is NPC -> if (!inMultiCombat || !target.inMultiCombat) { - if (!silent) message("You can only use your familiar in a multi-zone area.") + // 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 } } @@ -66,6 +68,11 @@ fun Player.commandFamiliarAttack(target: Character, silent: Boolean = false) { */ 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 } diff --git a/game/src/test/kotlin/content/entity/combat/CombatMovementTest.kt b/game/src/test/kotlin/content/entity/combat/CombatMovementTest.kt index c5ffcd1ccb..691cbdf2b5 100644 --- a/game/src/test/kotlin/content/entity/combat/CombatMovementTest.kt +++ b/game/src/test/kotlin/content/entity/combat/CombatMovementTest.kt @@ -1,8 +1,10 @@ package content.entity.combat import WorldTest +import content.skill.summoning.commandFamiliarAttack import content.skill.summoning.follower import npcOption +import world.gregs.voidps.engine.client.variable.start import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertTrue @@ -157,6 +159,37 @@ internal class CombatMovementTest : WorldTest() { assertTrue(familiar.mode is CombatMovement) } + @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 `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)) From 2110c58aba8b356e66e6a610107de0a1a8494d17 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Mon, 29 Jun 2026 18:50:04 -0700 Subject: [PATCH 025/136] Test familiar walks to a distant ordered target in single-way Covers the approach path (interact -> combat) for a target beyond the familiar's approach range, the scenario that wasn't exercised by the adjacent-target ordered-attack test. --- .../entity/combat/CombatMovementTest.kt | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/game/src/test/kotlin/content/entity/combat/CombatMovementTest.kt b/game/src/test/kotlin/content/entity/combat/CombatMovementTest.kt index 691cbdf2b5..c764f6fd0a 100644 --- a/game/src/test/kotlin/content/entity/combat/CombatMovementTest.kt +++ b/game/src/test/kotlin/content/entity/combat/CombatMovementTest.kt @@ -176,6 +176,24 @@ internal class CombatMovementTest : WorldTest() { assertEquals(target, (familiar.mode as CombatMovement).target) } + @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)) From 5a6f36a91fb9d67dfe4696cb6d93d78ed5e1f344 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Mon, 29 Jun 2026 19:20:46 -0700 Subject: [PATCH 026/136] Keep familiar pursuing its target when its path is blocked When ordered/assisting, the familiar walked to its target via the interact-then-combat path. The interact mode gives up the instant a player or npc fully blocks the route (cantReach -> EmptyMode) and never re-paths once the obstruction clears, leaving the familiar frozen. Drive the familiar with CombatMovement directly instead: it re-paths every tick and (for npcs) never bails on cantReach, so the familiar routes around or waits out blockers and resumes as soon as they move. --- .../content/skill/summoning/FamiliarCombat.kt | 11 ++++--- .../entity/combat/CombatMovementTest.kt | 29 +++++++++++++++++++ 2 files changed, 34 insertions(+), 6 deletions(-) diff --git a/game/src/main/kotlin/content/skill/summoning/FamiliarCombat.kt b/game/src/main/kotlin/content/skill/summoning/FamiliarCombat.kt index 84b7f259ed..a382570a57 100644 --- a/game/src/main/kotlin/content/skill/summoning/FamiliarCombat.kt +++ b/game/src/main/kotlin/content/skill/summoning/FamiliarCombat.kt @@ -9,8 +9,6 @@ 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.client.instruction.handle.interactNpc -import world.gregs.voidps.engine.client.instruction.handle.interactPlayer import world.gregs.voidps.engine.data.definition.CombatDefinitions import world.gregs.voidps.engine.data.definition.NPCDefinitions import world.gregs.voidps.engine.entity.character.Character @@ -56,10 +54,11 @@ fun Player.commandFamiliarAttack(target: Character, silent: Boolean = false) { return } } - when (target) { - is NPC -> familiar.interactNpc(target, "Attack") - is Player -> familiar.interactPlayer(target, "Attack") - } + // 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) } /** diff --git a/game/src/test/kotlin/content/entity/combat/CombatMovementTest.kt b/game/src/test/kotlin/content/entity/combat/CombatMovementTest.kt index c764f6fd0a..2f87c4d9ef 100644 --- a/game/src/test/kotlin/content/entity/combat/CombatMovementTest.kt +++ b/game/src/test/kotlin/content/entity/combat/CombatMovementTest.kt @@ -176,6 +176,35 @@ internal class CombatMovementTest : WorldTest() { assertEquals(target, (familiar.mode as CombatMovement).target) } + @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)) From 0da075a17fe27253498d9cefcfb12cd85fbb4a56 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Mon, 29 Jun 2026 20:05:42 -0700 Subject: [PATCH 027/136] Let familiars chase a moving combat target NPCCharacterTargetStrategy.destination() returns Tile.EMPTY for npcs with an EMPTY walk mode. Familiars have that walk mode (they don't wander), so destination() always returned EMPTY and recalculate() short-circuited (EMPTY == cleared destination) - the familiar never re-pathed, following only its initial path. A stationary target worked; a target that moved/retreated left the familiar stuck at the old tile. Exempt owned familiars (owner_index set) from the stationary-npc short-circuit so they recompute a path to the target's current tile each tick and chase it. --- .../move/target/NPCCharacterTargetStrategy.kt | 4 +++- .../entity/combat/CombatMovementTest.kt | 19 +++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) 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/game/src/test/kotlin/content/entity/combat/CombatMovementTest.kt b/game/src/test/kotlin/content/entity/combat/CombatMovementTest.kt index 2f87c4d9ef..1a5f665e20 100644 --- a/game/src/test/kotlin/content/entity/combat/CombatMovementTest.kt +++ b/game/src/test/kotlin/content/entity/combat/CombatMovementTest.kt @@ -176,6 +176,25 @@ internal class CombatMovementTest : WorldTest() { assertEquals(target, (familiar.mode as CombatMovement).target) } + @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)) From c131733febdc2f373b955cf1e71042cf6c018c9f Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Mon, 29 Jun 2026 20:22:14 -0700 Subject: [PATCH 028/136] Familiar returns to following its owner when it can't reach its target Familiars persist in CombatMovement and never call cantReach, so an unreachable target (fled somewhere with no path) left them stuck chasing forever. Track ticks of no progress while still far from the target; if the familiar makes none for a short grace period it gives up and reverts to EmptyMode, which NPCTask turns back into following its owner. Moving, or being all but in range (just waiting on a free attack tile in a crowd), resets the grace period so genuine fights aren't abandoned. --- .../character/mode/combat/CombatMovement.kt | 23 +++++++++++++++++++ .../entity/combat/CombatMovementTest.kt | 18 +++++++++++++++ 2 files changed, 41 insertions(+) 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 4f6ceeb9e4..34cd7d88e6 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 @@ -31,6 +31,8 @@ class CombatMovement( var started = false + private var unreachableTicks = 0 + override fun start() { if (character is NPC) { character.steps.clear() @@ -73,6 +75,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 } @@ -93,6 +109,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 } @@ -125,6 +142,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. */ diff --git a/game/src/test/kotlin/content/entity/combat/CombatMovementTest.kt b/game/src/test/kotlin/content/entity/combat/CombatMovementTest.kt index 1a5f665e20..46b0d7b0e8 100644 --- a/game/src/test/kotlin/content/entity/combat/CombatMovementTest.kt +++ b/game/src/test/kotlin/content/entity/combat/CombatMovementTest.kt @@ -176,6 +176,24 @@ internal class CombatMovementTest : WorldTest() { 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)) From 08e5ddf12544272030a573e08cb6f48821fc25ae Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Mon, 29 Jun 2026 20:46:11 -0700 Subject: [PATCH 029/136] Credit a familiar's kill to its owner When a familiar dealt the killing blow it was the npc's killer, but the death handler only drops loot (and grants slayer / logs the kill) when the killer is a Player, so familiar kills dropped nothing. Resolve a familiar killer to its owner so the loot drops for the player. --- .../kotlin/content/entity/death/NPCDeath.kt | 5 ++++- .../content/entity/combat/CombatTest.kt | 19 +++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/game/src/main/kotlin/content/entity/death/NPCDeath.kt b/game/src/main/kotlin/content/entity/death/NPCDeath.kt index 8191a51254..45c6947394 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,9 @@ 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)?.let { Players.indexed(it["owner_index", -1]) } ?: 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/test/kotlin/content/entity/combat/CombatTest.kt b/game/src/test/kotlin/content/entity/combat/CombatTest.kt index 920fd8804a..47ec3f2d0c 100644 --- a/game/src/test/kotlin/content/entity/combat/CombatTest.kt +++ b/game/src/test/kotlin/content/entity/combat/CombatTest.kt @@ -2,6 +2,7 @@ package content.entity.combat import FakeRandom import WorldTest +import content.entity.combat.damageDealers import content.entity.player.effect.skull import equipItem import interfaceOption @@ -13,6 +14,7 @@ import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertNotNull import playerOption 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 +87,23 @@ 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 `Kill rat with range`() { setRandom(object : FakeRandom() { From 618d29d323b855f888fe8ca7e712f63a3d08d7e5 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Mon, 29 Jun 2026 20:53:50 -0700 Subject: [PATCH 030/136] Fix crash resolving familiar owner on non-familiar npc kills A plain npc killer has owner_index -1, and Players.indexed(-1) throws ArrayIndexOutOfBoundsException - so every npc-on-npc kill crashed the tick. Only resolve an owner when owner_index is set. --- .../main/kotlin/content/entity/death/NPCDeath.kt | 3 ++- .../kotlin/content/entity/combat/CombatTest.kt | 14 ++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/game/src/main/kotlin/content/entity/death/NPCDeath.kt b/game/src/main/kotlin/content/entity/death/NPCDeath.kt index 45c6947394..14bc8bb1ed 100644 --- a/game/src/main/kotlin/content/entity/death/NPCDeath.kt +++ b/game/src/main/kotlin/content/entity/death/NPCDeath.kt @@ -59,7 +59,8 @@ class NPCDeath( queue(name = "death", 1) { // 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)?.let { Players.indexed(it["owner_index", -1]) } ?: killer + 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/test/kotlin/content/entity/combat/CombatTest.kt b/game/src/test/kotlin/content/entity/combat/CombatTest.kt index 47ec3f2d0c..80f84c1235 100644 --- a/game/src/test/kotlin/content/entity/combat/CombatTest.kt +++ b/game/src/test/kotlin/content/entity/combat/CombatTest.kt @@ -104,6 +104,20 @@ internal class CombatTest : WorldTest() { assertEquals(owner.name, bones!!.owner) } + @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() { From 8bcd7b33c5fe4bc49c5c7c824ae78de5758ecfc1 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Mon, 29 Jun 2026 20:58:03 -0700 Subject: [PATCH 031/136] Add familiar combat foundation Combat data and wiring the familiar combat behaviour builds on: - summoning.combat.toml / summoning_combat.anims.toml / .gfx.toml: per- familiar combat definitions (stats, attack styles, anims, projectiles). - summoning.npcs.toml: combat stats and combat_def links per familiar. - gameframe.ifaces.toml: summoning orb / follower-details "Attack" option. - Summoning.kt: apply the wilderness pvp combat form on summon and call. - NPCTask.kt: an idle familiar resumes following its owner instead of wandering. - Combat.kt: retaliation anchors its leash via leashAnchor() (owner tile for familiars). - Attack.kt: resolve an npc's swing definition through combat_def. --- .../modal/toplevel/gameframe.ifaces.toml | 14 +- data/skill/summoning/summoning.combat.toml | 739 +++++++++ data/skill/summoning/summoning.npcs.toml | 1392 ++++++++++++++++- .../summoning/summoning_combat.anims.toml | 651 ++++++++ .../skill/summoning/summoning_combat.gfx.toml | 21 + .../voidps/engine/client/update/NPCTask.kt | 14 +- .../kotlin/content/entity/combat/Combat.kt | 3 +- .../content/entity/npc/combat/Attack.kt | 2 +- .../content/skill/summoning/Summoning.kt | 2 + 9 files changed, 2827 insertions(+), 11 deletions(-) create mode 100644 data/skill/summoning/summoning.combat.toml create mode 100644 data/skill/summoning/summoning_combat.anims.toml create mode 100644 data/skill/summoning/summoning_combat.gfx.toml 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/summoning.combat.toml b/data/skill/summoning/summoning.combat.toml new file mode 100644 index 0000000000..6309552cfc --- /dev/null +++ b/data/skill/summoning/summoning.combat.toml @@ -0,0 +1,739 @@ +# Familiar combat definitions, ported from Darkan combatdefs/familiars. +# attack_speed left default (4); tune per-familiar later. Special/scroll attacks out of scope. + +[spirit_wolf_familiar] +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] +retreat_range = 8 +defend_anim = "dreadfowl_familiar_defend" +death_anim = "dreadfowl_familiar_death" + +[dreadfowl_familiar.melee] +range = 1 +anim = "dreadfowl_familiar_attack" +projectile = "dreadfowl_familiar_projectile" +target_hit = { offense = "crush", max = 40 } + +[spirit_spider_familiar] +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] +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] +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] +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] +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] +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" +target_hit = { offense = "magic", max = 50 } + +[albino_rat_familiar] +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] +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] +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] +retreat_range = 8 +defend_anim = "giant_chinchompa_familiar_defend" +death_anim = "giant_chinchompa_familiar_death" + +[giant_chinchompa_familiar.range] +range = 7 +anim = "giant_chinchompa_familiar_attack" +target_hit = { offense = "range", max = 38 } + +[vampire_bat_familiar] +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] +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] +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] +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] +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] +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] +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" +target_hit = { offense = "crush", max = 47 } + +[iron_minotaur_familiar] +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" +target_hit = { offense = "crush", max = 60 } + +[steel_minotaur_familiar] +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" +target_hit = { offense = "crush", max = 73 } + +[mithril_minotaur_familiar] +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" +target_hit = { offense = "crush", max = 110 } + +[adamant_minotaur_familiar] +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" +target_hit = { offense = "crush", max = 160 } + +[rune_minotaur_familiar] +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" +target_hit = { offense = "crush", max = 180 } + +[bull_ant_familiar] +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] +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] +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] +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] +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] +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] +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] +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] +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] +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] +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] +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] +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] +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] +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] +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] +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] +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" +target_hit = { offense = "range", max = 76 } + +[smoke_devil_familiar] +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] +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] +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] +retreat_range = 8 +defend_anim = "stranger_plant_familiar_defend" +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] +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] +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] +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] +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] +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] +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] +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] +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] +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] +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] +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] +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] +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] +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] +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] +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] +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] +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] +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] +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] +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] +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] +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" +target_hit = { offense = "range", max = 190 } + +[abyssal_titan_familiar] +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] +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] +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" +target_hit = { offense = "range", max = 244 } diff --git a/data/skill/summoning/summoning.npcs.toml b/data/skill/summoning/summoning.npcs.toml index 42d847213e..2a61f40f09 100644 --- a/data/skill/summoning/summoning.npcs.toml +++ b/data/skill/summoning/summoning.npcs.toml @@ -1,10 +1,24 @@ [spirit_terrorbird_familiar] id = 6794 +hitpoints = 2330 +att = 47 +str = 35 +def = 37 +range = 47 +mage = 47 +combat_def = "spirit_terrorbird_familiar" large_head = true interacts = false [granite_crab_familiar] 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 large_head = true @@ -12,11 +26,25 @@ interacts = false [praying_mantis_familiar] id = 6798 +hitpoints = 4280 +att = 72 +str = 35 +def = 64 +range = 72 +mage = 72 +combat_def = "praying_mantis_familiar" large_head = true interacts = false [giant_ent_familiar] 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 large_head = true @@ -24,16 +52,37 @@ interacts = false [spirit_cobra_familiar] id = 6802 +hitpoints = 3140 +att = 57 +str = 35 +def = 57 +range = 57 +mage = 57 +combat_def = "spirit_cobra_familiar" large_head = true interacts = false [spirit_dagannoth_familiar] id = 6804 +hitpoints = 5280 +att = 75 +str = 35 +def = 67 +range = 75 +mage = 75 +combat_def = "spirit_dagannoth_familiar" large_head = true interacts = false [thorny_snail_familiar] id = 6806 +hitpoints = 280 +att = 1 +str = 1 +def = 15 +range = 15 +mage = 1 +combat_def = "thorny_snail_familiar" large_head = true interacts = false @@ -46,26 +95,61 @@ interacts = false [karamthulhu_overlord_familiar] id = 6809 +hitpoints = 2760 +att = 53 +str = 35 +def = 47 +range = 53 +mage = 53 +combat_def = "karamthulhu_overlord_familiar" large_head = true interacts = false [hydra_familiar] id = 6811 +hitpoints = 4900 +att = 72 +str = 35 +def = 64 +range = 72 +mage = 72 +combat_def = "hydra_familiar" large_head = true interacts = false [spirit_jelly_familiar] id = 6992 +hitpoints = 2550 +att = 50 +str = 35 +def = 44 +range = 50 +mage = 50 +combat_def = "spirit_jelly_familiar" large_head = true interacts = false [bunyip_familiar] id = 6813 +hitpoints = 400 +att = 72 +str = 35 +def = 64 +range = 72 +mage = 72 +combat_def = "bunyip_familiar" large_head = true interacts = false [war_tortoise_familiar] id = 6815 +hitpoints = 3480 +att = 72 +str = 35 +def = 64 +range = 72 +mage = 72 +combat_def = "war_tortoise_familiar" large_head = true interacts = false @@ -78,6 +162,13 @@ interacts = false [abyssal_parasite_familiar] id = 6818 +hitpoints = 2340 +att = 49 +str = 35 +def = 44 +range = 49 +mage = 49 +combat_def = "abyssal_parasite_familiar" large_head = true dialogue = "leech" interacts = false @@ -87,6 +178,13 @@ summoning_beast_of_burden_essence = 1 [abyssal_lurker_familiar] id = 6820 +hitpoints = 3080 +att = 56 +str = 35 +def = 50 +range = 56 +mage = 56 +combat_def = "abyssal_lurker_familiar" large_head = true dialogue = "bird" interacts = false @@ -96,6 +194,13 @@ summoning_beast_of_burden_essence = 1 [unicorn_stallion_familiar] id = 6822 +hitpoints = 1000 +att = 80 +str = 35 +def = 69 +range = 80 +mage = 80 +combat_def = "unicorn_stallion_familiar" large_head = true interacts = false @@ -108,11 +213,25 @@ summoning_beast_of_burden_capacity = 30 [dreadfowl_familiar] id = 6825 +hitpoints = 160 +att = 15 +str = 15 +def = 10 +range = 10 +mage = 15 +combat_def = "dreadfowl_familiar" large_head = true interacts = false [stranger_plant_familiar] 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 large_head = true @@ -120,17 +239,38 @@ interacts = false [spirit_wolf_familiar] id = 6829 +hitpoints = 150 +att = 10 +str = 10 +def = 10 +range = 10 +mage = 10 +combat_def = "spirit_wolf_familiar" large_head = true dialogue = "wolf" interacts = false [desert_wyrm_familiar] id = 6831 +hitpoints = 470 +att = 20 +str = 20 +def = 20 +range = 20 +mage = 20 +combat_def = "desert_wyrm_familiar" large_head = true interacts = false [evil_turnip_familiar] 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 large_head = true @@ -138,21 +278,49 @@ interacts = false [vampire_bat_familiar] id = 6835 +hitpoints = 1050 +att = 30 +str = 30 +def = 30 +range = 30 +mage = 30 +combat_def = "vampire_bat_familiar" large_head = true interacts = false [spirit_scorpion_familiar] id = 6837 +hitpoints = 670 +att = 20 +str = 20 +def = 20 +range = 20 +mage = 20 +combat_def = "spirit_scorpion_familiar" large_head = true interacts = false [arctic_bear_familiar] id = 6839 +hitpoints = 3810 +att = 72 +str = 35 +def = 64 +range = 72 +mage = 72 +combat_def = "arctic_bear_familiar" large_head = true interacts = false [spirit_spider_familiar] 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 large_head = true @@ -160,26 +328,61 @@ interacts = false [bloated_leech_familiar] id = 6843 +hitpoints = 2110 +att = 45 +str = 35 +def = 40 +range = 45 +mage = 45 +combat_def = "bloated_leech_familiar" large_head = true interacts = false [spirit_kalphite_familiar] id = 6994 +hitpoints = 770 +att = 25 +str = 25 +def = 25 +range = 25 +mage = 25 +combat_def = "spirit_kalphite_familiar" large_head = true interacts = false [honey_badger_familiar] id = 6845 +hitpoints = 1100 +att = 29 +str = 30 +def = 26 +range = 29 +mage = 29 +combat_def = "honey_badger_familiar" large_head = true interacts = false [albino_rat_familiar] id = 6847 +hitpoints = 680 +att = 22 +str = 22 +def = 22 +range = 22 +mage = 22 +combat_def = "albino_rat_familiar" large_head = true interacts = false [granite_lobster_familiar] 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 large_head = true @@ -194,51 +397,121 @@ interacts = false [bronze_minotaur_familiar] id = 6853 +hitpoints = 1330 +att = 33 +str = 30 +def = 28 +range = 33 +mage = 33 +combat_def = "bronze_minotaur_familiar" large_head = true interacts = false [iron_minotaur_familiar] id = 6855 +hitpoints = 1930 +att = 42 +str = 35 +def = 37 +range = 42 +mage = 42 +combat_def = "iron_minotaur_familiar" large_head = true interacts = false [steel_minotaur_familiar] id = 6857 +hitpoints = 2600 +att = 51 +str = 35 +def = 44 +range = 51 +mage = 51 +combat_def = "steel_minotaur_familiar" large_head = true interacts = false [mithril_minotaur_familiar] id = 6859 +hitpoints = 3400 +att = 60 +str = 35 +def = 53 +range = 60 +mage = 60 +combat_def = "mithril_minotaur_familiar" large_head = true interacts = false [adamant_minotaur_familiar] id = 6861 +hitpoints = 4410 +att = 72 +str = 35 +def = 64 +range = 72 +mage = 72 +combat_def = "adamant_minotaur_familiar" large_head = true interacts = false [rune_minotaur_familiar] id = 6863 +hitpoints = 5700 +att = 80 +str = 35 +def = 69 +range = 80 +mage = 80 +combat_def = "rune_minotaur_familiar" large_head = true interacts = false [smoke_devil_familiar] id = 6865 +hitpoints = 3000 +att = 55 +str = 35 +def = 49 +range = 55 +mage = 55 +combat_def = "smoke_devil_familiar" large_head = true interacts = false [bull_ant_familiar] id = 6867 +hitpoints = 1540 +att = 36 +str = 30 +def = 32 +range = 36 +mage = 36 +combat_def = "bull_ant_familiar" large_head = true interacts = false [wolpertinger_familiar] id = 6869 +hitpoints = 6510 +att = 85 +str = 35 +def = 72 +range = 85 +mage = 85 +combat_def = "wolpertinger_familiar" large_head = true interacts = false [compost_mound_familiar] 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 large_head = true @@ -246,11 +519,25 @@ interacts = false [pack_yak_familiar] id = 6873 +hitpoints = 7100 +att = 87 +str = 87 +def = 77 +range = 87 +mage = 87 +combat_def = "pack_yak_familiar" large_head = true interacts = false [spirit_cockatrice_familiar] 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 large_head = true @@ -258,6 +545,13 @@ interacts = false [spirit_guthatrice_familiar] 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 large_head = true @@ -265,6 +559,13 @@ interacts = false [spirit_saratrice_familiar] 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 large_head = true @@ -272,6 +573,13 @@ interacts = false [spirit_zamatrice_familiar] 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 large_head = true @@ -279,6 +587,13 @@ interacts = false [spirit_pengatrice_familiar] 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 large_head = true @@ -286,6 +601,13 @@ interacts = false [spirit_coraxatrice_familiar] 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 large_head = true @@ -293,6 +615,13 @@ interacts = false [spirit_vulatrice_familiar] 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 large_head = true @@ -300,6 +629,13 @@ interacts = false [barker_toad_familiar] id = 6889 +hitpoints = 3400 +att = 60 +str = 35 +def = 53 +range = 60 +mage = 60 +combat_def = "barker_toad_familiar" large_head = true interacts = false @@ -312,56 +648,133 @@ interacts = false [swamp_titan_familiar] id = 7329 +hitpoints = 5660 +att = 77 +str = 35 +def = 67 +range = 77 +mage = 77 +combat_def = "swamp_titan_familiar" large_head = true interacts = false [spirit_mosquito_familiar] id = 7331 +hitpoints = 430 +att = 5 +str = 1 +def = 45 +range = 1 +mage = 1 +combat_def = "spirit_mosquito_familiar" large_head = true interacts = false [void_spinner_familiar] id = 7333 +hitpoints = 590 +att = 31 +str = 30 +def = 28 +range = 31 +mage = 31 +combat_def = "void_spinner_familiar" large_head = true interacts = false [forge_regent_familiar] id = 7335 +hitpoints = 4410 +att = 72 +str = 35 +def = 64 +range = 72 +mage = 72 +combat_def = "forge_regent_familiar" large_head = true interacts = false [spirit_larupia_familiar] id = 7337 +hitpoints = 2680 +att = 52 +str = 35 +def = 46 +range = 52 +mage = 52 +combat_def = "spirit_larupia_familiar" large_head = true interacts = false [geyser_titan_familiar] id = 7339 +hitpoints = 6100 +att = 85 +str = 35 +def = 72 +range = 85 +mage = 85 +combat_def = "geyser_titan_familiar" large_head = true interacts = false [lava_titan_familiar] id = 7341 +hitpoints = 5280 +att = 75 +str = 35 +def = 67 +range = 75 +mage = 75 +combat_def = "lava_titan_familiar" large_head = true interacts = false [steel_titan_familiar] id = 7343 +hitpoints = 7540 +att = 130 +str = 130 +def = 130 +range = 130 +mage = 130 +combat_def = "steel_titan_familiar" large_head = true interacts = false [obsidian_golem_familiar] id = 7345 +hitpoints = 4060 +att = 72 +str = 35 +def = 64 +range = 72 +mage = 72 +combat_def = "obsidian_golem_familiar" large_head = true interacts = false [talon_beast_familiar] id = 7347 +hitpoints = 4540 +att = 72 +str = 35 +def = 64 +range = 72 +mage = 72 +combat_def = "talon_beast_familiar" large_head = true interacts = false [abyssal_titan_familiar] id = 7349 +hitpoints = 6670 +att = 100 +str = 100 +def = 100 +range = 100 +mage = 100 +combat_def = "abyssal_titan_familiar" large_head = true interacts = false summoning_beast_of_burden = 1 @@ -370,56 +783,133 @@ summoning_beast_of_burden_essence = 1 [void_torcher_familiar] id = 7351 +hitpoints = 1210 +att = 31 +str = 30 +def = 28 +range = 31 +mage = 31 +combat_def = "void_torcher_familiar" large_head = true interacts = false [giant_chinchompa_familiar] id = 7353 +hitpoints = 970 +att = 25 +str = 25 +def = 25 +range = 25 +mage = 25 +combat_def = "giant_chinchompa_familiar" large_head = true interacts = false [fire_titan_familiar] id = 7355 +hitpoints = 4760 +att = 72 +str = 35 +def = 64 +range = 72 +mage = 72 +combat_def = "fire_titan_familiar" large_head = true interacts = false [moss_titan_familiar] id = 7357 +hitpoints = 4760 +att = 72 +str = 35 +def = 64 +range = 72 +mage = 72 +combat_def = "moss_titan_familiar" large_head = true interacts = false [ice_titan_familiar] id = 7359 +hitpoints = 4760 +att = 72 +str = 35 +def = 64 +range = 72 +mage = 72 +combat_def = "ice_titan_familiar" large_head = true interacts = false [spirit_tz-kih_familiar] id = 7361 +hitpoints = 630 +att = 20 +str = 20 +def = 20 +range = 20 +mage = 20 +combat_def = "spirit_tz-kih_familiar" large_head = true interacts = false [spirit_graahk_familiar] id = 7363 +hitpoints = 2680 +att = 52 +str = 35 +def = 46 +range = 52 +mage = 52 +combat_def = "spirit_graahk_familiar" large_head = true interacts = false [spirit_kyatt_familiar] id = 7365 +hitpoints = 2680 +att = 52 +str = 35 +def = 46 +range = 52 +mage = 52 +combat_def = "spirit_kyatt_familiar" large_head = true interacts = false [void_shifter_familiar] id = 7367 +hitpoints = 1210 +att = 31 +str = 30 +def = 28 +range = 31 +mage = 31 +combat_def = "void_shifter_familiar" large_head = true interacts = false [pyrelord_familiar] id = 7377 +hitpoints = 1930 +att = 60 +str = 40 +def = 30 +range = 1 +mage = 1 +combat_def = "pyrelord_familiar" large_head = true interacts = false [void_ravager_familiar] 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 large_head = true @@ -427,11 +917,25 @@ interacts = false [ravenous_locust_familiar] id = 7372 +hitpoints = 3700 +att = 72 +str = 35 +def = 64 +range = 72 +mage = 72 +combat_def = "ravenous_locust_familiar" large_head = true interacts = false [iron_titan_familiar] id = 7375 +hitpoints = 6940 +att = 120 +str = 120 +def = 120 +range = 120 +mage = 120 +combat_def = "iron_titan_familiar" large_head = true interacts = false @@ -462,6 +966,13 @@ interacts = false [phoenix_familiar] id = 8575 +hitpoints = 1530 +att = 72 +str = 35 +def = 64 +range = 72 +mage = 72 +combat_def = "phoenix_familiar" large_head = true interacts = false @@ -769,4 +1280,883 @@ interacts = false id = 11640 large_head = true interacts = false -familiar_time = 40 \ No newline at end of file +familiar_time = 40 + + +# --- PvP combat variants (id+1, the 'Attack'-option form shown in PvP areas) --- + +[spirit_wolf_familiar_combat] +id = 6830 +large_head = true +interacts = false +hitpoints = 150 +att = 10 +str = 10 +def = 10 +range = 10 +mage = 10 +combat_def = "spirit_wolf_familiar" + +[dreadfowl_familiar_combat] +id = 6826 +large_head = true +interacts = false +hitpoints = 160 +att = 15 +str = 15 +def = 10 +range = 10 +mage = 15 +combat_def = "dreadfowl_familiar" + +[spirit_spider_familiar_combat] +id = 6842 +large_head = true +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 +large_head = true +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 +large_head = true +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 +large_head = true +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 +large_head = true +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 +large_head = true +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 +large_head = true +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 +large_head = true +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 +large_head = true +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 +large_head = true +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 +large_head = true +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 +large_head = true +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 +large_head = true +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 +large_head = true +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 +large_head = true +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 +large_head = true +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 +large_head = true +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 +large_head = true +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 +large_head = true +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 +large_head = true +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 +large_head = true +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 +large_head = true +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 +large_head = true +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 +large_head = true +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 +large_head = true +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 +large_head = true +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 +large_head = true +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 +large_head = true +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 +large_head = true +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 +large_head = true +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 +large_head = true +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 +large_head = true +interacts = false +hitpoints = 1730 +att = 39 +str = 35 +def = 35 +range = 39 +mage = 39 +combat_def = "spirit_vulatrice_familiar" + +[pyrelord_familiar_combat] +id = 7378 +large_head = true +interacts = false +hitpoints = 1930 +att = 60 +str = 40 +def = 30 +range = 1 +mage = 1 +combat_def = "pyrelord_familiar" + +[bloated_leech_familiar_combat] +id = 6844 +large_head = true +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 +large_head = true +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 +large_head = true +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 +large_head = true +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 +large_head = true +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 +large_head = true +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 +large_head = true +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 +large_head = true +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 +large_head = true +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 +large_head = true +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 +large_head = true +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 +large_head = true +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 +large_head = true +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 +large_head = true +interacts = false +hitpoints = 3480 +att = 72 +str = 35 +def = 64 +range = 72 +mage = 72 +combat_def = "war_tortoise_familiar" + +[bunyip_familiar_combat] +id = 6814 +large_head = true +interacts = false +hitpoints = 400 +att = 72 +str = 35 +def = 64 +range = 72 +mage = 72 +combat_def = "bunyip_familiar" + +[ravenous_locust_familiar_combat] +id = 7373 +large_head = true +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 +large_head = true +interacts = false +hitpoints = 3810 +att = 72 +str = 35 +def = 64 +range = 72 +mage = 72 +combat_def = "arctic_bear_familiar" + +[phoenix_familiar_combat] +id = 8576 +large_head = true +interacts = false +hitpoints = 1530 +att = 72 +str = 35 +def = 64 +range = 72 +mage = 72 +combat_def = "phoenix_familiar" + +[obsidian_golem_familiar_combat] +id = 7346 +large_head = true +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 +large_head = true +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 +large_head = true +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 +large_head = true +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 +large_head = true +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 +large_head = true +interacts = false +hitpoints = 4670 +att = 72 +str = 35 +def = 64 +range = 72 +mage = 72 +combat_def = "giant_ent_familiar" + +[hydra_familiar_combat] +id = 6812 +large_head = true +interacts = false +hitpoints = 4900 +att = 72 +str = 35 +def = 64 +range = 72 +mage = 72 +combat_def = "hydra_familiar" + +[spirit_dagannoth_familiar_combat] +id = 6805 +large_head = true +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 +large_head = true +interacts = false +hitpoints = 1000 +att = 80 +str = 35 +def = 69 +range = 80 +mage = 80 +combat_def = "unicorn_stallion_familiar" + +[wolpertinger_familiar_combat] +id = 6870 +large_head = true +interacts = false +hitpoints = 6510 +att = 85 +str = 35 +def = 72 +range = 85 +mage = 85 +combat_def = "wolpertinger_familiar" + +[pack_yak_familiar_combat] +id = 6874 +large_head = true +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 +large_head = true +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 +large_head = true +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 +large_head = true +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 +large_head = true +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 +large_head = true +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 +large_head = true +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 +large_head = true +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 +large_head = true +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 +large_head = true +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_combat.anims.toml b/data/skill/summoning/summoning_combat.anims.toml new file mode 100644 index 0000000000..683b5aca78 --- /dev/null +++ b/data/skill/summoning/summoning_combat.anims.toml @@ -0,0 +1,651 @@ +[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 = 7963 + +[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 = 8292 + +[stranger_plant_familiar_defend] +id = 8294 + +[stranger_plant_familiar_death] +id = 8295 + +[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 = 5327 + +[arctic_bear_familiar_defend] +id = 5328 + +[arctic_bear_familiar_death] +id = 5329 + +[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 = 853 + +[pack_yak_familiar_defend] +id = 851 + +[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 = 8183 + +[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..c2f742127d --- /dev/null +++ b/data/skill/summoning/summoning_combat.gfx.toml @@ -0,0 +1,21 @@ +[dreadfowl_familiar_projectile] +id = 1318 + +[thorny_snail_familiar_projectile] +id = 1380 + +[thorny_snail_familiar_attack_gfx] +id = 1379 + +[smoke_devil_familiar_projectile] +id = 1376 + +[forge_regent_familiar_projectile] +id = 1330 + +[hydra_familiar_projectile] +id = 1488 + +[wolpertinger_familiar_projectile] +id = 2733 + 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 3daf9bc7a9..a048aaeff2 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/game/src/main/kotlin/content/entity/combat/Combat.kt b/game/src/main/kotlin/content/entity/combat/Combat.kt index 92b9b25c44..fd3de6227c 100644 --- a/game/src/main/kotlin/content/entity/combat/Combat.kt +++ b/game/src/main/kotlin/content/entity/combat/Combat.kt @@ -17,6 +17,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 @@ -131,7 +132,7 @@ class Combat(val combatDefinitions: CombatDefinitions) : if (character is NPC) { // 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) 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/skill/summoning/Summoning.kt b/game/src/main/kotlin/content/skill/summoning/Summoning.kt index 2d381cc576..91f57e3e4c 100644 --- a/game/src/main/kotlin/content/skill/summoning/Summoning.kt +++ b/game/src/main/kotlin/content/skill/summoning/Summoning.kt @@ -72,6 +72,7 @@ fun Player.summonFamiliar(familiar: NPCDefinition, restart: Boolean) { familiarNpc["owner_index"] = index familiarNpc.anim("${familiarNpc.id.removeSuffix("_familiar")}_spawn") familiarNpc.gfx("summon_familiar_size_${familiarNpc.size}") + updateFamiliarPvpForm() updateFamiliarInterface() if (!restart) { timers.start("familiar_timer") @@ -182,6 +183,7 @@ fun Player.callFollower() { if (follower.mode !is Follow) { follower.mode = Follow(follower, this) } + updateFamiliarPvpForm() } /** From 334e63c4600b60ed3b61977ce020578c993dbb42 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Tue, 30 Jun 2026 06:17:26 -0700 Subject: [PATCH 032/136] Familiar re-acquires the owner's current target when freed In multi-combat the familiar engaged the owner's target on combatStart and (correctly) never switched mid-fight, but once its own target died nothing re-engaged it if the owner had since moved to another enemy - it just stood idle. Assist on the owner's combatAttack too: each hit an idle familiar joins the owner's current (last-attacked) target, while the idle guard still leaves an actively fighting familiar alone. --- .../content/skill/summoning/FamiliarCombat.kt | 5 ++ .../entity/combat/CombatMovementTest.kt | 49 +++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/game/src/main/kotlin/content/skill/summoning/FamiliarCombat.kt b/game/src/main/kotlin/content/skill/summoning/FamiliarCombat.kt index a382570a57..8b9ab0bb52 100644 --- a/game/src/main/kotlin/content/skill/summoning/FamiliarCombat.kt +++ b/game/src/main/kotlin/content/skill/summoning/FamiliarCombat.kt @@ -138,6 +138,11 @@ class FamiliarCombat : Script, CombatApi { } } + // 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/test/kotlin/content/entity/combat/CombatMovementTest.kt b/game/src/test/kotlin/content/entity/combat/CombatMovementTest.kt index 46b0d7b0e8..fb5840acd5 100644 --- a/game/src/test/kotlin/content/entity/combat/CombatMovementTest.kt +++ b/game/src/test/kotlin/content/entity/combat/CombatMovementTest.kt @@ -1,6 +1,7 @@ package content.entity.combat import WorldTest +import content.skill.summoning.assistFamiliar import content.skill.summoning.commandFamiliarAttack import content.skill.summoning.follower import npcOption @@ -328,6 +329,54 @@ internal class CombatMovementTest : WorldTest() { 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)) From 6f04e5275340c77da3c36ad3480f374e69562442 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Tue, 30 Jun 2026 06:39:41 -0700 Subject: [PATCH 033/136] Let familiars phase through players, not npcs With world.players.collision on, a player stamps BLOCK_PLAYERS on its tile and an npc's blockMove (BLOCK_PLAYERS|BLOCK_NPCS) is the canTravel extraFlag, so a familiar couldn't step onto any player tile - standing between it and its target blocked it. Drop BLOCK_PLAYERS from blockMove for owned followers (familiars/pets) so they phase through players including their owner, while keeping BLOCK_NPCS; solid npcs already stamp FLOOR so the pathfinder still routes the familiar around them. --- .../voidps/engine/entity/character/npc/NPC.kt | 14 +++++++++++++- .../content/entity/combat/CombatMovementTest.kt | 17 +++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) 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 addacd3653..92d35bceea 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 @@ -32,7 +32,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/test/kotlin/content/entity/combat/CombatMovementTest.kt b/game/src/test/kotlin/content/entity/combat/CombatMovementTest.kt index fb5840acd5..4a5aebbe81 100644 --- a/game/src/test/kotlin/content/entity/combat/CombatMovementTest.kt +++ b/game/src/test/kotlin/content/entity/combat/CombatMovementTest.kt @@ -21,6 +21,7 @@ import world.gregs.voidps.engine.entity.character.move.running import world.gregs.voidps.engine.entity.character.move.tele import world.gregs.voidps.engine.entity.character.player.skill.Skill import world.gregs.voidps.engine.inv.equipment +import org.rsmod.game.pathfinder.flag.CollisionFlag import world.gregs.voidps.network.login.protocol.visual.update.player.EquipSlot import world.gregs.voidps.type.Tile import world.gregs.voidps.type.setRandom @@ -160,6 +161,22 @@ internal class CombatMovementTest : WorldTest() { assertTrue(familiar.mode is CombatMovement) } + @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)) From c401a692f121b12c4ffa92473a7e79992788f283 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Tue, 30 Jun 2026 07:04:06 -0700 Subject: [PATCH 034/136] Set standard familiar attack speed to 8 ticks Combat defs were left at the engine default of 4 (2.4s). Standard summoning familiar attack speed is 8 ticks (4.8s) - add attack_speed = 8 to every familiar combat def. --- data/skill/summoning/summoning.combat.toml | 75 ++++++++++++++++++- .../entity/combat/CombatMovementTest.kt | 7 ++ 2 files changed, 81 insertions(+), 1 deletion(-) diff --git a/data/skill/summoning/summoning.combat.toml b/data/skill/summoning/summoning.combat.toml index 6309552cfc..1bf1b19837 100644 --- a/data/skill/summoning/summoning.combat.toml +++ b/data/skill/summoning/summoning.combat.toml @@ -1,7 +1,9 @@ # Familiar combat definitions, ported from Darkan combatdefs/familiars. -# attack_speed left default (4); tune per-familiar later. Special/scroll attacks out of scope. +# 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" @@ -12,6 +14,7 @@ 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" @@ -23,6 +26,7 @@ projectile = "dreadfowl_familiar_projectile" 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" @@ -33,6 +37,7 @@ 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" @@ -45,6 +50,7 @@ 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" @@ -55,6 +61,7 @@ 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" @@ -65,6 +72,7 @@ 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" @@ -75,6 +83,7 @@ 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" @@ -95,6 +104,7 @@ anim = "spirit_tz-kih_familiar_attack" 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" @@ -105,6 +115,7 @@ 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" @@ -115,6 +126,7 @@ 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" @@ -125,6 +137,7 @@ 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" @@ -135,6 +148,7 @@ anim = "giant_chinchompa_familiar_attack" target_hit = { offense = "range", max = 38 } [vampire_bat_familiar] +attack_speed = 8 retreat_range = 8 defend_anim = "vampire_bat_familiar_defend" death_anim = "vampire_bat_familiar_death" @@ -145,6 +159,7 @@ 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" @@ -155,6 +170,7 @@ 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" @@ -165,6 +181,7 @@ 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" @@ -175,6 +192,7 @@ 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" @@ -185,6 +203,7 @@ 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" @@ -195,6 +214,7 @@ 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" @@ -205,6 +225,7 @@ anim = "bronze_minotaur_familiar_attack" 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" @@ -215,6 +236,7 @@ anim = "iron_minotaur_familiar_attack" 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" @@ -225,6 +247,7 @@ anim = "steel_minotaur_familiar_attack" 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" @@ -235,6 +258,7 @@ anim = "mithril_minotaur_familiar_attack" 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" @@ -245,6 +269,7 @@ anim = "adamant_minotaur_familiar_attack" 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" @@ -255,6 +280,7 @@ anim = "rune_minotaur_familiar_attack" 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" @@ -265,6 +291,7 @@ 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" @@ -275,6 +302,7 @@ 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" @@ -285,6 +313,7 @@ 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" @@ -295,6 +324,7 @@ 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" @@ -305,6 +335,7 @@ 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" @@ -315,6 +346,7 @@ 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" @@ -325,6 +357,7 @@ 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" @@ -335,6 +368,7 @@ 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" @@ -345,6 +379,7 @@ 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" @@ -355,6 +390,7 @@ 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" @@ -365,6 +401,7 @@ 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" @@ -375,6 +412,7 @@ 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" @@ -385,6 +423,7 @@ 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" @@ -395,6 +434,7 @@ 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" @@ -405,6 +445,7 @@ 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" @@ -415,6 +456,7 @@ 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" @@ -425,6 +467,7 @@ 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" @@ -435,6 +478,7 @@ anim = "karamthulhu_overlord_familiar_attack" 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" @@ -446,6 +490,7 @@ 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" @@ -456,6 +501,7 @@ 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" @@ -466,6 +512,7 @@ anim = "spirit_cobra_familiar_attack" target_hit = { offense = "crush", max = 82 } [stranger_plant_familiar] +attack_speed = 8 retreat_range = 8 defend_anim = "stranger_plant_familiar_defend" death_anim = "stranger_plant_familiar_death" @@ -476,6 +523,7 @@ 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" @@ -486,6 +534,7 @@ 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" @@ -496,6 +545,7 @@ 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" @@ -506,6 +556,7 @@ 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" @@ -516,6 +567,7 @@ 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" @@ -526,6 +578,7 @@ 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" @@ -536,6 +589,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" @@ -546,6 +600,7 @@ 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" @@ -556,6 +611,7 @@ 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" @@ -566,6 +622,7 @@ 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" @@ -577,6 +634,7 @@ 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" @@ -587,6 +645,7 @@ 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" @@ -597,6 +656,7 @@ 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" @@ -608,6 +668,7 @@ 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" @@ -618,6 +679,7 @@ 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" @@ -628,6 +690,7 @@ 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" @@ -639,6 +702,7 @@ 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" @@ -649,6 +713,7 @@ 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" @@ -659,6 +724,7 @@ 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" @@ -669,6 +735,7 @@ 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" @@ -679,6 +746,7 @@ 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" @@ -689,6 +757,7 @@ 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" @@ -699,6 +768,7 @@ 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" @@ -709,6 +779,7 @@ anim = "geyser_titan_familiar_attack" 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" @@ -719,6 +790,7 @@ 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" @@ -729,6 +801,7 @@ 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" diff --git a/game/src/test/kotlin/content/entity/combat/CombatMovementTest.kt b/game/src/test/kotlin/content/entity/combat/CombatMovementTest.kt index 4a5aebbe81..9aaf9ba0bf 100644 --- a/game/src/test/kotlin/content/entity/combat/CombatMovementTest.kt +++ b/game/src/test/kotlin/content/entity/combat/CombatMovementTest.kt @@ -20,6 +20,7 @@ import world.gregs.voidps.engine.entity.character.mode.combat.CombatMovement import world.gregs.voidps.engine.entity.character.move.running import world.gregs.voidps.engine.entity.character.move.tele import world.gregs.voidps.engine.entity.character.player.skill.Skill +import content.skill.melee.weapon.attackSpeed import world.gregs.voidps.engine.inv.equipment import org.rsmod.game.pathfinder.flag.CollisionFlag import world.gregs.voidps.network.login.protocol.visual.update.player.EquipSlot @@ -161,6 +162,12 @@ internal class CombatMovementTest : WorldTest() { 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)) From d8d603983e524d3bfe9c20b768286de1aa410fdd Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Tue, 30 Jun 2026 14:05:24 -0700 Subject: [PATCH 035/136] feat: add familiar attack projectiles and animations. --- data/skill/summoning/summoning.combat.toml | 8 ++++++ .../summoning/summoning_combat.anims.toml | 4 +-- .../skill/summoning/summoning_combat.gfx.toml | 26 ++++++++++++++++++- 3 files changed, 35 insertions(+), 3 deletions(-) diff --git a/data/skill/summoning/summoning.combat.toml b/data/skill/summoning/summoning.combat.toml index 1bf1b19837..6475da796c 100644 --- a/data/skill/summoning/summoning.combat.toml +++ b/data/skill/summoning/summoning.combat.toml @@ -101,6 +101,8 @@ 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] @@ -475,6 +477,8 @@ 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] @@ -776,6 +780,8 @@ 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] @@ -809,4 +815,6 @@ 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_combat.anims.toml b/data/skill/summoning/summoning_combat.anims.toml index 683b5aca78..ae66731976 100644 --- a/data/skill/summoning/summoning_combat.anims.toml +++ b/data/skill/summoning/summoning_combat.anims.toml @@ -371,7 +371,7 @@ id = 5227 id = 5230 [karamthulhu_overlord_familiar_attack] -id = 7963 +id = 7970 [karamthulhu_overlord_familiar_defend] id = 7962 @@ -641,7 +641,7 @@ id = 7948 id = 7947 [steel_titan_familiar_attack] -id = 8183 +id = 8190 [steel_titan_familiar_defend] id = 8185 diff --git a/data/skill/summoning/summoning_combat.gfx.toml b/data/skill/summoning/summoning_combat.gfx.toml index c2f742127d..1658faf8c8 100644 --- a/data/skill/summoning/summoning_combat.gfx.toml +++ b/data/skill/summoning/summoning_combat.gfx.toml @@ -14,8 +14,32 @@ id = 1376 id = 1330 [hydra_familiar_projectile] -id = 1488 +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 + From 22b440335d50bb64a0a02c7d757d4365cdba9c2e Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Tue, 30 Jun 2026 14:30:40 -0700 Subject: [PATCH 036/136] Drain summoning points over familiar lifetime Summoning was free: no summon cost was consumed, points never drained, and the lifetime timer read summoning_time_minutes which was set on no familiar, so the countdown never worked. Mirror the live game: - Gate summoning on having enough points and drain the pouch's summon_points on summon. - New per-tick SummoningDrain timer drains the player's summoning points evenly over the familiar's life (total = levelRequired - summonCost + 1), using the same integer-accumulator approach as PrayerDrain, and dismisses the familiar when points hit zero. - Populate per-familiar summoning_time_minutes (ported from 2009scape) so the duration timer counts down; convert the stray meerkats familiar_time. - Add a 0-60 special-move-points pool (varp 1177): initialised to 60 on summon and regenerated +15 every 30s by the familiar timer. Special moves now spend the pool via useFamiliarSpecial instead of draining summoning points; Unicorn Stallion's cure is the first to use it, driven by a new summoning_special_cost npc param. --- .../gregs/voidps/cache/definition/Params.kt | 2 + data/skill/summoning/summoning.npcs.toml | 80 ++++++++++++++++++- .../content/skill/summoning/Summoning.kt | 29 ++++++- .../content/skill/summoning/SummoningDrain.kt | 58 ++++++++++++++ .../skill/summoning/SummoningTimers.kt | 5 ++ .../summoning/familiar/UnicornStallion.kt | 15 ++-- 6 files changed, 177 insertions(+), 12 deletions(-) create mode 100644 game/src/main/kotlin/content/skill/summoning/SummoningDrain.kt 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/data/skill/summoning/summoning.npcs.toml b/data/skill/summoning/summoning.npcs.toml index 2a61f40f09..076c67d87b 100644 --- a/data/skill/summoning/summoning.npcs.toml +++ b/data/skill/summoning/summoning.npcs.toml @@ -1,4 +1,5 @@ [spirit_terrorbird_familiar] +summoning_time_minutes = 36 id = 6794 hitpoints = 2330 att = 47 @@ -11,6 +12,7 @@ large_head = true interacts = false [granite_crab_familiar] +summoning_time_minutes = 18 id = 6796 hitpoints = 160 att = 15 @@ -25,6 +27,7 @@ large_head = true interacts = false [praying_mantis_familiar] +summoning_time_minutes = 69 id = 6798 hitpoints = 4280 att = 72 @@ -37,6 +40,7 @@ large_head = true interacts = false [giant_ent_familiar] +summoning_time_minutes = 49 id = 6800 hitpoints = 4670 att = 72 @@ -51,6 +55,7 @@ large_head = true interacts = false [spirit_cobra_familiar] +summoning_time_minutes = 56 id = 6802 hitpoints = 3140 att = 57 @@ -63,6 +68,7 @@ large_head = true interacts = false [spirit_dagannoth_familiar] +summoning_time_minutes = 57 id = 6804 hitpoints = 5280 att = 75 @@ -75,6 +81,7 @@ large_head = true interacts = false [thorny_snail_familiar] +summoning_time_minutes = 16 id = 6806 hitpoints = 280 att = 1 @@ -87,6 +94,7 @@ large_head = true interacts = false [beaver_familiar] +summoning_time_minutes = 27 id = 6808 summoning_beast_of_burden = 1 summoning_beast_of_burden_capacity = 30 @@ -94,6 +102,7 @@ large_head = true interacts = false [karamthulhu_overlord_familiar] +summoning_time_minutes = 44 id = 6809 hitpoints = 2760 att = 53 @@ -106,6 +115,7 @@ large_head = true interacts = false [hydra_familiar] +summoning_time_minutes = 49 id = 6811 hitpoints = 4900 att = 72 @@ -118,6 +128,7 @@ large_head = true interacts = false [spirit_jelly_familiar] +summoning_time_minutes = 43 id = 6992 hitpoints = 2550 att = 50 @@ -130,6 +141,7 @@ large_head = true interacts = false [bunyip_familiar] +summoning_time_minutes = 44 id = 6813 hitpoints = 400 att = 72 @@ -142,6 +154,7 @@ large_head = true interacts = false [war_tortoise_familiar] +summoning_time_minutes = 43 id = 6815 hitpoints = 3480 att = 72 @@ -154,6 +167,7 @@ large_head = true interacts = false [fruit_bat_familiar] +summoning_time_minutes = 45 id = 6817 summoning_beast_of_burden = 1 summoning_beast_of_burden_capacity = 30 @@ -161,6 +175,7 @@ large_head = true interacts = false [abyssal_parasite_familiar] +summoning_time_minutes = 30 id = 6818 hitpoints = 2340 att = 49 @@ -177,6 +192,7 @@ summoning_beast_of_burden_capacity = 7 summoning_beast_of_burden_essence = 1 [abyssal_lurker_familiar] +summoning_time_minutes = 41 id = 6820 hitpoints = 3080 att = 56 @@ -193,6 +209,8 @@ 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 @@ -205,6 +223,7 @@ large_head = true interacts = false [magpie_familiar] +summoning_time_minutes = 34 id = 6824 large_head = true interacts = false @@ -212,6 +231,7 @@ summoning_beast_of_burden = 1 summoning_beast_of_burden_capacity = 30 [dreadfowl_familiar] +summoning_time_minutes = 4 id = 6825 hitpoints = 160 att = 15 @@ -224,6 +244,7 @@ large_head = true interacts = false [stranger_plant_familiar] +summoning_time_minutes = 49 id = 6827 hitpoints = 3220 att = 72 @@ -238,6 +259,7 @@ large_head = true interacts = false [spirit_wolf_familiar] +summoning_time_minutes = 6 id = 6829 hitpoints = 150 att = 10 @@ -251,6 +273,7 @@ dialogue = "wolf" interacts = false [desert_wyrm_familiar] +summoning_time_minutes = 19 id = 6831 hitpoints = 470 att = 20 @@ -263,6 +286,7 @@ large_head = true interacts = false [evil_turnip_familiar] +summoning_time_minutes = 30 id = 6833 hitpoints = 1670 att = 38 @@ -277,6 +301,7 @@ large_head = true interacts = false [vampire_bat_familiar] +summoning_time_minutes = 33 id = 6835 hitpoints = 1050 att = 30 @@ -289,6 +314,7 @@ large_head = true interacts = false [spirit_scorpion_familiar] +summoning_time_minutes = 17 id = 6837 hitpoints = 670 att = 20 @@ -301,6 +327,7 @@ large_head = true interacts = false [arctic_bear_familiar] +summoning_time_minutes = 28 id = 6839 hitpoints = 3810 att = 72 @@ -313,6 +340,7 @@ large_head = true interacts = false [spirit_spider_familiar] +summoning_time_minutes = 15 id = 6841 hitpoints = 180 att = 15 @@ -327,6 +355,7 @@ large_head = true interacts = false [bloated_leech_familiar] +summoning_time_minutes = 34 id = 6843 hitpoints = 2110 att = 45 @@ -339,6 +368,7 @@ large_head = true interacts = false [spirit_kalphite_familiar] +summoning_time_minutes = 22 id = 6994 hitpoints = 770 att = 25 @@ -351,6 +381,7 @@ large_head = true interacts = false [honey_badger_familiar] +summoning_time_minutes = 25 id = 6845 hitpoints = 1100 att = 29 @@ -363,6 +394,7 @@ large_head = true interacts = false [albino_rat_familiar] +summoning_time_minutes = 22 id = 6847 hitpoints = 680 att = 22 @@ -375,6 +407,7 @@ large_head = true interacts = false [granite_lobster_familiar] +summoning_time_minutes = 47 id = 6849 hitpoints = 4180 att = 72 @@ -389,6 +422,7 @@ large_head = true interacts = false [macaw_familiar] +summoning_time_minutes = 31 id = 6851 summoning_beast_of_burden = 1 summoning_beast_of_burden_capacity = 30 @@ -396,6 +430,7 @@ large_head = true interacts = false [bronze_minotaur_familiar] +summoning_time_minutes = 30 id = 6853 hitpoints = 1330 att = 33 @@ -408,6 +443,7 @@ large_head = true interacts = false [iron_minotaur_familiar] +summoning_time_minutes = 37 id = 6855 hitpoints = 1930 att = 42 @@ -420,6 +456,7 @@ large_head = true interacts = false [steel_minotaur_familiar] +summoning_time_minutes = 46 id = 6857 hitpoints = 2600 att = 51 @@ -432,6 +469,7 @@ large_head = true interacts = false [mithril_minotaur_familiar] +summoning_time_minutes = 55 id = 6859 hitpoints = 3400 att = 60 @@ -444,6 +482,7 @@ large_head = true interacts = false [adamant_minotaur_familiar] +summoning_time_minutes = 66 id = 6861 hitpoints = 4410 att = 72 @@ -456,6 +495,7 @@ large_head = true interacts = false [rune_minotaur_familiar] +summoning_time_minutes = 151 id = 6863 hitpoints = 5700 att = 80 @@ -468,6 +508,7 @@ large_head = true interacts = false [smoke_devil_familiar] +summoning_time_minutes = 48 id = 6865 hitpoints = 3000 att = 55 @@ -480,6 +521,7 @@ large_head = true interacts = false [bull_ant_familiar] +summoning_time_minutes = 30 id = 6867 hitpoints = 1540 att = 36 @@ -492,6 +534,7 @@ large_head = true interacts = false [wolpertinger_familiar] +summoning_time_minutes = 62 id = 6869 hitpoints = 6510 att = 85 @@ -504,6 +547,7 @@ large_head = true interacts = false [compost_mound_familiar] +summoning_time_minutes = 24 id = 6871 hitpoints = 930 att = 25 @@ -518,6 +562,7 @@ large_head = true interacts = false [pack_yak_familiar] +summoning_time_minutes = 58 id = 6873 hitpoints = 7100 att = 87 @@ -530,6 +575,7 @@ large_head = true interacts = false [spirit_cockatrice_familiar] +summoning_time_minutes = 36 id = 6875 hitpoints = 1730 att = 39 @@ -544,6 +590,7 @@ large_head = true interacts = false [spirit_guthatrice_familiar] +summoning_time_minutes = 36 id = 6877 hitpoints = 1730 att = 39 @@ -558,6 +605,7 @@ large_head = true interacts = false [spirit_saratrice_familiar] +summoning_time_minutes = 36 id = 6879 hitpoints = 1730 att = 39 @@ -572,6 +620,7 @@ large_head = true interacts = false [spirit_zamatrice_familiar] +summoning_time_minutes = 36 id = 6881 hitpoints = 1730 att = 39 @@ -586,6 +635,7 @@ large_head = true interacts = false [spirit_pengatrice_familiar] +summoning_time_minutes = 36 id = 6883 hitpoints = 1730 att = 39 @@ -600,6 +650,7 @@ large_head = true interacts = false [spirit_coraxatrice_familiar] +summoning_time_minutes = 36 id = 6885 hitpoints = 1730 att = 39 @@ -614,6 +665,7 @@ large_head = true interacts = false [spirit_vulatrice_familiar] +summoning_time_minutes = 36 id = 6887 hitpoints = 1730 att = 39 @@ -628,6 +680,7 @@ large_head = true interacts = false [barker_toad_familiar] +summoning_time_minutes = 8 id = 6889 hitpoints = 3400 att = 60 @@ -640,6 +693,7 @@ large_head = true interacts = false [ibis_familiar] +summoning_time_minutes = 38 id = 6991 summoning_beast_of_burden = 1 summoning_beast_of_burden_capacity = 30 @@ -647,6 +701,7 @@ large_head = true interacts = false [swamp_titan_familiar] +summoning_time_minutes = 56 id = 7329 hitpoints = 5660 att = 77 @@ -659,6 +714,7 @@ large_head = true interacts = false [spirit_mosquito_familiar] +summoning_time_minutes = 12 id = 7331 hitpoints = 430 att = 5 @@ -671,6 +727,7 @@ large_head = true interacts = false [void_spinner_familiar] +summoning_time_minutes = 27 id = 7333 hitpoints = 590 att = 31 @@ -683,6 +740,7 @@ large_head = true interacts = false [forge_regent_familiar] +summoning_time_minutes = 45 id = 7335 hitpoints = 4410 att = 72 @@ -695,6 +753,7 @@ large_head = true interacts = false [spirit_larupia_familiar] +summoning_time_minutes = 49 id = 7337 hitpoints = 2680 att = 52 @@ -707,6 +766,7 @@ large_head = true interacts = false [geyser_titan_familiar] +summoning_time_minutes = 69 id = 7339 hitpoints = 6100 att = 85 @@ -719,6 +779,7 @@ large_head = true interacts = false [lava_titan_familiar] +summoning_time_minutes = 61 id = 7341 hitpoints = 5280 att = 75 @@ -731,6 +792,7 @@ large_head = true interacts = false [steel_titan_familiar] +summoning_time_minutes = 64 id = 7343 hitpoints = 7540 att = 130 @@ -743,6 +805,7 @@ large_head = true interacts = false [obsidian_golem_familiar] +summoning_time_minutes = 55 id = 7345 hitpoints = 4060 att = 72 @@ -755,6 +818,7 @@ large_head = true interacts = false [talon_beast_familiar] +summoning_time_minutes = 49 id = 7347 hitpoints = 4540 att = 72 @@ -767,6 +831,7 @@ large_head = true interacts = false [abyssal_titan_familiar] +summoning_time_minutes = 93 id = 7349 hitpoints = 6670 att = 100 @@ -782,6 +847,7 @@ 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 @@ -794,6 +860,7 @@ large_head = true interacts = false [giant_chinchompa_familiar] +summoning_time_minutes = 31 id = 7353 hitpoints = 970 att = 25 @@ -806,6 +873,7 @@ large_head = true interacts = false [fire_titan_familiar] +summoning_time_minutes = 62 id = 7355 hitpoints = 4760 att = 72 @@ -818,6 +886,7 @@ large_head = true interacts = false [moss_titan_familiar] +summoning_time_minutes = 58 id = 7357 hitpoints = 4760 att = 72 @@ -830,6 +899,7 @@ large_head = true interacts = false [ice_titan_familiar] +summoning_time_minutes = 64 id = 7359 hitpoints = 4760 att = 72 @@ -842,6 +912,7 @@ large_head = true interacts = false [spirit_tz-kih_familiar] +summoning_time_minutes = 18 id = 7361 hitpoints = 630 att = 20 @@ -854,6 +925,7 @@ large_head = true interacts = false [spirit_graahk_familiar] +summoning_time_minutes = 57 id = 7363 hitpoints = 2680 att = 52 @@ -866,6 +938,7 @@ large_head = true interacts = false [spirit_kyatt_familiar] +summoning_time_minutes = 49 id = 7365 hitpoints = 2680 att = 52 @@ -878,6 +951,7 @@ large_head = true interacts = false [void_shifter_familiar] +summoning_time_minutes = 94 id = 7367 hitpoints = 1210 att = 31 @@ -890,6 +964,7 @@ large_head = true interacts = false [pyrelord_familiar] +summoning_time_minutes = 32 id = 7377 hitpoints = 1930 att = 60 @@ -902,6 +977,7 @@ large_head = true interacts = false [void_ravager_familiar] +summoning_time_minutes = 27 id = 7370 hitpoints = 1210 att = 31 @@ -916,6 +992,7 @@ large_head = true interacts = false [ravenous_locust_familiar] +summoning_time_minutes = 24 id = 7372 hitpoints = 3700 att = 72 @@ -928,6 +1005,7 @@ large_head = true interacts = false [iron_titan_familiar] +summoning_time_minutes = 60 id = 7375 hitpoints = 6940 att = 120 @@ -1277,10 +1355,10 @@ large_head = true interacts = false [meerkats_familiar] +summoning_time_minutes = 40 id = 11640 large_head = true interacts = false -familiar_time = 40 # --- PvP combat variants (id+1, the 'Attack'-option form shown in PvP areas) --- diff --git a/game/src/main/kotlin/content/skill/summoning/Summoning.kt b/game/src/main/kotlin/content/skill/summoning/Summoning.kt index 91f57e3e4c..2055ca64f3 100644 --- a/game/src/main/kotlin/content/skill/summoning/Summoning.kt +++ b/game/src/main/kotlin/content/skill/summoning/Summoning.kt @@ -62,9 +62,6 @@ 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) { @@ -76,6 +73,7 @@ fun Player.summonFamiliar(familiar: NPCDefinition, restart: Boolean) { updateFamiliarInterface() if (!restart) { timers.start("familiar_timer") + timers.start("summoning_drain") } if (get().get("forage_${familiarNpc.id.removeSuffix("_familiar")}") != null) { timers.start("forage") @@ -107,6 +105,7 @@ fun Player.dismissFamiliar(removeNpc: Boolean = true) { set("familiar_details_seconds_remaining", 0) } timers.stop("familiar_timer") + timers.stop("summoning_drain") timers.stop("forage") } @@ -199,6 +198,22 @@ fun Player.renewSummoningPoints() { } } +/** + * 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)) +} + /** * 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. @@ -237,6 +252,7 @@ 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) + val summonCost = option.item.def["summon_points", 0] if (!has(Skill.Summoning, familiarLevel)) { message("You are not high enough level to use this pouch.") return@itemOption @@ -245,8 +261,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) } @@ -356,6 +378,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 dfe6a933b9..1f15f3ed1d 100644 --- a/game/src/main/kotlin/content/skill/summoning/SummoningTimers.kt +++ b/game/src/main/kotlin/content/skill/summoning/SummoningTimers.kt @@ -21,6 +21,11 @@ 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()) { diff --git a/game/src/main/kotlin/content/skill/summoning/familiar/UnicornStallion.kt b/game/src/main/kotlin/content/skill/summoning/familiar/UnicornStallion.kt index 1fab5fe1b1..ec156cb5f0 100644 --- a/game/src/main/kotlin/content/skill/summoning/familiar/UnicornStallion.kt +++ b/game/src/main/kotlin/content/skill/summoning/familiar/UnicornStallion.kt @@ -1,6 +1,7 @@ package content.skill.summoning.familiar import content.skill.summoning.follower +import content.skill.summoning.useFamiliarSpecial import content.entity.player.dialogue.Happy import content.entity.player.dialogue.Neutral import content.entity.player.dialogue.type.npc @@ -57,18 +58,16 @@ class UnicornStallion : Script { message("This isn't your familiar.") return@npcOperate } - if (levels.get(Skill.Summoning) < 2) { - message("You do not have enough summoning points to do this.") - return@npcOperate - } if (!this["poisoned", false]) { message("You're not suffering from poison!") return@npcOperate } - follower?.anim("unicorn_stallion_cure") - follower?.gfx("unicorn_stallion_cure") - clear("poisoned") - levels.drain(Skill.Summoning, 2) + val cost = follower?.def["summoning_special_cost", 8] ?: 8 + useFamiliarSpecial(cost) { + follower?.anim("unicorn_stallion_cure") + follower?.gfx("unicorn_stallion_cure") + clear("poisoned") + } } } } From 93a5670d5e8435a5a51c996c29caab66af34593d Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Tue, 30 Jun 2026 15:59:37 -0700 Subject: [PATCH 037/136] feat: add familiar special moves with Howl projectile tuning Add the cast-button familiar special moves ported from 2009scape: combat, boost and utility specials plus their shared helpers, dispatcher, stubs and named anim/gfx data. Spirit Wolf Howl fires its tornado from the familiar with 2009scape projectile timing (delay 50, flight 46 + dist*10, curve 5) at ground height so it visibly travels instead of snapping from the owner. --- data/skill/summoning/summoning.combat.toml | 1 - .../summoning/summoning_special.anims.toml | 74 ++++++++++ .../summoning/summoning_special.gfx.toml | 137 ++++++++++++++++++ .../kotlin/content/skill/fishing/Fishing.kt | 2 +- .../kotlin/content/skill/mining/Mining.kt | 2 +- .../skill/summoning/FamiliarBoostSpecials.kt | 104 +++++++++++++ .../content/skill/summoning/FamiliarCombat.kt | 4 +- .../skill/summoning/FamiliarCombatSpecials.kt | 134 +++++++++++++++++ .../skill/summoning/FamiliarSpecialHelpers.kt | 102 +++++++++++++ .../skill/summoning/FamiliarSpecialMoves.kt | 85 +++++++++++ .../skill/summoning/FamiliarSpecialStubs.kt | 72 +++++++++ .../summoning/FamiliarUtilitySpecials.kt | 122 ++++++++++++++++ .../content/skill/summoning/Summoning.kt | 62 ++++++++ .../skill/summoning/familiar/LavaTitan.kt | 2 +- .../skill/summoning/familiar/Pyrelord.kt | 2 +- .../summoning/familiar/UnicornStallion.kt | 4 +- .../content/skill/thieving/Pickpocketing.kt | 2 +- .../entity/combat/CombatMovementTest.kt | 6 +- .../summoning/FamiliarSpecialMoveTest.kt | 91 ++++++++++++ 19 files changed, 996 insertions(+), 12 deletions(-) create mode 100644 data/skill/summoning/summoning_special.anims.toml create mode 100644 data/skill/summoning/summoning_special.gfx.toml create mode 100644 game/src/main/kotlin/content/skill/summoning/FamiliarBoostSpecials.kt create mode 100644 game/src/main/kotlin/content/skill/summoning/FamiliarCombatSpecials.kt create mode 100644 game/src/main/kotlin/content/skill/summoning/FamiliarSpecialHelpers.kt create mode 100644 game/src/main/kotlin/content/skill/summoning/FamiliarSpecialMoves.kt create mode 100644 game/src/main/kotlin/content/skill/summoning/FamiliarSpecialStubs.kt create mode 100644 game/src/main/kotlin/content/skill/summoning/FamiliarUtilitySpecials.kt create mode 100644 game/src/test/kotlin/content/skill/summoning/FamiliarSpecialMoveTest.kt diff --git a/data/skill/summoning/summoning.combat.toml b/data/skill/summoning/summoning.combat.toml index 6475da796c..2bd1e6cf31 100644 --- a/data/skill/summoning/summoning.combat.toml +++ b/data/skill/summoning/summoning.combat.toml @@ -1,4 +1,3 @@ -# Familiar combat definitions, ported from Darkan combatdefs/familiars. # Standard familiar attack speed is 8 ticks (4.8s); tune per-familiar as needed. # Special/scroll attacks out of scope. diff --git a/data/skill/summoning/summoning_special.anims.toml b/data/skill/summoning/summoning_special.anims.toml new file mode 100644 index 0000000000..44fc05e09e --- /dev/null +++ b/data/skill/summoning/summoning_special.anims.toml @@ -0,0 +1,74 @@ +# 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 + +# Boost / heal specials +[stony_shell] +id = 8109 + +[testudo] +id = 8288 + +[magic_focus] +id = 8267 + +[abyssal_stealth] +id = 7682 + +[unburden] +id = 7896 + +[tireless_run] +id = 1009 + +[healing_aura] +id = 8267 + +[thieving_fingers] +id = 8020 + +[insane_ferocity] +id = 7928 diff --git a/data/skill/summoning/summoning_special.gfx.toml b/data/skill/summoning/summoning_special.gfx.toml new file mode 100644 index 0000000000..971091c409 --- /dev/null +++ b/data/skill/summoning/summoning_special.gfx.toml @@ -0,0 +1,137 @@ +# Familiar special-move graphics + projectiles (cast-button specials). +# Suffix conventions: bare name = the familiar's own gfx, _proj = projectile, _hit = target gfx. + +# Combat specials +[spirit_wolf_howl] +id = 1334 + +[spirit_wolf_howl_proj] +id = 1333 +delay = 50 # wind-up before the tornado leaves the wolf +time_offset = 56 # 46 base + 10 to offset Void's distance-1: flight = 56 + (dist-1)*10 = 46 + dist*10 +multiplier = 10 # magic-speed slope per tile, matching Projectile.magic +curve = 5 # angle from Projectile.magic(this, npc, 1333, 40, 36, 50, 5) + +[electric_lash] +id = 1410 + +[electric_lash_proj] +id = 1411 + +[toad_bark] +id = 1403 + +[toad_bark_hit] +id = 1404 + +[slime_spray] +id = 1385 + +[slime_spray_proj] +id = 1386 + +[slime_spray_hit] +id = 1387 + +[arctic_blast] +id = 1405 + +[arctic_blast_proj] +id = 1406 + +[arctic_blast_hit] +id = 1407 + +[crushing_claw] +id = 1351 + +[crushing_claw_proj] +id = 1352 + +[dreadfowl_strike] +id = 1523 + +[dreadfowl_strike_proj] +id = 1318 + +[dissolve_proj] +id = 1360 + +[rending] +id = 1370 + +[rending_proj] +id = 1371 + +[evil_flames_proj] +id = 1330 + +[evil_flames_hit] +id = 1329 + +[abyssal_drain] +id = 1422 + +[abyssal_drain_proj] +id = 1423 + +[vampire_touch] +id = 1323 + +[petrifying_gaze] +id = 1467 + +[petrifying_gaze_proj] +id = 1468 + +[petrifying_gaze_hit] +id = 1469 + +[bull_rush] +id = 1496 + +[bull_rush_proj] +id = 1497 + +[poisonous_blast_proj] +id = 1508 + +[poisonous_blast_hit] +id = 1511 + +[boil] +id = 1375 + +[boil_proj] +id = 1376 + +[boil_hit] +id = 1377 + +# 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 diff --git a/game/src/main/kotlin/content/skill/fishing/Fishing.kt b/game/src/main/kotlin/content/skill/fishing/Fishing.kt index fad8fa2e00..1d40a74cc4 100644 --- a/game/src/main/kotlin/content/skill/fishing/Fishing.kt +++ b/game/src/main/kotlin/content/skill/fishing/Fishing.kt @@ -1,7 +1,7 @@ package content.skill.fishing -import content.skill.summoning.familiarBoost import com.github.michaelbull.logging.InlineLogger +import content.skill.summoning.familiarBoost import net.pearx.kasechange.toLowerSpaceCase import net.pearx.kasechange.toTitleCase import world.gregs.voidps.engine.Script diff --git a/game/src/main/kotlin/content/skill/mining/Mining.kt b/game/src/main/kotlin/content/skill/mining/Mining.kt index 79333f7f49..b62ffe7505 100644 --- a/game/src/main/kotlin/content/skill/mining/Mining.kt +++ b/game/src/main/kotlin/content/skill/mining/Mining.kt @@ -1,10 +1,10 @@ package content.skill.mining import content.activity.shooting_star.ShootingStarHandler -import content.skill.summoning.familiarBoost 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 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..5d521334fd --- /dev/null +++ b/game/src/main/kotlin/content/skill/summoning/FamiliarBoostSpecials.kt @@ -0,0 +1,104 @@ +package content.skill.summoning + +import content.entity.combat.hit.directHit +import content.entity.effect.toxin.curePoison +import content.entity.player.effect.energy.MAX_RUN_ENERGY +import content.entity.player.effect.energy.runEnergy +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.type.random +import kotlin.math.ceil + +/** + * 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, 9, "testudo", "testudo") } + FamiliarSpecialMoves.instant("obsidian_golem_familiar") { boost(Skill.Strength, 9, sourceGfx = "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") { + 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. + FamiliarSpecialMoves.instant("spirit_terrorbird_familiar") { + familiarSelfSpecial(anim = "tireless_run", playerGfx = "tireless_run") { + levels.boost(Skill.Agility, 2) + restoreRunEnergy() + } + } + + // Elemental titans - Titan's Constitution: Defence +12.5% and heal 8. + FamiliarSpecialMoves.instant("fire_titan_familiar", "moss_titan_familiar", "ice_titan_familiar") { + familiarSelfSpecial { + levels.boost(Skill.Defence, multiplier = 0.125) + levels.restore(Skill.Constitution, 8) + } + } + + // Healing Aura - heal the owner by 15% of their max hitpoints. + FamiliarSpecialMoves.instant("unicorn_stallion_familiar") { + familiarSelfSpecial(anim = "healing_aura", playerGfx = "healing_aura") { + levels.restore(Skill.Constitution, ceil(levels.getMax(Skill.Constitution) * 0.15).toInt()) + } + } + + // Blood Drain - cure poison, restore drained stats by ~20%, then take 1-5 recoil damage. + FamiliarSpecialMoves.instant("bloated_leech_familiar") { + familiarSelfSpecial { + curePoison() + for (skill in Skill.values()) { + val offset = levels.get(skill) - levels.getMax(skill) + if (offset < 0) { + levels.restore(skill, ceil(levels.getMax(skill) * 0.2).toInt()) + } + } + directHit(random.nextInt(5) + 1, "damage") + } + } + + // Thieving Fingers - the Thieving boost is a passive (see FamiliarBoosts); the special is + // just the visual flourish, but still costs a scroll + points like the live game. + FamiliarSpecialMoves.instant("magpie_familiar") { + familiarSelfSpecial(anim = "thieving_fingers", sourceGfx = "thieving_fingers") {} + } + + // Insane Ferocity - charge the next attack. The next-attack consumption is not wired yet, so + // this currently sets the charge flag + plays the visuals only (TODO: buff the next swing). + FamiliarSpecialMoves.instant("honey_badger_familiar") { + if (this["familiar_insane_ferocity", false]) { + message("Your familiar is already enraged.") + return@instant false + } + familiarSelfSpecial(anim = "insane_ferocity", sourceGfx = "insane_ferocity") { + set("familiar_insane_ferocity", true) + } + } + } + + /** Boosts [skill] by [amount] above max with the familiar's [anim]/[sourceGfx] flourish. */ + private fun Player.boost(skill: Skill, amount: Int, anim: String? = null, sourceGfx: String? = null): Boolean = familiarSelfSpecial(anim = anim, sourceGfx = sourceGfx) { 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/FamiliarCombat.kt b/game/src/main/kotlin/content/skill/summoning/FamiliarCombat.kt index 8b9ab0bb52..c2c2e1cfc9 100644 --- a/game/src/main/kotlin/content/skill/summoning/FamiliarCombat.kt +++ b/game/src/main/kotlin/content/skill/summoning/FamiliarCombat.kt @@ -101,7 +101,9 @@ fun Player.updateFamiliarPvpForm() { } } -class FamiliarCombat : Script, CombatApi { +class FamiliarCombat : + Script, + CombatApi { init { // Explicit command (cast-on-target): pick the attack option from the follower details 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..77e6c30556 --- /dev/null +++ b/game/src/main/kotlin/content/skill/summoning/FamiliarCombatSpecials.kt @@ -0,0 +1,134 @@ +package content.skill.summoning + +import content.entity.effect.toxin.poison +import content.entity.proj.shoot +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.entity.character.Character +import world.gregs.voidps.engine.entity.character.mode.Retreat +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 + +/** + * 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") + familiar.shoot("spirit_wolf_howl_proj", target, height = 0, endHeight = 0) + target.mode = Retreat(target, familiar) + true + } + + // Straightforward "fire a projectile, deal up to maxHit" combat specials. + FamiliarSpecialMoves.npc("desert_wyrm_familiar") { target -> + familiarSpecialHit(target, maxHit = 5, anim = "electric_lash", sourceGfx = "electric_lash", projectile = "electric_lash_proj") + } + FamiliarSpecialMoves.npc("barker_toad_familiar") { target -> + familiarSpecialHit(target, maxHit = 8, sourceGfx = "toad_bark", targetGfx = "toad_bark_hit") + } + FamiliarSpecialMoves.npc("thorny_snail_familiar") { target -> + familiarSpecialHit(target, maxHit = 8, anim = "slime_spray", sourceGfx = "slime_spray", projectile = "slime_spray_proj", targetGfx = "slime_spray_hit") + } + FamiliarSpecialMoves.npc("arctic_bear_familiar") { target -> + familiarSpecialHit(target, maxHit = 15, anim = "arctic_blast", sourceGfx = "arctic_blast", projectile = "arctic_blast_proj", targetGfx = "arctic_blast_hit") + } + FamiliarSpecialMoves.npc("granite_lobster_familiar") { target -> + familiarSpecialHit(target, maxHit = 14, anim = "crushing_claw", sourceGfx = "crushing_claw", projectile = "crushing_claw_proj") + } + FamiliarSpecialMoves.npc("dreadfowl_familiar") { target -> + familiarSpecialHit(target, maxHit = 3, anim = "dreadfowl_strike", sourceGfx = "dreadfowl_strike", projectile = "dreadfowl_strike_proj") + } + + // Combat specials that also drain one of the target's stats. + FamiliarSpecialMoves.npc("spirit_jelly_familiar") { target -> + familiarSpecialHit(target, maxHit = 13, anim = "dissolve", projectile = "dissolve_proj").alsoDrain(target, Skill.Attack, 3) + } + FamiliarSpecialMoves.npc("spirit_larupia_familiar") { target -> + familiarSpecialHit(target, maxHit = 10, anim = "rending", sourceGfx = "rending", projectile = "rending_proj").alsoDrain(target, Skill.Strength, 1) + } + FamiliarSpecialMoves.npc("evil_turnip_familiar") { target -> + val cast = familiarSpecialHit(target, maxHit = 10, anim = "evil_flames", projectile = "evil_flames_proj", targetGfx = "evil_flames_hit").alsoDrain(target, Skill.Magic, 1) + if (cast) { + levels.restore(Skill.Constitution, 2) + } + cast + } + FamiliarSpecialMoves.npc("abyssal_parasite_familiar") { target -> + familiarSpecialHit(target, maxHit = 7, anim = "abyssal_drain", sourceGfx = "abyssal_drain", projectile = "abyssal_drain_proj").alsoDrain(target, Skill.Prayer, random.nextInt(3) + 1) + } + + // Poisonous Blast - small hit with a 50% chance to poison. + FamiliarSpecialMoves.npc("stranger_plant_familiar") { target -> + val cast = familiarSpecialHit(target, maxHit = 2, anim = "poisonous_blast", projectile = "poisonous_blast_proj", targetGfx = "poisonous_blast_hit") + if (cast && random.nextInt(2) == 0) { + follower?.poison(target, 20) + } + cast + } + + // Boil - geyser titan ranged hit (approximates the live def-bonus formula with a flat max). + FamiliarSpecialMoves.npc("geyser_titan_familiar") { target -> + familiarSpecialHit(target, maxHit = 20, anim = "boil", sourceGfx = "boil", projectile = "boil_proj", targetGfx = "boil_hit") + } + + // Vampyre Touch: 12 max, 40% chance to heal the owner 2. + FamiliarSpecialMoves.npc("vampire_bat_familiar") { target -> + val cast = familiarSpecialHit(target, maxHit = 12, anim = "vampire_touch", sourceGfx = "vampire_touch") + if (cast && random.nextInt(100) < 40) { + levels.restore(Skill.Constitution, 2) + } + 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 = 10, anim = "petrifying_gaze", sourceGfx = "petrifying_gaze", projectile = "petrifying_gaze_proj", targetGfx = "petrifying_gaze_hit") + .alsoDrain(target, skill, 3) + } + } + + // Minotaur family - Bull Rush: max hit scales with metal tier (stun TODO - needs a stun primitive). + val bullRush = mapOf( + "bronze_minotaur_familiar" to 4, + "iron_minotaur_familiar" to 6, + "steel_minotaur_familiar" to 9, + "mithril_minotaur_familiar" to 13, + "adamant_minotaur_familiar" to 16, + "rune_minotaur_familiar" to 20, + ) + 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") + } + } + } + + /** Drains [skill] on [target] by [amount] but only when the hit actually landed ([cast] true). */ + private fun Boolean.alsoDrain(target: Character, skill: Skill, amount: Int): Boolean { + if (this) { + target.levels.drain(skill, amount) + } + return this + } +} 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..371eb61be4 --- /dev/null +++ b/game/src/main/kotlin/content/skill/summoning/FamiliarSpecialHelpers.kt @@ -0,0 +1,102 @@ +package content.skill.summoning + +import content.area.wilderness.inPvp +import content.entity.combat.Target +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.player.Player +import world.gregs.voidps.engine.entity.character.player.skill.Skill +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) { + is Player -> if (!inPvp || !target.inPvp) { + if (!silent) message("You can only attack players in a player-vs-player area.") + 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. + */ +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, +): Boolean { + if (!familiarCanSpecial(target)) { + return false + } + val familiar = follower ?: return false + familiar.watch(target) + anim?.let { familiar.anim(it) } + sourceGfx?.let { familiar.gfx(it) } + projectile?.let { familiar.shoot(it, target) } + val damage = random.nextInt(maxHit + 1) + familiar.hit(target, offensiveType = type, damage = damage) + targetGfx?.let { target.gfx(it) } + if (engage) { + commandFamiliarAttack(target, silent = true) + } + return true +} + +/** + * 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 +} + +/** + * Boosts [skill] by [amount] above the player's current max, not stacking past it - the common + * shape of the stat-boost specials (Stony Shell, Testudo, Magic Focus, ...). Returns true. + */ +fun Player.familiarBoostSkill(skill: Skill, amount: Int): Boolean { + levels.boost(skill, amount) + return true +} 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..9542c1050f --- /dev/null +++ b/game/src/main/kotlin/content/skill/summoning/FamiliarSpecialMoves.kt @@ -0,0 +1,85 @@ +package content.skill.summoning + +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.entity.character.npc.NPC +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.entity.obj.GameObject + +/** + * 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, ...) + * + * Item-target specials (use a fish/egg/bar on the familiar) are wired separately with + * `itemOnNPCApproach` in their own scripts since they pick an inventory item, not a world target. + * + * 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>() + + 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 + } +} + +/** + * Single dispatcher routing every `familiar_details:cast_*` interaction to the move registered for + * the current follower. 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 { + interfaceOption("*", "familiar_details:cast_*") { + val id = follower?.id ?: return@interfaceOption + val block = FamiliarSpecialMoves.instant[id] ?: return@interfaceOption + castFamiliarSpecial { block() } + } + + onNPCApproach("familiar_details:cast_*", "*") { (target) -> + approachRange(16) + val id = follower?.id ?: return@onNPCApproach + val block = FamiliarSpecialMoves.npcTarget[id] ?: return@onNPCApproach + castFamiliarSpecial { block(target) } + } + + onPlayerApproach("familiar_details:cast_*") { (target) -> + approachRange(16) + val id = follower?.id ?: return@onPlayerApproach + val block = FamiliarSpecialMoves.playerTarget[id] ?: return@onPlayerApproach + castFamiliarSpecial { block(target) } + } + + onObjectApproach("familiar_details:cast_*", "*") { (target) -> + approachRange(16) + val id = follower?.id ?: return@onObjectApproach + val block = FamiliarSpecialMoves.objectTarget[id] ?: return@onObjectApproach + castFamiliarSpecial { block(target) } + } + } +} diff --git a/game/src/main/kotlin/content/skill/summoning/FamiliarSpecialStubs.kt b/game/src/main/kotlin/content/skill/summoning/FamiliarSpecialStubs.kt new file mode 100644 index 0000000000..ff9f220248 --- /dev/null +++ b/game/src/main/kotlin/content/skill/summoning/FamiliarSpecialStubs.kt @@ -0,0 +1,72 @@ +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 + +/** + * Special moves not yet ported. They are registered under the interaction kind the live move uses + * so clicking the cast button gives feedback, but each returns false from [castFamiliarSpecial] so + * no scroll or special-move points are spent. Grouped by what each still needs: + * + * - Item interactions (cook/incubate/smelt): Bunyip, Spirit cobra, Pyre lord + * - Skill-system scenery: Beaver (Multichop), Hydra (Regrowth), Compost mound (Generate Compost) + * - "Charge the next attack" buffs: Spirit scorpion, Iron titan, Steel titan, Honey badger is done + * - AoE hits: Smoke devil, Giant chinchompa, Spirit kalphite + * - Item creation: Spirit spider (Egg Spawn) + * - Pest Control teleport (area not implemented in void): the four Void familiars' Call to Arms + * - Misc combat: Forge regent (disarm), Ravenous locust (Famine), Karamthulhu, Spirit dagannoth, + * Talon beast, Swamp titan, Lava titan, Praying mantis, Giant ent + * + * The Gorajo (bloodrager/deathslinger/...), meerkat and phoenix familiars have no 2009-era special + * and so are intentionally left unregistered (the cast button does nothing for them). + */ +class FamiliarSpecialStubs : Script { + + private val notImplemented = "Your familiar's special move isn't available yet." + + init { + // Combat specials that target an npc. + FamiliarSpecialMoves.npc( + "ravenous_locust_familiar", + "karamthulhu_overlord_familiar", + "spirit_dagannoth_familiar", + "talon_beast_familiar", + "swamp_titan_familiar", + "lava_titan_familiar", + "praying_mantis_familiar", + "giant_ent_familiar", + ) { _ -> stub() } + + // Forge regent's Inferno disarms another player. + FamiliarSpecialMoves.player("forge_regent_familiar") { _ -> stub() } + + // Instant self / AoE / charge specials. + FamiliarSpecialMoves.instant( + "smoke_devil_familiar", "giant_chinchompa_familiar", "spirit_kalphite_familiar", + "spirit_scorpion_familiar", "iron_titan_familiar", "steel_titan_familiar", + "spirit_spider_familiar", + "void_ravager_familiar", "void_shifter_familiar", "void_spinner_familiar", "void_torcher_familiar", + ) { stub() } + + // Scenery-target specials. + FamiliarSpecialMoves.obj("beaver_familiar", "hydra_familiar", "compost_mound_familiar") { _ -> stub() } + + // Item-target specials (use an item on the familiar). + itemOnNPCApproach("*", "bunyip_familiar") { handleItemStub() } + itemOnNPCApproach("*", "spirit_cobra_familiar") { handleItemStub() } + itemOnNPCApproach("*", "pyrelord_familiar") { handleItemStub() } + } + + private fun Player.stub(): Boolean { + message(notImplemented) + return false + } + + private fun Player.handleItemStub() { + if (follower == null) { + return + } + castFamiliarSpecial { stub() } + } +} 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..37f6c673bc --- /dev/null +++ b/game/src/main/kotlin/content/skill/summoning/FamiliarUtilitySpecials.kt @@ -0,0 +1,122 @@ +package content.skill.summoning + +import content.entity.player.bank.bank +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.item.floor.FloorItems +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 + +/** + * 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 is wired with `itemOnNPCApproach`. + */ +class FamiliarUtilitySpecials : Script { + + private val grimyHerbs = listOf( + "grimy_guam", + "grimy_marrentill", + "grimy_tarromin", + "grimy_harralander", + "grimy_ranarr", + "grimy_irit", + "grimy_avantoe", + "grimy_kwuarm", + ) + private val fruit = listOf("orange", "banana", "lemon", "lime", "papaya_fruit") + private val rawFish = listOf("raw_trout", "raw_salmon", "raw_cod", "raw_pike") + + init { + // Goad / Pester - the special simply sends the familiar at the target (with a Hunter feel). + FamiliarSpecialMoves.npc("spirit_graahk_familiar", "spirit_mosquito_familiar") { target -> + if (!familiarCanSpecial(target)) { + return@npc false + } + commandFamiliarAttack(target, silent = true) + true + } + + // Ambush - teleport the familiar to the owner, ready to strike. + FamiliarSpecialMoves.instant("spirit_kyatt_familiar") { + callFollower() + true + } + + // Cheese Feast - the albino rat produces a cheese. + FamiliarSpecialMoves.instant("albino_rat_familiar") { + familiarSelfSpecial { + if (!inventory.add("cheese")) { + FloorItems.add(tile, "cheese", disappearTicks = 300, owner = this) + } + } + } + + // Herbcall - drop a random grimy herb at the player's feet. + FamiliarSpecialMoves.instant("macaw_familiar") { + dropForage(grimyHerbs[random.nextInt(grimyHerbs.size)]) + } + + // Fruitfall - drop a papaya plus a few random fruits. + FamiliarSpecialMoves.instant("fruit_bat_familiar") { + dropForage("papaya_fruit") + repeat(random.nextInt(4)) { + dropForage(fruit[random.nextInt(fruit.size)]) + } + true + } + + // Fish Rain - drop one or two random raw fish. + FamiliarSpecialMoves.instant("ibis_familiar") { + repeat(random.nextInt(2) + 1) { + dropForage(rawFish[random.nextInt(rawFish.size)]) + } + true + } + + // Essence Shipment - bank all rune and pure essence carried. + FamiliarSpecialMoves.instant("abyssal_titan_familiar") { + val moved = bankAll("rune_essence") + bankAll("pure_essence") + if (moved == 0) { + message("You have no essence for your familiar to bank.") + return@instant false + } + message("Your familiar banks your essence.") + true + } + + // Winter Storage - bank the item the player uses on the pack yak. + itemOnNPCApproach("*", "pack_yak_familiar") { (npc, item) -> + if (npc != follower) { + return@itemOnNPCApproach + } + castFamiliarSpecial { + if (!inventory.contains(item.id)) { + return@castFamiliarSpecial false + } + inventory.remove(item.id, 1) + bank.add(item.id, 1) + true + } + } + } + + /** Drops [id] on the player's tile (it despawns after ~3 minutes), counting as a successful cast. */ + private fun Player.dropForage(id: String): Boolean { + FloorItems.add(tile, id, disappearTicks = 300, owner = this) + return 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 + } +} diff --git a/game/src/main/kotlin/content/skill/summoning/Summoning.kt b/game/src/main/kotlin/content/skill/summoning/Summoning.kt index 2055ca64f3..178654f905 100644 --- a/game/src/main/kotlin/content/skill/summoning/Summoning.kt +++ b/game/src/main/kotlin/content/skill/summoning/Summoning.kt @@ -11,6 +11,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 @@ -69,6 +71,8 @@ fun Player.summonFamiliar(familiar: NPCDefinition, restart: Boolean) { 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) { @@ -103,6 +107,7 @@ 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") @@ -214,6 +219,63 @@ fun Player.useFamiliarSpecial(cost: Int, action: () -> Unit) { 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 + } + if (!inventory.contains(scrollDef.stringId)) { + 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 + } + if (!effect()) { + return + } + inventory.remove(scrollDef.stringId, 1) + val points = get("summoning_special_points_remaining", 0) + set("summoning_special_points_remaining", (points - cost).coerceAtLeast(0)) + start("familiar_special_delay", 3) + 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. diff --git a/game/src/main/kotlin/content/skill/summoning/familiar/LavaTitan.kt b/game/src/main/kotlin/content/skill/summoning/familiar/LavaTitan.kt index 886b25e76e..5b8f56a6fc 100644 --- a/game/src/main/kotlin/content/skill/summoning/familiar/LavaTitan.kt +++ b/game/src/main/kotlin/content/skill/summoning/familiar/LavaTitan.kt @@ -1,11 +1,11 @@ package content.skill.summoning.familiar -import content.skill.summoning.follower 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.follower import world.gregs.voidps.engine.Script import world.gregs.voidps.engine.entity.character.move.tele import world.gregs.voidps.engine.entity.character.player.Player diff --git a/game/src/main/kotlin/content/skill/summoning/familiar/Pyrelord.kt b/game/src/main/kotlin/content/skill/summoning/familiar/Pyrelord.kt index 598db91be1..8790e1c897 100644 --- a/game/src/main/kotlin/content/skill/summoning/familiar/Pyrelord.kt +++ b/game/src/main/kotlin/content/skill/summoning/familiar/Pyrelord.kt @@ -1,10 +1,10 @@ package content.skill.summoning.familiar -import content.skill.summoning.follower 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.data.definition.Rows diff --git a/game/src/main/kotlin/content/skill/summoning/familiar/UnicornStallion.kt b/game/src/main/kotlin/content/skill/summoning/familiar/UnicornStallion.kt index ec156cb5f0..41043509cb 100644 --- a/game/src/main/kotlin/content/skill/summoning/familiar/UnicornStallion.kt +++ b/game/src/main/kotlin/content/skill/summoning/familiar/UnicornStallion.kt @@ -1,11 +1,11 @@ package content.skill.summoning.familiar -import content.skill.summoning.follower -import content.skill.summoning.useFamiliarSpecial 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 diff --git a/game/src/main/kotlin/content/skill/thieving/Pickpocketing.kt b/game/src/main/kotlin/content/skill/thieving/Pickpocketing.kt index 95a3749ae2..1d46ec9864 100644 --- a/game/src/main/kotlin/content/skill/thieving/Pickpocketing.kt +++ b/game/src/main/kotlin/content/skill/thieving/Pickpocketing.kt @@ -1,9 +1,9 @@ package content.skill.thieving -import content.skill.summoning.familiarBoost 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 diff --git a/game/src/test/kotlin/content/entity/combat/CombatMovementTest.kt b/game/src/test/kotlin/content/entity/combat/CombatMovementTest.kt index 9aaf9ba0bf..4c14e011d2 100644 --- a/game/src/test/kotlin/content/entity/combat/CombatMovementTest.kt +++ b/game/src/test/kotlin/content/entity/combat/CombatMovementTest.kt @@ -1,18 +1,20 @@ 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 world.gregs.voidps.engine.client.variable.start 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 @@ -20,9 +22,7 @@ import world.gregs.voidps.engine.entity.character.mode.combat.CombatMovement import world.gregs.voidps.engine.entity.character.move.running import world.gregs.voidps.engine.entity.character.move.tele import world.gregs.voidps.engine.entity.character.player.skill.Skill -import content.skill.melee.weapon.attackSpeed import world.gregs.voidps.engine.inv.equipment -import org.rsmod.game.pathfinder.flag.CollisionFlag import world.gregs.voidps.network.login.protocol.visual.update.player.EquipSlot import world.gregs.voidps.type.Tile import world.gregs.voidps.type.setRandom 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..1240b1cc33 --- /dev/null +++ b/game/src/test/kotlin/content/skill/summoning/FamiliarSpecialMoveTest.kt @@ -0,0 +1,91 @@ +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)) + } +} From 6a26ed0f88a31a3ef4d4a869536eb94b1179d6cc Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Wed, 1 Jul 2026 05:11:13 -0700 Subject: [PATCH 038/136] adjust howl special move projectile for spirit wolf familiar. --- data/skill/summoning/summoning_special.gfx.toml | 8 ++++---- .../content/skill/summoning/FamiliarCombatSpecials.kt | 9 +++++++-- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/data/skill/summoning/summoning_special.gfx.toml b/data/skill/summoning/summoning_special.gfx.toml index 971091c409..a3ab945c02 100644 --- a/data/skill/summoning/summoning_special.gfx.toml +++ b/data/skill/summoning/summoning_special.gfx.toml @@ -7,10 +7,10 @@ id = 1334 [spirit_wolf_howl_proj] id = 1333 -delay = 50 # wind-up before the tornado leaves the wolf -time_offset = 56 # 46 base + 10 to offset Void's distance-1: flight = 56 + (dist-1)*10 = 46 + dist*10 -multiplier = 10 # magic-speed slope per tile, matching Projectile.magic -curve = 5 # angle from Projectile.magic(this, npc, 1333, 40, 36, 50, 5) +delay = 50 +time_offset = 56 +multiplier = 1 +curve = 0 [electric_lash] id = 1410 diff --git a/game/src/main/kotlin/content/skill/summoning/FamiliarCombatSpecials.kt b/game/src/main/kotlin/content/skill/summoning/FamiliarCombatSpecials.kt index 77e6c30556..2c8a870cad 100644 --- a/game/src/main/kotlin/content/skill/summoning/FamiliarCombatSpecials.kt +++ b/game/src/main/kotlin/content/skill/summoning/FamiliarCombatSpecials.kt @@ -7,6 +7,8 @@ import world.gregs.voidps.engine.entity.character.Character import world.gregs.voidps.engine.entity.character.mode.Retreat import world.gregs.voidps.engine.entity.character.npc.NPC import world.gregs.voidps.engine.entity.character.player.skill.Skill +import world.gregs.voidps.engine.queue.queue +import world.gregs.voidps.engine.timer.CLIENT_TICKS import world.gregs.voidps.type.random /** @@ -25,8 +27,11 @@ class FamiliarCombatSpecials : Script { familiar.watch(target) familiar.anim("spirit_wolf_howl") familiar.gfx("spirit_wolf_howl") - familiar.shoot("spirit_wolf_howl_proj", target, height = 0, endHeight = 0) - target.mode = Retreat(target, familiar) + 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 } From 6b8b9eec99c9fd409acd7528348ae7d9f8bc015e Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Wed, 1 Jul 2026 10:49:21 -0700 Subject: [PATCH 039/136] fix: familiar special move casting, projectiles, hitsplats - Name familiar_details cast button component (79) so target specials actually dispatch instead of "Nothing interesting happens". - Tune Dreadfowl Strike projectile (delay/speed/curve) and remove the stray magic-bolt projectile from dreadfowl's normal melee attack. - Sync special-hit damage + impact gfx to projectile flight time so the hit lands with the projectile instead of on a fixed delay. - Attribute a familiar's hitsplats to its owner so the owner sees the blue 0/miss (block) splat, previously hidden from non-participants. - Stop target familiar specials firing twice: use approachRange(update=false) in the dispatch handlers and set the cooldown before the effect runs. Adds a re-entrancy regression test. --- data/skill/summoning/summoning.combat.toml | 1 - data/skill/summoning/summoning.ifaces.toml | 3 +++ data/skill/summoning/summoning_special.gfx.toml | 4 ++++ .../content/entity/combat/hit/CombatHitsplats.kt | 9 ++++++++- .../skill/summoning/FamiliarSpecialHelpers.kt | 15 ++++++++++++--- .../skill/summoning/FamiliarSpecialMoves.kt | 6 +++--- .../kotlin/content/skill/summoning/Summoning.kt | 5 ++++- .../skill/summoning/FamiliarSpecialMoveTest.kt | 15 +++++++++++++++ 8 files changed, 49 insertions(+), 9 deletions(-) diff --git a/data/skill/summoning/summoning.combat.toml b/data/skill/summoning/summoning.combat.toml index 2bd1e6cf31..b4c0ae3412 100644 --- a/data/skill/summoning/summoning.combat.toml +++ b/data/skill/summoning/summoning.combat.toml @@ -21,7 +21,6 @@ death_anim = "dreadfowl_familiar_death" [dreadfowl_familiar.melee] range = 1 anim = "dreadfowl_familiar_attack" -projectile = "dreadfowl_familiar_projectile" target_hit = { offense = "crush", max = 40 } [spirit_spider_familiar] 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_special.gfx.toml b/data/skill/summoning/summoning_special.gfx.toml index a3ab945c02..d810a1df26 100644 --- a/data/skill/summoning/summoning_special.gfx.toml +++ b/data/skill/summoning/summoning_special.gfx.toml @@ -53,6 +53,10 @@ id = 1523 [dreadfowl_strike_proj] id = 1318 +delay = 51 +time_offset = 46 +multiplier = 1 +curve = 0 [dissolve_proj] id = 1360 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..4826840cf7 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,14 @@ 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)) + // A familiar's hits are attributed to its owner (positive player index) rather than the + // familiar npc, so the owner counts as a participant and the client shows them the splat - + // including the blue 0/miss splat, which is otherwise hidden from non-participants. + val sourceIndex = when { + 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/skill/summoning/FamiliarSpecialHelpers.kt b/game/src/main/kotlin/content/skill/summoning/FamiliarSpecialHelpers.kt index 371eb61be4..b9c2683266 100644 --- a/game/src/main/kotlin/content/skill/summoning/FamiliarSpecialHelpers.kt +++ b/game/src/main/kotlin/content/skill/summoning/FamiliarSpecialHelpers.kt @@ -9,6 +9,8 @@ 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.skill.Skill +import world.gregs.voidps.engine.queue.queue +import world.gregs.voidps.engine.timer.CLIENT_TICKS import world.gregs.voidps.type.random /** @@ -69,10 +71,17 @@ fun Player.familiarSpecialHit( familiar.watch(target) anim?.let { familiar.anim(it) } sourceGfx?.let { familiar.gfx(it) } - projectile?.let { familiar.shoot(it, target) } + // 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) - familiar.hit(target, offensiveType = type, damage = damage) - targetGfx?.let { target.gfx(it) } + 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) } + } if (engage) { commandFamiliarAttack(target, silent = true) } diff --git a/game/src/main/kotlin/content/skill/summoning/FamiliarSpecialMoves.kt b/game/src/main/kotlin/content/skill/summoning/FamiliarSpecialMoves.kt index 9542c1050f..aa5cdec19f 100644 --- a/game/src/main/kotlin/content/skill/summoning/FamiliarSpecialMoves.kt +++ b/game/src/main/kotlin/content/skill/summoning/FamiliarSpecialMoves.kt @@ -62,21 +62,21 @@ class FamiliarSpecialMovesDispatch : Script { } onNPCApproach("familiar_details:cast_*", "*") { (target) -> - approachRange(16) + 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) + 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) + approachRange(16, update = false) val id = follower?.id ?: return@onObjectApproach val block = FamiliarSpecialMoves.objectTarget[id] ?: return@onObjectApproach castFamiliarSpecial { block(target) } diff --git a/game/src/main/kotlin/content/skill/summoning/Summoning.kt b/game/src/main/kotlin/content/skill/summoning/Summoning.kt index 178654f905..4f28dfd5af 100644 --- a/game/src/main/kotlin/content/skill/summoning/Summoning.kt +++ b/game/src/main/kotlin/content/skill/summoning/Summoning.kt @@ -266,13 +266,16 @@ fun Player.castFamiliarSpecial(effect: () -> Boolean) { 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 } inventory.remove(scrollDef.stringId, 1) val points = get("summoning_special_points_remaining", 0) set("summoning_special_points_remaining", (points - cost).coerceAtLeast(0)) - start("familiar_special_delay", 3) exp(Skill.Summoning, scrollDef["use_experience", 0.0]) } diff --git a/game/src/test/kotlin/content/skill/summoning/FamiliarSpecialMoveTest.kt b/game/src/test/kotlin/content/skill/summoning/FamiliarSpecialMoveTest.kt index 1240b1cc33..a612df40f1 100644 --- a/game/src/test/kotlin/content/skill/summoning/FamiliarSpecialMoveTest.kt +++ b/game/src/test/kotlin/content/skill/summoning/FamiliarSpecialMoveTest.kt @@ -88,4 +88,19 @@ class FamiliarSpecialMoveTest : WorldTest() { 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)) + } } From 0bb2d0f4b8e4cbd189245dea897d979c473640c4 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Wed, 1 Jul 2026 11:42:17 -0700 Subject: [PATCH 040/136] fix: wire albino rat as a forager with Cheese Feast - Configure albino rat as a beast-of-burden forager (capacity 30) with a forage_albino_rat table producing cheese, so its "Withdraw"/"Take" option opens the familiar inventory (was "Nothing"/"can't carry"). - Cheese Feast special now produces 4 cheese into the familiar's own inventory and plays the player summoning-cast animation (7660) with the cheese gfx (1384) on the rat, matching 2009scape. --- data/skill/summoning/summoning.drops.toml | 5 +++++ data/skill/summoning/summoning.npcs.toml | 2 ++ .../summoning/summoning_special.anims.toml | 4 ++++ .../skill/summoning/summoning_special.gfx.toml | 4 ++++ .../skill/summoning/FamiliarUtilitySpecials.kt | 18 ++++++++++++++---- .../skill/summoning/FamiliarSpecialMoveTest.kt | 10 ++++++++-- 6 files changed, 37 insertions(+), 6 deletions(-) diff --git a/data/skill/summoning/summoning.drops.toml b/data/skill/summoning/summoning.drops.toml index cbaffc5656..f5aae8281b 100644 --- a/data/skill/summoning/summoning.drops.toml +++ b/data/skill/summoning/summoning.drops.toml @@ -156,6 +156,11 @@ drops = [{ id = "strange_fruit" }] 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 diff --git a/data/skill/summoning/summoning.npcs.toml b/data/skill/summoning/summoning.npcs.toml index 076c67d87b..d1cd098a81 100644 --- a/data/skill/summoning/summoning.npcs.toml +++ b/data/skill/summoning/summoning.npcs.toml @@ -403,6 +403,8 @@ def = 22 range = 22 mage = 22 combat_def = "albino_rat_familiar" +summoning_beast_of_burden = 1 +summoning_beast_of_burden_capacity = 30 large_head = true interacts = false diff --git a/data/skill/summoning/summoning_special.anims.toml b/data/skill/summoning/summoning_special.anims.toml index 44fc05e09e..c8524112bd 100644 --- a/data/skill/summoning/summoning_special.anims.toml +++ b/data/skill/summoning/summoning_special.anims.toml @@ -72,3 +72,7 @@ id = 8020 [insane_ferocity] id = 7928 + +# Player animation played while casting a special-move scroll (2009scape base visualizeSpecialMove). +[summoning_special_cast] +id = 7660 diff --git a/data/skill/summoning/summoning_special.gfx.toml b/data/skill/summoning/summoning_special.gfx.toml index d810a1df26..98fa3121fe 100644 --- a/data/skill/summoning/summoning_special.gfx.toml +++ b/data/skill/summoning/summoning_special.gfx.toml @@ -139,3 +139,7 @@ id = 1336 [insane_ferocity] id = 1397 + +# Forage specials +[cheese_feast] +id = 1384 diff --git a/game/src/main/kotlin/content/skill/summoning/FamiliarUtilitySpecials.kt b/game/src/main/kotlin/content/skill/summoning/FamiliarUtilitySpecials.kt index 37f6c673bc..d15be674a0 100644 --- a/game/src/main/kotlin/content/skill/summoning/FamiliarUtilitySpecials.kt +++ b/game/src/main/kotlin/content/skill/summoning/FamiliarUtilitySpecials.kt @@ -6,6 +6,7 @@ import world.gregs.voidps.engine.client.message import world.gregs.voidps.engine.entity.character.player.Player import world.gregs.voidps.engine.entity.item.floor.FloorItems 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.type.random @@ -46,11 +47,20 @@ class FamiliarUtilitySpecials : Script { true } - // Cheese Feast - the albino rat produces a cheese. + // Cheese Feast - the albino rat produces 4 cheese into its own inventory FamiliarSpecialMoves.instant("albino_rat_familiar") { - familiarSelfSpecial { - if (!inventory.add("cheese")) { - FloorItems.add(tile, "cheese", disappearTicks = 300, owner = this) + ensureBeastOfBurdenInventory() + val free = beastOfBurdenCapacity - beastOfBurden.items.count { it.isNotEmpty() } + if (free < 4) { + message("Your familiar is too full to collect items.") + return@instant false + } + // Player plays the summoning special-cast animation; the rat shows the cheese gfx. + anim("summoning_special_cast") + familiarSelfSpecial(sourceGfx = "cheese_feast") { + beastOfBurden.add("cheese", 4) + if (interfaces.contains("beast_of_burden")) { + syncBeastOfBurdenInterface() } } } diff --git a/game/src/test/kotlin/content/skill/summoning/FamiliarSpecialMoveTest.kt b/game/src/test/kotlin/content/skill/summoning/FamiliarSpecialMoveTest.kt index a612df40f1..7692545966 100644 --- a/game/src/test/kotlin/content/skill/summoning/FamiliarSpecialMoveTest.kt +++ b/game/src/test/kotlin/content/skill/summoning/FamiliarSpecialMoveTest.kt @@ -96,8 +96,14 @@ class FamiliarSpecialMoveTest : WorldTest() { // 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 } + player.castFamiliarSpecial { + effects++ + true + } + player.castFamiliarSpecial { + effects++ + true + } assertEquals(1, effects) assertEquals(4, player.inventory.count("howl_scroll")) From 0aa89e506dced7a011bc1e6b1b6d2272b78b4dab Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Wed, 1 Jul 2026 19:43:00 -0700 Subject: [PATCH 041/136] Adjust player animation to correct animation for renewing summoning points at obelisk. --- data/skill/summoning/summoning.anims.toml | 3 +++ game/src/main/kotlin/content/skill/summoning/Summoning.kt | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/data/skill/summoning/summoning.anims.toml b/data/skill/summoning/summoning.anims.toml index 650c7150ce..169cd18d83 100644 --- a/data/skill/summoning/summoning.anims.toml +++ b/data/skill/summoning/summoning.anims.toml @@ -1,6 +1,9 @@ [summoning_infuse] id = 8500 +[summoning_renew] +id = 8502 + [expression_wolf_happy] id = 8372 diff --git a/game/src/main/kotlin/content/skill/summoning/Summoning.kt b/game/src/main/kotlin/content/skill/summoning/Summoning.kt index 4f28dfd5af..722beaff88 100644 --- a/game/src/main/kotlin/content/skill/summoning/Summoning.kt +++ b/game/src/main/kotlin/content/skill/summoning/Summoning.kt @@ -198,7 +198,7 @@ fun Player.renewSummoningPoints() { message("You already have full summoning points.") } else { levels.set(Skill.Summoning, levels.getMax(Skill.Summoning)) - anim("summoning_infuse") + anim("summoning_renew") message("You renew your summoning points at the obelisk.") } } From 71a91ac6db6fe5ffa8668a450013844b5bf4113f Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Thu, 2 Jul 2026 07:07:50 -0700 Subject: [PATCH 042/136] Adjust player animation to correct animation for renewing summoning points at obelisk. --- data/skill/summoning/summoning.anims.toml | 2 + data/skill/summoning/summoning.gfx.toml | 6 +++ .../content/skill/summoning/Summoning.kt | 18 ++++++-- .../skill/summoning/SummoningObeliskTest.kt | 44 ++++++++++++++++++- 4 files changed, 65 insertions(+), 5 deletions(-) diff --git a/data/skill/summoning/summoning.anims.toml b/data/skill/summoning/summoning.anims.toml index 169cd18d83..5263593013 100644 --- a/data/skill/summoning/summoning.anims.toml +++ b/data/skill/summoning/summoning.anims.toml @@ -3,6 +3,8 @@ id = 8500 [summoning_renew] id = 8502 +walk = false +run = false [expression_wolf_happy] id = 8372 diff --git a/data/skill/summoning/summoning.gfx.toml b/data/skill/summoning/summoning.gfx.toml index b5563b2c27..8216c6fd46 100644 --- a/data/skill/summoning/summoning.gfx.toml +++ b/data/skill/summoning/summoning.gfx.toml @@ -1,3 +1,9 @@ +[summoning_renew_obelisk] +id = 1516 + +[summoning_renew_player] +id = 1517 + [unicorn_stallion_cure] id = 1356 diff --git a/game/src/main/kotlin/content/skill/summoning/Summoning.kt b/game/src/main/kotlin/content/skill/summoning/Summoning.kt index 722beaff88..0a807ae7b9 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 @@ -28,6 +29,7 @@ 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.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 @@ -190,15 +192,23 @@ fun Player.callFollower() { 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_renew") + areaGfx("summoning_renew_player", tile) message("You renew your summoning points at the obelisk.") } } @@ -308,8 +318,8 @@ fun Player.renewFamiliar() { class Summoning : Script { init { - objectOperate("Renew-points") { - renewSummoningPoints() + objectOperate("Renew-points") { (target) -> + renewSummoningPoints(target) } itemOption("Summon", "*_pouch") { option -> 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)) From 08a32b2c660d1ea12ed08edc367b57e61e0c0018 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Thu, 2 Jul 2026 09:57:21 -0700 Subject: [PATCH 043/136] Feat: add egg spawn special move for spirit spider familiar --- .../summoning/summoning_special.anims.toml | 12 ++ .../summoning/summoning_special.gfx.toml | 18 +++ .../skill/summoning/FamiliarBoostSpecials.kt | 26 ++++ .../skill/summoning/FamiliarCombatSpecials.kt | 10 ++ .../skill/summoning/FamiliarSpecialHelpers.kt | 55 ++++++++ .../skill/summoning/FamiliarSpecialMoves.kt | 38 +++++- .../skill/summoning/FamiliarSpecialStubs.kt | 10 +- .../summoning/FamiliarUtilitySpecials.kt | 26 ++++ .../summoning/FamiliarSpecialEffectTest.kt | 123 ++++++++++++++++++ 9 files changed, 309 insertions(+), 9 deletions(-) create mode 100644 game/src/test/kotlin/content/skill/summoning/FamiliarSpecialEffectTest.kt diff --git a/data/skill/summoning/summoning_special.anims.toml b/data/skill/summoning/summoning_special.anims.toml index c8524112bd..fd6991a917 100644 --- a/data/skill/summoning/summoning_special.anims.toml +++ b/data/skill/summoning/summoning_special.anims.toml @@ -45,6 +45,18 @@ 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 diff --git a/data/skill/summoning/summoning_special.gfx.toml b/data/skill/summoning/summoning_special.gfx.toml index 98fa3121fe..c6bf8a82d3 100644 --- a/data/skill/summoning/summoning_special.gfx.toml +++ b/data/skill/summoning/summoning_special.gfx.toml @@ -112,6 +112,24 @@ id = 1376 [boil_hit] id = 1377 +[venom_shot] +id = 1354 + +[venom_shot_owner] +id = 1355 + +[fireball_assault_hit] +id = 1329 + +[sandstorm] +id = 1350 + +[sandstorm_proj] +id = 1349 + +[egg_spawn] +id = 1342 + # Boost / heal specials [stony_shell] id = 1326 diff --git a/game/src/main/kotlin/content/skill/summoning/FamiliarBoostSpecials.kt b/game/src/main/kotlin/content/skill/summoning/FamiliarBoostSpecials.kt index 5d521334fd..6801291b93 100644 --- a/game/src/main/kotlin/content/skill/summoning/FamiliarBoostSpecials.kt +++ b/game/src/main/kotlin/content/skill/summoning/FamiliarBoostSpecials.kt @@ -2,10 +2,12 @@ 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 world.gregs.voidps.engine.Script import world.gregs.voidps.engine.client.message +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.type.random @@ -93,6 +95,30 @@ class FamiliarBoostSpecials : Script { set("familiar_insane_ferocity", true) } } + + // 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 + } + familiarSelfSpecial(anim = "venom_shot", sourceGfx = "venom_shot", playerGfx = "venom_shot_owner") { + set("familiar_venom_shot_charged", 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") + // Light poison - enough to actually tick under the poison decay model (weapon poison is 20+). + source.poison(attack.target, 6) } /** Boosts [skill] by [amount] above max with the familiar's [anim]/[sourceGfx] flourish. */ diff --git a/game/src/main/kotlin/content/skill/summoning/FamiliarCombatSpecials.kt b/game/src/main/kotlin/content/skill/summoning/FamiliarCombatSpecials.kt index 2c8a870cad..6ca78ea5e7 100644 --- a/game/src/main/kotlin/content/skill/summoning/FamiliarCombatSpecials.kt +++ b/game/src/main/kotlin/content/skill/summoning/FamiliarCombatSpecials.kt @@ -113,6 +113,16 @@ class FamiliarCombatSpecials : Script { } } + // Fireball Assault - the spirit Tz-Kih flings fire at up to two nearby foes. + FamiliarSpecialMoves.instant("spirit_tz-kih_familiar") { + familiarAoeSpecial(maxTargets = 2, maxHit = 7, 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 = 20, radius = 6, anim = "sandstorm", sourceGfx = "sandstorm", projectile = "sandstorm_proj") + } + // Minotaur family - Bull Rush: max hit scales with metal tier (stun TODO - needs a stun primitive). val bullRush = mapOf( "bronze_minotaur_familiar" to 4, diff --git a/game/src/main/kotlin/content/skill/summoning/FamiliarSpecialHelpers.kt b/game/src/main/kotlin/content/skill/summoning/FamiliarSpecialHelpers.kt index b9c2683266..c6755307fc 100644 --- a/game/src/main/kotlin/content/skill/summoning/FamiliarSpecialHelpers.kt +++ b/game/src/main/kotlin/content/skill/summoning/FamiliarSpecialHelpers.kt @@ -7,8 +7,10 @@ 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.character.player.skill.Skill +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 @@ -88,6 +90,59 @@ fun Player.familiarSpecialHit( 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 = mutableListOf() + for (tile in familiar.tile.spiral(radius)) { + for (character in NPCs.at(tile)) { + if (character in targets || !familiarCanSpecial(character, silent = true)) { + continue + } + targets.add(character) + if (targets.size >= maxTargets) { + break + } + } + if (targets.size >= maxTargets) { + break + } + } + 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 +} + /** * A self/owner buff special with no target: plays the follower's special visuals and runs [effect]. * Always counts as a successful cast. diff --git a/game/src/main/kotlin/content/skill/summoning/FamiliarSpecialMoves.kt b/game/src/main/kotlin/content/skill/summoning/FamiliarSpecialMoves.kt index aa5cdec19f..80f35a34ca 100644 --- a/game/src/main/kotlin/content/skill/summoning/FamiliarSpecialMoves.kt +++ b/game/src/main/kotlin/content/skill/summoning/FamiliarSpecialMoves.kt @@ -49,12 +49,16 @@ object FamiliarSpecialMoves { } /** - * Single dispatcher routing every `familiar_details:cast_*` interaction to the move registered for - * the current follower. 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. + * 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_*") { val id = follower?.id ?: return@interfaceOption val block = FamiliarSpecialMoves.instant[id] ?: return@interfaceOption @@ -81,5 +85,33 @@ class FamiliarSpecialMovesDispatch : Script { val block = FamiliarSpecialMoves.objectTarget[id] ?: return@onObjectApproach castFamiliarSpecial { block(target) } } + + // Minimap summoning orb "Cast " option - one `cast_` component per move. + interfaceOption("*", "summoning_orb:cast_*") { + val id = follower?.id ?: return@interfaceOption + val block = FamiliarSpecialMoves.instant[id] ?: return@interfaceOption + castFamiliarSpecial { block() } + } + + 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) } + } } } diff --git a/game/src/main/kotlin/content/skill/summoning/FamiliarSpecialStubs.kt b/game/src/main/kotlin/content/skill/summoning/FamiliarSpecialStubs.kt index ff9f220248..749c9791ba 100644 --- a/game/src/main/kotlin/content/skill/summoning/FamiliarSpecialStubs.kt +++ b/game/src/main/kotlin/content/skill/summoning/FamiliarSpecialStubs.kt @@ -11,9 +11,8 @@ import world.gregs.voidps.engine.entity.character.player.Player * * - Item interactions (cook/incubate/smelt): Bunyip, Spirit cobra, Pyre lord * - Skill-system scenery: Beaver (Multichop), Hydra (Regrowth), Compost mound (Generate Compost) - * - "Charge the next attack" buffs: Spirit scorpion, Iron titan, Steel titan, Honey badger is done - * - AoE hits: Smoke devil, Giant chinchompa, Spirit kalphite - * - Item creation: Spirit spider (Egg Spawn) + * - "Charge the next attack" buffs: Iron titan, Steel titan (Spirit scorpion + Honey badger are done) + * - AoE hits: Smoke devil, Giant chinchompa * - Pest Control teleport (area not implemented in void): the four Void familiars' Call to Arms * - Misc combat: Forge regent (disarm), Ravenous locust (Famine), Karamthulhu, Spirit dagannoth, * Talon beast, Swamp titan, Lava titan, Praying mantis, Giant ent @@ -43,9 +42,8 @@ class FamiliarSpecialStubs : Script { // Instant self / AoE / charge specials. FamiliarSpecialMoves.instant( - "smoke_devil_familiar", "giant_chinchompa_familiar", "spirit_kalphite_familiar", - "spirit_scorpion_familiar", "iron_titan_familiar", "steel_titan_familiar", - "spirit_spider_familiar", + "smoke_devil_familiar", "giant_chinchompa_familiar", + "iron_titan_familiar", "steel_titan_familiar", "void_ravager_familiar", "void_shifter_familiar", "void_spinner_familiar", "void_torcher_familiar", ) { stub() } diff --git a/game/src/main/kotlin/content/skill/summoning/FamiliarUtilitySpecials.kt b/game/src/main/kotlin/content/skill/summoning/FamiliarUtilitySpecials.kt index d15be674a0..1b74ceff76 100644 --- a/game/src/main/kotlin/content/skill/summoning/FamiliarUtilitySpecials.kt +++ b/game/src/main/kotlin/content/skill/summoning/FamiliarUtilitySpecials.kt @@ -1,14 +1,20 @@ package content.skill.summoning +import content.entity.gfx.areaGfx import content.entity.player.bank.bank +import org.rsmod.game.pathfinder.StepValidator 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.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.type.random /** @@ -65,6 +71,26 @@ class FamiliarUtilitySpecials : Script { } } + // 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) + } + } + } + } + // Herbcall - drop a random grimy herb at the player's feet. FamiliarSpecialMoves.instant("macaw_familiar") { dropForage(grimyHerbs[random.nextInt(grimyHerbs.size)]) 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..05ba39db6a --- /dev/null +++ b/game/src/test/kotlin/content/skill/summoning/FamiliarSpecialEffectTest.kt @@ -0,0 +1,123 @@ +package content.skill.summoning + +import FakeRandom +import WorldTest +import content.entity.combat.hit.hit +import content.entity.effect.toxin.poisoned +import interfaceOption +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.item.floor.FloorItems +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) + + @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)) + } + + @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 `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) + } +} From 9bbda6ddc361bd2c790458605cf8e2becc72ada8 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Thu, 2 Jul 2026 10:29:34 -0700 Subject: [PATCH 044/136] fix: allow npcs to retaliate against player owned familiars. --- .../character/mode/combat/CombatMovement.kt | 6 ++- .../kotlin/content/entity/combat/Combat.kt | 6 ++- .../kotlin/content/entity/combat/Target.kt | 14 ++++-- .../summoning/FamiliarRetaliationLeashTest.kt | 45 +++++++++++++++++++ .../summoning/FamiliarRetaliationTest.kt | 42 +++++++++++++++++ 5 files changed, 106 insertions(+), 7 deletions(-) create mode 100644 game/src/test/kotlin/content/skill/summoning/FamiliarRetaliationLeashTest.kt create mode 100644 game/src/test/kotlin/content/skill/summoning/FamiliarRetaliationTest.kt 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 34cd7d88e6..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 @@ -54,10 +54,12 @@ class CombatMovement( character.mode = EmptyMode return } - if (character is NPC && character["owner_index", -1] == -1) { + 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. + // 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)) { diff --git a/game/src/main/kotlin/content/entity/combat/Combat.kt b/game/src/main/kotlin/content/entity/combat/Combat.kt index fd3de6227c..925d168759 100644 --- a/game/src/main/kotlin/content/entity/combat/Combat.kt +++ b/game/src/main/kotlin/content/entity/combat/Combat.kt @@ -129,7 +129,11 @@ 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.leashAnchor() ?: return diff --git a/game/src/main/kotlin/content/entity/combat/Target.kt b/game/src/main/kotlin/content/entity/combat/Target.kt index 28807d5b29..42b0bcb6eb 100644 --- a/game/src/main/kotlin/content/entity/combat/Target.kt +++ b/game/src/main/kotlin/content/entity/combat/Target.kt @@ -50,12 +50,18 @@ object Target { return false } } - 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/test/kotlin/content/skill/summoning/FamiliarRetaliationLeashTest.kt b/game/src/test/kotlin/content/skill/summoning/FamiliarRetaliationLeashTest.kt new file mode 100644 index 0000000000..cfbbfea393 --- /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.player.skill.Skill +import world.gregs.voidps.engine.entity.character.move.tele +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") + } +} From f01e6b86eb4e2e5fb3a7489383e027be2d49bf33 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Thu, 2 Jul 2026 10:30:04 -0700 Subject: [PATCH 045/136] fix formatting - ran spotless apply --- .../content/skill/summoning/FamiliarSpecialStubs.kt | 11 ++++++++--- .../skill/summoning/FamiliarRetaliationLeashTest.kt | 2 +- .../skill/summoning/FamiliarSpecialEffectTest.kt | 3 +-- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/game/src/main/kotlin/content/skill/summoning/FamiliarSpecialStubs.kt b/game/src/main/kotlin/content/skill/summoning/FamiliarSpecialStubs.kt index 749c9791ba..0160ac79ea 100644 --- a/game/src/main/kotlin/content/skill/summoning/FamiliarSpecialStubs.kt +++ b/game/src/main/kotlin/content/skill/summoning/FamiliarSpecialStubs.kt @@ -42,9 +42,14 @@ class FamiliarSpecialStubs : Script { // Instant self / AoE / charge specials. FamiliarSpecialMoves.instant( - "smoke_devil_familiar", "giant_chinchompa_familiar", - "iron_titan_familiar", "steel_titan_familiar", - "void_ravager_familiar", "void_shifter_familiar", "void_spinner_familiar", "void_torcher_familiar", + "smoke_devil_familiar", + "giant_chinchompa_familiar", + "iron_titan_familiar", + "steel_titan_familiar", + "void_ravager_familiar", + "void_shifter_familiar", + "void_spinner_familiar", + "void_torcher_familiar", ) { stub() } // Scenery-target specials. diff --git a/game/src/test/kotlin/content/skill/summoning/FamiliarRetaliationLeashTest.kt b/game/src/test/kotlin/content/skill/summoning/FamiliarRetaliationLeashTest.kt index cfbbfea393..6d1a6d02e7 100644 --- a/game/src/test/kotlin/content/skill/summoning/FamiliarRetaliationLeashTest.kt +++ b/game/src/test/kotlin/content/skill/summoning/FamiliarRetaliationLeashTest.kt @@ -7,8 +7,8 @@ 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.engine.entity.character.move.tele +import world.gregs.voidps.engine.entity.character.player.skill.Skill import world.gregs.voidps.type.setRandom /** diff --git a/game/src/test/kotlin/content/skill/summoning/FamiliarSpecialEffectTest.kt b/game/src/test/kotlin/content/skill/summoning/FamiliarSpecialEffectTest.kt index 05ba39db6a..f75e96dba0 100644 --- a/game/src/test/kotlin/content/skill/summoning/FamiliarSpecialEffectTest.kt +++ b/game/src/test/kotlin/content/skill/summoning/FamiliarSpecialEffectTest.kt @@ -34,8 +34,7 @@ class FamiliarSpecialEffectTest : WorldTest() { return player } - private fun Player.runSpecial(familiar: String): Boolean = - FamiliarSpecialMoves.instant.getValue(familiar).invoke(this) + private fun Player.runSpecial(familiar: String): Boolean = FamiliarSpecialMoves.instant.getValue(familiar).invoke(this) @Test fun `Summoning orb Cast option fires the special through the dispatcher`() { From 3d274fc0a79db2088096a26acfa94ce9a9525d9b Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Thu, 2 Jul 2026 11:21:15 -0700 Subject: [PATCH 046/136] bug: fix allows npcs to retaliate against familiars that use special move against them. --- .../content/skill/summoning/FamiliarSpecialHelpers.kt | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/game/src/main/kotlin/content/skill/summoning/FamiliarSpecialHelpers.kt b/game/src/main/kotlin/content/skill/summoning/FamiliarSpecialHelpers.kt index c6755307fc..15d5e0ecc6 100644 --- a/game/src/main/kotlin/content/skill/summoning/FamiliarSpecialHelpers.kt +++ b/game/src/main/kotlin/content/skill/summoning/FamiliarSpecialHelpers.kt @@ -1,7 +1,10 @@ package content.skill.summoning import content.area.wilderness.inPvp +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 @@ -84,6 +87,13 @@ fun Player.familiarSpecialHit( familiar.hit(target, offensiveType = type, damage = damage) targetGfx?.let { target.gfx(it) } } + if (target.inSingleCombat) { + target.attackers.clear() + target.attacker = familiar + } + if (familiar !in target.attackers) { + target.attackers.add(familiar) + } if (engage) { commandFamiliarAttack(target, silent = true) } From be002418c4e7edc9f7ac158d0b42555927819174 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Thu, 2 Jul 2026 12:14:41 -0700 Subject: [PATCH 047/136] feat: add Burrow npc option to desert wyrm familiar The wyrm digs to the nearest ore rock within 7 tiles and drops it. Like the live game it only mines the tiers below silver (copper, tin, clay, blurite, iron), preferring the highest tier available; messages when no rocks are near. --- data/skill/summoning/summoning.gfx.toml | 5 +- .../summoning/summoning_spawn.anims.toml | 4 + .../skill/summoning/familiar/DesertWyrm.kt | 57 +++++++++++++ .../familiar/DesertWyrmBurrowTest.kt | 81 +++++++++++++++++++ 4 files changed, 146 insertions(+), 1 deletion(-) create mode 100644 game/src/test/kotlin/content/skill/summoning/familiar/DesertWyrmBurrowTest.kt diff --git a/data/skill/summoning/summoning.gfx.toml b/data/skill/summoning/summoning.gfx.toml index 8216c6fd46..71dfa97fef 100644 --- a/data/skill/summoning/summoning.gfx.toml +++ b/data/skill/summoning/summoning.gfx.toml @@ -17,4 +17,7 @@ id = 1307 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 diff --git a/data/skill/summoning/summoning_spawn.anims.toml b/data/skill/summoning/summoning_spawn.anims.toml index 6aca8c698f..8f2912920e 100644 --- a/data/skill/summoning/summoning_spawn.anims.toml +++ b/data/skill/summoning/summoning_spawn.anims.toml @@ -124,6 +124,10 @@ 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 diff --git a/game/src/main/kotlin/content/skill/summoning/familiar/DesertWyrm.kt b/game/src/main/kotlin/content/skill/summoning/familiar/DesertWyrm.kt index 484bab191c..24962d742d 100644 --- a/game/src/main/kotlin/content/skill/summoning/familiar/DesertWyrm.kt +++ b/game/src/main/kotlin/content/skill/summoning/familiar/DesertWyrm.kt @@ -4,14 +4,42 @@ 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?") @@ -53,4 +81,33 @@ class DesertWyrm : Script { } } } + + /** + * 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/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..4e1ef50f22 --- /dev/null +++ b/game/src/test/kotlin/content/skill/summoning/familiar/DesertWyrmBurrowTest.kt @@ -0,0 +1,81 @@ +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.item.floor.FloorItems +import world.gregs.voidps.engine.entity.character.player.skill.Skill +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 } } + } +} From 11ddfd1b9b453cfe19d98ed76c39e3f0bed80ef7 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Thu, 2 Jul 2026 13:09:46 -0700 Subject: [PATCH 048/136] feat: spirit scorpion venom shot fires a projectile at its owner The scorpion plays its wind-up graphic, then once it finishes shoots a slow venom bolt at the owner, with the charge graphic landing on the owner when the bolt arrives. Flight time and start/end heights are passed explicitly because the projectile shares cache graphic 1355 with the impact gfx and void keys graphic definitions by numeric id (the shared entry's height/flight fields would otherwise collide). --- .../summoning/summoning_special.gfx.toml | 9 +++++++ .../skill/summoning/FamiliarBoostSpecials.kt | 24 +++++++++++++++++-- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/data/skill/summoning/summoning_special.gfx.toml b/data/skill/summoning/summoning_special.gfx.toml index c6bf8a82d3..75e874e8b7 100644 --- a/data/skill/summoning/summoning_special.gfx.toml +++ b/data/skill/summoning/summoning_special.gfx.toml @@ -114,9 +114,18 @@ id = 1377 [venom_shot] id = 1354 +# Charge wind-up duration: the venom bolt is fired at the owner once this graphic finishes. +ticks = 3 + +[venom_shot_proj] +id = 1355 +# Shares cache id 1355 with venom_shot_owner, so per-def flight fields can't be set here (definitions +# are keyed by numeric id and would collide) - the flight time is passed explicitly at the shoot call. [venom_shot_owner] id = 1355 +# Render the impact on the owner around chest height rather than at their feet. +height = 80 [fireball_assault_hit] id = 1329 diff --git a/game/src/main/kotlin/content/skill/summoning/FamiliarBoostSpecials.kt b/game/src/main/kotlin/content/skill/summoning/FamiliarBoostSpecials.kt index 6801291b93..9ff2966b68 100644 --- a/game/src/main/kotlin/content/skill/summoning/FamiliarBoostSpecials.kt +++ b/game/src/main/kotlin/content/skill/summoning/FamiliarBoostSpecials.kt @@ -5,14 +5,21 @@ 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 world.gregs.voidps.engine.timer.CLIENT_TICKS import world.gregs.voidps.type.random 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 @@ -103,9 +110,22 @@ class FamiliarBoostSpecials : Script { message("Your familiar's venom shot is already charged.") return@instant false } - familiarSelfSpecial(anim = "venom_shot", sourceGfx = "venom_shot", playerGfx = "venom_shot_owner") { - set("familiar_venom_shot_charged", true) + 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 venom bolt at the owner, and the owner's + // charge graphic plays when the bolt reaches them. + familiar.watch(this) + familiar.anim("venom_shot") + familiar.gfx("venom_shot") + val windUp = GraphicDefinitions.get("venom_shot")["ticks", 0] + queue("venom_shot_charge", windUp) { + // Explicit start/end heights: the projectile shares cache id 1355 with the impact gfx, + // whose height=50 would otherwise leak in and launch the bolt high above the scorpion. + val flight = familiar.shoot("venom_shot_proj", this, flightTime = VENOM_SHOT_FLIGHT, height = 20, endHeight = 35) + queue("venom_shot_impact", CLIENT_TICKS.toTicks(flight)) { gfx("venom_shot_owner") } } + set("familiar_venom_shot_charged", true) + true } combatAttack(handler = ::venomShot) From 9d1eb33303e29a4c8b73876ace51b05db708780a Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Thu, 2 Jul 2026 13:13:24 -0700 Subject: [PATCH 049/136] refactor: drop venom shot impact graphic, keep only the projectile Graphic 1355 has no owner-impact effect, so remove the venom_shot_owner gfx and the timed impact queue; Venom Shot now just plays the wind-up and fires the bolt. --- data/skill/summoning/summoning_special.gfx.toml | 8 -------- .../content/skill/summoning/FamiliarBoostSpecials.kt | 10 +++------- 2 files changed, 3 insertions(+), 15 deletions(-) diff --git a/data/skill/summoning/summoning_special.gfx.toml b/data/skill/summoning/summoning_special.gfx.toml index 75e874e8b7..4e0e8c7dd9 100644 --- a/data/skill/summoning/summoning_special.gfx.toml +++ b/data/skill/summoning/summoning_special.gfx.toml @@ -114,18 +114,10 @@ id = 1377 [venom_shot] id = 1354 -# Charge wind-up duration: the venom bolt is fired at the owner once this graphic finishes. ticks = 3 [venom_shot_proj] id = 1355 -# Shares cache id 1355 with venom_shot_owner, so per-def flight fields can't be set here (definitions -# are keyed by numeric id and would collide) - the flight time is passed explicitly at the shoot call. - -[venom_shot_owner] -id = 1355 -# Render the impact on the owner around chest height rather than at their feet. -height = 80 [fireball_assault_hit] id = 1329 diff --git a/game/src/main/kotlin/content/skill/summoning/FamiliarBoostSpecials.kt b/game/src/main/kotlin/content/skill/summoning/FamiliarBoostSpecials.kt index 9ff2966b68..250be60744 100644 --- a/game/src/main/kotlin/content/skill/summoning/FamiliarBoostSpecials.kt +++ b/game/src/main/kotlin/content/skill/summoning/FamiliarBoostSpecials.kt @@ -13,7 +13,6 @@ 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 world.gregs.voidps.engine.timer.CLIENT_TICKS import world.gregs.voidps.type.random import kotlin.math.ceil @@ -112,17 +111,14 @@ class FamiliarBoostSpecials : Script { } 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 venom bolt at the owner, and the owner's - // charge graphic plays when the bolt reaches them. + // 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) { - // Explicit start/end heights: the projectile shares cache id 1355 with the impact gfx, - // whose height=50 would otherwise leak in and launch the bolt high above the scorpion. - val flight = familiar.shoot("venom_shot_proj", this, flightTime = VENOM_SHOT_FLIGHT, height = 20, endHeight = 35) - queue("venom_shot_impact", CLIENT_TICKS.toTicks(flight)) { gfx("venom_shot_owner") } + familiar.shoot("venom_shot_proj", this, flightTime = VENOM_SHOT_FLIGHT, height = 20, endHeight = 35) } set("familiar_venom_shot_charged", true) true From f37a379c59a2ceaa10a5a0309db7d7882f69bc1f Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Thu, 2 Jul 2026 13:21:44 -0700 Subject: [PATCH 050/136] Adjust poison damage from venom shot charge --- .../kotlin/content/skill/summoning/FamiliarBoostSpecials.kt | 3 +-- .../content/skill/summoning/FamiliarSpecialEffectTest.kt | 6 ++++++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/game/src/main/kotlin/content/skill/summoning/FamiliarBoostSpecials.kt b/game/src/main/kotlin/content/skill/summoning/FamiliarBoostSpecials.kt index 250be60744..40939631ae 100644 --- a/game/src/main/kotlin/content/skill/summoning/FamiliarBoostSpecials.kt +++ b/game/src/main/kotlin/content/skill/summoning/FamiliarBoostSpecials.kt @@ -133,8 +133,7 @@ class FamiliarBoostSpecials : Script { return } source.clear("familiar_venom_shot_charged") - // Light poison - enough to actually tick under the poison decay model (weapon poison is 20+). - source.poison(attack.target, 6) + source.poison(attack.target, 60) } /** Boosts [skill] by [amount] above max with the familiar's [anim]/[sourceGfx] flourish. */ diff --git a/game/src/test/kotlin/content/skill/summoning/FamiliarSpecialEffectTest.kt b/game/src/test/kotlin/content/skill/summoning/FamiliarSpecialEffectTest.kt index f75e96dba0..9a0649f2e9 100644 --- a/game/src/test/kotlin/content/skill/summoning/FamiliarSpecialEffectTest.kt +++ b/game/src/test/kotlin/content/skill/summoning/FamiliarSpecialEffectTest.kt @@ -89,6 +89,12 @@ class FamiliarSpecialEffectTest : WorldTest() { 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 From 0f0cd105a0ab7ba5f526c75cf5509a7c44dcbf08 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Thu, 2 Jul 2026 13:58:03 -0700 Subject: [PATCH 051/136] feat: play cast animation, graphic and sound on every special move Move the owner's special-cast visuals (anim 7660 + gfx 1316) and sound (4161) into castFamiliarSpecial so they fire on every successful special, not just Cheese Feast. The albino rat now plays its own Cheese Feast anim + gfx on top. Also play the obelisk renew sound (4214) when renewing summoning points. --- data/skill/summoning/summoning.sounds.toml | 5 +++++ data/skill/summoning/summoning_special.anims.toml | 4 ++++ data/skill/summoning/summoning_special.gfx.toml | 4 ++++ .../content/skill/summoning/FamiliarUtilitySpecials.kt | 4 +--- .../src/main/kotlin/content/skill/summoning/Summoning.kt | 5 +++++ .../skill/summoning/familiar/DesertWyrmBurrowTest.kt | 9 ++++----- 6 files changed, 23 insertions(+), 8 deletions(-) create mode 100644 data/skill/summoning/summoning.sounds.toml 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_special.anims.toml b/data/skill/summoning/summoning_special.anims.toml index fd6991a917..a00a5d7b6b 100644 --- a/data/skill/summoning/summoning_special.anims.toml +++ b/data/skill/summoning/summoning_special.anims.toml @@ -88,3 +88,7 @@ id = 7928 # Player animation played while casting a special-move scroll (2009scape base visualizeSpecialMove). [summoning_special_cast] id = 7660 + +# Albino rat's Cheese Feast animation (played on the rat). +[cheese_feast] +id = 14858 diff --git a/data/skill/summoning/summoning_special.gfx.toml b/data/skill/summoning/summoning_special.gfx.toml index 4e0e8c7dd9..037aaf6f7a 100644 --- a/data/skill/summoning/summoning_special.gfx.toml +++ b/data/skill/summoning/summoning_special.gfx.toml @@ -159,6 +159,10 @@ id = 1336 [insane_ferocity] id = 1397 +# Player graphic played while casting a special-move scroll (2009scape base visualizeSpecialMove). +[summoning_special_cast] +id = 1316 + # Forage specials [cheese_feast] id = 1384 diff --git a/game/src/main/kotlin/content/skill/summoning/FamiliarUtilitySpecials.kt b/game/src/main/kotlin/content/skill/summoning/FamiliarUtilitySpecials.kt index 1b74ceff76..4af5e4257a 100644 --- a/game/src/main/kotlin/content/skill/summoning/FamiliarUtilitySpecials.kt +++ b/game/src/main/kotlin/content/skill/summoning/FamiliarUtilitySpecials.kt @@ -61,9 +61,7 @@ class FamiliarUtilitySpecials : Script { message("Your familiar is too full to collect items.") return@instant false } - // Player plays the summoning special-cast animation; the rat shows the cheese gfx. - anim("summoning_special_cast") - familiarSelfSpecial(sourceGfx = "cheese_feast") { + familiarSelfSpecial(anim = "cheese_feast", sourceGfx = "cheese_feast") { beastOfBurden.add("cheese", 4) 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 0a807ae7b9..ab1202a685 100644 --- a/game/src/main/kotlin/content/skill/summoning/Summoning.kt +++ b/game/src/main/kotlin/content/skill/summoning/Summoning.kt @@ -27,6 +27,7 @@ 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.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 @@ -209,6 +210,7 @@ suspend fun Player.renewSummoningPoints(obelisk: GameObject) { levels.set(Skill.Summoning, levels.getMax(Skill.Summoning)) anim("summoning_renew") areaGfx("summoning_renew_player", tile) + sound("summoning_renew") message("You renew your summoning points at the obelisk.") } } @@ -283,6 +285,9 @@ fun Player.castFamiliarSpecial(effect: () -> Boolean) { if (!effect()) { return } + anim("summoning_special_cast") + gfx("summoning_special_cast") + sound("summoning_special_cast") inventory.remove(scrollDef.stringId, 1) val points = get("summoning_special_points_remaining", 0) set("summoning_special_points_remaining", (points - cost).coerceAtLeast(0)) diff --git a/game/src/test/kotlin/content/skill/summoning/familiar/DesertWyrmBurrowTest.kt b/game/src/test/kotlin/content/skill/summoning/familiar/DesertWyrmBurrowTest.kt index 4e1ef50f22..ea82b47294 100644 --- a/game/src/test/kotlin/content/skill/summoning/familiar/DesertWyrmBurrowTest.kt +++ b/game/src/test/kotlin/content/skill/summoning/familiar/DesertWyrmBurrowTest.kt @@ -7,8 +7,8 @@ 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.item.floor.FloorItems 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() { @@ -74,8 +74,7 @@ internal class DesertWyrmBurrowTest : WorldTest() { 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 } } - } + 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 } } + } } From 9cf170db73db60886978ad6c2983efa38b70a0c7 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Thu, 2 Jul 2026 14:12:18 -0700 Subject: [PATCH 052/136] feat: add compost mound Generate Compost special move Cast on an empty compost bin to fill it with a full bin of ready compost, or supercompost on a 1-in-10 roll. The familiar animates and fires a projectile at the bin, filling it on impact. Removes the compost mound scenery stub. --- .../summoning/summoning_special.anims.toml | 4 ++ .../summoning/summoning_special.gfx.toml | 10 +++ .../skill/summoning/FamiliarSpecialStubs.kt | 4 +- .../summoning/FamiliarUtilitySpecials.kt | 26 ++++++++ .../summoning/CompostMoundGenerateTest.kt | 66 +++++++++++++++++++ 5 files changed, 108 insertions(+), 2 deletions(-) create mode 100644 game/src/test/kotlin/content/skill/summoning/CompostMoundGenerateTest.kt diff --git a/data/skill/summoning/summoning_special.anims.toml b/data/skill/summoning/summoning_special.anims.toml index a00a5d7b6b..4273f5b5ab 100644 --- a/data/skill/summoning/summoning_special.anims.toml +++ b/data/skill/summoning/summoning_special.anims.toml @@ -92,3 +92,7 @@ id = 7660 # Albino rat's Cheese Feast animation (played on the rat). [cheese_feast] id = 14858 + +# Compost mound's Generate Compost animation (played on the familiar). +[generate_compost] +id = 7775 diff --git a/data/skill/summoning/summoning_special.gfx.toml b/data/skill/summoning/summoning_special.gfx.toml index 037aaf6f7a..fec9d1af0b 100644 --- a/data/skill/summoning/summoning_special.gfx.toml +++ b/data/skill/summoning/summoning_special.gfx.toml @@ -166,3 +166,13 @@ id = 1316 # Forage specials [cheese_feast] id = 1384 + +# Compost mound Generate Compost: familiar gfx, projectile to the bin, and the impact at the bin. +[generate_compost] +id = 1461 + +[generate_compost_proj] +id = 1462 + +[generate_compost_bin] +id = 1460 diff --git a/game/src/main/kotlin/content/skill/summoning/FamiliarSpecialStubs.kt b/game/src/main/kotlin/content/skill/summoning/FamiliarSpecialStubs.kt index 0160ac79ea..212586ac5a 100644 --- a/game/src/main/kotlin/content/skill/summoning/FamiliarSpecialStubs.kt +++ b/game/src/main/kotlin/content/skill/summoning/FamiliarSpecialStubs.kt @@ -10,7 +10,7 @@ import world.gregs.voidps.engine.entity.character.player.Player * no scroll or special-move points are spent. Grouped by what each still needs: * * - Item interactions (cook/incubate/smelt): Bunyip, Spirit cobra, Pyre lord - * - Skill-system scenery: Beaver (Multichop), Hydra (Regrowth), Compost mound (Generate Compost) + * - Skill-system scenery: Beaver (Multichop), Hydra (Regrowth) * - "Charge the next attack" buffs: Iron titan, Steel titan (Spirit scorpion + Honey badger are done) * - AoE hits: Smoke devil, Giant chinchompa * - Pest Control teleport (area not implemented in void): the four Void familiars' Call to Arms @@ -53,7 +53,7 @@ class FamiliarSpecialStubs : Script { ) { stub() } // Scenery-target specials. - FamiliarSpecialMoves.obj("beaver_familiar", "hydra_familiar", "compost_mound_familiar") { _ -> stub() } + FamiliarSpecialMoves.obj("beaver_familiar", "hydra_familiar") { _ -> stub() } // Item-target specials (use an item on the familiar). itemOnNPCApproach("*", "bunyip_familiar") { handleItemStub() } diff --git a/game/src/main/kotlin/content/skill/summoning/FamiliarUtilitySpecials.kt b/game/src/main/kotlin/content/skill/summoning/FamiliarUtilitySpecials.kt index 4af5e4257a..889121ae10 100644 --- a/game/src/main/kotlin/content/skill/summoning/FamiliarUtilitySpecials.kt +++ b/game/src/main/kotlin/content/skill/summoning/FamiliarUtilitySpecials.kt @@ -2,6 +2,7 @@ package content.skill.summoning 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 @@ -15,6 +16,7 @@ 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 /** @@ -89,6 +91,30 @@ class FamiliarUtilitySpecials : Script { } } + // 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 - drop a random grimy herb at the player's feet. FamiliarSpecialMoves.instant("macaw_familiar") { dropForage(grimyHerbs[random.nextInt(grimyHerbs.size)]) 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") + } +} From 5835c5c7b038485a27a15f150682e18670dcb9bb Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Thu, 2 Jul 2026 14:49:56 -0700 Subject: [PATCH 053/136] feat: giant chinchompa Explode special move Detonates around the familiar hitting up to nine nearby foes (random 0-12) then dismisses it. Cast from the button, and auto-fires free on ~1/10 of the familiar's own attacks (no scroll/points spent). --- .../summoning/summoning_special.anims.toml | 4 ++ .../summoning/summoning_special.gfx.toml | 4 ++ .../skill/summoning/FamiliarCombatSpecials.kt | 51 +++++++++++++++++++ .../skill/summoning/FamiliarSpecialStubs.kt | 3 +- .../summoning/FamiliarSpecialEffectTest.kt | 50 ++++++++++++++++++ 5 files changed, 110 insertions(+), 2 deletions(-) diff --git a/data/skill/summoning/summoning_special.anims.toml b/data/skill/summoning/summoning_special.anims.toml index 4273f5b5ab..886eaa6555 100644 --- a/data/skill/summoning/summoning_special.anims.toml +++ b/data/skill/summoning/summoning_special.anims.toml @@ -96,3 +96,7 @@ id = 14858 # Compost mound's Generate Compost animation (played on the familiar). [generate_compost] id = 7775 + +# Giant chinchompa's Explode animation (played on the familiar). +[chinchompa_explode] +id = 7758 diff --git a/data/skill/summoning/summoning_special.gfx.toml b/data/skill/summoning/summoning_special.gfx.toml index fec9d1af0b..b65980a410 100644 --- a/data/skill/summoning/summoning_special.gfx.toml +++ b/data/skill/summoning/summoning_special.gfx.toml @@ -176,3 +176,7 @@ id = 1462 [generate_compost_bin] id = 1460 + +# Giant chinchompa Explode: the blast graphic played on the familiar. +[chinchompa_explode] +id = 1364 diff --git a/game/src/main/kotlin/content/skill/summoning/FamiliarCombatSpecials.kt b/game/src/main/kotlin/content/skill/summoning/FamiliarCombatSpecials.kt index 6ca78ea5e7..86640ed722 100644 --- a/game/src/main/kotlin/content/skill/summoning/FamiliarCombatSpecials.kt +++ b/game/src/main/kotlin/content/skill/summoning/FamiliarCombatSpecials.kt @@ -3,10 +3,16 @@ package content.skill.summoning import content.entity.effect.toxin.poison import content.entity.proj.shoot import world.gregs.voidps.engine.Script +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.CombatAttack 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.skill.Skill +import world.gregs.voidps.engine.get import world.gregs.voidps.engine.queue.queue import world.gregs.voidps.engine.timer.CLIENT_TICKS import world.gregs.voidps.type.random @@ -123,6 +129,14 @@ class FamiliarCombatSpecials : Script { familiarAoeSpecial(maxTargets = 6, maxHit = 20, radius = 6, anim = "sandstorm", sourceGfx = "sandstorm", projectile = "sandstorm_proj") } + // Explode - the giant chinchompa detonates, hitting everything around it, then is consumed by + // the blast. Fired from the cast button, or (like 2009scape) auto-triggered on ~1/10 of the + // familiar's own attacks - see [autoExplode]. + FamiliarSpecialMoves.instant("giant_chinchompa_familiar") { + chinchompaExplode() + } + npcCombatAttack("giant_chinchompa_familiar", handler = ::autoExplode) + // Minotaur family - Bull Rush: max hit scales with metal tier (stun TODO - needs a stun primitive). val bullRush = mapOf( "bronze_minotaur_familiar" to 4, @@ -139,6 +153,43 @@ class FamiliarCombatSpecials : Script { } } + /** + * 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 = 12, 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 giant chinchompa's attacks 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, attack: CombatAttack) { + if (random.nextInt(10) != 0) { + return + } + val owner = Players.indexed(familiar["owner_index", -1]) ?: return + if (owner.follower?.index != familiar.index) { + return + } + // The explosion's own hits re-enter this handler (they are familiar attacks too); the special + // cooldown clock blocks that recursion, just 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] but only when the hit actually landed ([cast] true). */ private fun Boolean.alsoDrain(target: Character, skill: Skill, amount: Int): Boolean { if (this) { diff --git a/game/src/main/kotlin/content/skill/summoning/FamiliarSpecialStubs.kt b/game/src/main/kotlin/content/skill/summoning/FamiliarSpecialStubs.kt index 212586ac5a..8f0106b01e 100644 --- a/game/src/main/kotlin/content/skill/summoning/FamiliarSpecialStubs.kt +++ b/game/src/main/kotlin/content/skill/summoning/FamiliarSpecialStubs.kt @@ -12,7 +12,7 @@ import world.gregs.voidps.engine.entity.character.player.Player * - Item interactions (cook/incubate/smelt): Bunyip, Spirit cobra, Pyre lord * - Skill-system scenery: Beaver (Multichop), Hydra (Regrowth) * - "Charge the next attack" buffs: Iron titan, Steel titan (Spirit scorpion + Honey badger are done) - * - AoE hits: Smoke devil, Giant chinchompa + * - AoE hits: Smoke devil (Giant chinchompa's Explode is done) * - Pest Control teleport (area not implemented in void): the four Void familiars' Call to Arms * - Misc combat: Forge regent (disarm), Ravenous locust (Famine), Karamthulhu, Spirit dagannoth, * Talon beast, Swamp titan, Lava titan, Praying mantis, Giant ent @@ -43,7 +43,6 @@ class FamiliarSpecialStubs : Script { // Instant self / AoE / charge specials. FamiliarSpecialMoves.instant( "smoke_devil_familiar", - "giant_chinchompa_familiar", "iron_titan_familiar", "steel_titan_familiar", "void_ravager_familiar", diff --git a/game/src/test/kotlin/content/skill/summoning/FamiliarSpecialEffectTest.kt b/game/src/test/kotlin/content/skill/summoning/FamiliarSpecialEffectTest.kt index 9a0649f2e9..dbc6cec3ff 100644 --- a/game/src/test/kotlin/content/skill/summoning/FamiliarSpecialEffectTest.kt +++ b/game/src/test/kotlin/content/skill/summoning/FamiliarSpecialEffectTest.kt @@ -109,6 +109,56 @@ class FamiliarSpecialEffectTest : WorldTest() { 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 on the familiar's attack`() { + // 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 target = createNPC("giant_rat", player.tile.addX(1)) + val before = target.levels.get(Skill.Constitution) + + // The familiar swinging at something rolls the auto-explode. + player.follower!!.hit(target, offensiveType = "melee", damage = 1) + tick(6) // let the blast land and the familiar dismiss + + assertTrue(target.levels.get(Skill.Constitution) < before, "the auto-explosion damaged the nearby npc") + 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 `Fireball Assault hits a nearby foe`() { // Force the random damage roll off zero so the hit is observable. From 2d7d87a177742365a6b17a6e515f5230b0eec22e Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Thu, 2 Jul 2026 15:09:56 -0700 Subject: [PATCH 054/136] feat: chinchompa auto-explode triggers when attacked, for free Move the ~1/10 auto-detonation from the familiar's own attacks to incoming hits (npcCombatDamage), and fire it directly instead of through the scroll/points gate so it costs no scroll, points, or owner scroll-throw flourish. A cooldown guard blocks re-entry. --- .../skill/summoning/FamiliarCombatSpecials.kt | 16 ++++++++-------- .../skill/summoning/FamiliarSpecialEffectTest.kt | 12 ++++++------ 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/game/src/main/kotlin/content/skill/summoning/FamiliarCombatSpecials.kt b/game/src/main/kotlin/content/skill/summoning/FamiliarCombatSpecials.kt index 86640ed722..9ca7c7e064 100644 --- a/game/src/main/kotlin/content/skill/summoning/FamiliarCombatSpecials.kt +++ b/game/src/main/kotlin/content/skill/summoning/FamiliarCombatSpecials.kt @@ -7,7 +7,7 @@ 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.CombatAttack +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 @@ -135,7 +135,7 @@ class FamiliarCombatSpecials : Script { FamiliarSpecialMoves.instant("giant_chinchompa_familiar") { chinchompaExplode() } - npcCombatAttack("giant_chinchompa_familiar", handler = ::autoExplode) + npcCombatDamage("giant_chinchompa_familiar", handler = ::autoExplode) // Minotaur family - Bull Rush: max hit scales with metal tier (stun TODO - needs a stun primitive). val bullRush = mapOf( @@ -169,11 +169,11 @@ class FamiliarCombatSpecials : Script { } /** - * ~1 in 10 of the giant chinchompa's attacks 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. + * ~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, attack: CombatAttack) { + private fun autoExplode(familiar: NPC, damage: CombatDamage) { if (random.nextInt(10) != 0) { return } @@ -181,8 +181,8 @@ class FamiliarCombatSpecials : Script { if (owner.follower?.index != familiar.index) { return } - // The explosion's own hits re-enter this handler (they are familiar attacks too); the special - // cooldown clock blocks that recursion, just as it does for the cast-button path. + // 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 } diff --git a/game/src/test/kotlin/content/skill/summoning/FamiliarSpecialEffectTest.kt b/game/src/test/kotlin/content/skill/summoning/FamiliarSpecialEffectTest.kt index dbc6cec3ff..8ca38ccf14 100644 --- a/game/src/test/kotlin/content/skill/summoning/FamiliarSpecialEffectTest.kt +++ b/game/src/test/kotlin/content/skill/summoning/FamiliarSpecialEffectTest.kt @@ -128,7 +128,7 @@ class FamiliarSpecialEffectTest : WorldTest() { } @Test - fun `Explode auto-fires for free on the familiar's attack`() { + 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 @@ -136,14 +136,14 @@ class FamiliarSpecialEffectTest : WorldTest() { 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 target = createNPC("giant_rat", player.tile.addX(1)) - val before = target.levels.get(Skill.Constitution) + val attacker = createNPC("giant_rat", player.tile.addX(1)) + val before = attacker.levels.get(Skill.Constitution) - // The familiar swinging at something rolls the auto-explode. - player.follower!!.hit(target, offensiveType = "melee", damage = 1) + // 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(target.levels.get(Skill.Constitution) < before, "the auto-explosion damaged the nearby npc") + 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") } From 523f26ce527ab173641d1e367651c40d4cc9b942 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Thu, 2 Jul 2026 15:09:56 -0700 Subject: [PATCH 055/136] fix: show owner the blue 0 splat when a familiar is hit The prior attribution only covered hits a familiar deals; a 0 hit landed on the familiar kept the attacker as the splat source, so the owner (neither victim nor source) never saw the blue block splat. Attribute splats on a familiar to its owner too. --- .../entity/combat/hit/CombatHitsplats.kt | 4 +--- .../kotlin/content/entity/combat/CombatTest.kt | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+), 3 deletions(-) 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 4826840cf7..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,10 +68,8 @@ 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() - // A familiar's hits are attributed to its owner (positive player index) rather than the - // familiar npc, so the owner counts as a participant and the client shows them the splat - - // including the blue 0/miss splat, which is otherwise hidden from non-participants. 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 } diff --git a/game/src/test/kotlin/content/entity/combat/CombatTest.kt b/game/src/test/kotlin/content/entity/combat/CombatTest.kt index 80f84c1235..0882ccbbea 100644 --- a/game/src/test/kotlin/content/entity/combat/CombatTest.kt +++ b/game/src/test/kotlin/content/entity/combat/CombatTest.kt @@ -3,6 +3,7 @@ package content.entity.combat import FakeRandom import WorldTest import content.entity.combat.damageDealers +import content.entity.combat.hit.hit import content.entity.player.effect.skull import equipItem import interfaceOption @@ -104,6 +105,23 @@ internal class CombatTest : WorldTest() { 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)) From 3062759b47b1a0408d2a4290b2f3a28df946592a Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Thu, 2 Jul 2026 15:09:56 -0700 Subject: [PATCH 056/136] fix: giant chinchompa attacks melee instead of ranged Its combat def used a range attack; switch to a melee crush attack at range 1 so it bites in melee like the live familiar. --- data/skill/summoning/summoning.combat.toml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/data/skill/summoning/summoning.combat.toml b/data/skill/summoning/summoning.combat.toml index b4c0ae3412..c11abd53eb 100644 --- a/data/skill/summoning/summoning.combat.toml +++ b/data/skill/summoning/summoning.combat.toml @@ -142,10 +142,10 @@ retreat_range = 8 defend_anim = "giant_chinchompa_familiar_defend" death_anim = "giant_chinchompa_familiar_death" -[giant_chinchompa_familiar.range] -range = 7 +[giant_chinchompa_familiar.melee] +range = 1 anim = "giant_chinchompa_familiar_attack" -target_hit = { offense = "range", max = 38 } +target_hit = { offense = "crush", max = 38 } [vampire_bat_familiar] attack_speed = 8 From 7aca3d5db0bbffb934a3f067feab20821da43dbe Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Thu, 2 Jul 2026 15:38:06 -0700 Subject: [PATCH 057/136] feat: honey badger Insane Ferocity buff Wire the previously-inert special: enrage the owner, boosting Attack and Strength by 5 + 15% each at the cost of 10% Ranged, Magic and Defence, matching the scroll description. Boosts decay naturally and recasting refreshes them. Adds the owner enrage graphic. --- .../skill/summoning/summoning_special.gfx.toml | 4 ++++ .../skill/summoning/FamiliarBoostSpecials.kt | 17 +++++++++-------- .../summoning/FamiliarSpecialEffectTest.kt | 18 ++++++++++++++++++ 3 files changed, 31 insertions(+), 8 deletions(-) diff --git a/data/skill/summoning/summoning_special.gfx.toml b/data/skill/summoning/summoning_special.gfx.toml index b65980a410..a1d3e4c6fc 100644 --- a/data/skill/summoning/summoning_special.gfx.toml +++ b/data/skill/summoning/summoning_special.gfx.toml @@ -159,6 +159,10 @@ id = 1336 [insane_ferocity] id = 1397 +# Insane Ferocity graphic played on the owner (the honey badger's enrage flourish). +[insane_ferocity_owner] +id = 1399 + # Player graphic played while casting a special-move scroll (2009scape base visualizeSpecialMove). [summoning_special_cast] id = 1316 diff --git a/game/src/main/kotlin/content/skill/summoning/FamiliarBoostSpecials.kt b/game/src/main/kotlin/content/skill/summoning/FamiliarBoostSpecials.kt index 40939631ae..8cd2bb5ccc 100644 --- a/game/src/main/kotlin/content/skill/summoning/FamiliarBoostSpecials.kt +++ b/game/src/main/kotlin/content/skill/summoning/FamiliarBoostSpecials.kt @@ -90,15 +90,16 @@ class FamiliarBoostSpecials : Script { familiarSelfSpecial(anim = "thieving_fingers", sourceGfx = "thieving_fingers") {} } - // Insane Ferocity - charge the next attack. The next-attack consumption is not wired yet, so - // this currently sets the charge flag + plays the visuals only (TODO: buff the next swing). + // 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") { - if (this["familiar_insane_ferocity", false]) { - message("Your familiar is already enraged.") - return@instant false - } - familiarSelfSpecial(anim = "insane_ferocity", sourceGfx = "insane_ferocity") { - set("familiar_insane_ferocity", true) + 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) } } diff --git a/game/src/test/kotlin/content/skill/summoning/FamiliarSpecialEffectTest.kt b/game/src/test/kotlin/content/skill/summoning/FamiliarSpecialEffectTest.kt index 8ca38ccf14..8b6092437b 100644 --- a/game/src/test/kotlin/content/skill/summoning/FamiliarSpecialEffectTest.kt +++ b/game/src/test/kotlin/content/skill/summoning/FamiliarSpecialEffectTest.kt @@ -159,6 +159,24 @@ class FamiliarSpecialEffectTest : WorldTest() { 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 `Fireball Assault hits a nearby foe`() { // Force the random damage roll off zero so the hit is observable. From b04bcaa1e63d77d537c36ed2794c3961b5a4c3ec Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Thu, 2 Jul 2026 17:11:41 -0700 Subject: [PATCH 058/136] feat: beaver Multichop, fletching and forage message Multichop: cast on a tree, the beaver walks over, chops (facing the tree, held in PauseMode so the idle-familiar watchdog doesn't drag it back), and stashes a log - possibly a lower tier than the tree - in its 30-slot pack. Interacting with it mid-chop is refused with a busy message, except using a log to fletch. Fletch: use a log on the beaver to fletch bows with no knife. Refactored the shared fletch flow to take a tool predicate; the beaver counts as a knife only while cutting, so a batch that outlasts the chop stops with "You need a knife to do that." Forage: foragers now message "Your familiar has produced an item." --- .../summoning/summoning_special.anims.toml | 4 + .../summoning/summoning_special.gfx.toml | 4 + .../skill/fletching/FletchUnfinished.kt | 110 +++++++++++------- .../content/skill/summoning/BeastOfBurden.kt | 21 ++++ .../skill/summoning/FamiliarSpecialStubs.kt | 6 +- .../kotlin/content/skill/summoning/Forager.kt | 14 +++ .../skill/summoning/familiar/Beaver.kt | 109 +++++++++++++++++ .../summoning/FamiliarSpecialEffectTest.kt | 75 ++++++++++++ 8 files changed, 297 insertions(+), 46 deletions(-) diff --git a/data/skill/summoning/summoning_special.anims.toml b/data/skill/summoning/summoning_special.anims.toml index 886eaa6555..646f69a60c 100644 --- a/data/skill/summoning/summoning_special.anims.toml +++ b/data/skill/summoning/summoning_special.anims.toml @@ -100,3 +100,7 @@ id = 7775 # Giant chinchompa's Explode animation (played on the familiar). [chinchompa_explode] id = 7758 + +# Beaver's Multichop animation (played on the familiar as it chops the tree). +[beaver_multichop] +id = 7722 diff --git a/data/skill/summoning/summoning_special.gfx.toml b/data/skill/summoning/summoning_special.gfx.toml index a1d3e4c6fc..f0f8acbe4f 100644 --- a/data/skill/summoning/summoning_special.gfx.toml +++ b/data/skill/summoning/summoning_special.gfx.toml @@ -184,3 +184,7 @@ id = 1460 # Giant chinchompa Explode: the blast graphic played on the familiar. [chinchompa_explode] id = 1364 + +# Beaver Multichop: the graphic played on the familiar as it chops. +[beaver_multichop] +id = 1459 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/summoning/BeastOfBurden.kt b/game/src/main/kotlin/content/skill/summoning/BeastOfBurden.kt index bb0051c97c..7653463b54 100644 --- a/game/src/main/kotlin/content/skill/summoning/BeastOfBurden.kt +++ b/game/src/main/kotlin/content/skill/summoning/BeastOfBurden.kt @@ -29,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 @@ -144,6 +153,10 @@ class BeastOfBurden(private val dropTables: DropTables) : Script { message("That's not your familiar.") return@npcOperate } + if (target[FAMILIAR_CHOPPING, false]) { + message(FAMILIAR_BUSY_MESSAGE) + return@npcOperate + } openBeastOfBurden() } @@ -152,6 +165,10 @@ class BeastOfBurden(private val dropTables: DropTables) : 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 @@ -163,6 +180,10 @@ class BeastOfBurden(private val dropTables: DropTables) : 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() } diff --git a/game/src/main/kotlin/content/skill/summoning/FamiliarSpecialStubs.kt b/game/src/main/kotlin/content/skill/summoning/FamiliarSpecialStubs.kt index 8f0106b01e..6d55ae45f8 100644 --- a/game/src/main/kotlin/content/skill/summoning/FamiliarSpecialStubs.kt +++ b/game/src/main/kotlin/content/skill/summoning/FamiliarSpecialStubs.kt @@ -10,7 +10,7 @@ import world.gregs.voidps.engine.entity.character.player.Player * no scroll or special-move points are spent. Grouped by what each still needs: * * - Item interactions (cook/incubate/smelt): Bunyip, Spirit cobra, Pyre lord - * - Skill-system scenery: Beaver (Multichop), Hydra (Regrowth) + * - Skill-system scenery: Hydra (Regrowth) (Beaver's Multichop is done) * - "Charge the next attack" buffs: Iron titan, Steel titan (Spirit scorpion + Honey badger are done) * - AoE hits: Smoke devil (Giant chinchompa's Explode is done) * - Pest Control teleport (area not implemented in void): the four Void familiars' Call to Arms @@ -51,8 +51,8 @@ class FamiliarSpecialStubs : Script { "void_torcher_familiar", ) { stub() } - // Scenery-target specials. - FamiliarSpecialMoves.obj("beaver_familiar", "hydra_familiar") { _ -> stub() } + // Scenery-target specials. (Beaver's Multichop is done - see Beaver.kt.) + FamiliarSpecialMoves.obj("hydra_familiar") { _ -> stub() } // Item-target specials (use an item on the familiar). itemOnNPCApproach("*", "bunyip_familiar") { handleItemStub() } diff --git a/game/src/main/kotlin/content/skill/summoning/Forager.kt b/game/src/main/kotlin/content/skill/summoning/Forager.kt index cacf3584df..698736aa05 100644 --- a/game/src/main/kotlin/content/skill/summoning/Forager.kt +++ b/game/src/main/kotlin/content/skill/summoning/Forager.kt @@ -1,6 +1,7 @@ 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.item.drop.DropTables import world.gregs.voidps.engine.inv.add @@ -31,12 +32,20 @@ class Forager(private val dropTables: DropTables) : Script { 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() } } @@ -48,12 +57,17 @@ class Forager(private val dropTables: DropTables) : Script { 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) + 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/familiar/Beaver.kt b/game/src/main/kotlin/content/skill/summoning/familiar/Beaver.kt index 2a7be629b2..29f28c084d 100644 --- a/game/src/main/kotlin/content/skill/summoning/familiar/Beaver.kt +++ b/game/src/main/kotlin/content/skill/summoning/familiar/Beaver.kt @@ -4,13 +4,122 @@ 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.") diff --git a/game/src/test/kotlin/content/skill/summoning/FamiliarSpecialEffectTest.kt b/game/src/test/kotlin/content/skill/summoning/FamiliarSpecialEffectTest.kt index 8b6092437b..4ceb288d2c 100644 --- a/game/src/test/kotlin/content/skill/summoning/FamiliarSpecialEffectTest.kt +++ b/game/src/test/kotlin/content/skill/summoning/FamiliarSpecialEffectTest.kt @@ -4,12 +4,16 @@ import FakeRandom import WorldTest import content.entity.combat.hit.hit import content.entity.effect.toxin.poisoned +import content.skill.fletching.fletchLog import interfaceOption import org.junit.jupiter.api.Test 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 @@ -36,6 +40,77 @@ class FamiliarSpecialEffectTest : WorldTest() { 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") From 5f007e3425fadd20c6c051e346028a4fae8a3ffa Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Thu, 2 Jul 2026 17:27:42 -0700 Subject: [PATCH 059/136] feat: Void familiars' Call to Arms teleport special Ports the Call to Arms special (shared by the Void ravager, shifter, spinner and torcher familiars) that teleports the owner to the Void Knights' Outpost courtyard (2659, 2658, 0). The outpost island is on the map even though Pest Control itself isn't implemented, so the destination is safe. Registers the shared instant move in FamiliarUtilitySpecials with the 2009 teleport-out/teleport-in visuals, removes the four familiars from FamiliarSpecialStubs, and adds the named anim/gfx entries. Covered by FamiliarSpecialEffectTest. --- .../summoning/summoning_special.anims.toml | 7 ++++++ .../summoning/summoning_special.gfx.toml | 7 ++++++ .../skill/summoning/FamiliarSpecialStubs.kt | 5 ---- .../summoning/FamiliarUtilitySpecials.kt | 20 ++++++++++++++++ .../summoning/FamiliarSpecialEffectTest.kt | 23 +++++++++++++++++++ 5 files changed, 57 insertions(+), 5 deletions(-) diff --git a/data/skill/summoning/summoning_special.anims.toml b/data/skill/summoning/summoning_special.anims.toml index 646f69a60c..a572b73509 100644 --- a/data/skill/summoning/summoning_special.anims.toml +++ b/data/skill/summoning/summoning_special.anims.toml @@ -104,3 +104,10 @@ id = 7758 # Beaver's Multichop animation (played on the familiar as it chops the tree). [beaver_multichop] id = 7722 + +# Call to Arms (Void familiars): the owner's teleport-out and teleport-in animations. +[call_to_arms] +id = 8136 + +[call_to_arms_land] +id = 8137 diff --git a/data/skill/summoning/summoning_special.gfx.toml b/data/skill/summoning/summoning_special.gfx.toml index f0f8acbe4f..f0dac85910 100644 --- a/data/skill/summoning/summoning_special.gfx.toml +++ b/data/skill/summoning/summoning_special.gfx.toml @@ -188,3 +188,10 @@ id = 1364 # Beaver Multichop: the graphic played on the familiar as it chops. [beaver_multichop] id = 1459 + +# Call to Arms (Void familiars): teleport-out and teleport-in graphics on the owner. +[call_to_arms] +id = 1503 + +[call_to_arms_land] +id = 1502 diff --git a/game/src/main/kotlin/content/skill/summoning/FamiliarSpecialStubs.kt b/game/src/main/kotlin/content/skill/summoning/FamiliarSpecialStubs.kt index 6d55ae45f8..27a2c4f7e7 100644 --- a/game/src/main/kotlin/content/skill/summoning/FamiliarSpecialStubs.kt +++ b/game/src/main/kotlin/content/skill/summoning/FamiliarSpecialStubs.kt @@ -13,7 +13,6 @@ import world.gregs.voidps.engine.entity.character.player.Player * - Skill-system scenery: Hydra (Regrowth) (Beaver's Multichop is done) * - "Charge the next attack" buffs: Iron titan, Steel titan (Spirit scorpion + Honey badger are done) * - AoE hits: Smoke devil (Giant chinchompa's Explode is done) - * - Pest Control teleport (area not implemented in void): the four Void familiars' Call to Arms * - Misc combat: Forge regent (disarm), Ravenous locust (Famine), Karamthulhu, Spirit dagannoth, * Talon beast, Swamp titan, Lava titan, Praying mantis, Giant ent * @@ -45,10 +44,6 @@ class FamiliarSpecialStubs : Script { "smoke_devil_familiar", "iron_titan_familiar", "steel_titan_familiar", - "void_ravager_familiar", - "void_shifter_familiar", - "void_spinner_familiar", - "void_torcher_familiar", ) { stub() } // Scenery-target specials. (Beaver's Multichop is done - see Beaver.kt.) diff --git a/game/src/main/kotlin/content/skill/summoning/FamiliarUtilitySpecials.kt b/game/src/main/kotlin/content/skill/summoning/FamiliarUtilitySpecials.kt index 889121ae10..30116e343b 100644 --- a/game/src/main/kotlin/content/skill/summoning/FamiliarUtilitySpecials.kt +++ b/game/src/main/kotlin/content/skill/summoning/FamiliarUtilitySpecials.kt @@ -6,6 +6,7 @@ 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.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 @@ -55,6 +56,25 @@ class FamiliarUtilitySpecials : Script { 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() diff --git a/game/src/test/kotlin/content/skill/summoning/FamiliarSpecialEffectTest.kt b/game/src/test/kotlin/content/skill/summoning/FamiliarSpecialEffectTest.kt index 4ceb288d2c..f9c413477a 100644 --- a/game/src/test/kotlin/content/skill/summoning/FamiliarSpecialEffectTest.kt +++ b/game/src/test/kotlin/content/skill/summoning/FamiliarSpecialEffectTest.kt @@ -252,6 +252,29 @@ class FamiliarSpecialEffectTest : WorldTest() { 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. From 3568d44d72578a606a475ad5469257d7ea44d5d0 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Thu, 2 Jul 2026 17:36:20 -0700 Subject: [PATCH 060/136] feat: Void spinner heals its owner 100 life points every 15s Adds a passive heal-over-time for the Void spinner familiar via a shared `familiar_heal` timer, gated by the FAMILIAR_HEAL_LIFEPOINTS id map (same shape as FAMILIAR_BOOSTS). Started on summon and stopped on dismiss; restore no-ops at full life points so it only tops up after damage. --- .../content/skill/summoning/FamiliarHeal.kt | 36 +++++++++++ .../content/skill/summoning/Summoning.kt | 4 ++ .../skill/summoning/VoidSpinnerHealTest.kt | 61 +++++++++++++++++++ 3 files changed, 101 insertions(+) create mode 100644 game/src/main/kotlin/content/skill/summoning/FamiliarHeal.kt create mode 100644 game/src/test/kotlin/content/skill/summoning/VoidSpinnerHealTest.kt 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..147aa92b5d --- /dev/null +++ b/game/src/main/kotlin/content/skill/summoning/FamiliarHeal.kt @@ -0,0 +1,36 @@ +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, +) + +/** + * 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 + levels.restore(Skill.Constitution, amount) + Timer.CONTINUE + } + } +} diff --git a/game/src/main/kotlin/content/skill/summoning/Summoning.kt b/game/src/main/kotlin/content/skill/summoning/Summoning.kt index ab1202a685..feaf14921a 100644 --- a/game/src/main/kotlin/content/skill/summoning/Summoning.kt +++ b/game/src/main/kotlin/content/skill/summoning/Summoning.kt @@ -85,6 +85,9 @@ fun Player.summonFamiliar(familiar: NPCDefinition, restart: Boolean) { if (get().get("forage_${familiarNpc.id.removeSuffix("_familiar")}") != null) { timers.start("forage") } + if (familiarNpc.id in FAMILIAR_HEAL_LIFEPOINTS) { + timers.start("familiar_heal") + } } } @@ -115,6 +118,7 @@ fun Player.dismissFamiliar(removeNpc: Boolean = true) { timers.stop("familiar_timer") timers.stop("summoning_drain") timers.stop("forage") + timers.stop("familiar_heal") } /** 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") + } +} From d38556157e3e926abdd4b94f8900e7f8416eb478 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Thu, 2 Jul 2026 17:43:11 -0700 Subject: [PATCH 061/136] feat: Void shifter teleports owner to safety below 10% life points Hooks Constitution level changes on the shifter's owner: once their life points fall under 10% of maximum, it teleports them to the Void Knights' Outpost courtyard (the same tile Call to Arms uses). --- .../skill/summoning/familiar/VoidShifter.kt | 13 ++++ .../summoning/familiar/VoidShifterTest.kt | 63 +++++++++++++++++++ 2 files changed, 76 insertions(+) create mode 100644 game/src/test/kotlin/content/skill/summoning/familiar/VoidShifterTest.kt diff --git a/game/src/main/kotlin/content/skill/summoning/familiar/VoidShifter.kt b/game/src/main/kotlin/content/skill/summoning/familiar/VoidShifter.kt index 017b20c949..e8a582cd88 100644 --- a/game/src/main/kotlin/content/skill/summoning/familiar/VoidShifter.kt +++ b/game/src/main/kotlin/content/skill/summoning/familiar/VoidShifter.kt @@ -4,11 +4,24 @@ 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 -> { 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..61ff0b971f --- /dev/null +++ b/game/src/test/kotlin/content/skill/summoning/familiar/VoidShifterTest.kt @@ -0,0 +1,63 @@ +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 `A different familiar does not teleport the owner`() { + val player = summon("spirit_wolf_familiar") + + player.dropTo(50) + tick(2) + + assertNotEquals(outpost, player.tile) + } +} From 0c3c752fcfe746dbb7eb7b68fe506e8ad54921b7 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Thu, 2 Jul 2026 17:45:10 -0700 Subject: [PATCH 062/136] test: cover Void shifter 10% threshold in life-point units Locks in that the shifter's below-10% trigger scales with life points (level 10 Constitution = 100 life points -> 10 life-point threshold), not the raw skill level. No behaviour change - getMax already returns life points. --- .../summoning/familiar/VoidShifterTest.kt | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/game/src/test/kotlin/content/skill/summoning/familiar/VoidShifterTest.kt b/game/src/test/kotlin/content/skill/summoning/familiar/VoidShifterTest.kt index 61ff0b971f..bbfddf568a 100644 --- a/game/src/test/kotlin/content/skill/summoning/familiar/VoidShifterTest.kt +++ b/game/src/test/kotlin/content/skill/summoning/familiar/VoidShifterTest.kt @@ -51,6 +51,25 @@ class VoidShifterTest : WorldTest() { 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") From 4f106eff0991975b52c41380e1251dafb0f06211 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Fri, 3 Jul 2026 05:46:40 -0700 Subject: [PATCH 063/136] feat: Bull Rush stuns its target Minotaur Bull Rush now stuns the target for 5 ticks on a real cast (via the shared stun primitive), on top of the tier-scaled ranged hit, so it can't act while the familiar charges in - completing the earlier TODO. --- .../skill/summoning/FamiliarCombatSpecials.kt | 13 +++++++++++-- .../summoning/FamiliarSpecialEffectTest.kt | 19 +++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/game/src/main/kotlin/content/skill/summoning/FamiliarCombatSpecials.kt b/game/src/main/kotlin/content/skill/summoning/FamiliarCombatSpecials.kt index 9ca7c7e064..6dce027ade 100644 --- a/game/src/main/kotlin/content/skill/summoning/FamiliarCombatSpecials.kt +++ b/game/src/main/kotlin/content/skill/summoning/FamiliarCombatSpecials.kt @@ -1,5 +1,6 @@ package content.skill.summoning +import content.entity.effect.stun import content.entity.effect.toxin.poison import content.entity.proj.shoot import world.gregs.voidps.engine.Script @@ -17,6 +18,9 @@ import world.gregs.voidps.engine.queue.queue import world.gregs.voidps.engine.timer.CLIENT_TICKS 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 + /** * 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 @@ -137,7 +141,8 @@ class FamiliarCombatSpecials : Script { } npcCombatDamage("giant_chinchompa_familiar", handler = ::autoExplode) - // Minotaur family - Bull Rush: max hit scales with metal tier (stun TODO - needs a stun primitive). + // 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). val bullRush = mapOf( "bronze_minotaur_familiar" to 4, "iron_minotaur_familiar" to 6, @@ -148,7 +153,11 @@ class FamiliarCombatSpecials : Script { ) 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") + val cast = familiarSpecialHit(target, maxHit = maxHit, type = "range", anim = "bull_rush", sourceGfx = "bull_rush", projectile = "bull_rush_proj") + if (cast) { + follower?.stun(target, BULL_RUSH_STUN_TICKS) + } + cast } } } diff --git a/game/src/test/kotlin/content/skill/summoning/FamiliarSpecialEffectTest.kt b/game/src/test/kotlin/content/skill/summoning/FamiliarSpecialEffectTest.kt index f9c413477a..3135bca61f 100644 --- a/game/src/test/kotlin/content/skill/summoning/FamiliarSpecialEffectTest.kt +++ b/game/src/test/kotlin/content/skill/summoning/FamiliarSpecialEffectTest.kt @@ -3,6 +3,7 @@ package content.skill.summoning import FakeRandom import WorldTest import content.entity.combat.hit.hit +import content.entity.effect.stunned import content.entity.effect.toxin.poisoned import content.skill.fletching.fletchLog import interfaceOption @@ -291,4 +292,22 @@ class FamiliarSpecialEffectTest : WorldTest() { assertTrue(cast) assertTrue(target.levels.get(Skill.Constitution) < before) } + + @Test + fun `Bull Rush stuns and damages the target`() { + // 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("rune_minotaur_familiar") + val target = createNPC("giant_rat", player.tile.addY(4)) + val before = target.levels.get(Skill.Constitution) + + val cast = FamiliarSpecialMoves.npcTarget.getValue("rune_minotaur_familiar").invoke(player, target) + assertTrue(cast) + assertTrue(target.stunned, "the charge stuns the target") + + tick(5) // let the ranged hit land + assertTrue(target.levels.get(Skill.Constitution) < before, "bull rush damaged the target") + } } From 9a5116ad9d5c31e43ea4b25d2e688dc70e43d6a8 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Fri, 3 Jul 2026 06:36:46 -0700 Subject: [PATCH 064/136] fix: stunned characters can't swing while charging in Stun set the target's delay/movement_delay/stunned clock, but the combat swing path (CombatMovement -> combatReached -> Combat.combat) only gated on action_delay. Movement was blocked, attacks weren't - so a minotaur's Bull Rush showed the stun graphic yet the target kept hitting. Bail out of combat() when the character is stunned; the stun then expires and the fight resumes on the next tick. Covers players too (e.g. a pickpocket stun landing mid-fight), not just Bull Rush. --- .../kotlin/content/entity/combat/Combat.kt | 8 +++++ .../content/entity/combat/CombatTest.kt | 29 +++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/game/src/main/kotlin/content/entity/combat/Combat.kt b/game/src/main/kotlin/content/entity/combat/Combat.kt index 925d168759..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.* @@ -172,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/test/kotlin/content/entity/combat/CombatTest.kt b/game/src/test/kotlin/content/entity/combat/CombatTest.kt index 0882ccbbea..10fdea6bc4 100644 --- a/game/src/test/kotlin/content/entity/combat/CombatTest.kt +++ b/game/src/test/kotlin/content/entity/combat/CombatTest.kt @@ -4,6 +4,8 @@ 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 @@ -14,6 +16,7 @@ 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 @@ -327,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 } From 986f628c14d7e7dfcd06899ba8c14d1fbe9db12f Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Fri, 3 Jul 2026 08:50:43 -0700 Subject: [PATCH 065/136] feat: minotaur familiars swing their axe (gfx 1498) on normal attacks Every metal tier of minotaur familiar now plays the "minotaur axe swing" graphic on a normal melee attack, matching the live game. Adds a shared minotaur_axe_swing gfx def (id 1498) referenced by all six tiers. --- data/skill/summoning/summoning.combat.toml | 6 ++++++ data/skill/summoning/summoning_combat.gfx.toml | 4 ++++ 2 files changed, 10 insertions(+) diff --git a/data/skill/summoning/summoning.combat.toml b/data/skill/summoning/summoning.combat.toml index c11abd53eb..89395abbaa 100644 --- a/data/skill/summoning/summoning.combat.toml +++ b/data/skill/summoning/summoning.combat.toml @@ -222,6 +222,7 @@ 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] @@ -233,6 +234,7 @@ 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] @@ -244,6 +246,7 @@ 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] @@ -255,6 +258,7 @@ 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] @@ -266,6 +270,7 @@ 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] @@ -277,6 +282,7 @@ 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] diff --git a/data/skill/summoning/summoning_combat.gfx.toml b/data/skill/summoning/summoning_combat.gfx.toml index 1658faf8c8..3d602441ee 100644 --- a/data/skill/summoning/summoning_combat.gfx.toml +++ b/data/skill/summoning/summoning_combat.gfx.toml @@ -43,3 +43,7 @@ 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 + From 3a1e8bc1a9eb04e5744941269958b84b4940a9a1 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Fri, 3 Jul 2026 08:50:43 -0700 Subject: [PATCH 066/136] fix: projectiles emit from the source edge facing the target --- .../content/entity/proj/ShootProjectile.kt | 40 ++++++++++++------- 1 file changed, 25 insertions(+), 15 deletions(-) diff --git a/game/src/main/kotlin/content/entity/proj/ShootProjectile.kt b/game/src/main/kotlin/content/entity/proj/ShootProjectile.kt index 4270507b97..6d175ea6d6 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,30 @@ 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 { + // The start offset (width * 64) leads the projectile that many tiles from the source's south-west + // tile *towards* the target. From the south-west corner that reaches the far edge only when the + // target is north/east; a target to the west/south overshoots by the source's width and the + // projectile appears to spawn on top of it. Anchoring on the footprint tile nearest the target + // makes the origin the target-facing edge in every direction (size-1 sources are unaffected - the + // nearest tile is their only tile). An explicit tile offset keeps its caller-chosen origin. + 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. From ef4952ce5a566c1c050c9f19f5243a5201036254 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Fri, 3 Jul 2026 08:50:43 -0700 Subject: [PATCH 067/136] feat: Bull Rush stuns on impact, a third of the time, over a 2s charge The minotaur's Bull Rush projectile now flies for ~2s and its stun waits for the charge to land rather than firing the instant the special is cast, and only lands about a third of the time (1-in-3). familiarSpecialHit gains an onLand callback, queued to the hit's landing tick so on-impact effects run once the shot arrives. It runs a tick after the projectile flight - the same tick the damage lands, queued after the hit - so the stun (which sets the target's "delay" and would otherwise block the still -pending hit's queue) only fires once the damage has been applied. --- .../summoning/summoning_special.gfx.toml | 4 +++ .../content/entity/proj/ShootProjectile.kt | 6 ---- .../skill/summoning/FamiliarCombatSpecials.kt | 13 ++++++--- .../skill/summoning/FamiliarSpecialHelpers.kt | 13 ++++++++- .../summoning/FamiliarSpecialEffectTest.kt | 29 +++++++++++++++---- 5 files changed, 49 insertions(+), 16 deletions(-) diff --git a/data/skill/summoning/summoning_special.gfx.toml b/data/skill/summoning/summoning_special.gfx.toml index f0dac85910..4d81029cb1 100644 --- a/data/skill/summoning/summoning_special.gfx.toml +++ b/data/skill/summoning/summoning_special.gfx.toml @@ -96,6 +96,10 @@ 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 diff --git a/game/src/main/kotlin/content/entity/proj/ShootProjectile.kt b/game/src/main/kotlin/content/entity/proj/ShootProjectile.kt index 6d175ea6d6..4ea2db6762 100644 --- a/game/src/main/kotlin/content/entity/proj/ShootProjectile.kt +++ b/game/src/main/kotlin/content/entity/proj/ShootProjectile.kt @@ -132,12 +132,6 @@ fun Character.shoot( tileOffsetX: Int = 0, tileOffsetY: Int = 0, ): Int { - // The start offset (width * 64) leads the projectile that many tiles from the source's south-west - // tile *towards* the target. From the south-west corner that reaches the far edge only when the - // target is north/east; a target to the west/south overshoots by the source's width and the - // projectile appears to spawn on top of it. Anchoring on the footprint tile nearest the target - // makes the origin the target-facing edge in every direction (size-1 sources are unaffected - the - // nearest tile is their only tile). An explicit tile offset keeps its caller-chosen origin. val explicitOrigin = tileOffsetX != 0 || tileOffsetY != 0 return projectile( id = id, diff --git a/game/src/main/kotlin/content/skill/summoning/FamiliarCombatSpecials.kt b/game/src/main/kotlin/content/skill/summoning/FamiliarCombatSpecials.kt index 6dce027ade..22a5ae5cb0 100644 --- a/game/src/main/kotlin/content/skill/summoning/FamiliarCombatSpecials.kt +++ b/game/src/main/kotlin/content/skill/summoning/FamiliarCombatSpecials.kt @@ -21,6 +21,9 @@ 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 + /** * 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 @@ -153,11 +156,13 @@ class FamiliarCombatSpecials : Script { ) for ((familiar, maxHit) in bullRush) { FamiliarSpecialMoves.npc(familiar) { target -> - val cast = familiarSpecialHit(target, maxHit = maxHit, type = "range", anim = "bull_rush", sourceGfx = "bull_rush", projectile = "bull_rush_proj") - if (cast) { - follower?.stun(target, BULL_RUSH_STUN_TICKS) + 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) + } } - cast } } } diff --git a/game/src/main/kotlin/content/skill/summoning/FamiliarSpecialHelpers.kt b/game/src/main/kotlin/content/skill/summoning/FamiliarSpecialHelpers.kt index 15d5e0ecc6..e4dbfe0f53 100644 --- a/game/src/main/kotlin/content/skill/summoning/FamiliarSpecialHelpers.kt +++ b/game/src/main/kotlin/content/skill/summoning/FamiliarSpecialHelpers.kt @@ -58,6 +58,10 @@ fun Player.familiarCanSpecial(target: Character, silent: Boolean = false): Boole * 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, @@ -68,6 +72,7 @@ fun Player.familiarSpecialHit( projectile: String? = null, targetGfx: String? = null, engage: Boolean = true, + onLand: ((Character) -> Unit)? = null, ): Boolean { if (!familiarCanSpecial(target)) { return false @@ -82,10 +87,16 @@ fun Player.familiarSpecialHit( 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) } } + 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() diff --git a/game/src/test/kotlin/content/skill/summoning/FamiliarSpecialEffectTest.kt b/game/src/test/kotlin/content/skill/summoning/FamiliarSpecialEffectTest.kt index 3135bca61f..b3dffa4e92 100644 --- a/game/src/test/kotlin/content/skill/summoning/FamiliarSpecialEffectTest.kt +++ b/game/src/test/kotlin/content/skill/summoning/FamiliarSpecialEffectTest.kt @@ -294,10 +294,11 @@ class FamiliarSpecialEffectTest : WorldTest() { } @Test - fun `Bull Rush stuns and damages the target`() { - // Force the random damage roll off zero so the hit is observable. + 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) = until - 1 + 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)) @@ -305,9 +306,27 @@ class FamiliarSpecialEffectTest : WorldTest() { val cast = FamiliarSpecialMoves.npcTarget.getValue("rune_minotaur_familiar").invoke(player, target) assertTrue(cast) - assertTrue(target.stunned, "the charge stuns the target") + assertFalse(target.stunned, "the stun waits for the projectile to reach the target") - tick(5) // let the ranged hit land + 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)) + 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") + } } From 1514dc0c351fbe85113366e1c68822704acc813c Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Fri, 3 Jul 2026 14:46:14 -0700 Subject: [PATCH 068/136] feat: Macaw Herbcall searches out a grimy herb, on a 1-minute cooldown Reworks the Macaw's Herbcall special to follow 2009scape's MacawNPC: the familiar plays its search animation (anim 8013 / gfx 1321), then a few ticks later drops a random grimy herb at its feet. The herb is a uniform pick from all fifteen herb types (guam through torstol, low- and high-level alike), and the special has a one-minute cooldown of its own on top of the usual scroll and point cost. Previously it dropped a single grimy herb from a smaller pool at the player's feet instantly, with no animation or cooldown. --- .../summoning/summoning_special.anims.toml | 4 +++ .../summoning/summoning_special.gfx.toml | 4 +++ .../summoning/FamiliarUtilitySpecials.kt | 29 +++++++++++++++++-- .../summoning/FamiliarSpecialEffectTest.kt | 22 ++++++++++++++ 4 files changed, 56 insertions(+), 3 deletions(-) diff --git a/data/skill/summoning/summoning_special.anims.toml b/data/skill/summoning/summoning_special.anims.toml index a572b73509..c78da7c1a1 100644 --- a/data/skill/summoning/summoning_special.anims.toml +++ b/data/skill/summoning/summoning_special.anims.toml @@ -97,6 +97,10 @@ id = 14858 [generate_compost] id = 7775 +# Macaw's Herbcall animation (played on the familiar as it searches out a herb). +[herbcall] +id = 8013 + # Giant chinchompa's Explode animation (played on the familiar). [chinchompa_explode] id = 7758 diff --git a/data/skill/summoning/summoning_special.gfx.toml b/data/skill/summoning/summoning_special.gfx.toml index 4d81029cb1..1ed347a5b4 100644 --- a/data/skill/summoning/summoning_special.gfx.toml +++ b/data/skill/summoning/summoning_special.gfx.toml @@ -175,6 +175,10 @@ id = 1316 [cheese_feast] id = 1384 +# Macaw's Herbcall graphic (played on the familiar as it searches out a herb). +[herbcall] +id = 1321 + # Compost mound Generate Compost: familiar gfx, projectile to the bin, and the impact at the bin. [generate_compost] id = 1461 diff --git a/game/src/main/kotlin/content/skill/summoning/FamiliarUtilitySpecials.kt b/game/src/main/kotlin/content/skill/summoning/FamiliarUtilitySpecials.kt index 30116e343b..ca5980e7b0 100644 --- a/game/src/main/kotlin/content/skill/summoning/FamiliarUtilitySpecials.kt +++ b/game/src/main/kotlin/content/skill/summoning/FamiliarUtilitySpecials.kt @@ -6,6 +6,8 @@ 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 @@ -27,15 +29,24 @@ import world.gregs.voidps.type.random */ class FamiliarUtilitySpecials : Script { - private val grimyHerbs = listOf( + // Herbcall's herb pool, mirroring 2009scape's MacawNPC.HERBS - a uniform pick from every herb + // type, low- and high-level alike (grimy, as the macaw digs them up). + 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", "papaya_fruit") private val rawFish = listOf("raw_trout", "raw_salmon", "raw_cod", "raw_pike") @@ -135,9 +146,21 @@ class FamiliarUtilitySpecials : Script { true } - // Herbcall - drop a random grimy herb at the player's feet. + // Herbcall - the macaw searches out a herb and drops it at its feet a few ticks later. FamiliarSpecialMoves.instant("macaw_familiar") { - dropForage(grimyHerbs[random.nextInt(grimyHerbs.size)]) + 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") + familiar.gfx("herbcall") + val herb = herbcallHerbs[random.nextInt(herbcallHerbs.size)] + queue("herbcall", 5) { + FloorItems.add(familiar.tile, herb, disappearTicks = 300, owner = this@instant) + } + start("herbcall_delay", 100) + true } // Fruitfall - drop a papaya plus a few random fruits. diff --git a/game/src/test/kotlin/content/skill/summoning/FamiliarSpecialEffectTest.kt b/game/src/test/kotlin/content/skill/summoning/FamiliarSpecialEffectTest.kt index b3dffa4e92..65f43564f6 100644 --- a/game/src/test/kotlin/content/skill/summoning/FamiliarSpecialEffectTest.kt +++ b/game/src/test/kotlin/content/skill/summoning/FamiliarSpecialEffectTest.kt @@ -12,6 +12,7 @@ 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.client.variable.hasClock import world.gregs.voidps.engine.entity.item.floor.FloorItems import world.gregs.voidps.engine.entity.obj.GameObject import world.gregs.voidps.engine.inv.beastOfBurden @@ -329,4 +330,25 @@ class FamiliarSpecialEffectTest : WorldTest() { 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 `Herbcall drops a clean herb at the familiar after a short search, then goes on cooldown`() { + // Pick the first herb in the pool (clean guam) deterministically. + setRandom(object : FakeRandom() { + override fun nextInt(until: Int) = 0 + }) + val player = summon("macaw_familiar") + val familiar = player.follower!! + + assertTrue(player.runSpecial("macaw_familiar")) + assertTrue(player.hasClock("herbcall_delay"), "Herbcall starts its one-minute cooldown") + assertTrue(FloorItems.at(familiar.tile).none { it.id == "grimy_guam" }, "the herb only drops once the search finishes") + + tick(6) + assertTrue(FloorItems.at(familiar.tile).any { it.id == "grimy_guam" }, "the macaw drops the herb it searched out") + + // Still on cooldown - a second cast is refused and produces nothing more. + assertFalse(player.runSpecial("macaw_familiar")) + assertEquals(1, FloorItems.at(familiar.tile).count { it.id == "grimy_guam" }, "no extra herb while on cooldown") + } } From 89f5693547146297a6cafb4cf5f870d71ce6e0ce Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Sat, 4 Jul 2026 06:09:03 -0700 Subject: [PATCH 069/136] feat: Herbcall flies up, drops its herb mid-graphic, then recalls the macaw Sequences the Macaw's Herbcall around its animations: the macaw plays its fly-up/search animation on cast, its drop graphic bursts where it hovers about three seconds later, and the grimy herb lands a moment after that before the bird is called back down beside its owner. The queue delays account for two engine quirks - a queued action fires initialDelay + 1 ticks later, and a dropped floor item only becomes visible the following tick - so the herb settles roughly four seconds after the cast. --- .../summoning/summoning_special.anims.toml | 4 +++- .../summoning/FamiliarUtilitySpecials.kt | 23 ++++++++++++++++--- .../summoning/FamiliarSpecialEffectTest.kt | 17 +++++++++----- 3 files changed, 34 insertions(+), 10 deletions(-) diff --git a/data/skill/summoning/summoning_special.anims.toml b/data/skill/summoning/summoning_special.anims.toml index c78da7c1a1..07d177bf65 100644 --- a/data/skill/summoning/summoning_special.anims.toml +++ b/data/skill/summoning/summoning_special.anims.toml @@ -97,9 +97,11 @@ id = 14858 [generate_compost] id = 7775 -# Macaw's Herbcall animation (played on the familiar as it searches out a herb). +# 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 # Giant chinchompa's Explode animation (played on the familiar). [chinchompa_explode] diff --git a/game/src/main/kotlin/content/skill/summoning/FamiliarUtilitySpecials.kt b/game/src/main/kotlin/content/skill/summoning/FamiliarUtilitySpecials.kt index ca5980e7b0..6dc9b5af73 100644 --- a/game/src/main/kotlin/content/skill/summoning/FamiliarUtilitySpecials.kt +++ b/game/src/main/kotlin/content/skill/summoning/FamiliarUtilitySpecials.kt @@ -22,6 +22,15 @@ 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 + /** * 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. @@ -146,7 +155,9 @@ class FamiliarUtilitySpecials : Script { true } - // Herbcall - the macaw searches out a herb and drops it at its feet a few ticks later. + // 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. As in + // 2009scape it has a one-minute cooldown of its own on top of the usual scroll/point cost. FamiliarSpecialMoves.instant("macaw_familiar") { val familiar = follower ?: return@instant false if (hasClock("herbcall_delay")) { @@ -154,10 +165,16 @@ class FamiliarUtilitySpecials : Script { return@instant false } familiar.anim("herbcall") - familiar.gfx("herbcall") val herb = herbcallHerbs[random.nextInt(herbcallHerbs.size)] - queue("herbcall", 5) { + // 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 diff --git a/game/src/test/kotlin/content/skill/summoning/FamiliarSpecialEffectTest.kt b/game/src/test/kotlin/content/skill/summoning/FamiliarSpecialEffectTest.kt index 65f43564f6..f9f1b6d742 100644 --- a/game/src/test/kotlin/content/skill/summoning/FamiliarSpecialEffectTest.kt +++ b/game/src/test/kotlin/content/skill/summoning/FamiliarSpecialEffectTest.kt @@ -332,23 +332,28 @@ class FamiliarSpecialEffectTest : WorldTest() { } @Test - fun `Herbcall drops a clean herb at the familiar after a short search, then goes on cooldown`() { - // Pick the first herb in the pool (clean guam) deterministically. + 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") - assertTrue(FloorItems.at(familiar.tile).none { it.id == "grimy_guam" }, "the herb only drops once the search finishes") - tick(6) - assertTrue(FloorItems.at(familiar.tile).any { it.id == "grimy_guam" }, "the macaw drops the herb it searched out") + 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(familiar.tile).count { it.id == "grimy_guam" }, "no extra herb while on cooldown") + assertEquals(1, FloorItems.at(searchTile).count { it.id == "grimy_guam" }, "no extra herb while on cooldown") } } From 9041e87fd5a20452a4fe45aa90b516362a3a6fa3 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Sat, 4 Jul 2026 10:13:44 -0700 Subject: [PATCH 070/136] feat: cockatrice Drain option recasts Petrifying Gaze; Evil Flames plays its breath gfx and stops self-healing --- .../summoning/summoning_special.gfx.toml | 4 ++ .../skill/summoning/FamiliarCombatSpecials.kt | 23 +++++++-- .../summoning/FamiliarSpecialEffectTest.kt | 49 +++++++++++++++++++ 3 files changed, 71 insertions(+), 5 deletions(-) diff --git a/data/skill/summoning/summoning_special.gfx.toml b/data/skill/summoning/summoning_special.gfx.toml index 1ed347a5b4..af1a0c1633 100644 --- a/data/skill/summoning/summoning_special.gfx.toml +++ b/data/skill/summoning/summoning_special.gfx.toml @@ -67,6 +67,10 @@ id = 1370 [rending_proj] id = 1371 +# Evil turnip Evil Flames: source gfx on the familiar as it breathes, the projectile, and the impact. +[evil_flames] +id = 1328 + [evil_flames_proj] id = 1330 diff --git a/game/src/main/kotlin/content/skill/summoning/FamiliarCombatSpecials.kt b/game/src/main/kotlin/content/skill/summoning/FamiliarCombatSpecials.kt index 22a5ae5cb0..d8cc47d938 100644 --- a/game/src/main/kotlin/content/skill/summoning/FamiliarCombatSpecials.kt +++ b/game/src/main/kotlin/content/skill/summoning/FamiliarCombatSpecials.kt @@ -1,9 +1,11 @@ package content.skill.summoning +import content.entity.combat.target import content.entity.effect.stun import content.entity.effect.toxin.poison 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 @@ -75,12 +77,9 @@ class FamiliarCombatSpecials : Script { FamiliarSpecialMoves.npc("spirit_larupia_familiar") { target -> familiarSpecialHit(target, maxHit = 10, anim = "rending", sourceGfx = "rending", projectile = "rending_proj").alsoDrain(target, Skill.Strength, 1) } + // Evil Flames - the evil turnip breathes a magic fireball, lowering the target's Magic by 1. FamiliarSpecialMoves.npc("evil_turnip_familiar") { target -> - val cast = familiarSpecialHit(target, maxHit = 10, anim = "evil_flames", projectile = "evil_flames_proj", targetGfx = "evil_flames_hit").alsoDrain(target, Skill.Magic, 1) - if (cast) { - levels.restore(Skill.Constitution, 2) - } - cast + familiarSpecialHit(target, maxHit = 10, anim = "evil_flames", sourceGfx = "evil_flames", projectile = "evil_flames_proj", targetGfx = "evil_flames_hit").alsoDrain(target, Skill.Magic, 1) } FamiliarSpecialMoves.npc("abyssal_parasite_familiar") { target -> familiarSpecialHit(target, maxHit = 7, anim = "abyssal_drain", sourceGfx = "abyssal_drain", projectile = "abyssal_drain_proj").alsoDrain(target, Skill.Prayer, random.nextInt(3) + 1) @@ -124,6 +123,20 @@ class FamiliarCombatSpecials : Script { familiarSpecialHit(target, maxHit = 10, 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) } + } } // Fireball Assault - the spirit Tz-Kih flings fire at up to two nearby foes. diff --git a/game/src/test/kotlin/content/skill/summoning/FamiliarSpecialEffectTest.kt b/game/src/test/kotlin/content/skill/summoning/FamiliarSpecialEffectTest.kt index f9f1b6d742..c2bb5f3e09 100644 --- a/game/src/test/kotlin/content/skill/summoning/FamiliarSpecialEffectTest.kt +++ b/game/src/test/kotlin/content/skill/summoning/FamiliarSpecialEffectTest.kt @@ -3,10 +3,12 @@ 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.data.definition.NPCDefinitions import world.gregs.voidps.engine.data.definition.Rows @@ -331,6 +333,53 @@ class FamiliarSpecialEffectTest : WorldTest() { 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. From 856e69720a73eb3ab5f17eabc7ef17b321faa657 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Sat, 4 Jul 2026 10:17:28 -0700 Subject: [PATCH 071/136] fix: familiar specials deal life-point (x10) damage, matching normal attacks All special max hits and flat heals were written against 2009scape's 1x hitpoint scale, but familiar auto-attacks (and the rest of combat) use x10 life points - so every special hit for a tenth of its real value. Rescaled to the x10 reference values, and while in here: Arctic Blast now stuns small targets on impact 1 time in 5, and Crushing Claw drains 5% Defence, both as in the live game. --- .../skill/summoning/FamiliarBoostSpecials.kt | 9 ++- .../skill/summoning/FamiliarCombatSpecials.kt | 65 ++++++++++++------- .../summoning/FamiliarSpecialEffectTest.kt | 3 + 3 files changed, 47 insertions(+), 30 deletions(-) diff --git a/game/src/main/kotlin/content/skill/summoning/FamiliarBoostSpecials.kt b/game/src/main/kotlin/content/skill/summoning/FamiliarBoostSpecials.kt index 8cd2bb5ccc..ef71b26439 100644 --- a/game/src/main/kotlin/content/skill/summoning/FamiliarBoostSpecials.kt +++ b/game/src/main/kotlin/content/skill/summoning/FamiliarBoostSpecials.kt @@ -13,7 +13,6 @@ 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 world.gregs.voidps.type.random import kotlin.math.ceil /** Flight time (client ticks; 30 = 1 game tick) of the spirit scorpion's slow venom bolt to its owner. */ @@ -55,11 +54,11 @@ class FamiliarBoostSpecials : Script { } } - // Elemental titans - Titan's Constitution: Defence +12.5% and heal 8. + // Elemental titans - Titan's Constitution: Defence +12.5% and heal 80. FamiliarSpecialMoves.instant("fire_titan_familiar", "moss_titan_familiar", "ice_titan_familiar") { familiarSelfSpecial { levels.boost(Skill.Defence, multiplier = 0.125) - levels.restore(Skill.Constitution, 8) + levels.restore(Skill.Constitution, 80) } } @@ -70,7 +69,7 @@ class FamiliarBoostSpecials : Script { } } - // Blood Drain - cure poison, restore drained stats by ~20%, then take 1-5 recoil damage. + // Blood Drain - cure poison, restore drained stats by ~20%, then take 25 recoil damage. FamiliarSpecialMoves.instant("bloated_leech_familiar") { familiarSelfSpecial { curePoison() @@ -80,7 +79,7 @@ class FamiliarBoostSpecials : Script { levels.restore(skill, ceil(levels.getMax(skill) * 0.2).toInt()) } } - directHit(random.nextInt(5) + 1, "damage") + directHit(25, "damage") } } diff --git a/game/src/main/kotlin/content/skill/summoning/FamiliarCombatSpecials.kt b/game/src/main/kotlin/content/skill/summoning/FamiliarCombatSpecials.kt index d8cc47d938..e378ebdbf1 100644 --- a/game/src/main/kotlin/content/skill/summoning/FamiliarCombatSpecials.kt +++ b/game/src/main/kotlin/content/skill/summoning/FamiliarCombatSpecials.kt @@ -26,6 +26,12 @@ 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 + /** * 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 @@ -52,42 +58,51 @@ class FamiliarCombatSpecials : Script { // Straightforward "fire a projectile, deal up to maxHit" combat specials. FamiliarSpecialMoves.npc("desert_wyrm_familiar") { target -> - familiarSpecialHit(target, maxHit = 5, anim = "electric_lash", sourceGfx = "electric_lash", projectile = "electric_lash_proj") + familiarSpecialHit(target, maxHit = 50, anim = "electric_lash", sourceGfx = "electric_lash", projectile = "electric_lash_proj") } FamiliarSpecialMoves.npc("barker_toad_familiar") { target -> - familiarSpecialHit(target, maxHit = 8, sourceGfx = "toad_bark", targetGfx = "toad_bark_hit") + familiarSpecialHit(target, maxHit = 80, sourceGfx = "toad_bark", targetGfx = "toad_bark_hit") } FamiliarSpecialMoves.npc("thorny_snail_familiar") { target -> - familiarSpecialHit(target, maxHit = 8, anim = "slime_spray", sourceGfx = "slime_spray", projectile = "slime_spray_proj", targetGfx = "slime_spray_hit") + 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 = 15, anim = "arctic_blast", sourceGfx = "arctic_blast", projectile = "arctic_blast_proj", targetGfx = "arctic_blast_hit") + 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 = 14, anim = "crushing_claw", sourceGfx = "crushing_claw", projectile = "crushing_claw_proj") + val cast = familiarSpecialHit(target, maxHit = 96, anim = "crushing_claw", sourceGfx = "crushing_claw", projectile = "crushing_claw_proj") + if (cast) { + target.levels.drain(Skill.Defence, multiplier = 0.05) + } + cast } FamiliarSpecialMoves.npc("dreadfowl_familiar") { target -> - familiarSpecialHit(target, maxHit = 3, anim = "dreadfowl_strike", sourceGfx = "dreadfowl_strike", projectile = "dreadfowl_strike_proj") + familiarSpecialHit(target, maxHit = 30, anim = "dreadfowl_strike", sourceGfx = "dreadfowl_strike", projectile = "dreadfowl_strike_proj") } // Combat specials that also drain one of the target's stats. FamiliarSpecialMoves.npc("spirit_jelly_familiar") { target -> - familiarSpecialHit(target, maxHit = 13, anim = "dissolve", projectile = "dissolve_proj").alsoDrain(target, Skill.Attack, 3) + familiarSpecialHit(target, maxHit = 120, anim = "dissolve", projectile = "dissolve_proj").alsoDrain(target, Skill.Attack, 3) } FamiliarSpecialMoves.npc("spirit_larupia_familiar") { target -> - familiarSpecialHit(target, maxHit = 10, anim = "rending", sourceGfx = "rending", projectile = "rending_proj").alsoDrain(target, Skill.Strength, 1) + familiarSpecialHit(target, maxHit = 120, anim = "rending", sourceGfx = "rending", projectile = "rending_proj").alsoDrain(target, Skill.Strength, 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 = 10, anim = "evil_flames", sourceGfx = "evil_flames", projectile = "evil_flames_proj", targetGfx = "evil_flames_hit").alsoDrain(target, Skill.Magic, 1) + familiarSpecialHit(target, maxHit = 100, anim = "evil_flames", sourceGfx = "evil_flames", projectile = "evil_flames_proj", targetGfx = "evil_flames_hit").alsoDrain(target, Skill.Magic, 1) } FamiliarSpecialMoves.npc("abyssal_parasite_familiar") { target -> - familiarSpecialHit(target, maxHit = 7, anim = "abyssal_drain", sourceGfx = "abyssal_drain", projectile = "abyssal_drain_proj").alsoDrain(target, Skill.Prayer, random.nextInt(3) + 1) + familiarSpecialHit(target, maxHit = 95, anim = "abyssal_drain", sourceGfx = "abyssal_drain", projectile = "abyssal_drain_proj").alsoDrain(target, Skill.Prayer, random.nextInt(3) + 1) } // Poisonous Blast - small hit with a 50% chance to poison. FamiliarSpecialMoves.npc("stranger_plant_familiar") { target -> - val cast = familiarSpecialHit(target, maxHit = 2, anim = "poisonous_blast", projectile = "poisonous_blast_proj", targetGfx = "poisonous_blast_hit") + 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) } @@ -96,14 +111,14 @@ class FamiliarCombatSpecials : Script { // Boil - geyser titan ranged hit (approximates the live def-bonus formula with a flat max). FamiliarSpecialMoves.npc("geyser_titan_familiar") { target -> - familiarSpecialHit(target, maxHit = 20, anim = "boil", sourceGfx = "boil", projectile = "boil_proj", targetGfx = "boil_hit") + familiarSpecialHit(target, maxHit = 240, anim = "boil", sourceGfx = "boil", projectile = "boil_proj", targetGfx = "boil_hit") } - // Vampyre Touch: 12 max, 40% chance to heal the owner 2. + // Vampyre Touch: 120 max, 40% chance to heal the owner 20. FamiliarSpecialMoves.npc("vampire_bat_familiar") { target -> - val cast = familiarSpecialHit(target, maxHit = 12, anim = "vampire_touch", sourceGfx = "vampire_touch") + val cast = familiarSpecialHit(target, maxHit = 120, anim = "vampire_touch", sourceGfx = "vampire_touch") if (cast && random.nextInt(100) < 40) { - levels.restore(Skill.Constitution, 2) + levels.restore(Skill.Constitution, 20) } cast } @@ -120,7 +135,7 @@ class FamiliarCombatSpecials : Script { ) for ((familiar, skill) in petrifyingGaze) { FamiliarSpecialMoves.npc(familiar) { target -> - familiarSpecialHit(target, maxHit = 10, anim = "petrifying_gaze", sourceGfx = "petrifying_gaze", projectile = "petrifying_gaze_proj", targetGfx = "petrifying_gaze_hit") + 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 @@ -141,12 +156,12 @@ class FamiliarCombatSpecials : Script { // Fireball Assault - the spirit Tz-Kih flings fire at up to two nearby foes. FamiliarSpecialMoves.instant("spirit_tz-kih_familiar") { - familiarAoeSpecial(maxTargets = 2, maxHit = 7, radius = 3, anim = "fireball_assault", targetGfx = "fireball_assault_hit") + 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 = 20, radius = 6, anim = "sandstorm", sourceGfx = "sandstorm", projectile = "sandstorm_proj") + familiarAoeSpecial(maxTargets = 6, maxHit = 200, radius = 6, anim = "sandstorm", sourceGfx = "sandstorm", projectile = "sandstorm_proj") } // Explode - the giant chinchompa detonates, hitting everything around it, then is consumed by @@ -160,12 +175,12 @@ class FamiliarCombatSpecials : Script { // 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). val bullRush = mapOf( - "bronze_minotaur_familiar" to 4, - "iron_minotaur_familiar" to 6, - "steel_minotaur_familiar" to 9, - "mithril_minotaur_familiar" to 13, - "adamant_minotaur_familiar" to 16, - "rune_minotaur_familiar" to 20, + "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 -> @@ -186,7 +201,7 @@ class FamiliarCombatSpecials : Script { * nothing, when there is nothing nearby to hit. */ private fun Player.chinchompaExplode(): Boolean { - val cast = familiarAoeSpecial(maxTargets = 9, maxHit = 12, radius = 6, anim = "chinchompa_explode", sourceGfx = "chinchompa_explode") + 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. diff --git a/game/src/test/kotlin/content/skill/summoning/FamiliarSpecialEffectTest.kt b/game/src/test/kotlin/content/skill/summoning/FamiliarSpecialEffectTest.kt index c2bb5f3e09..6e632542f3 100644 --- a/game/src/test/kotlin/content/skill/summoning/FamiliarSpecialEffectTest.kt +++ b/game/src/test/kotlin/content/skill/summoning/FamiliarSpecialEffectTest.kt @@ -305,6 +305,8 @@ class FamiliarSpecialEffectTest : WorldTest() { }) 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) @@ -324,6 +326,7 @@ class FamiliarSpecialEffectTest : WorldTest() { }) 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)) From 01201d7140ca5764e67e102cd8867b51582c87f9 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Sat, 4 Jul 2026 10:22:04 -0700 Subject: [PATCH 072/136] feat: nine familiar combat specials - Doomsphere, Spike Shot, Swamp Plague, Ebon Thunder, Mantis Strike, Deadly Claw, Acorn Missile, Famine and Inferno Karamthulhu's Doomsphere washes away 5% of the target's Magic; the swamp titan's plague poisons; the lava titan's bolt saps a player's special attack energy on impact; the praying mantis binds small targets in place (movement only - deliberately not the bull-rush stun); the talon beast rakes three times in a burst; the giant ent's acorns splash everything adjacent to the target; the ravenous locust turns a player's food rotten; and the forge regent burns another player's weapon and shield out of their hands. Values from darkan's x10 scale. --- .../summoning/summoning_special.anims.toml | 28 +++ .../summoning/summoning_special.gfx.toml | 93 ++++++++++ .../skill/summoning/FamiliarCombatSpecials.kt | 163 +++++++++++++++++- .../skill/summoning/FamiliarSpecialStubs.kt | 17 -- 4 files changed, 276 insertions(+), 25 deletions(-) diff --git a/data/skill/summoning/summoning_special.anims.toml b/data/skill/summoning/summoning_special.anims.toml index 07d177bf65..2445772bf9 100644 --- a/data/skill/summoning/summoning_special.anims.toml +++ b/data/skill/summoning/summoning_special.anims.toml @@ -117,3 +117,31 @@ id = 8136 [call_to_arms_land] id = 8137 + +# Karamthulhu overlord's Doomsphere attack animation. +[doomsphere] +id = 7974 + +# Spirit dagannoth's Spike Shot attack animation. +[spike_shot] +id = 7787 + +# Swamp titan's Swamp Plague attack animation. +[swamp_plague] +id = 8223 + +# Lava titan's Ebon Thunder attack animation. +[ebon_thunder] +id = 7986 + +# Praying mantis' Mantis Strike attack animation. +[mantis_strike] +id = 8071 + +# Giant ent's Acorn Missile attack animation. +[acorn_missile] +id = 7858 + +# Ravenous locust's Famine attack animation. +[famine] +id = 7998 diff --git a/data/skill/summoning/summoning_special.gfx.toml b/data/skill/summoning/summoning_special.gfx.toml index af1a0c1633..af28c709d1 100644 --- a/data/skill/summoning/summoning_special.gfx.toml +++ b/data/skill/summoning/summoning_special.gfx.toml @@ -207,3 +207,96 @@ id = 1503 [call_to_arms_land] id = 1502 + +# Karamthulhu overlord Doomsphere: familiar gfx, water-orb projectile, and the impact splash. +[doomsphere] +id = 1478 + +[doomsphere_proj] +id = 1479 +delay = 51 +time_offset = 46 +multiplier = 1 +curve = 0 + +[doomsphere_hit] +id = 1480 + +# Spirit dagannoth Spike Shot: the launched spike and its impact. +[spike_shot_proj] +id = 1426 +delay = 51 +time_offset = 46 +multiplier = 1 +curve = 0 + +[spike_shot_hit] +id = 1428 + +# Swamp titan Swamp Plague: the bog projectile. +[swamp_plague_proj] +id = 1462 +delay = 51 +time_offset = 46 +multiplier = 1 +curve = 0 + +# Lava titan Ebon Thunder: familiar gfx, the dark bolt, and the impact. +[ebon_thunder] +id = 1492 + +[ebon_thunder_proj] +id = 1493 +delay = 51 +time_offset = 46 +multiplier = 1 +curve = 0 + +[ebon_thunder_hit] +id = 1494 + +# Praying mantis Mantis Strike: familiar gfx, the bolt, and the binding impact. +[mantis_strike] +id = 1379 + +[mantis_strike_proj] +id = 1380 +delay = 51 +time_offset = 46 +multiplier = 1 +curve = 0 + +[mantis_strike_hit] +id = 1381 + +# Giant ent Acorn Missile: the lobbed acorns and their impact. +[acorn_missile_proj] +id = 1362 +delay = 51 +time_offset = 46 +multiplier = 1 +curve = 0 + +[acorn_missile_hit] +id = 1363 + +# Ravenous locust Famine: familiar gfx, the swarm projectile, and the impact. +[famine] +id = 1346 + +[famine_proj] +id = 1347 +delay = 51 +time_offset = 46 +multiplier = 1 +curve = 0 + +[famine_hit] +id = 1348 + +# Forge regent Inferno: the flare on the familiar and the burst on the disarmed player. +[inferno] +id = 1394 + +[inferno_hit] +id = 1393 diff --git a/game/src/main/kotlin/content/skill/summoning/FamiliarCombatSpecials.kt b/game/src/main/kotlin/content/skill/summoning/FamiliarCombatSpecials.kt index e378ebdbf1..dc723715f4 100644 --- a/game/src/main/kotlin/content/skill/summoning/FamiliarCombatSpecials.kt +++ b/game/src/main/kotlin/content/skill/summoning/FamiliarCombatSpecials.kt @@ -1,8 +1,12 @@ 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 @@ -12,12 +16,21 @@ 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.npc.NPCs import world.gregs.voidps.engine.entity.character.player.Player import world.gregs.voidps.engine.entity.character.player.Players +import world.gregs.voidps.engine.entity.character.player.name import world.gregs.voidps.engine.entity.character.player.skill.Skill import world.gregs.voidps.engine.get +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.map.spiral 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. */ @@ -32,6 +45,12 @@ 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 much special attack energy (out of 1000) the lava titan's Ebon Thunder drains from a player. */ +private const val EBON_THUNDER_ENERGY_DRAIN = 100 + /** * 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 @@ -75,11 +94,7 @@ class FamiliarCombatSpecials : Script { } } FamiliarSpecialMoves.npc("granite_lobster_familiar") { target -> - val cast = familiarSpecialHit(target, maxHit = 96, anim = "crushing_claw", sourceGfx = "crushing_claw", projectile = "crushing_claw_proj") - if (cast) { - target.levels.drain(Skill.Defence, multiplier = 0.05) - } - cast + 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") @@ -114,6 +129,119 @@ class FamiliarCombatSpecials : Script { familiarSpecialHit(target, maxHit = 240, 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. + FamiliarSpecialMoves.npc("spirit_dagannoth_familiar") { target -> + familiarSpecialHit(target, maxHit = 170, anim = "spike_shot", projectile = "spike_shot_proj", targetGfx = "spike_shot_hit") + } + + // 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 player target's 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 three times in quick succession. + FamiliarSpecialMoves.npc("talon_beast_familiar") { target -> + if (!familiarCanSpecial(target)) { + return@npc false + } + val familiar = follower ?: return@npc false + familiar.watch(target) + repeat(3) { swipe -> + familiar.hit(target, offensiveType = "magic", damage = random.nextInt(101), delay = 64 + 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 anything adjacent to 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) { + val familiar = follower + if (familiar != null && target is NPC) { + for (tile in target.tile.spiral(1)) { + for (splashed in NPCs.at(tile)) { + if (splashed == target || !familiarCanSpecial(splashed, silent = true)) { + continue + } + familiar.hit(splashed, offensiveType = "magic", damage = random.nextInt(101)) + } + } + } + } + cast + } + + // Famine - the ravenous locust swarms the target; a player target has some food eaten rotten. + FamiliarSpecialMoves.npc("ravenous_locust_familiar") { target -> + familiarSpecialHit(target, maxHit = 50, anim = "famine", sourceGfx = "famine", projectile = "famine_proj", targetGfx = "famine_hit") + } + FamiliarSpecialMoves.player("ravenous_locust_familiar") { target -> + val cast = familiarSpecialHit(target, maxHit = 50, anim = "famine", sourceGfx = "famine", projectile = "famine_proj", targetGfx = "famine_hit") + if (cast && rotFood(target)) { + message("Your locust devours some of ${target.name}'s food.") + target.message("${name}'s locust devours some of your food!") + } + cast + } + + // Inferno - the forge regent's flare knocks another player's weapon and shield out of their + // hands (into their inventory). Fails, charging nothing, if nothing could be unequipped. + FamiliarSpecialMoves.player("forge_regent_familiar") { target -> + if (!familiarCanSpecial(target)) { + return@player false + } + val familiar = follower ?: return@player false + 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) { + message("Your familiar couldn't disarm ${target.name}.") + return@player false + } + familiar.watch(target) + familiar.gfx("inferno") + target.gfx("inferno_hit") + target.message("${name}'s familiar burns the equipment from your hands!") + true + } + // 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") @@ -232,11 +360,30 @@ class FamiliarCombatSpecials : Script { owner.chinchompaExplode() } - /** Drains [skill] on [target] by [amount] but only when the hit actually landed ([cast] true). */ - private fun Boolean.alsoDrain(target: Character, skill: Skill, amount: Int): Boolean { + /** 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) + target.levels.drain(skill, amount, multiplier) } return this } + + /** Ebon Thunder's on-impact effect: saps some of a player target's special attack energy. */ + private fun drainSpecialEnergy(target: Character) { + if (target is Player) { + target.specialAttackEnergy = (target.specialAttackEnergy - EBON_THUNDER_ENERGY_DRAIN).coerceAtLeast(0) + } + } + + /** Famine's food theft: turns the first edible item in [target]'s inventory rotten. */ + private fun rotFood(target: Player): Boolean { + for (item in target.inventory.items) { + if (item.isEmpty() || !item.def.options.contains("Eat")) { + continue + } + target.inventory.replace(item.id, "rotten_food") + return true + } + return false + } } diff --git a/game/src/main/kotlin/content/skill/summoning/FamiliarSpecialStubs.kt b/game/src/main/kotlin/content/skill/summoning/FamiliarSpecialStubs.kt index 27a2c4f7e7..9fc4c73a0a 100644 --- a/game/src/main/kotlin/content/skill/summoning/FamiliarSpecialStubs.kt +++ b/game/src/main/kotlin/content/skill/summoning/FamiliarSpecialStubs.kt @@ -13,8 +13,6 @@ import world.gregs.voidps.engine.entity.character.player.Player * - Skill-system scenery: Hydra (Regrowth) (Beaver's Multichop is done) * - "Charge the next attack" buffs: Iron titan, Steel titan (Spirit scorpion + Honey badger are done) * - AoE hits: Smoke devil (Giant chinchompa's Explode is done) - * - Misc combat: Forge regent (disarm), Ravenous locust (Famine), Karamthulhu, Spirit dagannoth, - * Talon beast, Swamp titan, Lava titan, Praying mantis, Giant ent * * The Gorajo (bloodrager/deathslinger/...), meerkat and phoenix familiars have no 2009-era special * and so are intentionally left unregistered (the cast button does nothing for them). @@ -24,21 +22,6 @@ class FamiliarSpecialStubs : Script { private val notImplemented = "Your familiar's special move isn't available yet." init { - // Combat specials that target an npc. - FamiliarSpecialMoves.npc( - "ravenous_locust_familiar", - "karamthulhu_overlord_familiar", - "spirit_dagannoth_familiar", - "talon_beast_familiar", - "swamp_titan_familiar", - "lava_titan_familiar", - "praying_mantis_familiar", - "giant_ent_familiar", - ) { _ -> stub() } - - // Forge regent's Inferno disarms another player. - FamiliarSpecialMoves.player("forge_regent_familiar") { _ -> stub() } - // Instant self / AoE / charge specials. FamiliarSpecialMoves.instant( "smoke_devil_familiar", From 81228b57fa7013ad4b5dd152dc4a416130c35716 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Sat, 4 Jul 2026 10:24:07 -0700 Subject: [PATCH 073/136] feat: Dust Cloud chokes up to seven nearby foes; Iron Within and Steel of Legends charge the titan's next swing into a flurry The smoke devil's Dust Cloud is a radius-1 burst hitting up to seven targets for up to 80 each. The iron and steel titans charge their next attack (melee-only for iron) to land 3 / 4 hits on their target - the charge is stored on the owner like the spirit scorpion's Venom Shot and spent by an npcCombatAttack handler, which clears the flag before the extra hits so re-fired events can't recurse. --- .../summoning/summoning_special.anims.toml | 11 ++++++ .../summoning/summoning_special.gfx.toml | 21 +++++++++++ .../skill/summoning/FamiliarCombatSpecials.kt | 37 +++++++++++++++++++ .../skill/summoning/FamiliarSpecialStubs.kt | 9 ----- 4 files changed, 69 insertions(+), 9 deletions(-) diff --git a/data/skill/summoning/summoning_special.anims.toml b/data/skill/summoning/summoning_special.anims.toml index 2445772bf9..425a704aa7 100644 --- a/data/skill/summoning/summoning_special.anims.toml +++ b/data/skill/summoning/summoning_special.anims.toml @@ -145,3 +145,14 @@ id = 7858 # Ravenous locust's Famine attack animation. [famine] id = 7998 + +# Smoke devil's Dust Cloud attack animation. +[dust_cloud] +id = 7820 + +# Iron/steel titan's charge-up animation (shared swing, different graphics). +[iron_within] +id = 8183 + +[steel_of_legends] +id = 8183 diff --git a/data/skill/summoning/summoning_special.gfx.toml b/data/skill/summoning/summoning_special.gfx.toml index af28c709d1..062909b2a2 100644 --- a/data/skill/summoning/summoning_special.gfx.toml +++ b/data/skill/summoning/summoning_special.gfx.toml @@ -300,3 +300,24 @@ id = 1394 [inferno_hit] id = 1393 + +# Smoke devil Dust Cloud: familiar gfx, the dust burst, and the choking impact. +[dust_cloud] +id = 1375 + +[dust_cloud_proj] +id = 1376 +delay = 51 +time_offset = 46 +multiplier = 1 +curve = 0 + +[dust_cloud_hit] +id = 1377 + +# Iron Within / Steel of Legends: the charge-up glow on each titan. +[iron_within] +id = 1450 + +[steel_of_legends] +id = 1449 diff --git a/game/src/main/kotlin/content/skill/summoning/FamiliarCombatSpecials.kt b/game/src/main/kotlin/content/skill/summoning/FamiliarCombatSpecials.kt index dc723715f4..d5a890b5a2 100644 --- a/game/src/main/kotlin/content/skill/summoning/FamiliarCombatSpecials.kt +++ b/game/src/main/kotlin/content/skill/summoning/FamiliarCombatSpecials.kt @@ -282,6 +282,11 @@ class FamiliarCombatSpecials : Script { } } + // Dust Cloud - the smoke devil chokes everything around it in a burst of hot dust. + FamiliarSpecialMoves.instant("smoke_devil_familiar") { + familiarAoeSpecial(maxTargets = 7, maxHit = 80, radius = 1, anim = "dust_cloud", sourceGfx = "dust_cloud", projectile = "dust_cloud_proj", targetGfx = "dust_cloud_hit") + } + // 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") @@ -302,6 +307,38 @@ class FamiliarCombatSpecials : Script { // 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 / Steel of Legends - charge the titan so its next attack (melee-only for the + // iron titan) lands as a flurry of 3 / 4 hits on its target. The charge is set on cast and + // spent by the npcCombatAttack handler on the familiar's next swing. + for ((familiar, extraHits) in mapOf("iron_titan_familiar" to 2, "steel_titan_familiar" to 3)) { + val charge = "familiar_titan_charged" + FamiliarSpecialMoves.instant(familiar) { + if (this[charge, false]) { + message("Your familiar's special attack is already charged.") + return@instant false + } + val titan = follower ?: return@instant false + titan.anim(if (familiar == "iron_titan_familiar") "iron_within" else "steel_of_legends") + titan.gfx(if (familiar == "iron_titan_familiar") "iron_within" else "steel_of_legends") + set(charge, true) + true + } + npcCombatAttack(familiar) { attack -> + if (familiar == "iron_titan_familiar" && attack.type != "melee") { + return@npcCombatAttack + } + val owner = Players.indexed(this["owner_index", -1]) ?: return@npcCombatAttack + if (owner.follower?.index != index || !owner[charge, false]) { + return@npcCombatAttack + } + // Clear before the extra hits - each one re-fires this handler. + owner.clear(charge) + repeat(extraHits) { swing -> + hit(attack.target, offensiveType = attack.type, delay = attack.delay + (swing + 1) * 30) + } + } + } + val bullRush = mapOf( "bronze_minotaur_familiar" to 80, "iron_minotaur_familiar" to 100, diff --git a/game/src/main/kotlin/content/skill/summoning/FamiliarSpecialStubs.kt b/game/src/main/kotlin/content/skill/summoning/FamiliarSpecialStubs.kt index 9fc4c73a0a..fc02341b28 100644 --- a/game/src/main/kotlin/content/skill/summoning/FamiliarSpecialStubs.kt +++ b/game/src/main/kotlin/content/skill/summoning/FamiliarSpecialStubs.kt @@ -11,8 +11,6 @@ import world.gregs.voidps.engine.entity.character.player.Player * * - Item interactions (cook/incubate/smelt): Bunyip, Spirit cobra, Pyre lord * - Skill-system scenery: Hydra (Regrowth) (Beaver's Multichop is done) - * - "Charge the next attack" buffs: Iron titan, Steel titan (Spirit scorpion + Honey badger are done) - * - AoE hits: Smoke devil (Giant chinchompa's Explode is done) * * The Gorajo (bloodrager/deathslinger/...), meerkat and phoenix familiars have no 2009-era special * and so are intentionally left unregistered (the cast button does nothing for them). @@ -22,13 +20,6 @@ class FamiliarSpecialStubs : Script { private val notImplemented = "Your familiar's special move isn't available yet." init { - // Instant self / AoE / charge specials. - FamiliarSpecialMoves.instant( - "smoke_devil_familiar", - "iron_titan_familiar", - "steel_titan_familiar", - ) { stub() } - // Scenery-target specials. (Beaver's Multichop is done - see Beaver.kt.) FamiliarSpecialMoves.obj("hydra_familiar") { _ -> stub() } From 7820b8b315aa0d391611df11c773fd9043bc8496 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Sat, 4 Jul 2026 10:25:48 -0700 Subject: [PATCH 074/136] feat: Regrowth revives a felled tree from its stump The hydra's object-target special fires the stump's pending regrow timer, bringing the tree back immediately; farming-patch trees are player-side variables, so only world stumps qualify. --- .../summoning/summoning_special.anims.toml | 4 ++++ .../summoning/summoning_special.gfx.toml | 4 ++++ .../skill/summoning/FamiliarSpecialStubs.kt | 4 ---- .../content/skill/summoning/familiar/Hydra.kt | 23 +++++++++++++++++++ 4 files changed, 31 insertions(+), 4 deletions(-) diff --git a/data/skill/summoning/summoning_special.anims.toml b/data/skill/summoning/summoning_special.anims.toml index 425a704aa7..81a0f7b69d 100644 --- a/data/skill/summoning/summoning_special.anims.toml +++ b/data/skill/summoning/summoning_special.anims.toml @@ -156,3 +156,7 @@ id = 8183 [steel_of_legends] id = 8183 + +# Hydra's Regrowth animation (played on the familiar as it revives the tree). +[regrowth] +id = 7945 diff --git a/data/skill/summoning/summoning_special.gfx.toml b/data/skill/summoning/summoning_special.gfx.toml index 062909b2a2..f177848db5 100644 --- a/data/skill/summoning/summoning_special.gfx.toml +++ b/data/skill/summoning/summoning_special.gfx.toml @@ -321,3 +321,7 @@ id = 1450 [steel_of_legends] id = 1449 + +# Hydra Regrowth: the burst of life on the familiar. +[regrowth] +id = 1487 diff --git a/game/src/main/kotlin/content/skill/summoning/FamiliarSpecialStubs.kt b/game/src/main/kotlin/content/skill/summoning/FamiliarSpecialStubs.kt index fc02341b28..824813b94c 100644 --- a/game/src/main/kotlin/content/skill/summoning/FamiliarSpecialStubs.kt +++ b/game/src/main/kotlin/content/skill/summoning/FamiliarSpecialStubs.kt @@ -10,7 +10,6 @@ import world.gregs.voidps.engine.entity.character.player.Player * no scroll or special-move points are spent. Grouped by what each still needs: * * - Item interactions (cook/incubate/smelt): Bunyip, Spirit cobra, Pyre lord - * - Skill-system scenery: Hydra (Regrowth) (Beaver's Multichop is done) * * The Gorajo (bloodrager/deathslinger/...), meerkat and phoenix familiars have no 2009-era special * and so are intentionally left unregistered (the cast button does nothing for them). @@ -20,9 +19,6 @@ class FamiliarSpecialStubs : Script { private val notImplemented = "Your familiar's special move isn't available yet." init { - // Scenery-target specials. (Beaver's Multichop is done - see Beaver.kt.) - FamiliarSpecialMoves.obj("hydra_familiar") { _ -> stub() } - // Item-target specials (use an item on the familiar). itemOnNPCApproach("*", "bunyip_familiar") { handleItemStub() } itemOnNPCApproach("*", "spirit_cobra_familiar") { handleItemStub() } diff --git a/game/src/main/kotlin/content/skill/summoning/familiar/Hydra.kt b/game/src/main/kotlin/content/skill/summoning/familiar/Hydra.kt index f19a54cec2..7386ef5574 100644 --- a/game/src/main/kotlin/content/skill/summoning/familiar/Hydra.kt +++ b/game/src/main/kotlin/content/skill/summoning/familiar/Hydra.kt @@ -4,11 +4,34 @@ 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 -> { From c859e845906fd5e152a86779f2c56dbc47bfef8d Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Sat, 4 Jul 2026 10:29:17 -0700 Subject: [PATCH 075/136] feat: Swallow Whole, Ophidian Incubation and Immense Heat item-target specials The bunyip gulps a raw fish the owner could cook, healing the cooked value with no eat delay; the spirit cobra transmutes god-bird eggs into their cockatrice-family counterparts; and the pyrelord stands in for a furnace, opening the jewellery-crafting mould interface from a gold bar. With the last stubs implemented, FamiliarSpecialStubs is gone. --- .../summoning/summoning_special.anims.toml | 12 ++++++ .../summoning/summoning_special.gfx.toml | 12 ++++++ .../skill/summoning/FamiliarSpecialStubs.kt | 39 ----------------- .../skill/summoning/familiar/Bunyip.kt | 43 +++++++++++++++++++ .../skill/summoning/familiar/Pyrelord.kt | 17 ++++++++ .../skill/summoning/familiar/SpiritCobra.kt | 37 ++++++++++++++++ 6 files changed, 121 insertions(+), 39 deletions(-) delete mode 100644 game/src/main/kotlin/content/skill/summoning/FamiliarSpecialStubs.kt diff --git a/data/skill/summoning/summoning_special.anims.toml b/data/skill/summoning/summoning_special.anims.toml index 81a0f7b69d..9158335b82 100644 --- a/data/skill/summoning/summoning_special.anims.toml +++ b/data/skill/summoning/summoning_special.anims.toml @@ -160,3 +160,15 @@ id = 8183 # Hydra's Regrowth animation (played on the familiar as it revives the tree). [regrowth] id = 7945 + +# Bunyip's Swallow Whole animation (played on the familiar as it gulps the fish). +[swallow_whole] +id = 7747 + +# Spirit cobra's Ophidian Incubation animation (played on the familiar). +[ophidian_incubation] +id = 8159 + +# Pyrelord's Immense Heat animation (played on the owner as the pyrelord heats the gold). +[immense_heat] +id = 8081 diff --git a/data/skill/summoning/summoning_special.gfx.toml b/data/skill/summoning/summoning_special.gfx.toml index f177848db5..89a3cf5249 100644 --- a/data/skill/summoning/summoning_special.gfx.toml +++ b/data/skill/summoning/summoning_special.gfx.toml @@ -325,3 +325,15 @@ id = 1449 # Hydra Regrowth: the burst of life on the familiar. [regrowth] id = 1487 + +# Bunyip Swallow Whole: the gulp graphic on the familiar. +[swallow_whole] +id = 1481 + +# Spirit cobra Ophidian Incubation: the transmuting gaze on the familiar. +[ophidian_incubation] +id = 1388 + +# Pyrelord Immense Heat: the flare on the owner as the gold melts. +[immense_heat] +id = 1463 diff --git a/game/src/main/kotlin/content/skill/summoning/FamiliarSpecialStubs.kt b/game/src/main/kotlin/content/skill/summoning/FamiliarSpecialStubs.kt deleted file mode 100644 index 824813b94c..0000000000 --- a/game/src/main/kotlin/content/skill/summoning/FamiliarSpecialStubs.kt +++ /dev/null @@ -1,39 +0,0 @@ -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 - -/** - * Special moves not yet ported. They are registered under the interaction kind the live move uses - * so clicking the cast button gives feedback, but each returns false from [castFamiliarSpecial] so - * no scroll or special-move points are spent. Grouped by what each still needs: - * - * - Item interactions (cook/incubate/smelt): Bunyip, Spirit cobra, Pyre lord - * - * The Gorajo (bloodrager/deathslinger/...), meerkat and phoenix familiars have no 2009-era special - * and so are intentionally left unregistered (the cast button does nothing for them). - */ -class FamiliarSpecialStubs : Script { - - private val notImplemented = "Your familiar's special move isn't available yet." - - init { - // Item-target specials (use an item on the familiar). - itemOnNPCApproach("*", "bunyip_familiar") { handleItemStub() } - itemOnNPCApproach("*", "spirit_cobra_familiar") { handleItemStub() } - itemOnNPCApproach("*", "pyrelord_familiar") { handleItemStub() } - } - - private fun Player.stub(): Boolean { - message(notImplemented) - return false - } - - private fun Player.handleItemStub() { - if (follower == null) { - return - } - castFamiliarSpecial { stub() } - } -} diff --git a/game/src/main/kotlin/content/skill/summoning/familiar/Bunyip.kt b/game/src/main/kotlin/content/skill/summoning/familiar/Bunyip.kt index d638655a61..b679292ad0 100644 --- a/game/src/main/kotlin/content/skill/summoning/familiar/Bunyip.kt +++ b/game/src/main/kotlin/content/skill/summoning/familiar/Bunyip.kt @@ -4,12 +4,55 @@ 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.castFamiliarSpecial +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.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.inv.inventory +import world.gregs.voidps.engine.inv.remove import world.gregs.voidps.type.random class Bunyip : Script { init { + // 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. Item-target special, so it runs through the + // scroll + points gate and charges nothing when the fish is refused. + itemOnNPCApproach("raw_*", "bunyip_familiar") { (npc, item) -> + if (npc != follower) { + return@itemOnNPCApproach + } + castFamiliarSpecial { + val row = Rows.getOrNull("cooking.${item.id}") + if (row == null) { + message("Your bunyip only swallows raw fish.") + return@castFamiliarSpecial 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@castFamiliarSpecial false + } + if (!has(Skill.Cooking, row.int("level"), message = true)) { + return@castFamiliarSpecial false + } + if (!inventory.remove(item.id)) { + return@castFamiliarSpecial false + } + val bunyip = follower ?: return@castFamiliarSpecial 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) + true + } + } + npcOperate("Interact", "bunyip_familiar") { val fish = listOf( "raw_crayfish", "raw_shrimps", "raw_anchovies", "raw_sardine", "raw_giant_carp", diff --git a/game/src/main/kotlin/content/skill/summoning/familiar/Pyrelord.kt b/game/src/main/kotlin/content/skill/summoning/familiar/Pyrelord.kt index 8790e1c897..06ea829429 100644 --- a/game/src/main/kotlin/content/skill/summoning/familiar/Pyrelord.kt +++ b/game/src/main/kotlin/content/skill/summoning/familiar/Pyrelord.kt @@ -4,9 +4,12 @@ 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.castFamiliarSpecial import content.skill.summoning.follower import world.gregs.voidps.engine.Script import world.gregs.voidps.engine.client.message +import world.gregs.voidps.engine.client.ui.open +import world.gregs.voidps.engine.entity.World import world.gregs.voidps.engine.data.definition.Rows import world.gregs.voidps.engine.entity.character.player.chat.ChatType import world.gregs.voidps.engine.entity.character.player.skill.Skill @@ -60,6 +63,20 @@ class Pyrelord : Script { } } + // Immense Heat - the pyrelord's flames stand in for a furnace, letting the owner craft + // gold jewellery on the spot. Item-target special through the scroll + points gate. + itemOnNPCApproach("gold_bar", "pyrelord_familiar") { (npc) -> + if (npc != follower) { + return@itemOnNPCApproach + } + castFamiliarSpecial { + anim("immense_heat") + gfx("immense_heat") + open("make_mould${if (World.members) "_slayer" else ""}") + true + } + } + // The pyrelord acts as a portable fire source - it burns logs without a tinderbox. itemOnNPCOperate("*logs*", "pyrelord_familiar") { (target, item) -> if (target != follower) { diff --git a/game/src/main/kotlin/content/skill/summoning/familiar/SpiritCobra.kt b/game/src/main/kotlin/content/skill/summoning/familiar/SpiritCobra.kt index c58bb1508f..b09b92fcef 100644 --- a/game/src/main/kotlin/content/skill/summoning/familiar/SpiritCobra.kt +++ b/game/src/main/kotlin/content/skill/summoning/familiar/SpiritCobra.kt @@ -4,13 +4,50 @@ 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.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.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 { + // Ophidian Incubation - the spirit cobra transmutes an egg into the cockatrice-family egg of + // the matching god bird. Item-target special through the scroll + points gate. + for ((egg, product) in INCUBATION_EGGS) { + itemOnNPCApproach(egg, "spirit_cobra_familiar") { (npc, item) -> + if (npc != follower) { + return@itemOnNPCApproach + } + castFamiliarSpecial { + if (!inventory.replace(item.id, product)) { + return@castFamiliarSpecial false + } + val cobra = follower ?: return@castFamiliarSpecial false + cobra.anim("ophidian_incubation") + cobra.gfx("ophidian_incubation") + message("Your spirit cobra incubates the egg with its gaze.", ChatType.Filter) + true + } + } + } + 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!") From 9ae585d7fd31ab78ea0eba5339aba6232d4ecc21 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Sat, 4 Jul 2026 10:35:30 -0700 Subject: [PATCH 076/136] feat: familiar passives and item interactions - bunyip heal, forage xp, toad cannonball, ent transmute, regent firelighting, unicorn Cure fix The bunyip passively heals its owner 20 life points every 15s (with the water splash graphic); the granite lobster and ibis grant a tenth of the catch's Fishing xp on their foraged fish; Toad Bark now requires loading the barker toad with a cannonball and fires it for up to 300; the giant ent transmutes pure essence into earth/nature runes; the forge regent burns logs like the pyrelord (both now with the +10 xp helper bonus); and the unicorn stallion's Cure option actually cures - it checked a nonexistent 'poisoned' variable before - for 2 special-move points. --- data/skill/summoning/summoning.gfx.toml | 4 ++ .../summoning/summoning_special.anims.toml | 7 +++ .../summoning/summoning_special.gfx.toml | 11 ++++ .../skill/summoning/FamiliarCombatSpecials.kt | 13 +++- .../content/skill/summoning/FamiliarHeal.kt | 10 +++- .../kotlin/content/skill/summoning/Forager.kt | 12 ++++ .../skill/summoning/familiar/BarkerToad.kt | 24 ++++++++ .../skill/summoning/familiar/GiantEnt.kt | 17 ++++++ .../skill/summoning/familiar/Pyrelord.kt | 59 ++++++++++--------- .../summoning/familiar/UnicornStallion.kt | 12 ++-- 10 files changed, 135 insertions(+), 34 deletions(-) diff --git a/data/skill/summoning/summoning.gfx.toml b/data/skill/summoning/summoning.gfx.toml index 71dfa97fef..13b568a354 100644 --- a/data/skill/summoning/summoning.gfx.toml +++ b/data/skill/summoning/summoning.gfx.toml @@ -21,3 +21,7 @@ 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_special.anims.toml b/data/skill/summoning/summoning_special.anims.toml index 9158335b82..5763b4bea0 100644 --- a/data/skill/summoning/summoning_special.anims.toml +++ b/data/skill/summoning/summoning_special.anims.toml @@ -172,3 +172,10 @@ id = 8159 # Pyrelord's Immense Heat animation (played on the owner as the pyrelord heats the gold). [immense_heat] id = 8081 + +# Barker toad Toad Bark: the firing animation, and the load animation as a cannonball goes in. +[toad_bark] +id = 7703 + +[toad_bark_load] +id = 7704 diff --git a/data/skill/summoning/summoning_special.gfx.toml b/data/skill/summoning/summoning_special.gfx.toml index 89a3cf5249..731b2d495e 100644 --- a/data/skill/summoning/summoning_special.gfx.toml +++ b/data/skill/summoning/summoning_special.gfx.toml @@ -337,3 +337,14 @@ id = 1388 # Pyrelord Immense Heat: the flare on the owner as the gold melts. [immense_heat] id = 1463 + +# Barker toad Toad Bark: the load graphic and the fired cannonball. +[toad_bark_load] +id = 1400 + +[toad_bark_proj] +id = 1402 +delay = 51 +time_offset = 46 +multiplier = 1 +curve = 0 diff --git a/game/src/main/kotlin/content/skill/summoning/FamiliarCombatSpecials.kt b/game/src/main/kotlin/content/skill/summoning/FamiliarCombatSpecials.kt index d5a890b5a2..a72b0aa1ca 100644 --- a/game/src/main/kotlin/content/skill/summoning/FamiliarCombatSpecials.kt +++ b/game/src/main/kotlin/content/skill/summoning/FamiliarCombatSpecials.kt @@ -79,8 +79,19 @@ class FamiliarCombatSpecials : Script { 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 -> - familiarSpecialHit(target, maxHit = 80, sourceGfx = "toad_bark", targetGfx = "toad_bark_hit") + 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") diff --git a/game/src/main/kotlin/content/skill/summoning/FamiliarHeal.kt b/game/src/main/kotlin/content/skill/summoning/FamiliarHeal.kt index 147aa92b5d..48145dc9ab 100644 --- a/game/src/main/kotlin/content/skill/summoning/FamiliarHeal.kt +++ b/game/src/main/kotlin/content/skill/summoning/FamiliarHeal.kt @@ -13,6 +13,12 @@ import java.util.concurrent.TimeUnit */ 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", ) /** @@ -29,7 +35,9 @@ class FamiliarHeal : Script { timerTick("familiar_heal") { val amount = FAMILIAR_HEAL_LIFEPOINTS[follower?.id] ?: return@timerTick Timer.CANCEL - levels.restore(Skill.Constitution, amount) + 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/Forager.kt b/game/src/main/kotlin/content/skill/summoning/Forager.kt index 698736aa05..8240c4911a 100644 --- a/game/src/main/kotlin/content/skill/summoning/Forager.kt +++ b/game/src/main/kotlin/content/skill/summoning/Forager.kt @@ -3,6 +3,8 @@ 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 @@ -10,6 +12,15 @@ 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), +) + /** * 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 @@ -64,6 +75,7 @@ class Forager(private val dropTables: DropTables) : Script { continue } beastOfBurden.add(item.id, item.amount) + FORAGE_FISHING_XP[follower?.id]?.get(item.id)?.let { exp(Skill.Fishing, it) } produced = true } if (produced) { diff --git a/game/src/main/kotlin/content/skill/summoning/familiar/BarkerToad.kt b/game/src/main/kotlin/content/skill/summoning/familiar/BarkerToad.kt index 29beb7035b..4e7a250fba 100644 --- a/game/src/main/kotlin/content/skill/summoning/familiar/BarkerToad.kt +++ b/game/src/main/kotlin/content/skill/summoning/familiar/BarkerToad.kt @@ -4,12 +4,36 @@ 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?)") diff --git a/game/src/main/kotlin/content/skill/summoning/familiar/GiantEnt.kt b/game/src/main/kotlin/content/skill/summoning/familiar/GiantEnt.kt index 5c0b74f43c..24996547d4 100644 --- a/game/src/main/kotlin/content/skill/summoning/familiar/GiantEnt.kt +++ b/game/src/main/kotlin/content/skill/summoning/familiar/GiantEnt.kt @@ -5,11 +5,28 @@ 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.inventory +import world.gregs.voidps.engine.inv.replace 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" + if (inventory.replace("pure_essence", 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) { diff --git a/game/src/main/kotlin/content/skill/summoning/familiar/Pyrelord.kt b/game/src/main/kotlin/content/skill/summoning/familiar/Pyrelord.kt index 06ea829429..e4bf0eedc3 100644 --- a/game/src/main/kotlin/content/skill/summoning/familiar/Pyrelord.kt +++ b/game/src/main/kotlin/content/skill/summoning/familiar/Pyrelord.kt @@ -77,35 +77,38 @@ class Pyrelord : Script { } } - // The pyrelord acts as a portable fire source - it burns logs without a tinderbox. - itemOnNPCOperate("*logs*", "pyrelord_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 pyrelord only burns logs.") - return@itemOnNPCOperate - } - val level = row.int("level") - if (!has(Skill.Firemaking, level, true)) { - return@itemOnNPCOperate - } - if (GameObjects.getLayer(tile, ObjectLayer.GROUND) != null) { - message("You can't light a fire here.") - return@itemOnNPCOperate - } - if (!inventory.remove(item.id)) { - return@itemOnNPCOperate + // The pyrelord and forge regent act as portable fire sources - they burn logs without a + // tinderbox, granting the log's xp plus a small bonus for the demon's 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(tile, ObjectLayer.GROUND) != null) { + message("You can't light a fire here.") + return@itemOnNPCOperate + } + if (!inventory.remove(item.id)) { + return@itemOnNPCOperate + } + anim("light_fire") + exp(Skill.Firemaking, row.int("xp") / 10.0 + 10) + val colour = row.string("colour") + val life = row.int("life") + GameObjects.add("fire_$colour", tile, shape = ObjectShape.CENTRE_PIECE_STRAIGHT, rotation = 0, ticks = life) + FloorItems.add(tile, "ashes", revealTicks = life, disappearTicks = 60, owner = "") + message("The ${target.def.name.lowercase()} breathes fire and the logs begin to burn.", ChatType.Filter) } - anim("light_fire") - exp(Skill.Firemaking, row.int("xp") / 10.0) - val colour = row.string("colour") - val life = row.int("life") - GameObjects.add("fire_$colour", tile, shape = ObjectShape.CENTRE_PIECE_STRAIGHT, rotation = 0, ticks = life) - FloorItems.add(tile, "ashes", revealTicks = life, disappearTicks = 60, owner = "") - message("The pyrelord breathes fire and the logs begin to burn.", ChatType.Filter) } } } diff --git a/game/src/main/kotlin/content/skill/summoning/familiar/UnicornStallion.kt b/game/src/main/kotlin/content/skill/summoning/familiar/UnicornStallion.kt index 41043509cb..22779aac63 100644 --- a/game/src/main/kotlin/content/skill/summoning/familiar/UnicornStallion.kt +++ b/game/src/main/kotlin/content/skill/summoning/familiar/UnicornStallion.kt @@ -1,5 +1,7 @@ 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 @@ -12,6 +14,9 @@ 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) -> @@ -58,15 +63,14 @@ class UnicornStallion : Script { message("This isn't your familiar.") return@npcOperate } - if (!this["poisoned", false]) { + if (!poisoned) { message("You're not suffering from poison!") return@npcOperate } - val cost = follower?.def["summoning_special_cost", 8] ?: 8 - useFamiliarSpecial(cost) { + useFamiliarSpecial(CURE_COST) { follower?.anim("unicorn_stallion_cure") follower?.gfx("unicorn_stallion_cure") - clear("poisoned") + curePoison() } } } From 93e20fdaf3a18924809356bfb8a0922e31c7ec4b Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Sat, 4 Jul 2026 10:52:48 -0700 Subject: [PATCH 077/136] test: cover the combat, boost, item and passive familiar specials Two new suites invoke every newly-ported special and the rescaled ones: projectile hits, stat drains, stuns/binds (size-gated, so a 1x1 rat), the titan charge flurries, Dust Cloud, Toad Bark's cannonball cycle, the item-target specials through the real item-on-npc interaction, the Regrowth stump revival, and the passive bunyip heal, unicorn Cure, ent transmutes and forge-regent firelighting. Item-target specials moved from itemOnNPCApproach to itemOnNPCOperate: when the player is adjacent the interaction resolves the operate map, where the beast-of-burden store's *:familiar wildcard would swallow the click; operate's exact item:npc key wins instead. The titan flurry rolls its own flat max per extra hit (230 iron / 244 steel, from darkan) since NPC damage lives in combat defs, not npc-def max_hit params. --- .../skill/summoning/FamiliarCombatSpecials.kt | 8 +- .../skill/summoning/FamiliarSpecialMoves.kt | 3 +- .../skill/summoning/familiar/Bunyip.kt | 4 +- .../skill/summoning/familiar/Pyrelord.kt | 4 +- .../skill/summoning/familiar/SpiritCobra.kt | 4 +- .../FamiliarCombatSpecialEffectTest.kt | 350 ++++++++++++++++++ .../FamiliarUtilitySpecialEffectTest.kt | 283 ++++++++++++++ 7 files changed, 646 insertions(+), 10 deletions(-) create mode 100644 game/src/test/kotlin/content/skill/summoning/FamiliarCombatSpecialEffectTest.kt create mode 100644 game/src/test/kotlin/content/skill/summoning/FamiliarUtilitySpecialEffectTest.kt diff --git a/game/src/main/kotlin/content/skill/summoning/FamiliarCombatSpecials.kt b/game/src/main/kotlin/content/skill/summoning/FamiliarCombatSpecials.kt index a72b0aa1ca..ffab6a60cb 100644 --- a/game/src/main/kotlin/content/skill/summoning/FamiliarCombatSpecials.kt +++ b/game/src/main/kotlin/content/skill/summoning/FamiliarCombatSpecials.kt @@ -320,8 +320,10 @@ class FamiliarCombatSpecials : Script { // the target on a real cast so it can't act for a few ticks (as in the live game). // Iron Within / Steel of Legends - charge the titan so its next attack (melee-only for the // iron titan) lands as a flurry of 3 / 4 hits on its target. The charge is set on cast and - // spent by the npcCombatAttack handler on the familiar's next swing. - for ((familiar, extraHits) in mapOf("iron_titan_familiar" to 2, "steel_titan_familiar" to 3)) { + // spent by the npcCombatAttack handler on the familiar's next swing; each extra hit rolls + // up to the titan's own max (2 extra up to 230 for iron, 3 up to 244 for steel). + for ((familiar, flurry) in mapOf("iron_titan_familiar" to (2 to 230), "steel_titan_familiar" to (3 to 244))) { + val (extraHits, flurryMax) = flurry val charge = "familiar_titan_charged" FamiliarSpecialMoves.instant(familiar) { if (this[charge, false]) { @@ -345,7 +347,7 @@ class FamiliarCombatSpecials : Script { // Clear before the extra hits - each one re-fires this handler. owner.clear(charge) repeat(extraHits) { swing -> - hit(attack.target, offensiveType = attack.type, delay = attack.delay + (swing + 1) * 30) + hit(attack.target, offensiveType = attack.type, damage = random.nextInt(flurryMax + 1), delay = attack.delay + (swing + 1) * 30) } } } diff --git a/game/src/main/kotlin/content/skill/summoning/FamiliarSpecialMoves.kt b/game/src/main/kotlin/content/skill/summoning/FamiliarSpecialMoves.kt index 80f35a34ca..4090592f76 100644 --- a/game/src/main/kotlin/content/skill/summoning/FamiliarSpecialMoves.kt +++ b/game/src/main/kotlin/content/skill/summoning/FamiliarSpecialMoves.kt @@ -20,7 +20,8 @@ import world.gregs.voidps.engine.entity.obj.GameObject * - [objectTarget] : specials where the player then clicks scenery (chop a tree, fill a bin, ...) * * Item-target specials (use a fish/egg/bar on the familiar) are wired separately with - * `itemOnNPCApproach` in their own scripts since they pick an inventory item, not a world target. + * `itemOnNPCOperate` in their own scripts since they pick an inventory item, not a world target + * (operate, not approach, so their exact item:npc key beats the beast-of-burden store's wildcard). * * Each block returns whether the move actually happened so [castFamiliarSpecial] only spends a * scroll + points on a real cast. diff --git a/game/src/main/kotlin/content/skill/summoning/familiar/Bunyip.kt b/game/src/main/kotlin/content/skill/summoning/familiar/Bunyip.kt index b679292ad0..4779ade278 100644 --- a/game/src/main/kotlin/content/skill/summoning/familiar/Bunyip.kt +++ b/game/src/main/kotlin/content/skill/summoning/familiar/Bunyip.kt @@ -22,9 +22,9 @@ class Bunyip : Script { // 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. Item-target special, so it runs through the // scroll + points gate and charges nothing when the fish is refused. - itemOnNPCApproach("raw_*", "bunyip_familiar") { (npc, item) -> + itemOnNPCOperate("raw_*", "bunyip_familiar") { (npc, item) -> if (npc != follower) { - return@itemOnNPCApproach + return@itemOnNPCOperate } castFamiliarSpecial { val row = Rows.getOrNull("cooking.${item.id}") diff --git a/game/src/main/kotlin/content/skill/summoning/familiar/Pyrelord.kt b/game/src/main/kotlin/content/skill/summoning/familiar/Pyrelord.kt index e4bf0eedc3..e78193e83d 100644 --- a/game/src/main/kotlin/content/skill/summoning/familiar/Pyrelord.kt +++ b/game/src/main/kotlin/content/skill/summoning/familiar/Pyrelord.kt @@ -65,9 +65,9 @@ class Pyrelord : Script { // Immense Heat - the pyrelord's flames stand in for a furnace, letting the owner craft // gold jewellery on the spot. Item-target special through the scroll + points gate. - itemOnNPCApproach("gold_bar", "pyrelord_familiar") { (npc) -> + itemOnNPCOperate("gold_bar", "pyrelord_familiar") { (npc) -> if (npc != follower) { - return@itemOnNPCApproach + return@itemOnNPCOperate } castFamiliarSpecial { anim("immense_heat") diff --git a/game/src/main/kotlin/content/skill/summoning/familiar/SpiritCobra.kt b/game/src/main/kotlin/content/skill/summoning/familiar/SpiritCobra.kt index b09b92fcef..296b069967 100644 --- a/game/src/main/kotlin/content/skill/summoning/familiar/SpiritCobra.kt +++ b/game/src/main/kotlin/content/skill/summoning/familiar/SpiritCobra.kt @@ -31,9 +31,9 @@ class SpiritCobra : Script { // Ophidian Incubation - the spirit cobra transmutes an egg into the cockatrice-family egg of // the matching god bird. Item-target special through the scroll + points gate. for ((egg, product) in INCUBATION_EGGS) { - itemOnNPCApproach(egg, "spirit_cobra_familiar") { (npc, item) -> + itemOnNPCOperate(egg, "spirit_cobra_familiar") { (npc, item) -> if (npc != follower) { - return@itemOnNPCApproach + return@itemOnNPCOperate } castFamiliarSpecial { if (!inventory.replace(item.id, product)) { 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..5a6d0c36fc --- /dev/null +++ b/game/src/test/kotlin/content/skill/summoning/FamiliarCombatSpecialEffectTest.kt @@ -0,0 +1,350 @@ +package content.skill.summoning + +import FakeRandom +import WorldTest +import content.entity.combat.hit.hit +import content.entity.effect.frozen +import content.entity.effect.stunned +import content.entity.effect.toxin.poisoned +import content.entity.player.combat.special.specialAttackEnergy +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.skill.Skill +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`() { + 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)) + tick(6) + + assertTrue(before - target.levels.get(Skill.Constitution) >= 170, "the maxed spike hits for 170") + } + + @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 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 burns another player's weapon and shield out of their hands`() { + val player = summon("forge_regent_familiar") + val target = createPlayer(player.tile.addY(3)) + 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) + + 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")) + } + + @Test + fun `Inferno fails, charging nothing, when there is nothing to disarm`() { + val player = summon("forge_regent_familiar") + val target = createPlayer(player.tile.addY(3)) + player.set("in_pvp", true) + target.set("in_pvp", true) + + assertFalse(player.runPlayerSpecial("forge_regent_familiar", target)) + } + + @Test + fun `Dust Cloud 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 other = tankyRat(player, familiar.tile.addY(1)) + val before = target.levels.get(Skill.Constitution) + val otherBefore = other.levels.get(Skill.Constitution) + + assertTrue(player.runSpecial("smoke_devil_familiar")) + tick(8) + + assertTrue(target.levels.get(Skill.Constitution) < before, "the first rat chokes on the dust") + assertTrue(other.levels.get(Skill.Constitution) < otherBefore, "so does the second") + } + + @Test + fun `Iron Within charges the titan and refuses a second charge`() { + val player = summon("iron_titan_familiar") + + assertTrue(player.runSpecial("iron_titan_familiar")) + assertTrue(player.get("familiar_titan_charged", false)) + + assertFalse(player.runSpecial("iron_titan_familiar"), "already charged - no second cast") + } + + @Test + fun `A charged iron titan's next melee swing lands a flurry then clears the charge`() { + maxRolls() + val player = summon("iron_titan_familiar") + val target = tankyRat(player) + player.set("familiar_titan_charged", true) + val before = target.levels.get(Skill.Constitution) + + player.follower!!.hit(target, offensiveType = "melee", damage = 50) + tick(8) + + assertFalse(player.get("familiar_titan_charged", false), "the charge is spent by the swing") + assertTrue(before - target.levels.get(Skill.Constitution) > 50, "the flurry hit beyond the triggering swing") + } + + @Test + fun `A charged steel titan's next attack of any style lands a flurry`() { + maxRolls() + val player = summon("steel_titan_familiar") + val target = tankyRat(player) + player.set("familiar_titan_charged", true) + val before = target.levels.get(Skill.Constitution) + + player.follower!!.hit(target, offensiveType = "range", damage = 50) + tick(8) + + assertFalse(player.get("familiar_titan_charged", false)) + assertTrue(before - target.levels.get(Skill.Constitution) > 50, "the flurry hit beyond the triggering swing") + } + + @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 `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/FamiliarUtilitySpecialEffectTest.kt b/game/src/test/kotlin/content/skill/summoning/FamiliarUtilitySpecialEffectTest.kt new file mode 100644 index 0000000000..0b1ffbe188 --- /dev/null +++ b/game/src/test/kotlin/content/skill/summoning/FamiliarUtilitySpecialEffectTest.kt @@ -0,0 +1,283 @@ +package content.skill.summoning + +import WorldTest +import content.entity.effect.toxin.poison +import content.entity.effect.toxin.poisoned +import content.entity.player.effect.energy.runEnergy +import itemOnNpc +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.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.engine.inv.transact.operation.RemoveItem.remove +import world.gregs.voidps.type.Tile +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 9`() { + val player = summon("war_tortoise_familiar") + val before = player.levels.get(Skill.Defence) + + assertTrue(player.runSpecial("war_tortoise_familiar")) + assertEquals(before + 9, 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)) + assertTrue(player.runEnergy > 0, "the terrorbird tops up run energy") + } + + @Test + fun `Titan's Constitution raises Defence and heals 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 `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 `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 is a visual flourish that still counts as a cast`() { + val player = summon("magpie_familiar") + assertTrue(player.runSpecial("magpie_familiar")) + } + + @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) + + player.itemOnNpc(player.follower!!, 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) + } + + player.itemOnNpc(player.follower!!, 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 `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 `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`() { + val player = summon("giant_ent_familiar") + player.inventory.transaction { add("pure_essence", 1) } + + player.itemOnNpc(player.follower!!, 0) + tick(2) + + assertEquals(0, player.inventory.count("pure_essence")) + assertTrue(player.inventory.count("earth_rune") + player.inventory.count("nature_rune") == 1, "the essence became a rune") + } + + @Test + fun `The forge regent burns logs like the pyrelord, with a helper bonus`() { + val player = summon("forge_regent_familiar") + player.inventory.transaction { add("logs", 1) } + + player.itemOnNpc(player.follower!!, 0) + tick(2) + + assertEquals(0, player.inventory.count("logs"), "the regent burnt the log") + assertEquals(50.0, player.experience.get(Skill.Firemaking), "the log's 40 xp plus the 10 helper bonus") + } +} From b52baf5ff866701b205bc9fb2a30ea8111715e31 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Sat, 4 Jul 2026 11:57:23 -0700 Subject: [PATCH 078/136] feat: cast button explains how to trigger item-target specials Immense Heat, Swallow Whole and Ophidian Incubation are cast by using the item on the familiar, so the familiar-tab/orb cast button had no registration and silently did nothing - it now messages the trigger (e.g. 'use a gold bar on the pyrelord') without spending anything. --- .../kotlin/content/skill/summoning/familiar/Bunyip.kt | 7 +++++++ .../kotlin/content/skill/summoning/familiar/Pyrelord.kt | 7 +++++++ .../content/skill/summoning/familiar/SpiritCobra.kt | 7 +++++++ .../skill/summoning/FamiliarUtilitySpecialEffectTest.kt | 9 +++++++++ 4 files changed, 30 insertions(+) diff --git a/game/src/main/kotlin/content/skill/summoning/familiar/Bunyip.kt b/game/src/main/kotlin/content/skill/summoning/familiar/Bunyip.kt index 4779ade278..b153383f1f 100644 --- a/game/src/main/kotlin/content/skill/summoning/familiar/Bunyip.kt +++ b/game/src/main/kotlin/content/skill/summoning/familiar/Bunyip.kt @@ -4,6 +4,7 @@ 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 @@ -19,6 +20,12 @@ import world.gregs.voidps.type.random class Bunyip : Script { init { + // The cast button can't pick an inventory item, so it just points at the real trigger. + FamiliarSpecialMoves.instant("bunyip_familiar") { + message("To cast Swallow Whole, use a raw fish on the bunyip.") + 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. Item-target special, so it runs through the // scroll + points gate and charges nothing when the fish is refused. diff --git a/game/src/main/kotlin/content/skill/summoning/familiar/Pyrelord.kt b/game/src/main/kotlin/content/skill/summoning/familiar/Pyrelord.kt index e78193e83d..947161ea46 100644 --- a/game/src/main/kotlin/content/skill/summoning/familiar/Pyrelord.kt +++ b/game/src/main/kotlin/content/skill/summoning/familiar/Pyrelord.kt @@ -4,6 +4,7 @@ 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 @@ -63,6 +64,12 @@ class Pyrelord : Script { } } + // The cast button can't pick an inventory item, so it just points at the real trigger. + FamiliarSpecialMoves.instant("pyrelord_familiar") { + message("To cast Immense Heat, use 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. Item-target special through the scroll + points gate. itemOnNPCOperate("gold_bar", "pyrelord_familiar") { (npc) -> diff --git a/game/src/main/kotlin/content/skill/summoning/familiar/SpiritCobra.kt b/game/src/main/kotlin/content/skill/summoning/familiar/SpiritCobra.kt index 296b069967..8cc2fc3bcb 100644 --- a/game/src/main/kotlin/content/skill/summoning/familiar/SpiritCobra.kt +++ b/game/src/main/kotlin/content/skill/summoning/familiar/SpiritCobra.kt @@ -4,6 +4,7 @@ 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 @@ -28,6 +29,12 @@ private val INCUBATION_EGGS = mapOf( class SpiritCobra : Script { init { + // The cast button can't pick an inventory item, so it just points at the real trigger. + FamiliarSpecialMoves.instant("spirit_cobra_familiar") { + message("To cast Ophidian Incubation, use 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. Item-target special through the scroll + points gate. for ((egg, product) in INCUBATION_EGGS) { diff --git a/game/src/test/kotlin/content/skill/summoning/FamiliarUtilitySpecialEffectTest.kt b/game/src/test/kotlin/content/skill/summoning/FamiliarUtilitySpecialEffectTest.kt index 0b1ffbe188..e521f3df9a 100644 --- a/game/src/test/kotlin/content/skill/summoning/FamiliarUtilitySpecialEffectTest.kt +++ b/game/src/test/kotlin/content/skill/summoning/FamiliarUtilitySpecialEffectTest.kt @@ -197,6 +197,15 @@ class FamiliarUtilitySpecialEffectTest : WorldTest() { } } + @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") From 792476dd56365a1b5e1eef2c4c8912feacdc62d5 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Sat, 4 Jul 2026 12:06:55 -0700 Subject: [PATCH 079/136] feat: item-target specials cast from the button onto an inventory item A new FamiliarSpecialMoves.item map dispatches the familiar-tab/orb cast component used on an inventory item (spell-on-item style), so Immense Heat, Swallow Whole and Ophidian Incubation now work from the Cast option as well as by using the item on the familiar - both surfaces share one block per special. Winter Storage moves off item-on-npc entirely: the beast-of-burden store always wins that click when adjacent, so banking was unreachable. It now lives on Cast-on-item alone (refusing its own scroll), while using an item directly on the yak keeps storing it in the pack. --- .../skill/summoning/FamiliarSpecialMoves.kt | 26 +++++++- .../summoning/FamiliarUtilitySpecials.kt | 36 ++++++---- .../skill/summoning/familiar/Bunyip.kt | 65 ++++++++++--------- .../skill/summoning/familiar/Pyrelord.kt | 31 ++++++--- .../skill/summoning/familiar/SpiritCobra.kt | 40 ++++++++---- .../FamiliarUtilitySpecialEffectTest.kt | 45 +++++++++++++ 6 files changed, 173 insertions(+), 70 deletions(-) diff --git a/game/src/main/kotlin/content/skill/summoning/FamiliarSpecialMoves.kt b/game/src/main/kotlin/content/skill/summoning/FamiliarSpecialMoves.kt index 4090592f76..487ce3c057 100644 --- a/game/src/main/kotlin/content/skill/summoning/FamiliarSpecialMoves.kt +++ b/game/src/main/kotlin/content/skill/summoning/FamiliarSpecialMoves.kt @@ -3,6 +3,7 @@ package content.skill.summoning import world.gregs.voidps.engine.Script 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.obj.GameObject /** @@ -18,10 +19,11 @@ import world.gregs.voidps.engine.entity.obj.GameObject * - [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 * - * Item-target specials (use a fish/egg/bar on the familiar) are wired separately with - * `itemOnNPCOperate` in their own scripts since they pick an inventory item, not a world target - * (operate, not approach, so their exact item:npc key beats the beast-of-burden store's wildcard). + * 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. @@ -31,6 +33,7 @@ object FamiliarSpecialMoves { val npcTarget = HashMap Boolean>() val playerTarget = HashMap Boolean>() val objectTarget = HashMap Boolean>() + val itemTarget = HashMap Boolean>() fun instant(vararg familiars: String, block: Player.() -> Boolean) { for (familiar in familiars) instant[familiar] = block @@ -47,6 +50,10 @@ object FamiliarSpecialMoves { 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 + } } /** @@ -87,6 +94,13 @@ class FamiliarSpecialMovesDispatch : Script { 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) } + } + // Minimap summoning orb "Cast " option - one `cast_` component per move. interfaceOption("*", "summoning_orb:cast_*") { val id = follower?.id ?: return@interfaceOption @@ -114,5 +128,11 @@ class FamiliarSpecialMovesDispatch : Script { 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) } + } } } diff --git a/game/src/main/kotlin/content/skill/summoning/FamiliarUtilitySpecials.kt b/game/src/main/kotlin/content/skill/summoning/FamiliarUtilitySpecials.kt index 6dc9b5af73..7942173bc2 100644 --- a/game/src/main/kotlin/content/skill/summoning/FamiliarUtilitySpecials.kt +++ b/game/src/main/kotlin/content/skill/summoning/FamiliarUtilitySpecials.kt @@ -34,7 +34,8 @@ private const val HERBCALL_DROP_DELAY = 5 /** * 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 is wired with `itemOnNPCApproach`. + * 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 { @@ -208,19 +209,26 @@ class FamiliarUtilitySpecials : Script { true } - // Winter Storage - bank the item the player uses on the pack yak. - itemOnNPCApproach("*", "pack_yak_familiar") { (npc, item) -> - if (npc != follower) { - return@itemOnNPCApproach - } - castFamiliarSpecial { - if (!inventory.contains(item.id)) { - return@castFamiliarSpecial false - } - inventory.remove(item.id, 1) - bank.add(item.id, 1) - 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?.say("Baroo!") + message("Your pack yak sends the ${item.def.name.lowercase()} to your bank.") + true } } diff --git a/game/src/main/kotlin/content/skill/summoning/familiar/Bunyip.kt b/game/src/main/kotlin/content/skill/summoning/familiar/Bunyip.kt index b153383f1f..1dd02d243c 100644 --- a/game/src/main/kotlin/content/skill/summoning/familiar/Bunyip.kt +++ b/game/src/main/kotlin/content/skill/summoning/familiar/Bunyip.kt @@ -11,7 +11,9 @@ 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.item.Item 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.inv.inventory @@ -20,44 +22,22 @@ import world.gregs.voidps.type.random class Bunyip : Script { init { - // The cast button can't pick an inventory item, so it just points at the real trigger. + // A plain click on the cast button has no item to work on - point at the real triggers. FamiliarSpecialMoves.instant("bunyip_familiar") { - message("To cast Swallow Whole, use a raw fish on the bunyip.") + message("To cast Swallow Whole, use the Cast option or a raw fish on the bunyip.") 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. Item-target special, so it runs through the - // scroll + points gate and charges nothing when the fish is refused. + // the cooked fish's worth with no eat delay. Cast on a raw fish, or use the fish on the + // familiar - both run through the scroll + points gate, charging nothing on a refusal. + FamiliarSpecialMoves.item("bunyip_familiar") { item -> swallowWhole(item) } + itemOnNPCOperate("raw_*", "bunyip_familiar") { (npc, item) -> if (npc != follower) { return@itemOnNPCOperate } - castFamiliarSpecial { - val row = Rows.getOrNull("cooking.${item.id}") - if (row == null) { - message("Your bunyip only swallows raw fish.") - return@castFamiliarSpecial 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@castFamiliarSpecial false - } - if (!has(Skill.Cooking, row.int("level"), message = true)) { - return@castFamiliarSpecial false - } - if (!inventory.remove(item.id)) { - return@castFamiliarSpecial false - } - val bunyip = follower ?: return@castFamiliarSpecial 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) - true - } + castFamiliarSpecial { swallowWhole(item) } } npcOperate("Interact", "bunyip_familiar") { @@ -105,4 +85,31 @@ class Bunyip : Script { } } } + + /** 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/Pyrelord.kt b/game/src/main/kotlin/content/skill/summoning/familiar/Pyrelord.kt index 947161ea46..de074f5f11 100644 --- a/game/src/main/kotlin/content/skill/summoning/familiar/Pyrelord.kt +++ b/game/src/main/kotlin/content/skill/summoning/familiar/Pyrelord.kt @@ -12,6 +12,7 @@ import world.gregs.voidps.engine.client.message import world.gregs.voidps.engine.client.ui.open import world.gregs.voidps.engine.entity.World import world.gregs.voidps.engine.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.exp.exp @@ -64,24 +65,22 @@ class Pyrelord : Script { } } - // The cast button can't pick an inventory item, so it just points at the real trigger. + // 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 a gold bar on the pyrelord.") + 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. Item-target special through the scroll + points gate. - itemOnNPCOperate("gold_bar", "pyrelord_familiar") { (npc) -> + // 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 { - anim("immense_heat") - gfx("immense_heat") - open("make_mould${if (World.members) "_slayer" else ""}") - true - } + castFamiliarSpecial { immenseHeat(item.id) } } // The pyrelord and forge regent act as portable fire sources - they burn logs without a @@ -118,4 +117,16 @@ class Pyrelord : Script { } } } + + /** 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/SpiritCobra.kt b/game/src/main/kotlin/content/skill/summoning/familiar/SpiritCobra.kt index 8cc2fc3bcb..97c7235285 100644 --- a/game/src/main/kotlin/content/skill/summoning/familiar/SpiritCobra.kt +++ b/game/src/main/kotlin/content/skill/summoning/familiar/SpiritCobra.kt @@ -9,6 +9,7 @@ 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 @@ -29,29 +30,23 @@ private val INCUBATION_EGGS = mapOf( class SpiritCobra : Script { init { - // The cast button can't pick an inventory item, so it just points at the real trigger. + // 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 an egg on the spirit cobra.") + 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. Item-target special through the scroll + points gate. - for ((egg, product) in INCUBATION_EGGS) { + // 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 { - if (!inventory.replace(item.id, product)) { - return@castFamiliarSpecial false - } - val cobra = follower ?: return@castFamiliarSpecial false - cobra.anim("ophidian_incubation") - cobra.gfx("ophidian_incubation") - message("Your spirit cobra incubates the egg with its gaze.", ChatType.Filter) - true - } + castFamiliarSpecial { incubate(item.id) } } } @@ -105,4 +100,21 @@ class SpiritCobra : Script { } } } + + /** 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/test/kotlin/content/skill/summoning/FamiliarUtilitySpecialEffectTest.kt b/game/src/test/kotlin/content/skill/summoning/FamiliarUtilitySpecialEffectTest.kt index e521f3df9a..e99b6a0bd8 100644 --- a/game/src/test/kotlin/content/skill/summoning/FamiliarUtilitySpecialEffectTest.kt +++ b/game/src/test/kotlin/content/skill/summoning/FamiliarUtilitySpecialEffectTest.kt @@ -3,10 +3,12 @@ package content.skill.summoning import WorldTest import content.entity.effect.toxin.poison import content.entity.effect.toxin.poisoned +import content.entity.player.bank.bank import content.entity.player.effect.energy.runEnergy import itemOnNpc import npcOption import org.junit.jupiter.api.Test +import world.gregs.voidps.engine.client.ui.InterfaceApi import world.gregs.voidps.engine.client.variable.hasClock import world.gregs.voidps.engine.data.definition.NPCDefinitions import world.gregs.voidps.engine.entity.character.player.Player @@ -197,6 +199,49 @@ class FamiliarUtilitySpecialEffectTest : WorldTest() { } } + @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) + } + + 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) } + + 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) + } + + 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") From c0d7739c670a6adf41c15f38769c677090ce18f7 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Sat, 4 Jul 2026 12:11:38 -0700 Subject: [PATCH 080/136] fix: pyrelord and forge regent breathe fire beneath themselves when burning logs The familiar now plays its fire-breathing animation (8085) and the fire and ashes land on the familiar's tile, instead of the player miming a tinderbox and lighting the fire underfoot. --- data/skill/summoning/summoning.anims.toml | 5 ++++- .../content/skill/summoning/familiar/Pyrelord.kt | 12 ++++++------ .../summoning/FamiliarUtilitySpecialEffectTest.kt | 2 ++ 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/data/skill/summoning/summoning.anims.toml b/data/skill/summoning/summoning.anims.toml index 5263593013..d5c33e97ac 100644 --- a/data/skill/summoning/summoning.anims.toml +++ b/data/skill/summoning/summoning.anims.toml @@ -46,4 +46,7 @@ id = 8414 id = 8267 [dreadfowl_special] -id = 5387 \ No newline at end of file +id = 5387 +# Fire-breathing familiars (pyrelord, forge regent) lighting logs beneath themselves. +[familiar_light_fire] +id = 8085 diff --git a/game/src/main/kotlin/content/skill/summoning/familiar/Pyrelord.kt b/game/src/main/kotlin/content/skill/summoning/familiar/Pyrelord.kt index de074f5f11..4fef409cf7 100644 --- a/game/src/main/kotlin/content/skill/summoning/familiar/Pyrelord.kt +++ b/game/src/main/kotlin/content/skill/summoning/familiar/Pyrelord.kt @@ -83,8 +83,8 @@ class Pyrelord : Script { castFamiliarSpecial { immenseHeat(item.id) } } - // The pyrelord and forge regent act as portable fire sources - they burn logs without a - // tinderbox, granting the log's xp plus a small bonus for the demon's help. + // 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) { @@ -100,19 +100,19 @@ class Pyrelord : Script { if (!has(Skill.Firemaking, level, true)) { return@itemOnNPCOperate } - if (GameObjects.getLayer(tile, ObjectLayer.GROUND) != null) { + 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 } - anim("light_fire") + target.anim("familiar_light_fire") exp(Skill.Firemaking, row.int("xp") / 10.0 + 10) val colour = row.string("colour") val life = row.int("life") - GameObjects.add("fire_$colour", tile, shape = ObjectShape.CENTRE_PIECE_STRAIGHT, rotation = 0, ticks = life) - FloorItems.add(tile, "ashes", revealTicks = life, disappearTicks = 60, owner = "") + 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) } } diff --git a/game/src/test/kotlin/content/skill/summoning/FamiliarUtilitySpecialEffectTest.kt b/game/src/test/kotlin/content/skill/summoning/FamiliarUtilitySpecialEffectTest.kt index e99b6a0bd8..636cfa85e2 100644 --- a/game/src/test/kotlin/content/skill/summoning/FamiliarUtilitySpecialEffectTest.kt +++ b/game/src/test/kotlin/content/skill/summoning/FamiliarUtilitySpecialEffectTest.kt @@ -327,11 +327,13 @@ class FamiliarUtilitySpecialEffectTest : WorldTest() { fun `The forge regent burns logs like the pyrelord, with a helper bonus`() { val player = summon("forge_regent_familiar") player.inventory.transaction { add("logs", 1) } + val familiarTile = player.follower!!.tile player.itemOnNpc(player.follower!!, 0) tick(2) assertEquals(0, player.inventory.count("logs"), "the regent burnt the log") 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") } } From 5fb5ca1c22641355e0a3135306db2ffb5b2c2d8b Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Sat, 4 Jul 2026 12:35:50 -0700 Subject: [PATCH 081/136] feat: adjacent familiars face their idle owner; pyrelord steps west off its fire An npc following a target that is already within a tile now just faces them instead of shuffling onto the follow tile every step - it only moves again once the target walks out of reach. After lighting logs the pyrelord/forge regent steps one tile west when unblocked, like players do, then stands beside the fire facing its owner (resuming the follow immediately would walk it straight back onto the fire) until the owner moves off. --- .../engine/entity/character/mode/Follow.kt | 5 ++++ .../skill/summoning/familiar/Pyrelord.kt | 30 +++++++++++++++++++ .../FamiliarUtilitySpecialEffectTest.kt | 16 +++++++++- 3 files changed, 50 insertions(+), 1 deletion(-) 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..6bed7ab09d 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 @@ -51,6 +51,11 @@ class Follow( 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/game/src/main/kotlin/content/skill/summoning/familiar/Pyrelord.kt b/game/src/main/kotlin/content/skill/summoning/familiar/Pyrelord.kt index 4fef409cf7..a2e7d07d90 100644 --- a/game/src/main/kotlin/content/skill/summoning/familiar/Pyrelord.kt +++ b/game/src/main/kotlin/content/skill/summoning/familiar/Pyrelord.kt @@ -7,11 +7,17 @@ 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.entity.World import world.gregs.voidps.engine.data.definition.Rows +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.get +import world.gregs.voidps.engine.queue.queue 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 @@ -114,6 +120,30 @@ class Pyrelord : Script { 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) + } + } + } } } } diff --git a/game/src/test/kotlin/content/skill/summoning/FamiliarUtilitySpecialEffectTest.kt b/game/src/test/kotlin/content/skill/summoning/FamiliarUtilitySpecialEffectTest.kt index 636cfa85e2..55b7521107 100644 --- a/game/src/test/kotlin/content/skill/summoning/FamiliarUtilitySpecialEffectTest.kt +++ b/game/src/test/kotlin/content/skill/summoning/FamiliarUtilitySpecialEffectTest.kt @@ -325,7 +325,8 @@ class FamiliarUtilitySpecialEffectTest : WorldTest() { @Test fun `The forge regent burns logs like the pyrelord, with a helper bonus`() { - val player = summon("forge_regent_familiar") + // 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 @@ -335,5 +336,18 @@ class FamiliarUtilitySpecialEffectTest : WorldTest() { assertEquals(0, player.inventory.count("logs"), "the regent burnt the log") 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") + } + + @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") } } From 067849b7ebcd66dcf3c8e11c8601fa6d76a4b992 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Sat, 4 Jul 2026 12:45:31 -0700 Subject: [PATCH 082/136] fix: familiar no longer tucks in behind an owner who steps up beside it The adjacency guard lived only in Follow.recalculate, but a freshly-restored Follow mode calculates a full path to the follow tile on its first tick (Movement.calculate), bypassing the guard - so after the pyrelord's step west, the owner walking up next to it sent it circling behind them. The guard now also runs in Follow.tick before any movement. --- .../gregs/voidps/engine/entity/character/mode/Follow.kt | 7 +++++++ .../skill/summoning/FamiliarUtilitySpecialEffectTest.kt | 8 ++++++++ 2 files changed, 15 insertions(+) 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 6bed7ab09d..b2354d8d84 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 @@ -41,6 +41,13 @@ 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). + if (character is NPC && target.tile.distanceTo(character) <= 1) { + character.steps.clearDestination() + return + } if (!smart) { character.steps.clearDestination() } diff --git a/game/src/test/kotlin/content/skill/summoning/FamiliarUtilitySpecialEffectTest.kt b/game/src/test/kotlin/content/skill/summoning/FamiliarUtilitySpecialEffectTest.kt index 55b7521107..d515706c2c 100644 --- a/game/src/test/kotlin/content/skill/summoning/FamiliarUtilitySpecialEffectTest.kt +++ b/game/src/test/kotlin/content/skill/summoning/FamiliarUtilitySpecialEffectTest.kt @@ -339,6 +339,14 @@ class FamiliarUtilitySpecialEffectTest : WorldTest() { 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 From d87c5be77f4251e1c50c24034e0bc9b20e082fdf Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Sat, 4 Jul 2026 12:52:56 -0700 Subject: [PATCH 083/136] feat: logs land at the familiar's feet before the pyrelord breathes fire Using logs on the pyrelord/forge regent now drops them as a floor item on the familiar's tile (owner-visible, as a tinderbox drop would be), plays the fire-breath, and lights them two ticks later - aborting, fireless, if the logs are gone by then (e.g. picked back up). --- .../content/skill/summoning/familiar/Pyrelord.kt | 7 +++++++ .../summoning/FamiliarUtilitySpecialEffectTest.kt | 10 +++++++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/game/src/main/kotlin/content/skill/summoning/familiar/Pyrelord.kt b/game/src/main/kotlin/content/skill/summoning/familiar/Pyrelord.kt index a2e7d07d90..79cfed58bc 100644 --- a/game/src/main/kotlin/content/skill/summoning/familiar/Pyrelord.kt +++ b/game/src/main/kotlin/content/skill/summoning/familiar/Pyrelord.kt @@ -113,7 +113,14 @@ class Pyrelord : Script { 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") diff --git a/game/src/test/kotlin/content/skill/summoning/FamiliarUtilitySpecialEffectTest.kt b/game/src/test/kotlin/content/skill/summoning/FamiliarUtilitySpecialEffectTest.kt index d515706c2c..736e15106d 100644 --- a/game/src/test/kotlin/content/skill/summoning/FamiliarUtilitySpecialEffectTest.kt +++ b/game/src/test/kotlin/content/skill/summoning/FamiliarUtilitySpecialEffectTest.kt @@ -12,8 +12,10 @@ import world.gregs.voidps.engine.client.ui.InterfaceApi import world.gregs.voidps.engine.client.variable.hasClock import world.gregs.voidps.engine.data.definition.NPCDefinitions import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.entity.item.floor.FloorItems import world.gregs.voidps.engine.entity.character.player.skill.Skill import world.gregs.voidps.engine.entity.obj.GameObjects +import world.gregs.voidps.engine.entity.obj.ObjectLayer 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 @@ -333,7 +335,13 @@ class FamiliarUtilitySpecialEffectTest : WorldTest() { player.itemOnNpc(player.follower!!, 0) tick(2) - assertEquals(0, player.inventory.count("logs"), "the regent burnt the log") + // 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") From 024398b2f205d72ac1fb79c28f24a0b0760f190a Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Sat, 4 Jul 2026 12:59:12 -0700 Subject: [PATCH 084/136] feat: familiars step out from under a player standing on their tile Follow mode now mirrors combat movement's step-out: when the followed target stands on top of the npc (and it isn't allowed_under), it steps to a random free cardinal tile instead of idling underfoot. The adjacent-face guard exempts the overlap case so the step-out can run. --- .../engine/entity/character/mode/Follow.kt | 29 +++++++++++++++++-- .../FamiliarUtilitySpecialEffectTest.kt | 14 +++++++++ 2 files changed, 41 insertions(+), 2 deletions(-) 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 b2354d8d84..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, @@ -43,8 +46,9 @@ class Follow( 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). - if (character is NPC && target.tile.distanceTo(character) <= 1) { + // 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 } @@ -54,6 +58,27 @@ class Follow( 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 diff --git a/game/src/test/kotlin/content/skill/summoning/FamiliarUtilitySpecialEffectTest.kt b/game/src/test/kotlin/content/skill/summoning/FamiliarUtilitySpecialEffectTest.kt index 736e15106d..3f6737ac0a 100644 --- a/game/src/test/kotlin/content/skill/summoning/FamiliarUtilitySpecialEffectTest.kt +++ b/game/src/test/kotlin/content/skill/summoning/FamiliarUtilitySpecialEffectTest.kt @@ -11,7 +11,9 @@ import org.junit.jupiter.api.Test import world.gregs.voidps.engine.client.ui.InterfaceApi 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.distanceTo import world.gregs.voidps.engine.entity.item.floor.FloorItems import world.gregs.voidps.engine.entity.character.player.skill.Skill import world.gregs.voidps.engine.entity.obj.GameObjects @@ -366,4 +368,16 @@ class FamiliarUtilitySpecialEffectTest : WorldTest() { 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") + } } From ae3dfc71e29217fbc3e7ae606ca60d6f5e386fb1 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Sun, 5 Jul 2026 09:28:47 -0700 Subject: [PATCH 085/136] fix: Thieving Fingers actually boosts Thieving by 2 It was implemented as a visual-only flourish on the assumption the passive +3 covered it, but both references boost the stat on cast too - darkan raises Thieving by 2 with the owner sparkle (gfx 1300), on top of the magpie's passive invisible +3. --- data/skill/summoning/summoning_special.gfx.toml | 4 ++++ .../content/skill/summoning/FamiliarBoostSpecials.kt | 8 +++++--- .../skill/summoning/FamiliarUtilitySpecialEffectTest.kt | 5 ++++- 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/data/skill/summoning/summoning_special.gfx.toml b/data/skill/summoning/summoning_special.gfx.toml index 731b2d495e..830b98afbe 100644 --- a/data/skill/summoning/summoning_special.gfx.toml +++ b/data/skill/summoning/summoning_special.gfx.toml @@ -348,3 +348,7 @@ delay = 51 time_offset = 46 multiplier = 1 curve = 0 + +# Thieving Fingers: the sparkle played on the owner as the boost lands. +[thieving_fingers_owner] +id = 1300 diff --git a/game/src/main/kotlin/content/skill/summoning/FamiliarBoostSpecials.kt b/game/src/main/kotlin/content/skill/summoning/FamiliarBoostSpecials.kt index ef71b26439..8beb6ace2a 100644 --- a/game/src/main/kotlin/content/skill/summoning/FamiliarBoostSpecials.kt +++ b/game/src/main/kotlin/content/skill/summoning/FamiliarBoostSpecials.kt @@ -83,10 +83,12 @@ class FamiliarBoostSpecials : Script { } } - // Thieving Fingers - the Thieving boost is a passive (see FamiliarBoosts); the special is - // just the visual flourish, but still costs a scroll + points like the live game. + // 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") {} + 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 diff --git a/game/src/test/kotlin/content/skill/summoning/FamiliarUtilitySpecialEffectTest.kt b/game/src/test/kotlin/content/skill/summoning/FamiliarUtilitySpecialEffectTest.kt index 3f6737ac0a..7a9d5c27d5 100644 --- a/game/src/test/kotlin/content/skill/summoning/FamiliarUtilitySpecialEffectTest.kt +++ b/game/src/test/kotlin/content/skill/summoning/FamiliarUtilitySpecialEffectTest.kt @@ -135,9 +135,12 @@ class FamiliarUtilitySpecialEffectTest : WorldTest() { } @Test - fun `Thieving Fingers is a visual flourish that still counts as a cast`() { + 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 From ee4f99f40354a03516c18ceffa146b0c445fef3d Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Sun, 5 Jul 2026 09:38:08 -0700 Subject: [PATCH 086/136] fix: a second same-tick graphic no longer overwrites the first Character.gfx checked the primary-graphic mask but flagged the secondary, so every graphic landed in the secondary slot and a second gfx() in the same tick clobbered the first - which is why Thieving Fingers' owner sparkle never showed: the cast gate's scroll flourish (1316) played right after it. Checking the secondary mask (the slot actually used) sends a simultaneous second graphic to the free primary slot, so both render. --- .../voidps/engine/entity/character/Character.kt | 4 +++- .../summoning/FamiliarUtilitySpecialEffectTest.kt | 15 +++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) 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/game/src/test/kotlin/content/skill/summoning/FamiliarUtilitySpecialEffectTest.kt b/game/src/test/kotlin/content/skill/summoning/FamiliarUtilitySpecialEffectTest.kt index 7a9d5c27d5..a138a2adc5 100644 --- a/game/src/test/kotlin/content/skill/summoning/FamiliarUtilitySpecialEffectTest.kt +++ b/game/src/test/kotlin/content/skill/summoning/FamiliarUtilitySpecialEffectTest.kt @@ -5,6 +5,7 @@ import content.entity.effect.toxin.poison import content.entity.effect.toxin.poisoned import content.entity.player.bank.bank import content.entity.player.effect.energy.runEnergy +import interfaceOption import itemOnNpc import npcOption import org.junit.jupiter.api.Test @@ -143,6 +144,20 @@ class FamiliarUtilitySpecialEffectTest : WorldTest() { 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") From 57ea960da3747e9352d1f5b7074d72ddfb2e5644 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Sun, 5 Jul 2026 09:45:18 -0700 Subject: [PATCH 087/136] fix: Blood Drain plays its visuals and restores stats by 2 + 20% The bloated leech's special ran with no visuals at all; per darkan it now plays the leech's draw animation (7715) and graphic (1419), sends the blood bolt (1417) to the owner with their burst graphic (1420), and the restore gains darkan's flat +2 on top of the 20% (adjustStat(2, 0.20)) - Levels.restore(skill, 2, 0.2) matches exactly. Cure, all-stat restore and the 25 life-point bite are unchanged. --- data/skill/summoning/summoning_special.anims.toml | 4 ++++ data/skill/summoning/summoning_special.gfx.toml | 14 ++++++++++++++ .../skill/summoning/FamiliarBoostSpecials.kt | 11 +++++------ 3 files changed, 23 insertions(+), 6 deletions(-) diff --git a/data/skill/summoning/summoning_special.anims.toml b/data/skill/summoning/summoning_special.anims.toml index 5763b4bea0..b9bdbbeab3 100644 --- a/data/skill/summoning/summoning_special.anims.toml +++ b/data/skill/summoning/summoning_special.anims.toml @@ -179,3 +179,7 @@ id = 7703 [toad_bark_load] id = 7704 + +# Bloated leech's Blood Drain animation (played on the familiar as it draws blood). +[blood_drain] +id = 7715 diff --git a/data/skill/summoning/summoning_special.gfx.toml b/data/skill/summoning/summoning_special.gfx.toml index 830b98afbe..72cd8e9053 100644 --- a/data/skill/summoning/summoning_special.gfx.toml +++ b/data/skill/summoning/summoning_special.gfx.toml @@ -352,3 +352,17 @@ curve = 0 # Thieving Fingers: the sparkle played on the owner as the boost lands. [thieving_fingers_owner] id = 1300 + +# Blood Drain: the leech's gfx, the blood drawn to the owner, and the burst on the owner. +[blood_drain] +id = 1419 + +[blood_drain_proj] +id = 1417 +delay = 51 +time_offset = 46 +multiplier = 1 +curve = 0 + +[blood_drain_owner] +id = 1420 diff --git a/game/src/main/kotlin/content/skill/summoning/FamiliarBoostSpecials.kt b/game/src/main/kotlin/content/skill/summoning/FamiliarBoostSpecials.kt index 8beb6ace2a..059ab75445 100644 --- a/game/src/main/kotlin/content/skill/summoning/FamiliarBoostSpecials.kt +++ b/game/src/main/kotlin/content/skill/summoning/FamiliarBoostSpecials.kt @@ -69,15 +69,14 @@ class FamiliarBoostSpecials : Script { } } - // Blood Drain - cure poison, restore drained stats by ~20%, then take 25 recoil damage. + // 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 { + 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()) { - val offset = levels.get(skill) - levels.getMax(skill) - if (offset < 0) { - levels.restore(skill, ceil(levels.getMax(skill) * 0.2).toInt()) - } + levels.restore(skill, 2, 0.2) } directHit(25, "damage") } From 443a8aad55d153ec35684bf7508a093aac6352cd Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Sun, 5 Jul 2026 09:51:27 -0700 Subject: [PATCH 088/136] fix: Tireless Run plays the right visuals on the right characters Per darkan: the 1521 graphic belongs on the terrorbird (with its 8229 flap animation, not 1009), the owner gets the 1300 sparkle - previously 1521 played on the player and the familiar showed nothing. The cast now also refuses (charging nothing) at full run energy, and the energy restored is half the boosted Agility level rather than the base. --- data/skill/summoning/summoning_special.anims.toml | 2 +- data/skill/summoning/summoning_special.gfx.toml | 4 ++++ .../content/skill/summoning/FamiliarBoostSpecials.kt | 11 ++++++++--- .../summoning/FamiliarUtilitySpecialEffectTest.kt | 11 ++++++++++- 4 files changed, 23 insertions(+), 5 deletions(-) diff --git a/data/skill/summoning/summoning_special.anims.toml b/data/skill/summoning/summoning_special.anims.toml index b9bdbbeab3..3240c69fac 100644 --- a/data/skill/summoning/summoning_special.anims.toml +++ b/data/skill/summoning/summoning_special.anims.toml @@ -74,7 +74,7 @@ id = 7682 id = 7896 [tireless_run] -id = 1009 +id = 8229 [healing_aura] id = 8267 diff --git a/data/skill/summoning/summoning_special.gfx.toml b/data/skill/summoning/summoning_special.gfx.toml index 72cd8e9053..4add21cd8e 100644 --- a/data/skill/summoning/summoning_special.gfx.toml +++ b/data/skill/summoning/summoning_special.gfx.toml @@ -366,3 +366,7 @@ curve = 0 [blood_drain_owner] id = 1420 + +# Tireless Run: the sparkle played on the owner as the boost lands (the 1521 gfx is the terrorbird's). +[tireless_run_owner] +id = 1300 diff --git a/game/src/main/kotlin/content/skill/summoning/FamiliarBoostSpecials.kt b/game/src/main/kotlin/content/skill/summoning/FamiliarBoostSpecials.kt index 059ab75445..7b5f7e22e2 100644 --- a/game/src/main/kotlin/content/skill/summoning/FamiliarBoostSpecials.kt +++ b/game/src/main/kotlin/content/skill/summoning/FamiliarBoostSpecials.kt @@ -46,11 +46,16 @@ class FamiliarBoostSpecials : Script { } } - // Tireless Run - Agility +2 and restore run energy. + // 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") { - familiarSelfSpecial(anim = "tireless_run", playerGfx = "tireless_run") { + 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) - restoreRunEnergy() + runEnergy = (runEnergy + levels.get(Skill.Agility) * 50).coerceAtMost(MAX_RUN_ENERGY) } } diff --git a/game/src/test/kotlin/content/skill/summoning/FamiliarUtilitySpecialEffectTest.kt b/game/src/test/kotlin/content/skill/summoning/FamiliarUtilitySpecialEffectTest.kt index a138a2adc5..0f0e9f10a0 100644 --- a/game/src/test/kotlin/content/skill/summoning/FamiliarUtilitySpecialEffectTest.kt +++ b/game/src/test/kotlin/content/skill/summoning/FamiliarUtilitySpecialEffectTest.kt @@ -4,6 +4,7 @@ import WorldTest import content.entity.effect.toxin.poison import content.entity.effect.toxin.poisoned import content.entity.player.bank.bank +import content.entity.player.effect.energy.MAX_RUN_ENERGY import content.entity.player.effect.energy.runEnergy import interfaceOption import itemOnNpc @@ -91,7 +92,15 @@ class FamiliarUtilitySpecialEffectTest : WorldTest() { assertTrue(player.runSpecial("spirit_terrorbird_familiar")) assertEquals(agility + 2, player.levels.get(Skill.Agility)) - assertTrue(player.runEnergy > 0, "the terrorbird tops up run energy") + 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 From 7cf7ca7fe60062c7226cd0a73931941c45c769bd Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Sun, 5 Jul 2026 10:13:38 -0700 Subject: [PATCH 089/136] fix: QA pass over every familiar special in the testing tracker - Abyssal Drain saps 10% Magic and 10% Prayer (was flat 1-3 Prayer) - Dissolve/Rending drain 10% of Attack/Strength (were flat 3/1), with their missing familiar and impact graphics (1361/1360, 1372) - Goad gores twice for up to 120 each (was engage-only); Ambush pounces the kyatt onto its target for up to 224 (was a recall-to-owner) - Dust Cloud is a combat special: the picked target plus up to six foes within seven tiles (was a radius-1 AoE around the familiar) - Famine no longer deals damage and rots a random (not first) food - Inferno gains its up-to-85 fiery bolt, castable on npcs too; the player-target disarm stays on top - Deadly Claw uses the talon beast's attack animation with tighter volley timing; Acorn Missile splash caps at nine and shows its projectile and impact per extra target - Iron Within / Steel of Legends are immediate volleys (3x230 melee / 3x220 magic, 4x244 melee/ranged by distance) with darkan's 7954/8190 animations - not charge-next-attack buffs - Boil picks melee up close, ranged or magic at even odds from afar, with the titan's own 1373 graphic; Ebon Thunder drains a tenth of remaining special energy (was flat 100) - Testudo boosts Defence by 8 (was 9); Volcanic Strength and Abyssal Stealth gained their missing animations/graphics; Magic Focus uses anim 8308; Titan's Constitution heals 10% of max (was flat 80) with per-titan colours and a full-health refusal; Healing Aura gets the same refusal and its owner sparkle (familiar keeps 1356) - Fish Rain drops one of shrimps/cod/bass/mackerel at the ibis' feet with its 8199/1337 visuals; Fruitfall shakes 0-6 fruit (papaya first, pineapple joins the pool); Essence Shipment also empties the titan's pack and plays 7698/1457; Winter Storage shows the yak's 1358 gfx --- .../summoning/summoning_special.anims.toml | 44 ++- .../summoning/summoning_special.gfx.toml | 61 +++- .../skill/summoning/FamiliarBoostSpecials.kt | 31 ++- .../skill/summoning/FamiliarCombatSpecials.kt | 263 +++++++++++------- .../summoning/FamiliarUtilitySpecials.kt | 92 ++++-- .../FamiliarCombatSpecialEffectTest.kt | 78 ++++-- .../FamiliarUtilitySpecialEffectTest.kt | 66 ++++- 7 files changed, 473 insertions(+), 162 deletions(-) diff --git a/data/skill/summoning/summoning_special.anims.toml b/data/skill/summoning/summoning_special.anims.toml index 3240c69fac..0d9eb79ee8 100644 --- a/data/skill/summoning/summoning_special.anims.toml +++ b/data/skill/summoning/summoning_special.anims.toml @@ -65,7 +65,7 @@ id = 8109 id = 8288 [magic_focus] -id = 8267 +id = 8308 [abyssal_stealth] id = 7682 @@ -152,10 +152,10 @@ id = 7820 # Iron/steel titan's charge-up animation (shared swing, different graphics). [iron_within] -id = 8183 +id = 7954 [steel_of_legends] -id = 8183 +id = 8190 # Hydra's Regrowth animation (played on the familiar as it revives the tree). [regrowth] @@ -183,3 +183,41 @@ id = 7704 # Bloated leech's Blood Drain animation (played on the familiar as it draws blood). [blood_drain] id = 7715 + +# Obsidian golem's Volcanic Strength animation (played on the familiar). +[volcanic_strength] +id = 8053 + +# Spirit graahk's Goad goring animation. +[goad] +id = 7915 + +# Spirit kyatt's Ambush pounce animation (played as it lands on the target). +[ambush] +id = 7914 + +# Ibis' Fish Rain animation. +[fish_rain] +id = 8199 + +# Fruit bat's Fruitfall animation (the bat shaking the trees). +[fruitfall] +id = 8277 + +# Abyssal titan's Essence Shipment animation. +[essence_shipment] +id = 7698 + +# Elemental titans' Titan's Constitution animations, one per titan. +[titans_constitution_fire] +id = 7835 + +[titans_constitution_ice] +id = 7837 + +[titans_constitution_moss] +id = 7837 + +# Forge regent's Inferno flare animation. +[inferno] +id = 7871 diff --git a/data/skill/summoning/summoning_special.gfx.toml b/data/skill/summoning/summoning_special.gfx.toml index 4add21cd8e..7c934641c1 100644 --- a/data/skill/summoning/summoning_special.gfx.toml +++ b/data/skill/summoning/summoning_special.gfx.toml @@ -112,7 +112,7 @@ id = 1508 id = 1511 [boil] -id = 1375 +id = 1373 [boil_proj] id = 1376 @@ -370,3 +370,62 @@ id = 1420 # Tireless Run: the sparkle played on the owner as the boost lands (the 1521 gfx is the terrorbird's). [tireless_run_owner] id = 1300 + +# Dissolve: the jelly's own graphic and the melting impact. +[dissolve] +id = 1361 + +[dissolve_hit] +id = 1360 + +# Rending: the impact graphic on the target. +[rending_hit] +id = 1372 + +# Abyssal Stealth: the lurker's graphic and the owner's shroud. +[abyssal_stealth] +id = 1339 + +[abyssal_stealth_owner] +id = 1302 + +# Volcanic Strength is defined above (1465) - played on the familiar. + +# Goad has no graphic; Ambush: the kyatt's pounce-landing graphic. +[ambush] +id = 1366 + +# Fish Rain: the splash on the ibis. +[fish_rain] +id = 1337 + +# Essence Shipment: the flourish on the abyssal titan. +[essence_shipment] +id = 1457 + +# Winter Storage: the flourish on the pack yak as it banks an item. +[winter_storage] +id = 1358 + +# Titan's Constitution: each titan's own graphic, plus the owner's tint. +[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: the sparkle played on the owner (the 1356 gfx is the unicorn's). +[healing_aura_owner] +id = 1300 diff --git a/game/src/main/kotlin/content/skill/summoning/FamiliarBoostSpecials.kt b/game/src/main/kotlin/content/skill/summoning/FamiliarBoostSpecials.kt index 7b5f7e22e2..87c5aa0322 100644 --- a/game/src/main/kotlin/content/skill/summoning/FamiliarBoostSpecials.kt +++ b/game/src/main/kotlin/content/skill/summoning/FamiliarBoostSpecials.kt @@ -27,13 +27,13 @@ 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, 9, "testudo", "testudo") } - FamiliarSpecialMoves.instant("obsidian_golem_familiar") { boost(Skill.Strength, 9, sourceGfx = "volcanic_strength") } + FamiliarSpecialMoves.instant("war_tortoise_familiar") { boost(Skill.Defence, 8, "testudo", "testudo") } + 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") { + familiarSelfSpecial(anim = "abyssal_stealth", sourceGfx = "abyssal_stealth", playerGfx = "abyssal_stealth_owner") { levels.boost(Skill.Agility, 4) levels.boost(Skill.Thieving, 4) } @@ -59,17 +59,28 @@ class FamiliarBoostSpecials : Script { } } - // Elemental titans - Titan's Constitution: Defence +12.5% and heal 80. - FamiliarSpecialMoves.instant("fire_titan_familiar", "moss_titan_familiar", "ice_titan_familiar") { - familiarSelfSpecial { - levels.boost(Skill.Defence, multiplier = 0.125) - levels.restore(Skill.Constitution, 80) + // Elemental titans - Titan's Constitution: Defence +12.5% and heal a tenth of max life + // points, each titan with its own colours. Refuses, charging nothing, at full health. + for (tier in listOf("fire", "ice", "moss")) { + FamiliarSpecialMoves.instant("${tier}_titan_familiar") { + if (levels.get(Skill.Constitution) >= levels.getMax(Skill.Constitution)) { + message("You're already at full life points!") + return@instant false + } + familiarSelfSpecial(anim = "titans_constitution_$tier", sourceGfx = "titans_constitution_$tier", playerGfx = "titans_constitution_${tier}_owner") { + levels.boost(Skill.Defence, multiplier = 0.125) + levels.restore(Skill.Constitution, multiplier = 0.1) + } } } - // Healing Aura - heal the owner by 15% of their max hitpoints. + // Healing Aura - heal the owner by 15% of their max life points. Refuses at full health. FamiliarSpecialMoves.instant("unicorn_stallion_familiar") { - familiarSelfSpecial(anim = "healing_aura", playerGfx = "healing_aura") { + 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()) } } diff --git a/game/src/main/kotlin/content/skill/summoning/FamiliarCombatSpecials.kt b/game/src/main/kotlin/content/skill/summoning/FamiliarCombatSpecials.kt index ffab6a60cb..e0a30ce03b 100644 --- a/game/src/main/kotlin/content/skill/summoning/FamiliarCombatSpecials.kt +++ b/game/src/main/kotlin/content/skill/summoning/FamiliarCombatSpecials.kt @@ -21,6 +21,7 @@ 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.get import world.gregs.voidps.engine.inv.equipment import world.gregs.voidps.engine.inv.inventory @@ -48,9 +49,6 @@ 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 much special attack energy (out of 1000) the lava titan's Ebon Thunder drains from a player. */ -private const val EBON_THUNDER_ENERGY_DRAIN = 100 - /** * 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 @@ -111,19 +109,25 @@ class FamiliarCombatSpecials : Script { familiarSpecialHit(target, maxHit = 30, anim = "dreadfowl_strike", sourceGfx = "dreadfowl_strike", projectile = "dreadfowl_strike_proj") } - // Combat specials that also drain one of the target's stats. + // 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", projectile = "dissolve_proj").alsoDrain(target, Skill.Attack, 3) + 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").alsoDrain(target, Skill.Strength, 1) + 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 -> - familiarSpecialHit(target, maxHit = 95, anim = "abyssal_drain", sourceGfx = "abyssal_drain", projectile = "abyssal_drain_proj").alsoDrain(target, Skill.Prayer, random.nextInt(3) + 1) + 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. @@ -135,9 +139,17 @@ class FamiliarCombatSpecials : Script { cast } - // Boil - geyser titan ranged hit (approximates the live def-bonus formula with a flat max). + // 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 -> - familiarSpecialHit(target, maxHit = 240, anim = "boil", sourceGfx = "boil", projectile = "boil_proj", targetGfx = "boil_hit") + 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. @@ -159,7 +171,8 @@ class FamiliarCombatSpecials : Script { cast } - // Ebon Thunder - the lava titan's bolt drains a player target's special attack energy on impact. + // 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) } @@ -176,15 +189,16 @@ class FamiliarCombatSpecials : Script { } } - // Deadly Claw - the talon beast rakes its target three times in quick succession. + // 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 = 64 + swipe * 30) + familiar.hit(target, offensiveType = "magic", damage = random.nextInt(101), delay = swipe * 30) } if (familiar !in target.attackers) { target.attackers.add(familiar) @@ -193,64 +207,43 @@ class FamiliarCombatSpecials : Script { true } - // Acorn Missile - the giant ent lobs acorns that also pelt anything adjacent to the target. + // 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) { - val familiar = follower - if (familiar != null && target is NPC) { - for (tile in target.tile.spiral(1)) { - for (splashed in NPCs.at(tile)) { - if (splashed == target || !familiarCanSpecial(splashed, silent = true)) { - continue - } - familiar.hit(splashed, offensiveType = "magic", damage = random.nextInt(101)) - } - } - } + familiarSplash(target, maxTargets = 9, maxHit = 100, radius = 1, projectile = "acorn_missile_proj", targetGfx = "acorn_missile_hit") } cast } - // Famine - the ravenous locust swarms the target; a player target has some food eaten rotten. - FamiliarSpecialMoves.npc("ravenous_locust_familiar") { target -> - familiarSpecialHit(target, maxHit = 50, anim = "famine", sourceGfx = "famine", projectile = "famine_proj", targetGfx = "famine_hit") - } - FamiliarSpecialMoves.player("ravenous_locust_familiar") { target -> - val cast = familiarSpecialHit(target, maxHit = 50, anim = "famine", sourceGfx = "famine", projectile = "famine_proj", targetGfx = "famine_hit") - if (cast && rotFood(target)) { - message("Your locust devours some of ${target.name}'s food.") - target.message("${name}'s locust devours some of your food!") - } - 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 - the forge regent's flare knocks another player's weapon and shield out of their - // hands (into their inventory). Fails, charging nothing, if nothing could be unequipped. + // 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 -> - if (!familiarCanSpecial(target)) { - return@player false - } - val familiar = follower ?: return@player false - var disarmed = false - for (slot in intArrayOf(EquipSlot.Weapon.index, EquipSlot.Shield.index)) { - if (target.equipment[slot].isEmpty()) { - continue + 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 + } } - 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!") } } - if (!disarmed) { - message("Your familiar couldn't disarm ${target.name}.") - return@player false - } - familiar.watch(target) - familiar.gfx("inferno") - target.gfx("inferno_hit") - target.message("${name}'s familiar burns the equipment from your hands!") - true + cast } // Vampyre Touch: 120 max, 40% chance to heal the owner 20. @@ -293,9 +286,13 @@ class FamiliarCombatSpecials : Script { } } - // Dust Cloud - the smoke devil chokes everything around it in a burst of hot dust. - FamiliarSpecialMoves.instant("smoke_devil_familiar") { - familiarAoeSpecial(maxTargets = 7, maxHit = 80, radius = 1, anim = "dust_cloud", sourceGfx = "dust_cloud", projectile = "dust_cloud_proj", targetGfx = "dust_cloud_hit") + // Dust Cloud - the smoke devil chokes the picked target and up to six more foes around it. + 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. @@ -318,38 +315,27 @@ class FamiliarCombatSpecials : Script { // 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 / Steel of Legends - charge the titan so its next attack (melee-only for the - // iron titan) lands as a flurry of 3 / 4 hits on its target. The charge is set on cast and - // spent by the npcCombatAttack handler on the familiar's next swing; each extra hit rolls - // up to the titan's own max (2 extra up to 230 for iron, 3 up to 244 for steel). - for ((familiar, flurry) in mapOf("iron_titan_familiar" to (2 to 230), "steel_titan_familiar" to (3 to 244))) { - val (extraHits, flurryMax) = flurry - val charge = "familiar_titan_charged" - FamiliarSpecialMoves.instant(familiar) { - if (this[charge, false]) { - message("Your familiar's special attack is already charged.") - return@instant false - } - val titan = follower ?: return@instant false - titan.anim(if (familiar == "iron_titan_familiar") "iron_within" else "steel_of_legends") - titan.gfx(if (familiar == "iron_titan_familiar") "iron_within" else "steel_of_legends") - set(charge, true) - true + // 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") } - npcCombatAttack(familiar) { attack -> - if (familiar == "iron_titan_familiar" && attack.type != "melee") { - return@npcCombatAttack - } - val owner = Players.indexed(this["owner_index", -1]) ?: return@npcCombatAttack - if (owner.follower?.index != index || !owner[charge, false]) { - return@npcCombatAttack - } - // Clear before the extra hits - each one re-fires this handler. - owner.clear(charge) - repeat(extraHits) { swing -> - hit(attack.target, offensiveType = attack.type, damage = random.nextInt(flurryMax + 1), delay = attack.delay + (swing + 1) * 30) - } + cast + } + + // Steel of Legends - the steel titan strikes four times: melee up close, ranged from afar, + // up to 244 each. + 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") + follower?.gfx("steel_of_legends") + target.gfx("steel_of_legends") } + cast } val bullRush = mapOf( @@ -418,22 +404,99 @@ class FamiliarCombatSpecials : Script { return this } - /** Ebon Thunder's on-impact effect: saps some of a player target's special attack energy. */ + /** 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 - EBON_THUNDER_ENERGY_DRAIN).coerceAtLeast(0) + target.specialAttackEnergy -= target.specialAttackEnergy / 10 } } - /** Famine's food theft: turns the first edible item in [target]'s inventory rotten. */ + /** Famine's food theft: turns a random edible item in [target]'s inventory rotten. */ private fun rotFood(target: Player): Boolean { - for (item in target.inventory.items) { - if (item.isEmpty() || !item.def.options.contains("Eat")) { - continue + 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 + var count = 0 + for (tile in target.tile.spiral(radius)) { + for (splashed in NPCs.at(tile)) { + if (splashed == target || !familiarCanSpecial(splashed, silent = true)) { + continue + } + 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) } + } + if (++count >= maxTargets) { + return + } } - target.inventory.replace(item.id, "rotten_food") - return true } - return false + } + + /** + * 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/FamiliarUtilitySpecials.kt b/game/src/main/kotlin/content/skill/summoning/FamiliarUtilitySpecials.kt index 7942173bc2..3717dfdd85 100644 --- a/game/src/main/kotlin/content/skill/summoning/FamiliarUtilitySpecials.kt +++ b/game/src/main/kotlin/content/skill/summoning/FamiliarUtilitySpecials.kt @@ -1,5 +1,7 @@ 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 @@ -58,12 +60,12 @@ class FamiliarUtilitySpecials : Script { "grimy_dwarf_weed", "grimy_torstol", ) - private val fruit = listOf("orange", "banana", "lemon", "lime", "papaya_fruit") - private val rawFish = listOf("raw_trout", "raw_salmon", "raw_cod", "raw_pike") + private val fruit = listOf("orange", "banana", "lemon", "lime", "pineapple") + private val rawFish = listOf("raw_shrimps", "raw_cod", "raw_bass", "raw_mackerel") init { - // Goad / Pester - the special simply sends the familiar at the target (with a Hunter feel). - FamiliarSpecialMoves.npc("spirit_graahk_familiar", "spirit_mosquito_familiar") { target -> + // Pester - the special simply sends the mosquito at the target. + FamiliarSpecialMoves.npc("spirit_mosquito_familiar") { target -> if (!familiarCanSpecial(target)) { return@npc false } @@ -71,9 +73,47 @@ class FamiliarUtilitySpecials : Script { true } - // Ambush - teleport the familiar to the owner, ready to strike. - FamiliarSpecialMoves.instant("spirit_kyatt_familiar") { - callFollower() + // 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 } @@ -181,30 +221,39 @@ class FamiliarUtilitySpecials : Script { true } - // Fruitfall - drop a papaya plus a few random fruits. + // Fruitfall - the bat shakes loose up to six fruits around the owner, the first always a + // papaya. An unlucky cast (about 1 in 7) shakes down nothing at all but still counts. FamiliarSpecialMoves.instant("fruit_bat_familiar") { - dropForage("papaya_fruit") - repeat(random.nextInt(4)) { - dropForage(fruit[random.nextInt(fruit.size)]) + follower?.anim("fruitfall") + repeat(random.nextInt(7)) { i -> + dropForage(if (i == 0) "papaya_fruit" else fruit[random.nextInt(fruit.size)]) } true } - // Fish Rain - drop one or two random raw fish. + // Fish Rain - the ibis calls down a single low-level fish beside itself. FamiliarSpecialMoves.instant("ibis_familiar") { - repeat(random.nextInt(2) + 1) { - dropForage(rawFish[random.nextInt(rawFish.size)]) + 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. + // Essence Shipment - bank all rune and pure essence carried, the familiar's pack included. FamiliarSpecialMoves.instant("abyssal_titan_familiar") { - val moved = bankAll("rune_essence") + bankAll("pure_essence") + 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 } @@ -226,6 +275,7 @@ class FamiliarUtilitySpecials : Script { } 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 @@ -247,4 +297,14 @@ class FamiliarUtilitySpecials : Script { } 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/test/kotlin/content/skill/summoning/FamiliarCombatSpecialEffectTest.kt b/game/src/test/kotlin/content/skill/summoning/FamiliarCombatSpecialEffectTest.kt index 5a6d0c36fc..5ca98b6341 100644 --- a/game/src/test/kotlin/content/skill/summoning/FamiliarCombatSpecialEffectTest.kt +++ b/game/src/test/kotlin/content/skill/summoning/FamiliarCombatSpecialEffectTest.kt @@ -2,7 +2,6 @@ package content.skill.summoning import FakeRandom import WorldTest -import content.entity.combat.hit.hit import content.entity.effect.frozen import content.entity.effect.stunned import content.entity.effect.toxin.poisoned @@ -11,6 +10,7 @@ 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.distanceTo 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 @@ -178,13 +178,16 @@ class FamiliarCombatSpecialEffectTest : WorldTest() { } @Test - fun `Inferno burns another player's weapon and shield out of their hands`() { + 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)) @@ -192,73 +195,92 @@ class FamiliarCombatSpecialEffectTest : WorldTest() { 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 fails, charging nothing, when there is nothing to disarm`() { + 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) - assertFalse(player.runPlayerSpecial("forge_regent_familiar", target)) + assertTrue(player.runPlayerSpecial("forge_regent_familiar", target), "the bolt flies regardless") } @Test - fun `Dust Cloud chokes the foes around the smoke devil`() { + fun `Dust Cloud chokes the target and the foes around it`() { maxRolls() val player = summon("smoke_devil_familiar") - val familiar = player.follower!! - val target = tankyRat(player, familiar.tile.addX(1)) - val other = tankyRat(player, familiar.tile.addY(1)) + 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.runSpecial("smoke_devil_familiar")) + assertTrue(player.runNpcSpecial("smoke_devil_familiar", target)) tick(8) - assertTrue(target.levels.get(Skill.Constitution) < before, "the first rat chokes on the dust") - assertTrue(other.levels.get(Skill.Constitution) < otherBefore, "so does the second") + 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 charges the titan and refuses a second charge`() { + 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.runSpecial("iron_titan_familiar")) - assertTrue(player.get("familiar_titan_charged", false)) + assertTrue(player.runNpcSpecial("iron_titan_familiar", target)) + tick(8) - assertFalse(player.runSpecial("iron_titan_familiar"), "already charged - no second cast") + // 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 charged iron titan's next melee swing lands a flurry then clears the charge`() { + fun `Steel of Legends strikes the target four times from afar`() { maxRolls() - val player = summon("iron_titan_familiar") + val player = summon("steel_titan_familiar") val target = tankyRat(player) - player.set("familiar_titan_charged", true) val before = target.levels.get(Skill.Constitution) - player.follower!!.hit(target, offensiveType = "melee", damage = 50) + assertTrue(player.runNpcSpecial("steel_titan_familiar", target)) tick(8) - assertFalse(player.get("familiar_titan_charged", false), "the charge is spent by the swing") - assertTrue(before - target.levels.get(Skill.Constitution) > 50, "the flurry hit beyond the triggering swing") + assertTrue(before - target.levels.get(Skill.Constitution) >= 900, "four ranged strikes of up to 244 landed") } @Test - fun `A charged steel titan's next attack of any style lands a flurry`() { + fun `Goad gores the target twice on the spot`() { maxRolls() - val player = summon("steel_titan_familiar") + val player = summon("spirit_graahk_familiar") val target = tankyRat(player) - player.set("familiar_titan_charged", true) val before = target.levels.get(Skill.Constitution) - player.follower!!.hit(target, offensiveType = "range", damage = 50) - tick(8) + 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") - assertFalse(player.get("familiar_titan_charged", false)) - assertTrue(before - target.levels.get(Skill.Constitution) > 50, "the flurry hit beyond the triggering swing") + tick(4) + assertTrue(before - target.levels.get(Skill.Constitution) >= 200, "the maxed pounce hits close to 224") } @Test diff --git a/game/src/test/kotlin/content/skill/summoning/FamiliarUtilitySpecialEffectTest.kt b/game/src/test/kotlin/content/skill/summoning/FamiliarUtilitySpecialEffectTest.kt index 0f0e9f10a0..34d6374aef 100644 --- a/game/src/test/kotlin/content/skill/summoning/FamiliarUtilitySpecialEffectTest.kt +++ b/game/src/test/kotlin/content/skill/summoning/FamiliarUtilitySpecialEffectTest.kt @@ -1,5 +1,6 @@ package content.skill.summoning +import FakeRandom import WorldTest import content.entity.effect.toxin.poison import content.entity.effect.toxin.poisoned @@ -20,10 +21,12 @@ import world.gregs.voidps.engine.entity.item.floor.FloorItems import world.gregs.voidps.engine.entity.character.player.skill.Skill 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 @@ -47,12 +50,12 @@ class FamiliarUtilitySpecialEffectTest : WorldTest() { private fun Player.runSpecial(familiar: String): Boolean = FamiliarSpecialMoves.instant.getValue(familiar).invoke(this) @Test - fun `Testudo boosts Defence by 9`() { + 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 + 9, player.levels.get(Skill.Defence)) + assertEquals(before + 8, player.levels.get(Skill.Defence)) } @Test @@ -104,7 +107,7 @@ class FamiliarUtilitySpecialEffectTest : WorldTest() { } @Test - fun `Titan's Constitution raises Defence and heals 80 life points`() { + fun `Titan's Constitution raises Defence and heals a tenth of max 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) @@ -114,7 +117,14 @@ class FamiliarUtilitySpecialEffectTest : WorldTest() { 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)) + assertEquals(lifePoints + 99, player.levels.get(Skill.Constitution), "a tenth of the 990 maximum") + } + + @Test + fun `Titan's Constitution refuses at full life points, charging nothing`() { + val player = summon("moss_titan_familiar") + + assertFalse(player.runSpecial("moss_titan_familiar")) } @Test @@ -128,6 +138,54 @@ class FamiliarUtilitySpecialEffectTest : WorldTest() { 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 shakes loose a papaya and more fruit 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(2) + + assertTrue(FloorItems.at(player.tile).any { it.id == "papaya_fruit" }, "the first fruit down is always a papaya") + assertTrue(FloorItems.at(player.tile).any { it.id == "pineapple" }, "with more fruit alongside") + } + + @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") From 8ca5e789ea245d781b3be90059f4bc4803ccb138 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Sun, 5 Jul 2026 10:37:04 -0700 Subject: [PATCH 090/136] feat: familiar 'other abilities' from the testing tracker - Hunting cats teleport their owner home from the Interact option (graahk to the Karamja hunter area by the nature altar, kyatt to Piscatoris, larupia to Feldip Hills), refusing above level-20 wilderness; the lava titan's existing Lava Maze option now uses the same gated jewellery-style teleport instead of a bare tele - Using a raw fish on the bunyip transmutes it into 1 to heal/10 water runes (Swallow Whole moves to the Cast option alone) - The geyser titan boils a bowl into a bowl of hot water and recharges amulets of glory like the Fountain of Heroes - The ibis and granite lobster stand in for a harpoon when fishing - Ferocious: one in five of the spirit dagannoth's melee attacks strikes a second time (sync 7791/1427) - Passive boosts: the spirit graahk's missing Hunter +5, the stranger plant's Farming +1+4% and the geyser titan's Ranged +1+3% - the boost table now supports level-scaled formulas --- .../summoning/summoning_special.anims.toml | 8 ++ .../summoning/summoning_special.gfx.toml | 12 +++ .../kotlin/content/skill/fishing/Fishing.kt | 4 +- .../content/skill/summoning/FamiliarBoosts.kt | 47 +++++++---- .../skill/summoning/FamiliarTeleport.kt | 22 ++++++ .../skill/summoning/familiar/Bunyip.kt | 30 +++++-- .../skill/summoning/familiar/GeyserTitan.kt | 29 +++++++ .../skill/summoning/familiar/LavaTitan.kt | 4 +- .../summoning/familiar/SpiritDagannoth.kt | 20 +++++ .../skill/summoning/familiar/SpiritGraahk.kt | 78 +++++++++++-------- .../skill/summoning/familiar/SpiritKyatt.kt | 20 ++++- .../skill/summoning/familiar/SpiritLarupia.kt | 18 ++++- .../FamiliarUtilitySpecialEffectTest.kt | 46 ++++++++++- 13 files changed, 276 insertions(+), 62 deletions(-) create mode 100644 game/src/main/kotlin/content/skill/summoning/FamiliarTeleport.kt diff --git a/data/skill/summoning/summoning_special.anims.toml b/data/skill/summoning/summoning_special.anims.toml index 0d9eb79ee8..8541f33c3a 100644 --- a/data/skill/summoning/summoning_special.anims.toml +++ b/data/skill/summoning/summoning_special.anims.toml @@ -221,3 +221,11 @@ id = 7837 # Forge regent's Inferno flare animation. [inferno] id = 7871 + +# Bunyip's raw-fish transmute: the owner's casting motion as the fish becomes water runes. +[bunyip_transmute] +id = 2779 + +# Spirit dagannoth's Ferocious double-strike animation. +[ferocious] +id = 7791 diff --git a/data/skill/summoning/summoning_special.gfx.toml b/data/skill/summoning/summoning_special.gfx.toml index 7c934641c1..18da707dcf 100644 --- a/data/skill/summoning/summoning_special.gfx.toml +++ b/data/skill/summoning/summoning_special.gfx.toml @@ -429,3 +429,15 @@ id = 1308 # Healing Aura: the sparkle played on the owner (the 1356 gfx is the unicorn's). [healing_aura_owner] id = 1300 + +# Bunyip's raw-fish transmute: the watery bolt from the bunyip to its owner. +[bunyip_transmute_proj] +id = 1435 +delay = 51 +time_offset = 46 +multiplier = 1 +curve = 0 + +# Spirit dagannoth's Ferocious double-strike graphic. +[ferocious] +id = 1427 diff --git a/game/src/main/kotlin/content/skill/fishing/Fishing.kt b/game/src/main/kotlin/content/skill/fishing/Fishing.kt index 1d40a74cc4..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,7 @@ 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 @@ -77,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 diff --git a/game/src/main/kotlin/content/skill/summoning/FamiliarBoosts.kt b/game/src/main/kotlin/content/skill/summoning/FamiliarBoosts.kt index 98c3d26ad9..31326cf997 100644 --- a/game/src/main/kotlin/content/skill/summoning/FamiliarBoosts.kt +++ b/game/src/main/kotlin/content/skill/summoning/FamiliarBoosts.kt @@ -12,22 +12,28 @@ import world.gregs.voidps.engine.entity.character.player.skill.Skill * * Dreadfowl/compost mound's active "Special" Farming boost is a separate, visible mechanic. */ -private val FAMILIAR_BOOSTS: Map> = mapOf( - "beaver_familiar" to mapOf(Skill.Woodcutting to 2), - "granite_crab_familiar" to mapOf(Skill.Fishing to 1), - "ibis_familiar" to mapOf(Skill.Fishing to 3), - "granite_lobster_familiar" to mapOf(Skill.Fishing to 4), - "arctic_bear_familiar" to mapOf(Skill.Hunter to 7), - "wolpertinger_familiar" to mapOf(Skill.Hunter to 5), - "spirit_kyatt_familiar" to mapOf(Skill.Hunter to 5), - "spirit_larupia_familiar" to mapOf(Skill.Hunter to 5), - "desert_wyrm_familiar" to mapOf(Skill.Mining to 1), - "void_ravager_familiar" to mapOf(Skill.Mining to 1), - "obsidian_golem_familiar" to mapOf(Skill.Mining to 7), - "lava_titan_familiar" to mapOf(Skill.Mining to 10, Skill.Firemaking to 10), - "pyrelord_familiar" to mapOf(Skill.Firemaking to 3), - "forge_regent_familiar" to mapOf(Skill.Firemaking to 4), - "magpie_familiar" to mapOf(Skill.Thieving to 3), +/** 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)), ) /** @@ -37,5 +43,12 @@ private val FAMILIAR_BOOSTS: Map> = mapOf( */ fun Player.familiarBoost(skill: Skill): Int { val id = follower?.id ?: return 0 - return FAMILIAR_BOOSTS[id]?.get(skill) ?: 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" 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/familiar/Bunyip.kt b/game/src/main/kotlin/content/skill/summoning/familiar/Bunyip.kt index 1dd02d243c..e09915e457 100644 --- a/game/src/main/kotlin/content/skill/summoning/familiar/Bunyip.kt +++ b/game/src/main/kotlin/content/skill/summoning/familiar/Bunyip.kt @@ -4,6 +4,7 @@ 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.castFamiliarSpecial import content.skill.summoning.follower @@ -13,31 +14,48 @@ 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.item.Item 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 triggers. + // 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 or a raw fish on the bunyip.") + 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, or use the fish on the - // familiar - both run through the scroll + points gate, charging nothing on a refusal. + // 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 } - castFamiliarSpecial { swallowWhole(item) } + 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") + follower?.shoot("bunyip_transmute_proj", this) + message("Your bunyip transmutes the ${item.def.name.lowercase()} into some water runes.", ChatType.Filter) } npcOperate("Interact", "bunyip_familiar") { diff --git a/game/src/main/kotlin/content/skill/summoning/familiar/GeyserTitan.kt b/game/src/main/kotlin/content/skill/summoning/familiar/GeyserTitan.kt index b99027144c..b67b293d38 100644 --- a/game/src/main/kotlin/content/skill/summoning/familiar/GeyserTitan.kt +++ b/game/src/main/kotlin/content/skill/summoning/familiar/GeyserTitan.kt @@ -4,10 +4,39 @@ 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/LavaTitan.kt b/game/src/main/kotlin/content/skill/summoning/familiar/LavaTitan.kt index 5b8f56a6fc..421330e7a3 100644 --- a/game/src/main/kotlin/content/skill/summoning/familiar/LavaTitan.kt +++ b/game/src/main/kotlin/content/skill/summoning/familiar/LavaTitan.kt @@ -5,9 +5,9 @@ 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.move.tele import world.gregs.voidps.engine.entity.character.player.Player import world.gregs.voidps.type.Tile @@ -24,7 +24,7 @@ class LavaTitan : Script { option("Teleport to Lava Maze") { choice("Are you sure you want to teleport here? It's very high wilderness.") { option("Yes. I'm sure.") { - tele(Tile(3030, 3838, 0)) + familiarTeleport(Tile(3030, 3838), "the lava titan") } option("Nevermind. That sounds dangerous.") } diff --git a/game/src/main/kotlin/content/skill/summoning/familiar/SpiritDagannoth.kt b/game/src/main/kotlin/content/skill/summoning/familiar/SpiritDagannoth.kt index d59140b927..7fb3725932 100644 --- a/game/src/main/kotlin/content/skill/summoning/familiar/SpiritDagannoth.kt +++ b/game/src/main/kotlin/content/skill/summoning/familiar/SpiritDagannoth.kt @@ -1,5 +1,6 @@ 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 @@ -7,8 +8,27 @@ 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 -> { diff --git a/game/src/main/kotlin/content/skill/summoning/familiar/SpiritGraahk.kt b/game/src/main/kotlin/content/skill/summoning/familiar/SpiritGraahk.kt index 52e488e617..14ed5c4de1 100644 --- a/game/src/main/kotlin/content/skill/summoning/familiar/SpiritGraahk.kt +++ b/game/src/main/kotlin/content/skill/summoning/familiar/SpiritGraahk.kt @@ -2,48 +2,64 @@ 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") { - 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!*") + choice { + option("Chat") { + chat() } - 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!") + // 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/SpiritKyatt.kt b/game/src/main/kotlin/content/skill/summoning/familiar/SpiritKyatt.kt index 43cc583afc..c260684632 100644 --- a/game/src/main/kotlin/content/skill/summoning/familiar/SpiritKyatt.kt +++ b/game/src/main/kotlin/content/skill/summoning/familiar/SpiritKyatt.kt @@ -2,16 +2,33 @@ 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?") @@ -21,7 +38,7 @@ class SpiritKyatt : Script { 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@npcOperate + return } when (random.nextInt(4)) { 0 -> { @@ -62,6 +79,5 @@ class SpiritKyatt : Script { 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 index 2113fe95c8..8d12ac6014 100644 --- a/game/src/main/kotlin/content/skill/summoning/familiar/SpiritLarupia.kt +++ b/game/src/main/kotlin/content/skill/summoning/familiar/SpiritLarupia.kt @@ -3,14 +3,31 @@ 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!") @@ -44,6 +61,5 @@ class SpiritLarupia : Script { player("Oh. Good.") } } - } } } diff --git a/game/src/test/kotlin/content/skill/summoning/FamiliarUtilitySpecialEffectTest.kt b/game/src/test/kotlin/content/skill/summoning/FamiliarUtilitySpecialEffectTest.kt index 34d6374aef..44d7d92cbd 100644 --- a/game/src/test/kotlin/content/skill/summoning/FamiliarUtilitySpecialEffectTest.kt +++ b/game/src/test/kotlin/content/skill/summoning/FamiliarUtilitySpecialEffectTest.kt @@ -4,6 +4,7 @@ import FakeRandom import WorldTest import content.entity.effect.toxin.poison import content.entity.effect.toxin.poisoned +import dialogueOption import content.entity.player.bank.bank import content.entity.player.effect.energy.MAX_RUN_ENERGY import content.entity.player.effect.energy.runEnergy @@ -237,7 +238,7 @@ class FamiliarUtilitySpecialEffectTest : WorldTest() { } val before = player.levels.get(Skill.Constitution) - player.itemOnNpc(player.follower!!, 1) + 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") @@ -255,13 +256,54 @@ class FamiliarUtilitySpecialEffectTest : WorldTest() { add("raw_shark", 1) } - player.itemOnNpc(player.follower!!, 1) + 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 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( From 1c5895cc6abe338d3596965831648a766b1fcebf Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Sun, 5 Jul 2026 11:14:49 -0700 Subject: [PATCH 091/136] feat: fishing foragers only produce while their owner fishes The granite lobster and ibis forage was already wired and rolling (a 1-in-12 chance per 30-second window, exactly darkan's 50-tick cadence) but rolled whether or not the owner was doing anything. Per darkan's canRollForager both now only roll while the owner is actively fishing, spearing swordfish/sharks (lobster) or tuna/swordfish (ibis) alongside them with a tenth of the catch xp. --- .../kotlin/content/skill/summoning/Forager.kt | 13 ++++++++- .../FamiliarUtilitySpecialEffectTest.kt | 27 +++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/game/src/main/kotlin/content/skill/summoning/Forager.kt b/game/src/main/kotlin/content/skill/summoning/Forager.kt index 8240c4911a..e2df373b1f 100644 --- a/game/src/main/kotlin/content/skill/summoning/Forager.kt +++ b/game/src/main/kotlin/content/skill/summoning/Forager.kt @@ -21,6 +21,15 @@ private val FORAGE_FISHING_XP: Map> = mapOf( "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 @@ -37,7 +46,9 @@ class Forager(private val dropTables: DropTables) : Script { if (forageTable(dropTables) == null) { return@timerTick Timer.CANCEL } - forage() + if (FORAGE_CONDITIONS[follower?.id]?.invoke(this) != false) { + forage() + } Timer.CONTINUE } diff --git a/game/src/test/kotlin/content/skill/summoning/FamiliarUtilitySpecialEffectTest.kt b/game/src/test/kotlin/content/skill/summoning/FamiliarUtilitySpecialEffectTest.kt index 44d7d92cbd..8bd074d499 100644 --- a/game/src/test/kotlin/content/skill/summoning/FamiliarUtilitySpecialEffectTest.kt +++ b/game/src/test/kotlin/content/skill/summoning/FamiliarUtilitySpecialEffectTest.kt @@ -292,6 +292,33 @@ class FamiliarUtilitySpecialEffectTest : WorldTest() { 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 hunting cats teleport their owner home`() { val player = summon("spirit_graahk_familiar") From 67e0f1625bb267f2ee8bd85546d678bb57969417 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Sun, 5 Jul 2026 11:22:37 -0700 Subject: [PATCH 092/136] feat: iron/steel titan and wolpertinger style-gated defence passives While summoned, the iron titan makes its owner 10% harder to hit with melee, the steel titan 15%, and the wolpertinger 5% harder to hit with magic - applied as a multiplier to the owner's defensive rating for the incoming attack style only (the tracker's 'boost only applies to melee/magic attacks'). Darkan only implements the steel titan's and unconditionally; the style gate follows the wiki. --- .../kotlin/content/entity/combat/hit/Hit.kt | 4 +++ .../content/skill/summoning/FamiliarBoosts.kt | 12 +++++++ .../FamiliarUtilitySpecialEffectTest.kt | 34 +++++++++++++++++++ 3 files changed, 50 insertions(+) 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..9691568d02 100644 --- a/game/src/main/kotlin/content/entity/combat/hit/Hit.kt +++ b/game/src/main/kotlin/content/entity/combat/hit/Hit.kt @@ -4,6 +4,7 @@ import com.github.michaelbull.logging.InlineLogger import content.entity.combat.Bonus import content.entity.combat.dead import content.entity.player.combat.special.specialAttack +import content.skill.summoning.familiarDefenceMultiplier import content.entity.player.equip.Equipment import content.skill.magic.spell.spell import content.skill.melee.weapon.Weapon @@ -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/skill/summoning/FamiliarBoosts.kt b/game/src/main/kotlin/content/skill/summoning/FamiliarBoosts.kt index 31326cf997..39931a7b70 100644 --- a/game/src/main/kotlin/content/skill/summoning/FamiliarBoosts.kt +++ b/game/src/main/kotlin/content/skill/summoning/FamiliarBoosts.kt @@ -52,3 +52,15 @@ fun Player.familiarBoost(skill: Skill): Int { * 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/test/kotlin/content/skill/summoning/FamiliarUtilitySpecialEffectTest.kt b/game/src/test/kotlin/content/skill/summoning/FamiliarUtilitySpecialEffectTest.kt index 8bd074d499..dd3aed1888 100644 --- a/game/src/test/kotlin/content/skill/summoning/FamiliarUtilitySpecialEffectTest.kt +++ b/game/src/test/kotlin/content/skill/summoning/FamiliarUtilitySpecialEffectTest.kt @@ -2,6 +2,7 @@ package content.skill.summoning import FakeRandom import WorldTest +import content.entity.combat.hit.Hit import content.entity.effect.toxin.poison import content.entity.effect.toxin.poisoned import dialogueOption @@ -22,6 +23,7 @@ import world.gregs.voidps.engine.entity.item.floor.FloorItems import world.gregs.voidps.engine.entity.character.player.skill.Skill import world.gregs.voidps.engine.entity.obj.GameObjects import world.gregs.voidps.engine.entity.obj.ObjectLayer +import world.gregs.voidps.engine.entity.item.Item import world.gregs.voidps.engine.inv.beastOfBurden import world.gregs.voidps.engine.inv.inventory import world.gregs.voidps.engine.inv.transact.operation.AddItem.add @@ -319,6 +321,38 @@ class FamiliarUtilitySpecialEffectTest : WorldTest() { 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 `The hunting cats teleport their owner home`() { val player = summon("spirit_graahk_familiar") From a610e0df3b57252a99c4977b072afdb30514d83d Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Sun, 5 Jul 2026 11:35:11 -0700 Subject: [PATCH 093/136] feat: Drown and Flames order the familiar at its owner's target The karamthulhu overlord's Drown and smoke devil's Flames right-click options had no handler, so clicking them did nothing. Both now send the familiar at its current target (falling back to the owner's), firing its water/fire auto-attack - neither reference implements these (2009scape's Flames is a TODO stub), so they follow the wiki's 'hits opponent with a water/fire spell'. --- .../summoning/familiar/KaramthulhuOverlord.kt | 18 +++++++++++++ .../skill/summoning/familiar/SmokeDevil.kt | 18 +++++++++++++ .../FamiliarUtilitySpecialEffectTest.kt | 25 +++++++++++++++++++ 3 files changed, 61 insertions(+) diff --git a/game/src/main/kotlin/content/skill/summoning/familiar/KaramthulhuOverlord.kt b/game/src/main/kotlin/content/skill/summoning/familiar/KaramthulhuOverlord.kt index 603522601c..8f0a7a0101 100644 --- a/game/src/main/kotlin/content/skill/summoning/familiar/KaramthulhuOverlord.kt +++ b/game/src/main/kotlin/content/skill/summoning/familiar/KaramthulhuOverlord.kt @@ -1,5 +1,6 @@ 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 @@ -8,11 +9,28 @@ 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.commandFamiliarAttack +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.type.random class KaramthulhuOverlord : Script { init { + // "Drown" orders the overlord to blast the owner's current foe with its water spell. + 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 + } + commandFamiliarAttack(enemy) + } + npcOperate("Interact", "karamthulhu_overlord_familiar") { when (random.nextInt(4)) { 0 -> { diff --git a/game/src/main/kotlin/content/skill/summoning/familiar/SmokeDevil.kt b/game/src/main/kotlin/content/skill/summoning/familiar/SmokeDevil.kt index 1897b31fc9..0997d8dc64 100644 --- a/game/src/main/kotlin/content/skill/summoning/familiar/SmokeDevil.kt +++ b/game/src/main/kotlin/content/skill/summoning/familiar/SmokeDevil.kt @@ -1,16 +1,34 @@ 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.commandFamiliarAttack +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.type.random class SmokeDevil : Script { init { + // "Flames" orders the devil to scorch the owner's current foe with its fiery breath. + 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 + } + commandFamiliarAttack(enemy) + } + npcOperate("Interact", "smoke_devil_familiar") { when (random.nextInt(4)) { 0 -> { diff --git a/game/src/test/kotlin/content/skill/summoning/FamiliarUtilitySpecialEffectTest.kt b/game/src/test/kotlin/content/skill/summoning/FamiliarUtilitySpecialEffectTest.kt index dd3aed1888..b73e26daeb 100644 --- a/game/src/test/kotlin/content/skill/summoning/FamiliarUtilitySpecialEffectTest.kt +++ b/game/src/test/kotlin/content/skill/summoning/FamiliarUtilitySpecialEffectTest.kt @@ -3,6 +3,7 @@ package content.skill.summoning import FakeRandom import WorldTest import content.entity.combat.hit.Hit +import content.entity.combat.target import content.entity.effect.toxin.poison import content.entity.effect.toxin.poisoned import dialogueOption @@ -353,6 +354,30 @@ class FamiliarUtilitySpecialEffectTest : WorldTest() { 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 + + player.npcOption(player.follower!!, "Drown") + tick(2) + + assertEquals(rat, player.follower!!.target, "the overlord turns its water spell on the owner's foe") + } + + @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 + + player.npcOption(player.follower!!, "Flames") + tick(2) + + assertEquals(rat, player.follower!!.target, "the devil turns its fire on the owner's foe") + } + @Test fun `The hunting cats teleport their owner home`() { val player = summon("spirit_graahk_familiar") From 9f907b94896a47a304169fe8a8aa9bdeb3d368c4 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Sun, 5 Jul 2026 11:40:56 -0700 Subject: [PATCH 094/136] fix: Dust Cloud fires as an AoE on a plain cast click Converting it to a target-then-cast combat special (darkan's shape) in the audit left a bare click on the cast button doing nothing until an npc was picked. A plain cast now chokes everything around the smoke devil itself (up to six foes, radius 1, as in 2009scape), while casting onto a specific foe keeps the darkan behaviour - that target plus up to six more around it. --- .../skill/summoning/FamiliarCombatSpecials.kt | 6 +++++- .../summoning/FamiliarCombatSpecialEffectTest.kt | 14 ++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/game/src/main/kotlin/content/skill/summoning/FamiliarCombatSpecials.kt b/game/src/main/kotlin/content/skill/summoning/FamiliarCombatSpecials.kt index e0a30ce03b..1983c57e6f 100644 --- a/game/src/main/kotlin/content/skill/summoning/FamiliarCombatSpecials.kt +++ b/game/src/main/kotlin/content/skill/summoning/FamiliarCombatSpecials.kt @@ -286,7 +286,11 @@ class FamiliarCombatSpecials : Script { } } - // Dust Cloud - the smoke devil chokes the picked target and up to six more foes around it. + // 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) { diff --git a/game/src/test/kotlin/content/skill/summoning/FamiliarCombatSpecialEffectTest.kt b/game/src/test/kotlin/content/skill/summoning/FamiliarCombatSpecialEffectTest.kt index 5ca98b6341..1745d9376e 100644 --- a/game/src/test/kotlin/content/skill/summoning/FamiliarCombatSpecialEffectTest.kt +++ b/game/src/test/kotlin/content/skill/summoning/FamiliarCombatSpecialEffectTest.kt @@ -212,6 +212,20 @@ class FamiliarCombatSpecialEffectTest : WorldTest() { 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 chokes the target and the foes around it`() { maxRolls() From 5e59fa9ec4071ced919a915c12792f8196b2d43f Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Sun, 5 Jul 2026 11:47:37 -0700 Subject: [PATCH 095/136] fix: familiar AoE specials find npcs by their bodies, not anchor tiles Dust Cloud (and Fireball Assault, Sandstorm, Explode, the Acorn Missile splash) scanned spiral tiles against NPCs.at, which only matches an npc's south-west anchor tile - any monster larger than 1x1 standing right beside the familiar was invisible to the special and it reported nothing nearby to attack. The scan is now zone-based and measures the distance to the npc's bounds, nearest first. --- .../skill/summoning/FamiliarCombatSpecials.kt | 27 +++++-------- .../skill/summoning/FamiliarSpecialHelpers.kt | 38 +++++++++++-------- .../FamiliarCombatSpecialEffectTest.kt | 16 ++++++++ 3 files changed, 48 insertions(+), 33 deletions(-) diff --git a/game/src/main/kotlin/content/skill/summoning/FamiliarCombatSpecials.kt b/game/src/main/kotlin/content/skill/summoning/FamiliarCombatSpecials.kt index 1983c57e6f..13d5665442 100644 --- a/game/src/main/kotlin/content/skill/summoning/FamiliarCombatSpecials.kt +++ b/game/src/main/kotlin/content/skill/summoning/FamiliarCombatSpecials.kt @@ -457,24 +457,15 @@ class FamiliarCombatSpecials : Script { */ private fun Player.familiarSplash(target: Character, maxTargets: Int, maxHit: Int, radius: Int, projectile: String? = null, targetGfx: String? = null) { val familiar = follower ?: return - var count = 0 - for (tile in target.tile.spiral(radius)) { - for (splashed in NPCs.at(tile)) { - if (splashed == target || !familiarCanSpecial(splashed, silent = true)) { - continue - } - 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) } - } - if (++count >= maxTargets) { - 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) } } } } diff --git a/game/src/main/kotlin/content/skill/summoning/FamiliarSpecialHelpers.kt b/game/src/main/kotlin/content/skill/summoning/FamiliarSpecialHelpers.kt index e4dbfe0f53..be3cf917e5 100644 --- a/game/src/main/kotlin/content/skill/summoning/FamiliarSpecialHelpers.kt +++ b/game/src/main/kotlin/content/skill/summoning/FamiliarSpecialHelpers.kt @@ -13,9 +13,11 @@ import world.gregs.voidps.engine.entity.character.npc.NPC import world.gregs.voidps.engine.entity.character.npc.NPCs import world.gregs.voidps.engine.entity.character.player.Player import world.gregs.voidps.engine.entity.character.player.skill.Skill +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 /** @@ -128,21 +130,7 @@ fun Player.familiarAoeSpecial( targetGfx: String? = null, ): Boolean { val familiar = follower ?: return false - val targets = mutableListOf() - for (tile in familiar.tile.spiral(radius)) { - for (character in NPCs.at(tile)) { - if (character in targets || !familiarCanSpecial(character, silent = true)) { - continue - } - targets.add(character) - if (targets.size >= maxTargets) { - break - } - } - if (targets.size >= maxTargets) { - break - } - } + val targets = nearbyAttackableNpcs(familiar.tile, radius).take(maxTargets) if (targets.isEmpty()) { message("There is nothing nearby for your familiar to attack.") return false @@ -164,6 +152,26 @@ fun Player.familiarAoeSpecial( 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. diff --git a/game/src/test/kotlin/content/skill/summoning/FamiliarCombatSpecialEffectTest.kt b/game/src/test/kotlin/content/skill/summoning/FamiliarCombatSpecialEffectTest.kt index 1745d9376e..f71a085862 100644 --- a/game/src/test/kotlin/content/skill/summoning/FamiliarCombatSpecialEffectTest.kt +++ b/game/src/test/kotlin/content/skill/summoning/FamiliarCombatSpecialEffectTest.kt @@ -226,6 +226,22 @@ class FamiliarCombatSpecialEffectTest : WorldTest() { 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() From cf4df2d262774bc16a84658a666b029cdd9f8330 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Sun, 5 Jul 2026 11:57:18 -0700 Subject: [PATCH 096/136] fix: transformed npcs fall back to base-id interaction handlers Npc interactions resolve handlers by the transformed definition id, so a familiar in its wilderness combat form ('_combat') stopped answering Interact, Store, Dismiss, Drown and item-on-familiar uses - every handler is keyed on the base id. Player-on-npc and item-on-npc interactions now fall back to the base id when the transform has no handler of its own, with transform-specific handlers keeping priority. --- .../character/mode/interact/ItemOnNPCInteract.kt | 10 +++++++--- .../mode/interact/PlayerOnNPCInteract.kt | 8 +++++--- .../summoning/FamiliarUtilitySpecialEffectTest.kt | 15 +++++++++++++++ 3 files changed, 27 insertions(+), 6 deletions(-) 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/game/src/test/kotlin/content/skill/summoning/FamiliarUtilitySpecialEffectTest.kt b/game/src/test/kotlin/content/skill/summoning/FamiliarUtilitySpecialEffectTest.kt index b73e26daeb..75d5793fda 100644 --- a/game/src/test/kotlin/content/skill/summoning/FamiliarUtilitySpecialEffectTest.kt +++ b/game/src/test/kotlin/content/skill/summoning/FamiliarUtilitySpecialEffectTest.kt @@ -4,6 +4,7 @@ import FakeRandom import WorldTest import content.entity.combat.hit.Hit import content.entity.combat.target +import content.entity.effect.transform import content.entity.effect.toxin.poison import content.entity.effect.toxin.poisoned import dialogueOption @@ -15,6 +16,7 @@ import itemOnNpc 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 @@ -378,6 +380,19 @@ class FamiliarUtilitySpecialEffectTest : WorldTest() { assertEquals(rat, player.follower!!.target, "the devil turns its fire on the owner's foe") } + @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") From e99368fd1edd78bf8c9ce7d550ede5b93561c304 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Sun, 5 Jul 2026 14:29:16 -0700 Subject: [PATCH 097/136] fix: stranger plant swings its own attack animation Its combat anims (8292/8294/8295) belonged to npcs 6829/6830 - an off-by-one-npc slip that darkan's stranger_plant.json also carries. 2009scape's npc config gives the stranger plant melee animation 8208 and death 8209, with no defence animation, so the defend entry is dropped rather than left pointing at the wrong model. --- data/skill/summoning/summoning.combat.toml | 1 - data/skill/summoning/summoning_combat.anims.toml | 7 ++----- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/data/skill/summoning/summoning.combat.toml b/data/skill/summoning/summoning.combat.toml index 89395abbaa..e3b8582a2e 100644 --- a/data/skill/summoning/summoning.combat.toml +++ b/data/skill/summoning/summoning.combat.toml @@ -522,7 +522,6 @@ target_hit = { offense = "crush", max = 82 } [stranger_plant_familiar] attack_speed = 8 retreat_range = 8 -defend_anim = "stranger_plant_familiar_defend" death_anim = "stranger_plant_familiar_death" [stranger_plant_familiar.melee] diff --git a/data/skill/summoning/summoning_combat.anims.toml b/data/skill/summoning/summoning_combat.anims.toml index ae66731976..2bb2435140 100644 --- a/data/skill/summoning/summoning_combat.anims.toml +++ b/data/skill/summoning/summoning_combat.anims.toml @@ -407,13 +407,10 @@ id = 8154 id = 8153 [stranger_plant_familiar_attack] -id = 8292 - -[stranger_plant_familiar_defend] -id = 8294 +id = 8208 [stranger_plant_familiar_death] -id = 8295 +id = 8209 [barker_toad_familiar_attack] id = 7260 From 74a9644c8dfe88c7fa544ad4fea38c93b521e21e Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Sun, 5 Jul 2026 14:35:48 -0700 Subject: [PATCH 098/136] fix: Toad Bark's cannonball flies darkan's low rising arc The shot flew flat at the default height-40 line with no curve, which the client renders with the model oriented backwards. There is no projectile rotation in the protocol - the client orients the model along its trajectory - so the fix is darkan's launch profile: low out of the toad's mouth, rising slightly (10 -> 16, curve 16). --- data/skill/summoning/summoning_special.gfx.toml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/data/skill/summoning/summoning_special.gfx.toml b/data/skill/summoning/summoning_special.gfx.toml index 18da707dcf..a4834bb8f5 100644 --- a/data/skill/summoning/summoning_special.gfx.toml +++ b/data/skill/summoning/summoning_special.gfx.toml @@ -342,12 +342,17 @@ id = 1463 [toad_bark_load] id = 1400 +# The cannonball launches low from the toad's mouth and rises slightly (darkan's 10 -> 16 arc, +# curve 16) - character heights default to 40, hence the negative offsets. The client orients the +# model along this trajectory; there is no projectile rotation in the protocol. [toad_bark_proj] id = 1402 delay = 51 time_offset = 46 multiplier = 1 -curve = 0 +curve = 16 +height = -30 +end_height = -24 # Thieving Fingers: the sparkle played on the owner as the boost lands. [thieving_fingers_owner] From 5f5e48af5705eb2db2bc20c10fb30365d6534380 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Sun, 5 Jul 2026 14:42:31 -0700 Subject: [PATCH 099/136] fix: familiar special projectiles fly at distance-scaled speed Special-move projectiles either flew near-instantly regardless of range (multiplier = 1, ~1 client tick per tile) or fell back to the fast 0 + 5/tile default. All are now delay = 30, time_offset = 10, multiplier = 10 - a flight of 1 game tick plus a third of a tick per tile, matching the live game's '1 + floor(distance / 3)' hit delay. Deliberate exceptions stay put: Bull Rush's constant 2s charge, the compost lob, and Venom Shot's explicit slow bolt. The standard is documented in the file header. --- .../summoning/summoning_special.gfx.toml | 123 ++++++++++++------ 1 file changed, 84 insertions(+), 39 deletions(-) diff --git a/data/skill/summoning/summoning_special.gfx.toml b/data/skill/summoning/summoning_special.gfx.toml index a4834bb8f5..2c0e1d4388 100644 --- a/data/skill/summoning/summoning_special.gfx.toml +++ b/data/skill/summoning/summoning_special.gfx.toml @@ -1,5 +1,10 @@ # 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] @@ -7,9 +12,9 @@ id = 1334 [spirit_wolf_howl_proj] id = 1333 -delay = 50 -time_offset = 56 -multiplier = 1 +delay = 30 +time_offset = 10 +multiplier = 10 curve = 0 [electric_lash] @@ -17,6 +22,9 @@ id = 1410 [electric_lash_proj] id = 1411 +delay = 30 +time_offset = 10 +multiplier = 10 [toad_bark] id = 1403 @@ -29,6 +37,9 @@ id = 1385 [slime_spray_proj] id = 1386 +delay = 30 +time_offset = 10 +multiplier = 10 [slime_spray_hit] id = 1387 @@ -38,6 +49,9 @@ id = 1405 [arctic_blast_proj] id = 1406 +delay = 30 +time_offset = 10 +multiplier = 10 [arctic_blast_hit] id = 1407 @@ -47,32 +61,45 @@ id = 1351 [crushing_claw_proj] id = 1352 +delay = 30 +time_offset = 10 +multiplier = 10 [dreadfowl_strike] id = 1523 [dreadfowl_strike_proj] id = 1318 -delay = 51 -time_offset = 46 -multiplier = 1 +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 turnip Evil Flames: source gfx on the familiar as it breathes, the projectile, and the impact. + [evil_flames] id = 1328 [evil_flames_proj] id = 1330 +delay = 30 +time_offset = 10 +multiplier = 10 [evil_flames_hit] id = 1329 @@ -82,6 +109,9 @@ id = 1422 [abyssal_drain_proj] id = 1423 +delay = 30 +time_offset = 10 +multiplier = 10 [vampire_touch] id = 1323 @@ -91,6 +121,9 @@ id = 1467 [petrifying_gaze_proj] id = 1468 +delay = 30 +time_offset = 10 +multiplier = 10 [petrifying_gaze_hit] id = 1469 @@ -107,6 +140,9 @@ multiplier = 0 [poisonous_blast_proj] id = 1508 +delay = 30 +time_offset = 10 +multiplier = 10 [poisonous_blast_hit] id = 1511 @@ -116,6 +152,9 @@ id = 1373 [boil_proj] id = 1376 +delay = 30 +time_offset = 10 +multiplier = 10 [boil_hit] id = 1377 @@ -135,6 +174,9 @@ id = 1350 [sandstorm_proj] id = 1349 +delay = 30 +time_offset = 10 +multiplier = 10 [egg_spawn] id = 1342 @@ -214,9 +256,9 @@ id = 1478 [doomsphere_proj] id = 1479 -delay = 51 -time_offset = 46 -multiplier = 1 +delay = 30 +time_offset = 10 +multiplier = 10 curve = 0 [doomsphere_hit] @@ -225,9 +267,9 @@ id = 1480 # Spirit dagannoth Spike Shot: the launched spike and its impact. [spike_shot_proj] id = 1426 -delay = 51 -time_offset = 46 -multiplier = 1 +delay = 30 +time_offset = 10 +multiplier = 10 curve = 0 [spike_shot_hit] @@ -236,20 +278,21 @@ id = 1428 # Swamp titan Swamp Plague: the bog projectile. [swamp_plague_proj] id = 1462 -delay = 51 -time_offset = 46 -multiplier = 1 +delay = 30 +time_offset = 10 +multiplier = 10 curve = 0 # Lava titan Ebon Thunder: familiar gfx, the dark bolt, and the impact. + [ebon_thunder] id = 1492 [ebon_thunder_proj] id = 1493 -delay = 51 -time_offset = 46 -multiplier = 1 +delay = 30 +time_offset = 10 +multiplier = 10 curve = 0 [ebon_thunder_hit] @@ -261,9 +304,9 @@ id = 1379 [mantis_strike_proj] id = 1380 -delay = 51 -time_offset = 46 -multiplier = 1 +delay = 30 +time_offset = 10 +multiplier = 10 curve = 0 [mantis_strike_hit] @@ -272,9 +315,9 @@ id = 1381 # Giant ent Acorn Missile: the lobbed acorns and their impact. [acorn_missile_proj] id = 1362 -delay = 51 -time_offset = 46 -multiplier = 1 +delay = 30 +time_offset = 10 +multiplier = 10 curve = 0 [acorn_missile_hit] @@ -286,9 +329,9 @@ id = 1346 [famine_proj] id = 1347 -delay = 51 -time_offset = 46 -multiplier = 1 +delay = 30 +time_offset = 10 +multiplier = 10 curve = 0 [famine_hit] @@ -307,9 +350,9 @@ id = 1375 [dust_cloud_proj] id = 1376 -delay = 51 -time_offset = 46 -multiplier = 1 +delay = 30 +time_offset = 10 +multiplier = 10 curve = 0 [dust_cloud_hit] @@ -347,14 +390,15 @@ id = 1400 # model along this trajectory; there is no projectile rotation in the protocol. [toad_bark_proj] id = 1402 -delay = 51 -time_offset = 46 -multiplier = 1 +delay = 30 +time_offset = 10 +multiplier = 10 curve = 16 height = -30 end_height = -24 # Thieving Fingers: the sparkle played on the owner as the boost lands. + [thieving_fingers_owner] id = 1300 @@ -364,9 +408,9 @@ id = 1419 [blood_drain_proj] id = 1417 -delay = 51 -time_offset = 46 -multiplier = 1 +delay = 30 +time_offset = 10 +multiplier = 10 curve = 0 [blood_drain_owner] @@ -438,11 +482,12 @@ id = 1300 # Bunyip's raw-fish transmute: the watery bolt from the bunyip to its owner. [bunyip_transmute_proj] id = 1435 -delay = 51 -time_offset = 46 -multiplier = 1 +delay = 30 +time_offset = 10 +multiplier = 10 curve = 0 # Spirit dagannoth's Ferocious double-strike graphic. + [ferocious] id = 1427 From dc4bdd8c0e0ef4424088fbb92283c6437feb422a Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Sun, 5 Jul 2026 14:45:53 -0700 Subject: [PATCH 100/136] fix: Toad Bark fires the multicannon's round cannonball graphic The reference's 1402 projectile model faces backwards in this revision's cache, and the projectile protocol has no rotation - the client orients models along the trajectory alone - so no arc could right it. The dwarf multicannon's cannonball (gfx 53) is a plain sphere with no facing to get wrong; the low rising arc stays. --- data/skill/summoning/summoning_special.gfx.toml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/data/skill/summoning/summoning_special.gfx.toml b/data/skill/summoning/summoning_special.gfx.toml index 2c0e1d4388..5de4bd1027 100644 --- a/data/skill/summoning/summoning_special.gfx.toml +++ b/data/skill/summoning/summoning_special.gfx.toml @@ -386,10 +386,11 @@ id = 1463 id = 1400 # The cannonball launches low from the toad's mouth and rises slightly (darkan's 10 -> 16 arc, -# curve 16) - character heights default to 40, hence the negative offsets. The client orients the -# model along this trajectory; there is no projectile rotation in the protocol. +# curve 16) - character heights default to 40, hence the negative offsets. The reference's 1402 +# graphic faces backwards in this revision's cache and projectiles can't be rotated server-side, +# so the dwarf multicannon's round cannonball (53) flies instead. [toad_bark_proj] -id = 1402 +id = 53 delay = 30 time_offset = 10 multiplier = 10 From e35543ffc4e007536bd1c41762ac05362900c09a Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Sun, 5 Jul 2026 14:57:15 -0700 Subject: [PATCH 101/136] feat: cache graphic patching rotates the Toad Bark cannonball 180 degrees The projectile protocol carries no rotation (darkan's packet is field-identical - its 'angle' byte is our 'curve'), but the cache's graphic definitions do: opcode 6 is a model rotation in degrees (the revision's existing values are only ever 90/180/270, gfx 1402's was 0). Adds a GraphicEncoder mirroring the decoder, and a FixGraphics tool (fixGraphics gradle task, also wired into CacheBuilder) that patches gfx 1402 with a 180 degree rotation after round-trip-validating the re-encode - so Toad Bark keeps its smoke-trailed cannonball, flying the right way round. The local cache has been patched. --- .../definition/encoder/GraphicEncoder.kt | 77 +++++++++++++++++++ .../summoning/summoning_special.gfx.toml | 8 +- tools/build.gradle.kts | 6 ++ .../gregs/voidps/tools/cache/CacheBuilder.kt | 1 + .../gregs/voidps/tools/cache/FixGraphics.kt | 65 ++++++++++++++++ 5 files changed, 153 insertions(+), 4 deletions(-) create mode 100644 cache/src/main/kotlin/world/gregs/voidps/cache/definition/encoder/GraphicEncoder.kt create mode 100644 tools/src/main/kotlin/world/gregs/voidps/tools/cache/FixGraphics.kt 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/skill/summoning/summoning_special.gfx.toml b/data/skill/summoning/summoning_special.gfx.toml index 5de4bd1027..8821285e6c 100644 --- a/data/skill/summoning/summoning_special.gfx.toml +++ b/data/skill/summoning/summoning_special.gfx.toml @@ -386,11 +386,11 @@ id = 1463 id = 1400 # The cannonball launches low from the toad's mouth and rises slightly (darkan's 10 -> 16 arc, -# curve 16) - character heights default to 40, hence the negative offsets. The reference's 1402 -# graphic faces backwards in this revision's cache and projectiles can't be rotated server-side, -# so the dwarf multicannon's round cannonball (53) flies instead. +# curve 16) - character heights default to 40, hence the negative offsets. The 1402 model faces +# backwards in this revision's cache; FixGraphics patches its definition with a 180 degree +# rotation (there is no projectile rotation in the protocol - the model itself is turned). [toad_bark_proj] -id = 53 +id = 1402 delay = 30 time_offset = 10 multiplier = 10 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) + } +} From b1dbae67c695d912a360cbddc26fccb685854190 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Sun, 5 Jul 2026 15:00:28 -0700 Subject: [PATCH 102/136] feat: Testudo plays its shell tint (gfx 1308) on the owner The war tortoise's cast showed only the familiar's own animation and graphic; the owner now gets the 1308 graphic alongside the scroll-cast flourish. The boost helper accepts an optional owner graphic for any future single-stat boosts that need one. --- data/skill/summoning/summoning_special.gfx.toml | 4 ++++ .../kotlin/content/skill/summoning/FamiliarBoostSpecials.kt | 6 +++--- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/data/skill/summoning/summoning_special.gfx.toml b/data/skill/summoning/summoning_special.gfx.toml index 8821285e6c..f578233103 100644 --- a/data/skill/summoning/summoning_special.gfx.toml +++ b/data/skill/summoning/summoning_special.gfx.toml @@ -492,3 +492,7 @@ curve = 0 [ferocious] id = 1427 + +# Testudo: the shell-shield tint played on the owner as the Defence boost lands. +[testudo_owner] +id = 1308 diff --git a/game/src/main/kotlin/content/skill/summoning/FamiliarBoostSpecials.kt b/game/src/main/kotlin/content/skill/summoning/FamiliarBoostSpecials.kt index 87c5aa0322..8f9b547d1a 100644 --- a/game/src/main/kotlin/content/skill/summoning/FamiliarBoostSpecials.kt +++ b/game/src/main/kotlin/content/skill/summoning/FamiliarBoostSpecials.kt @@ -27,7 +27,7 @@ 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") } + 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") } @@ -153,8 +153,8 @@ class FamiliarBoostSpecials : Script { source.poison(attack.target, 60) } - /** Boosts [skill] by [amount] above max with the familiar's [anim]/[sourceGfx] flourish. */ - private fun Player.boost(skill: Skill, amount: Int, anim: String? = null, sourceGfx: String? = null): Boolean = familiarSelfSpecial(anim = anim, sourceGfx = sourceGfx) { levels.boost(skill, amount) } + /** 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) From ee9f0622e84be1830f9389e33a0e6f70c644b6e4 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Sun, 5 Jul 2026 15:09:26 -0700 Subject: [PATCH 103/136] fix: the bunyip transmute projectile flies from the player to the bunyip The tossed fish flew out of the bunyip at its owner; it now travels the other way, from the player's throw to the bunyip's mouth. --- .../src/main/kotlin/content/skill/summoning/familiar/Bunyip.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/game/src/main/kotlin/content/skill/summoning/familiar/Bunyip.kt b/game/src/main/kotlin/content/skill/summoning/familiar/Bunyip.kt index e09915e457..1e41ba13a6 100644 --- a/game/src/main/kotlin/content/skill/summoning/familiar/Bunyip.kt +++ b/game/src/main/kotlin/content/skill/summoning/familiar/Bunyip.kt @@ -54,7 +54,8 @@ class Bunyip : Script { val runes = random.nextInt((heals.last / 10).coerceAtLeast(1)) + 1 inventory.add("water_rune", runes) anim("bunyip_transmute") - follower?.shoot("bunyip_transmute_proj", this) + // 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) } From fb9c2b0a1253ee8bc8bb2c2d90f523b2f1cd8b30 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Sun, 5 Jul 2026 15:14:13 -0700 Subject: [PATCH 104/136] fix: Fruitfall scatters fruit onto free tiles with landing splashes Per darkan, each shaken-loose fruit lands on its own free tile around the owner - deduped, splash graphic (1331) a tick before it appears - instead of the whole harvest piling up underfoot with no visuals. The first fruit is still always a papaya and an unlucky cast still drops nothing. --- .../summoning/summoning_special.gfx.toml | 4 ++++ .../summoning/FamiliarUtilitySpecials.kt | 23 +++++++++++++++---- .../FamiliarUtilitySpecialEffectTest.kt | 10 ++++---- 3 files changed, 28 insertions(+), 9 deletions(-) diff --git a/data/skill/summoning/summoning_special.gfx.toml b/data/skill/summoning/summoning_special.gfx.toml index f578233103..dbbd2a12eb 100644 --- a/data/skill/summoning/summoning_special.gfx.toml +++ b/data/skill/summoning/summoning_special.gfx.toml @@ -496,3 +496,7 @@ id = 1427 # Testudo: the shell-shield tint played on the owner as the Defence boost lands. [testudo_owner] id = 1308 + +# Fruitfall: the splash where each shaken-loose fruit lands. +[fruitfall_land] +id = 1331 diff --git a/game/src/main/kotlin/content/skill/summoning/FamiliarUtilitySpecials.kt b/game/src/main/kotlin/content/skill/summoning/FamiliarUtilitySpecials.kt index 3717dfdd85..5b66c53974 100644 --- a/game/src/main/kotlin/content/skill/summoning/FamiliarUtilitySpecials.kt +++ b/game/src/main/kotlin/content/skill/summoning/FamiliarUtilitySpecials.kt @@ -221,12 +221,25 @@ class FamiliarUtilitySpecials : Script { true } - // Fruitfall - the bat shakes loose up to six fruits around the owner, the first always a - // papaya. An unlucky cast (about 1 in 7) shakes down nothing at all but still counts. + // Fruitfall - the bat shakes 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 still counts. FamiliarSpecialMoves.instant("fruit_bat_familiar") { - follower?.anim("fruitfall") - repeat(random.nextInt(7)) { i -> - dropForage(if (i == 0) "papaya_fruit" else fruit[random.nextInt(fruit.size)]) + val bat = follower ?: return@instant false + bat.anim("fruitfall") + 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)) + for (fruitTile in fruitTiles) { + areaGfx("fruitfall_land", fruitTile) + } + queue("fruitfall", 1) { + 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 } diff --git a/game/src/test/kotlin/content/skill/summoning/FamiliarUtilitySpecialEffectTest.kt b/game/src/test/kotlin/content/skill/summoning/FamiliarUtilitySpecialEffectTest.kt index 75d5793fda..7026e5893d 100644 --- a/game/src/test/kotlin/content/skill/summoning/FamiliarUtilitySpecialEffectTest.kt +++ b/game/src/test/kotlin/content/skill/summoning/FamiliarUtilitySpecialEffectTest.kt @@ -163,7 +163,7 @@ class FamiliarUtilitySpecialEffectTest : WorldTest() { } @Test - fun `Fruitfall shakes loose a papaya and more fruit around the owner`() { + 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 @@ -171,10 +171,12 @@ class FamiliarUtilitySpecialEffectTest : WorldTest() { val player = summon("fruit_bat_familiar") assertTrue(player.runSpecial("fruit_bat_familiar")) - tick(2) + tick(3) - assertTrue(FloorItems.at(player.tile).any { it.id == "papaya_fruit" }, "the first fruit down is always a papaya") - assertTrue(FloorItems.at(player.tile).any { it.id == "pineapple" }, "with more fruit alongside") + 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 From 4c460973e7f4aee11c3d6651768d47c11dc48d37 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Sun, 5 Jul 2026 15:22:07 -0700 Subject: [PATCH 105/136] feat: the fruit bat flies up into the trees before dropping its fruit Fruitfall now plays 2009scape's two-stage sequence: the bat ascends (anim 8320 with the swirling-leaves graphic 1332), then ~4 ticks later swoops into its drop animation (8321) as the landing splashes flash and the fruit falls a tick after - the same staged-queue shape as the macaw's Herbcall. --- .../summoning/summoning_special.anims.toml | 7 ++++-- .../summoning/summoning_special.gfx.toml | 4 ++++ .../summoning/FamiliarUtilitySpecials.kt | 24 +++++++++++++------ .../FamiliarUtilitySpecialEffectTest.kt | 2 +- 4 files changed, 27 insertions(+), 10 deletions(-) diff --git a/data/skill/summoning/summoning_special.anims.toml b/data/skill/summoning/summoning_special.anims.toml index 8541f33c3a..4a7e18c7f3 100644 --- a/data/skill/summoning/summoning_special.anims.toml +++ b/data/skill/summoning/summoning_special.anims.toml @@ -200,9 +200,12 @@ id = 7914 [fish_rain] id = 8199 -# Fruit bat's Fruitfall animation (the bat shaking the trees). +# Fruit bat's Fruitfall: the flight up into the trees, then the swooping drop (2009scape's pair). +[fruitfall_ascend] +id = 8320 + [fruitfall] -id = 8277 +id = 8321 # Abyssal titan's Essence Shipment animation. [essence_shipment] diff --git a/data/skill/summoning/summoning_special.gfx.toml b/data/skill/summoning/summoning_special.gfx.toml index dbbd2a12eb..94426888f7 100644 --- a/data/skill/summoning/summoning_special.gfx.toml +++ b/data/skill/summoning/summoning_special.gfx.toml @@ -500,3 +500,7 @@ id = 1308 # Fruitfall: the splash where each shaken-loose fruit lands. [fruitfall_land] id = 1331 + +# Fruitfall: the leaves swirling around the bat as it flies up into the trees. +[fruitfall_ascend] +id = 1332 diff --git a/game/src/main/kotlin/content/skill/summoning/FamiliarUtilitySpecials.kt b/game/src/main/kotlin/content/skill/summoning/FamiliarUtilitySpecials.kt index 5b66c53974..49001383f9 100644 --- a/game/src/main/kotlin/content/skill/summoning/FamiliarUtilitySpecials.kt +++ b/game/src/main/kotlin/content/skill/summoning/FamiliarUtilitySpecials.kt @@ -33,6 +33,12 @@ 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. @@ -221,22 +227,26 @@ class FamiliarUtilitySpecials : Script { true } - // Fruitfall - the bat shakes 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 still counts. + // 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") + bat.anim("fruitfall_ascend") + bat.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)) - for (fruitTile in fruitTiles) { - areaGfx("fruitfall_land", fruitTile) + queue("fruitfall_drop", FRUITFALL_DROP_DELAY) { + bat.anim("fruitfall") + for (fruitTile in fruitTiles) { + areaGfx("fruitfall_land", fruitTile) + } } - queue("fruitfall", 1) { + 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) } diff --git a/game/src/test/kotlin/content/skill/summoning/FamiliarUtilitySpecialEffectTest.kt b/game/src/test/kotlin/content/skill/summoning/FamiliarUtilitySpecialEffectTest.kt index 7026e5893d..04a4af62d3 100644 --- a/game/src/test/kotlin/content/skill/summoning/FamiliarUtilitySpecialEffectTest.kt +++ b/game/src/test/kotlin/content/skill/summoning/FamiliarUtilitySpecialEffectTest.kt @@ -171,7 +171,7 @@ class FamiliarUtilitySpecialEffectTest : WorldTest() { val player = summon("fruit_bat_familiar") assertTrue(player.runSpecial("fruit_bat_familiar")) - tick(3) + 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") From 9abc4a3938fc74de28855c7f5073c5bf5284a3f4 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Sun, 5 Jul 2026 15:40:29 -0700 Subject: [PATCH 106/136] fix: familiars follow the real PvP rules when sent at players familiarCanSpecial and commandFamiliarAttack required the in_pvp flag on both sides, but the wilderness only sets in_wilderness - so Famine, Ebon Thunder, Inferno and the attack command all refused with 'You can only attack players in a player-vs-player area' inside actual PvP. Player targets are now validated by Target.attackable with the owner as source - the same wilderness/PvP-area, combat-level-bracket and single-way rules as attacking directly. --- .../kotlin/content/entity/combat/hit/Hit.kt | 2 +- .../content/skill/summoning/FamiliarCombat.kt | 6 +- .../skill/summoning/FamiliarCombatSpecials.kt | 6 +- .../skill/summoning/FamiliarSpecialHelpers.kt | 6 +- .../summoning/FamiliarUtilitySpecials.kt | 2 +- .../skill/summoning/familiar/Bunyip.kt | 1 - .../skill/summoning/familiar/Pyrelord.kt | 6 +- .../skill/summoning/familiar/SpiritKyatt.kt | 96 +++++++++---------- .../skill/summoning/familiar/SpiritLarupia.kt | 64 ++++++------- .../FamiliarCombatSpecialEffectTest.kt | 20 +++- .../summoning/FamiliarSpecialEffectTest.kt | 2 +- .../FamiliarUtilitySpecialEffectTest.kt | 8 +- 12 files changed, 117 insertions(+), 102 deletions(-) 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 9691568d02..b58b23c1bc 100644 --- a/game/src/main/kotlin/content/entity/combat/hit/Hit.kt +++ b/game/src/main/kotlin/content/entity/combat/hit/Hit.kt @@ -4,12 +4,12 @@ import com.github.michaelbull.logging.InlineLogger import content.entity.combat.Bonus import content.entity.combat.dead import content.entity.player.combat.special.specialAttack -import content.skill.summoning.familiarDefenceMultiplier import content.entity.player.equip.Equipment 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 diff --git a/game/src/main/kotlin/content/skill/summoning/FamiliarCombat.kt b/game/src/main/kotlin/content/skill/summoning/FamiliarCombat.kt index c2c2e1cfc9..58d9d37037 100644 --- a/game/src/main/kotlin/content/skill/summoning/FamiliarCombat.kt +++ b/game/src/main/kotlin/content/skill/summoning/FamiliarCombat.kt @@ -1,7 +1,6 @@ package content.skill.summoning import content.area.wilderness.inMultiCombat -import content.area.wilderness.inPvp import content.area.wilderness.inWilderness import content.entity.combat.Target import content.entity.combat.target @@ -43,8 +42,9 @@ fun Player.commandFamiliarAttack(target: Character, silent: Boolean = false) { return } when (target) { - is Player -> if (!inPvp || !target.inPvp) { - if (!silent) message("You can only attack players in a player-vs-player area.") + // 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 diff --git a/game/src/main/kotlin/content/skill/summoning/FamiliarCombatSpecials.kt b/game/src/main/kotlin/content/skill/summoning/FamiliarCombatSpecials.kt index 13d5665442..ef5fc9618c 100644 --- a/game/src/main/kotlin/content/skill/summoning/FamiliarCombatSpecials.kt +++ b/game/src/main/kotlin/content/skill/summoning/FamiliarCombatSpecials.kt @@ -16,7 +16,6 @@ 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.npc.NPCs import world.gregs.voidps.engine.entity.character.player.Player import world.gregs.voidps.engine.entity.character.player.Players import world.gregs.voidps.engine.entity.character.player.name @@ -28,7 +27,6 @@ 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.map.spiral 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 @@ -240,7 +238,7 @@ class FamiliarCombatSpecials : Script { } if (disarmed) { target.gfx("inferno_hit") - target.message("${name}'s familiar burns the equipment from your hands!") + target.message("$name's familiar burns the equipment from your hands!") } } cast @@ -442,7 +440,7 @@ class FamiliarCombatSpecials : Script { 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!") + target.message("$name's locust devours some of your food!") } if (familiar !in target.attackers) { target.attackers.add(familiar) diff --git a/game/src/main/kotlin/content/skill/summoning/FamiliarSpecialHelpers.kt b/game/src/main/kotlin/content/skill/summoning/FamiliarSpecialHelpers.kt index be3cf917e5..c8cc09dcb8 100644 --- a/game/src/main/kotlin/content/skill/summoning/FamiliarSpecialHelpers.kt +++ b/game/src/main/kotlin/content/skill/summoning/FamiliarSpecialHelpers.kt @@ -1,6 +1,5 @@ package content.skill.summoning -import content.area.wilderness.inPvp import content.area.wilderness.inSingleCombat import content.entity.combat.Target import content.entity.combat.attacker @@ -44,8 +43,9 @@ fun Player.familiarCanSpecial(target: Character, silent: Boolean = false): Boole return false } when (target) { - is Player -> if (!inPvp || !target.inPvp) { - if (!silent) message("You can only attack players in a player-vs-player area.") + // 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)) { diff --git a/game/src/main/kotlin/content/skill/summoning/FamiliarUtilitySpecials.kt b/game/src/main/kotlin/content/skill/summoning/FamiliarUtilitySpecials.kt index 49001383f9..803acef0de 100644 --- a/game/src/main/kotlin/content/skill/summoning/FamiliarUtilitySpecials.kt +++ b/game/src/main/kotlin/content/skill/summoning/FamiliarUtilitySpecials.kt @@ -233,7 +233,7 @@ class FamiliarUtilitySpecials : Script { FamiliarSpecialMoves.instant("fruit_bat_familiar") { val bat = follower ?: return@instant false bat.anim("fruitfall_ascend") - bat.gfx("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) } diff --git a/game/src/main/kotlin/content/skill/summoning/familiar/Bunyip.kt b/game/src/main/kotlin/content/skill/summoning/familiar/Bunyip.kt index 1e41ba13a6..b090ad791a 100644 --- a/game/src/main/kotlin/content/skill/summoning/familiar/Bunyip.kt +++ b/game/src/main/kotlin/content/skill/summoning/familiar/Bunyip.kt @@ -6,7 +6,6 @@ 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.castFamiliarSpecial import content.skill.summoning.follower import world.gregs.voidps.engine.Script import world.gregs.voidps.engine.client.message diff --git a/game/src/main/kotlin/content/skill/summoning/familiar/Pyrelord.kt b/game/src/main/kotlin/content/skill/summoning/familiar/Pyrelord.kt index 79cfed58bc..7709c6af53 100644 --- a/game/src/main/kotlin/content/skill/summoning/familiar/Pyrelord.kt +++ b/game/src/main/kotlin/content/skill/summoning/familiar/Pyrelord.kt @@ -11,13 +11,11 @@ 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.entity.World 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.get -import world.gregs.voidps.engine.queue.queue 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 @@ -27,8 +25,10 @@ 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 { diff --git a/game/src/main/kotlin/content/skill/summoning/familiar/SpiritKyatt.kt b/game/src/main/kotlin/content/skill/summoning/familiar/SpiritKyatt.kt index c260684632..d87daf6857 100644 --- a/game/src/main/kotlin/content/skill/summoning/familiar/SpiritKyatt.kt +++ b/game/src/main/kotlin/content/skill/summoning/familiar/SpiritKyatt.kt @@ -29,55 +29,55 @@ class SpiritKyatt : Script { } 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 + 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!") } - 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.") - } + 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 index 8d12ac6014..6c848fe6be 100644 --- a/game/src/main/kotlin/content/skill/summoning/familiar/SpiritLarupia.kt +++ b/game/src/main/kotlin/content/skill/summoning/familiar/SpiritLarupia.kt @@ -28,38 +28,38 @@ class SpiritLarupia : Script { } 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.") - } + 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/test/kotlin/content/skill/summoning/FamiliarCombatSpecialEffectTest.kt b/game/src/test/kotlin/content/skill/summoning/FamiliarCombatSpecialEffectTest.kt index f71a085862..dfc9978d85 100644 --- a/game/src/test/kotlin/content/skill/summoning/FamiliarCombatSpecialEffectTest.kt +++ b/game/src/test/kotlin/content/skill/summoning/FamiliarCombatSpecialEffectTest.kt @@ -10,9 +10,10 @@ 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.distanceTo +import world.gregs.voidps.engine.entity.character.player.combatLevel 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.inv.equipment import world.gregs.voidps.engine.inv.inventory import world.gregs.voidps.engine.inv.transact.operation.AddItem.add @@ -161,6 +162,23 @@ class FamiliarCombatSpecialEffectTest : WorldTest() { 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() diff --git a/game/src/test/kotlin/content/skill/summoning/FamiliarSpecialEffectTest.kt b/game/src/test/kotlin/content/skill/summoning/FamiliarSpecialEffectTest.kt index 6e632542f3..843d13058a 100644 --- a/game/src/test/kotlin/content/skill/summoning/FamiliarSpecialEffectTest.kt +++ b/game/src/test/kotlin/content/skill/summoning/FamiliarSpecialEffectTest.kt @@ -10,11 +10,11 @@ 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.client.variable.hasClock import world.gregs.voidps.engine.entity.item.floor.FloorItems import world.gregs.voidps.engine.entity.obj.GameObject import world.gregs.voidps.engine.inv.beastOfBurden diff --git a/game/src/test/kotlin/content/skill/summoning/FamiliarUtilitySpecialEffectTest.kt b/game/src/test/kotlin/content/skill/summoning/FamiliarUtilitySpecialEffectTest.kt index 04a4af62d3..4f130afad3 100644 --- a/game/src/test/kotlin/content/skill/summoning/FamiliarUtilitySpecialEffectTest.kt +++ b/game/src/test/kotlin/content/skill/summoning/FamiliarUtilitySpecialEffectTest.kt @@ -4,13 +4,13 @@ import FakeRandom import WorldTest import content.entity.combat.hit.Hit import content.entity.combat.target -import content.entity.effect.transform import content.entity.effect.toxin.poison import content.entity.effect.toxin.poisoned -import dialogueOption +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 npcOption @@ -21,12 +21,12 @@ 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.character.player.skill.Skill import world.gregs.voidps.engine.entity.obj.GameObjects import world.gregs.voidps.engine.entity.obj.ObjectLayer -import world.gregs.voidps.engine.entity.item.Item import world.gregs.voidps.engine.inv.beastOfBurden import world.gregs.voidps.engine.inv.inventory import world.gregs.voidps.engine.inv.transact.operation.AddItem.add From d6af23a4fcfd81c9351b57e889c04c15ce0e3d0f Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Sun, 5 Jul 2026 15:44:22 -0700 Subject: [PATCH 107/136] fix: the arctic bear swings its own claws, not a spider's legs Its combat animations (5327/5328/5329) belong to the spider family - the spirit spider familiar two npc ids along uses the same set, and darkan's arctic_bear.json carries the identical off-by-one copy-paste. 2009scape's npc config gives the bear attack 4925, defend 4928 and death 4929, completing its own 4925-4929 cluster alongside the Arctic Blast special's already-correct 4926. --- data/skill/summoning/summoning_combat.anims.toml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/data/skill/summoning/summoning_combat.anims.toml b/data/skill/summoning/summoning_combat.anims.toml index 2bb2435140..b0de3867d1 100644 --- a/data/skill/summoning/summoning_combat.anims.toml +++ b/data/skill/summoning/summoning_combat.anims.toml @@ -449,13 +449,13 @@ id = 7995 id = 7996 [arctic_bear_familiar_attack] -id = 5327 +id = 4925 [arctic_bear_familiar_defend] -id = 5328 +id = 4928 [arctic_bear_familiar_death] -id = 5329 +id = 4929 [phoenix_familiar_attack] id = 11093 From 69a175f1393cbab11953a80fcb159476b048b2d1 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Sun, 5 Jul 2026 16:46:43 -0700 Subject: [PATCH 108/136] feat: Rise from the Ashes - the phoenix's special, built from the wiki Neither reference implements it: darkan's is a TODO stub ('this special is a pain in the ass to code') and 2009scape commented the scroll out, so the phoenix was deliberately left without a special until now. Per the wiki it has two modes: cast on a foe it blasts for up to the phoenix's 160 magic max and sears away half their Attack, Magic and Ranged, returned exactly ~6 seconds later (set back directly - restore caps at base level and would swallow it); cast on ashes the phoenix is reborn, the ashes burning away along with all its wounds. A plain cast click explains both triggers. --- .../skill/summoning/familiar/Phoenix.kt | 68 +++++++++++++++++++ .../FamiliarCombatSpecialEffectTest.kt | 33 +++++++++ 2 files changed, 101 insertions(+) diff --git a/game/src/main/kotlin/content/skill/summoning/familiar/Phoenix.kt b/game/src/main/kotlin/content/skill/summoning/familiar/Phoenix.kt index 138df9348b..576d7ed8f6 100644 --- a/game/src/main/kotlin/content/skill/summoning/familiar/Phoenix.kt +++ b/game/src/main/kotlin/content/skill/summoning/familiar/Phoenix.kt @@ -4,11 +4,60 @@ 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.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.Character +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.inv.inventory +import world.gregs.voidps.engine.inv.remove +import world.gregs.voidps.engine.queue.queue import world.gregs.voidps.type.random +import kotlin.math.abs + +/** The offensive stats Rise from the Ashes sears away from its target. */ +private val RISE_DRAIN_SKILLS = arrayOf(Skill.Attack, Skill.Magic, Skill.Ranged) + +/** The seared stats return after ~6 seconds, unlike a normal decaying drain. */ +private const val RISE_DRAIN_TICKS = 10 + +/** The blast hits up to the phoenix's own magic max. */ +private const val RISE_MAX_HIT = 160 class Phoenix : Script { init { + // A plain click on the cast button has no target - point at both triggers. + FamiliarSpecialMoves.instant("phoenix_familiar") { + message("Cast Rise from the Ashes on a foe, or on ashes to rejuvenate the phoenix.") + false + } + + // Rise from the Ashes - a searing blast that halves the target's Attack, Magic and Ranged + // for a few seconds. Neither reference implements this (darkan's is a TODO stub), so the + // mechanics adapt the wiki: magic damage plus a severe short-lived offensive drain. + FamiliarSpecialMoves.npc("phoenix_familiar") { target -> riseFromTheAshes(target) } + FamiliarSpecialMoves.player("phoenix_familiar") { target -> riseFromTheAshes(target) } + + // Cast on ashes instead, the phoenix is reborn: the ashes burn away and its wounds with them. + FamiliarSpecialMoves.item("phoenix_familiar") { item -> + if (item.id != "ashes") { + message("The phoenix can only rise from ashes.") + return@item false + } + val phoenix = follower ?: return@item false + if (!inventory.remove("ashes")) { + return@item false + } + phoenix.anim("phoenix_spawn") + phoenix.levels.restore(Skill.Constitution, phoenix.levels.getMax(Skill.Constitution)) + message("Your phoenix rises from the ashes, its wounds burning away.", ChatType.Filter) + true + } + npcOperate("Interact", "phoenix_familiar") { when (random.nextInt(4)) { 0 -> { @@ -44,4 +93,23 @@ class Phoenix : Script { } } } + + /** + * The searing blast: magic damage with the phoenix's own attack animation, then half the + * target's offensive stats burn away, returning once the flames die down. + */ + private fun Player.riseFromTheAshes(target: Character): Boolean { + val cast = familiarSpecialHit(target, maxHit = RISE_MAX_HIT, anim = "phoenix_familiar_attack") + if (cast) { + val drained = RISE_DRAIN_SKILLS.associateWith { abs(target.levels.drain(it, multiplier = 0.5)) } + target.queue("rise_from_the_ashes_restore", RISE_DRAIN_TICKS) { + // Give back exactly what was seared - restore() caps at the base level, which would + // swallow the returned stats of anything fighting above it (boosts, set levels). + for ((skill, amount) in drained) { + target.levels.set(skill, target.levels.get(skill) + amount) + } + } + } + return cast + } } diff --git a/game/src/test/kotlin/content/skill/summoning/FamiliarCombatSpecialEffectTest.kt b/game/src/test/kotlin/content/skill/summoning/FamiliarCombatSpecialEffectTest.kt index dfc9978d85..c79c765a5a 100644 --- a/game/src/test/kotlin/content/skill/summoning/FamiliarCombatSpecialEffectTest.kt +++ b/game/src/test/kotlin/content/skill/summoning/FamiliarCombatSpecialEffectTest.kt @@ -8,6 +8,7 @@ import content.entity.effect.toxin.poisoned import content.entity.player.combat.special.specialAttackEnergy import itemOnNpc import org.junit.jupiter.api.Test +import world.gregs.voidps.engine.client.ui.InterfaceApi 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.combatLevel @@ -404,6 +405,38 @@ class FamiliarCombatSpecialEffectTest : WorldTest() { } } + @Test + fun `Rise from the Ashes sears half the target's offensive stats, returning after 6 seconds`() { + maxRolls() + val player = summon("phoenix_familiar") + val target = tankyRat(player) + target.levels.set(Skill.Magic, 20) + val before = target.levels.get(Skill.Constitution) + + assertTrue(player.runNpcSpecial("phoenix_familiar", target)) + assertEquals(10, target.levels.get(Skill.Magic), "half the target's Magic burns away") + + tick(11) // ~6 seconds later the seared stats return + assertEquals(20, target.levels.get(Skill.Magic), "the flames die down and the stats return") + assertTrue(target.levels.get(Skill.Constitution) < before, "the blast also damaged the target") + } + + @Test + fun `Cast on ashes the phoenix is reborn at full health`() { + val player = summon("phoenix_familiar") + val phoenix = player.follower!! + phoenix.levels.drain(Skill.Constitution, 500) + player.inventory.transaction { add("rise_from_the_ashes_scroll", 1) } + player.inventory.transaction { add("ashes", 1) } + val hurt = phoenix.levels.get(Skill.Constitution) + + InterfaceApi.onItem(player, "familiar_details:cast_rise_from_the_ashes", player.inventory[1]) + + assertEquals(0, player.inventory.count("ashes"), "the ashes burn away") + assertTrue(phoenix.levels.get(Skill.Constitution) > hurt, "and the phoenix's wounds with them") + assertEquals(phoenix.levels.getMax(Skill.Constitution), phoenix.levels.get(Skill.Constitution), "fully reborn") + } + @Test fun `Crushing Claw drains a twentieth of the target's Defence`() { maxRolls() From 5c9287680b36aa09d43456c6174050426525628a Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Sun, 5 Jul 2026 16:57:32 -0700 Subject: [PATCH 109/136] fix: Rise from the Ashes targets ashes on the ground, reborn-scaled The special is cast on a pile of ashes lying on the ground - not on a combat target or inventory ashes as first built. A new floor-item dispatch surface (FamiliarSpecialMoves.floorItem + cast-component onFloorItemApproach hooks) carries it: the phoenix drops in place and glows, then is reborn atop the ashes at full life points, scorching up to five foes beside them with magic hits scaling on the life points it was missing - a quarter of the deficit as the max hit, so an unhurt phoenix's rebirth is a pure heal. It stands over the spent ashes a few ticks (PauseMode) so the follow doesn't drag it back mid-flare. The live game's more-than-five glitch (rebirth but no attack) is not reproduced - the flare simply caps at five. --- .../skill/summoning/FamiliarSpecialMoves.kt | 32 ++++++- .../skill/summoning/familiar/Phoenix.kt | 94 +++++++++---------- .../FamiliarCombatSpecialEffectTest.kt | 34 +++---- 3 files changed, 87 insertions(+), 73 deletions(-) diff --git a/game/src/main/kotlin/content/skill/summoning/FamiliarSpecialMoves.kt b/game/src/main/kotlin/content/skill/summoning/FamiliarSpecialMoves.kt index 487ce3c057..0f43b88818 100644 --- a/game/src/main/kotlin/content/skill/summoning/FamiliarSpecialMoves.kt +++ b/game/src/main/kotlin/content/skill/summoning/FamiliarSpecialMoves.kt @@ -4,6 +4,7 @@ import world.gregs.voidps.engine.Script 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 /** @@ -15,11 +16,12 @@ import world.gregs.voidps.engine.entity.obj.GameObject * 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 + * - [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 @@ -34,6 +36,7 @@ object FamiliarSpecialMoves { 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 @@ -54,6 +57,10 @@ object FamiliarSpecialMoves { 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 + } } /** @@ -101,6 +108,14 @@ class FamiliarSpecialMovesDispatch : Script { 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_*") { val id = follower?.id ?: return@interfaceOption @@ -134,5 +149,12 @@ class FamiliarSpecialMovesDispatch : Script { 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) } + } } } diff --git a/game/src/main/kotlin/content/skill/summoning/familiar/Phoenix.kt b/game/src/main/kotlin/content/skill/summoning/familiar/Phoenix.kt index 576d7ed8f6..d10da54f93 100644 --- a/game/src/main/kotlin/content/skill/summoning/familiar/Phoenix.kt +++ b/game/src/main/kotlin/content/skill/summoning/familiar/Phoenix.kt @@ -5,56 +5,71 @@ 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.familiarSpecialHit 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.Character -import world.gregs.voidps.engine.entity.character.player.Player +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.inv.inventory -import world.gregs.voidps.engine.inv.remove +import world.gregs.voidps.engine.entity.item.floor.FloorItems import world.gregs.voidps.engine.queue.queue import world.gregs.voidps.type.random -import kotlin.math.abs +import content.entity.combat.hit.hit -/** The offensive stats Rise from the Ashes sears away from its target. */ -private val RISE_DRAIN_SKILLS = arrayOf(Skill.Attack, Skill.Magic, Skill.Ranged) +/** How long the phoenix drops in place and glows before reappearing atop the ashes. */ +private const val RISE_REBIRTH_DELAY = 3 -/** The seared stats return after ~6 seconds, unlike a normal decaying drain. */ -private const val RISE_DRAIN_TICKS = 10 +/** How many enemies around the ashes the rebirth flare can scorch. */ +private const val RISE_MAX_TARGETS = 5 -/** The blast hits up to the phoenix's own magic max. */ -private const val RISE_MAX_HIT = 160 +/** 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 both triggers. + // 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 foe, or on ashes to rejuvenate the phoenix.") + message("Cast Rise from the Ashes on a pile of ashes on the ground.") false } - // Rise from the Ashes - a searing blast that halves the target's Attack, Magic and Ranged - // for a few seconds. Neither reference implements this (darkan's is a TODO stub), so the - // mechanics adapt the wiki: magic damage plus a severe short-lived offensive drain. - FamiliarSpecialMoves.npc("phoenix_familiar") { target -> riseFromTheAshes(target) } - FamiliarSpecialMoves.player("phoenix_familiar") { target -> riseFromTheAshes(target) } - - // Cast on ashes instead, the phoenix is reborn: the ashes burn away and its wounds with them. - FamiliarSpecialMoves.item("phoenix_familiar") { item -> - if (item.id != "ashes") { + // Rise from the Ashes - cast on ashes lying on the ground: the phoenix drops in place and + // glows, then is reborn atop the ashes at full life points, scorching up to five foes + // beside them. The blast draws its strength from the phoenix's wounds - the closer to + // death it was, the harder the rebirth hits (unhurt, it is a pure rebirth). Neither + // reference implements this (darkan's is a TODO stub); mechanics per the live game. + FamiliarSpecialMoves.floorItem("phoenix_familiar") { ashes -> + if (ashes.id != "ashes") { message("The phoenix can only rise from ashes.") - return@item false + return@floorItem false } - val phoenix = follower ?: return@item false - if (!inventory.remove("ashes")) { - return@item 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) + } } - phoenix.anim("phoenix_spawn") - phoenix.levels.restore(Skill.Constitution, phoenix.levels.getMax(Skill.Constitution)) - message("Your phoenix rises from the ashes, its wounds burning away.", ChatType.Filter) true } @@ -93,23 +108,4 @@ class Phoenix : Script { } } } - - /** - * The searing blast: magic damage with the phoenix's own attack animation, then half the - * target's offensive stats burn away, returning once the flames die down. - */ - private fun Player.riseFromTheAshes(target: Character): Boolean { - val cast = familiarSpecialHit(target, maxHit = RISE_MAX_HIT, anim = "phoenix_familiar_attack") - if (cast) { - val drained = RISE_DRAIN_SKILLS.associateWith { abs(target.levels.drain(it, multiplier = 0.5)) } - target.queue("rise_from_the_ashes_restore", RISE_DRAIN_TICKS) { - // Give back exactly what was seared - restore() caps at the base level, which would - // swallow the returned stats of anything fighting above it (boosts, set levels). - for ((skill, amount) in drained) { - target.levels.set(skill, target.levels.get(skill) + amount) - } - } - } - return cast - } } diff --git a/game/src/test/kotlin/content/skill/summoning/FamiliarCombatSpecialEffectTest.kt b/game/src/test/kotlin/content/skill/summoning/FamiliarCombatSpecialEffectTest.kt index c79c765a5a..722556d932 100644 --- a/game/src/test/kotlin/content/skill/summoning/FamiliarCombatSpecialEffectTest.kt +++ b/game/src/test/kotlin/content/skill/summoning/FamiliarCombatSpecialEffectTest.kt @@ -15,6 +15,7 @@ import world.gregs.voidps.engine.entity.character.player.combatLevel 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.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 @@ -406,35 +407,30 @@ class FamiliarCombatSpecialEffectTest : WorldTest() { } @Test - fun `Rise from the Ashes sears half the target's offensive stats, returning after 6 seconds`() { + fun `Rise from the Ashes reincarnates the phoenix atop the ashes, scorching foes beside them`() { maxRolls() val player = summon("phoenix_familiar") - val target = tankyRat(player) - target.levels.set(Skill.Magic, 20) + 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(player.runNpcSpecial("phoenix_familiar", target)) - assertEquals(10, target.levels.get(Skill.Magic), "half the target's Magic burns away") + assertTrue(FamiliarSpecialMoves.floorItemTarget.getValue("phoenix_familiar").invoke(player, ashes)) + tick(8) // the drop-and-glow, the rebirth, then the flare's magic hits landing - tick(11) // ~6 seconds later the seared stats return - assertEquals(20, target.levels.get(Skill.Magic), "the flames die down and the stats return") - assertTrue(target.levels.get(Skill.Constitution) < before, "the blast also damaged the target") + 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 `Cast on ashes the phoenix is reborn at full health`() { + fun `Rise from the Ashes refuses anything that is not ashes`() { val player = summon("phoenix_familiar") - val phoenix = player.follower!! - phoenix.levels.drain(Skill.Constitution, 500) - player.inventory.transaction { add("rise_from_the_ashes_scroll", 1) } - player.inventory.transaction { add("ashes", 1) } - val hurt = phoenix.levels.get(Skill.Constitution) - - InterfaceApi.onItem(player, "familiar_details:cast_rise_from_the_ashes", player.inventory[1]) + val bones = FloorItems.add(player.tile.addY(2), "bones", disappearTicks = 300, owner = player) - assertEquals(0, player.inventory.count("ashes"), "the ashes burn away") - assertTrue(phoenix.levels.get(Skill.Constitution) > hurt, "and the phoenix's wounds with them") - assertEquals(phoenix.levels.getMax(Skill.Constitution), phoenix.levels.get(Skill.Constitution), "fully reborn") + assertFalse(FamiliarSpecialMoves.floorItemTarget.getValue("phoenix_familiar").invoke(player, bones)) } @Test From 50ce4022478cd977655b7945c7badf052770d3d0 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Sun, 5 Jul 2026 17:07:09 -0700 Subject: [PATCH 110/136] fix: the giant ent's transmuted runes stack inventory.replace swaps the essence for a rune in the same slot, so every transmute produced its own one-rune slot instead of joining the stack. Remove-then-add lets the rune merge with any existing stack. --- .../content/skill/summoning/familiar/GiantEnt.kt | 7 +++++-- .../summoning/FamiliarUtilitySpecialEffectTest.kt | 10 ++++++++-- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/game/src/main/kotlin/content/skill/summoning/familiar/GiantEnt.kt b/game/src/main/kotlin/content/skill/summoning/familiar/GiantEnt.kt index 24996547d4..8a05e65711 100644 --- a/game/src/main/kotlin/content/skill/summoning/familiar/GiantEnt.kt +++ b/game/src/main/kotlin/content/skill/summoning/familiar/GiantEnt.kt @@ -9,8 +9,9 @@ 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.replace +import world.gregs.voidps.engine.inv.remove import world.gregs.voidps.type.random class GiantEnt : Script { @@ -22,7 +23,9 @@ class GiantEnt : Script { return@itemOnNPCOperate } val rune = if (random.nextInt(9) < 4) "earth_rune" else "nature_rune" - if (inventory.replace("pure_essence", 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) } } diff --git a/game/src/test/kotlin/content/skill/summoning/FamiliarUtilitySpecialEffectTest.kt b/game/src/test/kotlin/content/skill/summoning/FamiliarUtilitySpecialEffectTest.kt index 4f130afad3..92c0550dbe 100644 --- a/game/src/test/kotlin/content/skill/summoning/FamiliarUtilitySpecialEffectTest.kt +++ b/game/src/test/kotlin/content/skill/summoning/FamiliarUtilitySpecialEffectTest.kt @@ -546,15 +546,21 @@ class FamiliarUtilitySpecialEffectTest : WorldTest() { } @Test - fun `The giant ent transmutes pure essence into a rune`() { + 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")) - assertTrue(player.inventory.count("earth_rune") + player.inventory.count("nature_rune") == 1, "the essence became a rune") + 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 From e2eed8df429b867329178ad38cf55a3661cc3556 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Sun, 5 Jul 2026 17:12:35 -0700 Subject: [PATCH 111/136] fix: Titan's Constitution over-heals 80 life points past maximum The heal was a max-capped restore (darkan's 10%) with a full-health refusal; per the live game it boosts life points by a flat 80 that can carry the owner above their maximum, alongside the 12.5% Defence boost, shared by the fire, ice and moss titans. levels.boost on Constitution does exactly this, capping at max+260 like other constitution boosts. --- .../content/skill/summoning/FamiliarBoostSpecials.kt | 10 +++------- .../kotlin/content/skill/summoning/familiar/Phoenix.kt | 2 +- .../skill/summoning/FamiliarCombatSpecialEffectTest.kt | 3 +-- .../summoning/FamiliarUtilitySpecialEffectTest.kt | 10 ++++++---- 4 files changed, 11 insertions(+), 14 deletions(-) diff --git a/game/src/main/kotlin/content/skill/summoning/FamiliarBoostSpecials.kt b/game/src/main/kotlin/content/skill/summoning/FamiliarBoostSpecials.kt index 8f9b547d1a..fe3eb9d4e0 100644 --- a/game/src/main/kotlin/content/skill/summoning/FamiliarBoostSpecials.kt +++ b/game/src/main/kotlin/content/skill/summoning/FamiliarBoostSpecials.kt @@ -59,17 +59,13 @@ class FamiliarBoostSpecials : Script { } } - // Elemental titans - Titan's Constitution: Defence +12.5% and heal a tenth of max life - // points, each titan with its own colours. Refuses, charging nothing, at full health. + // 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") { - if (levels.get(Skill.Constitution) >= levels.getMax(Skill.Constitution)) { - message("You're already at full life points!") - return@instant false - } familiarSelfSpecial(anim = "titans_constitution_$tier", sourceGfx = "titans_constitution_$tier", playerGfx = "titans_constitution_${tier}_owner") { levels.boost(Skill.Defence, multiplier = 0.125) - levels.restore(Skill.Constitution, multiplier = 0.1) + levels.boost(Skill.Constitution, 80) } } } diff --git a/game/src/main/kotlin/content/skill/summoning/familiar/Phoenix.kt b/game/src/main/kotlin/content/skill/summoning/familiar/Phoenix.kt index d10da54f93..41152c9a65 100644 --- a/game/src/main/kotlin/content/skill/summoning/familiar/Phoenix.kt +++ b/game/src/main/kotlin/content/skill/summoning/familiar/Phoenix.kt @@ -1,5 +1,6 @@ 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 @@ -17,7 +18,6 @@ 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 -import content.entity.combat.hit.hit /** How long the phoenix drops in place and glows before reappearing atop the ashes. */ private const val RISE_REBIRTH_DELAY = 3 diff --git a/game/src/test/kotlin/content/skill/summoning/FamiliarCombatSpecialEffectTest.kt b/game/src/test/kotlin/content/skill/summoning/FamiliarCombatSpecialEffectTest.kt index 722556d932..075b9efd53 100644 --- a/game/src/test/kotlin/content/skill/summoning/FamiliarCombatSpecialEffectTest.kt +++ b/game/src/test/kotlin/content/skill/summoning/FamiliarCombatSpecialEffectTest.kt @@ -8,11 +8,10 @@ import content.entity.effect.toxin.poisoned import content.entity.player.combat.special.specialAttackEnergy import itemOnNpc import org.junit.jupiter.api.Test -import world.gregs.voidps.engine.client.ui.InterfaceApi 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.combatLevel 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 diff --git a/game/src/test/kotlin/content/skill/summoning/FamiliarUtilitySpecialEffectTest.kt b/game/src/test/kotlin/content/skill/summoning/FamiliarUtilitySpecialEffectTest.kt index 92c0550dbe..0f35b3073b 100644 --- a/game/src/test/kotlin/content/skill/summoning/FamiliarUtilitySpecialEffectTest.kt +++ b/game/src/test/kotlin/content/skill/summoning/FamiliarUtilitySpecialEffectTest.kt @@ -113,7 +113,7 @@ class FamiliarUtilitySpecialEffectTest : WorldTest() { } @Test - fun `Titan's Constitution raises Defence and heals a tenth of max life points`() { + 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) @@ -123,14 +123,16 @@ class FamiliarUtilitySpecialEffectTest : WorldTest() { assertTrue(player.runSpecial("fire_titan_familiar")) assertTrue(player.levels.get(Skill.Defence) > defence, "Defence rises by an eighth") - assertEquals(lifePoints + 99, player.levels.get(Skill.Constitution), "a tenth of the 990 maximum") + assertEquals(lifePoints + 80, player.levels.get(Skill.Constitution)) } @Test - fun `Titan's Constitution refuses at full life points, charging nothing`() { + 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 - assertFalse(player.runSpecial("moss_titan_familiar")) + assertTrue(player.runSpecial("moss_titan_familiar")) + assertEquals(1070, player.levels.get(Skill.Constitution), "80 life points above the 990 maximum") } @Test From 8c3841f5cf2306aebe5fb00f2588ea8c979ff89d Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Sun, 5 Jul 2026 17:17:40 -0700 Subject: [PATCH 112/136] fix: Spike Shot stuns its target on impact Darkan's version is damage-only, but per the wiki the spirit dagannoth's spike 'attacks the target, stunning them' - the stun now lands with the projectile (5 ticks, like Bull Rush) rather than the hit alone. --- .../content/skill/summoning/FamiliarCombatSpecials.kt | 10 ++++++++-- .../skill/summoning/FamiliarCombatSpecialEffectTest.kt | 9 +++++++-- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/game/src/main/kotlin/content/skill/summoning/FamiliarCombatSpecials.kt b/game/src/main/kotlin/content/skill/summoning/FamiliarCombatSpecials.kt index ef5fc9618c..b65e03399d 100644 --- a/game/src/main/kotlin/content/skill/summoning/FamiliarCombatSpecials.kt +++ b/game/src/main/kotlin/content/skill/summoning/FamiliarCombatSpecials.kt @@ -47,6 +47,9 @@ 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 @@ -155,9 +158,12 @@ class FamiliarCombatSpecials : Script { 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. + // 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") + 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. diff --git a/game/src/test/kotlin/content/skill/summoning/FamiliarCombatSpecialEffectTest.kt b/game/src/test/kotlin/content/skill/summoning/FamiliarCombatSpecialEffectTest.kt index 075b9efd53..390a5740cb 100644 --- a/game/src/test/kotlin/content/skill/summoning/FamiliarCombatSpecialEffectTest.kt +++ b/game/src/test/kotlin/content/skill/summoning/FamiliarCombatSpecialEffectTest.kt @@ -78,16 +78,21 @@ class FamiliarCombatSpecialEffectTest : WorldTest() { } @Test - fun `Spike Shot lands a heavy single hit`() { + 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)) - tick(6) + 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 From 981bf920ea160e49948ff6bef5e93ff9942a5b6a Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Sun, 5 Jul 2026 17:30:18 -0700 Subject: [PATCH 113/136] fix: combat specials fire at the familiar's foe on a plain cast click Iron Within (and every other npc-target special) only worked by clicking the button and then clicking a target - a plain click silently armed targeting mode, reading as broken. As in darkan and the live game, a plain click now auto-fires the special at the familiar's current combat target, falling back to the owner's, with a message when neither is fighting; clicking a target directly still works through the approach hooks. --- .../skill/summoning/FamiliarSpecialMoves.kt | 31 +++++++++++++++---- .../FamiliarCombatSpecialEffectTest.kt | 18 +++++++++++ 2 files changed, 43 insertions(+), 6 deletions(-) diff --git a/game/src/main/kotlin/content/skill/summoning/FamiliarSpecialMoves.kt b/game/src/main/kotlin/content/skill/summoning/FamiliarSpecialMoves.kt index 0f43b88818..2429c856db 100644 --- a/game/src/main/kotlin/content/skill/summoning/FamiliarSpecialMoves.kt +++ b/game/src/main/kotlin/content/skill/summoning/FamiliarSpecialMoves.kt @@ -1,6 +1,8 @@ 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 @@ -75,9 +77,7 @@ class FamiliarSpecialMovesDispatch : Script { init { // Follower-details tab cast button. interfaceOption("*", "familiar_details:cast_*") { - val id = follower?.id ?: return@interfaceOption - val block = FamiliarSpecialMoves.instant[id] ?: return@interfaceOption - castFamiliarSpecial { block() } + castButton() } onNPCApproach("familiar_details:cast_*", "*") { (target) -> @@ -118,9 +118,7 @@ class FamiliarSpecialMovesDispatch : Script { // Minimap summoning orb "Cast " option - one `cast_` component per move. interfaceOption("*", "summoning_orb:cast_*") { - val id = follower?.id ?: return@interfaceOption - val block = FamiliarSpecialMoves.instant[id] ?: return@interfaceOption - castFamiliarSpecial { block() } + castButton() } onNPCApproach("summoning_orb:cast_*", "*") { (target) -> @@ -157,4 +155,25 @@ class FamiliarSpecialMovesDispatch : Script { 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/test/kotlin/content/skill/summoning/FamiliarCombatSpecialEffectTest.kt b/game/src/test/kotlin/content/skill/summoning/FamiliarCombatSpecialEffectTest.kt index 390a5740cb..796e785503 100644 --- a/game/src/test/kotlin/content/skill/summoning/FamiliarCombatSpecialEffectTest.kt +++ b/game/src/test/kotlin/content/skill/summoning/FamiliarCombatSpecialEffectTest.kt @@ -1,7 +1,9 @@ package content.skill.summoning import FakeRandom +import interfaceOption import WorldTest +import content.entity.combat.target import content.entity.effect.frozen import content.entity.effect.stunned import content.entity.effect.toxin.poisoned @@ -296,6 +298,22 @@ class FamiliarCombatSpecialEffectTest : WorldTest() { 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() From 323a04d01c526d9e8acb6296ee76949ea01f15d8 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Sun, 5 Jul 2026 17:33:52 -0700 Subject: [PATCH 114/136] fix: Steel of Legends' blade-storm graphic plays on the target alone The 1449 graphic showed on both the titan and its target; it belongs on the target tile only - the titan keeps just its attack animation. --- .../kotlin/content/skill/summoning/FamiliarCombatSpecials.kt | 3 +-- .../content/skill/summoning/FamiliarCombatSpecialEffectTest.kt | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/game/src/main/kotlin/content/skill/summoning/FamiliarCombatSpecials.kt b/game/src/main/kotlin/content/skill/summoning/FamiliarCombatSpecials.kt index b65e03399d..4daf9284a7 100644 --- a/game/src/main/kotlin/content/skill/summoning/FamiliarCombatSpecials.kt +++ b/game/src/main/kotlin/content/skill/summoning/FamiliarCombatSpecials.kt @@ -335,12 +335,11 @@ class FamiliarCombatSpecials : Script { } // Steel of Legends - the steel titan strikes four times: melee up close, ranged from afar, - // up to 244 each. + // 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") - follower?.gfx("steel_of_legends") target.gfx("steel_of_legends") } cast diff --git a/game/src/test/kotlin/content/skill/summoning/FamiliarCombatSpecialEffectTest.kt b/game/src/test/kotlin/content/skill/summoning/FamiliarCombatSpecialEffectTest.kt index 796e785503..958780589b 100644 --- a/game/src/test/kotlin/content/skill/summoning/FamiliarCombatSpecialEffectTest.kt +++ b/game/src/test/kotlin/content/skill/summoning/FamiliarCombatSpecialEffectTest.kt @@ -1,13 +1,13 @@ package content.skill.summoning import FakeRandom -import interfaceOption 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 From 9f069280494e701b362eff619204455debc5a743 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Sun, 5 Jul 2026 17:35:58 -0700 Subject: [PATCH 115/136] fix: pack yak attack and defend animations The yak used darkan's 853/851 - another wrong-model set from its familiar combatdefs. 2009scape's npc config gives the pack yak's own cluster: attack 5782, defend 5783. --- data/skill/summoning/summoning_combat.anims.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/data/skill/summoning/summoning_combat.anims.toml b/data/skill/summoning/summoning_combat.anims.toml index b0de3867d1..6940a27305 100644 --- a/data/skill/summoning/summoning_combat.anims.toml +++ b/data/skill/summoning/summoning_combat.anims.toml @@ -557,10 +557,10 @@ id = 8304 id = 8305 [pack_yak_familiar_attack] -id = 853 +id = 5782 [pack_yak_familiar_defend] -id = 851 +id = 5783 [pack_yak_familiar_death] id = 852 From e4df9276cf0ca19f631acb2f0d6d45de5af85ae6 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Sun, 5 Jul 2026 17:47:09 -0700 Subject: [PATCH 116/136] chore: strip narration comments from the special toml files, remove dead code The per-entry 'X's Y animation' comments in summoning_special.anims/gfx said nothing the entry names don't; only the load-bearing notes remain (suffix conventions, the projectile speed standard, Bull Rush's constant-flight exception, the Toad Bark arc/cache-rotation note, and Herbcall's tick maths). Dead code: dropForage (Fruitfall and Fish Rain place their drops per tile themselves now) and familiarBoostSkill (never called - the boost specials use their own helper), plus unused imports and commandFamiliarAttack's stale PvP-area-only doc. --- .../summoning/summoning_special.anims.toml | 32 +----------- .../summoning/summoning_special.gfx.toml | 51 +------------------ .../content/skill/summoning/FamiliarCombat.kt | 9 ++-- .../skill/summoning/FamiliarCombatSpecials.kt | 4 -- .../skill/summoning/FamiliarSpecialHelpers.kt | 9 ---- .../summoning/FamiliarUtilitySpecials.kt | 10 +--- .../skill/summoning/familiar/Phoenix.kt | 5 -- 7 files changed, 8 insertions(+), 112 deletions(-) diff --git a/data/skill/summoning/summoning_special.anims.toml b/data/skill/summoning/summoning_special.anims.toml index 4a7e18c7f3..b0cb015a59 100644 --- a/data/skill/summoning/summoning_special.anims.toml +++ b/data/skill/summoning/summoning_special.anims.toml @@ -85,15 +85,13 @@ id = 8020 [insane_ferocity] id = 7928 -# Player animation played while casting a special-move scroll (2009scape base visualizeSpecialMove). +# Player animation played while casting a special-move scroll. [summoning_special_cast] id = 7660 -# Albino rat's Cheese Feast animation (played on the rat). [cheese_feast] id = 14858 -# Compost mound's Generate Compost animation (played on the familiar). [generate_compost] id = 7775 @@ -103,115 +101,90 @@ id = 7775 id = 8013 ticks = 9 -# Giant chinchompa's Explode animation (played on the familiar). [chinchompa_explode] id = 7758 -# Beaver's Multichop animation (played on the familiar as it chops the tree). [beaver_multichop] id = 7722 -# Call to Arms (Void familiars): the owner's teleport-out and teleport-in animations. [call_to_arms] id = 8136 [call_to_arms_land] id = 8137 -# Karamthulhu overlord's Doomsphere attack animation. [doomsphere] id = 7974 -# Spirit dagannoth's Spike Shot attack animation. [spike_shot] id = 7787 -# Swamp titan's Swamp Plague attack animation. [swamp_plague] id = 8223 -# Lava titan's Ebon Thunder attack animation. [ebon_thunder] id = 7986 -# Praying mantis' Mantis Strike attack animation. [mantis_strike] id = 8071 -# Giant ent's Acorn Missile attack animation. [acorn_missile] id = 7858 -# Ravenous locust's Famine attack animation. [famine] id = 7998 -# Smoke devil's Dust Cloud attack animation. [dust_cloud] id = 7820 -# Iron/steel titan's charge-up animation (shared swing, different graphics). [iron_within] id = 7954 [steel_of_legends] id = 8190 -# Hydra's Regrowth animation (played on the familiar as it revives the tree). [regrowth] id = 7945 -# Bunyip's Swallow Whole animation (played on the familiar as it gulps the fish). [swallow_whole] id = 7747 -# Spirit cobra's Ophidian Incubation animation (played on the familiar). [ophidian_incubation] id = 8159 -# Pyrelord's Immense Heat animation (played on the owner as the pyrelord heats the gold). [immense_heat] id = 8081 -# Barker toad Toad Bark: the firing animation, and the load animation as a cannonball goes in. [toad_bark] id = 7703 [toad_bark_load] id = 7704 -# Bloated leech's Blood Drain animation (played on the familiar as it draws blood). [blood_drain] id = 7715 -# Obsidian golem's Volcanic Strength animation (played on the familiar). [volcanic_strength] id = 8053 -# Spirit graahk's Goad goring animation. [goad] id = 7915 -# Spirit kyatt's Ambush pounce animation (played as it lands on the target). [ambush] id = 7914 -# Ibis' Fish Rain animation. [fish_rain] id = 8199 -# Fruit bat's Fruitfall: the flight up into the trees, then the swooping drop (2009scape's pair). [fruitfall_ascend] id = 8320 [fruitfall] id = 8321 -# Abyssal titan's Essence Shipment animation. [essence_shipment] id = 7698 -# Elemental titans' Titan's Constitution animations, one per titan. [titans_constitution_fire] id = 7835 @@ -221,14 +194,11 @@ id = 7837 [titans_constitution_moss] id = 7837 -# Forge regent's Inferno flare animation. [inferno] id = 7871 -# Bunyip's raw-fish transmute: the owner's casting motion as the fish becomes water runes. [bunyip_transmute] id = 2779 -# Spirit dagannoth's Ferocious double-strike animation. [ferocious] id = 7791 diff --git a/data/skill/summoning/summoning_special.gfx.toml b/data/skill/summoning/summoning_special.gfx.toml index 94426888f7..0cdb6aec13 100644 --- a/data/skill/summoning/summoning_special.gfx.toml +++ b/data/skill/summoning/summoning_special.gfx.toml @@ -90,8 +90,6 @@ delay = 30 time_offset = 10 multiplier = 10 -# Evil turnip Evil Flames: source gfx on the familiar as it breathes, the projectile, and the impact. - [evil_flames] id = 1328 @@ -209,11 +207,10 @@ id = 1336 [insane_ferocity] id = 1397 -# Insane Ferocity graphic played on the owner (the honey badger's enrage flourish). [insane_ferocity_owner] id = 1399 -# Player graphic played while casting a special-move scroll (2009scape base visualizeSpecialMove). +# Player graphic played while casting a special-move scroll. [summoning_special_cast] id = 1316 @@ -221,11 +218,9 @@ id = 1316 [cheese_feast] id = 1384 -# Macaw's Herbcall graphic (played on the familiar as it searches out a herb). [herbcall] id = 1321 -# Compost mound Generate Compost: familiar gfx, projectile to the bin, and the impact at the bin. [generate_compost] id = 1461 @@ -235,22 +230,18 @@ id = 1462 [generate_compost_bin] id = 1460 -# Giant chinchompa Explode: the blast graphic played on the familiar. [chinchompa_explode] id = 1364 -# Beaver Multichop: the graphic played on the familiar as it chops. [beaver_multichop] id = 1459 -# Call to Arms (Void familiars): teleport-out and teleport-in graphics on the owner. [call_to_arms] id = 1503 [call_to_arms_land] id = 1502 -# Karamthulhu overlord Doomsphere: familiar gfx, water-orb projectile, and the impact splash. [doomsphere] id = 1478 @@ -264,7 +255,6 @@ curve = 0 [doomsphere_hit] id = 1480 -# Spirit dagannoth Spike Shot: the launched spike and its impact. [spike_shot_proj] id = 1426 delay = 30 @@ -275,7 +265,6 @@ curve = 0 [spike_shot_hit] id = 1428 -# Swamp titan Swamp Plague: the bog projectile. [swamp_plague_proj] id = 1462 delay = 30 @@ -283,8 +272,6 @@ time_offset = 10 multiplier = 10 curve = 0 -# Lava titan Ebon Thunder: familiar gfx, the dark bolt, and the impact. - [ebon_thunder] id = 1492 @@ -298,7 +285,6 @@ curve = 0 [ebon_thunder_hit] id = 1494 -# Praying mantis Mantis Strike: familiar gfx, the bolt, and the binding impact. [mantis_strike] id = 1379 @@ -312,7 +298,6 @@ curve = 0 [mantis_strike_hit] id = 1381 -# Giant ent Acorn Missile: the lobbed acorns and their impact. [acorn_missile_proj] id = 1362 delay = 30 @@ -323,7 +308,6 @@ curve = 0 [acorn_missile_hit] id = 1363 -# Ravenous locust Famine: familiar gfx, the swarm projectile, and the impact. [famine] id = 1346 @@ -337,14 +321,12 @@ curve = 0 [famine_hit] id = 1348 -# Forge regent Inferno: the flare on the familiar and the burst on the disarmed player. [inferno] id = 1394 [inferno_hit] id = 1393 -# Smoke devil Dust Cloud: familiar gfx, the dust burst, and the choking impact. [dust_cloud] id = 1375 @@ -358,37 +340,27 @@ curve = 0 [dust_cloud_hit] id = 1377 -# Iron Within / Steel of Legends: the charge-up glow on each titan. [iron_within] id = 1450 [steel_of_legends] id = 1449 -# Hydra Regrowth: the burst of life on the familiar. [regrowth] id = 1487 -# Bunyip Swallow Whole: the gulp graphic on the familiar. [swallow_whole] id = 1481 -# Spirit cobra Ophidian Incubation: the transmuting gaze on the familiar. [ophidian_incubation] id = 1388 -# Pyrelord Immense Heat: the flare on the owner as the gold melts. [immense_heat] id = 1463 -# Barker toad Toad Bark: the load graphic and the fired cannonball. [toad_bark_load] id = 1400 -# The cannonball launches low from the toad's mouth and rises slightly (darkan's 10 -> 16 arc, -# curve 16) - character heights default to 40, hence the negative offsets. The 1402 model faces -# backwards in this revision's cache; FixGraphics patches its definition with a 180 degree -# rotation (there is no projectile rotation in the protocol - the model itself is turned). [toad_bark_proj] id = 1402 delay = 30 @@ -398,12 +370,9 @@ curve = 16 height = -30 end_height = -24 -# Thieving Fingers: the sparkle played on the owner as the boost lands. - [thieving_fingers_owner] id = 1300 -# Blood Drain: the leech's gfx, the blood drawn to the owner, and the burst on the owner. [blood_drain] id = 1419 @@ -417,47 +386,36 @@ curve = 0 [blood_drain_owner] id = 1420 -# Tireless Run: the sparkle played on the owner as the boost lands (the 1521 gfx is the terrorbird's). [tireless_run_owner] id = 1300 -# Dissolve: the jelly's own graphic and the melting impact. [dissolve] id = 1361 [dissolve_hit] id = 1360 -# Rending: the impact graphic on the target. [rending_hit] id = 1372 -# Abyssal Stealth: the lurker's graphic and the owner's shroud. [abyssal_stealth] id = 1339 [abyssal_stealth_owner] id = 1302 -# Volcanic Strength is defined above (1465) - played on the familiar. - -# Goad has no graphic; Ambush: the kyatt's pounce-landing graphic. [ambush] id = 1366 -# Fish Rain: the splash on the ibis. [fish_rain] id = 1337 -# Essence Shipment: the flourish on the abyssal titan. [essence_shipment] id = 1457 -# Winter Storage: the flourish on the pack yak as it banks an item. [winter_storage] id = 1358 -# Titan's Constitution: each titan's own graphic, plus the owner's tint. [titans_constitution_fire] id = 1514 @@ -476,11 +434,9 @@ id = 1513 [titans_constitution_moss_owner] id = 1308 -# Healing Aura: the sparkle played on the owner (the 1356 gfx is the unicorn's). [healing_aura_owner] id = 1300 -# Bunyip's raw-fish transmute: the watery bolt from the bunyip to its owner. [bunyip_transmute_proj] id = 1435 delay = 30 @@ -488,19 +444,14 @@ time_offset = 10 multiplier = 10 curve = 0 -# Spirit dagannoth's Ferocious double-strike graphic. - [ferocious] id = 1427 -# Testudo: the shell-shield tint played on the owner as the Defence boost lands. [testudo_owner] id = 1308 -# Fruitfall: the splash where each shaken-loose fruit lands. [fruitfall_land] id = 1331 -# Fruitfall: the leaves swirling around the bat as it flies up into the trees. [fruitfall_ascend] id = 1332 diff --git a/game/src/main/kotlin/content/skill/summoning/FamiliarCombat.kt b/game/src/main/kotlin/content/skill/summoning/FamiliarCombat.kt index 58d9d37037..aa987aa703 100644 --- a/game/src/main/kotlin/content/skill/summoning/FamiliarCombat.kt +++ b/game/src/main/kotlin/content/skill/summoning/FamiliarCombat.kt @@ -27,10 +27,11 @@ fun NPC.canFight(): Boolean { } /** - * Directs the player's familiar to attack [target]. A familiar may only attack players in PvP - * areas; 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). + * 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 diff --git a/game/src/main/kotlin/content/skill/summoning/FamiliarCombatSpecials.kt b/game/src/main/kotlin/content/skill/summoning/FamiliarCombatSpecials.kt index 4daf9284a7..6414dfcd57 100644 --- a/game/src/main/kotlin/content/skill/summoning/FamiliarCombatSpecials.kt +++ b/game/src/main/kotlin/content/skill/summoning/FamiliarCombatSpecials.kt @@ -21,7 +21,6 @@ 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.get import world.gregs.voidps.engine.inv.equipment import world.gregs.voidps.engine.inv.inventory import world.gregs.voidps.engine.inv.move @@ -313,9 +312,6 @@ class FamiliarCombatSpecials : Script { familiarAoeSpecial(maxTargets = 6, maxHit = 200, radius = 6, anim = "sandstorm", sourceGfx = "sandstorm", projectile = "sandstorm_proj") } - // Explode - the giant chinchompa detonates, hitting everything around it, then is consumed by - // the blast. Fired from the cast button, or (like 2009scape) auto-triggered on ~1/10 of the - // familiar's own attacks - see [autoExplode]. FamiliarSpecialMoves.instant("giant_chinchompa_familiar") { chinchompaExplode() } diff --git a/game/src/main/kotlin/content/skill/summoning/FamiliarSpecialHelpers.kt b/game/src/main/kotlin/content/skill/summoning/FamiliarSpecialHelpers.kt index c8cc09dcb8..ba4dd03278 100644 --- a/game/src/main/kotlin/content/skill/summoning/FamiliarSpecialHelpers.kt +++ b/game/src/main/kotlin/content/skill/summoning/FamiliarSpecialHelpers.kt @@ -11,7 +11,6 @@ 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.character.player.skill.Skill import world.gregs.voidps.engine.entity.distanceTo import world.gregs.voidps.engine.map.spiral import world.gregs.voidps.engine.queue.queue @@ -185,11 +184,3 @@ fun Player.familiarSelfSpecial(anim: String? = null, sourceGfx: String? = null, return true } -/** - * Boosts [skill] by [amount] above the player's current max, not stacking past it - the common - * shape of the stat-boost specials (Stony Shell, Testudo, Magic Focus, ...). Returns true. - */ -fun Player.familiarBoostSkill(skill: Skill, amount: Int): Boolean { - levels.boost(skill, amount) - return true -} diff --git a/game/src/main/kotlin/content/skill/summoning/FamiliarUtilitySpecials.kt b/game/src/main/kotlin/content/skill/summoning/FamiliarUtilitySpecials.kt index 803acef0de..246c116a77 100644 --- a/game/src/main/kotlin/content/skill/summoning/FamiliarUtilitySpecials.kt +++ b/game/src/main/kotlin/content/skill/summoning/FamiliarUtilitySpecials.kt @@ -47,8 +47,6 @@ private const val FRUITFALL_LAND_DELAY = 4 */ class FamiliarUtilitySpecials : Script { - // Herbcall's herb pool, mirroring 2009scape's MacawNPC.HERBS - a uniform pick from every herb - // type, low- and high-level alike (grimy, as the macaw digs them up). private val herbcallHerbs = listOf( "grimy_guam", "grimy_marrentill", @@ -203,8 +201,7 @@ class FamiliarUtilitySpecials : Script { } // 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. As in - // 2009scape it has a one-minute cooldown of its own on top of the usual scroll/point cost. + // 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")) { @@ -305,11 +302,6 @@ class FamiliarUtilitySpecials : Script { } } - /** Drops [id] on the player's tile (it despawns after ~3 minutes), counting as a successful cast. */ - private fun Player.dropForage(id: String): Boolean { - FloorItems.add(tile, id, disappearTicks = 300, owner = this) - return true - } /** Moves every [id] from the inventory to the bank, returning how many were moved. */ private fun Player.bankAll(id: String): Int { diff --git a/game/src/main/kotlin/content/skill/summoning/familiar/Phoenix.kt b/game/src/main/kotlin/content/skill/summoning/familiar/Phoenix.kt index 41152c9a65..1b1f3537bb 100644 --- a/game/src/main/kotlin/content/skill/summoning/familiar/Phoenix.kt +++ b/game/src/main/kotlin/content/skill/summoning/familiar/Phoenix.kt @@ -36,11 +36,6 @@ class Phoenix : Script { false } - // Rise from the Ashes - cast on ashes lying on the ground: the phoenix drops in place and - // glows, then is reborn atop the ashes at full life points, scorching up to five foes - // beside them. The blast draws its strength from the phoenix's wounds - the closer to - // death it was, the harder the rebirth hits (unhurt, it is a pure rebirth). Neither - // reference implements this (darkan's is a TODO stub); mechanics per the live game. FamiliarSpecialMoves.floorItem("phoenix_familiar") { ashes -> if (ashes.id != "ashes") { message("The phoenix can only rise from ashes.") From faecc2506aba00056e222a7c974ffb46741a446b Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Sun, 5 Jul 2026 18:04:03 -0700 Subject: [PATCH 117/136] test: wrap InterfaceApi.onItem calls in runTest - it is suspend on main --- .../summoning/FamiliarUtilitySpecialEffectTest.kt | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/game/src/test/kotlin/content/skill/summoning/FamiliarUtilitySpecialEffectTest.kt b/game/src/test/kotlin/content/skill/summoning/FamiliarUtilitySpecialEffectTest.kt index 0f35b3073b..9a9e42f5ee 100644 --- a/game/src/test/kotlin/content/skill/summoning/FamiliarUtilitySpecialEffectTest.kt +++ b/game/src/test/kotlin/content/skill/summoning/FamiliarUtilitySpecialEffectTest.kt @@ -13,6 +13,7 @@ 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 @@ -247,7 +248,7 @@ class FamiliarUtilitySpecialEffectTest : WorldTest() { } val before = player.levels.get(Skill.Constitution) - InterfaceApi.onItem(player, "familiar_details:cast_swallow_whole", player.inventory[1]) + 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") @@ -265,7 +266,7 @@ class FamiliarUtilitySpecialEffectTest : WorldTest() { add("raw_shark", 1) } - InterfaceApi.onItem(player, "familiar_details:cast_swallow_whole", player.inventory[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") @@ -443,7 +444,7 @@ class FamiliarUtilitySpecialEffectTest : WorldTest() { add("bronze_sword", 1) } - InterfaceApi.onItem(player, "familiar_details:cast_winter_storage", player.inventory[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") @@ -456,7 +457,7 @@ class FamiliarUtilitySpecialEffectTest : WorldTest() { val player = summon("pack_yak_familiar") player.inventory.transaction { add("winter_storage_scroll", 1) } - InterfaceApi.onItem(player, "familiar_details:cast_winter_storage", player.inventory[0]) + 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") @@ -471,7 +472,7 @@ class FamiliarUtilitySpecialEffectTest : WorldTest() { add("gold_bar", 1) } - InterfaceApi.onItem(player, "familiar_details:cast_immense_heat", player.inventory[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") From 59a33087b3aebc6f954ad5d0fe89c836f38499d8 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Sun, 5 Jul 2026 18:15:41 -0700 Subject: [PATCH 118/136] chore: adjust npc spawn for pikkupstix. --- data/area/asgarnia/taverley/taverley.npc-spawns.toml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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 }, From 244809d1995bdbe87e5d49f3936648b7fbaafcae Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Sun, 5 Jul 2026 18:50:45 -0700 Subject: [PATCH 119/136] feat: Pikkupstix tutor dialogue and enchanted headgear scroll storage Pikkupstix gains his post-Wolf-Whistle Talk-to dialogue (2009scape transcript: the summoning overview, supplies shop, skillcape purchase) and an Enchant option. Enchanted headgear (from darkan's EnchantedHeadwear) stores combat scrolls: use combat scrolls on a hunter helm to charge it (antlers 40, lizard skull 65, feather headdress 150, one scroll type at a time), empty it back on a summoning obelisk, and a worn charged helm feeds a scroll to a special move when the pack has none. Only combat scrolls store (checked via the scroll -> pouch -> familiar -> npcTarget lookup); non-combat scrolls like Healing Aura are refused. For void's hunter helms the plain form is already the enchanted one (darkan's base==enchanted), so charging is the only visible state change. --- .../area/asgarnia/taverley/Pikkupstix.kt | 125 +++++++++++++++ .../skill/summoning/EnchantedHeadgear.kt | 151 ++++++++++++++++++ .../content/skill/summoning/Summoning.kt | 10 +- .../area/asgarnia/taverley/PikkupstixTest.kt | 33 ++++ .../skill/summoning/EnchantedHeadgearTest.kt | 101 ++++++++++++ 5 files changed, 418 insertions(+), 2 deletions(-) create mode 100644 game/src/main/kotlin/content/area/asgarnia/taverley/Pikkupstix.kt create mode 100644 game/src/main/kotlin/content/skill/summoning/EnchantedHeadgear.kt create mode 100644 game/src/test/kotlin/content/area/asgarnia/taverley/PikkupstixTest.kt create mode 100644 game/src/test/kotlin/content/skill/summoning/EnchantedHeadgearTest.kt 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..9fbe85bd63 --- /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 (2009scape's transcript). 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 will enchant it to hold Summoning scrolls, free of charge. Just use it on me, or fill it with scrolls yourself.") + } + 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/skill/summoning/EnchantedHeadgear.kt b/game/src/main/kotlin/content/skill/summoning/EnchantedHeadgear.kt new file mode 100644 index 0000000000..1c5f88f7e4 --- /dev/null +++ b/game/src/main/kotlin/content/skill/summoning/EnchantedHeadgear.kt @@ -0,0 +1,151 @@ +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.has +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.network.login.protocol.visual.update.player.EquipSlot + +/** + * A helm that can be enchanted to hold combat Summoning scrolls: its plain, tradeable form ([base]), + * the charged form it takes while holding scrolls ([charged]), the [level] to enchant it and the + * [capacity] of scrolls it holds. For void's hunter helms the plain form is already the enchanted + * one (matching darkan's base==enchanted for these), so charging is the only visible state change. + */ +private data class Headgear(val base: String, val charged: String, val level: Int, val capacity: Int) + +private val HEADGEAR = listOf( + Headgear("antlers", "antlers_charged", 10, 40), + Headgear("lizard_skull", "lizard_skull_charged", 30, 65), + Headgear("feather_headdress_blue", "feather_headdress_charged_blue", 50, 150), + Headgear("feather_headdress_yellow", "feather_headdress_charged_yellow", 50, 150), + Headgear("feather_headdress_red", "feather_headdress_charged_red", 50, 150), + Headgear("feather_headdress_stripy", "feather_headdress_charged_stripy", 50, 150), + Headgear("feather_headdress_orange", "feather_headdress_charged_orange", 50, 150), +).flatMap { listOf(it.base 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 (or unenchants) a piece of headwear. For the hunter helms void carries the + * plain form already holds scrolls, so there's nothing to convert - he says as much. Charged helms + * must be emptied first. Returns true if [item] was a recognised piece of headgear. + */ +fun Player.enchantHeadgear(item: Item): Boolean { + val headgear = HEADGEAR[item.id] ?: return false + when (item.id) { + headgear.charged -> message("You need to remove the scrolls before I can work on that helmet.") + else -> message("That helmet is already able to hold Summoning scrolls - just use your combat scrolls on it to fill it.") + } + 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 (HEADGEAR[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 plain form when the last goes. */ +fun Player.spendEnchantedHeadgearScroll() { + val count = get(SCROLL_COUNT, 0) - 1 + set(SCROLL_COUNT, count) + if (count <= 0) { + val helm = equipped(EquipSlot.Hat).id + HEADGEAR[helm]?.let { equipment.replace(EquipSlot.Hat.index, it.charged, it.base) } + clear(SCROLL_ID) + clear(SCROLL_COUNT) + } +} + +class EnchantedHeadgear : Script { + init { + // Fill a helm by using combat scrolls on it - all matching scrolls in the pack, up to the + // helm's capacity. A helm holds one scroll type at a time; the plain helm becomes its + // charged form. + for (headgear in HEADGEAR.values.distinct()) { + itemOnItem("*_scroll", headgear.base) { fromItem, toItem -> + store(if (fromItem.id == headgear.base) toItem else fromItem, headgear) + } + // Empty a charged helm back to its plain form on a summoning obelisk; scrolls return. + itemOnObjectOperate(headgear.charged, "obelisk") { + withdraw(headgear) + } + } + } + + private fun Player.store(scroll: Item, headgear: Headgear) { + if (!isCombatScroll(scroll)) { + message("Only combat scrolls can be stored in headgear.") + return + } + if (!has(Skill.Summoning, headgear.level, message = true)) { + 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) + inventory.replace(headgear.base, headgear.charged) + 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.withdraw(headgear: Headgear) { + val scroll = get(SCROLL_ID, "") + val count = get(SCROLL_COUNT, 0) + if (scroll.isEmpty() || count <= 0) { + inventory.replace(headgear.charged, headgear.base) + clear(SCROLL_ID) + clear(SCROLL_COUNT) + return + } + inventory.add(scroll, count) + inventory.replace(headgear.charged, headgear.base) + clear(SCROLL_ID) + clear(SCROLL_COUNT) + message("You empty the helmet, recovering $count ${ItemDefinitions.get(scroll).name.lowercase()}${if (count == 1) "" else "s"}.", ChatType.Filter) + } +} diff --git a/game/src/main/kotlin/content/skill/summoning/Summoning.kt b/game/src/main/kotlin/content/skill/summoning/Summoning.kt index feaf14921a..78ed98042e 100644 --- a/game/src/main/kotlin/content/skill/summoning/Summoning.kt +++ b/game/src/main/kotlin/content/skill/summoning/Summoning.kt @@ -274,7 +274,9 @@ fun Player.castFamiliarSpecial(effect: () -> Boolean) { message("Your familiar does not have enough special move points left.") return } - if (!inventory.contains(scrollDef.stringId)) { + // 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 } @@ -292,7 +294,11 @@ fun Player.castFamiliarSpecial(effect: () -> Boolean) { anim("summoning_special_cast") gfx("summoning_special_cast") sound("summoning_special_cast") - inventory.remove(scrollDef.stringId, 1) + 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]) 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/skill/summoning/EnchantedHeadgearTest.kt b/game/src/test/kotlin/content/skill/summoning/EnchantedHeadgearTest.kt new file mode 100644 index 0000000000..d405c0bfd5 --- /dev/null +++ b/game/src/test/kotlin/content/skill/summoning/EnchantedHeadgearTest.kt @@ -0,0 +1,101 @@ +package content.skill.summoning + +import WorldTest +import itemOnItem +import itemOnObject +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.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.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) + + assertEquals(1, player.inventory.count("antlers_charged"), "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 `A charged helm emptied on an obelisk returns its scrolls`() { + val player = summoner(Tile(2528, 3095)) // by the Taverley obelisk region + val obelisk = createObject("obelisk", player.tile.addX(1)) + player.inventory.transaction { + add("antlers", 1) + add("iron_bull_rush_scroll", 15) + } + player.itemOnItem(1, 0) + tick(1) + + player.itemOnObject(obelisk, 0) + tick(1) + + assertEquals(15, player.inventory.count("iron_bull_rush_scroll"), "the scrolls come back") + assertEquals(1, player.inventory.count("antlers"), "the helm reverts to plain") + assertEquals(0, player.inventory.count("antlers_charged")) + } + + @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") + } +} From 23a18492cb00e9e00f569908084d7de7f0d825d1 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Sun, 5 Jul 2026 19:14:03 -0700 Subject: [PATCH 120/136] feat: enchant metal/god helms for scroll storage; Commune and Uncharge options Extends enchanted headgear from the three hunter helms to darkan's full set - snakeskin bandana, archer/berserker/warrior/farseer/rune/splitbark helms, helm of Neitiznot, dragon med helm, lunar helm and armadyl helmet - each with its Summoning level and capacity. Unlike the hunter helms (whose plain form is already scroll-capable) these have the real three -state flow: Pikkupstix enchants the plain helm into its empty (e) form, scrolls charge it, and it disenchants back. The (e) middle-state items already exist in the cache; added their item defs. The charged helms already carry Commune and Uncharge in their cache option arrays but had no handlers, so the options did nothing. Commune now reports the stored scrolls and Uncharge empties them back to the pack (reverting to the enchanted form) - replacing the obelisk-empty stopgap. --- data/skill/summoning/summoning.items.toml | 79 ++++++++++++ .../area/asgarnia/taverley/Pikkupstix.kt | 2 +- .../skill/summoning/EnchantedHeadgear.kt | 122 ++++++++++++------ .../skill/summoning/EnchantedHeadgearTest.kt | 48 ++++++- 4 files changed, 200 insertions(+), 51 deletions(-) diff --git a/data/skill/summoning/summoning.items.toml b/data/skill/summoning/summoning.items.toml index 767df4a2ea..450245e901 100644 --- a/data/skill/summoning/summoning.items.toml +++ b/data/skill/summoning/summoning.items.toml @@ -4467,3 +4467,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/game/src/main/kotlin/content/area/asgarnia/taverley/Pikkupstix.kt b/game/src/main/kotlin/content/area/asgarnia/taverley/Pikkupstix.kt index 9fbe85bd63..3673170e02 100644 --- a/game/src/main/kotlin/content/area/asgarnia/taverley/Pikkupstix.kt +++ b/game/src/main/kotlin/content/area/asgarnia/taverley/Pikkupstix.kt @@ -34,7 +34,7 @@ class Pikkupstix : Script { // 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 will enchant it to hold Summoning scrolls, free of charge. Just use it on me, or fill it with scrolls yourself.") + 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) diff --git a/game/src/main/kotlin/content/skill/summoning/EnchantedHeadgear.kt b/game/src/main/kotlin/content/skill/summoning/EnchantedHeadgear.kt index 1c5f88f7e4..f81f12e578 100644 --- a/game/src/main/kotlin/content/skill/summoning/EnchantedHeadgear.kt +++ b/game/src/main/kotlin/content/skill/summoning/EnchantedHeadgear.kt @@ -20,21 +20,37 @@ 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 charged form it takes while holding scrolls ([charged]), the [level] to enchant it and the - * [capacity] of scrolls it holds. For void's hunter helms the plain form is already the enchanted - * one (matching darkan's base==enchanted for these), so charging is the only visible state change. + * 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 charged: String, val level: Int, val capacity: Int) +private data class Headgear(val base: String, val enchanted: String, val charged: String, val level: Int, val capacity: Int) private val HEADGEAR = listOf( - Headgear("antlers", "antlers_charged", 10, 40), - Headgear("lizard_skull", "lizard_skull_charged", 30, 65), - Headgear("feather_headdress_blue", "feather_headdress_charged_blue", 50, 150), - Headgear("feather_headdress_yellow", "feather_headdress_charged_yellow", 50, 150), - Headgear("feather_headdress_red", "feather_headdress_charged_red", 50, 150), - Headgear("feather_headdress_stripy", "feather_headdress_charged_stripy", 50, 150), - Headgear("feather_headdress_orange", "feather_headdress_charged_orange", 50, 150), -).flatMap { listOf(it.base to it, it.charged to it) }.toMap() + // 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" @@ -52,15 +68,26 @@ private fun isCombatScroll(scroll: Item): Boolean { } /** - * Pikkupstix enchants (or unenchants) a piece of headwear. For the hunter helms void carries the - * plain form already holds scrolls, so there's nothing to convert - he says as much. Charged helms - * must be emptied first. Returns true if [item] was a recognised piece of headgear. + * 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 = HEADGEAR[item.id] ?: return false + 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.") - else -> message("That helmet is already able to hold Summoning scrolls - just use your combat scrolls on it to fill it.") + 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 (!has(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 } @@ -71,19 +98,19 @@ fun Player.enchantHeadgear(item: Item): Boolean { */ fun Player.enchantedHeadgearScroll(): String? { val helm = equipped(EquipSlot.Hat).id - if (HEADGEAR[helm]?.charged != helm || get(SCROLL_COUNT, 0) <= 0) { + 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 plain form when the last goes. */ +/** 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) if (count <= 0) { val helm = equipped(EquipSlot.Hat).id - HEADGEAR[helm]?.let { equipment.replace(EquipSlot.Hat.index, it.charged, it.base) } + BY_ID[helm]?.let { equipment.replace(EquipSlot.Hat.index, it.charged, it.enchanted) } clear(SCROLL_ID) clear(SCROLL_COUNT) } @@ -91,17 +118,18 @@ fun Player.spendEnchantedHeadgearScroll() { class EnchantedHeadgear : Script { init { - // Fill a helm by using combat scrolls on it - all matching scrolls in the pack, up to the - // helm's capacity. A helm holds one scroll type at a time; the plain helm becomes its - // charged form. - for (headgear in HEADGEAR.values.distinct()) { - itemOnItem("*_scroll", headgear.base) { fromItem, toItem -> - store(if (fromItem.id == headgear.base) toItem else fromItem, headgear) - } - // Empty a charged helm back to its plain form on a summoning obelisk; scrolls return. - itemOnObjectOperate(headgear.charged, "obelisk") { - withdraw(headgear) + 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. + itemOnItem("*_scroll", headgear.enchanted) { fromItem, toItem -> + store(if (fromItem.id == headgear.enchanted) 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) } } } @@ -110,9 +138,6 @@ class EnchantedHeadgear : Script { message("Only combat scrolls can be stored in headgear.") return } - if (!has(Skill.Summoning, headgear.level, message = true)) { - 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.") @@ -129,23 +154,34 @@ class EnchantedHeadgear : Script { } set(SCROLL_ID, scroll.id) set(SCROLL_COUNT, stored + amount) - inventory.replace(headgear.base, headgear.charged) + inventory.replace(headgear.enchanted, headgear.charged) 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.withdraw(headgear: Headgear) { - val scroll = get(SCROLL_ID, "") + private fun Player.commune() { val count = get(SCROLL_COUNT, 0) - if (scroll.isEmpty() || count <= 0) { - inventory.replace(headgear.charged, headgear.base) - clear(SCROLL_ID) - clear(SCROLL_COUNT) + val scroll = get(SCROLL_ID, "") + if (count <= 0 || scroll.isEmpty()) { + message("The helmet holds no scrolls.") return } - inventory.add(scroll, count) - inventory.replace(headgear.charged, headgear.base) + 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) + } + // The charged helm reverts to its empty enchanted form whether or not it held anything. + 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) - message("You empty the helmet, recovering $count ${ItemDefinitions.get(scroll).name.lowercase()}${if (count == 1) "" else "s"}.", ChatType.Filter) } } diff --git a/game/src/test/kotlin/content/skill/summoning/EnchantedHeadgearTest.kt b/game/src/test/kotlin/content/skill/summoning/EnchantedHeadgearTest.kt index d405c0bfd5..76e0ec05a2 100644 --- a/game/src/test/kotlin/content/skill/summoning/EnchantedHeadgearTest.kt +++ b/game/src/test/kotlin/content/skill/summoning/EnchantedHeadgearTest.kt @@ -1,13 +1,14 @@ package content.skill.summoning import WorldTest +import containsMessage import itemOnItem -import itemOnObject +import itemOption +import itemOnNpc 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.equipment import world.gregs.voidps.engine.inv.inventory import world.gregs.voidps.engine.inv.remove @@ -57,9 +58,8 @@ class EnchantedHeadgearTest : WorldTest() { } @Test - fun `A charged helm emptied on an obelisk returns its scrolls`() { - val player = summoner(Tile(2528, 3095)) // by the Taverley obelisk region - val obelisk = createObject("obelisk", player.tile.addX(1)) + 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) @@ -67,14 +67,48 @@ class EnchantedHeadgearTest : WorldTest() { player.itemOnItem(1, 0) tick(1) - player.itemOnObject(obelisk, 0) + 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 helm reverts to plain") + 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) + assertEquals(1, player.inventory.count("helm_of_neitiznot_charged"), "scrolls charge it") + assertEquals(0, player.inventory.count("iron_bull_rush_scroll")) + } + + @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() From a7fdff91bac6a5943792f7e163c37d74c351f78a Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Sun, 5 Jul 2026 19:46:08 -0700 Subject: [PATCH 121/136] feat: mirror stored scroll count onto the charged helm's item charge The count on the follower-details cast button (662,75) and the item is rendered client-side from the item's charge value, so the helm's stored scrolls have to live on the item, not just a server variable. The charged helms now carry a charges/charges_max cap, and the stored count is mirrored onto the worn or held helm's charge whenever it changes - so the number shows on the helm and feeds the cast-button count. Reverts reset the charge to 1 first so an emptied helm is a clean single item, not one carrying the old count. --- data/skill/summoning/summoning.items.toml | 36 ++++++++++++++++ .../area/asgarnia/taverley/Pikkupstix.kt | 2 +- .../skill/summoning/EnchantedHeadgear.kt | 42 +++++++++++++++++-- .../skill/summoning/EnchantedHeadgearTest.kt | 19 ++++++++- 4 files changed, 92 insertions(+), 7 deletions(-) diff --git a/data/skill/summoning/summoning.items.toml b/data/skill/summoning/summoning.items.toml index 450245e901..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 diff --git a/game/src/main/kotlin/content/area/asgarnia/taverley/Pikkupstix.kt b/game/src/main/kotlin/content/area/asgarnia/taverley/Pikkupstix.kt index 3673170e02..922b72d3c3 100644 --- a/game/src/main/kotlin/content/area/asgarnia/taverley/Pikkupstix.kt +++ b/game/src/main/kotlin/content/area/asgarnia/taverley/Pikkupstix.kt @@ -24,7 +24,7 @@ import world.gregs.voidps.engine.inv.transact.operation.RemoveItem.remove class Pikkupstix : Script { init { - // The post-Wolf-Whistle tutor dialogue (2009scape's transcript). The quest itself isn't + // 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?") diff --git a/game/src/main/kotlin/content/skill/summoning/EnchantedHeadgear.kt b/game/src/main/kotlin/content/skill/summoning/EnchantedHeadgear.kt index f81f12e578..38d3e2b40e 100644 --- a/game/src/main/kotlin/content/skill/summoning/EnchantedHeadgear.kt +++ b/game/src/main/kotlin/content/skill/summoning/EnchantedHeadgear.kt @@ -16,6 +16,7 @@ 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 /** @@ -108,11 +109,31 @@ fun Player.enchantedHeadgearScroll(): String? { 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) { - val helm = equipped(EquipSlot.Hat).id - BY_ID[helm]?.let { equipment.replace(EquipSlot.Hat.index, it.charged, it.enchanted) } + // 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) } } } @@ -121,9 +142,15 @@ class EnchantedHeadgear : Script { 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() } @@ -154,7 +181,12 @@ class EnchantedHeadgear : Script { } set(SCROLL_ID, scroll.id) set(SCROLL_COUNT, stored + amount) - inventory.replace(headgear.enchanted, headgear.charged) + // 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) } @@ -175,7 +207,9 @@ class EnchantedHeadgear : Script { inventory.add(scroll, count) message("You empty the helmet, recovering $count ${ItemDefinitions.get(scroll).name.lowercase()}${if (count == 1) "" else "s"}.", ChatType.Filter) } - // The charged helm reverts to its empty enchanted form whether or not it held anything. + // 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 { diff --git a/game/src/test/kotlin/content/skill/summoning/EnchantedHeadgearTest.kt b/game/src/test/kotlin/content/skill/summoning/EnchantedHeadgearTest.kt index 76e0ec05a2..8eca767558 100644 --- a/game/src/test/kotlin/content/skill/summoning/EnchantedHeadgearTest.kt +++ b/game/src/test/kotlin/content/skill/summoning/EnchantedHeadgearTest.kt @@ -37,7 +37,7 @@ class EnchantedHeadgearTest : WorldTest() { player.itemOnItem(1, 0) // scrolls onto the antlers tick(1) - assertEquals(1, player.inventory.count("antlers_charged"), "the antlers become charged") + 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") } @@ -90,10 +90,25 @@ class EnchantedHeadgearTest : WorldTest() { player.itemOnItem(1, player.inventory.indexOf("helm_of_neitiznot_enchanted")) tick(1) - assertEquals(1, player.inventory.count("helm_of_neitiznot_charged"), "scrolls charge it") + 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() From 737e5c08b1472b70b0789d5d75f614ee29c7ccc1 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Sun, 5 Jul 2026 19:46:08 -0700 Subject: [PATCH 122/136] feat: mirror stored scroll count onto the charged helm's item charge The count on the follower-details cast button (662,75) and the item is rendered client-side from the item's charge value, so the helm's stored scrolls have to live on the item, not just a server variable. The charged helms now carry a charges/charges_max cap, and the stored count is mirrored onto the worn or held helm's charge whenever it changes - so the number shows on the helm and feeds the cast-button count. Reverts reset the charge to 1 first so an emptied helm is a clean single item, not one carrying the old count. --- .../kotlin/content/skill/summoning/FamiliarSpecialHelpers.kt | 1 - .../kotlin/content/skill/summoning/FamiliarUtilitySpecials.kt | 1 - .../kotlin/content/skill/summoning/EnchantedHeadgearTest.kt | 2 +- 3 files changed, 1 insertion(+), 3 deletions(-) diff --git a/game/src/main/kotlin/content/skill/summoning/FamiliarSpecialHelpers.kt b/game/src/main/kotlin/content/skill/summoning/FamiliarSpecialHelpers.kt index ba4dd03278..a39c5f206f 100644 --- a/game/src/main/kotlin/content/skill/summoning/FamiliarSpecialHelpers.kt +++ b/game/src/main/kotlin/content/skill/summoning/FamiliarSpecialHelpers.kt @@ -183,4 +183,3 @@ fun Player.familiarSelfSpecial(anim: String? = null, sourceGfx: String? = null, effect() return true } - diff --git a/game/src/main/kotlin/content/skill/summoning/FamiliarUtilitySpecials.kt b/game/src/main/kotlin/content/skill/summoning/FamiliarUtilitySpecials.kt index 246c116a77..3b3d21ea64 100644 --- a/game/src/main/kotlin/content/skill/summoning/FamiliarUtilitySpecials.kt +++ b/game/src/main/kotlin/content/skill/summoning/FamiliarUtilitySpecials.kt @@ -302,7 +302,6 @@ class FamiliarUtilitySpecials : Script { } } - /** 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) diff --git a/game/src/test/kotlin/content/skill/summoning/EnchantedHeadgearTest.kt b/game/src/test/kotlin/content/skill/summoning/EnchantedHeadgearTest.kt index 8eca767558..41c9619fbc 100644 --- a/game/src/test/kotlin/content/skill/summoning/EnchantedHeadgearTest.kt +++ b/game/src/test/kotlin/content/skill/summoning/EnchantedHeadgearTest.kt @@ -3,8 +3,8 @@ package content.skill.summoning import WorldTest import containsMessage import itemOnItem -import itemOption 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 From 9cfe6f6064933323b484b7ea144312f9db91ed94 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Mon, 6 Jul 2026 10:45:55 -0700 Subject: [PATCH 123/136] fix: add the albino rat's missing summon animation The albino_rat_spawn entry was absent, so the familiar appeared with no animation on summon/call. 2009scape spawns familiars with a graphic and a player gesture rather than a per-npc animation, so there was no id to lift directly; identified 14859 from void's cache as the albino rat's summon animation - a 23-frame materialise matching the spirit wolf's summon (8298) profile exactly, from the rat's 14856-14863 cluster. --- data/skill/summoning/summoning_spawn.anims.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/data/skill/summoning/summoning_spawn.anims.toml b/data/skill/summoning/summoning_spawn.anims.toml index 8f2912920e..137bab493a 100644 --- a/data/skill/summoning/summoning_spawn.anims.toml +++ b/data/skill/summoning/summoning_spawn.anims.toml @@ -28,6 +28,9 @@ id = 13676 [adept_worldbearer_spawn] id = 13684 +[albino_rat_spawn] +id = 14859 + [arctic_bear_spawn] id = 8522 From 48097f113c5586e8a59dce5529244bbb13eb65c5 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Mon, 6 Jul 2026 10:54:26 -0700 Subject: [PATCH 124/136] fix: remove albino rat's incorrect summon animation No valid rev-634 spawn animation exists for the albino rat: darkan's value (16080) is a post-634 re-animation, absent from this cache (max animation id 15260), and 2009scape plays no per-familiar spawn anim at all. Better to play nothing than a wrong animation. --- data/skill/summoning/summoning_spawn.anims.toml | 3 --- 1 file changed, 3 deletions(-) diff --git a/data/skill/summoning/summoning_spawn.anims.toml b/data/skill/summoning/summoning_spawn.anims.toml index 137bab493a..8f2912920e 100644 --- a/data/skill/summoning/summoning_spawn.anims.toml +++ b/data/skill/summoning/summoning_spawn.anims.toml @@ -28,9 +28,6 @@ id = 13676 [adept_worldbearer_spawn] id = 13684 -[albino_rat_spawn] -id = 14859 - [arctic_bear_spawn] id = 8522 From 55c259ab01aee1f657bae777840e625a33e85a49 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Mon, 6 Jul 2026 11:54:47 -0700 Subject: [PATCH 125/136] feat: abyssal parasite turns Abyss prayer drain into gradual leak Entry no longer zeroes Prayer with a parasite summoned; instead 10 points leak every 8-16 ticks while inside. Losing the parasite in the Abyss drains the remainder, so dismissing it grants no free prayer. --- .../area/wilderness/abyss/AbyssPrayerDrain.kt | 46 ++++++++ .../area/wilderness/abyss/MageOfZamorak.kt | 4 +- .../wilderness/abyss/AbyssPrayerDrainTest.kt | 108 ++++++++++++++++++ 3 files changed, 157 insertions(+), 1 deletion(-) create mode 100644 game/src/main/kotlin/content/area/wilderness/abyss/AbyssPrayerDrain.kt create mode 100644 game/src/test/kotlin/content/area/wilderness/abyss/AbyssPrayerDrainTest.kt 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/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)) + } +} From 9b7a17b70e008ed6b821a8f2fee511ad0f5aefda Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Mon, 6 Jul 2026 12:20:39 -0700 Subject: [PATCH 126/136] feat: Drown instantly casts water blast for two summoning points Previously only re-targeted the overlord like a plain attack order. Now fires an immediate magic-based hit (max 76) with its attack visuals, charging two summoning points only when the cast succeeds. --- .../summoning/familiar/KaramthulhuOverlord.kt | 21 ++++++++++++++++--- .../FamiliarUtilitySpecialEffectTest.kt | 17 +++++++++++++++ 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/game/src/main/kotlin/content/skill/summoning/familiar/KaramthulhuOverlord.kt b/game/src/main/kotlin/content/skill/summoning/familiar/KaramthulhuOverlord.kt index 8f0a7a0101..ad811b9920 100644 --- a/game/src/main/kotlin/content/skill/summoning/familiar/KaramthulhuOverlord.kt +++ b/game/src/main/kotlin/content/skill/summoning/familiar/KaramthulhuOverlord.kt @@ -9,16 +9,18 @@ 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.commandFamiliarAttack +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" orders the overlord to blast the owner's current foe with its water spell. + // "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 @@ -28,7 +30,20 @@ class KaramthulhuOverlord : Script { message("Your familiar has no target to attack.") return@npcOperate } - commandFamiliarAttack(enemy) + 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") { diff --git a/game/src/test/kotlin/content/skill/summoning/FamiliarUtilitySpecialEffectTest.kt b/game/src/test/kotlin/content/skill/summoning/FamiliarUtilitySpecialEffectTest.kt index 9a9e42f5ee..bdd75f0735 100644 --- a/game/src/test/kotlin/content/skill/summoning/FamiliarUtilitySpecialEffectTest.kt +++ b/game/src/test/kotlin/content/skill/summoning/FamiliarUtilitySpecialEffectTest.kt @@ -2,6 +2,7 @@ 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 @@ -366,11 +367,27 @@ class FamiliarUtilitySpecialEffectTest : WorldTest() { 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 From 124fc2914f7da5cfb526cffc624796d85fe2df99 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Mon, 6 Jul 2026 12:37:46 -0700 Subject: [PATCH 127/136] feat: Flames instantly casts fire spell for two summoning points Previously only re-targeted the smoke devil like a plain attack order. Now fires an immediate magic-based hit (max 150) with its attack anim and a dedicated flame projectile (gfx 1473), charging two summoning points only when the cast succeeds. --- .../skill/summoning/summoning_combat.gfx.toml | 3 +++ .../skill/summoning/familiar/SmokeDevil.kt | 20 ++++++++++++++++--- .../FamiliarUtilitySpecialEffectTest.kt | 16 +++++++++++++++ 3 files changed, 36 insertions(+), 3 deletions(-) diff --git a/data/skill/summoning/summoning_combat.gfx.toml b/data/skill/summoning/summoning_combat.gfx.toml index 3d602441ee..92f292b27f 100644 --- a/data/skill/summoning/summoning_combat.gfx.toml +++ b/data/skill/summoning/summoning_combat.gfx.toml @@ -10,6 +10,9 @@ id = 1379 [smoke_devil_familiar_projectile] id = 1376 +[smoke_devil_familiar_flames_projectile] +id = 1473 + [forge_regent_familiar_projectile] id = 1330 diff --git a/game/src/main/kotlin/content/skill/summoning/familiar/SmokeDevil.kt b/game/src/main/kotlin/content/skill/summoning/familiar/SmokeDevil.kt index 0997d8dc64..8652fee274 100644 --- a/game/src/main/kotlin/content/skill/summoning/familiar/SmokeDevil.kt +++ b/game/src/main/kotlin/content/skill/summoning/familiar/SmokeDevil.kt @@ -7,16 +7,18 @@ 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.commandFamiliarAttack +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" orders the devil to scorch the owner's current foe with its fiery breath. + // "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 @@ -26,7 +28,19 @@ class SmokeDevil : Script { message("Your familiar has no target to attack.") return@npcOperate } - commandFamiliarAttack(enemy) + 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") { diff --git a/game/src/test/kotlin/content/skill/summoning/FamiliarUtilitySpecialEffectTest.kt b/game/src/test/kotlin/content/skill/summoning/FamiliarUtilitySpecialEffectTest.kt index bdd75f0735..4b9e11bbb3 100644 --- a/game/src/test/kotlin/content/skill/summoning/FamiliarUtilitySpecialEffectTest.kt +++ b/game/src/test/kotlin/content/skill/summoning/FamiliarUtilitySpecialEffectTest.kt @@ -395,11 +395,27 @@ class FamiliarUtilitySpecialEffectTest : WorldTest() { 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 From 78d62fb2d1779fb635f281da555207a6d3f977db Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Mon, 6 Jul 2026 13:34:35 -0700 Subject: [PATCH 128/136] fix: default familiar chatheads to small dialogue heads Familiars had large_head = true across the board, routing every dialogue through the large head component; the engine default of false matches the client. Also drops the per-familiar dialogue expression keys in favour of the cs2-driven chathead lookup. --- data/skill/summoning/summoning.npcs.toml | 219 ----------------------- 1 file changed, 219 deletions(-) diff --git a/data/skill/summoning/summoning.npcs.toml b/data/skill/summoning/summoning.npcs.toml index d1cd098a81..9b54a78ecc 100644 --- a/data/skill/summoning/summoning.npcs.toml +++ b/data/skill/summoning/summoning.npcs.toml @@ -8,7 +8,6 @@ def = 37 range = 47 mage = 47 combat_def = "spirit_terrorbird_familiar" -large_head = true interacts = false [granite_crab_familiar] @@ -23,7 +22,6 @@ mage = 0 combat_def = "granite_crab_familiar" summoning_beast_of_burden = 1 summoning_beast_of_burden_capacity = 30 -large_head = true interacts = false [praying_mantis_familiar] @@ -36,7 +34,6 @@ def = 64 range = 72 mage = 72 combat_def = "praying_mantis_familiar" -large_head = true interacts = false [giant_ent_familiar] @@ -51,7 +48,6 @@ mage = 72 combat_def = "giant_ent_familiar" summoning_beast_of_burden = 1 summoning_beast_of_burden_capacity = 30 -large_head = true interacts = false [spirit_cobra_familiar] @@ -64,7 +60,6 @@ def = 57 range = 57 mage = 57 combat_def = "spirit_cobra_familiar" -large_head = true interacts = false [spirit_dagannoth_familiar] @@ -77,7 +72,6 @@ def = 67 range = 75 mage = 75 combat_def = "spirit_dagannoth_familiar" -large_head = true interacts = false [thorny_snail_familiar] @@ -90,7 +84,6 @@ def = 15 range = 15 mage = 1 combat_def = "thorny_snail_familiar" -large_head = true interacts = false [beaver_familiar] @@ -98,7 +91,6 @@ summoning_time_minutes = 27 id = 6808 summoning_beast_of_burden = 1 summoning_beast_of_burden_capacity = 30 -large_head = true interacts = false [karamthulhu_overlord_familiar] @@ -111,7 +103,6 @@ def = 47 range = 53 mage = 53 combat_def = "karamthulhu_overlord_familiar" -large_head = true interacts = false [hydra_familiar] @@ -124,7 +115,6 @@ def = 64 range = 72 mage = 72 combat_def = "hydra_familiar" -large_head = true interacts = false [spirit_jelly_familiar] @@ -137,7 +127,6 @@ def = 44 range = 50 mage = 50 combat_def = "spirit_jelly_familiar" -large_head = true interacts = false [bunyip_familiar] @@ -150,7 +139,6 @@ def = 64 range = 72 mage = 72 combat_def = "bunyip_familiar" -large_head = true interacts = false [war_tortoise_familiar] @@ -163,7 +151,6 @@ def = 64 range = 72 mage = 72 combat_def = "war_tortoise_familiar" -large_head = true interacts = false [fruit_bat_familiar] @@ -171,7 +158,6 @@ summoning_time_minutes = 45 id = 6817 summoning_beast_of_burden = 1 summoning_beast_of_burden_capacity = 30 -large_head = true interacts = false [abyssal_parasite_familiar] @@ -184,8 +170,6 @@ def = 44 range = 49 mage = 49 combat_def = "abyssal_parasite_familiar" -large_head = true -dialogue = "leech" interacts = false summoning_beast_of_burden = 1 summoning_beast_of_burden_capacity = 7 @@ -201,8 +185,6 @@ def = 50 range = 56 mage = 56 combat_def = "abyssal_lurker_familiar" -large_head = true -dialogue = "bird" interacts = false summoning_beast_of_burden = 1 summoning_beast_of_burden_capacity = 12 @@ -219,13 +201,11 @@ def = 69 range = 80 mage = 80 combat_def = "unicorn_stallion_familiar" -large_head = true interacts = false [magpie_familiar] summoning_time_minutes = 34 id = 6824 -large_head = true interacts = false summoning_beast_of_burden = 1 summoning_beast_of_burden_capacity = 30 @@ -240,7 +220,6 @@ def = 10 range = 10 mage = 15 combat_def = "dreadfowl_familiar" -large_head = true interacts = false [stranger_plant_familiar] @@ -255,7 +234,6 @@ mage = 72 combat_def = "stranger_plant_familiar" summoning_beast_of_burden = 1 summoning_beast_of_burden_capacity = 30 -large_head = true interacts = false [spirit_wolf_familiar] @@ -268,8 +246,6 @@ def = 10 range = 10 mage = 10 combat_def = "spirit_wolf_familiar" -large_head = true -dialogue = "wolf" interacts = false [desert_wyrm_familiar] @@ -282,7 +258,6 @@ def = 20 range = 20 mage = 20 combat_def = "desert_wyrm_familiar" -large_head = true interacts = false [evil_turnip_familiar] @@ -297,7 +272,6 @@ mage = 34 combat_def = "evil_turnip_familiar" summoning_beast_of_burden = 1 summoning_beast_of_burden_capacity = 30 -large_head = true interacts = false [vampire_bat_familiar] @@ -310,7 +284,6 @@ def = 30 range = 30 mage = 30 combat_def = "vampire_bat_familiar" -large_head = true interacts = false [spirit_scorpion_familiar] @@ -323,7 +296,6 @@ def = 20 range = 20 mage = 20 combat_def = "spirit_scorpion_familiar" -large_head = true interacts = false [arctic_bear_familiar] @@ -336,7 +308,6 @@ def = 64 range = 72 mage = 72 combat_def = "arctic_bear_familiar" -large_head = true interacts = false [spirit_spider_familiar] @@ -351,7 +322,6 @@ mage = 0 combat_def = "spirit_spider_familiar" summoning_beast_of_burden = 1 summoning_beast_of_burden_capacity = 30 -large_head = true interacts = false [bloated_leech_familiar] @@ -364,7 +334,6 @@ def = 40 range = 45 mage = 45 combat_def = "bloated_leech_familiar" -large_head = true interacts = false [spirit_kalphite_familiar] @@ -377,7 +346,6 @@ def = 25 range = 25 mage = 25 combat_def = "spirit_kalphite_familiar" -large_head = true interacts = false [honey_badger_familiar] @@ -405,7 +373,6 @@ mage = 22 combat_def = "albino_rat_familiar" summoning_beast_of_burden = 1 summoning_beast_of_burden_capacity = 30 -large_head = true interacts = false [granite_lobster_familiar] @@ -420,7 +387,6 @@ mage = 72 combat_def = "granite_lobster_familiar" summoning_beast_of_burden = 1 summoning_beast_of_burden_capacity = 30 -large_head = true interacts = false [macaw_familiar] @@ -428,7 +394,6 @@ summoning_time_minutes = 31 id = 6851 summoning_beast_of_burden = 1 summoning_beast_of_burden_capacity = 30 -large_head = true interacts = false [bronze_minotaur_familiar] @@ -441,7 +406,6 @@ def = 28 range = 33 mage = 33 combat_def = "bronze_minotaur_familiar" -large_head = true interacts = false [iron_minotaur_familiar] @@ -454,7 +418,6 @@ def = 37 range = 42 mage = 42 combat_def = "iron_minotaur_familiar" -large_head = true interacts = false [steel_minotaur_familiar] @@ -467,7 +430,6 @@ def = 44 range = 51 mage = 51 combat_def = "steel_minotaur_familiar" -large_head = true interacts = false [mithril_minotaur_familiar] @@ -480,7 +442,6 @@ def = 53 range = 60 mage = 60 combat_def = "mithril_minotaur_familiar" -large_head = true interacts = false [adamant_minotaur_familiar] @@ -493,7 +454,6 @@ def = 64 range = 72 mage = 72 combat_def = "adamant_minotaur_familiar" -large_head = true interacts = false [rune_minotaur_familiar] @@ -506,7 +466,6 @@ def = 69 range = 80 mage = 80 combat_def = "rune_minotaur_familiar" -large_head = true interacts = false [smoke_devil_familiar] @@ -519,7 +478,6 @@ def = 49 range = 55 mage = 55 combat_def = "smoke_devil_familiar" -large_head = true interacts = false [bull_ant_familiar] @@ -532,7 +490,6 @@ def = 32 range = 36 mage = 36 combat_def = "bull_ant_familiar" -large_head = true interacts = false [wolpertinger_familiar] @@ -545,7 +502,6 @@ def = 72 range = 85 mage = 85 combat_def = "wolpertinger_familiar" -large_head = true interacts = false [compost_mound_familiar] @@ -560,7 +516,6 @@ mage = 25 combat_def = "compost_mound_familiar" summoning_beast_of_burden = 1 summoning_beast_of_burden_capacity = 30 -large_head = true interacts = false [pack_yak_familiar] @@ -573,7 +528,6 @@ def = 77 range = 87 mage = 87 combat_def = "pack_yak_familiar" -large_head = true interacts = false [spirit_cockatrice_familiar] @@ -588,7 +542,6 @@ mage = 39 combat_def = "spirit_cockatrice_familiar" summoning_beast_of_burden = 1 summoning_beast_of_burden_capacity = 30 -large_head = true interacts = false [spirit_guthatrice_familiar] @@ -603,7 +556,6 @@ mage = 39 combat_def = "spirit_guthatrice_familiar" summoning_beast_of_burden = 1 summoning_beast_of_burden_capacity = 30 -large_head = true interacts = false [spirit_saratrice_familiar] @@ -618,7 +570,6 @@ mage = 39 combat_def = "spirit_saratrice_familiar" summoning_beast_of_burden = 1 summoning_beast_of_burden_capacity = 30 -large_head = true interacts = false [spirit_zamatrice_familiar] @@ -633,7 +584,6 @@ mage = 39 combat_def = "spirit_zamatrice_familiar" summoning_beast_of_burden = 1 summoning_beast_of_burden_capacity = 30 -large_head = true interacts = false [spirit_pengatrice_familiar] @@ -648,7 +598,6 @@ mage = 39 combat_def = "spirit_pengatrice_familiar" summoning_beast_of_burden = 1 summoning_beast_of_burden_capacity = 30 -large_head = true interacts = false [spirit_coraxatrice_familiar] @@ -663,7 +612,6 @@ mage = 39 combat_def = "spirit_coraxatrice_familiar" summoning_beast_of_burden = 1 summoning_beast_of_burden_capacity = 30 -large_head = true interacts = false [spirit_vulatrice_familiar] @@ -678,7 +626,6 @@ mage = 39 combat_def = "spirit_vulatrice_familiar" summoning_beast_of_burden = 1 summoning_beast_of_burden_capacity = 30 -large_head = true interacts = false [barker_toad_familiar] @@ -691,7 +638,6 @@ def = 53 range = 60 mage = 60 combat_def = "barker_toad_familiar" -large_head = true interacts = false [ibis_familiar] @@ -699,7 +645,6 @@ summoning_time_minutes = 38 id = 6991 summoning_beast_of_burden = 1 summoning_beast_of_burden_capacity = 30 -large_head = true interacts = false [swamp_titan_familiar] @@ -712,7 +657,6 @@ def = 67 range = 77 mage = 77 combat_def = "swamp_titan_familiar" -large_head = true interacts = false [spirit_mosquito_familiar] @@ -725,7 +669,6 @@ def = 45 range = 1 mage = 1 combat_def = "spirit_mosquito_familiar" -large_head = true interacts = false [void_spinner_familiar] @@ -738,7 +681,6 @@ def = 28 range = 31 mage = 31 combat_def = "void_spinner_familiar" -large_head = true interacts = false [forge_regent_familiar] @@ -751,7 +693,6 @@ def = 64 range = 72 mage = 72 combat_def = "forge_regent_familiar" -large_head = true interacts = false [spirit_larupia_familiar] @@ -764,7 +705,6 @@ def = 46 range = 52 mage = 52 combat_def = "spirit_larupia_familiar" -large_head = true interacts = false [geyser_titan_familiar] @@ -777,7 +717,6 @@ def = 72 range = 85 mage = 85 combat_def = "geyser_titan_familiar" -large_head = true interacts = false [lava_titan_familiar] @@ -790,7 +729,6 @@ def = 67 range = 75 mage = 75 combat_def = "lava_titan_familiar" -large_head = true interacts = false [steel_titan_familiar] @@ -803,7 +741,6 @@ def = 130 range = 130 mage = 130 combat_def = "steel_titan_familiar" -large_head = true interacts = false [obsidian_golem_familiar] @@ -816,7 +753,6 @@ def = 64 range = 72 mage = 72 combat_def = "obsidian_golem_familiar" -large_head = true interacts = false [talon_beast_familiar] @@ -829,7 +765,6 @@ def = 64 range = 72 mage = 72 combat_def = "talon_beast_familiar" -large_head = true interacts = false [abyssal_titan_familiar] @@ -842,7 +777,6 @@ def = 100 range = 100 mage = 100 combat_def = "abyssal_titan_familiar" -large_head = true interacts = false summoning_beast_of_burden = 1 summoning_beast_of_burden_capacity = 20 @@ -858,7 +792,6 @@ def = 28 range = 31 mage = 31 combat_def = "void_torcher_familiar" -large_head = true interacts = false [giant_chinchompa_familiar] @@ -871,7 +804,6 @@ def = 25 range = 25 mage = 25 combat_def = "giant_chinchompa_familiar" -large_head = true interacts = false [fire_titan_familiar] @@ -884,7 +816,6 @@ def = 64 range = 72 mage = 72 combat_def = "fire_titan_familiar" -large_head = true interacts = false [moss_titan_familiar] @@ -897,7 +828,6 @@ def = 64 range = 72 mage = 72 combat_def = "moss_titan_familiar" -large_head = true interacts = false [ice_titan_familiar] @@ -910,7 +840,6 @@ def = 64 range = 72 mage = 72 combat_def = "ice_titan_familiar" -large_head = true interacts = false [spirit_tz-kih_familiar] @@ -923,7 +852,6 @@ def = 20 range = 20 mage = 20 combat_def = "spirit_tz-kih_familiar" -large_head = true interacts = false [spirit_graahk_familiar] @@ -936,7 +864,6 @@ def = 46 range = 52 mage = 52 combat_def = "spirit_graahk_familiar" -large_head = true interacts = false [spirit_kyatt_familiar] @@ -949,7 +876,6 @@ def = 46 range = 52 mage = 52 combat_def = "spirit_kyatt_familiar" -large_head = true interacts = false [void_shifter_familiar] @@ -962,7 +888,6 @@ def = 28 range = 31 mage = 31 combat_def = "void_shifter_familiar" -large_head = true interacts = false [pyrelord_familiar] @@ -975,7 +900,6 @@ def = 30 range = 1 mage = 1 combat_def = "pyrelord_familiar" -large_head = true interacts = false [void_ravager_familiar] @@ -990,7 +914,6 @@ mage = 31 combat_def = "void_ravager_familiar" summoning_beast_of_burden = 1 summoning_beast_of_burden_capacity = 30 -large_head = true interacts = false [ravenous_locust_familiar] @@ -1003,7 +926,6 @@ def = 64 range = 72 mage = 72 combat_def = "ravenous_locust_familiar" -large_head = true interacts = false [iron_titan_familiar] @@ -1016,32 +938,26 @@ def = 120 range = 120 mage = 120 combat_def = "iron_titan_familiar" -large_head = true interacts = false [clay_familiar_class_1_familiar] id = 8240 -large_head = true interacts = false [clay_familiar_class_2_familiar] id = 8242 -large_head = true interacts = false [clay_familiar_class_3_familiar] id = 8244 -large_head = true interacts = false [clay_familiar_class_4_familiar] id = 8246 -large_head = true interacts = false [clay_familiar_class_5_familiar] id = 8248 -large_head = true interacts = false [phoenix_familiar] @@ -1053,313 +969,251 @@ def = 64 range = 72 mage = 72 combat_def = "phoenix_familiar" -large_head = true interacts = false [cub_bloodrager_familiar] id = 11106 -large_head = true interacts = false [little_bloodrager_familiar] id = 11108 -large_head = true interacts = false [naïve_bloodrager_familiar] id = 11110 -large_head = true interacts = false [keen_bloodrager_familiar] id = 11112 -large_head = true interacts = false [brave_bloodrager_familiar] id = 11114 -large_head = true interacts = false [brah_bloodrager_familiar] id = 11116 -large_head = true interacts = false [naabe_bloodrager_familiar] id = 11118 -large_head = true interacts = false [wise_bloodrager_familiar] id = 11120 -large_head = true interacts = false [adept_bloodrager_familiar] id = 11122 -large_head = true interacts = false [sachem_bloodrager_familiar] id = 11124 -large_head = true interacts = false [cub_stormbringer_familiar] id = 11126 -large_head = true interacts = false [little_stormbringer_familiar] id = 11128 -large_head = true interacts = false [naïve_stormbringer_familiar] id = 11130 -large_head = true interacts = false [keen_stormbringer_familiar] id = 11132 -large_head = true interacts = false [brave_stormbringer_familiar] id = 11134 -large_head = true interacts = false [brah_stormbringer_familiar] id = 11136 -large_head = true interacts = false [naabe_stormbringer_familiar] id = 11138 -large_head = true interacts = false [wise_stormbringer_familiar] id = 11140 -large_head = true interacts = false [adept_stormbringer_familiar] id = 11142 -large_head = true interacts = false [sachem_stormbringer_familiar] id = 11144 -large_head = true interacts = false [cub_hoardstalker_familiar] id = 11146 -large_head = true interacts = false [little_hoardstalker_familiar] id = 11148 -large_head = true interacts = false [naïve_hoardstalker_familiar] id = 11150 -large_head = true interacts = false [keen_hoardstalker_familiar] id = 11152 -large_head = true interacts = false [brave_hoardstalker_familiar] id = 11154 -large_head = true interacts = false [brah_hoardstalker_familiar] id = 11156 -large_head = true interacts = false [naabe_hoardstalker_familiar] id = 11158 -large_head = true interacts = false [wise_hoardstalker_familiar] id = 11160 -large_head = true interacts = false [adept_hoardstalker_familiar] id = 11162 -large_head = true interacts = false [sachem_hoardstalker_familiar] id = 11164 -large_head = true interacts = false [cub_skinweaver_familiar] id = 11166 -large_head = true interacts = false [little_skinweaver_familiar] id = 11168 -large_head = true interacts = false [naïve_skinweaver_familiar] id = 11170 -large_head = true interacts = false [keen_skinweaver_familiar] id = 11172 -large_head = true interacts = false [brave_skinweaver_familiar] id = 11174 -large_head = true interacts = false [brah_skinweaver_familiar] id = 11176 -large_head = true interacts = false [naabe_skinweaver_familiar] id = 11178 -large_head = true interacts = false [wise_skinweaver_familiar] id = 11180 -large_head = true interacts = false [adept_skinweaver_familiar] id = 11182 -large_head = true interacts = false [sachem_skinweaver_familiar] id = 11184 -large_head = true interacts = false [cub_worldbearer_familiar] id = 11186 -large_head = true interacts = false [little_worldbearer_familiar] id = 11188 -large_head = true interacts = false [naïve_worldbearer_familiar] id = 11190 -large_head = true interacts = false [keen_worldbearer_familiar] id = 11192 -large_head = true interacts = false [brave_worldbearer_familiar] id = 11194 -large_head = true interacts = false [brah_worldbearer_familiar] id = 11196 -large_head = true interacts = false [naabe_worldbearer_familiar] id = 11198 -large_head = true interacts = false [wise_worldbearer_familiar] id = 11200 -large_head = true interacts = false [adept_worldbearer_familiar] id = 11202 -large_head = true interacts = false [sachem_worldbearer_familiar] id = 11204 -large_head = true interacts = false [cub_deathslinger_familiar] id = 11206 -large_head = true interacts = false [little_deathslinger_familiar] id = 11208 -large_head = true interacts = false [naive_deathslinger_familiar] id = 11210 -large_head = true interacts = false [keen_deathslinger_familiar] id = 11212 -large_head = true interacts = false [brave_deathslinger_familiar] id = 11214 -large_head = true interacts = false [brah_deathslinger_familiar] id = 11216 -large_head = true interacts = false [naabe_deathslinger_familiar] id = 11218 -large_head = true interacts = false [wise_deathslinger_familiar] id = 11220 -large_head = true interacts = false [adept_deathslinger_familiar] id = 11222 -large_head = true interacts = false [sachem_deathslinger_familiar] id = 11224 -large_head = true interacts = false [meerkats_familiar] summoning_time_minutes = 40 id = 11640 -large_head = true interacts = false @@ -1367,7 +1221,6 @@ interacts = false [spirit_wolf_familiar_combat] id = 6830 -large_head = true interacts = false hitpoints = 150 att = 10 @@ -1379,7 +1232,6 @@ combat_def = "spirit_wolf_familiar" [dreadfowl_familiar_combat] id = 6826 -large_head = true interacts = false hitpoints = 160 att = 15 @@ -1391,7 +1243,6 @@ combat_def = "dreadfowl_familiar" [spirit_spider_familiar_combat] id = 6842 -large_head = true interacts = false hitpoints = 180 att = 15 @@ -1403,7 +1254,6 @@ combat_def = "spirit_spider_familiar" [thorny_snail_familiar_combat] id = 6807 -large_head = true interacts = false hitpoints = 280 att = 1 @@ -1415,7 +1265,6 @@ combat_def = "thorny_snail_familiar" [granite_crab_familiar_combat] id = 6797 -large_head = true interacts = false hitpoints = 160 att = 15 @@ -1427,7 +1276,6 @@ combat_def = "granite_crab_familiar" [spirit_mosquito_familiar_combat] id = 7332 -large_head = true interacts = false hitpoints = 430 att = 5 @@ -1439,7 +1287,6 @@ combat_def = "spirit_mosquito_familiar" [desert_wyrm_familiar_combat] id = 6832 -large_head = true interacts = false hitpoints = 470 att = 20 @@ -1451,7 +1298,6 @@ combat_def = "desert_wyrm_familiar" [spirit_scorpion_familiar_combat] id = 6838 -large_head = true interacts = false hitpoints = 670 att = 20 @@ -1463,7 +1309,6 @@ combat_def = "spirit_scorpion_familiar" [spirit_tz-kih_familiar_combat] id = 7362 -large_head = true interacts = false hitpoints = 630 att = 20 @@ -1475,7 +1320,6 @@ combat_def = "spirit_tz-kih_familiar" [albino_rat_familiar_combat] id = 6848 -large_head = true interacts = false hitpoints = 680 att = 22 @@ -1487,7 +1331,6 @@ combat_def = "albino_rat_familiar" [spirit_kalphite_familiar_combat] id = 6995 -large_head = true interacts = false hitpoints = 770 att = 25 @@ -1499,7 +1342,6 @@ combat_def = "spirit_kalphite_familiar" [compost_mound_familiar_combat] id = 6872 -large_head = true interacts = false hitpoints = 930 att = 25 @@ -1511,7 +1353,6 @@ combat_def = "compost_mound_familiar" [giant_chinchompa_familiar_combat] id = 7354 -large_head = true interacts = false hitpoints = 970 att = 25 @@ -1523,7 +1364,6 @@ combat_def = "giant_chinchompa_familiar" [vampire_bat_familiar_combat] id = 6836 -large_head = true interacts = false hitpoints = 1050 att = 30 @@ -1535,7 +1375,6 @@ combat_def = "vampire_bat_familiar" [honey_badger_familiar_combat] id = 6846 -large_head = true interacts = false hitpoints = 1100 att = 29 @@ -1547,7 +1386,6 @@ combat_def = "honey_badger_familiar" [void_ravager_familiar_combat] id = 7371 -large_head = true interacts = false hitpoints = 1210 att = 31 @@ -1559,7 +1397,6 @@ combat_def = "void_ravager_familiar" [void_spinner_familiar_combat] id = 7334 -large_head = true interacts = false hitpoints = 590 att = 31 @@ -1571,7 +1408,6 @@ combat_def = "void_spinner_familiar" [void_torcher_familiar_combat] id = 7352 -large_head = true interacts = false hitpoints = 1210 att = 31 @@ -1583,7 +1419,6 @@ combat_def = "void_torcher_familiar" [void_shifter_familiar_combat] id = 7368 -large_head = true interacts = false hitpoints = 1210 att = 31 @@ -1595,7 +1430,6 @@ combat_def = "void_shifter_familiar" [bronze_minotaur_familiar_combat] id = 6854 -large_head = true interacts = false hitpoints = 1330 att = 33 @@ -1607,7 +1441,6 @@ combat_def = "bronze_minotaur_familiar" [iron_minotaur_familiar_combat] id = 6856 -large_head = true interacts = false hitpoints = 1930 att = 42 @@ -1619,7 +1452,6 @@ combat_def = "iron_minotaur_familiar" [steel_minotaur_familiar_combat] id = 6858 -large_head = true interacts = false hitpoints = 2600 att = 51 @@ -1631,7 +1463,6 @@ combat_def = "steel_minotaur_familiar" [mithril_minotaur_familiar_combat] id = 6860 -large_head = true interacts = false hitpoints = 3400 att = 60 @@ -1643,7 +1474,6 @@ combat_def = "mithril_minotaur_familiar" [adamant_minotaur_familiar_combat] id = 6862 -large_head = true interacts = false hitpoints = 4410 att = 72 @@ -1655,7 +1485,6 @@ combat_def = "adamant_minotaur_familiar" [rune_minotaur_familiar_combat] id = 6864 -large_head = true interacts = false hitpoints = 5700 att = 80 @@ -1667,7 +1496,6 @@ combat_def = "rune_minotaur_familiar" [bull_ant_familiar_combat] id = 6868 -large_head = true interacts = false hitpoints = 1540 att = 36 @@ -1679,7 +1507,6 @@ combat_def = "bull_ant_familiar" [evil_turnip_familiar_combat] id = 6834 -large_head = true interacts = false hitpoints = 1670 att = 38 @@ -1691,7 +1518,6 @@ combat_def = "evil_turnip_familiar" [spirit_cockatrice_familiar_combat] id = 6876 -large_head = true interacts = false hitpoints = 1730 att = 39 @@ -1703,7 +1529,6 @@ combat_def = "spirit_cockatrice_familiar" [spirit_guthatrice_familiar_combat] id = 6878 -large_head = true interacts = false hitpoints = 1730 att = 39 @@ -1715,7 +1540,6 @@ combat_def = "spirit_guthatrice_familiar" [spirit_saratrice_familiar_combat] id = 6880 -large_head = true interacts = false hitpoints = 1730 att = 39 @@ -1727,7 +1551,6 @@ combat_def = "spirit_saratrice_familiar" [spirit_zamatrice_familiar_combat] id = 6882 -large_head = true interacts = false hitpoints = 1730 att = 39 @@ -1739,7 +1562,6 @@ combat_def = "spirit_zamatrice_familiar" [spirit_pengatrice_familiar_combat] id = 6884 -large_head = true interacts = false hitpoints = 1730 att = 39 @@ -1751,7 +1573,6 @@ combat_def = "spirit_pengatrice_familiar" [spirit_coraxatrice_familiar_combat] id = 6886 -large_head = true interacts = false hitpoints = 1730 att = 39 @@ -1763,7 +1584,6 @@ combat_def = "spirit_coraxatrice_familiar" [spirit_vulatrice_familiar_combat] id = 6888 -large_head = true interacts = false hitpoints = 1730 att = 39 @@ -1775,7 +1595,6 @@ combat_def = "spirit_vulatrice_familiar" [pyrelord_familiar_combat] id = 7378 -large_head = true interacts = false hitpoints = 1930 att = 60 @@ -1787,7 +1606,6 @@ combat_def = "pyrelord_familiar" [bloated_leech_familiar_combat] id = 6844 -large_head = true interacts = false hitpoints = 2110 att = 45 @@ -1799,7 +1617,6 @@ combat_def = "bloated_leech_familiar" [spirit_terrorbird_familiar_combat] id = 6795 -large_head = true interacts = false hitpoints = 2330 att = 47 @@ -1811,7 +1628,6 @@ combat_def = "spirit_terrorbird_familiar" [abyssal_parasite_familiar_combat] id = 6819 -large_head = true interacts = false hitpoints = 2340 att = 49 @@ -1823,7 +1639,6 @@ combat_def = "abyssal_parasite_familiar" [spirit_jelly_familiar_combat] id = 6993 -large_head = true interacts = false hitpoints = 2550 att = 50 @@ -1835,7 +1650,6 @@ combat_def = "spirit_jelly_familiar" [spirit_kyatt_familiar_combat] id = 7366 -large_head = true interacts = false hitpoints = 2680 att = 52 @@ -1847,7 +1661,6 @@ combat_def = "spirit_kyatt_familiar" [spirit_larupia_familiar_combat] id = 7338 -large_head = true interacts = false hitpoints = 2680 att = 52 @@ -1859,7 +1672,6 @@ combat_def = "spirit_larupia_familiar" [spirit_graahk_familiar_combat] id = 7364 -large_head = true interacts = false hitpoints = 2680 att = 52 @@ -1871,7 +1683,6 @@ combat_def = "spirit_graahk_familiar" [karamthulhu_overlord_familiar_combat] id = 6810 -large_head = true interacts = false hitpoints = 2760 att = 53 @@ -1883,7 +1694,6 @@ combat_def = "karamthulhu_overlord_familiar" [smoke_devil_familiar_combat] id = 6866 -large_head = true interacts = false hitpoints = 3000 att = 55 @@ -1895,7 +1705,6 @@ combat_def = "smoke_devil_familiar" [abyssal_lurker_familiar_combat] id = 6821 -large_head = true interacts = false hitpoints = 3080 att = 56 @@ -1907,7 +1716,6 @@ combat_def = "abyssal_lurker_familiar" [spirit_cobra_familiar_combat] id = 6803 -large_head = true interacts = false hitpoints = 3140 att = 57 @@ -1919,7 +1727,6 @@ combat_def = "spirit_cobra_familiar" [stranger_plant_familiar_combat] id = 6828 -large_head = true interacts = false hitpoints = 3220 att = 72 @@ -1931,7 +1738,6 @@ combat_def = "stranger_plant_familiar" [barker_toad_familiar_combat] id = 6890 -large_head = true interacts = false hitpoints = 3400 att = 60 @@ -1943,7 +1749,6 @@ combat_def = "barker_toad_familiar" [war_tortoise_familiar_combat] id = 6816 -large_head = true interacts = false hitpoints = 3480 att = 72 @@ -1955,7 +1760,6 @@ combat_def = "war_tortoise_familiar" [bunyip_familiar_combat] id = 6814 -large_head = true interacts = false hitpoints = 400 att = 72 @@ -1967,7 +1771,6 @@ combat_def = "bunyip_familiar" [ravenous_locust_familiar_combat] id = 7373 -large_head = true interacts = false hitpoints = 3700 att = 72 @@ -1979,7 +1782,6 @@ combat_def = "ravenous_locust_familiar" [arctic_bear_familiar_combat] id = 6840 -large_head = true interacts = false hitpoints = 3810 att = 72 @@ -1991,7 +1793,6 @@ combat_def = "arctic_bear_familiar" [phoenix_familiar_combat] id = 8576 -large_head = true interacts = false hitpoints = 1530 att = 72 @@ -2003,7 +1804,6 @@ combat_def = "phoenix_familiar" [obsidian_golem_familiar_combat] id = 7346 -large_head = true interacts = false hitpoints = 4060 att = 72 @@ -2015,7 +1815,6 @@ combat_def = "obsidian_golem_familiar" [granite_lobster_familiar_combat] id = 6850 -large_head = true interacts = false hitpoints = 4180 att = 72 @@ -2027,7 +1826,6 @@ combat_def = "granite_lobster_familiar" [praying_mantis_familiar_combat] id = 6799 -large_head = true interacts = false hitpoints = 4280 att = 72 @@ -2039,7 +1837,6 @@ combat_def = "praying_mantis_familiar" [forge_regent_familiar_combat] id = 7336 -large_head = true interacts = false hitpoints = 4410 att = 72 @@ -2051,7 +1848,6 @@ combat_def = "forge_regent_familiar" [talon_beast_familiar_combat] id = 7348 -large_head = true interacts = false hitpoints = 4540 att = 72 @@ -2063,7 +1859,6 @@ combat_def = "talon_beast_familiar" [giant_ent_familiar_combat] id = 6801 -large_head = true interacts = false hitpoints = 4670 att = 72 @@ -2075,7 +1870,6 @@ combat_def = "giant_ent_familiar" [hydra_familiar_combat] id = 6812 -large_head = true interacts = false hitpoints = 4900 att = 72 @@ -2087,7 +1881,6 @@ combat_def = "hydra_familiar" [spirit_dagannoth_familiar_combat] id = 6805 -large_head = true interacts = false hitpoints = 5280 att = 75 @@ -2099,7 +1892,6 @@ combat_def = "spirit_dagannoth_familiar" [unicorn_stallion_familiar_combat] id = 6823 -large_head = true interacts = false hitpoints = 1000 att = 80 @@ -2111,7 +1903,6 @@ combat_def = "unicorn_stallion_familiar" [wolpertinger_familiar_combat] id = 6870 -large_head = true interacts = false hitpoints = 6510 att = 85 @@ -2123,7 +1914,6 @@ combat_def = "wolpertinger_familiar" [pack_yak_familiar_combat] id = 6874 -large_head = true interacts = false hitpoints = 7100 att = 87 @@ -2135,7 +1925,6 @@ combat_def = "pack_yak_familiar" [fire_titan_familiar_combat] id = 7356 -large_head = true interacts = false hitpoints = 4760 att = 72 @@ -2147,7 +1936,6 @@ combat_def = "fire_titan_familiar" [moss_titan_familiar_combat] id = 7358 -large_head = true interacts = false hitpoints = 4760 att = 72 @@ -2159,7 +1947,6 @@ combat_def = "moss_titan_familiar" [ice_titan_familiar_combat] id = 7360 -large_head = true interacts = false hitpoints = 4760 att = 72 @@ -2171,7 +1958,6 @@ combat_def = "ice_titan_familiar" [lava_titan_familiar_combat] id = 7342 -large_head = true interacts = false hitpoints = 5280 att = 75 @@ -2183,7 +1969,6 @@ combat_def = "lava_titan_familiar" [swamp_titan_familiar_combat] id = 7330 -large_head = true interacts = false hitpoints = 5660 att = 77 @@ -2195,7 +1980,6 @@ combat_def = "swamp_titan_familiar" [geyser_titan_familiar_combat] id = 7340 -large_head = true interacts = false hitpoints = 6100 att = 85 @@ -2207,7 +1991,6 @@ combat_def = "geyser_titan_familiar" [abyssal_titan_familiar_combat] id = 7350 -large_head = true interacts = false hitpoints = 6670 att = 100 @@ -2219,7 +2002,6 @@ combat_def = "abyssal_titan_familiar" [iron_titan_familiar_combat] id = 7376 -large_head = true interacts = false hitpoints = 6940 att = 120 @@ -2231,7 +2013,6 @@ combat_def = "iron_titan_familiar" [steel_titan_familiar_combat] id = 7344 -large_head = true interacts = false hitpoints = 7540 att = 130 From 337bb3211ad04c4fcb3eb07634098b19670b4a8c Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Mon, 6 Jul 2026 13:34:47 -0700 Subject: [PATCH 129/136] feat: resolve familiar dialogue chatheads like cs2 script 751 Dialogue chatheads previously used the humanoid expression animations (or hand-maintained per-familiar toml sets, some wrong - the spirit wolf had the cat's). Familiars now resolve their animation at runtime exactly as interface 662's client script does: the follower_details_chathead_animation varbit value keys enum 1276, or enum 1275 with 50 subtracted when over 50. Also strips the _familiar suffix before setting the varbit so it transmits the mapped index instead of -1 (the map keys are base names), keeping the details interface in sync server-side. --- data/client/all.enums.toml | 4 +-- data/skill/summoning/summoning.anims.toml | 36 ------------------- .../entity/player/dialogue/DialogueCommon.kt | 34 +++++++++++++++++- .../player/dialogue/type/NPCDialogue.kt | 10 +++++- .../content/skill/summoning/Summoning.kt | 3 +- .../FamiliarDialogueExpressionsTest.kt | 36 +++++++++++++++++++ 6 files changed, 82 insertions(+), 41 deletions(-) create mode 100644 game/src/test/kotlin/content/skill/summoning/FamiliarDialogueExpressionsTest.kt 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/skill/summoning/summoning.anims.toml b/data/skill/summoning/summoning.anims.toml index d5c33e97ac..4265ed7bc1 100644 --- a/data/skill/summoning/summoning.anims.toml +++ b/data/skill/summoning/summoning.anims.toml @@ -6,42 +6,6 @@ id = 8502 walk = false run = false -[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 - [unicorn_stallion_cure] id = 8267 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..3cc1050966 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,40 @@ 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's value, which keys enum 1276, or enum 1275 with 50 subtracted when over 50. + * Null for npcs without a mapping, which fall back to the standard [Expression] animations. + */ +fun familiarChatheadAnimation(npcId: String): Int? { + if (!npcId.endsWith("_familiar")) { + 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/skill/summoning/Summoning.kt b/game/src/main/kotlin/content/skill/summoning/Summoning.kt index 78ed98042e..7d77d729ad 100644 --- a/game/src/main/kotlin/content/skill/summoning/Summoning.kt +++ b/game/src/main/kotlin/content/skill/summoning/Summoning.kt @@ -144,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")) } /** 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..28e94ffa58 --- /dev/null +++ b/game/src/test/kotlin/content/skill/summoning/FamiliarDialogueExpressionsTest.kt @@ -0,0 +1,36 @@ +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 `Npcs without a chathead mapping fall back to expression animations`() { + assertNull(familiarChatheadAnimation("hans")) + assertNull(familiarChatheadAnimation("phoenix_familiar")) + } +} From c9c237227e7970cfc97e7c5144938ea3081c847e Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Mon, 6 Jul 2026 13:41:03 -0700 Subject: [PATCH 130/136] fix: stop unmapped familiar chatheads playing the enums' default Familiars with chathead value 0 (arctic bear, albino rat, minotaurs) or an out-of-range second-bank key (unicorn stallion) have no entry in enums 1276/1275, so the lookup returned the enums' default - the cat's animation - and playing it on a mismatched head model crashed the client. Missing entries now fall back to the standard expression animations like any unmapped npc. --- .../content/entity/player/dialogue/DialogueCommon.kt | 8 +++++--- .../skill/summoning/FamiliarDialogueExpressionsTest.kt | 9 +++++++++ 2 files changed, 14 insertions(+), 3 deletions(-) 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 3cc1050966..992092f217 100644 --- a/game/src/main/kotlin/content/entity/player/dialogue/DialogueCommon.kt +++ b/game/src/main/kotlin/content/entity/player/dialogue/DialogueCommon.kt @@ -41,7 +41,9 @@ fun Interfaces.sendChat( * 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's value, which keys enum 1276, or enum 1275 with 50 subtracted when over 50. - * Null for npcs without a mapping, which fall back to the standard [Expression] animations. + * Null for npcs without a mapping or without an enum entry (the enums' default is another + * familiar's animation, which crashes the client on a mismatched head model) - those fall back + * to the standard [Expression] animations. */ fun familiarChatheadAnimation(npcId: String): Int? { if (!npcId.endsWith("_familiar")) { @@ -50,9 +52,9 @@ fun familiarChatheadAnimation(npcId: String): Int? { 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) + EnumDefinitions.intOrNull("pet_details_chathead_animations_sad", value - 50) } else { - EnumDefinitions.int("pet_details_chathead_animations_normal", value) + EnumDefinitions.intOrNull("pet_details_chathead_animations_normal", value) } } diff --git a/game/src/test/kotlin/content/skill/summoning/FamiliarDialogueExpressionsTest.kt b/game/src/test/kotlin/content/skill/summoning/FamiliarDialogueExpressionsTest.kt index 28e94ffa58..4de8a63853 100644 --- a/game/src/test/kotlin/content/skill/summoning/FamiliarDialogueExpressionsTest.kt +++ b/game/src/test/kotlin/content/skill/summoning/FamiliarDialogueExpressionsTest.kt @@ -33,4 +33,13 @@ class FamiliarDialogueExpressionsTest : WorldTest() { assertNull(familiarChatheadAnimation("hans")) assertNull(familiarChatheadAnimation("phoenix_familiar")) } + + @Test + fun `Familiars without an enum entry fall back instead of playing the enum default`() { + // The enums' defaults are the cat's animations (8373/8374); sending those to a + // mismatched head model crashes the client. + assertNull(familiarChatheadAnimation("arctic_bear_familiar"), "value 0 has no enum entry") + assertNull(familiarChatheadAnimation("unicorn_stallion_familiar"), "value 89 - 50 has no enum entry") + assertNull(familiarChatheadAnimation("albino_rat_familiar"), "value 0 has no enum entry") + } } From 686bf2839684e65488d67f55b0e09a8350f0a3f9 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Mon, 6 Jul 2026 13:46:45 -0700 Subject: [PATCH 131/136] fix: replace typographic punctuation that crashes the client writeString sends each char as a single byte, so smart quotes (U+2018/9) and ellipses (U+2026) truncate to control bytes the client's font handling can't render - interacting with the arctic bear, talon beast or swamp titan crashed the game. Replace them with plain ASCII in all client-bound strings. --- .../area/asgarnia/falador/TownCrier.kt | 2 +- .../misthalin/wizards_tower/WizardMizgog.kt | 2 +- .../skill/summoning/familiar/ArcticBear.kt | 20 +++++++++---------- .../skill/summoning/familiar/SwampTitan.kt | 12 +++++------ .../skill/summoning/familiar/TalonBeast.kt | 18 ++++++++--------- .../kotlin/content/skill/thieving/Stalls.kt | 2 +- 6 files changed, 28 insertions(+), 28 deletions(-) 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/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/skill/summoning/familiar/ArcticBear.kt b/game/src/main/kotlin/content/skill/summoning/familiar/ArcticBear.kt index 5018cad84c..67890f6a21 100644 --- a/game/src/main/kotlin/content/skill/summoning/familiar/ArcticBear.kt +++ b/game/src/main/kotlin/content/skill/summoning/familiar/ArcticBear.kt @@ -14,28 +14,28 @@ class ArcticBear : Script { 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’.") + 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.") + 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?") + 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?") + player("What? What's happening?") npc("Maybe he's scented a rival.") - player("I smell something, but it’s not 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.") + 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.") + 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?") + 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/SwampTitan.kt b/game/src/main/kotlin/content/skill/summoning/familiar/SwampTitan.kt index 3e97a3b563..3623a78855 100644 --- a/game/src/main/kotlin/content/skill/summoning/familiar/SwampTitan.kt +++ b/game/src/main/kotlin/content/skill/summoning/familiar/SwampTitan.kt @@ -9,14 +9,14 @@ import world.gregs.voidps.engine.Script class SwampTitan : Script { init { npcOperate("Interact", "swamp_titan_familiar") { - npc("I’m alone, all alone I say.") + 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…") + 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…") + 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 index 3505dfbc7f..829ee1206c 100644 --- a/game/src/main/kotlin/content/skill/summoning/familiar/TalonBeast.kt +++ b/game/src/main/kotlin/content/skill/summoning/familiar/TalonBeast.kt @@ -14,30 +14,30 @@ class TalonBeast : Script { 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("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…") + npc("This place smells odd...") player("Odd?") - npc("Yes, not enough is rotting…") + 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.") + 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!") + 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.") + player("Well, I can see you're going to go far in debates.") } } } diff --git a/game/src/main/kotlin/content/skill/thieving/Stalls.kt b/game/src/main/kotlin/content/skill/thieving/Stalls.kt index 6d08e2c418..52cf11a568 100644 --- a/game/src/main/kotlin/content/skill/thieving/Stalls.kt +++ b/game/src/main/kotlin/content/skill/thieving/Stalls.kt @@ -32,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) From 70373a3de6f4154e28caae14e0fc1125fa1d0d7c Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Mon, 6 Jul 2026 13:49:25 -0700 Subject: [PATCH 132/136] test: scan content sources for multi-byte client-bound characters writeString truncates characters over 0xFF to a control byte the client can't render, so smart quotes or ellipses in dialogue crash the game. Fails with file:line of any offending string, ignoring comments and interface option matchers. --- .../content/TypographicCharactersTest.kt | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 game/src/test/kotlin/content/TypographicCharactersTest.kt 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")}") + } +} From 3fbfe7573cbcdff9e959397a9d39cb0da6710d94 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Mon, 6 Jul 2026 14:00:07 -0700 Subject: [PATCH 133/136] fix: show static chatheads for familiars with unverified map values The varbit map's zeros are unverified gaps from the original capture, so familiars like the desert wyrm and arctic bear resolved no enum entry and bobbed with humanoid expression animations on non-human head models. Give them a static head (-1) instead, matching what the details interface shows, until their real values are identified. --- data/skill/summoning/summoning.npcs.toml | 2 ++ .../content/entity/player/dialogue/DialogueCommon.kt | 12 +++++++----- .../summoning/FamiliarDialogueExpressionsTest.kt | 9 +++++---- 3 files changed, 14 insertions(+), 9 deletions(-) diff --git a/data/skill/summoning/summoning.npcs.toml b/data/skill/summoning/summoning.npcs.toml index 9b54a78ecc..31842826db 100644 --- a/data/skill/summoning/summoning.npcs.toml +++ b/data/skill/summoning/summoning.npcs.toml @@ -285,6 +285,7 @@ range = 30 mage = 30 combat_def = "vampire_bat_familiar" interacts = false +large_head = true [spirit_scorpion_familiar] summoning_time_minutes = 17 @@ -309,6 +310,7 @@ range = 72 mage = 72 combat_def = "arctic_bear_familiar" interacts = false +large_head = true [spirit_spider_familiar] summoning_time_minutes = 15 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 992092f217..153d774ec9 100644 --- a/game/src/main/kotlin/content/entity/player/dialogue/DialogueCommon.kt +++ b/game/src/main/kotlin/content/entity/player/dialogue/DialogueCommon.kt @@ -41,9 +41,11 @@ fun Interfaces.sendChat( * 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's value, which keys enum 1276, or enum 1275 with 50 subtracted when over 50. - * Null for npcs without a mapping or without an enum entry (the enums' default is another - * familiar's animation, which crashes the client on a mismatched head model) - those fall back - * to the standard [Expression] animations. + * + * Familiars whose value has no enum entry (the unverified `0`s in the varbit map) get -1 - a + * static head, like the details interface shows - rather than the enums' default, which is + * another familiar's animation and crashes the client on a mismatched head model. Null only for + * npcs outside the map entirely, which use the standard [Expression] animations. */ fun familiarChatheadAnimation(npcId: String): Int? { if (!npcId.endsWith("_familiar")) { @@ -52,9 +54,9 @@ fun familiarChatheadAnimation(npcId: String): Int? { 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.intOrNull("pet_details_chathead_animations_sad", value - 50) + EnumDefinitions.intOrNull("pet_details_chathead_animations_sad", value - 50) ?: -1 } else { - EnumDefinitions.intOrNull("pet_details_chathead_animations_normal", value) + EnumDefinitions.intOrNull("pet_details_chathead_animations_normal", value) ?: -1 } } diff --git a/game/src/test/kotlin/content/skill/summoning/FamiliarDialogueExpressionsTest.kt b/game/src/test/kotlin/content/skill/summoning/FamiliarDialogueExpressionsTest.kt index 4de8a63853..8da3dd15c9 100644 --- a/game/src/test/kotlin/content/skill/summoning/FamiliarDialogueExpressionsTest.kt +++ b/game/src/test/kotlin/content/skill/summoning/FamiliarDialogueExpressionsTest.kt @@ -35,11 +35,12 @@ class FamiliarDialogueExpressionsTest : WorldTest() { } @Test - fun `Familiars without an enum entry fall back instead of playing the enum default`() { + fun `Familiars without an enum entry show a static head instead of the enum default`() { // The enums' defaults are the cat's animations (8373/8374); sending those to a // mismatched head model crashes the client. - assertNull(familiarChatheadAnimation("arctic_bear_familiar"), "value 0 has no enum entry") - assertNull(familiarChatheadAnimation("unicorn_stallion_familiar"), "value 89 - 50 has no enum entry") - assertNull(familiarChatheadAnimation("albino_rat_familiar"), "value 0 has no enum entry") + assertEquals(-1, familiarChatheadAnimation("arctic_bear_familiar"), "value 0 has no enum entry") + assertEquals(-1, familiarChatheadAnimation("unicorn_stallion_familiar"), "value 89 - 50 has no enum entry") + assertEquals(-1, familiarChatheadAnimation("albino_rat_familiar"), "value 0 has no enum entry") + assertEquals(-1, familiarChatheadAnimation("desert_wyrm_familiar"), "value 0 has no enum entry") } } From 89b4297ac09219e707286266cc358a15d1d2b700 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Mon, 6 Jul 2026 14:07:02 -0700 Subject: [PATCH 134/136] fix: play the enums' default chathead animation like the client does The details interface animates value-0 familiars (desert wyrm, arctic bear) correctly because the client's datamap lookup returns the enum default - a generic head-bob - for missing keys. Match that instead of sending a static head; the earlier crash was the typographic punctuation, not this animation. --- .../content/entity/player/dialogue/DialogueCommon.kt | 12 +++++------- .../summoning/FamiliarDialogueExpressionsTest.kt | 11 ++++------- 2 files changed, 9 insertions(+), 14 deletions(-) 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 153d774ec9..1a8b26a85c 100644 --- a/game/src/main/kotlin/content/entity/player/dialogue/DialogueCommon.kt +++ b/game/src/main/kotlin/content/entity/player/dialogue/DialogueCommon.kt @@ -41,11 +41,9 @@ fun Interfaces.sendChat( * 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's value, which keys enum 1276, or enum 1275 with 50 subtracted when over 50. - * - * Familiars whose value has no enum entry (the unverified `0`s in the varbit map) get -1 - a - * static head, like the details interface shows - rather than the enums' default, which is - * another familiar's animation and crashes the client on a mismatched head model. Null only for - * npcs outside the map entirely, which use the standard [Expression] animations. + * 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 only for npcs outside the map + * entirely, which use the standard [Expression] animations. */ fun familiarChatheadAnimation(npcId: String): Int? { if (!npcId.endsWith("_familiar")) { @@ -54,9 +52,9 @@ fun familiarChatheadAnimation(npcId: String): Int? { 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.intOrNull("pet_details_chathead_animations_sad", value - 50) ?: -1 + EnumDefinitions.int("pet_details_chathead_animations_sad", value - 50) } else { - EnumDefinitions.intOrNull("pet_details_chathead_animations_normal", value) ?: -1 + EnumDefinitions.int("pet_details_chathead_animations_normal", value) } } diff --git a/game/src/test/kotlin/content/skill/summoning/FamiliarDialogueExpressionsTest.kt b/game/src/test/kotlin/content/skill/summoning/FamiliarDialogueExpressionsTest.kt index 8da3dd15c9..bc2cac8134 100644 --- a/game/src/test/kotlin/content/skill/summoning/FamiliarDialogueExpressionsTest.kt +++ b/game/src/test/kotlin/content/skill/summoning/FamiliarDialogueExpressionsTest.kt @@ -35,12 +35,9 @@ class FamiliarDialogueExpressionsTest : WorldTest() { } @Test - fun `Familiars without an enum entry show a static head instead of the enum default`() { - // The enums' defaults are the cat's animations (8373/8374); sending those to a - // mismatched head model crashes the client. - assertEquals(-1, familiarChatheadAnimation("arctic_bear_familiar"), "value 0 has no enum entry") - assertEquals(-1, familiarChatheadAnimation("unicorn_stallion_familiar"), "value 89 - 50 has no enum entry") - assertEquals(-1, familiarChatheadAnimation("albino_rat_familiar"), "value 0 has no enum entry") - assertEquals(-1, familiarChatheadAnimation("desert_wyrm_familiar"), "value 0 has no enum entry") + 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") } } From 2871387db3daa796d96975866da767dd2d962a08 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Mon, 6 Jul 2026 14:13:30 -0700 Subject: [PATCH 135/136] feat: resolve pet dialogue chatheads through the cs2 enum lookup talkToPet keyed the chathead enum by npc id - the wrong key space, the enums key on the varbit value - and passed the resulting animation name as an expression, which double-prefixed the lookup and rendered a static head. Mapped pets (sneakerpeeper) now resolve through familiarChatheadAnimation like familiars; unmapped pets fall back to the neutral expression. --- data/skill/summoning/summoning.npcs.toml | 3 --- .../content/entity/player/dialogue/DialogueCommon.kt | 11 ++++++----- .../main/kotlin/content/skill/summoning/pet/Pet.kt | 11 ++++------- .../summoning/FamiliarDialogueExpressionsTest.kt | 7 +++++++ 4 files changed, 17 insertions(+), 15 deletions(-) diff --git a/data/skill/summoning/summoning.npcs.toml b/data/skill/summoning/summoning.npcs.toml index 31842826db..7e2c05c4f3 100644 --- a/data/skill/summoning/summoning.npcs.toml +++ b/data/skill/summoning/summoning.npcs.toml @@ -285,7 +285,6 @@ range = 30 mage = 30 combat_def = "vampire_bat_familiar" interacts = false -large_head = true [spirit_scorpion_familiar] summoning_time_minutes = 17 @@ -310,7 +309,6 @@ range = 72 mage = 72 combat_def = "arctic_bear_familiar" interacts = false -large_head = true [spirit_spider_familiar] summoning_time_minutes = 15 @@ -360,7 +358,6 @@ def = 26 range = 29 mage = 29 combat_def = "honey_badger_familiar" -large_head = true interacts = false [albino_rat_familiar] 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 1a8b26a85c..b914e74a91 100644 --- a/game/src/main/kotlin/content/entity/player/dialogue/DialogueCommon.kt +++ b/game/src/main/kotlin/content/entity/player/dialogue/DialogueCommon.kt @@ -40,13 +40,14 @@ fun Interfaces.sendChat( /** * 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'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 only for npcs outside the map - * entirely, which use the standard [Expression] animations. + * 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")) { + if (!npcId.endsWith("_familiar") && !npcId.startsWith("pet_")) { return null } val values = (VariableDefinitions.get("follower_details_chathead_animation")?.values as? MapValues)?.values ?: return null 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..b86ff0d8e2 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 @@ -194,15 +193,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()) diff --git a/game/src/test/kotlin/content/skill/summoning/FamiliarDialogueExpressionsTest.kt b/game/src/test/kotlin/content/skill/summoning/FamiliarDialogueExpressionsTest.kt index bc2cac8134..aab9240957 100644 --- a/game/src/test/kotlin/content/skill/summoning/FamiliarDialogueExpressionsTest.kt +++ b/game/src/test/kotlin/content/skill/summoning/FamiliarDialogueExpressionsTest.kt @@ -28,10 +28,17 @@ class FamiliarDialogueExpressionsTest : WorldTest() { 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 From 1feaadc82f3e83b350a05dad5c255a704be7e873 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Mon, 6 Jul 2026 14:36:11 -0700 Subject: [PATCH 136/136] fix: check the real level, not summoning points, in summoning gates Summoning's current level doubles as the points pool, so gates using Level.has - summoning a pouch familiar, dropping a pet, incubating an egg, enchanting headgear - wrongly refused players whose points had drained below the requirement. Switch them to hasMax, which reads the level derived from experience. Boostable-skill gates (bunyip's Cooking, pyrelord's Firemaking) are left on the current level. --- .../skill/summoning/EnchantedHeadgear.kt | 4 +- .../content/skill/summoning/Summoning.kt | 6 ++- .../content/skill/summoning/pet/Incubator.kt | 4 +- .../kotlin/content/skill/summoning/pet/Pet.kt | 10 +++-- .../skill/summoning/EnchantedHeadgearTest.kt | 1 + .../skill/summoning/SummoningLevelGateTest.kt | 44 +++++++++++++++++++ .../summoning/pet/IncubatorUseEggTest.kt | 1 + .../skill/summoning/pet/PetExclusivityTest.kt | 1 + 8 files changed, 61 insertions(+), 10 deletions(-) create mode 100644 game/src/test/kotlin/content/skill/summoning/SummoningLevelGateTest.kt diff --git a/game/src/main/kotlin/content/skill/summoning/EnchantedHeadgear.kt b/game/src/main/kotlin/content/skill/summoning/EnchantedHeadgear.kt index 38d3e2b40e..279cf9f0b7 100644 --- a/game/src/main/kotlin/content/skill/summoning/EnchantedHeadgear.kt +++ b/game/src/main/kotlin/content/skill/summoning/EnchantedHeadgear.kt @@ -9,7 +9,7 @@ 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.has +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 @@ -83,7 +83,7 @@ fun Player.enchantHeadgear(item: Item): Boolean { inventory.replace(headgear.enchanted, headgear.base) message("Pikkupstix removes the enchantment from your headwear.") } - else -> if (!has(Skill.Summoning, headgear.level, message = true)) { + 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) diff --git a/game/src/main/kotlin/content/skill/summoning/Summoning.kt b/game/src/main/kotlin/content/skill/summoning/Summoning.kt index 7d77d729ad..3e529d7187 100644 --- a/game/src/main/kotlin/content/skill/summoning/Summoning.kt +++ b/game/src/main/kotlin/content/skill/summoning/Summoning.kt @@ -26,7 +26,7 @@ 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 @@ -344,7 +344,9 @@ class Summoning : Script { val summoningXp = option.item.def["summon_experience", 0.0] val familiar = NPCDefinitions.get(familiarId) val summonCost = option.item.def["summon_points", 0] - if (!has(Skill.Summoning, familiarLevel)) { + // 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 } 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 b86ff0d8e2..10c22a4f5d 100644 --- a/game/src/main/kotlin/content/skill/summoning/pet/Pet.kt +++ b/game/src/main/kotlin/content/skill/summoning/pet/Pet.kt @@ -22,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 @@ -67,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 } @@ -75,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 } @@ -279,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/test/kotlin/content/skill/summoning/EnchantedHeadgearTest.kt b/game/src/test/kotlin/content/skill/summoning/EnchantedHeadgearTest.kt index 41c9619fbc..f4ffea7231 100644 --- a/game/src/test/kotlin/content/skill/summoning/EnchantedHeadgearTest.kt +++ b/game/src/test/kotlin/content/skill/summoning/EnchantedHeadgearTest.kt @@ -22,6 +22,7 @@ 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 } 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/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")