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 d899e60979..c27b9b4aad 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 @@ -137,6 +137,10 @@ object Params { const val REGION = 5136 const val REQ_COMBAT = 5137 const val REQ_ITEMS = 5138 + + /** Item ids a quest expects the player to bring; used by the questprep command. */ + const val REQ_ITEM_IDS = 5267 + const val REQ_QUEST_POINTS = 5139 const val REQ_QUESTS = 5140 const val REQ_SKILLS = 5141 @@ -472,6 +476,7 @@ object Params { "regen_rate_ticks" -> REGEN_RATE_TICKS "region" -> REGION "req_combat" -> REQ_COMBAT + "req_item_ids" -> REQ_ITEM_IDS "req_items" -> REQ_ITEMS "req_quest_points" -> REQ_QUEST_POINTS "req_quests" -> REQ_QUESTS diff --git a/data/area/morytania/braindeath_island/braindeath_island.anims.toml b/data/area/morytania/braindeath_island/braindeath_island.anims.toml index 2a619f7fdb..cce4e03f7c 100644 --- a/data/area/morytania/braindeath_island/braindeath_island.anims.toml +++ b/data/area/morytania/braindeath_island/braindeath_island.anims.toml @@ -14,4 +14,28 @@ id = 5653 id = 5651 [zombie_pirate_death] -id = 5654 \ No newline at end of file +id = 5654 + +[deal_press_press] +id = 2808 + +[deal_smite_control] +id = 2810 + +[deal_wrench_flourish] +id = 2811 + +[deal_bowl_fish] +id = 2813 + +[spirit_attack] +id = 2804 + +[spirit_death] +id = 2805 + +[spirit_parry] +id = 2806 + +[swab_panic] +id = 9560 diff --git a/data/area/morytania/braindeath_island/braindeath_island.combat.toml b/data/area/morytania/braindeath_island/braindeath_island.combat.toml index 37b7449706..9649013d51 100644 --- a/data/area/morytania/braindeath_island/braindeath_island.combat.toml +++ b/data/area/morytania/braindeath_island/braindeath_island.combat.toml @@ -30,3 +30,15 @@ range = 1 anim = "zombie_pirate_attack" target_sound = "zombie_pirate_attack" target_hit = { offense = "crush", max = 60 } + +[evil_spirit] +defend_anim = "spirit_parry" +defend_sound = "deal_spirit_attack" +death_anim = "spirit_death" +death_sound = "rumdeal_spirit_depart" + +[evil_spirit.melee] +range = 1 +anim = "spirit_attack" +target_sound = "deal_spirit_hit" +target_hit = { offense = "slash", max = 280 } diff --git a/data/area/morytania/braindeath_island/braindeath_island.npcs.toml b/data/area/morytania/braindeath_island/braindeath_island.npcs.toml index cd7f78a027..2327b955d2 100644 --- a/data/area/morytania/braindeath_island/braindeath_island.npcs.toml +++ b/data/area/morytania/braindeath_island/braindeath_island.npcs.toml @@ -7,6 +7,7 @@ examine = "A shabby-looking leader." [50_luke] id = 2828 +dialogue = "old" [davey] id = 2829 @@ -14,33 +15,41 @@ examine = "Looks like he's taking a break." [captain_donnie] id = 2830 +dialogue = "old" [zombie_protester] id = 2831 +dialogue = "old" hunt_mode = "spotted" [zombie_protester_2] id = 2832 +dialogue = "old" hunt_mode = "spotted" [zombie_protester_3] id = 2833 +dialogue = "old" hunt_mode = "spotted" [zombie_protester_4] id = 2834 +dialogue = "old" hunt_mode = "spotted" [zombie_protester_5] id = 2835 +dialogue = "old" hunt_mode = "spotted" [zombie_protester_6] id = 2836 +dialogue = "old" hunt_mode = "spotted" [zombie_pirate] id = 2837 +dialogue = "old" hitpoints = 520 att = 49 str = 50 @@ -75,6 +84,7 @@ clone = "zombie_pirate" [zombie_swab] id = 2843 +dialogue = "old" hitpoints = 500 att = 45 str = 50 @@ -85,32 +95,32 @@ categories = ["zombies"] respawn_delay = 37 drop_table = "pirate" examine = "He talks a good fight." -hunt_mode = "cowardly" +hunt_mode = "zombie_swab" [zombie_swab_2] id = 2844 clone = "zombie_swab" -hunt_mode = "cowardly" +hunt_mode = "zombie_swab" [zombie_swab_3] id = 2845 clone = "zombie_swab" -hunt_mode = "cowardly" +hunt_mode = "zombie_swab" [zombie_swab_4] id = 2846 clone = "zombie_swab" -hunt_mode = "cowardly" +hunt_mode = "zombie_swab" [zombie_swab_5] id = 2847 clone = "zombie_swab" -hunt_mode = "cowardly" +hunt_mode = "zombie_swab" [zombie_swab_6] id = 2848 clone = "zombie_swab" -hunt_mode = "cowardly" +hunt_mode = "zombie_swab" [fever_spider] id = 2850 diff --git a/data/area/morytania/braindeath_island/braindeath_island.objs.toml b/data/area/morytania/braindeath_island/braindeath_island.objs.toml index b8b6063350..7f9e91bea3 100644 --- a/data/area/morytania/braindeath_island/braindeath_island.objs.toml +++ b/data/area/morytania/braindeath_island/braindeath_island.objs.toml @@ -12,4 +12,43 @@ examine = "It leads up." [braindeath_island_ladder_down] id = 10168 + +[braindeath_island_stagnant_lake] +id = 10105 + +[braindeath_island_brewing_controls_multi] +id = 10104 + +[braindeath_island_brewing_controls] +id = 10142 + +[braindeath_island_brewing_controls_possessed] +id = 10143 + +[braindeath_island_brewing_controls_working] +id = 10144 + +[braindeath_island_output_tap] +id = 10148 + +[braindeath_island_tool_cupboard] +id = 10162 + +[braindeath_island_tool_cupboard_open] +id = 10163 + +[braindeath_island_pressure_lever_multi] +id = 10164 + +[braindeath_island_pressure_lever_up] +id = 10165 + +[braindeath_island_pressure_lever_down] +id = 10166 + +[braindeath_island_intake_hopper] +id = 10170 + +[braindeath_island_pressure_barrel] +id = 10171 examine = "I can climb down this." diff --git a/data/area/morytania/braindeath_island/braindeath_island.sounds.toml b/data/area/morytania/braindeath_island/braindeath_island.sounds.toml index 0ea7fe50cc..ffd8e24958 100644 --- a/data/area/morytania/braindeath_island/braindeath_island.sounds.toml +++ b/data/area/morytania/braindeath_island/braindeath_island.sounds.toml @@ -6,3 +6,32 @@ id = 924 [zombie_pirate_death] id = 925 + + +[rumdeal_hopper_1] +id = 1762 + +[rumdeal_pressurize] +id = 1763 + +[rumdeal_stuff_other] +id = 1764 + +[rumdeal_stuff_slugling] +id = 1765 + +[wrench_crush] +id = 1766 + +# Not present in the 474 synth list; the reference plays it at the fishing spot. +[rumdeal_bowl_fish] +id = 2600 + +[deal_spirit_attack] +id = 1756 + +[deal_spirit_hit] +id = 1757 + +[rumdeal_spirit_depart] +id = 1755 diff --git a/data/entity/npc/hunt_modes.toml b/data/entity/npc/hunt_modes.toml index 2e5d655560..a313141cac 100644 --- a/data/entity/npc/hunt_modes.toml +++ b/data/entity/npc/hunt_modes.toml @@ -44,6 +44,15 @@ check_not_combat_self = true check_not_too_strong = true check_not_busy = true +# Rum Deal zombie swabs; they lose interest once the quest is over, filtered in ZombieSwab +[zombie_swab] +type = "player" +check_visual = "line_of_sight" +check_not_combat = true +check_not_combat_self = true +check_not_too_strong = true +check_not_busy = true + # Attacking tzhaar crafters [tzhaar_hunter] type = "player" diff --git a/data/entity/player/player.sounds.toml b/data/entity/player/player.sounds.toml index 241885bf0f..9f3c98844d 100644 --- a/data/entity/player/player.sounds.toml +++ b/data/entity/player/player.sounds.toml @@ -39,3 +39,6 @@ id = 761 [unarmed_kick] id = 2565 + +[pour_tea] +id = 2134 diff --git a/data/quest/members/rum_deal/rum_deal.gfx.toml b/data/quest/members/rum_deal/rum_deal.gfx.toml index 5f46016a96..1a1485e6f9 100644 --- a/data/quest/members/rum_deal/rum_deal.gfx.toml +++ b/data/quest/members/rum_deal/rum_deal.gfx.toml @@ -1,2 +1,5 @@ [karamthulhu] id = 627 + +[deal_spotanim] +id = 465 diff --git a/data/quest/members/rum_deal/rum_deal.ifaces.toml b/data/quest/members/rum_deal/rum_deal.ifaces.toml new file mode 100644 index 0000000000..5695b9c58c --- /dev/null +++ b/data/quest/members/rum_deal/rum_deal.ifaces.toml @@ -0,0 +1,7 @@ +[rum_deal_censor] +id = 294 +type = "main_screen" + +[rum_deal_title] +id = 295 +type = "main_screen" diff --git a/data/quest/members/rum_deal/rum_deal.items.toml b/data/quest/members/rum_deal/rum_deal.items.toml index c501425214..aecd87d583 100644 --- a/data/quest/members/rum_deal/rum_deal.items.toml +++ b/data/quest/members/rum_deal/rum_deal.items.toml @@ -2,6 +2,10 @@ [blindweed_seed] id = 6710 tradeable = false +farming_level = 40 +farming_crop = "blindweed" +farming_xp = 32.0 +farming_patch = "blindweed" examine = "A Blindweed seed - plant in a Blindweed patch." kept = "Wilderness" @@ -9,6 +13,7 @@ kept = "Wilderness" id = 6711 tradeable = false weight = 0.007 +farming_xp = 45.0 examine = "An inedible, foul smelling herb." kept = "Wilderness" diff --git a/data/quest/members/rum_deal/rum_deal.jingles.toml b/data/quest/members/rum_deal/rum_deal.jingles.toml new file mode 100644 index 0000000000..2c4b9b3468 --- /dev/null +++ b/data/quest/members/rum_deal/rum_deal.jingles.toml @@ -0,0 +1,5 @@ +[zombie_pirates] +id = 209 + +[captain_braindeath] +id = 79 diff --git a/data/quest/members/rum_deal/rum_deal.npcs.toml b/data/quest/members/rum_deal/rum_deal.npcs.toml index 0ac7608cbd..b4e220e67b 100644 --- a/data/quest/members/rum_deal/rum_deal.npcs.toml +++ b/data/quest/members/rum_deal/rum_deal.npcs.toml @@ -4,7 +4,7 @@ hitpoints = 900 att = 170 str = 146 def = 100 -style = "crush" +style = "slash" max_hit_melee = 280 hunt_mode = "cowardly" drop_table = "ashes" diff --git a/data/quest/members/rum_deal/rum_deal.varbits.toml b/data/quest/members/rum_deal/rum_deal.varbits.toml new file mode 100644 index 0000000000..c3a1973fd4 --- /dev/null +++ b/data/quest/members/rum_deal/rum_deal.varbits.toml @@ -0,0 +1,47 @@ +[rum_deal_pressure_count] +id = 1354 +persist = true + +[rum_deal_brewing_control] +id = 1355 +persist = true + +[rum_deal_squid_spot] +id = 1356 +persist = true + +[rum_deal_slugling_count] +id = 1357 +persist = true + +[rum_deal_karamthulhu_count] +id = 1358 +persist = true + +[rum_deal_swab_a] +id = 1359 +persist = true + +[rum_deal_swab_b] +id = 1360 +persist = true + +[rum_deal_swab_c] +id = 1361 +persist = true + +[rum_deal_swab_d] +id = 1362 +persist = true + +[rum_deal_swab_e] +id = 1363 +persist = true + +[rum_deal_swab_f] +id = 1364 +persist = true + +[rum_deal_diversion] +id = 1365 +persist = true diff --git a/data/quest/members/rum_deal/rum_deal.varps.toml b/data/quest/members/rum_deal/rum_deal.varps.toml index b6be5efc10..1dbb27a5f6 100644 --- a/data/quest/members/rum_deal/rum_deal.varps.toml +++ b/data/quest/members/rum_deal/rum_deal.varps.toml @@ -3,4 +3,4 @@ id = 600 persist = true format = "map" default = "unstarted" -values = { unstarted = 0, started = 1 } +values = { unstarted = 0, agreed_to_help = 1, slay_barrelor = 2, met_braindeath = 3, given_seeds = 4, grown_blindweed = 5, show_blindweed = 6, blindweed_added = 7, fetch_water = 8, collected_water = 9, water_added = 10, catch_creatures = 11, pressurised = 12, bless_wrench = 13, spirit_banished = 14, kill_spider = 15, spider_added = 16, collect_swill = 17, told_donnie = 18, completed = 19 } diff --git a/data/quest/quests.toml b/data/quest/quests.toml index c802a88f6c..026c80bf55 100644 --- a/data/quest/quests.toml +++ b/data/quest/quests.toml @@ -3216,7 +3216,25 @@ req_skills = { Crafting = 42, Slayer = 42 } +req_quests = ["zogre_flesh_eaters"] req_items = "Combat equipment and food, Slayer gloves." +# Everything else is handed out by Captain Braindeath or the basement tool cupboard. +req_item_ids = ["slayer_gloves", "spade"] +variables = [ + "rum_deal", + "rum_deal_pressure_count", + "rum_deal_brewing_control", + "rum_deal_squid_spot", + "rum_deal_slugling_count", + "rum_deal_karamthulhu_count", + "rum_deal_swab_a", + "rum_deal_swab_b", + "rum_deal_swab_c", + "rum_deal_swab_d", + "rum_deal_swab_e", + "rum_deal_swab_f", + "rum_deal_diversion", +] req_combat = "You will need to defeat a level 56 melee enemy and a level 70 ranged enemy." points = 2 reward = "Holy wrench
Access to Braindeath Island
Tangled fishbowl and net" diff --git a/data/skill/farming/farming.varbits.toml b/data/skill/farming/farming.varbits.toml index a7ca74b4f8..778fb737d6 100644 --- a/data/skill/farming/farming.varbits.toml +++ b/data/skill/farming/farming.varbits.toml @@ -40,6 +40,13 @@ persist = true default = "weeds_3" values = { weeds_3 = 0, weeds_2 = 1, weeds_1 = 2, weeds_0 = 3, belladonna_0 = 4, belladonna_1 = 5, belladonna_2 = 6, belladonna_3 = 7, belladonna_life1 = 8, belladonna_diseased_1 = 9, belladonna_diseased_2 = 10, belladonna_diseased_3 = 11, belladonna_dead_1 = 12, belladonna_dead_2 = 13, belladonna_dead_3 = 14 } +[farming_blindweed_patch_braindeath_island] +id = 1366 +format = "map" +persist = true +default = "weeds_3" +values = { weeds_3 = 0, weeds_2 = 1, weeds_1 = 2, weeds_0 = 3, blindweed_0 = 4, blindweed_life1 = 5 } + [farming_evil_turnip_patch_draynor] id = 4291 format = "map" diff --git a/data/skill/farming/farming_produce.toml b/data/skill/farming/farming_produce.toml index df0ec0e27f..0cad2a567e 100644 --- a/data/skill/farming/farming_produce.toml +++ b/data/skill/farming/farming_produce.toml @@ -192,3 +192,6 @@ disease_chance = 30 [potato] disease_chance = 30 + +[blindweed] +disease_chance = 0 diff --git a/data/skill/farming/patch/blindweed_patch.objs.toml b/data/skill/farming/patch/blindweed_patch.objs.toml new file mode 100644 index 0000000000..c63891f3f1 --- /dev/null +++ b/data/skill/farming/patch/blindweed_patch.objs.toml @@ -0,0 +1,31 @@ +[blindweed_patch_weeds_3] +id = 10097 +examine = "Weedy." + +[blindweed_patch_weeds_2] +id = 10098 +examine = "Less weedy." + +[blindweed_patch_weeds_1] +id = 10099 +examine = "Almost clear." + +[blindweed_patch_weeded] +id = 10100 +examine = "Free from weeds." + +[blindweed_seedling] +id = 10101 +examine = "Something is growing here." + +[blindweed_fullygrown] +id = 10102 +harvest = "blindweed" +examine = "Fully grown Blindweed." + +[blindweed_patch_trashed] +id = 10103 +examine = "Weedy, wrecked and infertile." + +[farming_blindweed_patch_braindeath_island] +id = 10096 diff --git a/engine/src/main/kotlin/world/gregs/voidps/engine/client/EncodeExtensions.kt b/engine/src/main/kotlin/world/gregs/voidps/engine/client/EncodeExtensions.kt index 9b1c282650..b7fd5a63c0 100644 --- a/engine/src/main/kotlin/world/gregs/voidps/engine/client/EncodeExtensions.kt +++ b/engine/src/main/kotlin/world/gregs/voidps/engine/client/EncodeExtensions.kt @@ -6,6 +6,7 @@ import world.gregs.voidps.engine.data.definition.FontDefinitions 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.chat.ChatType import world.gregs.voidps.engine.get import world.gregs.voidps.network.login.protocol.encode.* @@ -214,52 +215,81 @@ fun Player.clearMinimap() = client?.sendMinimapState(0) ?: Unit /** * Add an [arrow] hint to a [tile] with [radius] */ -fun Player.hint(tile: Tile, radius: Int = 1, arrow: Int = HintArrow.FILLED, direction: Direction = Direction.NONE, height: Int = 0): Int { - val viewport = viewport ?: return -1 - val index = viewport.hints.firstOrNull { it == 0 } ?: return -1 +fun Player.markHint(tile: Tile, radius: Int = 1, arrow: Int = HintArrow.FILLED, direction: Direction = Direction.NONE, height: Int = 0) { + val viewport = viewport ?: return val type = when (direction) { - Direction.WEST -> 3 - Direction.EAST -> 4 - Direction.SOUTH -> 5 - Direction.NORTH -> 6 - else -> 2 + Direction.WEST -> HintType.WEST + Direction.EAST -> HintType.EAST + Direction.SOUTH -> HintType.SOUTH + Direction.NORTH -> HintType.NORTH + else -> HintType.TILE } + val index = hintIndex(type) ?: return viewport.hints[index] = type client?.arrowHint(type, index, sprite = arrow, x = tile.x, y = tile.y, level = tile.level, z = height, radius = radius) - return index + return } /** - * Add an [arrow] hint over [character] + * Clears arrow hints for those who have this character hinted */ -fun Player.hint(character: Character, arrow: Int = HintArrow.FILLED): Int { - val viewport = viewport ?: return -1 - val index = viewport.hints.firstOrNull { it == 0 } ?: return -1 - val type = when (character) { - is Player -> 10 - is NPC -> 1 - else -> return -1 +fun Character.clearHinted() { + val hints: Set = get("hinted_players") ?: return + val type = if (this is NPC) HintType.NPC else HintType.PLAYER + for (accountName in hints) { + val player = Players.findByAccount(accountName) ?: continue + player.clearHint(type, index) } - viewport.hints[index] = type + clear("hinted_players") +} + +/** + * Add an [arrow] hint over [character] + */ +fun Player.markHint(character: Character, arrow: Int = HintArrow.FILLED) { + val viewport = viewport ?: return + val type = if (character is NPC) HintType.NPC else HintType.PLAYER + val hints = character.getOrPut("hinted_players") { mutableSetOf() } + hints.add(accountName) + val index = hintIndex(type) ?: return + viewport.hints[index] = character.index client?.arrowHint(type, index, sprite = arrow, entityIndex = character.index) - return index +} + +fun Player.clearHint(type: Int, entityIndex: Int) { + val viewport = viewport ?: return + val index = hintIndex(type) ?: return + if (viewport.hints[index] == entityIndex) { + clearHint(type) + } } /** - * Clear the hint at [index] (or all if -1) + * Clear the [type] hint */ -fun Player.clearHint(index: Int = -1) { +fun Player.clearHint(type: Int) { + val viewport = viewport ?: return + val index = hintIndex(type) ?: return + if (viewport.hints[index] == 0) { + return + } + client?.arrowHint(HintType.CLEAR, index) + viewport.hints[index] = 0 +} + +private fun hintIndex(type: Int) = when (type) { + HintType.CLEAR -> null + HintType.PLAYER -> 7 + else -> type - 1 +} + +fun Player.clearHints() { val viewport = viewport ?: return - if (index == -1) { - for (i in viewport.hints.indices) { - if (viewport.hints[i] == 0) { - continue - } - client?.arrowHint(0, i) - viewport.hints[i] = 0 + for (i in viewport.hints.indices) { + if (viewport.hints[i] == 0) { + continue } - } else { - client?.arrowHint(0, index) - viewport.hints[index] = 0 + client?.arrowHint(HintType.CLEAR, i) + viewport.hints[i] = 0 } } \ No newline at end of file diff --git a/engine/src/main/kotlin/world/gregs/voidps/engine/entity/character/mode/Leash.kt b/engine/src/main/kotlin/world/gregs/voidps/engine/entity/character/mode/Leash.kt new file mode 100644 index 0000000000..e22c39bb09 --- /dev/null +++ b/engine/src/main/kotlin/world/gregs/voidps/engine/entity/character/mode/Leash.kt @@ -0,0 +1,104 @@ +package world.gregs.voidps.engine.entity.character.mode + +import world.gregs.voidps.engine.entity.character.Character +import world.gregs.voidps.engine.entity.character.mode.move.Movement +import world.gregs.voidps.engine.entity.character.mode.move.Step +import world.gregs.voidps.engine.entity.character.mode.move.target.FollowTargetStrategy +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.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 + +/** + * Follows [target] on a leash; the npc trails them for as long as both stay within [range] of + * [spawn] and loses interest the moment either doesn't. + * + * Unlike [Follow] - which is written for pets and teleports an npc that falls more than 15 tiles + * behind or ends up on another level - a leashed npc never teleports. It can only ever be [range] + * tiles from [spawn], and a target that outruns, teleports or climbs away from it is simply + * forgotten. + */ +class Leash( + private val npc: NPC, + val target: Character, + private val range: Int, + private val spawn: Tile = npc["spawn_tile"]!!, + private val strategy: TargetStrategy = FollowTargetStrategy(target), +) : Movement(npc, strategy) { + + override fun start() { + npc.watch(target) + } + + override fun tick() { + // within also requires matching levels, so climbing away loses interest too + if (!npc.tile.within(spawn, range) || !target.tile.within(spawn, range)) { + npc.mode = EmptyMode + return + } + if (!npc.watching(target)) { + npc.watch(target) + } + npc.walkTrigger() + // An npc already beside its target just faces it rather than shuffling onto the follow + // tile. A target standing on top of the npc falls through so [stepOut] can move it to a + // free adjacent tile. + if (!underTarget() && target.tile.distanceTo(npc) <= 1) { + npc.steps.clearDestination() + return + } + // Npcs single-step rather than pathfind, so the destination is recalculated every tick + npc.steps.clearDestination() + super.tick() + } + + override fun recalculate(): Boolean { + if (target.tile.distanceTo(npc) <= 1) { + return false + } + if (equals(strategy.tile, npc.steps.destination)) { + return false + } + npc.steps.queueStep(strategy.tile, noRun = strategy.forceWalk(npc)) + return true + } + + override fun getTarget(): Step? { + val step = npc.steps.peek() + if (step == null) { + recalculate() + return npc.steps.peek() + } + return super.getTarget() + } + + /** + * The 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 { + 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)) { + npc.steps.queueStep(npc.tile.add(direction), noRun = strategy.forceWalk(npc)) + break + } + } + return true + } + + private fun underTarget(): Boolean = Overlap.isUnder(npc.tile, npc.size, npc.size, target.tile, target.size, target.size) + + override fun onCompletion() { + } + + override fun stop(replacement: Mode) { + npc.clearWatch() + } +} diff --git a/game/src/main/kotlin/content/area/misthalin/lumbridge/swamp/chams_of_tears/Juna.kt b/game/src/main/kotlin/content/area/misthalin/lumbridge/swamp/chams_of_tears/Juna.kt index 652feee85e..297671a42e 100644 --- a/game/src/main/kotlin/content/area/misthalin/lumbridge/swamp/chams_of_tears/Juna.kt +++ b/game/src/main/kotlin/content/area/misthalin/lumbridge/swamp/chams_of_tears/Juna.kt @@ -1,22 +1,12 @@ package content.area.misthalin.lumbridge.swamp.chams_of_tears import content.entity.player.dialogue.* -import content.entity.player.dialogue.type.ChoiceOption -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.entity.player.dialogue.type.* import content.entity.player.modal.Tab import content.entity.player.modal.tab -import content.quest.closeTabs -import content.quest.openTabs -import content.quest.quest -import content.quest.questComplete -import content.quest.questCompleted -import content.quest.refreshQuestJournal +import content.quest.* import world.gregs.voidps.engine.Script import world.gregs.voidps.engine.client.clearCamera -import world.gregs.voidps.engine.client.command.adminCommand import world.gregs.voidps.engine.client.message import world.gregs.voidps.engine.client.moveCamera import world.gregs.voidps.engine.client.turnCamera @@ -58,12 +48,6 @@ import kotlin.math.floor class Juna : Script { init { - adminCommand("test") { - clearCamera() - moveCamera(Tile(3233, 9505, 2), height = 700, speed = 5, acceleration = 5) - turnCamera(Tile(3227, 9493, 2), height = 200, speed = 5, acceleration = 5) - } - objectOperate("Talk-to", "juna_tears,juna") { (target) -> talkWith(junaNpc()) if (equipped(EquipSlot.Weapon).id == "stone_bowl") { diff --git a/game/src/main/kotlin/content/area/morytania/braindeath_island/Brewer.kt b/game/src/main/kotlin/content/area/morytania/braindeath_island/Brewer.kt index b71c3e7505..1bc7418d4f 100644 --- a/game/src/main/kotlin/content/area/morytania/braindeath_island/Brewer.kt +++ b/game/src/main/kotlin/content/area/morytania/braindeath_island/Brewer.kt @@ -6,107 +6,204 @@ import content.entity.player.dialogue.Happy import content.entity.player.dialogue.Laugh import content.entity.player.dialogue.Neutral import content.entity.player.dialogue.Quiz +import content.entity.player.dialogue.Sad import content.entity.player.dialogue.Scared import content.entity.player.dialogue.Shifty import content.entity.player.dialogue.Shock import content.entity.player.dialogue.type.npc import content.entity.player.dialogue.type.player -import content.quest.questCompleted +import content.quest.questStage 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.inv.inventory +/** + * Brewers on Braindeath Island. Eight unique NPCs all share this dialogue + * handler with personality-specific responses at each branch point. + * + * NPC IDs (Java) -> roles: + * 2851 - "one of THEM" / paranoid + * 2852 - "Game over, man!" / panicked + * 2853 - calm but secretly crying + * 2854 - angry war veteran + * 2855 - opportunistic traitor / Hungry Frank + * 2856 - sleep-deprived / drunk + * 2857 - paranoid about disguised zombies + * 2858 - resigned to dying + */ class Brewer : Script { init { - npcOperate("Talk-to", "brewer") { - if (!questCompleted("rum_deal")) { - return@npcOperate + npcOperate("Talk-to", "brewer*") { (target) -> + val brewer = target.number() + when (questStage("rum_deal")) { + 13 -> wrenchSearch(brewer) + 19 -> postQuestGreeting(brewer) + else -> initialGreeting(brewer) } - player("Hello there!") - npc("I don't know what your game is, but I know you're one of THEM!") - player("But I just saved you!") - npc("The voices tell me different. It's all part of a plot! Confess!") } + } - npcOperate("Talk-to", "brewer_2") { - if (!questCompleted("rum_deal")) { - return@npcOperate - } - player("Hello there!") - npc("Have they gone yet?") - player("Well, no, but they are a lot calmer now.") - npc("What are we gonna do now, huh? What are we gunna do now?") - player("In your case I would say relax.") - } + // ===== Default greeting (during the quest, before the wrench step) ===== - npcOperate("Talk-to", "brewer_3") { - if (!questCompleted("rum_deal")) { - return@npcOperate + private suspend fun Player.initialGreeting(brewer: Int) { + player("So... how are you holding up?") + when (brewer) { + 1 -> { + npc("Who are you?") + npc("You're one of THEM arent you!") + player("O...kay...walking away now, smiling and not making eye contact.") } - player("Hello there!") - npc("Hello yourself!") - player("How's things?") - npc("Fine...") - player("Excellent! Since I get the feeling I don't want to know why you said that so oddly I'll just go over here!") - npc("I think that would be for the best!") - } - - npcOperate("Talk-to", "brewer_4") { - if (!questCompleted("rum_deal")) { - return@npcOperate + 2 -> { + npc("Game over, man! Game over!") + player("Errr...keep up the good work.") } - player("Hello there!") - npc("Hello yerself Landlubber!") - player("Everything ok with you now?") - npc("Hmmm...Overall everything is good!") - player("Great!") - } - - npcOperate("Talk-to", "brewer_5") { - if (!questCompleted("rum_deal")) { - return@npcOperate + 3 -> { + npc("Very well, considering.") + player("Considering what?") + npc("That I've been crawling into the corner to cry whenever nobody is looking.") + player("Wow... I always thought pirates were tough...") + } + 4 -> { + npc("Don't sneak up on me like that!") + npc("Don't you know there's a war on?") + player("I didn't think this counted as a war.") + npc("That's because you're a landlubber!") + npc("I bet ye've never had to beat a dozen zombies to death with a blunt toothpick!") + player("That's right. Have you?") + npc("No, I've spent most of my time in the toilet to be honest.") + } + 5 -> npc("Don't talk to me! If I play this right, I can sell you lot out and make it to the mainland!") + 6 -> { + npc("No school today mother, my brain hurts real baaaad...") + player("What?") + npc("I no sleep in many, many, many days.") + } + 7 -> { + npc("How do I know you're not one of them?") + player("Well, I don't know... the way I'm breathing could give it away.") + npc("That means nothing! NOTHING!") + } + 8 -> { + npc("Very well.") + player("You sure?") + npc("Oh Guthix, who am I kidding?") + npc("We're all gonna die!") } - player("Hello there!") - npc("Hello there, brave hero, in whom I had total confidence!") - player("Total confidence?") - npc("Yes! I was so confident that I would never, ever have sold your soft, edible body to the pirates outside!") - player("Well, great...") - npc("On a completely unrelated note, I would steer clear of Hungry Frank for a while.") - npc("He's a filthy liar. And a forger. It wouldn't surprise me if he has written out a note detailing the terms of our surrender and your dismemberment and cooking in MY handwriting.") - npc("Imagine that, the fiend.") } + } - npcOperate("Talk-to", "brewer_6") { - if (!questCompleted("rum_deal")) { - return@npcOperate + // ===== Progress 13: Looking for a priest and a wrench ===== + + private suspend fun Player.wrenchSearch(brewer: Int) { + if (inventory.contains("holy_wrench")) { + // Show off the blessed wrench + player("Bask in the glory of my Holy Wrench!") + when (brewer) { + 1 -> npc("Oooh...It's so shiny...") + 2 -> npc("(Sob) It's so beautiful...") + 3 -> npc("I am not worthy to gaze upon it!") + 4 -> npc("I can feel the aura of Wrenchly Power radiating from it!") + 5 -> npc("Take it away, I am not worthy to be in its Wrenchly presence.") + 6 -> npc("Wow...I don't feel tired any more...") + 7 -> npc("Such power!") + 8 -> npc("We're saved!") + } + } else { + // Asking around for help + player("I'm looking for a priest. And a wrench.") + when (brewer) { + 1 -> npc("What possible plan could involve a priest and a wrench?") + 2 -> { + npc("What? Are you gonna build some strange mechanical priest to save us?") + npc("What if it runs riot? We'll all be killed!") + } + 3 -> npc("Well good luck. If you find any spare trousers please pass them my way.") + 4 -> npc("Well the usual combo is an old priest and a young priest, but whatever floats your boat lad.") + 5 -> npc("What are you going to do to that priest? No, wait, I don't want to know.") + 6 -> { + npc("Try Davey, he might have seen it.") + player("Thanks!") + npc("His giant, green, inflatable dwarf might be able to tell you where one is as well.") + } + 7 -> npc("If this is some sort of secret zombie code then I don't get it.") + 8 -> { + npc("Well I can't help you there.") + player("Oh, sorry to have bothered you.") + npc("No problem.") + npc("It's not like I'm going to live through this or anything.") + npc("What if they get me while I sleep?") + npc("I don't wanna die!") + } } - player("So...got any sleep yet?") - npc("My brain is no longer capable of sleep.") - player("So...what are you going to do now?") - npc("I was gonna try and will myself dead.") - player("Right...good luck with that.") } + } - npcOperate("Talk-to", "brewer_7") { - if (!questCompleted("rum_deal")) { - return@npcOperate - } + // ===== Progress 19: Post-quest greeting ===== + + private suspend fun Player.postQuestGreeting(brewer: Int) { + // The sleep-deprived brewer gets a different opener + if (brewer == 6) { + player("So...got any sleep yet?") + } else { player("Hello there!") - npc("Well you proved that you're probably not a zombie.") - npc("So what are you then? A ghoul? A vampyre?") - player("I'm not any form of undead!") - npc("Oh...oh god I'm sorry, I didn't realise it was natural...") - player("What?") - npc("Nothing...") } - - npcOperate("Talk-to", "brewer_8") { - if (!questCompleted("rum_deal")) { - return@npcOperate + when (brewer) { + 1 -> { + npc("I don't know what your game is, but I know you're one of THEM!") + player("But I just saved you!") + npc("The voices tell me different. It's all part of a plot! Confess!") + } + 2 -> { + npc("Have they gone yet?") + player("Well, no, but they are a lot calmer now.") + npc("What are we gonna do now, huh? What are we gunna do now?") + player("In your case I would say relax.") + } + 3 -> { + npc("Hello yourself!") + player("How's things?") + npc("Fine...") + player("Excellent! Since I get the feeling I don't want to know why you said that so oddly I'll just go over here!") + npc("I think that would be for the best!") + } + 4 -> { + npc("Hello yerself Landlubber!") + player("Everything ok with you now?") + npc("Hmmm...Overall everything is good!") + player("Great!") + } + 5 -> { + npc("Hello there, brave hero, in whom I had total confidence!") + player("Total confidence?") + npc("Yes! I was so confident that I would never, ever have sold your soft, edible body to the pirates outside!") + player("Well, great...") + npc("On a completely unrelated note, I would steer clear of Hungry Frank for a while.") + npc("He's a filthy liar. And a forger. It wouldn't surprise me if he has written out a note detailing the terms of our surrender and your dismemberment and cooking in MY handwriting.") + npc("Imagine that, the fiend.") + } + 6 -> { + npc("My brain is no longer capable of sleep.") + player("So...what are you going to do now?") + npc("I was gonna try and will myself dead.") + player("Right...good luck with that.") + } + 7 -> { + npc("Well you proved that you're probably not a zombie.") + npc("So what are you then? A ghoul? A vampire?") + player("I'm not any form of undead!") + player("What?") + npc("Nothing...") + } + 8 -> { + npc("You saved us! Huzzah!") + player("All in a day's work, think nothing of it.") + npc("I don't have anything to reward you with except my collection of bleak, gothic poetry I wrote when I assumed we were all done for. Do you want it?") + player("I may come for it later, you hang on to it for now.") } - player("Hello there!") - npc("You saved us! Huzzah!") - player("All in a day's work, think nothing of it.") - npc("I don't have anything to reward you with except my collection of bleak, gothic poetry I wrote when I assumed we were all done for. Do you want it?") - player("I may come for it later, you hang on to it for now.") } } + + /** Brewers are named `brewer` then `brewer_2` through `brewer_8`. */ + private fun NPC.number(): Int = id.substringAfterLast('_').toIntOrNull() ?: 1 } diff --git a/game/src/main/kotlin/content/area/morytania/braindeath_island/CaptainBraindeath.kt b/game/src/main/kotlin/content/area/morytania/braindeath_island/CaptainBraindeath.kt new file mode 100644 index 0000000000..3a80fb095e --- /dev/null +++ b/game/src/main/kotlin/content/area/morytania/braindeath_island/CaptainBraindeath.kt @@ -0,0 +1,650 @@ +package content.area.morytania.braindeath_island + +import content.entity.effect.clearTransform +import content.entity.effect.transform +import content.entity.player.dialogue.Angry +import content.entity.player.dialogue.Drunk +import content.entity.player.dialogue.Happy +import content.entity.player.dialogue.Laugh +import content.entity.player.dialogue.Neutral +import content.entity.player.dialogue.Quiz +import content.entity.player.dialogue.Sad +import content.entity.player.dialogue.Shifty +import content.entity.player.dialogue.Shock +import content.entity.player.dialogue.type.ChoiceOption +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.entity.player.inv.item.addOrDrop +import content.quest.questComplete +import content.quest.questStage +import content.quest.refreshQuestJournal +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.client.clearCamera +import world.gregs.voidps.engine.client.moveCamera +import world.gregs.voidps.engine.client.turnCamera +import world.gregs.voidps.engine.client.ui.close +import world.gregs.voidps.engine.client.ui.open +import world.gregs.voidps.engine.entity.character.jingle +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.equip.equipped +import world.gregs.voidps.engine.entity.character.player.male +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.event.AuditLog +import world.gregs.voidps.engine.inv.add +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.network.login.protocol.visual.update.player.EquipSlot +import world.gregs.voidps.type.Tile + +class CaptainBraindeath : Script { + init { + npcOperate("Talk-to", "captain_braindeath") { (target) -> + when (questStage("rum_deal")) { + 2 -> introCutsceneIntro() + 3 -> handoutBlindweedSeed() + 5 -> deliverBlindweed() + 7 -> nextStagnantWater() + 10 -> nextSluglings() + 12 -> slugShockSnap() + 14 -> finalIngredient() + 16 -> spiderInHopper() + 17 -> spokeToCaptainNo() + 18 -> spokeToCaptainYes() + 19 -> offerRum() + else -> defaultGreeting() + } + } + } + + // ===== Progress 2: Wake-up + intro cutscene ===== + + private suspend fun Player.introCutsceneIntro() { + npc("Are ye alright, ${ladOrLass()}?") + player("Ohhhh... My head...") + player("It feels like someone has smacked me one with a bottle...") + npc("Arr... Those devils gave ye a nasty knock when ye came to aid us.") + npc("But now yer here we'll run those evil brain-eatin' dogs off the island fer good!") + player("What? What is going on here?") + player("I can't seem to remember anything beyond chatting to a man at the docks.") + npc("Arr. Well, ${ladOrLass()}, that would be Pete, one of my men.") + npc("He's been out lookin' fer heroes like yerself to aid us in our peril.") + npc("When ye arrived ye took a nasty knock to the head, so ye probably don't remember agreein' to help us out. But I swear to ye that ye did.") + player("Okay... I'll buy that. It sounds like something I would do.") + player("So where am I, and what is going on?") + npc("Yer on Braindeath Island!") + npc("Where it lies is a secret, because ye are standin' in the brewery of Cap'n Braindeath, purveyor of the most vitriolic alcoholic beverages in the world!") + player("Wow!") + npc("I am the notorious alchemist Cap'n Braindeath, and this whole operation be my idea!") + npc("With my crew of sturdy, upright pirate brewers, we sail the seven seas, distributing cheap 'alcohol' to all and sundry.") + npc("Well, fer a price, at any rate.") + player("Oooh!") + npc("These be dark times, though, ${ladOrLass()}. See, a week ago we awoke to find ourselves beseiged.") + npc("The lads and I have held them off so far, but 'tis only a matter of time before they sweep through the buildin' and put us all to the sword.") + player("Who?") + npc("Them!") + statement("The Captain points out of the window...", clickToContinue = false) + playProtestCutscene() + player("...protesting?") + npc("Arr, ${ladOrLass()}! That they are!") + npc("Day and night they seek to break our will with their chantin', and their singin' and their passive resistance!") + npc("Seems they lost their fightin' spirit after the first few days. Now most of them just protest all the time.") + player("So, what do you want me to do?") + npc("Well, me and the lads got our heads together and decided that if we can get their Cap'n drunk enough, perhaps they'll stop protestin'.") + npc("If that happens, we'll slip out the back and set up shop somewhere else.") + player("Well, how can I help?") + npc("Well, first of all, we need someone to go out the front and grow us some Blindweed.") + npc("'Tis one of the ingredients of our 'rum'.") + npc("The only problem is that those rottin' fiends have torn up and destroyed all but one of the Blindweed Patches.") + if (hasSpace()) { + addOrDrop("blindweed_seed") + set("rum_deal", "given_seeds") + npc("Here, ${ladOrLass()}. I'll give ye the seed you'll need fer growin' the herb. Help yerself to the gardenin' equipment in the basement.") + blindweedWarnings() + } else { + set("rum_deal", "met_braindeath") + npc("Well, I have some Blindweed seeds fer ye. When ye have some free space fer them, come and talk to me.") + } + } + + private suspend fun Player.blindweedWarnings() { + npc("I'll warn ye again that those devils are sat right on top of the patch.") + npc("Try hecklin' 'em from a distance. Those Swabs may talk a good fight, but if ye can put a scare in 'em they'll keep out of yer way!") + } + + // ===== Progress 3: Came back with space, hand out the seed ===== + + private suspend fun Player.handoutBlindweedSeed() { + if (hasSpace()) { + addOrDrop("blindweed_seed") + set("rum_deal", "given_seeds") + npc("Finally, ${ladOrLass()}! Here, take this seed and grow us some Blindweed.") + npc("Ye can help yerself to the gardenin' equipment in the basement, just be quick about it!") + blindweedWarnings() + } else { + defaultGreeting() + } + } + + // ===== Progress 5: Player either has Blindweed or lost it ===== + + private suspend fun Player.deliverBlindweed() { + if (inventory.contains("blindweed")) { + player("Here! I have your Blindweed!") + npc("Splendid, ${ladOrLass()}! Go shove it in the Intake Hopper upstairs.") + set("rum_deal", "show_blindweed") + npc("We'll beat them zombies yet! Arr!") + } else { + player("Captain... I grew some blindweed but then I lost it...") + npc("There there, ${ladOrLass()}. 'Tis all right.") + if (hasSpace()) { + npc("Here, I've been savin' some fer an emergency.") + set("rum_deal", "show_blindweed") + npc("Go shove that in the Intake Hopper, and try not ter lose this lot!") + addOrDrop("blindweed") + } else { + npc("When ye've quite finished blubbin', come see me and I'll give ye some Blindweed.") + npc("Clear a little space from yer pack first though, ${ladOrLass()}, or ye'll be hard pressed to hold it.") + } + } + } + + // ===== Progress 7: Stagnant water step ===== + + private suspend fun Player.nextStagnantWater() { + player("Well, that takes care of the Blindweed. What now?") + npc("Well, now ye've shoved the Blindweed into the mix, what we need is a bucket of stagnant water.") + player("Where can I get some of that?") + npc("Ye won't have to go far, ${ladOrLass()}, we have a pool of the stuff here!") + player("Here in the brewery?") + npc("No, ${ladOrLass()}, that would be a strange thing to have in a brewery!") + npc("It's up the mountain to the north.") + player("Up a mountain?") + npc("Well, 'tis technically a volcano, but ye get the general idea.") + player("And I assume the place is crawling with these zombies?") + npc("No ${ladOrLass()}, there be not a zombie in sight.") + player("Oh, good!") + npc("All ye have to do is get past the keen-eyed lookout that's been spottin' my men when I send 'em.") + npc("I'll tell ye that it won't be easy!") + player("When is it ever...") + npc("Don't ferget yer bucket!") + if (!inventory.contains("bucket") && hasSpace()) { + addOrDrop("bucket") + } + set("rum_deal", "fetch_water") + } + + // ===== Progress 10: Sluglings step ===== + + private suspend fun Player.nextSluglings() { + player("So is that everything?") + npc("No, ${ladOrLass()}, next ye'll need to go outside and catch five loads of Sluglings fer the brew.") + player("What? Sluglings? That's disgusting!") + npc("Arr, ${ladOrLass()}, that it be.") + npc("To a weak-stomached, knock-kneed landlubber...") + player("Why Sluglings?") + npc("'Cos, ${ladOrLass()} they're one of our super-secret ingredients!") + npc("Yer not too susceptible to mind control are ye ${ladOrLass()}?") + player("Why?") + npc("Because they have been known te, well, influence people every now and again.") + if (isSeaSlugComplete()) { + player("They aren't related to those Seaslugs are they?") + npc("Aye they're related! Tis a good job they'll starve if they tried te eat yer brains!") + } else { + npc("I'm sure ye've got nothing te worry about!") + } + player("So what should I do with these Sluglings anyway?") + npc("Well, ye shove them in the Pressure Barrel in the attic.") + npc("And then ye...") + player("And then I...") + npc("And then ye...") + npc("Pressurise 'em.") + player("Pressurise them?!") + npc("Look, we don't have all day, get movin'.") + if (hasSpace()) { + set("rum_deal", "catch_creatures") + if (inventory.contains("fishbowl_and_net")) { + npc("Arr! I see ye've already got a fishbowl in a net.") + } else { + addOrDrop("fishbowl_and_net") + npc("Here. Ye'll need this to catch them.") + } + player("What should I do with it?") + npc("Just dunk it in the water. I'm sure a clever ${ladOrLass()} like yerself will have no problem.") + npc("Oh, and if ye haul up some squiddy-looking things, don't tate to shove 'em in the barrel too.") + npc("They add a special, fishy texture to the drink.") + } else { + npc("Ye'll be needin' a fishbowl in a big net to help ye catch them slimy little blighters.") + set("rum_deal", "catch_creatures") + npc("Pity ye ain't got any free space to carry one.") + } + } + + // ===== Progress 12: Slug-shock recovery ===== + + private suspend fun Player.slugShockSnap() { + player("How could I kill my sluggy brethren...?") + npc("Snap out of it, ${ladOrLass()}! Yer in slug-shock!") + player("What? Who?") + player("Gah! Sorry about that.") + npc("No problem. Well, now ye have just one more ingredient to grab, and then we can get this 'rum' flowin!") + player("Well, how far away will I have to go to grab it?") + npc("Not far at all, ${ladOrLass()}. Ye've just got to get it from the basement!") + player("Great! What is it?") + npc("Hold yer horses, ${ladOrLass()}! While ye was off gallivantin' with yer slimy aquatic playmates, the 'rum' achieved spiritual critical mass.") + npc("To put it in terms ye'll understand: the brewin' equipment is possessed.") + player("Possessed!") + npc("Don't ye worry yerself about it! This happens all the time.") + npc("Well, to tell the truth, my lads are a little quicker off the mark, so it only happens occasionally.") + npc("Not that I'm criticisin' yer performance, ${ladOrLass()}.") + if (hasSpace()) { + inventory.add("wrench") + set("rum_deal", "bless_wrench") + npc("Give the controls a couple of belts with this wrench.") + npc("One of the lads did a little priestin' on the side before he came here. Get him to bless it and ye'll do fine.") + } else { + set("rum_deal", "bless_wrench") + npc("We've got a special item fer dealin' with this problem. Dump some of that stuff yer carryin' and I'll hand it over!") + } + } + + // ===== Progress 14: Final ingredient (fever spider body) ===== + + private suspend fun Player.finalIngredient() { + npc("Well, now that ye've got that spirit out of there ye can dump in the final ingredient.") + player("And that is?") + npc("We need the body of a diseased Fever Spider!") + player("Remind me never to drink anything you have ever made. Or touched.") + npc("When yer quite done flappin' yer lips, go down into the basement and whack spiders until ye find a fever spider body.") + npc("Shove it in the hopper, and then we're in business.") + when { + wearingSlayerGloves() -> npc("I see yer already wearin' some Slayer Gloves. That'll keep the Fever Spiders from gnawin' yer hands off!") + inventory.contains("slayer_gloves") -> { + npc("Ye'll be wanting to put them Slayer Gloves of yours on before ye head off ${ladOrLass()}, as those Fever Spiders carry a nasty disease.") + npc("They'll give it to ye if ye aren't wearin' somethin' too thick fer them to bite through.") + } + else -> { + npc("Ye may want to hop back to the mainland and see a Slayer Master.") + npc("These Fever Spiders carry a nasty disease, and ye'll need a pair of Slayer Gloves on to not catch it!") + } + } + set("rum_deal", "kill_spider") + } + + // ===== Progress 16: Spider in hopper, brew sequence ===== + + private suspend fun Player.spiderInHopper() { + player("Well, I stuck your spider in the hopper, what now?") + npc("Now ye stand well back and watch the glory of brewin' at its best!") + playBrewCutscene() + player("Is that it?") + npc("Aye ${ladOrLass()}! Now get outside and feed that stuff to the pirates.") + npc("Try givin' it to the Captain, he's in charge. Get him bladdered and the rest will fall!") + when { + inventory.contains("bucket") -> { + npc("Ye'll need to use that bucket of yours. Most stuff can't stand bein' in contact with our 'rum' fer too long.") + npc("Took us a lot of dissolvin' to work that one out.") + set("rum_deal", "collect_swill") + } + hasSpace() -> { + inventory.add("bucket") + npc("Here, ye'll need to use one of these. The rum tends to eat through almost everything else.") + set("rum_deal", "collect_swill") + } + else -> { + npc("Oh, and don't try using anything but a bucket. Our 'rum' tends to eat through stuff.") + } + } + } + + // ===== Progress 17/18: Reporting back from the Captain ===== + + private suspend fun Player.spokeToCaptainNo() { + npc("So, what did he say?") + player("Who?") + npc("The Cap'n!") + player("Oh! I haven't spoken to him yet!") + npc("Well, get a move on!") + if (!inventory.contains("unsanitary_swill")) { + npc("And don't ferget the 'rum'!") + } + } + + private suspend fun Player.spokeToCaptainYes() { + npc("So, what did he say?") + player("Not much that was coherent.") + player("Who is Rabid Jack?") + npc("Rabid Jack!") + npc("THE Rabid Jack!") + npc("Egad...I haven't heard that name...") + npc("...before.") + player("So, who is he?") + npc("Dunno ${ladOrLass()}.") + npc("Almost as if I aren't changing subjects, well done!") + npc("With those rottin' dogs legless they'll never keep fighting us now, so we've decided to stay here and keep the 'rum' flowin!") + npc("Thanks, ${ladOrLass()}. We'd never have managed without ye!") + completeRumDeal() + } + + // ===== Progress 19: Post-quest "want some rum?" ===== + + private suspend fun Player.offerRum() { + npc("Fancy some 'rum', ${ladOrLass()}? It's still fresh. Well, fresh-ish...") + player("No... I think I'll pass.") + } + + // ===== Default greeting / generic options menu ===== + + private suspend fun Player.defaultGreeting() { + player("So...") + defaultOptionsChoice() + } + + suspend fun Player.defaultOptionsChoice() { + choice { + whatDoYouWantMeToDo() + whyTalkLikePirate() + whatDoYouMakeHere() + } + } + + fun ChoiceOption.whatDoYouWantMeToDo(): Unit = option("What exactly do you want me to do?") { + when (questStage("rum_deal")) { + 3 -> npc("Well, I need ye to go grow me some Blindweed, but ye are carryin' too much stuff fer me to give ye the seed.") + 4 -> growBlindweedReminder() + 6 -> putBlindweedInHopper() + 8 -> goGetStagnantWater() + 9 -> pourBucketInHopper() + 11 -> catchFiveSeaCreatures() + 13 -> clearEvilSpirit() + 15 -> jamBodyInHopper() + } + } + + fun ChoiceOption.whyTalkLikePirate(): Unit = option("Why do you talk like a pirate?") { + player("Why do you talk like a pirate? Didn't you tell me you were an alchemist?") + npc("Arr, ${ladOrLass()}, 'tis true.") + npc("However, 'tis also true that I stumbled across the basic recipe fer my most potent of brews in a terrible alchemical accident.") + npc("See, 'twas a dark and stormy night, and the wind was howlin' around the trees as I worked late into the night.") + npc("Steppin' too close to a candle with my flask in my hand, I was suddenly swept up in a terrible, yet potently alcoholic, explosion.") + player("And?") + npc("Well' ${ladOrLass()}, it seems the fumes from that first batch of me 'rum' did strange things to me brain.") + npc("I don't remember the exact words the healers used, but apparently the stuff burned out the tiny, specialised part of me brain that tells me not to talk like a pirate.") + player("That... that... that sounds utterly impossible!") + npc("Arr! That be what I told them!") + } + + fun ChoiceOption.whatDoYouMakeHere(): Unit = option("So what do you make here anyway?") { + npc("'Rum', ${ladOrLass()}!") + npc("The finest, most potent, most flammable and most debilitatin' 'rum' in the whole of RuneScape!") + player("Rum, eh?") + npc("No, ${ladOrLass()}, 'rum'!") + player("What's the difference?") + npc("Well, see, it's like this.") + npc("If we called the stuff we make 'rum' without makin' the little quote gestures every time, then the Cookin' Guild has promised to do entertaininly painful things to us with whisks.") + npc("See, technically -") + npc("And by that I mean technically according to the Disposal of Hazardous Waste Act and the Health and Safety Laws -") + npc("technically, what we're brewin' here is Artificially Produced Hyper Condensed Sweetened 'Rum' Flavour Distillate.") + player("Riiiiiiiight...") + npc("So ye see, ${ladOrLass()}, we just call it 'rum' because the real name be a bit of a mouthful.") + npc("Want a drop?") + player("No thanks... I think I'll skip it for now.") + } + + // ===== Per-progress reminder branches from the options menu ===== + + private suspend fun Player.growBlindweedReminder() { + npc("Arr, well I want ye to get outside and grow some Blindweed.") + npc("Best be careful, ${ladOrLass()}, fer them pirates will skin ye alive if they catch ye.") + when { + get("farming_blindweed_patch_braindeath_island", 0) >= 4 -> { + npc("Ye'll need ter keep an eye on yer plants, ${ladOrLass()}.") + npc("Wouldn't want them pirates to trample all over 'em, would ye?") + } + inventory.contains("blindweed_seed") -> { + // Player already has the seed — nothing more to say + } + else -> { + player("And what happens if I lose the seed?") + npc("Then I'll have ye flogged, hung, slapped with a haddock and sent back out there to fight the zombie hordes with little more than harsh language!") + if (hasSpace()) { + npc("Arr... Here, ${ladOrLass()}. Just take this and get goin'!") + inventory.add("blindweed_seed") + } else { + npc("Come back and see me when ye ain't loaded down with stuff, and I'll give ye another.") + } + } + } + } + + private suspend fun Player.putBlindweedInHopper() { + npc("I want ye to go put yer Blindweed in the intake hopper, and to be sharp about it!") + if (inventory.contains("blindweed")) { + return + } + player("And what happens if I lose the Blindweed between here and the intake hopper?") + player("Theoretically, of course, as it would be almost impossible for me to simply lose the stuff in the time it will take me.") + npc("Gah!") + npc("Yer nothin' better than a sea-slug wearin', lilly-legged, bandy-livered landlubber!") + if (hasSpace()) { + npc("Here, I was savin' a little fer an emergency, so take this and shove it in the hopper before I bung ye in there and turn the handle!") + addOrDrop("blindweed") + player("You had some all the time?") + player("Why was I risking my neck killing pirates to grow some if you had some all along?") + npc("Less talk, ${ladOrLass()}, haven't ye got a hopper to be fillin'?") + } else { + npc("I've got a little put aside fer an emergency. Drop some of that junk yer carryin', and come see me to get it.") + } + } + + private suspend fun Player.goGetStagnantWater() { + npc("To get off yer lazy behind and head out to the stagnant lake on top of the volcano.") + npc("Chop chop, ${ladOrLass()}.") + if (!inventory.contains("bucket") && hasSpace()) { + inventory.add("bucket") + } + npc("Don't ferget yer bucket!") + } + + private suspend fun Player.pourBucketInHopper() { + npc("I want ye to go pour yer bucket into yonder hopper.") + npc("Take yer time, ${ladOrLass()}, we're only bein' besieged by zombies.") + if (inventory.contains("bucket_of_water_stagnant")) { + return + } + // Stolen-bucket scene + player("Err... Captain...") + npc("Aye, what is it, ${ladOrLass()}?") + player("Well, on my way over to the hopper some big boy stole the bucket and ran away...") + npc("Give me strength!") + npc("If ye weren't so good at distractin' the zombies I'd skin ye alive!") + if (hasSpace()) { + inventory.add("bucket_of_water_stagnant") + npc("Take this bucket and pour it into the hopper. If ye don't get it right this time I'll have yer guts fer garters!") + } else { + npc("Well, 'tis a good job I saved a little, isn't it? Now get rid of something so I can give it to ye.") + } + } + + private suspend fun Player.catchFiveSeaCreatures() { + npc("I want ye to go outside and catch five sea creatures from the squid fishin' spot.") + npc("When ye've grabbed them, jam 'em in the barrel in the attic and pressurise 'em.") + if (inventory.contains("fishbowl_and_net")) { + return + } + player("How am I suppost to catch these things?") + npc("Ye'll be needin' to use a fishbowl in a big net to snare these little devils.") + player("And if I don't have one?") + when { + !inventory.contains("fishbowl") && inventory.contains("big_fishing_net") -> { + if (hasSpace()) { + inventory.add("fishbowl") + npc("Yer lucky all my precious fish died last week, ${ladOrLass()}. Take this and wrap it in that net, then go grab some Sluglings!") + } else { + npc("Well, I'll give ye a fishbowl fer that net of yours if ye'll drop some of that clutter in yer pack.") + } + } + !inventory.contains("big_fishing_net") && inventory.contains("fishbowl") -> { + npc("Gah! I don't believe it!") + if (hasSpace()) { + inventory.add("big_fishing_net") + npc("Here, wrap this around yer fishbowl and get back out there!") + } else { + npc("Well, I'll give ye a net fer that fishbowl of yours if ye'll drop some of that clutter in yer pack.") + } + } + else -> { + npc("Why, I oughta...") + if (hasSpace()) { + npc("Here! Try not ter lose this one! These things don't grow on trees, ye know.") + inventory.add("fishbowl_and_net") + } else { + npc("Come see me when yer not so laden down. I've got another one fer ye if ye need it.") + } + } + } + } + + private suspend fun Player.clearEvilSpirit() { + npc("I want ye to go clear the Evil Spirit out of the brewin' controls. Use the wrench I gave ye, but get it blessed first.") + when { + inventory.contains("holy_wrench") -> npc("I see ye've got the wrench good and blessed, so go whack the controls till the Evil Spirit pops out.") + inventory.contains("wrench") -> { + // Player has the unblessed wrench — nothing more to say + } + else -> { + player("Mumblemumblewhatwrenchmumble...") + npc("What were that?") + player("I said, what if I've lost the wrench?") + if (hasSpace()) { + inventory.add("wrench") + npc("Arr! I'd get more work out of a minty flavoured brick!") + npc("If ye lose this one I'll have ye turned inside out, covered with spiders and turned rightside in so they'll eat ye alive!") + player("Steady on!") + } else { + npc("Arr, ye landlubber!") + npc("Calm down, remember yer ulcer...") + npc("Look, I've got another wrench, but ye don't have any free space fer it. Come back when ye do.") + } + } + } + } + + private suspend fun Player.jamBodyInHopper() { + when { + wearingSlayerGloves() -> npc("I see yer already wearin' some Slayer Gloves. That'll keep the Fever Spiders from gnawin' yer hands off!") + inventory.contains("fever_spider_body") -> { + npc("I want ye to jam that body into the hopper and come see me.") + npc("Arr! Yer not as slow as I took ye fer!") + } + else -> { + npc("I want ye to go kill a Fever Spider and jam its body in the hopper.") + npc("Whenever yer ready, ${ladOrLass()}.") + } + } + } + + // ===== Helpers ===== + + private fun Player.ladOrLass(): String = if (male) "lad" else "lass" + + private fun Player.hasSpace(): Boolean = inventory.spaces > 0 + + private fun Player.wearingSlayerGloves(): Boolean = equipped(EquipSlot.Hands).id == "slayer_gloves" + + private fun Player.isSeaSlugComplete(): Boolean = get("sea_slug", "") == "completed" + + private suspend fun Player.playProtestCutscene() { + open("fade_out") + delay(3) + // 1957 is an unnamed, model-less npc; transforming into it hides the player while + // the camera pans over the protest + transform("rupert_the_beard_3") + + jingle("zombie_pirates") + tele(2152, 5098, 0) + moveCamera( + tile = Tile(2145, 5088), + height = 310, + acceleration = 100, + ) + turnCamera( + tile = Tile(2145, 5066), + height = 300, + acceleration = 100, + ) + delay(1) + + moveCamera( + tile = Tile(2156, 5088), + height = 310, + speed = 2, + acceleration = 0, + ) + delay(2) + + close("fade_out") + delay(2) + + open("rum_deal_title") + delay(17) + + open("fade_out") + delay(2) + + close("rum_deal_title") + clearTransform() + tele(2144, 5108, 1) + delay(2) + + clearCamera() + open("fade_in") + player("Are they...") + } + + private suspend fun Player.playBrewCutscene() { + moveCamera( + tile = Tile(2144, 5106), + height = 250, + ) + turnCamera( + tile = Tile(2144, 5099), + height = 100, + ) + delay(3) + set("rum_deal_brewing_control", 2) + delay(3) + clearCamera() + } + + private fun Player.completeRumDeal() { + jingle("quest_complete_1") + exp(Skill.Prayer, 7000.0) + exp(Skill.Fishing, 7000.0) + exp(Skill.Farming, 7000.0) + addOrDrop("holy_wrench") + inc("quest_points", 2) + AuditLog.event(this, "quest_completed", "rum_deal") + set("rum_deal", "completed") + set("rum_deal_pressure_count", 0) + set("rum_deal_brewing_control", 2) + set("rum_deal_slugling_count", 0) + set("rum_deal_karamthulhu_count", 0) + for (swab in 'a'..'f') { + set("rum_deal_swab_$swab", 1) + } + set("farming_blindweed_patch_braindeath_island", "weeds_0") + refreshQuestJournal() + questComplete( + "Rum Deal", + "2 Quest Points", + "7,000 Prayer XP", + "7,000 Fishing XP", + "7,000 Farming XP", + "A Holy Wrench", + item = "holy_wrench", + ) + } +} diff --git a/game/src/main/kotlin/content/area/morytania/braindeath_island/CaptainDonnie.kt b/game/src/main/kotlin/content/area/morytania/braindeath_island/CaptainDonnie.kt index c758f36efd..09965d0147 100644 --- a/game/src/main/kotlin/content/area/morytania/braindeath_island/CaptainDonnie.kt +++ b/game/src/main/kotlin/content/area/morytania/braindeath_island/CaptainDonnie.kt @@ -1,30 +1,130 @@ package content.area.morytania.braindeath_island +import content.entity.player.dialogue.Angry import content.entity.player.dialogue.Drunk +import content.entity.player.dialogue.Happy +import content.entity.player.dialogue.Neutral import content.entity.player.dialogue.Quiz +import content.entity.player.dialogue.Sad import content.entity.player.dialogue.Shifty +import content.entity.player.dialogue.Shock +import content.entity.player.dialogue.type.ChoiceOption +import content.entity.player.dialogue.type.choice import content.entity.player.dialogue.type.npc import content.entity.player.dialogue.type.player -import content.quest.questCompleted +import content.entity.player.dialogue.type.statement +import content.quest.questStage 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.engine.inv.remove class CaptainDonnie : Script { init { - npcOperate("Talk-to", "captain_donnie") { - if (!questCompleted("rum_deal")) { - return@npcOperate - } - npc("Hey! You!") - player("Who? Me?") - npc("Aye! Ye!") - npc("Got any more rum?") - player("What happens if I don't?") - npc("Then I'll clap ye in Runes!") - player("Don't you mean clap me in irons?") - npc("No lad, not irons, Runes!") - npc("We upgraded last week.") - player("Ok, well it's a good job that I have some over here isn't it?") - npc("Arr, yer a good lad...") + npcOperate("Talk-to", "captain_donnie") { (target) -> + talkWithDonnie() + } + + itemOnNPCOperate("unsanitary_swill", "captain_donnie") { + talkWithDonnie() + } + } + + private suspend fun Player.talkWithDonnie() { + when { + inventory.contains("unsanitary_swill") && questStage("rum_deal") == 17 -> deliverSwillFirstTime() + inventory.contains("unsanitary_swill") -> deliverSwillRepeat() + else -> initialGreeting() + } + } + + private suspend fun Player.initialGreeting() { + npc("Arr! What be ye wantin?") + player("I, err, came to...") + approachChoice() + } + + suspend fun Player.approachChoice() { + choice { + tellYouToLeave() + askWhatYouWanted() + joinYourCrew() + } + } + + fun ChoiceOption.tellYouToLeave(): Unit = option("Tell you to leave.") { + player("I have come to tell you to leave.") + npc("Ye have?") + player("Yes.") + npc("Hahahahahahahahahahahahaha!") + npc("Bwahahahahahahahahahahahaha!") + npc("Wheeze...wheeze...") + npc("Gadzooks lad, that be the funniest thing I've heard all day! Say it again!") + player("I have come to tell you to leave.") + npc("Stop lad! I'll shatter me ribs!") + } + + fun ChoiceOption.askWhatYouWanted(): Unit = option("Ask what you wanted.") { + player("I've come to ask you what you want.") + npc("Whadda we want? 'Rum'! When do we want it? Now!") + player("So...if I were to give you 'rum' you would leave?") + npc("Not really lad. If ye were to give us 'rum' we'd kill ye quickly, as opposed to over a few weeks.") + player("Oh...") + } + + fun ChoiceOption.joinYourCrew(): Unit = option("Join your crew.") { + player("I've come to join your crew!") + player("Err, I mean...") + player("Arr! Shiver me mainbraces and make them landlubbers walk the scurvy plank, Cap'n! I've come to join yer cut-throat, bilge swillin' crew! Also, arr!") + npc("Are ye quite done, lad?") + player("Yes, for the time being anyway.") + npc("Well, ye'll be glad to know that after that little performance I'd be glad to have ye on me crew!") + player("Huzzah!") + npc("Course, I'll have te kill ye first.") + player("Oh...") + npc("Don't ye worry, lad. After we take the island I'll have the boss haul yer body to the temple and...") + npc("Err, never mind.") + player("Never mind what?") + npc("Ferget I said anything.") + } + + private suspend fun Player.deliverSwillFirstTime() { + npc("Be that the finest, most abrasive 'rum' I've ever smelled?") + player("Yes! That it be!") + npc("Hand it over or I'll run ye through!") + inventory.remove("unsanitary_swill") + set("rum_deal", "told_donnie") + advanceSwabVarbits() + statement("The Captain drinks the 'rum' as quickly as possible.") + npc("Arr.") + npc("Ye be a good lad, fer a filthy livin' landlubber.") + player("So... I take it your boss will be pleased?") + npc("Arr, that he will. I'll tell...") + npc("Wait a minute...") + npc("Arr, ye tricky dog!") + npc("Ye tried to trick old Donnie!") + player("Oh well, I guess I'll have to try again.") + npc("Arr, lad, you tried to trick me, but I was too clever for ye!") + npc("Besides, Rabid Jack would have my hide if I told ye it were him that sent me!") + player("I'm sure he would. Good job you caught me out, eh!") + npc("Aye! Now get ye gone, and don't return without more 'rum'!") + } + + private suspend fun Player.deliverSwillRepeat() { + npc("Hey you! I can smell the 'rum' on ye!") + npc("If it isn't in my belly by the time I cout ter three, I'll have ye flogged!") + inventory.remove("unsanitary_swill") + statement("Donnie drinks the 'rum' like it is water.") + npc("Arrr...that hits the spot...") + } + + /** + * Once Donnie has drunk the swill the whole crew joins him, so every zombie pirate + * outside the compound switches to its drunk variant. + */ + private fun Player.advanceSwabVarbits() { + for (swab in 'a'..'f') { + set("rum_deal_swab_$swab", 1) } } } diff --git a/game/src/main/kotlin/content/area/morytania/braindeath_island/Davey.kt b/game/src/main/kotlin/content/area/morytania/braindeath_island/Davey.kt new file mode 100644 index 0000000000..93938df5ce --- /dev/null +++ b/game/src/main/kotlin/content/area/morytania/braindeath_island/Davey.kt @@ -0,0 +1,159 @@ +package content.area.morytania.braindeath_island + +import content.entity.player.dialogue.Happy +import content.entity.player.dialogue.Neutral +import content.entity.player.dialogue.Quiz +import content.entity.player.dialogue.Shifty +import content.entity.player.dialogue.type.npc +import content.entity.player.dialogue.type.player +import content.quest.questStage +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.client.clearCamera +import world.gregs.voidps.engine.client.moveCamera +import world.gregs.voidps.engine.client.turnCamera +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.add +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.engine.inv.replace +import world.gregs.voidps.type.Tile + +class Davey : Script { + init { + npcOperate("Talk-to", "davey") { (target) -> + walkToDelay(tile = Tile(2132, 5099, 1), forceWalk = true) + when (questStage("rum_deal")) { + 13 -> rumDealHelpRequest(target) + 19 -> postQuestGreeting() + else -> defaultGreeting() + } + } + } + + private suspend fun Player.defaultGreeting() { + player("Hello!") + npc("'Ello guv.") + player("Why are you not helping out the others?") + npc("Well you see, guv, I've got these unshakable religious convictions.") + player("And those are?") + npc("Well, it's like this, see. I used to do a bit of the old priestin' on the side.") + npc("You know, collectin' for repairs to the church roof, passing the collection plate, jumble sales, nicking the lead off the temple roof so they needed repairing again.") + npc("Real holy stuff, you know.") + player("That doesn't sound all that holy to me.") + npc("Well, that's because you're not a man of the cloth are you?") + npc("You are, in fact, what we refer to in layman's terms as a punter.") + npc("But anyway, all that priestin' left me with an unshakable faith in three things.") + npc("The power of good over evil.") + npc("The true glory of the human spirit.") + npc("And that I ain't goin out there with those things runnin' around.") + } + + private suspend fun Player.rumDealHelpRequest(dave: NPC) { + player("You used to be a priest, right?") + npc("I didn't nick anything, guv. I've got twenty people who'll swear blind I was...") + player("What?") + player("Never mind. I need some help with the spirit in the brewing equipment.") + when { + inventory.contains("holy_wrench") -> npc("You mean more help? I've already blessed one wrench for you, that should be enough.") + !inventory.contains("wrench") -> npc("Well, I'll bless something for you if you need it, but just don't spread it around that I'm giving out freebies.") + levels.get(Skill.Prayer) >= 47 -> { + npc("Trust me, guv, lots of people need help after coming into contact with the spirits we produce here.") + askToBlessWrench(dave) + } + else -> askToBlessWrench(dave) + } + } + + private suspend fun Player.askToBlessWrench(dave: NPC) { + player("Could you bless this wrench for me?") + if (levels.get(Skill.Prayer) >= 47) { + blessTheWrench(dave) + } else { + npc("Nope.") + player("Nope?") + npc("Nope. You don't strike me as being... devout, you know?") + player("Come again?") + npc("Your holy aura, it's a little shoddy, if you catch my drift.") + player("What?") + npc("You need a Prayer of 47.") + player("Oh!") + player("Well why didn't you just say so?") + npc("I might have had a bit of an overestimation problem guv. I'm sure it won't happen again.") + } + } + + private suspend fun Player.blessTheWrench(dave: NPC) { + npc("I might well do. Remember, only the first one's free.") + npc("Dominoes Ad Nauseum, Romanes Eunt Domus.") + npc("Sorted.") + player("Is that it?") + npc("Oh, you want the full package deal.") + npc("All right. Brace yourself.") + playBlessingCutscene(dave) + player("Thanks!") + npc("No problem, guv. Good luck with your little problem.") + npc("You might find that little wrench worth hanging on to after you're done with the Spirit.") + player("Really? I mean, it's holy and everything, but I don't think it looks all that useful.") + npc("Well, it may not look much, but you'll find that you might need a few less prayer potions if you have it in your pack, if you know what I mean.") + player("Well, no. I don't know what you mean, but I'm sure I'll find out!") + } + + private suspend fun Player.postQuestGreeting() { + if (inventory.contains("holy_wrench")) { + npc("Ello again guv. How's things?") + player("Good! Everything seems to have worked out ok.") + npc("I'm sure it'll stay that way so long as you don't know it's missing.") + player("What?") + npc("Nothin' guv, you just have a nice day.") + } else { + npc("Ello again guv. I take it you're here about the wrench?") + player("What? The Holy Wrench? But I lost it somewhere...") + npc("Well guv, maybe that's what I want you to think.") + if (inventory.add("holy_wrench")) { + npc("There you go. I've kept it nice and holy for you.") + player("Thanks...") + } else { + npc("As soon as you've got somewhere to put it I'll hand it over. I'll keep it nice and holy until then.") + } + } + } + + private suspend fun Player.playBlessingCutscene(davey: NPC) { + davey.anim("emote_cheer") + inventory.replace("wrench", "holy_wrench") + delay(4) + moveCamera( + tile = Tile(2134, 5099), + height = 180, + speed = 10, + acceleration = 70, + ) + turnCamera( + tile = Tile(2130, 5099), + height = 100, + speed = 10, + acceleration = 70, + ) + delay(2) + moveCamera( + tile = Tile(2135, 5099), + height = 275, + speed = 1, + acceleration = 70, + ) + turnCamera( + tile = Tile(2132, 5099), + height = 200, + speed = 10, + acceleration = 70, + ) + delay(3) + gfx("deal_spotanim") + anim("deal_wrench_flourish") + delay(3) + say("Groovy.") + delay(3) + clearCamera() + } +} diff --git a/game/src/main/kotlin/content/area/morytania/braindeath_island/FeverSpider.kt b/game/src/main/kotlin/content/area/morytania/braindeath_island/FeverSpider.kt index 985dc276b8..bf3e43e4c7 100644 --- a/game/src/main/kotlin/content/area/morytania/braindeath_island/FeverSpider.kt +++ b/game/src/main/kotlin/content/area/morytania/braindeath_island/FeverSpider.kt @@ -1,6 +1,7 @@ package content.area.morytania.braindeath_island import content.entity.combat.hit.damage +import content.skill.prayer.protectMelee import world.gregs.voidps.engine.Script import world.gregs.voidps.engine.entity.character.player.Player import world.gregs.voidps.engine.entity.character.player.equip.equipped @@ -10,9 +11,13 @@ import world.gregs.voidps.network.login.protocol.visual.update.player.EquipSlot class FeverSpider : Script { init { npcCondition("slayer_gloves") { it is Player && it.equipped(EquipSlot.Hands).id == "slayer_gloves" } + npcCondition("no_slayer_gloves") { it is Player && it.equipped(EquipSlot.Hands).id != "slayer_gloves" } + npcAttack("fever_spider", "disease") { - val damage = (it.levels.get(Skill.Constitution) / 100.0) * 12.5 + // Protecting from melee blocks the bite itself but not the disease, which the + // combat definition applies regardless. + val damage = if (it.protectMelee()) 0.0 else (it.levels.get(Skill.Constitution) / 100.0) * 12.5 it.damage(damage.toInt()) } } diff --git a/game/src/main/kotlin/content/area/morytania/braindeath_island/Luke.kt b/game/src/main/kotlin/content/area/morytania/braindeath_island/Luke.kt new file mode 100644 index 0000000000..1f53b88db8 --- /dev/null +++ b/game/src/main/kotlin/content/area/morytania/braindeath_island/Luke.kt @@ -0,0 +1,99 @@ +package content.area.morytania.braindeath_island + +import content.entity.player.dialogue.Angry +import content.entity.player.dialogue.Happy +import content.entity.player.dialogue.Neutral +import content.entity.player.dialogue.Quiz +import content.entity.player.dialogue.Sad +import content.entity.player.dialogue.Shifty +import content.entity.player.dialogue.Shock +import content.entity.player.dialogue.type.ChoiceOption +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.male + +class Luke : Script { + init { + npcOperate("Talk-to", "50_luke") { (target) -> + player("Are you all right?") + npc("Who goes there?") + npc("Arr! A landlubber!") + npc("Begone afore I take my cutlass to ye! I've been charged with guardin' this gate and no noodle-armed landlubbers will make it past me alive!") + player("That's not a cutlass.") + player("I think it's a twig.") + npc("Ye cheeky begger! I was wavin' my finger at ye!") + player("All right...") + npc("Arrr... just 'cos me body happens to be 50% wood does not mean I'm heartless.") + npc("I got a bag of 'em here. Wanna see?") + player("I think I'll pass.") + mainQuestionsChoice() + } + } + + suspend fun Player.mainQuestionsChoice() { + choice { + whatHappened() + howFlammable() + whatsGoingOn() + } + } + + fun ChoiceOption.whatHappened(): Unit = option("What happened to you?") { + npc("Well, it all starts with this albatross...") + npc("Wait, never mind, I'll skip forward a bit.") + player("Are you sure?") + npc("I have to lad, Cap'n Donnie will flay what little is left of me if I told ye.") + npc("But anyway, I got recruited to the zombie pirates along with the rest of the crew in an unspecified incident involvin this albatross.") + npc("We was sailin' along happily, and I was partakin' of a little 'rum' in the crows nest.") + npc("Well, we hit either a really rough wave or some rocks.") + npc("Twas kind of hard for me to tell which, as I was well out of it by then!") + npc("Regardless I toppled from the crows nest into the water.") + player("Is that how you got so badly injured?") + npc("No ${ladOrLass()}!") + npc("What happened next was that I discovered a new, previously uncharted reef of hard, spiky coral.") + npc("I made a mental note of its location, and to this day it is still marked on our fleet's charts as Lukes Reef.") + npc("I managed to grab a hold of our ship, the Inebriated, as it passed overhead.") + npc("And then I discovered another, taller, spikier reef of even sharper and more painful coral.") + npc("To this day it is still marked on our fleet's charts as The Other 50% Reef.") + player("Owwwwwwwww...") + npc("It gets worse...") + npc("When they hauled what was left of me on deck, they dropped me onto the floor while they decided what to do with me.") + npc("Bear in mind this would be on a ghost ship, the planks of which sweat a thick mixture of stagnant water...and pure salt crystals.") + player("Oh...my...god...") + npc("But on the good side, all my thrashin' and pained squealin' settled the matter in the Captain's mind, and he had the shipwright carve me half a body out of his Witchwood Planks.") + player("Witchwood? What's that?") + npc("Tis a special, magical wood from a now extinct tree.") + npc("Once they nailed it all in place the stuff moves like it is part of me body.") + npc("The stuff will also grow back if it breaks, which is dead handy!") + player("Wow, that stuff must be very valuable!") + npc("Arr! That it be!") + npc("So, that be the tale of how I managed to lose precisely 50% of my body.") + player("There there.") + } + + fun ChoiceOption.howFlammable(): Unit = option("How flammable are you?") { + player("So how flammable are you?") + npc("What kind of a question is that?") + player("A rather rude, personal one?") + npc("Ye've got that right!") + } + + fun ChoiceOption.whatsGoingOn(): Unit = option("So what is going on here anyway?") { + npc("Ye expect me to talk?") + player("No Mr. Luke, I expect you to die!") + npc("Hah! I'm one step ahead of ye!") + player("Egad, outsmarted by the man with the wooden brain.") + player("But seriously, what is going on here?") + npc("I can't tell ye lad.") + npc("The Cap'n would have me whittled down to toothpicks if I did.") + player("Well if you can't tell me, perhaps you could show me through the medium of Interpretive Dance?") + npc("No. Just...no.") + player("Mime?") + npc("Look, lad I'm not tellin' ye a thing! So clear out while ye still can!") + } + + private fun Player.ladOrLass(): String = if (male) "lad" else "lass" +} diff --git a/game/src/main/kotlin/content/area/morytania/braindeath_island/ZombieProtester.kt b/game/src/main/kotlin/content/area/morytania/braindeath_island/ZombieProtester.kt index a816d03a72..e24933ddbb 100644 --- a/game/src/main/kotlin/content/area/morytania/braindeath_island/ZombieProtester.kt +++ b/game/src/main/kotlin/content/area/morytania/braindeath_island/ZombieProtester.kt @@ -1,14 +1,19 @@ package content.area.morytania.braindeath_island +import content.entity.player.dialogue.Angry +import content.entity.player.dialogue.Bored import content.entity.player.dialogue.Drunk +import content.entity.player.dialogue.Quiz import content.entity.player.dialogue.Shifty import content.entity.player.dialogue.type.npc import content.entity.player.dialogue.type.player -import content.quest.questCompleted +import content.quest.questStage import world.gregs.voidps.engine.Script -import world.gregs.voidps.engine.entity.character.mode.Follow +import world.gregs.voidps.engine.entity.character.mode.Leash +import world.gregs.voidps.engine.entity.character.player.Player import world.gregs.voidps.engine.timer.Timer import world.gregs.voidps.engine.timer.toTicks +import world.gregs.voidps.type.Tile import world.gregs.voidps.type.random import java.util.concurrent.TimeUnit @@ -16,20 +21,23 @@ class ZombieProtester : Script { init { huntPlayer("zombie_protester*", "spotted") { - if (!tile.within(it.tile, 4)) { + val post: Tile = get("spawn_tile") ?: return@huntPlayer + if (!tile.within(it.tile, 4) || !it.tile.within(post, LEASH_RANGE)) { return@huntPlayer } - softTimers.start("protesting") - mode = Follow(this, it) + softTimers.startIfAbsent("protesting") + // Leash rather than Follow; a protester that chased with Follow would teleport after + // the player the moment they outran it or changed level, and Hunting only re-runs for + // an idle, wandering or patrolling npc so nothing would ever call it back. + mode = Leash(this, it, LEASH_RANGE, post) } npcOperate("Talk-to", "zombie_protester*") { - if (!questCompleted("rum_deal")) { - return@npcOperate + if (questStage("rum_deal") >= 18) { + drunkProtester() + } else { + soberProtester() } - npc("Arrrr! Tis yerself! Have a drink!") - player("Errr...Arrr! I will in a sec, I've just go to, err, plunder some landlubbers...") - npc("Good huntin'!") } npcTimerStart("protesting") { TimeUnit.SECONDS.toTicks(30) } @@ -48,11 +56,28 @@ class ZombieProtester : Script { else -> "Rum, rum, we want rum!" }, ) - if (mode is Follow) { - Timer.CONTINUE - } else { - Timer.CANCEL - } + Timer.CONTINUE } } + + private suspend fun Player.soberProtester() { + player("Excuse me, but...") + npc("Arr!") + player("Is there any way you could...") + npc("ARRR!") + player("Is there anyone else I could...") + npc("Arrrrrrrrrr!!!") + player("Fiiine...") + } + + private suspend fun Player.drunkProtester() { + npc("Arrrr! Tis yerself! Have a drink!") + player("Errr...Arrr! I will in a sec, I've just go to, err, plunder some landlubbers...") + npc("Good huntin'!") + } + + companion object { + /** How far a protester will chase from its post before losing interest. */ + private const val LEASH_RANGE = 10 + } } diff --git a/game/src/main/kotlin/content/area/morytania/braindeath_island/ZombieSwab.kt b/game/src/main/kotlin/content/area/morytania/braindeath_island/ZombieSwab.kt new file mode 100644 index 0000000000..83a0933752 --- /dev/null +++ b/game/src/main/kotlin/content/area/morytania/braindeath_island/ZombieSwab.kt @@ -0,0 +1,134 @@ +package content.area.morytania.braindeath_island + +import content.entity.player.dialogue.Angry +import content.entity.player.dialogue.Laugh +import content.entity.player.dialogue.Scared +import content.entity.player.dialogue.type.npc +import content.entity.player.dialogue.type.player +import content.entity.player.dialogue.type.statement +import content.quest.questCompleted +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.client.instruction.handle.interactPlayer +import world.gregs.voidps.engine.client.message +import world.gregs.voidps.engine.client.ui.close +import world.gregs.voidps.engine.client.ui.open +import world.gregs.voidps.engine.data.Settings +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.male + +/** + * The six zombie swabs (2843-2848) guarding the blindweed patch outside the brewery. + * + * Each swab has its own varbit tracking whether the player has intimidated it. Until then + * "Intimidate" runs the heckling routine and the swab attacks on sight; afterwards the swab + * only cowers when talked to. + */ +class ZombieSwab : Script { + + init { + // Swabs use their own hunt mode rather than "cowardly" so aggression can be judged per + // player - the generic handler attacks regardless of how far along the quest is. + huntPlayer("zombie_swab*", "zombie_swab") { target -> + if (!Settings["world.npcs.aggression", true]) { + return@huntPlayer + } + // A swab that has been put in its place won't start anything, and there's nothing + // left to guard once the rum is brewed + if (target.intimidated(this) || target.questCompleted("rum_deal")) { + return@huntPlayer + } + interactPlayer(target, "Attack") + } + + npcOperate("Talk-to", "zombie_swab*") { (target) -> + if (!intimidated(target)) { + return@npcOperate message("I don't think he wants to talk to you.") + } + cower(target) + } + + npcOperate("Intimidate", "zombie_swab*") { (target) -> + if (intimidated(target)) { + return@npcOperate message("I don't think he will be causing any more trouble.") + } + heckle(target) + } + } + + private suspend fun Player.cower(swab: NPC) { + when (swab.number()) { + 1 -> npc("Arr! Do what ye like, just don't hurt my face!") + 2 -> npc("(Gulp) Be there anything ye want, ${sirOrMiss()}?") + 3 -> npc("Be there anything I can say to get out of here undead?") + 4 -> npc("Arrgh! It's ${himOrHer()}! Run fer it lads!") + 5 -> npc("No! Not the flyswatter! I'll be good!") + 6 -> npc("Egad! 'Tis ${himOrHer()}!") + } + } + + private suspend fun Player.heckle(swab: NPC) { + val number = swab.number() + player("Hey you!") + when (number) { + 1 -> npc("Arr! Prepare to die!") + 2 -> npc("Yer a deadman, sez I!") + 3 -> npc("Yer brave, but stupid!") + 4 -> npc("Hahahaha!") + 5 -> npc("Who? Me?") + 6 -> npc("Hey yerself, landlubber!") + } + if (number == 4) { + npc("Yer goin ter die!") + } + statement( + "We interrupt this insult to bring you this soothing picture of a kitten. Normal service will be resumed shortly. Thank you for your patience.", + clickToContinue = false, + ) + open("rum_deal_censor") + open("total_blackness") + delay(8) + close("rum_deal_censor") + close("total_blackness") + set(swab.varbit(), 1) + anim("emote_angry") + when (number) { + 1 -> { + player("...meaning you'll need a dozen Ogres and a team of highly skilled surgeons to remove them!") + backDown(swab) + } + 2 -> { + player("...leading to the eventual, violent explosion of those organs!") + backDown(swab) + } + 3 -> { + player("...and then I'll use a wrench to remove the handles, so you'll see it every time you cough!") + backDown(swab) + } + 4, 5 -> { + message("He seems to have vanished...you must have scared him off.") + swab.respawn(swab.def["respawn_delay", 50]) + } + 6 -> { + player("...so that you will know exactly what time it is due to the horrible pain in your earlobes!") + backDown(swab) + } + } + } + + private suspend fun Player.backDown(swab: NPC) { + npc("That be the most intimidatin' thing I've ever heard! I'm not messin' with you!") + swab.anim("swab_panic") + } + + private fun Player.intimidated(swab: NPC): Boolean = get(swab.varbit(), 0) == 1 + + /** Swabs are named `zombie_swab` then `zombie_swab_2` through `zombie_swab_6`. */ + private fun NPC.number(): Int = id.substringAfterLast('_').toIntOrNull() ?: 1 + + private fun NPC.varbit(): String = "rum_deal_swab_${'a' + number() - 1}" + + private fun Player.sirOrMiss(): String = if (male) "sir" else "miss" + + private fun Player.himOrHer(): String = if (male) "him" else "her" +} diff --git a/game/src/main/kotlin/content/area/morytania/port_phasmatys/PiratePete.kt b/game/src/main/kotlin/content/area/morytania/port_phasmatys/PiratePete.kt index 33c0ff80a1..7d6dcc6f87 100644 --- a/game/src/main/kotlin/content/area/morytania/port_phasmatys/PiratePete.kt +++ b/game/src/main/kotlin/content/area/morytania/port_phasmatys/PiratePete.kt @@ -12,25 +12,51 @@ import content.entity.player.dialogue.type.choice import content.entity.player.dialogue.type.npc import content.entity.player.dialogue.type.player import content.quest.questCompleted +import content.quest.questStage +import content.quest.startCutscene import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.client.clearCamera +import world.gregs.voidps.engine.client.instruction.handle.interactNpc +import world.gregs.voidps.engine.client.moveCamera +import world.gregs.voidps.engine.client.turnCamera +import world.gregs.voidps.engine.client.ui.dialogue.talkWith import world.gregs.voidps.engine.client.ui.open +import world.gregs.voidps.engine.entity.character.jingle 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.Player +import world.gregs.voidps.engine.entity.character.player.male +import world.gregs.voidps.engine.entity.character.player.skill.Skill +import world.gregs.voidps.engine.entity.character.player.skill.level.Level.hasMax import world.gregs.voidps.engine.entity.character.sound import world.gregs.voidps.type.Direction +import world.gregs.voidps.type.Region import world.gregs.voidps.type.Tile import world.gregs.voidps.type.random class PiratePete : Script { init { npcOperate("Talk-to", "pirate_pete*") { (target) -> - if (!questCompleted("rum_deal")) { - player("Hello there!") - npc("Mornin'.") - player("Got any quests?") - npc("I may have a quest, but you don't look like you'd be able handle the kind of monsters I have problems with.") - return@npcOperate + when (questStage("rum_deal")) { + 0 -> { + player("Hello there!") + npc("Mornin'.") + if (!questCompleted("zogre_flesh_eaters") || !hasMax(Skill.Slayer, 42)) { + player("Got any quests?") + npc("I may have a quest, but you don't look like you'd be able handle the kind of monsters I have problems with.") + return@npcOperate + } + return@npcOperate offerQuest(target) + } + 1 -> { + player("Had any luck finding someone to slay the demon?") + return@npcOperate askAgain(target) + } + 2 -> { + player("Hello again!") + return@npcOperate greetAgain(target) + } } player("Do I know you?") npc("Yes, you owe me some money.") @@ -49,7 +75,7 @@ class PiratePete : Script { travel(target, Tile(2162, 5114, 1)) } } - option("Not now") { + option("Not now") { player("I'm getting an awful headache talking to you. Any idea why?") npc("No idea whatsoever.") } @@ -81,6 +107,227 @@ class PiratePete : Script { } } + // ===== Progress 0: The sob story ===== + + private suspend fun Player.offerQuest(pete: NPC) { + npc("Hey...you're an adventurer, right?") + player("Yes I am!") + player("Got any quests for me?") + npc("Yeah, I do, as a matter of fact!") + npc("(Ahem.)") + npc("I am a poor, dispossessed nobleman, forced by circumstance to lurk in the middle of nowhere, soliciting help from passers-by.") + npc("You see, my fiendish half-brother has seized my estates and forced me into exile.") + npc("The simple lemon farmers suffer under his tyrannous yoke, and only a brave adventurer can lift his iron boot from the neck of the poor.") + npc("To reclaim my lands I will need to have my family sword returned to me so that I may present it as proof of my rulership.") + npc("Will you help me find my family sword?") + choice { + option("Yes!") { + player("Yes! Your uncorroborated sob story has touched my heart. When do we set off?") + set("rum_deal", "agreed_to_help") + describeBarrelor(pete) + } + option("No.") { + player("No, I don't think I'll help you out this time.") + waitForSomeoneHeroic() + } + } + } + + private suspend fun Player.describeBarrelor(pete: NPC) { + npc("You'll help! Wonderful!") + npc("But, alas, my half brother has a powerful ally, the mighty demon...") + npc("Err...") + npc("Err...Barrelor!") + npc("Yes, the mighty, fearsome, tall, deadly, oaken, round demon Barrelor the Destroyer.") + player("Barrelor?") + npc("That's what I said!") + npc("Barrelor is an awesome opponent, and to reclaim my family sword you will need to defeat him, for he guards it within the deadly Trapped Pit of Barrelor.") + npc("Wanna give it a shot?") + choice { + option("Of course, I fear no demon!") { + player("Of course, I fear no demon!") + set("rum_deal", "slay_barrelor") + offerPayment(pete) + } + option("Not a chance, this sounds too dangerous.") { + player("Not a chance, this sounds too dangerous.") + waitForSomeoneHeroic() + } + } + } + + private suspend fun Player.offerPayment(pete: NPC) { + npc("Atta ${boyOrGirl()}!") + npc("When I am reinstated in my rightful place, I will not be a very wealthy man, as my half-brother has squandered my family fortune.") + npc("However, I will gladly give you every bent penny of what is left, and starve in the gutter with my many adorable children if you say you will help me.") + choice { + option("Nonsense! Keep the money!") { + player("Nonsense! Keep the money! I will dispose of this evil half-brother of yours and leave you what little money is left to feed your family.") + setOff(pete) + } + option("Great, I'll take the cash in used coins please.") { + player("Great, I'll take the cash in used coins please.") + npc("Er...") + waitForSomeoneHeroic() + } + } + } + + // ===== Progress 1: Turned him down, but came back ===== + + private suspend fun Player.askAgain(pete: NPC) { + npc("What?") + npc("Oh! Yes! The demon, with the oppression and stuff!") + npc("No, not yet. Why?") + choice { + option("Because I came to offer to help.") { + player("Because I came to offer to help.") + set("rum_deal", "slay_barrelor") + offerPayment(pete) + } + option("No reason.") { + player("No reason. I was just in the area and I thought I would ask.") + npc("I see...") + waitForSomeoneHeroic() + } + } + } + + // ===== Progress 2: Agreed to fight, hasn't set off yet ===== + + private suspend fun Player.greetAgain(pete: NPC) { + npc("Oh, it's you again.") + npc("So, what brings you down here today?") + choice { + option("I've decided to help you for free.") { + player("I've decided to help you for free! I will dispose of this evil half-brother of yours and leave you what little money is left to feed your family.") + setOff(pete) + } + option("No reason.") { + player("No reason. I was just in the area and I thought I would ask.") + npc("I see...") + waitForSomeoneHeroic() + } + } + } + + private suspend fun Player.waitForSomeoneHeroic() { + npc("Look, I think I'll wait here for someone a little more... you know...") + npc("...heroic.") + } + + private suspend fun Player.setOff(pete: NPC) { + npc("Wonderful! Just pick up your diversion and we'll leave!") + player("What diversion?") + face(Direction.SOUTH) + introCutscene(pete) + } + + /** + * Pete knocks the player out and rows them to Braindeath Island; they come round in the + * brewery just as Pete is reporting back to Captain Braindeath. + */ + private suspend fun Player.introCutscene(pete: NPC) { + delay(2) + say("Ow!") + pete.anim("mace_pummel") + gfx("stun_long", delay = 20) + sound("cudgel", delay = 15) + open("fade_out") + delay(4) + val cutscene = startCutscene("rum_deal_intro", Region(8527)) + cutscene.onEnd { + open("fade_out") + delay(3) + clearCamera() + tele(2144, 5108, 1) + face(Direction.NORTH) + gfx("stun_long") + sound("stunned") + } + tele(cutscene.tile(2142, 5105, 1), clearInterfaces = false) + val braindeath = NPCs.add("captain_braindeath", cutscene.tile(2142, 5111, 1), Direction.NORTH) + val peteCopy = NPCs.add("pirate_pete", cutscene.tile(2148, 5108, 1), Direction.NORTH) + moveCamera(cutscene.tile(2143, 5107), height = 305) + turnCamera(cutscene.tile(2143, 5111), height = 100) + delay(2) + open("fade_in") + jingle("captain_braindeath") + npc("captain_braindeath", "Arrr... 'Tis lookin' bleak...", clickToContinue = false) + overhead(braindeath, "Arrr... 'Tis lookin' bleak...") + + npc("pirate_pete", "Cap'n!", clickToContinue = false) + overhead(peteCopy, "Cap'n!") + + peteCopy.walkTo(cutscene.tile(2144, 5111, 1)) + npc("pirate_pete", "Good news Cap'n!", clickToContinue = false) + overhead(peteCopy, "Good news Cap'n!") + + npc("pirate_pete", "I found us a hero down by the docks!", clickToContinue = false) + overhead(peteCopy, "I found us a hero down", "by the docks!") + + peteCopy.face(braindeath) + braindeath.face(peteCopy) + npc("captain_braindeath", "Be they heroic, brave and true?", clickToContinue = false) + overhead(braindeath, "Be they heroic, brave and", "true?") + + npc("pirate_pete", "Aye! They also be gullible, tied up and unconscious!", clickToContinue = false) + overhead(peteCopy, "Aye! They also be gullible,", "tied up and unconscious!") + + npc( + "pirate_pete", + "They were willing to help out some random stranger with a good enough sob story, " + + "so I smacked them with a bottle and rowed them over.", + clickToContinue = false, + ) + overhead( + peteCopy, + "They were willing to help", + "out some random stranger", + "with a good enough sob", + "story, so I smacked them", + "with a bottle and rowed", + "them over.", + ) + + npc("captain_braindeath", "Brilliant! The island's location will remain a secret!", clickToContinue = false) + overhead(braindeath, "Brilliant! The island's", "location will remain a", "secret!") + + npc("captain_braindeath", "Bring 'em here and wake 'em up.", clickToContinue = false) + overhead(braindeath, "Bring 'em here and wake", "'em up.") + + npc("captain_braindeath", "We may make it through this yet...", clickToContinue = false) + overhead(braindeath, "We may make it through", "this yet...") + + cutscene.end() + + // The original hands straight over to Braindeath once the player comes round, rather than + // leaving them stood in the brewery. onEnd drops them directly south of his spawn. + delay(2) + val captain = NPCs.findOrNull(tile.regionLevel, "captain_braindeath") + if (captain != null) { + talkWith(captain) + interactNpc(captain, "Talk-to") + } + } + + private fun Player.boyOrGirl(): String = if (male) "boy" else "girl" + + /** + * Cutscene speech. The chatbox line carries no continue arrow because the scene is timed + * rather than clicked through, and the speaker repeats it overhead in the same short chunks + * the original splits it into - overhead text is too narrow for a whole line. + */ + private suspend fun Player.overhead(speaker: NPC, vararg chunks: String) { + for ((index, chunk) in chunks.withIndex()) { + if (index > 0) { + delay(3) + } + speaker.say(chunk) + } + delay(3) + } + private suspend fun Player.travel(target: NPC, tile: Tile) { if (random.nextBoolean()) { npc("Err... sure...") diff --git a/game/src/main/kotlin/content/area/wilderness/daemonheim/DungeoneeringTutor.kt b/game/src/main/kotlin/content/area/wilderness/daemonheim/DungeoneeringTutor.kt index f70e267a6e..55012d9251 100644 --- a/game/src/main/kotlin/content/area/wilderness/daemonheim/DungeoneeringTutor.kt +++ b/game/src/main/kotlin/content/area/wilderness/daemonheim/DungeoneeringTutor.kt @@ -13,12 +13,13 @@ import content.entity.player.dialogue.type.item import content.entity.player.dialogue.type.npc import world.gregs.voidps.engine.Script import world.gregs.voidps.engine.client.clearHint -import world.gregs.voidps.engine.client.hint +import world.gregs.voidps.engine.client.markHint import world.gregs.voidps.engine.client.ui.open import world.gregs.voidps.engine.entity.character.player.Player import world.gregs.voidps.engine.inv.add import world.gregs.voidps.engine.inv.inventory import world.gregs.voidps.engine.timer.toTicks +import world.gregs.voidps.network.login.protocol.encode.HintType import world.gregs.voidps.type.Tile import java.util.concurrent.TimeUnit @@ -42,7 +43,7 @@ class DungeoneeringTutor : Script { } timerStop("dungeoneering_tutor_hint_timeout") { - clearHint() + clearHint(HintType.NPC) } } @@ -70,7 +71,7 @@ class DungeoneeringTutor : Script { choice { option("Yes, please.") { npc("It's this way.") - hint(Tile(3449, 3744), radius = 2) + markHint(Tile(3449, 3744), radius = 2) softTimers.start("dungeoneering_tutor_hint_timeout") } option("No thanks, not right now.") { diff --git a/game/src/main/kotlin/content/entity/death/NPCDeath.kt b/game/src/main/kotlin/content/entity/death/NPCDeath.kt index 6102912454..6d38d992d1 100644 --- a/game/src/main/kotlin/content/entity/death/NPCDeath.kt +++ b/game/src/main/kotlin/content/entity/death/NPCDeath.kt @@ -11,6 +11,7 @@ import content.entity.player.inv.item.tradeable import content.skill.slayer.* import content.social.clan.clan import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.client.clearHinted import world.gregs.voidps.engine.client.message import world.gregs.voidps.engine.client.ui.chat.plural import world.gregs.voidps.engine.data.Settings @@ -71,6 +72,7 @@ class NPCDeath( if (combat.deathSound != null) { (killer as? Player)?.sound(combat.deathSound!!.id) } + clearHinted() delay(if (ticks == 0) 4 else ticks) if (killer is Player) { AuditLog.event(killer, "killed", npc, tile) diff --git a/game/src/main/kotlin/content/entity/death/PlayerDeath.kt b/game/src/main/kotlin/content/entity/death/PlayerDeath.kt index 4d1f03dc64..72660f5e0c 100644 --- a/game/src/main/kotlin/content/entity/death/PlayerDeath.kt +++ b/game/src/main/kotlin/content/entity/death/PlayerDeath.kt @@ -20,6 +20,7 @@ import content.skill.prayer.getActivePrayerVarKey import content.skill.prayer.praying import content.skill.summoning.dismissFamiliar import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.client.clearHinted import world.gregs.voidps.engine.client.message import world.gregs.voidps.engine.data.Settings import world.gregs.voidps.engine.data.definition.Areas @@ -71,6 +72,7 @@ class PlayerDeath : Script { message("Oh dear, you are dead!") anim("human_death") queue.clear() + clearHinted() delay(5) clearAnim() attackers.clear() diff --git a/game/src/main/kotlin/content/entity/player/modal/tab/QuestJournals.kt b/game/src/main/kotlin/content/entity/player/modal/tab/QuestJournals.kt index 7c617e40fd..a6c07666f9 100644 --- a/game/src/main/kotlin/content/entity/player/modal/tab/QuestJournals.kt +++ b/game/src/main/kotlin/content/entity/player/modal/tab/QuestJournals.kt @@ -1,24 +1,13 @@ package content.entity.player.modal.tab import com.github.michaelbull.logging.InlineLogger -import content.entity.player.bank.bank -import content.entity.player.command.find import content.quest.refreshQuestJournal import world.gregs.voidps.engine.Script import world.gregs.voidps.engine.client.clearCamera -import world.gregs.voidps.engine.client.command.adminCommand -import world.gregs.voidps.engine.client.command.boolArg -import world.gregs.voidps.engine.client.command.stringArg import world.gregs.voidps.engine.client.ui.InterfaceApi import world.gregs.voidps.engine.client.ui.closeInterfaces import world.gregs.voidps.engine.data.definition.AccountDefinitions import world.gregs.voidps.engine.data.definition.QuestDefinitions -import world.gregs.voidps.engine.entity.character.player.Player -import world.gregs.voidps.engine.entity.character.player.Players -import world.gregs.voidps.engine.inv.beastOfBurden -import world.gregs.voidps.engine.inv.equipment -import world.gregs.voidps.engine.inv.inventory -import world.gregs.voidps.engine.inv.removeToLimit import world.gregs.voidps.engine.timer.Timer class QuestJournals( @@ -81,46 +70,5 @@ class QuestJournals( set("quest_journal_order", type) } } - - adminCommand( - "reset_quest", - stringArg("quest", desc = "Name of the quest to reset", autofill = questDefinitions.ids.keys), - boolArg("remove-items", desc = "Removes all quest related items from the player", optional = true), - stringArg("player-name", optional = true, autofill = accounts.displayNames.keys), - desc = "Resets a quest for the player", - handler = ::resetQuest, - ) - } - - /** - * Resets a player's progress on a quest - * Note: Not foolproof - * - Doesn't remove them from a quest area - * - Dropped quest items can avoid the reset - * - Doesn't reset rewards - * - Allows player to reclaim rewards (such as XP) - */ - fun resetQuest(player: Player, args: List) { - val target = Players.find(player, args.getOrNull(2)) ?: return - val id = args[0] - val def = questDefinitions.getOrNull(id) ?: return - val vars: List = def.getOrNull("variables") ?: emptyList() - for (variable in vars) { - target.clear(variable) - } - val removeItems = args.getOrNull(1)?.toBooleanStrictOrNull() ?: true - if (removeItems) { - val items: List = def.getOrNull("items") ?: emptyList() - for (item in items) { - removeItems(target, item) - } - } - } - - private fun removeItems(player: Player, item: String) { - player.inventory.removeToLimit(item, Int.MAX_VALUE) - player.bank.removeToLimit(item, Int.MAX_VALUE) - player.beastOfBurden.removeToLimit(item, Int.MAX_VALUE) - player.equipment.removeToLimit(item, Int.MAX_VALUE) } } diff --git a/game/src/main/kotlin/content/minigame/barrows/BarrowsCrypts.kt b/game/src/main/kotlin/content/minigame/barrows/BarrowsCrypts.kt index 7f0f58685b..0b97caa20e 100644 --- a/game/src/main/kotlin/content/minigame/barrows/BarrowsCrypts.kt +++ b/game/src/main/kotlin/content/minigame/barrows/BarrowsCrypts.kt @@ -235,7 +235,7 @@ class BarrowsCrypts : Script { npc.say(if (npc.tile.level == 3) "You dare disturb my rest!" else "You dare steal from us!") npc.interactPlayer(player, "Attack") player["${brother}_spawn"] = npc.index - player.hint(npc) + player.markHint(npc) } } } diff --git a/game/src/main/kotlin/content/quest/InstanceLogout.kt b/game/src/main/kotlin/content/quest/InstanceLogout.kt index 8477c07e76..b4308ed9e7 100644 --- a/game/src/main/kotlin/content/quest/InstanceLogout.kt +++ b/game/src/main/kotlin/content/quest/InstanceLogout.kt @@ -1,6 +1,7 @@ package content.quest import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.client.clearHinted class InstanceLogout : Script { init { @@ -16,6 +17,7 @@ class InstanceLogout : Script { if (get("instance_logout", false)) { exitInstance() } + clearHinted() } } } diff --git a/game/src/main/kotlin/content/quest/Quest.kt b/game/src/main/kotlin/content/quest/Quest.kt index 297abf5d61..de2a239de8 100644 --- a/game/src/main/kotlin/content/quest/Quest.kt +++ b/game/src/main/kotlin/content/quest/Quest.kt @@ -29,6 +29,7 @@ val quests = setOf( "nature_spirit", "plague_city", "priest_in_peril", + "rum_deal", "lost_city", "tears_of_guthix", "the_golem", diff --git a/game/src/main/kotlin/content/quest/QuestCommands.kt b/game/src/main/kotlin/content/quest/QuestCommands.kt new file mode 100644 index 0000000000..31139a7927 --- /dev/null +++ b/game/src/main/kotlin/content/quest/QuestCommands.kt @@ -0,0 +1,136 @@ +package content.quest + +import content.entity.player.bank.bank +import content.entity.player.command.find +import content.entity.player.inv.item.addOrDrop +import world.gregs.voidps.cache.config.data.QuestDefinition +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.client.command.adminCommand +import world.gregs.voidps.engine.client.command.stringArg +import world.gregs.voidps.engine.client.message +import world.gregs.voidps.engine.client.ui.chat.plural +import world.gregs.voidps.engine.data.definition.ItemDefinitions +import world.gregs.voidps.engine.data.definition.QuestDefinitions +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.level.Level +import world.gregs.voidps.engine.get +import world.gregs.voidps.engine.inv.beastOfBurden +import world.gregs.voidps.engine.inv.equipment +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.engine.inv.removeToLimit + +/** + * Sets a player up to attempt a quest: skills raised to the requirements listed in `quests.toml`, + * prerequisite quests marked complete, and any items the quest expects the player to bring handed + * over. Requirements come from the quest definition, so this works for every quest that declares + * them; supply-your-own items are read from the optional `req_item_ids` key. + */ +class QuestCommands : Script { + + init { + adminCommand( + "quest_reset", + stringArg("quest-id", "the quest to reset", autofill = { get().ids.keys }), + desc = "Reset a quest back to unstarted, clearing its variables", + handler = ::reset, + ) + + adminCommand( + "quest_prep", + stringArg("quest-id", "the quest to prepare for", autofill = { get().ids.keys }), + desc = "Meet a quest's skill, quest and item requirements", + handler = ::prepare, + ) + } + + /** + * Puts a quest back to unstarted. Along with the quest variable itself this clears every + * variable named after it, which is how quest progress flags are named - Rum Deal's counters + * and swab flags are all `rum_deal_*`. Variables a quest shares with a skill (the blindweed + * farming patch, say) aren't named after the quest and are left alone. + * Note: Not foolproof + * - Doesn't remove them from a quest area + * - Dropped quest items can avoid the reset + * - Doesn't reset rewards + * - Allows player to reclaim rewards (such as XP) + */ + private fun reset(player: Player, args: List) { + val questId = args.getOrNull(0) ?: return + val target = Players.find(player, args.getOrNull(2)) ?: return + val quest = findQuest(player, questId) ?: return + player.clear(questId) + var cleared = 0 + val vars: List = quest.getOrNull("variables") ?: emptyList() + for (variable in vars) { + target.clear(variable) + cleared++ + } + val removeItems = args.getOrNull(1)?.toBooleanStrictOrNull() ?: true + if (removeItems) { + val items: List = quest.getOrNull("items") ?: emptyList() + for (item in items) { + removeItems(target, item) + } + } + player.refreshQuestJournal() + player.message("Reset ${quest["name", questId]} to unstarted, clearing $cleared ${"variable".plural(cleared)}.") + } + + private fun removeItems(player: Player, item: String) { + player.inventory.removeToLimit(item, Int.MAX_VALUE) + player.bank.removeToLimit(item, Int.MAX_VALUE) + player.beastOfBurden.removeToLimit(item, Int.MAX_VALUE) + player.equipment.removeToLimit(item, Int.MAX_VALUE) + } + + private fun findQuest(player: Player, questId: String): QuestDefinition? { + val questDefinitions: QuestDefinitions = get() + val quest = questDefinitions.getOrNull(questId) + if (quest == null) { + player.message("No quest found with id '$questId'.") + return null + } + return quest + } + + private fun prepare(player: Player, args: List) { + val questId = args.getOrNull(0) ?: return + val quest = findQuest(player, questId) ?: return + val skills = quest["req_skills", emptyMap()] + for ((name, level) in skills) { + val skill = Skill.entries.firstOrNull { it.name == name } + if (skill == null) { + player.message("Unknown skill '$name' in ${quest.stringId} requirements.") + continue + } + if (player.levels.getMax(skill) < level) { + player.experience.set(skill, Level.experience(level)) + } + } + + val quests = quest["req_quests", emptyList()] + for (required in quests) { + player[required] = "completed" + } + + val items = quest["req_item_ids", emptyList()] + var given = 0 + for (item in items) { + if (ItemDefinitions.getOrNull(item) == null) { + player.message("Unknown item '$item' in ${quest.stringId} requirements.") + continue + } + if (!player.inventory.contains(item)) { + player.addOrDrop(item) + given++ + } + } + + player.message( + "Prepared for ${quest["name", questId]}: ${skills.size} skills, " + + "${quests.size} ${if (quests.size == 1) "quest" else "quests"}, $given items.", + ) + } +} diff --git a/game/src/main/kotlin/content/quest/member/ghosts_ahoy/GhostsAhoy.kt b/game/src/main/kotlin/content/quest/member/ghosts_ahoy/GhostsAhoy.kt index 1953633755..90aa061552 100644 --- a/game/src/main/kotlin/content/quest/member/ghosts_ahoy/GhostsAhoy.kt +++ b/game/src/main/kotlin/content/quest/member/ghosts_ahoy/GhostsAhoy.kt @@ -14,8 +14,8 @@ import content.quest.questJournal import content.quest.questStage import content.quest.refreshQuestJournal import world.gregs.voidps.engine.Script -import world.gregs.voidps.engine.client.hint import world.gregs.voidps.engine.client.instruction.handle.interactPlayer +import world.gregs.voidps.engine.client.markHint import world.gregs.voidps.engine.client.message import world.gregs.voidps.engine.entity.character.jingle import world.gregs.voidps.engine.entity.character.npc.NPCs @@ -280,7 +280,7 @@ class GhostsAhoy : Script { } else if (lobster == null) { message("You are attacked by a giant lobster!!") val lobster = NPCs.add("giant_lobster_ghosts_ahoy", Tile(3616, 3543, 0), ticks = TimeUnit.MINUTES.toTicks(8), owner = this) - hint(lobster) + markHint(lobster) lobster.interactPlayer(this, "Attack") } return@objectOperate diff --git a/game/src/main/kotlin/content/quest/member/rum_deal/RumDeal.kt b/game/src/main/kotlin/content/quest/member/rum_deal/RumDeal.kt new file mode 100644 index 0000000000..44fdad114b --- /dev/null +++ b/game/src/main/kotlin/content/quest/member/rum_deal/RumDeal.kt @@ -0,0 +1,810 @@ +package content.quest.member.rum_deal + +import content.entity.combat.killer +import content.entity.player.dialogue.Angry +import content.entity.player.dialogue.Confused +import content.entity.player.dialogue.Mad +import content.entity.player.dialogue.Neutral +import content.entity.player.dialogue.Quiz +import content.entity.player.dialogue.Shifty +import content.entity.player.dialogue.Shock +import content.entity.player.dialogue.type.choice +import content.entity.player.dialogue.type.npc +import content.entity.player.dialogue.type.player +import content.entity.player.dialogue.type.statement +import content.quest.questJournal +import content.quest.questStage +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.client.instruction.handle.interactPlayer +import world.gregs.voidps.engine.client.markHint +import world.gregs.voidps.engine.client.message +import world.gregs.voidps.engine.client.ui.dialogue.talkWith +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.character.player.skill.level.Level.has +import world.gregs.voidps.engine.entity.character.sound +import world.gregs.voidps.engine.entity.obj.GameObject +import world.gregs.voidps.engine.entity.obj.GameObjects +import world.gregs.voidps.engine.entity.obj.ObjectLayer +import world.gregs.voidps.engine.entity.obj.ObjectShape +import world.gregs.voidps.engine.entity.obj.replace +import world.gregs.voidps.engine.inv.add +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.engine.inv.remove +import world.gregs.voidps.engine.timer.toTicks +import world.gregs.voidps.type.Direction +import world.gregs.voidps.type.Tile +import world.gregs.voidps.type.area.Cuboid +import java.util.concurrent.TimeUnit + +class RumDeal : Script { + + init { + questJournalOpen("rum_deal") { + val lines = when (val stage = questStage("rum_deal")) { + 0 -> notStartedJournal() + 19 -> completedJournal() + else -> startedJournal(stage) + } + questJournal("Rum Deal", lines) + } + + // ---------- Item-on-NPC: braindeath reactions to weird items ---------- + + itemOnNPCOperate("sluglings", "captain_braindeath") { + player("The slugs are talking to me... They tell me to do bad things...") + npc("Gimme that!") + statement("The Captain takes the Slugling and stamps on it until it stops squirming, then hands it back.") + player("Egad... what just happened?") + npc("What happened is ye were messin' about with Sluglings!") + npc("Don't ye have anything better to do?") + } + + itemOnNPCOperate("karamthulhu", "captain_braindeath") { + npc("Gah! Get that slimy thing away from me!") + } + + itemOnNPCOperate("fever_spider_body", "captain_braindeath") { + npc("What are ye doin', lass? I want ye to shove that in the hopper, not in me face!") + npc("Ye just can't get good help these days...") + } + + // ---------------------------------------------------------------- // + // Luke's gate (10172) — stand-and-distract + // ---------------------------------------------------------------- // + + // The zombies wrecked every patch but one. FarmingPatchInspect only handles `farming_*` + // ids, so these would otherwise fall through to "nothing interesting happens". + objectOperate("Inspect", "blindweed_patch_trashed") { + message("The soil here is too poor to farm on.") + } + + // Nothing advances the quest when the blindweed finishes growing - the original + // leaves progress 4 falling through to Braindeath's generic "So..." reply forever, + // so harvesting the patch is what moves it on. + // Neither the original nor void tells you a crop has finished growing, which leaves the + // player waiting at the patch with no signal. The quest needs the blindweed, so say so. + variableSet("farming_blindweed_patch_braindeath_island") { _, _, to -> + if (to == "blindweed_life1" && questStage("rum_deal") == 4) { + message("I wonder how my Blindweed is coming along...") + } + } + + itemAdded("blindweed", "inventory") { + if (questStage("rum_deal") == 4) { + set("rum_deal", "grown_blindweed") + } + } + + objectOperate("Open", "gate_104_closed") { (target) -> + // Luke wanders his post, so he is looked up by region rather than by tile. + val luke = NPCs.findOrNull(tile.regionLevel, "50_luke") ?: return@objectOperate + + if (tile.y > 5098) { + // Outside the compound - Luke marches the player straight back in + talkWith(luke) + npc("Hey! What are you doing out there?") + player("Nothing.") + npc("Well Cap'n Donnie said no livin' landlubbers were allowed out of the compound.") + npc("So get yerself back in here, or yer for it!") + useGate(target, luke, north = false) + return@objectOperate + } + + // Trying to sneak out + talkWith(luke) + npc("Arr! Tryin' ter get away eh? Well ye'll never sneak past me, I'm the best lookout this crew has ever seen!") + + if (questStage("rum_deal") < 8) { + player("Err...look...an astonishing...thing. There. Behind you.") + npc("Ye'll have te do better than that, landlubber!") + return@objectOperate + } + + // Any of the six diversions works once Braindeath has sent you for water + when ((0..5).random()) { + 0 -> player("Oh my! Is that a genuine 3rd Age Diversion?") + 1 -> player("Who's that making faces behind you?") + 2 -> player("Is that your distraction?") + 3 -> player("Who is that behind you?") + 4 -> player("Hey you! Look over there!") + 5 -> player("That is the most amazing thing I have ever seen!") + } + useGate(target, luke, north = true) + } + + objectOperate("Open", "braindeath_island_tool_cupboard") { (target) -> + anim("human_opencupboard") + sound("cupboard_open") + target.replace("braindeath_island_tool_cupboard_open", ticks = 500) + } + + objectOperate("Shut", "braindeath_island_tool_cupboard_open") { (target) -> + anim("human_opencupboard") + sound("cupboard_close") + target.replace("braindeath_island_tool_cupboard") + } + + objectOperate("Search", "braindeath_island_tool_cupboard_open") { + statement("There is a tangle of rusty old farming equipment in here.") + statement("What would you like to take?") + choice("What would you like to take?") { + option("A rake") { + takeFromCupboard("rake", "rake", "You can't find another rake. Good job you already have one.") + } + option("A seed dibber") { + takeFromCupboard( + "seed_dibber", + "seed dibber", + "The only dibber you find has a bent dibbing bit. You should use the one you have.", + ) + } + option("A watering can") { + takeFromCupboard( + "watering_can", + "watering can", + "You can't find any watering cans without holes in them, so you decide to use your own.", + ) + } + option("All of the above!") { + if (inventory.contains("watering_can") && + inventory.contains("rake") && + inventory.contains("seed_dibber") + ) { + statement("The remaining stuff seems fused together in a lump of spiky, tetanus-inducing rust. Best leave it alone for now.") + } else if (inventory.spaces > 0) { + for (item in listOf("watering_can", "rake", "seed_dibber")) { + if (!inventory.contains(item) && inventory.spaces > 0) { + inventory.add(item) + } + } + statement("You help yourself to what you need.") + } else { + statement("You do not have any free space for all of these items.") + } + } + } + } + + // ---------------------------------------------------------------- // + // Intake hopper (10170) — accepts blindweed / stagnant water / spider + // ---------------------------------------------------------------- // + + itemOnObjectOperate("blindweed,bucket_of_water_stagnant,fever_spider_body", "braindeath_island_intake_hopper") { interaction -> + val progress = questStage("rum_deal") + val id = interaction.item.id + + val matches = (id == "blindweed" && progress == 6) || + (id == "bucket_of_water_stagnant" && progress == 9) || + (id == "fever_spider_body" && progress == 15) + + if (!matches) { + return@itemOnObjectOperate message("Nothing interesting happens.") + } + + when (id) { + "blindweed" -> { + set("rum_deal", "blindweed_added") + delay(1) + message("You stuff the Blindweed into the Hopper.") + anim("net_catch") + inventory.remove("blindweed") + sound("rumdeal_stuff_other") + delay(2) + } + "bucket_of_water_stagnant" -> { + set("rum_deal", "water_added") + delay(1) + message("You dump the water into the hopper.") + anim("farming_pour_water") + inventory.remove("bucket_of_water_stagnant") + inventory.add("bucket") + sound("pour_tea") + delay(5) + } + "fever_spider_body" -> { + set("rum_deal", "spider_added") + delay(1) + message("You cram the diseased Fever Spider body into the hopper.") + anim("net_catch") + inventory.remove("fever_spider_body") + sound("rumdeal_stuff_other") + delay(2) + } + } + } + + // ---------------------------------------------------------------- // + // Pressure barrel (10171) — accepts sluglings / karamthulhu + // ---------------------------------------------------------------- // + + itemOnObjectOperate("sluglings,karamthulhu", "braindeath_island_pressure_barrel") { interaction -> + if (questStage("rum_deal") != 11) { + return@itemOnObjectOperate message("Nothing Interesting Happens") + } + + val slug = interaction.item.id == "sluglings" + val pressureCount = get("rum_deal_pressure_count", 0) + + if (pressureCount == 5) { + val name = if (slug) "Sluglings" else "Karamthulhu" + return@itemOnObjectOperate message("You don't think you can stuff any more $name in there.") + } + + set("rum_deal_pressure_count", pressureCount + 1) + if (slug) { + set("rum_deal_slugling_count", get("rum_deal_slugling_count", 0) + 1) + } else { + set("rum_deal_karamthulhu_count", get("rum_deal_karamthulhu_count", 0) + 1) + } + + val name = if (slug) "Sluglings" else "Karamthulhu" + message("You stuff the squirming $name into the barrel.") + anim("human_pickuptable") + inventory.remove(interaction.item.id) + sound("rumdeal_stuff_slugling") + delay(2) + } + + objectOperate("Count", "braindeath_island_pressure_barrel") { + val slugs = get("rum_deal_slugling_count", 0) + val karam = get("rum_deal_karamthulhu_count", 0) + message( + when (slugs) { + 0 if karam == 0 -> "This barrel is empty and smells nasty." + 1 if karam == 0 -> "There are some Sluglings in this barrel." + 0 if karam == 1 -> "There is a Karamthulhu in this barrel." + 1 if karam == 1 -> "There are some Sluglings and a Karamthulhu in this barrel." + else -> "There are $slugs loads of Sluglings and $karam Karamthulhu in this barrel." + }, + ) + } + + // ---------- Pressure lever (10165 / 10166) ---------- + + objectOperate("Pull", "braindeath_island_pressure_lever_up,braindeath_island_pressure_lever_down") { + if (get("rum_deal_pressure_count", 0) != 5) { + return@objectOperate message("You do not yet have five sea creatures in the barrel!") + } + + set("rum_deal_pressure_count", 0) + set("rum_deal_slugling_count", 0) + set("rum_deal_karamthulhu_count", 0) + message("You pressurise the assorted sea creatures.") + sound("rumdeal_pressurize") + delay(2) + set("rum_deal", "pressurised") + set("rum_deal_brewing_control", 1) + GameObjects.findLayerOrNull(Tile(2141, 5102, 2), ObjectLayer.GROUND, "braindeath_island_pressure_barrel") + ?.anim("deal_press_press") + delay(2) + } + + // ---------------------------------------------------------------- // + // Stagnant water collection (10105) + // ---------------------------------------------------------------- // + + itemOnObjectOperate("bucket", "braindeath_island_stagnant_lake") { + if (questStage("rum_deal") == 8) { + set("rum_deal", "collected_water") + } + message("You scoop up a bucket of the stagnant water.") + anim("fill_bucket_slime") // 4471 + inventory.remove("bucket") + inventory.add("bucket_of_water_stagnant") + delay(3) + } + + // ---------------------------------------------------------------- // + // Fishbowl-net combine / uncombine + // ---------------------------------------------------------------- // + + itemOption("Unwrap", "fishbowl_and_net") { + if (inventory.spaces < 1) { + return@itemOption message("You do not have enough space to unwrap the net from the fishbowl.") + } + message("You unwrap the net from the fishbowl.") + inventory.remove("fishbowl_and_net") + inventory.add("fishbowl") + inventory.add("big_fishing_net") + } + + itemOnItem("fishbowl", "big_fishing_net") { _, _ -> + message("You wrap the net around the empty bowl.") + inventory.remove("fishbowl") + inventory.remove("big_fishing_net") + inventory.add("fishbowl_and_net") + } + + // ---------------------------------------------------------------- // + // Sea creature fishing spot (NPC 2859) + // ---------------------------------------------------------------- // + + npcOperate("Fish", "fishing_spot_braindeath_island") { + arriveDelay() + if (questStage("rum_deal") != 11) { + return@npcOperate message("You don't think you have time for fishing.") + } + + if (!has(Skill.Fishing, 50)) { + return@npcOperate message("You cannot fish here, you need a Fishing level of at least 50.") + } + + if (!inventory.contains("fishbowl_and_net")) { + return@npcOperate message("You do not have the correct equipment to fish here.") + } + + if (inventory.spaces < 1) { + return@npcOperate message("You do not have any free space for anything that you will catch!") + } + + message("You dunk the bowl in the water...") + anim("deal_bowl_fish") + sound("rumdeal_bowl_fish", delay = 20) + delay(4) + + if ((0..2).random() == 0) { + message("...and you catch a Karamthulhu!") + inventory.add("karamthulhu") + } else { + message("...and you catch some Sluglings!") + inventory.add("sluglings") + } + delay(1) + } + + // ---------------------------------------------------------------- // + // Output tap (10148) — fill bucket with swill + // ---------------------------------------------------------------- // + + objectOperate("Turn", "braindeath_island_output_tap") { + handleSwillFill(emptyVatMessage = "Nothing interesting happens.") + } + + itemOnObjectOperate("bucket", "braindeath_island_output_tap") { + handleSwillFill(emptyVatMessage = "The vat is empty.") + } + + // ---------------------------------------------------------------- // + // Brewing controls (10143) — smite with holy wrench + // ---------------------------------------------------------------- // + + itemOnObjectOperate("holy_wrench", "braindeath_island_brewing_controls_possessed") { + if (questStage("rum_deal") != 13) { + return@itemOnObjectOperate message("Nothing interesting happens.") + } + + if (evilSpirit() != null) { + return@itemOnObjectOperate message("The Evil Spirit has already manifested!") + } + + message("You raise your wrench on high and smite the controls mightily!") + sound("wrench_crush") + anim("deal_smite_control") + say("The power of Guthix compels you!") + delay(2) + message("The Evil Spirit is forced from the controls!") + sound("rumdeal_hopper_1") + val spirit = NPCs.addRandom( + "evil_spirit", + Cuboid(Tile(2143, 5098, 1), width = 4, height = 4, levels = 1), + ticks = TimeUnit.MINUTES.toTicks(8), + owner = this, + ) + if (spirit != null) { + markHint(spirit) + spirit.interactPlayer(this, "Attack") + } + } + + npcDeath("evil_spirit") { + val killer = killer as? Player ?: return@npcDeath + killer["rum_deal_brewing_control"] = 0 + if (killer.questStage("rum_deal") == 13) { + killer["rum_deal"] = "spirit_banished" + } + killer.message("You have banished the Evil Spirit!") + } + } + + // ---------------------------------------------------------------------- + // Helpers + // ---------------------------------------------------------------------- + + /** Slips the player through Luke's gate, [north] out of the compound or south back into it. */ + private suspend fun Player.useGate( + target: GameObject, + luke: NPC, + north: Boolean, + ) { + if (north) { + luke.face(Direction.SOUTH) + luke.say("Where?") + } + sound("barrows_door_open") + target.replace( + "inviswall", + shape = ObjectShape.WALL_STRAIGHT, + rotation = target.rotation, + ticks = 2, + ) + // The open gate is a wall piece; without the shape it defaults to a centrepiece, + // lands on the wrong layer and renders nothing. + GameObjects.add( + id = "gate_104_opened", + tile = Tile(2120, 5099, 0), + shape = ObjectShape.WALL_STRAIGHT, + rotation = 0, + ticks = 2, + ) + walkTo( + target = Tile(this.tile.x, this.tile.y + if (north) 1 else -1, this.tile.level), + forceWalk = true, + noCollision = true, + ) + delay(2) + } + + /** [name] is the readable name; the item id would otherwise be shown to the player verbatim. */ + private suspend fun Player.takeFromCupboard(item: String, name: String, alreadyHave: String) { + if (inventory.contains(item)) { + statement(alreadyHave) + return + } + if (inventory.spaces < 1) { + statement("You do not have any free space for the $name.") + return + } + inventory.add(item) + statement("You take an old $name.") + } + + private fun Player.evilSpirit(): NPC? = NPCs.at(tile.regionLevel).firstOrNull { it.id == "evil_spirit" && it["owner", ""] == accountName } + + private fun Player.handleSwillFill(emptyVatMessage: String) { + val progress = questStage("rum_deal") + + if (progress >= 18) { + return message("You thankfully have no need to do that any more.") + } + if (progress != 17) { + return message(emptyVatMessage) + } + if (!inventory.contains("bucket")) { + return message("Nothing interesting happens.") + } + + inventory.remove("bucket") + inventory.add("unsanitary_swill") + message("You carefully fill the bucket with the foul-smelling swill.") + } + + // ---------------------------------------------------------------------- + // Journal + // ---------------------------------------------------------------------- + + /** The patch variable only names a `blindweed_*` state once the seeds are in the ground. */ + private fun Player.blindweedPlanted(): Boolean = get("farming_blindweed_patch_braindeath_island", "weeds_0").startsWith("blindweed") + + private fun Player.notStartedJournal(): List { + // Java: checks Farming/Fishing/Prayer/Crafting/Slayer levels and Zogre Flesh Eaters completion. + val farming = levels.getMax(Skill.Farming) >= 40 + val fishing = levels.getMax(Skill.Fishing) >= 50 + val prayer = levels.getMax(Skill.Prayer) >= 47 + val crafting = levels.getMax(Skill.Crafting) >= 42 + val slayer = levels.getMax(Skill.Slayer) >= 42 + val zogre = get("zogre_flesh_eaters", "") == "completed" + + fun line(done: Boolean, text: String) = if (done) "$text" else "$text" + + return listOf( + "I need to speak to Pirate Pete in Port Phasmatys.", + "", + "To complete this quest I need:", + line(farming, "40 Farming"), + line(fishing, "50 Fishing"), + line(prayer, "47 Prayer"), + line(crafting, "42 Crafting"), + line(slayer, "42 Slayer"), + line(zogre, "I must have completed Zogre Flesh Eaters"), + "To be able to defeat a level 150 Monster", + ) + } + + /** + * The original's journal is cumulative - completing the quest strikes through everything that + * came before and appends the closing lines, rather than replacing it with a summary. + */ + private fun Player.completedJournal(): List = startedJournal(19) + listOf( + "", + "I have spoken to Captain Braindeath, who has decided to", + "stay on the island and keep the Zombie Pirates drunk!", + "Despite claiming he has never heard of Rabid Jack he was", + "more than happy with my performance, and rewarded me", + "with Farming, Fishing and Prayer experience, as well as the", + "Blessed Wrench.", + "", + "QUEST COMPLETE!", + ) + + private fun Player.startedJournal(stage: Int): List { + val list = mutableListOf() + + // Stage 1+ + if (stage < 2) { + list += "I have spoken to Pirate Pete, and I have agreed to help him" + list += "find his Family Sword." + } else { + list += "I have spoken to Pirate Pete, and have agreed to help him" + list += "find his Family Sword." + } + + // Stage 2+ + if (stage in 2..2) { + list += "" + list += "I have agreed to help Pirate Pete slay Barrelor the" + list += "Destroyer so that he can reclaim his lands." + } else if (stage > 2) { + list += "I have agreed to help Pirate Pete slay Barrelor the" + list += "Destroyer so that he can reclaim his lands." + } + + // Stage 3+ + if (stage in 3..3) { + list += "" + list += "I have a splitting headache. Captain Braindeath has" + list += "explained the situation to me, and I have agreed to help" + list += "brew up a batch of 'rum'." + } else if (stage > 3) { + list += "I have a splitting headache. Captain Braindeath has" + list += "explained the situation to me, and I have agreed to help" + list += "brew up a batch of 'rum'." + } + + // Stage 4+ + if (stage in 4..4) { + list += "" + list += "I have been given some Blindweed Seeds, and told to grow" + list += "them in the Herb Patch outside. Captain Braindeath" + list += "recommended that I try and intimidate the Swabs guarding" + list += "the Herb Patch if I want them to stop attacking me." + + if (blindweedPlanted()) { + list += "" + list += "I have planted my Blindweed Seeds, and should wait for" + list += "them to grow." + } + } else if (stage > 4) { + list += "I have been given some Blindweed Seeds, and told to grow" + list += "them in the Herb Patch outside. Captain Braindeath" + list += "recommended that I try and intimidate the Swabs guarding" + list += "the Herb Patch if I want them to stop attacking me." + list += "I have planted my Blindweed Seeds, and should wait for" + list += "them to grow." + } + + // Stage 5 + if (stage == 5) { + list += "" + list += "I have grown some Blindweed, I should show it to Captain" + list += "Braindeath so that he can tell me what to do with it." + + if (!inventory.contains("blindweed")) { + list += "" + list += "Having lost my Blindweed I should go and see Captain" + list += "Braindeath, he may have some kept to one side for use in" + list += "an emergency." + } + } else if (stage > 5) { + list += "I have grown some Blindweed, I should show it to Captain" + list += "Braindeath so that he can tell me what to do with it." + } + + // Stage 6 + if (stage in 6..6) { + list += "" + list += "I have shown my Blindweed to Captain Braindeath. He told" + list += "me to put it into the Intake Hopper." + } else if (stage > 6) { + list += "I have shown my Blindweed to Captain Braindeath. He told" + list += "me to put it into the Intake Hopper." + } + + // Stage 7 + if (stage in 7..7) { + list += "" + list += "I have added the Blindweed to the Intake Hopper. I should" + list += "ask Captain Braindeath what he wants me to do next." + } else if (stage > 7) { + list += "I have added the Blindweed to the hopper. I should ask" + list += "Captain Braindeath what he wants me to do next." + } + + // Stage 8 + if (stage in 8..8) { + list += "" + list += "Captain Braindeath has told me to fetch a bucket of" + list += "Stagnant Water from the Stagnant Lake near the top of" + list += "the Volcano to the North." + } else if (stage > 8) { + list += "Captain Braindeath has told me to fetch a bucket of" + list += "Stagnant Water from the Stagnant Lake near the top of" + list += "the Volcano to the North." + } + + // Stage 9 + if (stage in 9..9) { + list += "" + list += "Using superior cunning and stealth I have managed to exit" + list += "the Brewery compound and have collected a bucket of" + list += "Stagnant Water." + + if (!inventory.contains("bucket_of_water_stagnant")) { + list += "" + list += "Due to my inferior ability to hold objects I have lost my" + list += "Bucket of Stagnant Water. I should go and see Captain" + list += "Braindeath to see what he wants me to do about this" + list += "situation." + } + } else if (stage > 9) { + list += "Using superior cunning and stealth I have managed to exit" + list += "the Brewery compound and have collected a bucket of" + list += "Stagnant Water." + } + + // Stage 10 + if (stage in 10..10) { + list += "" + list += "I have poured the Stagnant Water into the Intake Hopper. I" + list += "should speak to Captain Braindeath again to see what else" + list += "he needs." + } else if (stage > 10) { + list += "I have poured the Stagnant Water into the Intake Hopper. I" + list += "should speak to Captain Braindeath again to see what else" + list += "he needs." + } + + // Stage 11 + if (stage in 11..11) { + list += "" + list += "Captain Braindeath has told me to fish with a Fishbowl and" + list += "Big Net in the Squid Fishing Spot. Whatever 5 Sea" + list += "Creatures I dredge up are to be put into a barrel and" + list += "pressurised." + } else if (stage > 11) { + list += "Captain Braindeath has told me to fish with a Fishbowl and" + list += "Big Net in the Squid Fishing Spot. Whatever 5 Sea" + list += "Creatures I dredge up are to be put into a barrel and" + list += "pressurised." + } + + // Stage 12 + if (stage in 12..12) { + list += "" + list += "I have pressurised the Assorted Sea Creatures in the" + list += "Pressure Barrel. I should see Captain Braindeath to check" + list += "if this is all he needs me to do." + } else if (stage > 12) { + list += "I have pressurised the Assorted Sea Creatures in the" + list += "Pressure Barrel. I should see Captain Braindeath to check" + list += "if this is all he needs me to do." + } + + // Stage 13 + if (stage in 13..13) { + list += "" + list += "Apparently the Brewing Equipment has become possessed." + list += "Captain Braindeath has given me a Wrench and told me to" + list += "sort it out. I should check amongst the Brewers to see" + list += "which of them can bless it for me." + list += "" + + if (inventory.contains("holy_wrench")) { + list += "I am now the proud owner of a Holy Wrench. I will take up" + list += "this Holy Wrench and strike the Brewing Controls until they" + list += "are very, very sorry." + } else if (!inventory.contains("wrench")) { + list += "I have just realised that I will have a hard time getting my" + list += "Wrench blessed now that I have thrown it away. I think I" + list += "should see Captain Braindeath to get a replacement" + list += "before the situation deteriorates further." + } + } else if (stage > 13) { + list += "Apparently the Brewing Equipment has become possessed." + list += "Captain Braindeath has given me a Wrench and told me to" + list += "sort it out. I should check amongst the Brewers to see" + list += "which of them can bless it for me." + list += "I am now the proud owner of a Holy Wrench. I will take up" + list += "this Holy Wrench and strike the Brewing Controls until they" + list += "are very, very sorry." + } + + // Stage 14 + if (stage in 14..14) { + list += "" + list += "I have banished the Evil Spirit. Captain Braindeath told me" + list += "there is one more ingredient left. I should ask him what" + list += "this is." + } else if (stage > 14) { + list += "I have banished the Evil Spirit. Captain Braindeath told me" + list += "there is one more ingredient left. I should ask him what" + list += "this is." + } + + // Stage 15 + if (stage in 15..15) { + list += "" + list += "Captain Braindeath wants me to kill a Fever Spider, and put" + list += "its Body into the Intake Hopper. He told me to wear Slayer" + list += "Gloves (Available from the Slayer Masters apparently)" + list += "while I did so, as they carry a nasty disease. Mental Note;" + list += "do not drink whatever comes out of that machine." + } else if (stage > 15) { + list += "Captain Braindeath wants me to kill a Fever Spider, and put" + list += "its Body into the Intake Hopper. He told me to wear Slayer" + list += "Gloves (Available from the Slayer Masters apparently)" + list += "while I did so, as they carry a nasty disease. Mental Note;" + list += "do not drink whatever comes out of that machine." + } + + // Stage 16 + if (stage in 16..16) { + list += "" + list += "I have placed the Fever Spider Body in the Hopper. Captain" + list += "Braindeath will be able to produce some 'rum' when I tell" + list += "him." + } else if (stage > 16) { + list += "I have placed the Fever Spider Body in the Hopper. Captain" + list += "Braindeath will be able to produce some 'rum' when I tell" + list += "him." + } + + // Stage 17 + if (stage in 17..17) { + list += "" + list += "Captain Braindeath has filled the Output Vat with 'rum'. I" + list += "should take a bucket of the stuff to Captain Donnie and" + list += "give it to him." + } else if (stage > 17) { + list += "Captain Braindeath has filled the Output Vat with 'rum'. I" + list += "should take a bucket of the stuff to Captain Donnie and" + list += "give it to him." + } + + // Stage 18 + if (stage in 18..18) { + list += "" + list += "I have spoken to Captain Donnie, and have found out the" + list += "name of his boss, Rabid Jack. I should tell Captain" + list += "Braindeath about this." + } else if (stage > 18) { + list += "I have spoken to Captain Donnie, and have found out the" + list += "name of his boss, Rabid Jack. I should tell Captain" + list += "Braindeath about this." + } + + return list + } +} diff --git a/game/src/main/kotlin/content/skill/dungeoneering/DungeonMark.kt b/game/src/main/kotlin/content/skill/dungeoneering/DungeonMark.kt index 8841ad2f16..5f47b83a23 100644 --- a/game/src/main/kotlin/content/skill/dungeoneering/DungeonMark.kt +++ b/game/src/main/kotlin/content/skill/dungeoneering/DungeonMark.kt @@ -3,7 +3,7 @@ package content.skill.dungeoneering import content.area.wilderness.daemonheim.DungeoneeringParty.Companion.dungeonLeader import content.area.wilderness.daemonheim.DungeoneeringParty.Companion.dungeonMembers import world.gregs.voidps.engine.Script -import world.gregs.voidps.engine.client.hint +import world.gregs.voidps.engine.client.markHint import world.gregs.voidps.engine.client.message import world.gregs.voidps.engine.entity.Approachable import world.gregs.voidps.engine.entity.character.npc.NPC @@ -22,8 +22,7 @@ class DungeonMark : Script { return } for (member in dungeonMembers) { - val index = member.hint(target) - member["dungeon_hint_${target.index}"] = index + member.markHint(target) } } } diff --git a/game/src/main/kotlin/content/skill/farming/Farming.kt b/game/src/main/kotlin/content/skill/farming/Farming.kt index f78c00b20d..1a69238de3 100644 --- a/game/src/main/kotlin/content/skill/farming/Farming.kt +++ b/game/src/main/kotlin/content/skill/farming/Farming.kt @@ -40,7 +40,7 @@ class Farming( if (!contains("farming_offset_mins")) { set("farming_offset_mins", random.nextInt(0, 30)) } - if (contains("last_growth_cycle")) { + if (contains("last_growth_cycle") || hasCrop()) { timers.start("farming_tick", true) } } @@ -61,6 +61,23 @@ class Farming( } } + /** + * A patch with something already in the ground needs the growth timer even when it has never + * run. Only raking and composting start it, so a crop planted into an already clear patch would + * otherwise sit unfinished forever. + */ + private fun Player.hasCrop(): Boolean { + for (patches in FarmingPatches.patches.values) { + for (variable in patches) { + val value: String = this[variable] ?: continue + if (!value.startsWith("weeds")) { + return true + } + } + } + return false + } + fun grow(player: Player, minute: Int) { if (minute.rem(Settings["farming.decompose.mins", 2]) == 0) { growCompost(player) @@ -113,7 +130,11 @@ class Farming( continue } val stage = type.toInt() - val next = (stage + 1).rem(4) + // Stage 3 is fully overgrown - weeds stop there rather than wrapping back to clear + if (stage >= 3) { + continue + } + val next = stage + 1 player[variable] = when (next) { 3 -> if (variable.contains("farming_veg_")) { "weeds_${ diff --git a/game/src/main/kotlin/content/skill/farming/FarmingPatchPlant.kt b/game/src/main/kotlin/content/skill/farming/FarmingPatchPlant.kt index cb0ec39c37..50846a037d 100644 --- a/game/src/main/kotlin/content/skill/farming/FarmingPatchPlant.kt +++ b/game/src/main/kotlin/content/skill/farming/FarmingPatchPlant.kt @@ -80,15 +80,15 @@ class FarmingPatchPlant : Script { player.message("You need a seed dibber to plant the seed in the dirt.") // TODO proper message return } - if (!player.inventory.remove(item.id, amount)) { - player.message("You need $amount ${item.def.name.plural(amount)} to grow those.") - return - } val level = item.def["farming_level", 1] if (!player.has(Skill.Farming, level)) { player.statement("You need to be a level $level to plant that.") return } + if (!player.inventory.remove(item.id, amount)) { + player.message("You need $amount ${item.def.name.plural(amount)} to grow those.") + return + } if (patchName.contains("tree")) { player.anim("human_dig") player.sound("dig_spade") @@ -100,6 +100,9 @@ class FarmingPatchPlant : Script { player.message("You plant ${if (amount == 1) "a" else amount} ${item.def.name.lowercase().plural(amount)} in the $patchName.", type = ChatType.Filter) val crop: String = item.def.getOrNull("farming_crop") ?: return player[variable] = "${crop}_0" + // Growth is otherwise only kicked off by raking or composting, which leaves a crop + // planted into an already clear patch never growing at all. + player.timers.startIfAbsent("farming_tick") player.exp(Skill.Farming, item.def["farming_xp", 0.0]) } diff --git a/game/src/main/kotlin/content/skill/farming/FarmingPatchTreat.kt b/game/src/main/kotlin/content/skill/farming/FarmingPatchTreat.kt index af7f0e2df9..e99d6a3b09 100644 --- a/game/src/main/kotlin/content/skill/farming/FarmingPatchTreat.kt +++ b/game/src/main/kotlin/content/skill/farming/FarmingPatchTreat.kt @@ -11,6 +11,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 import world.gregs.voidps.engine.entity.character.sound +import world.gregs.voidps.engine.entity.obj.GameObject import world.gregs.voidps.engine.inv.* import world.gregs.voidps.engine.queue.weakQueue @@ -18,6 +19,7 @@ class FarmingPatchTreat : Script { init { objectOperate("Rake", "*_patch_weeds_*", handler = ::rake) + itemOnObjectOperate("rake", "*", handler = ::rakeWith) itemOnObjectOperate("compost,supercompost", "*", handler = ::compost) itemOnObjectOperate("watering_can_*", "*", handler = ::water) } @@ -84,7 +86,22 @@ class FarmingPatchTreat : Script { player[target.id] = value.replaceFirst("_", "_watered_") } - private fun rake(player: Player, interact: PlayerOnObjectInteract, count: Int = 3) { + private fun rake(player: Player, interact: PlayerOnObjectInteract) = rake(player, interact.target) + + /** + * Not every patch carries a Rake option - the Rum Deal blindweed patch has none - so the rake + * can be used on the patch directly instead. + */ + private fun rakeWith(player: Player, interact: ItemOnObjectInteract) { + val target = interact.target + if (!target.id.startsWith("farming_")) { + player.noInterest() + return + } + rake(player, target) + } + + private fun rake(player: Player, obj: GameObject, count: Int = 3) { if (count <= 0) { return } @@ -92,8 +109,8 @@ class FarmingPatchTreat : Script { player.message("You need a rake to weed a farming patch") return } - val obj = interact.target - if (player[obj.id, "weeds_life3"] == "weeds_0") { + val value = player[obj.id, "weeds_life3"] + if (!value.startsWith("weeds") || value == "weeds_0") { player.message("This ${obj.patchName()} doesn't need weeding right now.") return } @@ -104,6 +121,8 @@ class FarmingPatchTreat : Script { "weeds_life3" -> "weeds_2" "weeds_life2" -> "weeds_1" "weeds_life1" -> "weeds_0" + // Allotments use weeds_life* for a fully overgrown patch, everything else weeds_3 + "weeds_3" -> "weeds_2" "weeds_2" -> "weeds_1" "weeds_1" -> "weeds_0" else -> return@weakQueue @@ -113,9 +132,9 @@ class FarmingPatchTreat : Script { player.addOrDrop("weeds") player.timers.startIfAbsent("farming_tick") player.exp(Skill.Farming, 8.0) - rake(player, interact, count - 1) + rake(player, obj, count - 1) } else { - rake(player, interact, count) + rake(player, obj, count) } } } diff --git a/game/src/main/kotlin/content/skill/farming/FarmingPatches.kt b/game/src/main/kotlin/content/skill/farming/FarmingPatches.kt index df7328cbe1..51cb4738f5 100644 --- a/game/src/main/kotlin/content/skill/farming/FarmingPatches.kt +++ b/game/src/main/kotlin/content/skill/farming/FarmingPatches.kt @@ -20,6 +20,7 @@ object FarmingPatches { "farming_flower_patch_ardougne", "patch_herblore_habitat_vine_flower", "farming_flower_patch_morytania", + "farming_blindweed_patch_braindeath_island", ), 2 to listOf( // allotments diff --git a/game/src/test/kotlin/content/area/morytania/braindeath_island/FeverSpiderTest.kt b/game/src/test/kotlin/content/area/morytania/braindeath_island/FeverSpiderTest.kt new file mode 100644 index 0000000000..77483a10c7 --- /dev/null +++ b/game/src/test/kotlin/content/area/morytania/braindeath_island/FeverSpiderTest.kt @@ -0,0 +1,36 @@ +package content.area.morytania.braindeath_island + +import WorldTest +import content.skill.prayer.PrayerConfigs +import content.skill.prayer.protectMelee +import org.junit.jupiter.api.Test +import world.gregs.voidps.type.Tile +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * Fever spiders bite through bare hands for heavy damage and always inflict disease. Protecting + * from melee stops the bite but not the disease, matching the original's FeverSpiderScript. + */ +class FeverSpiderTest : WorldTest() { + + @Test + fun `Protecting from melee stops the fever spider's bite`() { + val player = createPlayer(Tile(2140, 5100)) + + assertFalse(player.protectMelee(), "no protection prayer by default") + + player.addVarbit(PrayerConfigs.ACTIVE_PRAYERS, "protect_from_melee") + + assertTrue(player.protectMelee(), "protect from melee should register") + } + + @Test + fun `Deflect melee also counts as melee protection`() { + val player = createPlayer(Tile(2140, 5100)) + player[PrayerConfigs.PRAYERS] = "curses" + player.addVarbit(PrayerConfigs.ACTIVE_CURSES, "deflect_melee") + + assertTrue(player.protectMelee(), "deflect melee should register") + } +} diff --git a/game/src/test/kotlin/content/area/morytania/braindeath_island/ZombieProtesterTest.kt b/game/src/test/kotlin/content/area/morytania/braindeath_island/ZombieProtesterTest.kt new file mode 100644 index 0000000000..66b6cf599c --- /dev/null +++ b/game/src/test/kotlin/content/area/morytania/braindeath_island/ZombieProtesterTest.kt @@ -0,0 +1,66 @@ +package content.area.morytania.braindeath_island + +import WorldTest +import org.junit.jupiter.api.Test +import world.gregs.voidps.engine.entity.character.mode.Leash +import world.gregs.voidps.engine.entity.character.move.tele +import world.gregs.voidps.type.Tile +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * Protesters heckle and chase whoever comes near, but only as far as their post. Follow has no + * range limit of its own - it teleports after a target that outruns it or changes level - and + * Hunting won't reconsider an npc that is already following, so protesters chase on a [Leash] + * instead and lose interest the moment the player leaves their patch of the island. + */ +class ZombieProtesterTest : WorldTest() { + + @Test + fun `Zombie protesters chase a nearby player`() { + val player = createPlayer(Tile(2145, 5088)) + val protester = createNPC("zombie_protester", Tile(2145, 5087)) + + tickIf(limit = 20) { protester.mode !is Leash } + + assertTrue(protester.mode is Leash, "a protester should chase a player who walks up to it") + } + + @Test + fun `Zombie protesters give up once dragged away from their post`() { + val player = createPlayer(Tile(2145, 5088)) + val protester = createNPC("zombie_protester", Tile(2145, 5087)) + val post = protester.tile + + tickIf(limit = 20) { protester.mode !is Leash } + assertTrue(protester.mode is Leash, "the protester should be chasing to begin with") + + // Lead it away across the island; past the leash it should break off + player.tele(2160, 5070) + tickIf(limit = 100) { protester.mode is Leash } + + assertFalse(protester.mode is Leash, "the protester should give up the chase") + assertTrue(protester.tile.within(post, 10), "and stay on its own patch rather than teleporting after the player") + + // and then make its own way back to the protest + tickIf(limit = 400) { !protester.tile.within(post, 8) } + assertTrue(protester.tile.within(post, 8), "and return to its post") + } + + @Test + fun `Zombie protesters never leave their post`() { + val player = createPlayer(Tile(2145, 5088)) + val protester = createNPC("zombie_protester", Tile(2145, 5087)) + val post = protester.tile + + tickIf(limit = 20) { protester.mode !is Leash } + + // Walk the length of the island a step at a time; the protester tails then drops off + repeat(40) { + player.tele(player.tile.addY(1)) + tick() + assertTrue(protester.tile.within(post, 10), "a protester should never chase beyond its post") + } + assertFalse(protester.mode is Leash, "and should have lost interest long before the end") + } +} diff --git a/game/src/test/kotlin/content/area/morytania/braindeath_island/ZombieSwabTest.kt b/game/src/test/kotlin/content/area/morytania/braindeath_island/ZombieSwabTest.kt new file mode 100644 index 0000000000..6ca8bcc990 --- /dev/null +++ b/game/src/test/kotlin/content/area/morytania/braindeath_island/ZombieSwabTest.kt @@ -0,0 +1,45 @@ +package content.area.morytania.braindeath_island + +import WorldTest +import org.junit.jupiter.api.Test +import world.gregs.voidps.engine.entity.character.mode.combat.CombatMovement +import world.gregs.voidps.type.Tile +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * Swabs guard the blindweed patch and attack anyone who comes near, until they are either + * intimidated into backing off or the quest is finished and there is nothing left to guard. + */ +class ZombieSwabTest : WorldTest() { + + @Test + fun `Zombie swabs attack a nearby player during the quest`() { + createPlayer(Tile(2152, 5074)) + val swab = createNPC("zombie_swab", Tile(2152, 5073)) + + tickIf(limit = 20) { swab.mode !is CombatMovement } + + assertTrue(swab.mode is CombatMovement, "a swab should attack a player who walks up to it") + } + + @Test + fun `An intimidated zombie swab stops attacking`() { + createPlayer(Tile(2152, 5074)) { it["rum_deal_swab_a"] = 1 } + val swab = createNPC("zombie_swab", Tile(2152, 5073)) + + tick(20) + + assertFalse(swab.mode is CombatMovement, "a swab that has been heckled should back off") + } + + @Test + fun `Zombie swabs ignore a player who has completed Rum Deal`() { + createPlayer(Tile(2152, 5074)) { it["rum_deal"] = "completed" } + val swab = createNPC("zombie_swab", Tile(2152, 5073)) + + tick(20) + + assertFalse(swab.mode is CombatMovement, "a swab should leave a player who finished the quest alone") + } +} diff --git a/game/src/test/kotlin/content/quest/QuestCommandsTest.kt b/game/src/test/kotlin/content/quest/QuestCommandsTest.kt new file mode 100644 index 0000000000..b7e1b57613 --- /dev/null +++ b/game/src/test/kotlin/content/quest/QuestCommandsTest.kt @@ -0,0 +1,94 @@ +package content.quest + +import WorldTest +import containsMessage +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test +import world.gregs.voidps.engine.client.command.Commands +import world.gregs.voidps.engine.entity.character.player.PlayerRights +import world.gregs.voidps.engine.entity.character.player.rights +import world.gregs.voidps.engine.entity.character.player.skill.Skill +import world.gregs.voidps.engine.entity.character.player.skill.level.Level +import world.gregs.voidps.engine.inv.inventory +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class QuestCommandsTest : WorldTest() { + + @Test + fun `Quest prep meets a quest's skill, quest and item requirements`() { + val admin = createPlayer(name = "prep admin") + admin.rights = PlayerRights.Admin + + runTest { Commands.call(admin, "quest_prep rum_deal") } + tick() + + // req_skills from quests.toml + assertEquals(40, admin.levels.getMax(Skill.Farming)) + assertEquals(50, admin.levels.getMax(Skill.Fishing)) + assertEquals(47, admin.levels.getMax(Skill.Prayer)) + assertEquals(42, admin.levels.getMax(Skill.Crafting)) + assertEquals(42, admin.levels.getMax(Skill.Slayer)) + + // req_quests + assertTrue(admin.questCompleted("zogre_flesh_eaters")) + + // req_item_ids - everything else is handed out during the quest + assertTrue(admin.inventory.contains("slayer_gloves")) + assertTrue(admin.inventory.contains("spade")) + } + + @Test + fun `Quest prep never lowers a skill the player has already trained`() { + val admin = createPlayer(name = "prep admin2") + admin.rights = PlayerRights.Admin + admin.experience.set(Skill.Fishing, Level.experience(80)) + + runTest { Commands.call(admin, "quest_prep rum_deal") } + tick() + + assertEquals(80, admin.levels.getMax(Skill.Fishing)) + } + + @Test + fun `Quest prep reports an unknown quest`() { + val admin = createPlayer(name = "prep admin3") + admin.rights = PlayerRights.Admin + + runTest { Commands.call(admin, "quest_prep not_a_quest") } + tick() + + assertTrue(admin.containsMessage("No quest found with id 'not_a_quest'.")) + } + + @Test + fun `Quest reset puts the quest back to unstarted and clears its variables`() { + val admin = createPlayer(name = "reset admin") + admin.rights = PlayerRights.Admin + admin["rum_deal"] = "collect_swill" + admin["rum_deal_pressure_count"] = 5 + admin["rum_deal_slugling_count"] = 3 + admin["rum_deal_swab_a"] = 1 + admin["rum_deal_brewing_control"] = 2 + + runTest { Commands.call(admin, "quest_reset rum_deal") } + tick() + + assertEquals("unstarted", admin["rum_deal", "unstarted"]) + assertEquals(0, admin["rum_deal_pressure_count", 0]) + assertEquals(0, admin["rum_deal_slugling_count", 0]) + assertEquals(0, admin["rum_deal_swab_a", 0]) + assertEquals(0, admin["rum_deal_brewing_control", 0]) + } + + @Test + fun `Quest reset reports an unknown quest`() { + val admin = createPlayer(name = "reset admin2") + admin.rights = PlayerRights.Admin + + runTest { Commands.call(admin, "quest_reset not_a_quest") } + tick() + + assertTrue(admin.containsMessage("No quest found with id 'not_a_quest'.")) + } +} diff --git a/game/src/test/kotlin/content/quest/member/rum_deal/BraindeathSpawnTest.kt b/game/src/test/kotlin/content/quest/member/rum_deal/BraindeathSpawnTest.kt new file mode 100644 index 0000000000..7c47fa57a2 --- /dev/null +++ b/game/src/test/kotlin/content/quest/member/rum_deal/BraindeathSpawnTest.kt @@ -0,0 +1,47 @@ +package content.quest.member.rum_deal + +import WorldTest +import dialogueContinue +import npcOption +import org.junit.jupiter.api.Test +import world.gregs.voidps.engine.client.ui.dialogue +import world.gregs.voidps.engine.entity.character.npc.NPCs +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.type.Tile +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** + * Talks to the Captain Braindeath that spawns from braindeath_island.npc-spawns.toml, rather than + * one created by the test, so the real definition and spawn tile are exercised. + */ +class BraindeathSpawnTest : WorldTest() { + + override var loadNpcs: Boolean = true + + @Test + fun `The spawned Captain Braindeath talks at the stagnant water stage`() { + val player = createPlayer(Tile(2144, 5108, 1)) + player["rum_deal"] = "blindweed_added" + tick(2) + + val braindeath = NPCs.findOrNull(Tile(2144, 5109, 1), "captain_braindeath") + assertNotNull(braindeath, "Captain Braindeath should be spawned at 2144, 5109, 1") + + player.npcOption(braindeath, "Talk-to") + tick(3) + + assertNotNull(player.dialogue, "talking to the spawned Braindeath should open a dialogue") + + var steps = 0 + while (player.dialogue != null && steps < 40) { + player.dialogueContinue() + steps++ + tick() + } + + assertEquals("fetch_water", player["rum_deal", "unstarted"], "the conversation should send the player for stagnant water") + assertTrue(player.inventory.contains("bucket"), "Braindeath hands over a bucket") + } +} diff --git a/game/src/test/kotlin/content/quest/member/rum_deal/IntroCutsceneTest.kt b/game/src/test/kotlin/content/quest/member/rum_deal/IntroCutsceneTest.kt new file mode 100644 index 0000000000..eb942c59dc --- /dev/null +++ b/game/src/test/kotlin/content/quest/member/rum_deal/IntroCutsceneTest.kt @@ -0,0 +1,54 @@ +package content.quest.member.rum_deal + +import WorldTest +import dialogueContinue +import dialogueOption +import npcOption +import org.junit.jupiter.api.Test +import skipDialogues +import world.gregs.voidps.engine.client.ui.dialogue +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.type.Tile +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + +/** + * Pirate Pete's intro knocks the player out and drops them in the brewery, where Captain + * Braindeath's conversation should pick up on its own. + */ +class IntroCutsceneTest : WorldTest() { + + override var loadNpcs: Boolean = true + + @Test + fun `The intro cutscene hands over to Captain Braindeath`() { + val player = createPlayer(Tile(3672, 3538)) + player["zogre_flesh_eaters"] = "completed" + player.experience.set(Skill.Slayer, Level.experience(42)) + player["rum_deal"] = "slay_barrelor" + val pete = createNPC("pirate_pete", Tile(3673, 3538)) + tick(2) + + player.npcOption(pete, "Talk-to") + tick(2) + player.skipDialogues() + player.dialogueOption("line1") // I've decided to help you for free. + + // Ride out the knockout, the cutscene and the hand-over + var steps = 0 + while (steps < 200) { + tick() + steps++ + if (player.dialogue != null) { + player.dialogueContinue() + } + if (player["rum_deal", ""] != "slay_barrelor") { + break + } + } + + assertEquals(Tile(2144, 5108, 1), player.tile, "the player should wake in the brewery") + assertNotNull(player.dialogue, "Braindeath's conversation should start on its own") + } +} diff --git a/game/src/test/kotlin/content/quest/member/rum_deal/RumDealTest.kt b/game/src/test/kotlin/content/quest/member/rum_deal/RumDealTest.kt new file mode 100644 index 0000000000..e6ebc02828 --- /dev/null +++ b/game/src/test/kotlin/content/quest/member/rum_deal/RumDealTest.kt @@ -0,0 +1,578 @@ +package content.quest.member.rum_deal + +import WorldTest +import containsMessage +import content.entity.combat.hit.damage +import content.skill.farming.Farming +import dialogueContinue +import dialogueOption +import itemOnObject +import npcOption +import objectOption +import org.junit.jupiter.api.Test +import skipDialogues +import world.gregs.voidps.engine.client.ui.dialogue +import world.gregs.voidps.engine.data.definition.CombatDefinitions +import world.gregs.voidps.engine.entity.character.npc.NPCs +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.obj.GameObjects +import world.gregs.voidps.engine.entity.obj.ObjectLayer +import world.gregs.voidps.engine.get +import world.gregs.voidps.engine.inv.add +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.type.Tile +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Braindeath Island object locations: + * blindweed patch (2162, 5069, 0) tool cupboard (2157, 5093, 0) + * gate (2120, 5098, 0) stagnant lake (2134, 5162, 0) + * output tap (2142, 5093, 1) brewing control (2143, 5100, 1) + * intake hopper (2142, 5102, 2) pressure barrel (2141, 5102, 2) + * pressure lever (2141, 5103, 2) + */ +class RumDealTest : WorldTest() { + + @Test + fun `Pirate Pete talks the player into hunting Barrelor the Destroyer`() { + val player = createPlayer(Tile(3672, 3538)) + player["zogre_flesh_eaters"] = "completed" + player.experience.set(Skill.Slayer, Level.experience(42)) + val pete = createNPC("pirate_pete", Tile(3673, 3538)) + + player.npcOption(pete, "Talk-to") + tick(2) + player.skipDialogues() // greeting through "Will you help me find my family sword?" + player.dialogueOption("line1") // Yes! + player.dialogueContinue() + assertEquals("agreed_to_help", player["rum_deal", "unstarted"]) + + player.skipDialogues() // the Barrelor build-up + player.dialogueOption("line1") // Of course, I fear no demon! + player.dialogueContinue() + assertEquals("slay_barrelor", player["rum_deal", "unstarted"]) + } + + @Test + fun `Pirate Pete turns the player away without Zogre Flesh Eaters`() { + val player = createPlayer(Tile(3672, 3538)) + val pete = createNPC("pirate_pete", Tile(3673, 3538)) + + player.npcOption(pete, "Talk-to") + tick(2) + player.skipDialogues() + assertEquals("unstarted", player["rum_deal", "unstarted"]) + } + + @Test + fun `The tool cupboard hands out farming equipment`() { + val player = createPlayer(Tile(2159, 5092)) + val cupboard = GameObjects.find(Tile(2157, 5093), "braindeath_island_tool_cupboard") + tick(2) + + player.objectOption(cupboard, "Open") + tick(4) + val open = GameObjects.find(Tile(2157, 5093), "braindeath_island_tool_cupboard_open") + player.objectOption(open, "Search") + tick(2) + player.skipDialogues() + player.dialogueOption("line4") // All of the above! + tick(2) + + assertTrue(player.inventory.contains("rake")) + assertTrue(player.inventory.contains("seed_dibber")) + assertTrue(player.inventory.contains("watering_can")) + } + + @Test + fun `Blindweed goes into the intake hopper`() { + val player = createPlayer(Tile(2142, 5103, 2)) + player["rum_deal"] = "show_blindweed" + player.inventory.add("blindweed") + val hopper = GameObjects.find(Tile(2142, 5102, 2), "braindeath_island_intake_hopper") + + player.itemOnObject(hopper, player.inventory.indexOf("blindweed")) + tick(4) + + assertEquals("blindweed_added", player["rum_deal", "unstarted"]) + assertTrue(player.containsMessage("You stuff the Blindweed into the Hopper.")) + assertEquals(0, player.inventory.count("blindweed")) + } + + @Test + fun `Stagnant water is scooped into an empty bucket`() { + val player = createPlayer(Tile(2134, 5161)) + player["rum_deal"] = "fetch_water" + player.inventory.add("bucket") + val lake = GameObjects.find(Tile(2134, 5162), "braindeath_island_stagnant_lake") + + player.itemOnObject(lake, player.inventory.indexOf("bucket")) + tick(5) + + assertEquals("collected_water", player["rum_deal", "unstarted"]) + assertTrue(player.inventory.contains("bucket_of_water_stagnant")) + } + + @Test + fun `Sea creatures are stuffed into the barrel and pressurised`() { + val player = createPlayer(Tile(2140, 5102, 2)) + player["rum_deal"] = "catch_creatures" + player.inventory.add("sluglings", 3) + player.inventory.add("karamthulhu", 2) + val barrel = GameObjects.find(Tile(2141, 5102, 2), "braindeath_island_pressure_barrel") + tick(2) + + repeat(3) { + player.itemOnObject(barrel, player.inventory.indexOf("sluglings")) + tick(5) + } + repeat(2) { + player.itemOnObject(barrel, player.inventory.indexOf("karamthulhu")) + tick(5) + } + assertEquals(5, player["rum_deal_pressure_count", 0]) + + player.objectOption(barrel, "Count") + tick(2) + assertTrue(player.containsMessage("There are 3 loads of Sluglings and 2 Karamthulhu in this barrel.")) + + // Count 5 transforms the lever to its ready state + val lever = GameObjects.find(Tile(2141, 5103, 2), "braindeath_island_pressure_lever_multi") + player.objectOption(lever, "Pull") + tick(6) + + assertEquals("pressurised", player["rum_deal", "unstarted"]) + assertEquals(0, player["rum_deal_pressure_count", 0]) + assertEquals(1, player["rum_deal_brewing_control", 0]) + } + + @Test + fun `The lever refuses to pressurise fewer than five creatures`() { + val player = createPlayer(Tile(2142, 5103, 2)) + player["rum_deal"] = "catch_creatures" + player["rum_deal_pressure_count"] = 3 + val lever = GameObjects.find(Tile(2141, 5103, 2), "braindeath_island_pressure_lever_multi") + + player.objectOption(lever, "Pull") + tick(2) + + assertTrue(player.containsMessage("You do not yet have five sea creatures in the barrel!")) + assertEquals("catch_creatures", player["rum_deal", "unstarted"]) + } + + @Test + fun `Netting the fishing spot needs level 50 and the tangled bowl`() { + val player = createPlayer(Tile(2160, 5100)) + player["rum_deal"] = "catch_creatures" + val spot = createNPC("fishing_spot_braindeath_island", Tile(2161, 5100)) + + player.npcOption(spot, "Fish") + tick(2) + assertTrue(player.containsMessage("You cannot fish here, you need a Fishing level of at least 50.")) + + player.experience.set(Skill.Fishing, Level.experience(50)) + player.npcOption(spot, "Fish") + tick(2) + assertTrue(player.containsMessage("You do not have the correct equipment to fish here.")) + + player.inventory.add("fishbowl_and_net") + player.npcOption(spot, "Fish") + tick(8) + assertTrue(player.inventory.contains("sluglings") || player.inventory.contains("karamthulhu")) + } + + @Test + fun `Smiting the brewing controls forces out the Evil Spirit`() { + val player = createPlayer(Tile(2143, 5101, 1)) + player["rum_deal"] = "bless_wrench" + player["rum_deal_brewing_control"] = 1 + player.inventory.add("holy_wrench") + val controls = GameObjects.find(Tile(2143, 5100, 1), "braindeath_island_brewing_controls_multi") + + player.itemOnObject(controls, player.inventory.indexOf("holy_wrench")) + tick(8) + + assertTrue(player.containsMessage("The Evil Spirit is forced from the controls!")) + val spirit = NPCs.at(player.tile.regionLevel).firstOrNull { it.id == "evil_spirit" } + assertNotNull(spirit, "the Evil Spirit should have manifested") + + // Same rules as the Ghosts Ahoy lobster: owned, temporary, hint-marked and attacking + assertEquals(player.accountName, spirit["owner", ""], "the spirit belongs to its summoner") + assertTrue(player.viewport!!.hints.any { it != 0 }, "a hint arrow should mark the spirit") + } + + @Test + fun `The output tap only pours once the vat is full`() { + val player = createPlayer(Tile(2142, 5092, 1)) + player["rum_deal"] = "spider_added" + player.inventory.add("bucket") + val tap = GameObjects.find(Tile(2142, 5093, 1), "braindeath_island_output_tap") + + player.itemOnObject(tap, player.inventory.indexOf("bucket")) + tick(4) + assertTrue(player.containsMessage("The vat is empty.")) + assertNull(player.inventory.indexOf("unsanitary_swill").takeIf { it >= 0 }) + + player["rum_deal"] = "collect_swill" + player.itemOnObject(tap, player.inventory.indexOf("bucket")) + tick(2) + assertTrue(player.inventory.contains("unsanitary_swill")) + } + + @Test + fun `A zombie swab can only be talked to once intimidated`() { + val player = createPlayer(Tile(2160, 5070)) + val swab = createNPC("zombie_swab", Tile(2161, 5070)) + + player.npcOption(swab, "Talk-to") + tick(2) + assertTrue(player.containsMessage("I don't think he wants to talk to you.")) + + player["rum_deal_swab_a"] = 1 + player.npcOption(swab, "Talk-to") + tick(2) + player.dialogueContinue() + + player.npcOption(swab, "Intimidate") + tick(2) + assertTrue(player.containsMessage("I don't think he will be causing any more trouble.")) + } + + @Test + fun `Each brewer has their own dialogue`() { + val player = createPlayer(Tile(2150, 5093, 1)) + val first = createNPC("brewer", Tile(2149, 5093, 1)) + val fourth = createNPC("brewer_4", Tile(2151, 5093, 1)) + tick(2) + + // A brewer whose id did not match would end the dialogue right after the opener + player.npcOption(first, "Talk-to") + tick(2) + player.dialogueContinue() // "So... how are you holding up?" + assertNotNull(player.dialogue, "brewer should have its own reply") + player.skipDialogues() + + player.npcOption(fourth, "Talk-to") + tick(2) + player.dialogueContinue() + assertNotNull(player.dialogue, "brewer_4 should have its own reply") + } + + @Test + fun `Luke sees through a diversion before Braindeath sends you for water`() { + val player = createPlayer(Tile(2120, 5098)) + createNPC("50_luke", Tile(2120, 5096)) + player["rum_deal"] = "blindweed_added" // stage 7, one short of the water errand + val gate = GameObjects.find(Tile(2120, 5098), "gate_104_closed") + tick(2) + + player.objectOption(gate, "Open") + tick(2) + player.skipDialogues() + tick(4) + + assertEquals(Tile(2120, 5098), player.tile, "Luke should not let the player through") + } + + @Test + fun `A diversion gets the player out through Luke's gate`() { + val player = createPlayer(Tile(2120, 5098)) + createNPC("50_luke", Tile(2120, 5096)) + player["rum_deal"] = "fetch_water" // stage 8 unlocks the diversions + val gate = GameObjects.find(Tile(2120, 5098), "gate_104_closed") + tick(2) + + player.objectOption(gate, "Open") + tick(2) + player.skipDialogues() + tick(6) + + assertEquals(Tile(2120, 5099), player.tile, "the player should slip north past Luke") + } + + @Test + fun `Luke marches the player back into the compound`() { + val player = createPlayer(Tile(2120, 5099)) + createNPC("50_luke", Tile(2120, 5096)) + player["rum_deal"] = "fetch_water" + val gate = GameObjects.find(Tile(2120, 5098), "gate_104_closed") + tick(2) + + player.objectOption(gate, "Open") + tick(2) + player.skipDialogues() + tick(6) + + assertEquals(Tile(2120, 5098), player.tile, "the player should be sent back south") + } + + @Test + fun `Luke tells the tale of his missing half`() { + val player = createPlayer(Tile(2120, 5097)) + val luke = createNPC("50_luke", Tile(2120, 5096)) + tick(2) + + player.npcOption(luke, "Talk-to") + tick(2) + player.skipDialogues() + player.dialogueOption("line1") // What happened to you? + player.dialogueContinue() + assertNotNull(player.dialogue, "the albatross story should follow") + player.skipDialogues() + } + + @Test + fun `The Evil Spirit fights with its own animations and sounds`() { + val definition = get().getOrNull("evil_spirit") + assertNotNull(definition, "the Evil Spirit needs a combat definition to fight back") + + assertEquals("spirit_parry", definition.defendAnim) + assertEquals("spirit_death", definition.deathAnim) + assertEquals("deal_spirit_attack", definition.defendSound?.id) + assertEquals("rumdeal_spirit_depart", definition.deathSound?.id) + + val melee = definition.attacks["melee"] + assertNotNull(melee, "the Evil Spirit needs a melee attack") + assertEquals("spirit_attack", melee.anim) + val hit = melee.targetHits.first() + assertEquals("slash", hit.offense) + assertEquals(280, hit.max) + } + + @Test + fun `Blindweed seeds can be planted and harvested in the Braindeath patch`() { + val player = createPlayer(Tile(2161, 5069)) + player.experience.set(Skill.Farming, Level.experience(40)) + player.inventory.add("seed_dibber") + player.inventory.add("spade") + player.inventory.add("blindweed_seed") + val patch = GameObjects.find(Tile(2162, 5069), "farming_blindweed_patch_braindeath_island") + tick(2) + + player["farming_blindweed_patch_braindeath_island"] = "weeds_0" + + player.itemOnObject(patch, player.inventory.indexOf("blindweed_seed")) + tick(6) + assertEquals( + "blindweed_0", + player["farming_blindweed_patch_braindeath_island", ""], + "the seed should be in the ground", + ) + assertEquals(0, player.inventory.count("blindweed_seed")) + assertTrue( + player.timers.contains("farming_tick"), + "planting must start the growth timer for a patch that was already clear", + ) + + // Skip the growth tick + player["farming_blindweed_patch_braindeath_island"] = "blindweed_life1" + player.objectOption(patch, "Pick") + tick(6) + + assertEquals(1, player.inventory.count("blindweed"), "picking should yield one blindweed") + } + + @Test + fun `Blindweed reaches full growth in a single farming cycle`() { + val player = createPlayer(Tile(2161, 5069)) + val farming = scripts.filterIsInstance().first() + player["farming_blindweed_patch_braindeath_island"] = "blindweed_0" + + // Blindweed is a 2-stage crop on a 5 minute tick, so one cycle finishes it + farming.grow(player, 5) + assertEquals("blindweed_life1", player["farming_blindweed_patch_braindeath_island", ""]) + + // life1 is the last stage; further cycles must not advance or kill it + farming.grow(player, 5) + farming.grow(player, 10) + assertEquals("blindweed_life1", player["farming_blindweed_patch_braindeath_island", ""]) + } + + @Test + fun `Planting below the farming level keeps the seed`() { + val player = createPlayer(Tile(2161, 5069)) + player.experience.set(Skill.Farming, Level.experience(39)) + player.inventory.add("seed_dibber") + player.inventory.add("blindweed_seed") + player["farming_blindweed_patch_braindeath_island"] = "weeds_0" + val patch = GameObjects.find(Tile(2162, 5069), "farming_blindweed_patch_braindeath_island") + tick(2) + + player.itemOnObject(patch, player.inventory.indexOf("blindweed_seed")) + tick(4) + + assertEquals(1, player.inventory.count("blindweed_seed"), "the seed should survive a failed level check") + assertEquals("weeds_0", player["farming_blindweed_patch_braindeath_island", ""]) + } + + @Test + fun `Harvesting the blindweed advances the quest`() { + val player = createPlayer(Tile(2161, 5069)) + player.experience.set(Skill.Farming, Level.experience(40)) + player.inventory.add("spade") + player["rum_deal"] = "given_seeds" + player["farming_blindweed_patch_braindeath_island"] = "blindweed_life1" + val patch = GameObjects.find(Tile(2162, 5069), "farming_blindweed_patch_braindeath_island") + tick(2) + + player.objectOption(patch, "Pick") + tick(6) + + assertTrue(player.inventory.contains("blindweed")) + assertEquals( + "grown_blindweed", + player["rum_deal", "unstarted"], + "picking the blindweed is what moves the quest off given_seeds", + ) + } + + @Test + fun `A crop planted before the timer fix resumes growing on login`() { + // Simulates a save from before planting started the timer: seed in the ground, + // no last_growth_cycle because the growth tick had never run. + val player = createPlayer(Tile(2161, 5069)) { + it["farming_blindweed_patch_braindeath_island"] = "blindweed_0" + } + + assertTrue( + player.timers.contains("farming_tick"), + "a crop already in the ground must resume growing on login", + ) + } + + @Test + fun `Login does not start the growth timer for a player with nothing planted`() { + val player = createPlayer(Tile(2161, 5069)) + + assertTrue( + !player.timers.contains("farming_tick"), + "players who have never farmed should not tick every minute", + ) + } + + @Test + fun `The player is told when the blindweed finishes growing`() { + val player = createPlayer(Tile(2161, 5069)) + player["rum_deal"] = "given_seeds" + player["farming_blindweed_patch_braindeath_island"] = "blindweed_0" + val farming = scripts.filterIsInstance().first() + + farming.grow(player, 5) + + assertEquals("blindweed_life1", player["farming_blindweed_patch_braindeath_island", ""]) + assertTrue(player.containsMessage("I wonder how my Blindweed is coming along...")) + } + + @Test + fun `Opening Luke's gate swaps in the open gate model`() { + val player = createPlayer(Tile(2120, 5098)) + createNPC("50_luke", Tile(2120, 5096)) + player["rum_deal"] = "fetch_water" + val gate = GameObjects.find(Tile(2120, 5098), "gate_104_closed") + tick(2) + + player.objectOption(gate, "Open") + tick(2) + player.skipDialogues() + tick() + + // The closed gate is swapped for an invisible wall and the open gate appears alongside it, + // both on the wall layer - a centrepiece here would render nothing. + assertNotNull( + GameObjects.findLayerOrNull(Tile(2120, 5098), ObjectLayer.WALL, "inviswall"), + "the closed gate should be replaced by an invisible wall", + ) + assertNotNull( + GameObjects.findLayerOrNull(Tile(2120, 5099), ObjectLayer.WALL, "gate_104_opened"), + "the open gate should render on the wall layer", + ) + } + + @Test + fun `Weeds regrow on the blindweed patch and stop when fully overgrown`() { + val player = createPlayer(Tile(2161, 5069)) + val farming = scripts.filterIsInstance().first() + player["farming_blindweed_patch_braindeath_island"] = "weeds_0" + + // weeds_3 is the varbit default, so a fully overgrown patch stores no value of its own + for ((cycle, expected) in listOf("weeds_1", "weeds_2", "weeds_3").withIndex()) { + farming.grow(player, 5 * (cycle + 1)) + assertEquals(expected, player["farming_blindweed_patch_braindeath_island", "weeds_3"]) + } + + // weeds_3 is fully overgrown - further cycles must not wrap it back around to clear + farming.grow(player, 20) + farming.grow(player, 25) + assertEquals("weeds_3", player["farming_blindweed_patch_braindeath_island", "weeds_3"]) + } + + @Test + fun `Blindweed patch can be raked clear with a rake`() { + val player = createPlayer(Tile(2161, 5069)) + player.inventory.add("rake") + player["farming_blindweed_patch_braindeath_island"] = "weeds_3" + val patch = GameObjects.find(Tile(2162, 5069), "farming_blindweed_patch_braindeath_island") + tick(2) + + // These weed states have no Rake option, so the rake is used on the patch instead + player.itemOnObject(patch, player.inventory.indexOf("rake")) + tick(20) + + assertEquals("weeds_0", player["farming_blindweed_patch_braindeath_island", ""]) + assertEquals(3, player.inventory.count("weeds")) + } + + @Test + fun `Blindweed yields a single harvest and empties the patch`() { + val player = createPlayer(Tile(2161, 5069)) + player.experience.set(Skill.Farming, Level.experience(40)) + player.inventory.add("spade") + player["farming_blindweed_patch_braindeath_island"] = "blindweed_life1" + val patch = GameObjects.find(Tile(2162, 5069), "farming_blindweed_patch_braindeath_island") + tick(2) + + player.objectOption(patch, "Pick") + tick(20) + + // blindweed_life1 is the only life state, so picking it exhausts the patch + assertEquals(1, player.inventory.count("blindweed"), "blindweed has a single life") + assertEquals("weeds_0", player["farming_blindweed_patch_braindeath_island", ""]) + } + + @Test + fun `Banishing the Evil Spirit clears only its own hint arrow`() { + val player = createPlayer(Tile(2143, 5101, 1)) + player["rum_deal"] = "bless_wrench" + player["rum_deal_brewing_control"] = 1 + player.inventory.add("holy_wrench") + + val controls = GameObjects.find(Tile(2143, 5100, 1), "braindeath_island_brewing_controls_multi") + player.itemOnObject(controls, player.inventory.indexOf("holy_wrench")) + tick(6) + + val spirit = NPCs.at(player.tile.regionLevel).first { it.id == "evil_spirit" } + val hints = player.viewport!!.hints + assertEquals(1, hints.count { it != 0 }, "the spirit's arrow should take a slot of its own") + + spirit.damage(5000, source = player) + tick(6) + + assertEquals(0, hints.count { it != 0 }, "the spirit's arrow should be gone") + } + + @Test + fun `Inspecting a trashed patch says the soil is ruined`() { + val player = createPlayer(Tile(2142, 5070)) + val trashed = GameObjects.find(Tile(2142, 5069), "blindweed_patch_trashed") + tick(2) + + player.objectOption(trashed, "Inspect") + tick(2) + + assertTrue(player.containsMessage("The soil here is too poor to farm on.")) + } +} diff --git a/game/src/test/kotlin/content/quest/member/rum_deal/ZombieDialogueTest.kt b/game/src/test/kotlin/content/quest/member/rum_deal/ZombieDialogueTest.kt new file mode 100644 index 0000000000..99c12e1739 --- /dev/null +++ b/game/src/test/kotlin/content/quest/member/rum_deal/ZombieDialogueTest.kt @@ -0,0 +1,27 @@ +package content.quest.member.rum_deal + +import WorldTest +import org.junit.jupiter.api.Test +import world.gregs.voidps.engine.data.definition.NPCDefinitions +import kotlin.test.assertEquals + +/** + * Luke, the zombie crew and Captain Donnie use the old-man chathead set, matching the OLD_* emotes the + * original uses for them. The `_2`..`_6` variants inherit it through `clone`. + */ +class ZombieDialogueTest : WorldTest() { + + @Test + fun `Luke, the zombies and Captain Donnie use the old chathead set`() { + val expected = buildList { + add("captain_donnie") + add("50_luke") + for (i in 1..6) add(if (i == 1) "zombie_protester" else "zombie_protester_$i") + for (i in 1..6) add(if (i == 1) "zombie_swab" else "zombie_swab_$i") + for (i in 1..6) add(if (i == 1) "zombie_pirate" else "zombie_pirate_$i") + } + for (id in expected) { + assertEquals("old", NPCDefinitions.get(id)["dialogue", ""], "$id should use the old chathead set") + } + } +} diff --git a/game/src/test/kotlin/content/skill/farming/FarmingTest.kt b/game/src/test/kotlin/content/skill/farming/FarmingTest.kt index 38f4d0b40b..2b9f1aeb85 100644 --- a/game/src/test/kotlin/content/skill/farming/FarmingTest.kt +++ b/game/src/test/kotlin/content/skill/farming/FarmingTest.kt @@ -94,7 +94,7 @@ class FarmingTest : KoinMock() { setRandom(object : FakeRandom() { override fun nextInt(until: Int): Int = 0 }) - player["farming_veg_patch_falador_nw"] = "weeds_3" + player["farming_veg_patch_falador_nw"] = "weeds_0" player["farming_offset_mins"] = 0 // simulate several growth cycles diff --git a/network/src/main/kotlin/world/gregs/voidps/network/login/protocol/encode/HintEncoder.kt b/network/src/main/kotlin/world/gregs/voidps/network/login/protocol/encode/HintEncoder.kt index f3382c997e..993eff5d6b 100644 --- a/network/src/main/kotlin/world/gregs/voidps/network/login/protocol/encode/HintEncoder.kt +++ b/network/src/main/kotlin/world/gregs/voidps/network/login/protocol/encode/HintEncoder.kt @@ -19,6 +19,16 @@ object HintArrow { const val RED_MINIMAP = 8 } +object HintType { + const val CLEAR = 0 + const val NPC = 1 + const val TILE = 2 + const val WEST = 3 + const val EAST = 4 + const val SOUTH = 5 + const val NORTH = 6 + const val PLAYER = 10 +} /** * Send system update timer * @param type of hint (0=clear, 1=npc, 2=tile_centre, 3=tile_west, 4=tile_east, 5=tile_south, 6=tile_north, 10=player) @@ -45,19 +55,33 @@ fun Client.arrowHint( model: Int = 65535, ) = send(Protocol.HINT_ARROW) { writeByte((arrowIndex shl 5) or type) + // The packet is a fixed twelve bytes, so every branch - clearing included - has to write all + // of it. Stopping short leaves the client reading the packets behind this one as the rest of + // it, which desyncs the stream and takes the client down. + if (type == 0) { + repeat(11) { + writeByte(0) + } + return@send + } writeByte(sprite) - if (sprite >= 0) { - if (type == 1 || type == 10) { + when (type) { + 1, 10 -> { writeShort(entityIndex) + // Skip 6 writeInt(0) writeShort(0) - } else if (type in 2..6) { + } + in 2..6 -> { writeByte(level) // level writeShort(x) // x writeShort(y) // y writeByte(z) // z? writeShort(radius) } - writeShort(model) + else -> repeat(8) { + writeByte(0) + } } -} \ No newline at end of file + writeShort(model) +} diff --git a/network/src/test/kotlin/world/gregs/voidps/network/login/protocol/EncodersTest.kt b/network/src/test/kotlin/world/gregs/voidps/network/login/protocol/EncodersTest.kt index 2ba288dee4..92ceb35137 100644 --- a/network/src/test/kotlin/world/gregs/voidps/network/login/protocol/EncodersTest.kt +++ b/network/src/test/kotlin/world/gregs/voidps/network/login/protocol/EncodersTest.kt @@ -123,6 +123,9 @@ class EncodersTest { { clearZone(123, 234, 12) }, { updateZone(123, 234, 12) }, { arrowHint(2, 1, 3, -1, 100, 123, 2, 50, 2, 1234) }, + // Clearing writes the same twelve bytes as any other hint; a shorter packet + // desyncs the client + { arrowHint(0, 1) }, ).mapIndexed { index, packet -> random = Random(0) val (name, expected) = packets[index] diff --git a/network/src/test/resources/encoder-packets.csv b/network/src/test/resources/encoder-packets.csv index ee6a6ffbea..d250472e95 100644 --- a/network/src/test/resources/encoder-packets.csv +++ b/network/src/test/resources/encoder-packets.csv @@ -66,3 +66,4 @@ weight, 78, 48, 57 clear zone, 20, -116, 22, -123 update zone, 28, 22, -116, -5 arrow hint, 56, 34, 3, 2, 0, 100, 0, 123, 50, 0, 2, 4, -46 +clear hint, 56, 32, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0