From 76c8c790bc9d04d3530479115aedd684aaf08598 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Tue, 7 Jul 2026 17:21:43 -0700 Subject: [PATCH 1/3] Add random event system and maze event Implements the random event trigger, kidnap teleport and mysterious old man framework, plus the Maze random event where players navigate to a central shrine before time runs out. Tutorial Island is tagged no_random_events so events don't fire there. Ref GregHib/void-private#223 --- .../event/random/maze/maze.ifaces.toml | 3 + .../activity/event/random/maze/maze.objs.toml | 47 +++++ .../event/random/maze/maze.tables.toml | 118 +++++++++++++ .../event/random/maze/maze.varps.toml | 3 + .../activity/event/random/maze/maze.vars.toml | 3 + .../event/random/random_event.areas.toml | 23 +++ .../event/random/random_event.npcs.toml | 4 + .../event/random/random_event.tables.toml | 41 +++++ .../event/random/random_event.vars.toml | 11 ++ .../tutorial_island.areas.toml | 4 + .../event/random/RandomEventKidnap.kt | 82 +++++++++ .../event/random/RandomEventTrigger.kt | 59 +++++++ .../activity/event/random/RandomEvents.kt | 99 +++++++++++ .../activity/event/random/maze/Maze.kt | 163 ++++++++++++++++++ .../content/activity/event/random/MazeTest.kt | 134 ++++++++++++++ .../event/random/RandomEventTriggerTest.kt | 76 ++++++++ 16 files changed, 870 insertions(+) create mode 100644 data/activity/event/random/maze/maze.ifaces.toml create mode 100644 data/activity/event/random/maze/maze.objs.toml create mode 100644 data/activity/event/random/maze/maze.tables.toml create mode 100644 data/activity/event/random/maze/maze.varps.toml create mode 100644 data/activity/event/random/maze/maze.vars.toml create mode 100644 data/activity/event/random/random_event.areas.toml create mode 100644 data/activity/event/random/random_event.tables.toml create mode 100644 data/activity/event/random/random_event.vars.toml create mode 100644 data/area/misthalin/tutorial_island/tutorial_island.areas.toml create mode 100644 game/src/main/kotlin/content/activity/event/random/RandomEventKidnap.kt create mode 100644 game/src/main/kotlin/content/activity/event/random/RandomEventTrigger.kt create mode 100644 game/src/main/kotlin/content/activity/event/random/RandomEvents.kt create mode 100644 game/src/main/kotlin/content/activity/event/random/maze/Maze.kt create mode 100644 game/src/test/kotlin/content/activity/event/random/MazeTest.kt create mode 100644 game/src/test/kotlin/content/activity/event/random/RandomEventTriggerTest.kt diff --git a/data/activity/event/random/maze/maze.ifaces.toml b/data/activity/event/random/maze/maze.ifaces.toml new file mode 100644 index 0000000000..0b5db0fcdc --- /dev/null +++ b/data/activity/event/random/maze/maze.ifaces.toml @@ -0,0 +1,3 @@ +[maze_timer] +id = 209 +type = "overlay" diff --git a/data/activity/event/random/maze/maze.objs.toml b/data/activity/event/random/maze/maze.objs.toml new file mode 100644 index 0000000000..6ee04427a8 --- /dev/null +++ b/data/activity/event/random/maze/maze.objs.toml @@ -0,0 +1,47 @@ +[maze_wall] +id = 3626 + +# The cache has no swung-open wall variant; the opened ids reuse the same +# object which the door handler swings by rotation. Opened ids are defined +# first so map objects resolve to the closed state. +[maze_door_1_opened] +id = 3628 + +[maze_door_2_opened] +id = 3629 + +[maze_door_3_opened] +id = 3630 + +[maze_door_4_opened] +id = 3631 + +[maze_door_5_opened] +id = 3632 + +[maze_door_1_closed] +id = 3628 + +[maze_door_2_closed] +id = 3629 + +[maze_door_3_closed] +id = 3630 + +[maze_door_4_closed] +id = 3631 + +[maze_door_5_closed] +id = 3632 + +[maze_chest] +id = 3635 +examine = "A chest, I wonder what's inside." + +[maze_chest_opened] +id = 3636 +examine = "An open chest." + +[strange_shrine] +id = 3634 +examine = "A strange shrine." diff --git a/data/activity/event/random/maze/maze.tables.toml b/data/activity/event/random/maze/maze.tables.toml new file mode 100644 index 0000000000..d3cc442945 --- /dev/null +++ b/data/activity/event/random/maze/maze.tables.toml @@ -0,0 +1,118 @@ +[maze_rewards] +item = "item" +divisor = "int" + +[.coins] +item = "coins" +divisor = 1 + +[.feather] +item = "feather" +divisor = 2 + +[.iron_arrow] +item = "iron_arrow" +divisor = 3 + +[.chaos_rune] +item = "chaos_rune" +divisor = 9 + +[.steel_arrow] +item = "steel_arrow" +divisor = 12 + +[.death_rune] +item = "death_rune" +divisor = 18 + +[.coal] +item = "coal" +divisor = 45 + +[.nature_rune] +item = "nature_rune" +divisor = 162 + +[.mithril_ore] +item = "mithril_ore" +divisor = 180 + +[maze_chest_rewards] +item = "item" +amount = "int" +text = "string" + +[.air_rune] +item = "air_rune" +amount = 15 +text = "You've found some air runes!" + +[.water_rune] +item = "water_rune" +amount = 10 +text = "You've found some water runes!" + +[.earth_rune] +item = "earth_rune" +amount = 10 +text = "You've found some earth runes!" + +[.fire_rune] +item = "fire_rune" +amount = 10 +text = "You've found some fire runes!" + +[.bronze_arrow] +item = "bronze_arrow" +amount = 20 +text = "You've found some bronze arrows!" + +[.bronze_bolts] +item = "bronze_bolts" +amount = 10 +text = "You've found some bronze bolts!" + +[.iron_arrow] +item = "iron_arrow" +amount = 15 +text = "You've found some iron arrows!" + +[.attack_potion] +item = "attack_potion_2" +amount = 1 +text = "You've found an attack potion!" + +[.strength_potion] +item = "strength_potion_2" +amount = 1 +text = "You've found a strength potion!" + +[.defence_potion] +item = "defence_potion_2" +amount = 1 +text = "You've found a defence potion!" + +[maze_start_points] +tile = "tile" + +[.south_east] +tile = { x = 2928, y = 4553 } + +[.south_middle] +tile = { x = 2917, y = 4553 } + +[.south_west] +tile = { x = 2908, y = 4555 } + +[.west_south] +tile = { x = 2891, y = 4589 } + +[.west_north] +tile = { x = 2891, y = 4595 } + +[.north_middle] +tile = { x = 2926, y = 4597 } + +[.north_east] +tile = { x = 2931, y = 4597 } diff --git a/data/activity/event/random/maze/maze.varps.toml b/data/activity/event/random/maze/maze.varps.toml new file mode 100644 index 0000000000..8256de9173 --- /dev/null +++ b/data/activity/event/random/maze/maze.varps.toml @@ -0,0 +1,3 @@ +[maze_timer_bar] +id = 531 +format = "int" diff --git a/data/activity/event/random/maze/maze.vars.toml b/data/activity/event/random/maze/maze.vars.toml new file mode 100644 index 0000000000..0fedb7d508 --- /dev/null +++ b/data/activity/event/random/maze/maze.vars.toml @@ -0,0 +1,3 @@ +[maze_chests_opened] +format = "int" +persist = true diff --git a/data/activity/event/random/random_event.areas.toml b/data/activity/event/random/random_event.areas.toml new file mode 100644 index 0000000000..c8b5d5257b --- /dev/null +++ b/data/activity/event/random/random_event.areas.toml @@ -0,0 +1,23 @@ +[random_event_exile_lumbridge] +x = [3221, 3223] +y = [3217, 3219] + +[random_event_exile_varrock] +x = [3211, 3213] +y = [3422, 3424] + +[random_event_exile_falador] +x = [2963, 2965] +y = [3377, 3379] + +[random_event_exile_edgeville] +x = [3092, 3094] +y = [3493, 3495] + +[random_event_exile_draynor] +x = [3092, 3094] +y = [3243, 3245] + +[random_event_exile_al_kharid] +x = [3292, 3294] +y = [3183, 3185] diff --git a/data/activity/event/random/random_event.npcs.toml b/data/activity/event/random/random_event.npcs.toml index f3d8243a29..4a3dbffa40 100644 --- a/data/activity/event/random/random_event.npcs.toml +++ b/data/activity/event/random/random_event.npcs.toml @@ -1,3 +1,7 @@ +[mysterious_old_man] +id = 410 +examine = "An old man with an aura of magic." + [freaky_forester] id = 2458 wander_range = 3 diff --git a/data/activity/event/random/random_event.tables.toml b/data/activity/event/random/random_event.tables.toml new file mode 100644 index 0000000000..96aa8d8c42 --- /dev/null +++ b/data/activity/event/random/random_event.tables.toml @@ -0,0 +1,41 @@ +[random_events] +event = "string" +weight = "int" + +[.maze] +event = "maze" +weight = 10 + +[random_event_settings] +value = "int" + +# 1-in-N chance of an event per experience drop +[.roll_chance] +value = 300 + +[.min_cooldown_minutes] +value = 45 + +[.max_cooldown_minutes] +value = 90 + +[random_event_exiles] +area = "string" + +[.lumbridge] +area = "random_event_exile_lumbridge" + +[.varrock] +area = "random_event_exile_varrock" + +[.falador] +area = "random_event_exile_falador" + +[.edgeville] +area = "random_event_exile_edgeville" + +[.draynor] +area = "random_event_exile_draynor" + +[.al_kharid] +area = "random_event_exile_al_kharid" diff --git a/data/activity/event/random/random_event.vars.toml b/data/activity/event/random/random_event.vars.toml new file mode 100644 index 0000000000..e243e95cef --- /dev/null +++ b/data/activity/event/random/random_event.vars.toml @@ -0,0 +1,11 @@ +[random_event] +format = "string" +persist = true + +[random_event_origin] +format = "int" +persist = true + +[random_event_cooldown] +format = "int" +persist = true diff --git a/data/area/misthalin/tutorial_island/tutorial_island.areas.toml b/data/area/misthalin/tutorial_island/tutorial_island.areas.toml new file mode 100644 index 0000000000..c6f0c7a53d --- /dev/null +++ b/data/area/misthalin/tutorial_island/tutorial_island.areas.toml @@ -0,0 +1,4 @@ +[tutorial_island] +x = [3040, 3160] +y = [3040, 3136] +tags = ["no_random_events"] diff --git a/game/src/main/kotlin/content/activity/event/random/RandomEventKidnap.kt b/game/src/main/kotlin/content/activity/event/random/RandomEventKidnap.kt new file mode 100644 index 0000000000..16ccf952ff --- /dev/null +++ b/game/src/main/kotlin/content/activity/event/random/RandomEventKidnap.kt @@ -0,0 +1,82 @@ +package content.activity.event.random + +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.client.message +import world.gregs.voidps.engine.entity.character.areaSound +import world.gregs.voidps.engine.entity.character.move.tele +import world.gregs.voidps.engine.entity.character.npc.NPCs +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.entity.character.player.name +import world.gregs.voidps.engine.queue.strongQueue +import world.gregs.voidps.type.Tile + +/** + * The Mysterious Old Man appears next to the player and whisks them away to an event. + */ +class RandomEventKidnap : Script { + + init { + // An event interrupted by logout restarts fresh on login; the original + // location persists so completing it still returns the player there. + playerSpawn { + val event: String = get("random_event") ?: return@playerSpawn + RandomEvents.start(this, event) + } + + for (type in TELEPORT_TYPES) { + teleportTakeOff(type) { + if (contains("random_event")) { + message("You can't leave just yet.") + false + } else { + true + } + } + } + } + + companion object { + private val TELEPORT_TYPES = listOf( + "modern", + "ancient", + "lunar", + "dungeoneering", + "scroll", + "tablet", + "lumbridge_home_teleport", + "jewellery", + "fairy", + "fairy_ring", + "spirit_tree", + "kinship", + "ectophial", + "skull_sceptre", + "puro_puro", + "wilderness", + ) + } +} + +fun summonOldMan(player: Player, event: String? = null): Boolean { + val id = event ?: RandomEvents.pick() ?: return false + val npc = NPCs.addRandom("mysterious_old_man", player.tile.toCuboid(1), ticks = 25, owner = player) + ?: NPCs.add("mysterious_old_man", player.tile, ticks = 25, owner = player) + npc.watch(player) + npc.say("Ah, ${player.name}, you'll do nicely!") + player.strongQueue("random_event_kidnap", 2) { + RandomEvents.start(player, id) + } + return true +} + +/** + * Teleport the player into an event area; events call this from their launcher. + */ +suspend fun Player.kidnap(destination: Tile) { + areaSound("teleport", tile = tile, radius = 10) + anim("teleport_other_impact") + gfx("teleport_other_impact") + delay(3) + clearAnim() + tele(destination) +} diff --git a/game/src/main/kotlin/content/activity/event/random/RandomEventTrigger.kt b/game/src/main/kotlin/content/activity/event/random/RandomEventTrigger.kt new file mode 100644 index 0000000000..18c6b670d9 --- /dev/null +++ b/game/src/main/kotlin/content/activity/event/random/RandomEventTrigger.kt @@ -0,0 +1,59 @@ +package content.activity.event.random + +import content.bot.isBot +import content.entity.combat.inCombat +import content.quest.instance +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.dialogue +import world.gregs.voidps.engine.client.ui.menu +import world.gregs.voidps.engine.client.variable.hasClock +import world.gregs.voidps.engine.data.definition.Areas +import world.gregs.voidps.engine.data.definition.Tables +import world.gregs.voidps.engine.entity.character.mode.EmptyMode +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.timer.epochSeconds +import world.gregs.voidps.type.random + +/** + * Rolls for a random event on each experience drop, so only active players are targeted. + * The persisted cooldown clock starts on first login and restarts whenever an event + * begins or ends, keeping events infrequent regardless of experience rates. + */ +class RandomEventTrigger : Script { + + init { + experience { _, _, _ -> + if (!contains("random_event_cooldown")) { + // First activity; events unlock once the initial cooldown expires + RandomEvents.cooldown(this) + return@experience + } + if (eligible(this) && random.nextInt(Tables.int("random_event_settings.roll_chance.value")) == 0) { + // Restart the cooldown immediately so a failed pick can't re-roll every drop + RandomEvents.cooldown(this) + summonOldMan(this) + } + } + + adminCommand("randomevent", stringArg("event", optional = true), desc = "Start a random event") { args -> + val event = args.getOrNull(0) ?: RandomEvents.pick() + if (event == null || !summonOldMan(this, event)) { + message("No random event found${if (args.isEmpty()) "" else " for '${args[0]}'"}.") + } + } + } + + private fun eligible(player: Player): Boolean = !player.isBot && + !player.contains("random_event") && + player.instance() == null && + !player.inCombat && + player.menu == null && + player.dialogue == null && + player.mode == EmptyMode && + !player.contains("delay") && + !player.hasClock("random_event_cooldown", epochSeconds()) && + Areas.get(player.tile.zone).none { it.tags.contains("no_random_events") } +} diff --git a/game/src/main/kotlin/content/activity/event/random/RandomEvents.kt b/game/src/main/kotlin/content/activity/event/random/RandomEvents.kt new file mode 100644 index 0000000000..f388764294 --- /dev/null +++ b/game/src/main/kotlin/content/activity/event/random/RandomEvents.kt @@ -0,0 +1,99 @@ +package content.activity.event.random + +import content.quest.clearInstance +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.client.message +import world.gregs.voidps.engine.client.variable.start +import world.gregs.voidps.engine.data.definition.Areas +import world.gregs.voidps.engine.data.definition.Tables +import world.gregs.voidps.engine.entity.character.move.tele +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.map.collision.random +import world.gregs.voidps.engine.queue.strongQueue +import world.gregs.voidps.engine.timer.epochSeconds +import world.gregs.voidps.type.Tile +import world.gregs.voidps.type.random +import java.util.concurrent.TimeUnit + +/** + * Registry and lifecycle for anti-macro random events. + * https://runescape.wiki/w/Random_events?oldid=3667851 + * + * Each event registers a launcher in its script's init block and calls + * [complete] when the player succeeds or [fail] when they fail or abandon it. + */ +object RandomEvents { + + private val events = mutableMapOf Unit>() + + fun register(id: String, launcher: suspend Player.() -> Unit) { + Script.checkLoading() + events[id] = launcher + } + + /** + * Weighted pick over the `random_events` table, skipping rows without a registered launcher. + */ + fun pick(): String? { + val rows = Tables.get("random_events").rows().filter { events.containsKey(it.string("event")) } + if (rows.isEmpty()) { + return null + } + var roll = random.nextInt(rows.sumOf { it.int("weight") }) + for (row in rows) { + roll -= row.int("weight") + if (roll < 0) { + return row.string("event") + } + } + return null + } + + fun start(player: Player, id: String? = pick()): Boolean { + val launcher = events[id ?: return false] ?: return false + player["random_event"] = id + if (!player.contains("random_event_origin")) { + player["random_event_origin"] = player.tile.id + } + player.strongQueue("random_event_start") { + launcher.invoke(player) + } + return true + } + + /** + * Success; return the player to where they were taken from. + */ + fun complete(player: Player) { + player.tele(exit(player)) + } + + /** + * Failure or abandonment; teleport the player to a random safe location with no reward. + */ + fun fail(player: Player) { + exit(player) + val row = Tables.get("random_event_exiles").rows().random(random) + player.tele(Areas[row.string("area")].random(player) ?: Tile(3222, 3218)) + player.message("You wake up feeling drowsy, unsure of where you are.") + } + + private fun exit(player: Player): Tile { + val origin = Tile(player["random_event_origin", player.tile.id]) + player.clearInstance() + player.clear("random_event") + player.clear("random_event_origin") + cooldown(player) + return origin + } + + /** + * Delay the next event by `random_event_settings` min to max minutes. + */ + fun cooldown(player: Player) { + val min = Tables.int("random_event_settings.min_cooldown_minutes.value") + val max = Tables.int("random_event_settings.max_cooldown_minutes.value") + val minutes = random.nextInt(min, max + 1) + player.start("random_event_cooldown", TimeUnit.MINUTES.toSeconds(minutes.toLong()).toInt(), epochSeconds()) + } +} diff --git a/game/src/main/kotlin/content/activity/event/random/maze/Maze.kt b/game/src/main/kotlin/content/activity/event/random/maze/Maze.kt new file mode 100644 index 0000000000..6e4b06e292 --- /dev/null +++ b/game/src/main/kotlin/content/activity/event/random/maze/Maze.kt @@ -0,0 +1,163 @@ +package content.activity.event.random.maze + +import content.activity.event.random.RandomEvents +import content.activity.event.random.kidnap +import content.entity.obj.door.enterDoor +import content.entity.player.dialogue.type.item +import content.entity.player.dialogue.type.statement +import content.quest.closeTabs +import content.quest.instance +import content.quest.instanceOffset +import content.quest.openTabs +import content.quest.setInstanceLogout +import content.quest.smallInstance +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.client.Minimap +import world.gregs.voidps.engine.client.clearMinimap +import world.gregs.voidps.engine.client.message +import world.gregs.voidps.engine.client.minimap +import world.gregs.voidps.engine.client.ui.close +import world.gregs.voidps.engine.client.ui.open +import world.gregs.voidps.engine.data.definition.Tables +import world.gregs.voidps.engine.entity.character.jingle +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.entity.character.player.skill.Skill +import world.gregs.voidps.engine.entity.item.floor.FloorItems +import world.gregs.voidps.engine.entity.obj.replace +import world.gregs.voidps.engine.inv.add +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.engine.timer.Timer +import world.gregs.voidps.type.Region +import world.gregs.voidps.type.Tile +import world.gregs.voidps.type.random + +/** + * Maze random event; reach the shrine at the centre before the time runs out. + * The reward scales with the player's total level and the time remaining. + * https://runescape.wiki/w/Maze + */ +class Maze : Script { + + private val duration = 300 + + // Wall tile to the player tiles it can't be opened from + private val oneWayWalls = mapOf( + Tile(2904, 4573) to setOf(Tile(2904, 4573), Tile(2904, 4572), Tile(2904, 4574)), + Tile(2906, 4586) to setOf(Tile(2906, 4586), Tile(2907, 4586)), + Tile(2902, 4575) to setOf(Tile(2903, 4575)), + Tile(2924, 4583) to setOf(Tile(2923, 4583)), + ) + + init { + RandomEvents.register("maze") { + startMaze() + } + + timerStart("maze") { 1 } + + timerTick("maze") { + val ticks = dec("maze_ticks") + set("maze_timer_bar", ticks / 3) + when { + instance() == null -> { + RandomEvents.fail(this) + Timer.CANCEL + } + ticks <= 0 -> { + message("You failed to reach the centre of the maze in time.") + RandomEvents.fail(this) + Timer.CANCEL + } + else -> Timer.CONTINUE + } + } + + timerStop("maze") { + close("maze_timer") + openTabs() + clearMinimap() + clear("maze_ticks") + } + + objectOperate("Touch", "strange_shrine") { (target) -> + val ticks = get("maze_ticks", 0) + softTimers.stop("maze") + // The player finishes on the shrine's footprint, so the engine's face_entity + // auto-face resolves to the wrong direction. Clear it and face the centre tile. + clear("face_entity") + face(target.tile.add(target.width / 2, target.height / 2)) + delay(2) + anim("emote_cheer") + delay(2) + reward(this, ticks) + RandomEvents.complete(this) + jingle("maze_complete") + } + + objectOperate("Open", "maze_wall") { + message("That bit doesn't open.") + } + + objectOperate("Open", "maze_door_*_closed") { (target) -> + val blocked = oneWayWalls[target.tile.minus(instanceOffset())] + if (blocked != null && tile.minus(instanceOffset()) in blocked) { + statement("I don't think that's the right way.") + } else { + enterDoor(target) + } + } + + objectOperate("Open", "maze_chest") { (target) -> + if (get("maze_ticks", 0) <= 0 || this["maze_chests_opened", 0] >= MAX_CHESTS) { + message("You find nothing of interest.") + return@objectOperate + } + inc("maze_chests_opened") + anim("open_chest") + target.replace("maze_chest_opened", ticks = 50) + val row = Tables.get("maze_chest_rewards").rows().random(random) + val reward = row.item("item") + val amount = row.int("amount") + if (!inventory.add(reward, amount)) { + FloorItems.add(tile, reward, amount, disappearTicks = 300, owner = this) + } + item(reward, row.string("text")) + } + + objectOperate("Search", "maze_chest_opened") { + message("You find nothing of interest.") + } + } + + private suspend fun Player.startMaze() { + smallInstance(Region(MAZE_REGION), levels = 1) + setInstanceLogout(Tile(this["random_event_origin", tile.id])) + val start = Tables.get("maze_start_points").rows().random(random).tile("tile") + kidnap(start.add(instanceOffset())) + closeTabs() + minimap(Minimap.HideMap) + set("maze_ticks", duration) + set("maze_timer_bar", duration / 3) + open("maze_timer") + softTimers.start("maze") + message("You need to reach the maze centre, then you'll be returned to where you were.") + } + + private fun reward(player: Player, ticks: Int) { + val row = Tables.get("maze_rewards").rows().random(random) + val totalLevel = Skill.all.sumOf { if (it == Skill.Constitution) player.levels.getMax(it) / 10 else player.levels.getMax(it) } + val amount = (totalLevel * (ticks / duration.toDouble()) * 3.33 / row.int("divisor")).toInt() + if (amount <= 0) { + return + } + val item = row.item("item") + if (!player.inventory.add(item, amount)) { + FloorItems.add(player.tile, item, amount, disappearTicks = 300, owner = player) + } + } + + companion object { + private const val MAZE_REGION = 11591 + private const val MAX_CHESTS = 10 + } +} diff --git a/game/src/test/kotlin/content/activity/event/random/MazeTest.kt b/game/src/test/kotlin/content/activity/event/random/MazeTest.kt new file mode 100644 index 0000000000..b70f7caa5b --- /dev/null +++ b/game/src/test/kotlin/content/activity/event/random/MazeTest.kt @@ -0,0 +1,134 @@ +package content.activity.event.random + +import WorldTest +import containsMessage +import content.quest.instance +import objectOption +import org.junit.jupiter.api.Test +import world.gregs.voidps.engine.entity.Despawn +import world.gregs.voidps.engine.entity.Spawn +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.entity.character.player.Players +import world.gregs.voidps.engine.entity.obj.GameObjects +import world.gregs.voidps.engine.entity.obj.ObjectShape +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 + +class MazeTest : WorldTest() { + + @Test + fun `Touching the shrine rewards and returns the player`() { + val player = createPlayer(Tile(3221, 3218), "maze_complete") + RandomEvents.start(player, "maze") + tick(10) + + assertNotNull(player.instance()) + assertTrue(player.interfaces.contains("maze_timer")) + + val shrine = createObject("strange_shrine", player.tile.addY(1)) + player.objectOption(shrine, "Touch") + tick(10) + + assertNull(player.instance()) + assertEquals(Tile(3221, 3218), player.tile) + assertNull(player.get("random_event")) + assertTrue(player.contains("random_event_cooldown")) + assertTrue(player.inventory.count("coins") > 0) + } + + @Test + fun `Running out of time exiles the player without reward`() { + val player = createPlayer(Tile(3221, 3218), "maze_timeout") + RandomEvents.start(player, "maze") + tick(10) + assertNotNull(player.instance()) + + tick(305) + + assertNull(player.instance()) + assertNull(player.get("random_event")) + assertEquals(0, player.inventory.count("coins")) + assertTrue(player.tile.x in 3221..3223 && player.tile.y in 3217..3219, "Expected exile to Lumbridge, was at ${player.tile}") + } + + @Test + fun `Logging out mid-maze restarts the event on login`() { + val player = createPlayer(Tile(3221, 3218), "maze_relog") + RandomEvents.start(player, "maze") + tick(10) + assertNotNull(player.instance()) + val origin = Tile(player["random_event_origin", 0]) + + logout(player) + assertNull(player.instance()) + login(player) + tick(10) + + assertEquals("maze", player.get("random_event")) + assertNotNull(player.instance()) + assertEquals(origin, Tile(player["random_event_origin", 0])) + + val shrine = createObject("strange_shrine", player.tile.addY(1)) + player.objectOption(shrine, "Touch") + tick(10) + + assertEquals(Tile(3221, 3218), player.tile) + } + + @Test + fun `Maze walls swing open and close behind the player`() { + val player = createPlayer(Tile(3221, 3218), "maze_door") + val door = createObject("maze_door_1_closed", Tile(3221, 3219), shape = ObjectShape.WALL_STRAIGHT) + + player.objectOption(door, "Open") + tickIf { GameObjects.findOrNull(Tile(3221, 3219), "maze_door_1_closed") != null } + + assertNull(GameObjects.findOrNull(Tile(3221, 3219), "maze_door_1_closed")) + tickIf { GameObjects.findOrNull(Tile(3221, 3219), "maze_door_1_closed") == null } + assertNotNull(GameObjects.findOrNull(Tile(3221, 3219), "maze_door_1_closed")) + } + + @Test + fun `Chests give a small reward once opened`() { + val player = createPlayer(Tile(3221, 3218), "maze_chest") + player["maze_ticks"] = 100 + val chest = createObject("maze_chest", Tile(3221, 3219)) + + player.objectOption(chest, "Open") + tick(3) + + assertEquals(15, player.inventory.count("air_rune")) + assertEquals(1, player["maze_chests_opened", 0]) + assertNull(GameObjects.findOrNull(Tile(3221, 3219), "maze_chest")) + assertNotNull(GameObjects.findOrNull(Tile(3221, 3219), "maze_chest_opened")) + } + + @Test + fun `Chests give nothing once the cap is reached`() { + val player = createPlayer(Tile(3221, 3218), "maze_chest_cap") + player["maze_ticks"] = 100 + player["maze_chests_opened"] = 10 + val chest = createObject("maze_chest", Tile(3221, 3219)) + + player.objectOption(chest, "Open") + tick(3) + + assertEquals(0, player.inventory.count("air_rune")) + assertTrue(player.containsMessage("You find nothing of interest.")) + assertNotNull(GameObjects.findOrNull(Tile(3221, 3219), "maze_chest")) + } + + private fun login(player: Player) { + Players.add(player) + Spawn.player(player) + } + + private fun logout(player: Player) { + Despawn.player(player) + Players.remove(player) + } +} diff --git a/game/src/test/kotlin/content/activity/event/random/RandomEventTriggerTest.kt b/game/src/test/kotlin/content/activity/event/random/RandomEventTriggerTest.kt new file mode 100644 index 0000000000..913a3bd8b1 --- /dev/null +++ b/game/src/test/kotlin/content/activity/event/random/RandomEventTriggerTest.kt @@ -0,0 +1,76 @@ +package content.activity.event.random + +import WorldTest +import content.bot.Bot +import content.quest.instance +import io.mockk.mockk +import org.junit.jupiter.api.Test +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.type.Tile +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class RandomEventTriggerTest : WorldTest() { + + @Test + fun `Experience drop starts a random event once cooldown expires`() { + val player = createPlayer(Tile(3221, 3218), "re_trigger") + player["random_event_cooldown"] = 1 + + player.exp(Skill.Woodcutting, 100.0) + tick(10) + + assertEquals("maze", player.get("random_event")) + assertNotNull(player.instance()) + assertTrue(player.contains("random_event_cooldown")) + } + + @Test + fun `First experience drop arms the cooldown without an event`() { + val player = createPlayer(Tile(3221, 3218), "re_cooldown") + + player.exp(Skill.Woodcutting, 100.0) + tick(10) + + assertNull(player.get("random_event")) + assertTrue(player.contains("random_event_cooldown")) + } + + @Test + fun `Bots don't receive random events`() { + val player = createPlayer(Tile(3221, 3218), "re_bot") { it["bot"] = mockk(relaxed = true) } + player["random_event_cooldown"] = 1 + + player.exp(Skill.Woodcutting, 100.0) + tick(10) + + assertNull(player.get("random_event")) + } + + @Test + fun `No random event during another random event`() { + val player = createPlayer(Tile(3221, 3218), "re_active") + player["random_event"] = "maze" + player["random_event_cooldown"] = 1 + + player.exp(Skill.Woodcutting, 100.0) + tick() + + assertFalse(player.queue.contains("random_event_kidnap")) + } + + @Test + fun `No random event in areas tagged no_random_events`() { + val player = createPlayer(Tile(3098, 3107), "re_tutorial") + player["random_event_cooldown"] = 1 + + player.exp(Skill.Woodcutting, 100.0) + tick(10) + + assertNull(player.get("random_event")) + } +} From 18143202ab276f7877b2e6aa166d884ad82c868e Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Tue, 7 Jul 2026 17:51:36 -0700 Subject: [PATCH 2/3] Add in-place random event framework Extend the random event system beyond the kidnap-to-instance pattern so in-place nagging events (Certer, Sandwich Lady, Evil Twin) can be built: - RandomEvents.noteAndTeleport: ignore penalty that notes the player's inventory then exiles them, so an event can't be macroed through. - InPlaceRandomEvent + startInPlaceEvent/endInPlaceEvent: spawn an event NPC beside the player that follows and nags on a timer, applying the ignore penalty when its lifetime runs out. - Decouple entry from the old man: the trigger now calls RandomEvents.start directly and each launcher owns its intro. Kidnap events call the new mysteriousOldMan() helper; in-place events spawn their own NPC. Maze's launcher allocates its instance before the intro so a resumed timer doesn't fail the event mid-intro. - rewardCostumeOrCoins: shared collect-the-set reward (camo, lederhosen). Ref GregHib/void-private#223 --- .../event/random/InPlaceRandomEvent.kt | 91 +++++++++++++++++ .../event/random/RandomEventKidnap.kt | 22 ++--- .../event/random/RandomEventRewards.kt | 28 ++++++ .../event/random/RandomEventTrigger.kt | 4 +- .../activity/event/random/RandomEvents.kt | 23 +++++ .../activity/event/random/maze/Maze.kt | 4 + .../event/random/RandomEventTriggerTest.kt | 2 +- .../activity/event/random/RandomEventsTest.kt | 98 +++++++++++++++++++ 8 files changed, 258 insertions(+), 14 deletions(-) create mode 100644 game/src/main/kotlin/content/activity/event/random/InPlaceRandomEvent.kt create mode 100644 game/src/main/kotlin/content/activity/event/random/RandomEventRewards.kt create mode 100644 game/src/test/kotlin/content/activity/event/random/RandomEventsTest.kt diff --git a/game/src/main/kotlin/content/activity/event/random/InPlaceRandomEvent.kt b/game/src/main/kotlin/content/activity/event/random/InPlaceRandomEvent.kt new file mode 100644 index 0000000000..248f4e07c3 --- /dev/null +++ b/game/src/main/kotlin/content/activity/event/random/InPlaceRandomEvent.kt @@ -0,0 +1,91 @@ +package content.activity.event.random + +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.entity.character.mode.Follow +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.timer.Timer +import world.gregs.voidps.type.random + +/** + * Shared behaviour for in-place "nagging" random events (Certer, Sandwich Lady, Evil Twin...). + * The event NPC spawns next to the player, follows them, repeats nag lines, and applies the + * ignore penalty [RandomEvents.noteAndTeleport] once its lifetime runs out. Each event's own + * `npcOperate("Talk-to")` handler drives the puzzle and calls [endInPlaceEvent] when solved. + */ +class InPlaceRandomEvent : Script { + + init { + npcTimerStart(NAG_TIMER) { 1 } + npcTimerTick(NAG_TIMER) { tick(this) } + } + + private fun tick(npc: NPC): Int { + val owner = npc.owner + // Owner logged out or moved on to a different event: remove this orphaned NPC quietly. + // (The event can't be escaped by running - Follow keeps the NPC on the player - so the + // only exits are solving it or the ignore penalty below.) + if (owner == null || owner.get("random_event") != npc.get("random_event")) { + NPCs.remove(npc) + return Timer.CANCEL + } + if (!npc.watching(owner)) { + npc.watch(owner) + } + val remaining = npc.dec("random_event_life", refresh = false) + if (remaining <= 0) { + RandomEvents.noteAndTeleport(owner) + NPCs.remove(npc) + return Timer.CANCEL + } + val interval = npc.get("random_event_nag_interval", DEFAULT_INTERVAL) + if (remaining % interval == 0) { + npc.nag() + } + return Timer.CONTINUE + } + + private fun NPC.nag() { + val lines: List = get("random_event_nag_lines") ?: return + if (lines.isNotEmpty()) { + say(lines.random(random)) + } + } + + companion object { + const val NAG_TIMER = "random_event_nag" + private const val DEFAULT_INTERVAL = 25 + } +} + +/** + * Spawn an in-place random event NPC beside the player. It follows the player, nags every + * [nagInterval] ticks with a random line from [nagLines], and exiles the player via the ignore + * penalty after [lifetime] ticks. Returns the spawned NPC, or null if no walkable tile was found. + */ +fun Player.startInPlaceEvent( + id: String, + nagLines: List, + lifetime: Int = 300, + nagInterval: Int = 25, +): NPC? { + val npc = NPCs.addRandom(id, tile.toCuboid(1), owner = this) ?: NPCs.add(id, tile, ticks = -1, owner = this) + npc["random_event"] = get("random_event") ?: id + npc["random_event_life"] = lifetime + npc["random_event_nag_interval"] = nagInterval + npc["random_event_nag_lines"] = nagLines + npc.mode = Follow(npc, this) + npc.watch(this) + npc.softTimers.start(InPlaceRandomEvent.NAG_TIMER) + return npc +} + +/** + * Stop an in-place event NPC's nag timer and remove it. The event's Talk-to handler calls this + * once the puzzle is solved (or dismissed); reward + [RandomEvents.complete] remain its job. + */ +fun endInPlaceEvent(npc: NPC) { + npc.softTimers.stop(InPlaceRandomEvent.NAG_TIMER) + NPCs.remove(npc) +} diff --git a/game/src/main/kotlin/content/activity/event/random/RandomEventKidnap.kt b/game/src/main/kotlin/content/activity/event/random/RandomEventKidnap.kt index 16ccf952ff..9110b933f8 100644 --- a/game/src/main/kotlin/content/activity/event/random/RandomEventKidnap.kt +++ b/game/src/main/kotlin/content/activity/event/random/RandomEventKidnap.kt @@ -7,7 +7,6 @@ import world.gregs.voidps.engine.entity.character.move.tele import world.gregs.voidps.engine.entity.character.npc.NPCs import world.gregs.voidps.engine.entity.character.player.Player import world.gregs.voidps.engine.entity.character.player.name -import world.gregs.voidps.engine.queue.strongQueue import world.gregs.voidps.type.Tile /** @@ -57,16 +56,17 @@ class RandomEventKidnap : Script { } } -fun summonOldMan(player: Player, event: String? = null): Boolean { - val id = event ?: RandomEvents.pick() ?: return false - val npc = NPCs.addRandom("mysterious_old_man", player.tile.toCuboid(1), ticks = 25, owner = player) - ?: NPCs.add("mysterious_old_man", player.tile, ticks = 25, owner = player) - npc.watch(player) - npc.say("Ah, ${player.name}, you'll do nicely!") - player.strongQueue("random_event_kidnap", 2) { - RandomEvents.start(player, id) - } - return true +/** + * The Mysterious Old Man appears beside the player to herald a teleport ("kidnap") event. + * Kidnap-style launchers call this before [kidnap]; in-place events skip it and spawn their own + * NPC via [startInPlaceEvent]. + */ +suspend fun Player.mysteriousOldMan() { + val npc = NPCs.addRandom("mysterious_old_man", tile.toCuboid(1), ticks = 25, owner = this) + ?: NPCs.add("mysterious_old_man", tile, ticks = 25, owner = this) + npc.watch(this) + npc.say("Ah, $name, you'll do nicely!") + delay(2) } /** diff --git a/game/src/main/kotlin/content/activity/event/random/RandomEventRewards.kt b/game/src/main/kotlin/content/activity/event/random/RandomEventRewards.kt new file mode 100644 index 0000000000..39c61912ec --- /dev/null +++ b/game/src/main/kotlin/content/activity/event/random/RandomEventRewards.kt @@ -0,0 +1,28 @@ +package content.activity.event.random + +import content.entity.player.bank.ownsItem +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.entity.item.floor.FloorItems +import world.gregs.voidps.engine.inv.add +import world.gregs.voidps.engine.inv.inventory + +/** + * "Collect the set" reward shared by the costume random events (Drill Demon's camouflage set, + * Freaky Forester's lederhosen). Awards the first costume piece the player doesn't already own + * (carried or banked); if they own the whole set, awards [coins] instead. Anything that won't fit + * drops to the floor beneath them. + */ +fun Player.rewardCostumeOrCoins(vararg pieces: String, coins: Int) { + val missing = pieces.firstOrNull { !ownsItem(it) } + if (missing != null) { + giveOrDrop(missing, 1) + } else { + giveOrDrop("coins", coins) + } +} + +private fun Player.giveOrDrop(item: String, amount: Int) { + if (!inventory.add(item, amount)) { + FloorItems.add(tile, item, amount, disappearTicks = 300, owner = this) + } +} diff --git a/game/src/main/kotlin/content/activity/event/random/RandomEventTrigger.kt b/game/src/main/kotlin/content/activity/event/random/RandomEventTrigger.kt index 18c6b670d9..b538dc2dbe 100644 --- a/game/src/main/kotlin/content/activity/event/random/RandomEventTrigger.kt +++ b/game/src/main/kotlin/content/activity/event/random/RandomEventTrigger.kt @@ -34,13 +34,13 @@ class RandomEventTrigger : Script { if (eligible(this) && random.nextInt(Tables.int("random_event_settings.roll_chance.value")) == 0) { // Restart the cooldown immediately so a failed pick can't re-roll every drop RandomEvents.cooldown(this) - summonOldMan(this) + RandomEvents.start(this) } } adminCommand("randomevent", stringArg("event", optional = true), desc = "Start a random event") { args -> val event = args.getOrNull(0) ?: RandomEvents.pick() - if (event == null || !summonOldMan(this, event)) { + if (event == null || !RandomEvents.start(this, event)) { message("No random event found${if (args.isEmpty()) "" else " for '${args[0]}'"}.") } } diff --git a/game/src/main/kotlin/content/activity/event/random/RandomEvents.kt b/game/src/main/kotlin/content/activity/event/random/RandomEvents.kt index f388764294..91260c538a 100644 --- a/game/src/main/kotlin/content/activity/event/random/RandomEvents.kt +++ b/game/src/main/kotlin/content/activity/event/random/RandomEvents.kt @@ -5,9 +5,12 @@ import world.gregs.voidps.engine.Script import world.gregs.voidps.engine.client.message import world.gregs.voidps.engine.client.variable.start import world.gregs.voidps.engine.data.definition.Areas +import world.gregs.voidps.engine.data.definition.ItemDefinitions import world.gregs.voidps.engine.data.definition.Tables import world.gregs.voidps.engine.entity.character.move.tele import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.engine.inv.transact.operation.ReplaceItem.replace import world.gregs.voidps.engine.map.collision.random import world.gregs.voidps.engine.queue.strongQueue import world.gregs.voidps.engine.timer.epochSeconds @@ -78,6 +81,26 @@ object RandomEvents { player.message("You wake up feeling drowsy, unsure of where you are.") } + /** + * Ignore penalty for in-place events: note every noteable inventory item so the + * player can't macro through a full inventory, then exile them as [fail]. + */ + fun noteAndTeleport(player: Player) { + player.inventory.transaction { + for (index in player.inventory.indices) { + val item = player.inventory[index] + if (item.isEmpty()) { + continue + } + val noteId = item.def.noteId + if (noteId != -1) { + replace(index, item.id, ItemDefinitions.get(noteId).stringId) + } + } + } + fail(player) + } + private fun exit(player: Player): Tile { val origin = Tile(player["random_event_origin", player.tile.id]) player.clearInstance() diff --git a/game/src/main/kotlin/content/activity/event/random/maze/Maze.kt b/game/src/main/kotlin/content/activity/event/random/maze/Maze.kt index 6e4b06e292..d36d7f6c40 100644 --- a/game/src/main/kotlin/content/activity/event/random/maze/Maze.kt +++ b/game/src/main/kotlin/content/activity/event/random/maze/Maze.kt @@ -2,6 +2,7 @@ package content.activity.event.random.maze import content.activity.event.random.RandomEvents import content.activity.event.random.kidnap +import content.activity.event.random.mysteriousOldMan import content.entity.obj.door.enterDoor import content.entity.player.dialogue.type.item import content.entity.player.dialogue.type.statement @@ -130,8 +131,11 @@ class Maze : Script { } private suspend fun Player.startMaze() { + // Allocate the instance before the old-man intro so a resumed maze timer (on relog) sees a + // live instance during the intro delay instead of failing the event. smallInstance(Region(MAZE_REGION), levels = 1) setInstanceLogout(Tile(this["random_event_origin", tile.id])) + mysteriousOldMan() val start = Tables.get("maze_start_points").rows().random(random).tile("tile") kidnap(start.add(instanceOffset())) closeTabs() diff --git a/game/src/test/kotlin/content/activity/event/random/RandomEventTriggerTest.kt b/game/src/test/kotlin/content/activity/event/random/RandomEventTriggerTest.kt index 913a3bd8b1..ad749cc21f 100644 --- a/game/src/test/kotlin/content/activity/event/random/RandomEventTriggerTest.kt +++ b/game/src/test/kotlin/content/activity/event/random/RandomEventTriggerTest.kt @@ -60,7 +60,7 @@ class RandomEventTriggerTest : WorldTest() { player.exp(Skill.Woodcutting, 100.0) tick() - assertFalse(player.queue.contains("random_event_kidnap")) + assertFalse(player.queue.contains("random_event_start")) } @Test diff --git a/game/src/test/kotlin/content/activity/event/random/RandomEventsTest.kt b/game/src/test/kotlin/content/activity/event/random/RandomEventsTest.kt new file mode 100644 index 0000000000..38ecf93c05 --- /dev/null +++ b/game/src/test/kotlin/content/activity/event/random/RandomEventsTest.kt @@ -0,0 +1,98 @@ +package content.activity.event.random + +import WorldTest +import org.junit.jupiter.api.Test +import world.gregs.voidps.engine.entity.character.npc.NPCs +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 + +class RandomEventsTest : WorldTest() { + + @Test + fun `noteAndTeleport notes carried items, exiles the player and arms the cooldown`() { + val player = createPlayer(Tile(3221, 3218), "re_note") + player["random_event"] = "certer" + player["random_event_origin"] = Tile(3221, 3218).id + player.inventory.add("logs", 5) + player.inventory.add("coins", 100) + + RandomEvents.noteAndTeleport(player) + tick() + + // Noteable stack replaced with its banknote, coins (no note) untouched + assertEquals(5, player.inventory.count("logs_noted")) + assertEquals(0, player.inventory.count("logs")) + assertEquals(100, player.inventory.count("coins")) + // Event state cleared and player exiled somewhere away from the origin + assertNull(player.get("random_event")) + assertNull(player.get("random_event_origin")) + assertTrue(player.contains("random_event_cooldown")) + } + + @Test + fun `In-place event spawns a following NPC beside the player`() { + val player = createPlayer(Tile(3221, 3218), "re_inplace") + player["random_event"] = "certer" + + val npc = player.startInPlaceEvent("mysterious_old_man", listOf("You there!"), lifetime = 100) + tick() + + assertNotNull(npc) + assertEquals("certer", npc.get("random_event")) + assertTrue(npc.tile.within(player.tile, 2), "Event NPC should spawn beside the player") + assertNotNull(NPCs.indexed(npc.index), "Event NPC should still be alive") + } + + @Test + fun `Ignoring an in-place event past its lifetime triggers the note-and-teleport penalty`() { + val player = createPlayer(Tile(3221, 3218), "re_inplace_ignore") + player["random_event"] = "certer" + player["random_event_origin"] = Tile(3221, 3218).id + player.inventory.add("logs", 3) + + val npc = player.startInPlaceEvent("mysterious_old_man", listOf("You there!"), lifetime = 2) + tick(6) + + assertNull(player.get("random_event")) + assertTrue(player.contains("random_event_cooldown")) + assertEquals(3, player.inventory.count("logs_noted")) + assertNull(NPCs.indexed(npc!!.index), "Event NPC should be removed after the penalty") + } + + @Test + fun `Costume reward gives the first missing piece then falls back to coins`() { + val player = createPlayer(Tile(3221, 3218), "re_costume") + val set = arrayOf("lederhosen_hat", "lederhosen_top", "lederhosen_shorts") + + player.rewardCostumeOrCoins(*set, coins = 500) + assertEquals(1, player.inventory.count("lederhosen_hat")) + + player.rewardCostumeOrCoins(*set, coins = 500) + assertEquals(1, player.inventory.count("lederhosen_top")) + + player.rewardCostumeOrCoins(*set, coins = 500) + assertEquals(1, player.inventory.count("lederhosen_shorts")) + + // Owns the full set now -> coins + player.rewardCostumeOrCoins(*set, coins = 500) + assertEquals(500, player.inventory.count("coins")) + } + + @Test + fun `endInPlaceEvent removes the event NPC`() { + val player = createPlayer(Tile(3221, 3218), "re_inplace_end") + player["random_event"] = "certer" + + val npc = player.startInPlaceEvent("mysterious_old_man", listOf("You there!"))!! + tick() + endInPlaceEvent(npc) + tick() + + assertNull(NPCs.indexed(npc.index)) + } +} From db2b25e2667f8183b7cd81dfc1091aa38857b435 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Tue, 7 Jul 2026 18:20:33 -0700 Subject: [PATCH 3/3] Add Freaky Forester random event The player is teleported to the forester's clearing and told to kill a pheasant with a specific number of tails. Only the assigned pheasant drops the correct raw pheasant; wrong ones drop an incorrect one that the forester rejects. Handing in the correct pheasant rewards the first missing lederhosen piece, or 500 coins if the set is complete. - Conditional pheasant drops keyed to the killer's assigned task - Blocks attacking further pheasants once one is killed - Static pheasant spawns in the clearing; forester dialogue drives the task - Removes the unconditional pheasant drop table (drops are now per-task) Ref GregHib/void-private#255 --- .../freaky_forester.npc-spawns.toml | 10 ++ .../freaky_forester/freaky_forester.vars.toml | 3 + .../event/random/random_event.drops.toml | 5 - .../event/random/random_event.npcs.toml | 1 - .../event/random/random_event.tables.toml | 4 + .../random/freaky_forester/FreakyForester.kt | 119 +++++++++++++++++ .../event/random/FreakyForesterTest.kt | 120 ++++++++++++++++++ 7 files changed, 256 insertions(+), 6 deletions(-) create mode 100644 data/activity/event/random/freaky_forester/freaky_forester.npc-spawns.toml create mode 100644 data/activity/event/random/freaky_forester/freaky_forester.vars.toml delete mode 100644 data/activity/event/random/random_event.drops.toml create mode 100644 game/src/main/kotlin/content/activity/event/random/freaky_forester/FreakyForester.kt create mode 100644 game/src/test/kotlin/content/activity/event/random/FreakyForesterTest.kt diff --git a/data/activity/event/random/freaky_forester/freaky_forester.npc-spawns.toml b/data/activity/event/random/freaky_forester/freaky_forester.npc-spawns.toml new file mode 100644 index 0000000000..ac6c0a0ec2 --- /dev/null +++ b/data/activity/event/random/freaky_forester/freaky_forester.npc-spawns.toml @@ -0,0 +1,10 @@ +spawns = [ + { id = "pheasant_1_tail", x = 2596, y = 4772, direction = "SOUTH" }, + { id = "pheasant_1_tail_2", x = 2605, y = 4773, direction = "SOUTH" }, + { id = "pheasant_2_tails", x = 2598, y = 4780, direction = "SOUTH" }, + { id = "pheasant_2_tails_2", x = 2607, y = 4779, direction = "SOUTH" }, + { id = "pheasant_3_tails", x = 2595, y = 4777, direction = "SOUTH" }, + { id = "pheasant_3_tails_2", x = 2609, y = 4776, direction = "SOUTH" }, + { id = "pheasant_4_tails", x = 2601, y = 4783, direction = "SOUTH" }, + { id = "pheasant_4_tails_2", x = 2604, y = 4769, direction = "SOUTH" }, +] diff --git a/data/activity/event/random/freaky_forester/freaky_forester.vars.toml b/data/activity/event/random/freaky_forester/freaky_forester.vars.toml new file mode 100644 index 0000000000..10712e54cc --- /dev/null +++ b/data/activity/event/random/freaky_forester/freaky_forester.vars.toml @@ -0,0 +1,3 @@ +[freaky_forester_task] +format = "int" +persist = true diff --git a/data/activity/event/random/random_event.drops.toml b/data/activity/event/random/random_event.drops.toml deleted file mode 100644 index 710d8e793c..0000000000 --- a/data/activity/event/random/random_event.drops.toml +++ /dev/null @@ -1,5 +0,0 @@ -[pheasant_drop_table] -type = "all" -drops = [ - { id = "raw_pheasant" }, -] diff --git a/data/activity/event/random/random_event.npcs.toml b/data/activity/event/random/random_event.npcs.toml index 4a3dbffa40..63f7859bfe 100644 --- a/data/activity/event/random/random_event.npcs.toml +++ b/data/activity/event/random/random_event.npcs.toml @@ -16,7 +16,6 @@ def = 3 style = "stab" max_hit_melee = 10 respawn_delay = 5 -drop_table = "pheasant" examine = "A brightly coloured game bird." [pheasant_1_tail_2] diff --git a/data/activity/event/random/random_event.tables.toml b/data/activity/event/random/random_event.tables.toml index 96aa8d8c42..efa710b6d8 100644 --- a/data/activity/event/random/random_event.tables.toml +++ b/data/activity/event/random/random_event.tables.toml @@ -6,6 +6,10 @@ weight = "int" event = "maze" weight = 10 +[.freaky_forester] +event = "freaky_forester" +weight = 10 + [random_event_settings] value = "int" diff --git a/game/src/main/kotlin/content/activity/event/random/freaky_forester/FreakyForester.kt b/game/src/main/kotlin/content/activity/event/random/freaky_forester/FreakyForester.kt new file mode 100644 index 0000000000..59c47c0eea --- /dev/null +++ b/game/src/main/kotlin/content/activity/event/random/freaky_forester/FreakyForester.kt @@ -0,0 +1,119 @@ +package content.activity.event.random.freaky_forester + +import content.activity.event.random.RandomEvents +import content.activity.event.random.kidnap +import content.activity.event.random.rewardCostumeOrCoins +import content.entity.combat.killer +import content.entity.player.bank.ownsItem +import content.entity.player.dialogue.Happy +import content.entity.player.dialogue.Neutral +import content.entity.player.dialogue.type.npc +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.client.message +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.entity.character.player.name +import world.gregs.voidps.engine.entity.item.floor.FloorItems +import world.gregs.voidps.engine.inv.add +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.engine.inv.remove +import world.gregs.voidps.type.Tile +import world.gregs.voidps.type.random + +/** + * Freaky Forester random event: the player is whisked to the forester's clearing and told to kill + * a pheasant with a specific number of tails. Only the assigned pheasant yields the correct raw + * pheasant; handing it back rewards a lederhosen costume piece (or coins if the set is complete). + * https://runescape.wiki/w/Random_events?oldid=3667851#Freaky_Forester + */ +class FreakyForester : Script { + + init { + RandomEvents.register("freaky_forester") { startEvent() } + + npcOperate("Talk-to", "freaky_forester") { + if (get("random_event") != "freaky_forester") { + message("The forester is too busy to talk to you.") + return@npcOperate + } + foresterDialogue() + } + + // One pheasant per trip: don't let the player farm the clearing. + canAttack("pheasant_*") { + if (carriesRawPheasant()) { + message("You don't need to attack any more pheasants.") + false + } else { + true + } + } + + npcDeath("pheasant_*") { death -> + val killer = killer as? Player ?: return@npcDeath + if (killer.get("random_event") != "freaky_forester") { + return@npcDeath + } + death.dropItems = false + val correct = tailCount(id) == killer.get("freaky_forester_task", 0) + killer.giveRawPheasant(if (correct) "raw_pheasant" else "raw_pheasant_incorrect") + } + } + + private suspend fun Player.startEvent() { + set("freaky_forester_task", random.nextInt(1, TAILS + 1)) + kidnap(CLEARING) + giveTask() + } + + private suspend fun Player.foresterDialogue() { + when { + inventory.contains("raw_pheasant") -> { + inventory.remove("raw_pheasant") + npc("freaky_forester", "Thanks, $name, you may leave the area now.") + reward() + clear("freaky_forester_task") + RandomEvents.complete(this) + } + inventory.contains("raw_pheasant_incorrect") -> { + inventory.remove("raw_pheasant_incorrect") + npc("freaky_forester", "That's not the right one.") + } + else -> giveTask() + } + } + + private suspend fun Player.giveTask() { + val tails = get("freaky_forester_task", 1) + npc( + "freaky_forester", + "Hey there $name. Can you kill the ${TAIL_WORDS[tails]} tailed pheasant please. " + + "Bring me the raw pheasant when you're done.", + ) + } + + private suspend fun Player.reward() { + val pieces = arrayOf("lederhosen_hat", "lederhosen_top", "lederhosen_shorts") + if (pieces.all { ownsItem(it) }) { + npc("freaky_forester", "You get some money for your help, many thanks!") + } else { + npc("freaky_forester", "You get a lederhosen item as a reward for your help, many thanks!") + } + rewardCostumeOrCoins(*pieces, coins = 500) + } + + private fun Player.carriesRawPheasant() = inventory.contains("raw_pheasant") || inventory.contains("raw_pheasant_incorrect") + + private fun Player.giveRawPheasant(item: String) { + if (!inventory.add(item)) { + FloorItems.add(tile, item, 1, disappearTicks = 300, owner = this) + } + } + + private fun tailCount(pheasantId: String) = pheasantId.removePrefix("pheasant_").substringBefore("_").toIntOrNull() ?: 0 + + companion object { + private const val TAILS = 4 + private val TAIL_WORDS = arrayOf("", "one", "two", "three", "four") + private val CLEARING = Tile(2601, 4777) + } +} diff --git a/game/src/test/kotlin/content/activity/event/random/FreakyForesterTest.kt b/game/src/test/kotlin/content/activity/event/random/FreakyForesterTest.kt new file mode 100644 index 0000000000..2270425af3 --- /dev/null +++ b/game/src/test/kotlin/content/activity/event/random/FreakyForesterTest.kt @@ -0,0 +1,120 @@ +package content.activity.event.random + +import WorldTest +import content.entity.combat.hit.damage +import npcOption +import org.junit.jupiter.api.Test +import skipDialogues +import world.gregs.voidps.engine.entity.character.mode.combat.CombatApi +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.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class FreakyForesterTest : WorldTest() { + + private val clearing = Tile(2601, 4777) + + private fun startInClearing(name: String, task: Int, origin: Tile = Tile(3221, 3218)) = createPlayer(clearing, name).apply { + this["random_event"] = "freaky_forester" + this["random_event_origin"] = origin.id + this["freaky_forester_task"] = task + } + + @Test + fun `Event teleports the player to the clearing and assigns a pheasant`() { + val player = createPlayer(Tile(3221, 3218), "ff_start") + RandomEvents.start(player, "freaky_forester") + tick(10) + + assertEquals("freaky_forester", player.get("random_event")) + assertTrue(player.tile.within(clearing, 4), "Expected the player in the clearing, was ${player.tile}") + assertTrue(player.get("freaky_forester_task", 0) in 1..4) + } + + @Test + fun `Killing the assigned pheasant yields the correct raw pheasant`() { + val player = startInClearing("ff_correct_kill", task = 2) + val pheasant = createNPC("pheasant_2_tails", Tile(2603, 4777)) + + pheasant.damage(1000, source = player) + tick(4) + + assertTrue(player.inventory.contains("raw_pheasant")) + assertFalse(player.inventory.contains("raw_pheasant_incorrect")) + } + + @Test + fun `Killing the wrong pheasant yields an incorrect raw pheasant`() { + val player = startInClearing("ff_wrong_kill", task = 2) + val pheasant = createNPC("pheasant_1_tail", Tile(2603, 4777)) + + pheasant.damage(1000, source = player) + tick(4) + + assertTrue(player.inventory.contains("raw_pheasant_incorrect")) + assertFalse(player.inventory.contains("raw_pheasant")) + } + + @Test + fun `Handing in the correct pheasant rewards a costume piece and returns the player`() { + val origin = Tile(3221, 3218) + val player = startInClearing("ff_handin", task = 3, origin = origin) + player.inventory.add("raw_pheasant") + val forester = createNPC("freaky_forester", Tile(2601, 4776)) + + player.npcOption(forester, "Talk-to") + tick() + player.skipDialogues() + tick(2) + + assertEquals(1, player.inventory.count("lederhosen_hat")) + assertFalse(player.inventory.contains("raw_pheasant")) + assertNull(player.get("random_event")) + assertEquals(origin, player.tile) + } + + @Test + fun `Handing in the wrong pheasant is rejected and keeps the player in the event`() { + val player = startInClearing("ff_reject", task = 3) + player.inventory.add("raw_pheasant_incorrect") + val forester = createNPC("freaky_forester", Tile(2601, 4776)) + + player.npcOption(forester, "Talk-to") + tick() + player.skipDialogues() + + assertFalse(player.inventory.contains("raw_pheasant_incorrect")) + assertEquals("freaky_forester", player.get("random_event")) + assertEquals(clearing, player.tile) + } + + @Test + fun `Owning the full lederhosen set rewards coins instead`() { + val player = startInClearing("ff_coins", task = 1) + player.inventory.add("lederhosen_hat") + player.inventory.add("lederhosen_top") + player.inventory.add("lederhosen_shorts") + player.inventory.add("raw_pheasant") + val forester = createNPC("freaky_forester", Tile(2601, 4776)) + + player.npcOption(forester, "Talk-to") + tick() + player.skipDialogues() + tick(2) + + assertEquals(500, player.inventory.count("coins")) + } + + @Test + fun `Cannot attack another pheasant while carrying a raw pheasant`() { + val player = startInClearing("ff_block", task = 2) + player.inventory.add("raw_pheasant") + val pheasant = createNPC("pheasant_4_tails", Tile(2603, 4777)) + + assertFalse(CombatApi.canAttack(player, pheasant)) + } +}