From 76c8c790bc9d04d3530479115aedd684aaf08598 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Tue, 7 Jul 2026 17:21:43 -0700 Subject: [PATCH 01/89] 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 02/89] 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 03/89] 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)) + } +} From 676a16b9c9592f96ce72a22a00f8200debcc7a58 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Tue, 7 Jul 2026 18:28:37 -0700 Subject: [PATCH 04/89] Drop the raw pheasant to the floor instead of the inventory The assigned pheasant now drops the raw pheasant as a normal killer-owned floor drop (non-tradeable, private) for the player to pick up and hand to the forester, matching the live behaviour. --- .../random/freaky_forester/FreakyForester.kt | 11 +++-------- .../event/random/FreakyForesterTest.kt | 19 +++++++++++++------ 2 files changed, 16 insertions(+), 14 deletions(-) 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 index 59c47c0eea..6c6534c4d8 100644 --- 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 @@ -13,7 +13,6 @@ 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 @@ -54,8 +53,10 @@ class FreakyForester : Script { return@npcDeath } death.dropItems = false + // The pheasant drops the raw bird for the killer to pick up and hand to the forester. val correct = tailCount(id) == killer.get("freaky_forester_task", 0) - killer.giveRawPheasant(if (correct) "raw_pheasant" else "raw_pheasant_incorrect") + val bird = if (correct) "raw_pheasant" else "raw_pheasant_incorrect" + FloorItems.add(tile, bird, revealTicks = FloorItems.NEVER, disappearTicks = 300, owner = killer) } } @@ -103,12 +104,6 @@ class FreakyForester : Script { 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 { diff --git a/game/src/test/kotlin/content/activity/event/random/FreakyForesterTest.kt b/game/src/test/kotlin/content/activity/event/random/FreakyForesterTest.kt index 2270425af3..7a5bd759c4 100644 --- a/game/src/test/kotlin/content/activity/event/random/FreakyForesterTest.kt +++ b/game/src/test/kotlin/content/activity/event/random/FreakyForesterTest.kt @@ -6,11 +6,14 @@ 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.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.type.Tile import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue @@ -36,27 +39,31 @@ class FreakyForesterTest : WorldTest() { } @Test - fun `Killing the assigned pheasant yields the correct raw pheasant`() { + fun `Killing the assigned pheasant drops the correct raw pheasant for the killer`() { val player = startInClearing("ff_correct_kill", task = 2) val pheasant = createNPC("pheasant_2_tails", Tile(2603, 4777)) + val dropTile = pheasant.tile pheasant.damage(1000, source = player) tick(4) - assertTrue(player.inventory.contains("raw_pheasant")) - assertFalse(player.inventory.contains("raw_pheasant_incorrect")) + val drop = FloorItems.firstOrNull(dropTile, "raw_pheasant") + assertNotNull(drop) + assertEquals(player.name, drop.owner) + assertNull(FloorItems.firstOrNull(dropTile, "raw_pheasant_incorrect")) } @Test - fun `Killing the wrong pheasant yields an incorrect raw pheasant`() { + fun `Killing the wrong pheasant drops an incorrect raw pheasant`() { val player = startInClearing("ff_wrong_kill", task = 2) val pheasant = createNPC("pheasant_1_tail", Tile(2603, 4777)) + val dropTile = pheasant.tile pheasant.damage(1000, source = player) tick(4) - assertTrue(player.inventory.contains("raw_pheasant_incorrect")) - assertFalse(player.inventory.contains("raw_pheasant")) + assertNotNull(FloorItems.firstOrNull(dropTile, "raw_pheasant_incorrect")) + assertNull(FloorItems.firstOrNull(dropTile, "raw_pheasant")) } @Test From ab4a8af5503c4f9484634bc5fd412e12e9fca64c Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Tue, 7 Jul 2026 18:51:40 -0700 Subject: [PATCH 05/89] Add Sandwich Lady random event First in-place event on the framework: the Sandwich Lady appears beside the player pushing her tray and nags until spoken to. Talking opens her refreshment tray (interface 297); choosing the food she offered hands it over and ends the event in place, while any other choice knocks the player out via the note-and-teleport ignore penalty. Talking mid-combat hands over the correct food directly, and other players are turned away. Adds RandomEvents.completeInPlace for events that finish without a return teleport, and defines interface 297's seven food buttons (10-22). Ref GregHib/void-private#261 --- .../event/random/random_event.tables.toml | 4 + .../sandwich_lady/sandwich_lady.ifaces.toml | 28 ++++++ .../sandwich_lady/sandwich_lady.vars.toml | 2 + .../activity/event/random/RandomEvents.kt | 8 ++ .../random/sandwich_lady/SandwichLady.kt | 94 +++++++++++++++++++ .../activity/event/random/SandwichLadyTest.kt | 92 ++++++++++++++++++ 6 files changed, 228 insertions(+) create mode 100644 data/activity/event/random/sandwich_lady/sandwich_lady.ifaces.toml create mode 100644 data/activity/event/random/sandwich_lady/sandwich_lady.vars.toml create mode 100644 game/src/main/kotlin/content/activity/event/random/sandwich_lady/SandwichLady.kt create mode 100644 game/src/test/kotlin/content/activity/event/random/SandwichLadyTest.kt diff --git a/data/activity/event/random/random_event.tables.toml b/data/activity/event/random/random_event.tables.toml index efa710b6d8..e52f42eeca 100644 --- a/data/activity/event/random/random_event.tables.toml +++ b/data/activity/event/random/random_event.tables.toml @@ -10,6 +10,10 @@ weight = 10 event = "freaky_forester" weight = 10 +[.sandwich_lady] +event = "sandwich_lady" +weight = 10 + [random_event_settings] value = "int" diff --git a/data/activity/event/random/sandwich_lady/sandwich_lady.ifaces.toml b/data/activity/event/random/sandwich_lady/sandwich_lady.ifaces.toml new file mode 100644 index 0000000000..1490cdd00c --- /dev/null +++ b/data/activity/event/random/sandwich_lady/sandwich_lady.ifaces.toml @@ -0,0 +1,28 @@ +# The Sandwich Lady's refreshment tray. The seven "Choose refreshment" buttons +# (components 10-22) are named after the food each one hands out. +[sandwich_lady_select] +id = 297 + +[.baguette] +id = 10 + +[.triangle_sandwich] +id = 12 + +[.square_sandwich] +id = 14 + +[.roll] +id = 16 + +[.meat_pie] +id = 18 + +[.doughnut] +id = 20 + +[.chocolate_bar] +id = 22 + +[.title] +id = 48 diff --git a/data/activity/event/random/sandwich_lady/sandwich_lady.vars.toml b/data/activity/event/random/sandwich_lady/sandwich_lady.vars.toml new file mode 100644 index 0000000000..a02482b437 --- /dev/null +++ b/data/activity/event/random/sandwich_lady/sandwich_lady.vars.toml @@ -0,0 +1,2 @@ +[sandwich_lady_food] +format = "string" 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 91260c538a..404b90bfed 100644 --- a/game/src/main/kotlin/content/activity/event/random/RandomEvents.kt +++ b/game/src/main/kotlin/content/activity/event/random/RandomEvents.kt @@ -71,6 +71,14 @@ object RandomEvents { player.tele(exit(player)) } + /** + * Success for an in-place event (Sandwich Lady, Certer...); the player never left, so just + * clear the event state and arm the cooldown without teleporting them anywhere. + */ + fun completeInPlace(player: Player) { + exit(player) + } + /** * Failure or abandonment; teleport the player to a random safe location with no reward. */ diff --git a/game/src/main/kotlin/content/activity/event/random/sandwich_lady/SandwichLady.kt b/game/src/main/kotlin/content/activity/event/random/sandwich_lady/SandwichLady.kt new file mode 100644 index 0000000000..3f18032e1b --- /dev/null +++ b/game/src/main/kotlin/content/activity/event/random/sandwich_lady/SandwichLady.kt @@ -0,0 +1,94 @@ +package content.activity.event.random.sandwich_lady + +import content.activity.event.random.RandomEvents +import content.activity.event.random.startInPlaceEvent +import content.entity.combat.inCombat +import content.entity.player.dialogue.Happy +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.client.ui.close +import world.gregs.voidps.engine.client.ui.open +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.type.random + +/** + * Sandwich Lady random event: she appears beside the player pushing a refreshment tray and nags + * until spoken to. Talking opens her tray (interface 297); picking the food she offered hands it + * over, picking any other knocks the player out (notes their items and teleports them away). + * https://runescape.wiki/w/Random_events?oldid=3667851#Sandwich_Lady + */ +class SandwichLady : Script { + + init { + RandomEvents.register("sandwich_lady") { startEvent() } + + npcOperate("Talk-to", "sandwich_lady") { (lady) -> + if (lady.owner != this) { + npc("sandwich_lady", "This is for ${lady.owner?.name ?: "someone else"}, not you!") + return@npcOperate + } + val food = get("sandwich_lady_food") ?: return@npcOperate + if (inCombat) { + // No time for the tray mid-fight; she just hands over the right one. + serve(food) + return@npcOperate + } + npc("sandwich_lady", "You look hungry to me. I tell you what - have a ${description(food)} on me.") + interfaces.sendText("sandwich_lady_select", "title", "Have a ${description(food)} for free!") + open("sandwich_lady_select") + } + + interfaceOption("Choose refreshment", "sandwich_lady_select:*") { + val food = get("sandwich_lady_food") ?: return@interfaceOption + close("sandwich_lady_select") + if (it.component == food) { + serve(food) + } else { + message("The sandwich lady knocks you out and you wake up somewhere... different.") + RandomEvents.noteAndTeleport(this) + } + } + } + + private fun Player.startEvent() { + val food = FOODS.random(random) + set("sandwich_lady_food", food) + val lady = startInPlaceEvent("sandwich_lady", nagLines(), nagInterval = 30) ?: return + lady.say("Sandwich delivery for $name!") + } + + private fun Player.serve(food: String) { + message("The sandwich lady gives you a ${description(food)}!") + if (!inventory.add(food)) { + FloorItems.add(tile, food, 1, disappearTicks = 300, owner = this) + } + // Clearing the event state removes the following NPC on its next tick. + RandomEvents.completeInPlace(this) + } + + private fun Player.nagLines() = listOf( + "All types of sandwiches, $name.", + "Come on $name, I made these specifically!", + "You better start showing some manners!", + "You think I made these just for fun?!!?", + ) + + private fun description(food: String) = food.replace('_', ' ') + + companion object { + private val FOODS = listOf( + "baguette", + "triangle_sandwich", + "square_sandwich", + "roll", + "meat_pie", + "doughnut", + "chocolate_bar", + ) + } +} diff --git a/game/src/test/kotlin/content/activity/event/random/SandwichLadyTest.kt b/game/src/test/kotlin/content/activity/event/random/SandwichLadyTest.kt new file mode 100644 index 0000000000..ef06f52af6 --- /dev/null +++ b/game/src/test/kotlin/content/activity/event/random/SandwichLadyTest.kt @@ -0,0 +1,92 @@ +package content.activity.event.random + +import WorldTest +import interfaceOption +import npcOption +import org.junit.jupiter.api.Test +import skipDialogues +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.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 SandwichLadyTest : WorldTest() { + + private val spot = Tile(3221, 3218) + + private fun setup(name: String, food: String): Pair { + val player = createPlayer(spot, name) + player["random_event"] = "sandwich_lady" + player["random_event_origin"] = spot.id + player["sandwich_lady_food"] = food + val lady = createNPC("sandwich_lady", spot.addX(1)) + lady["owner"] = player.accountName + return player to lady + } + + private fun openTray(player: Player, lady: NPC) { + player.npcOption(lady, "Talk-to") + tick() + player.skipDialogues() // "You look hungry..." -> opens the tray + tick() + } + + @Test + fun `Event spawns the sandwich lady beside the player with a chosen food`() { + val player = createPlayer(spot, "sl_spawn") + RandomEvents.start(player, "sandwich_lady") + tick(2) + + assertTrue(player.get("sandwich_lady_food") != null) + val lady = (-2..2).flatMap { dx -> (-2..2).map { dy -> player.tile.add(dx, dy) } } + .firstNotNullOfOrNull { t -> NPCs.firstOrNull(t) { it.id == "sandwich_lady" } } + assertTrue(lady != null, "Expected a sandwich lady spawned near the player") + } + + @Test + fun `Choosing the offered food hands it over and ends the event in place`() { + val (player, lady) = setup("sl_correct", "meat_pie") + openTray(player, lady) + + assertTrue(player.interfaces.contains("sandwich_lady_select")) + player.interfaceOption("sandwich_lady_select", "meat_pie", "Choose refreshment") + tick() + + assertEquals(1, player.inventory.count("meat_pie")) + assertEquals(spot, player.tile) // in-place: not teleported anywhere + assertNull(player.get("random_event")) + assertTrue(player.contains("random_event_cooldown")) + } + + @Test + fun `Choosing the wrong food knocks the player out with the ignore penalty`() { + val (player, lady) = setup("sl_wrong", "meat_pie") + player.inventory.add("logs", 4) + openTray(player, lady) + + player.interfaceOption("sandwich_lady_select", "baguette", "Choose refreshment") + tick() + + assertEquals(0, player.inventory.count("baguette")) + assertEquals(4, player.inventory.count("logs_noted")) + assertNull(player.get("random_event")) + assertTrue(player.contains("random_event_cooldown")) + } + + @Test + fun `Another player can't take the sandwich`() { + val (owner, lady) = setup("sl_owner", "roll") + val intruder = createPlayer(spot.addY(1), "sl_intruder") + + intruder.npcOption(lady, "Talk-to") + tick() + + assertFalse(intruder.interfaces.contains("sandwich_lady_select")) + } +} From e1361f6ba4eed95ca2e9e4616245652ca97f9001 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Tue, 7 Jul 2026 19:11:27 -0700 Subject: [PATCH 06/89] Sandwich Lady reacts to the tray choice She now says "Hope that fills you up!" when handing over the correct food, and smacks the player for 3 before the knockout when they pick the wrong one, matching live behaviour. --- .../random/sandwich_lady/sandwich_lady.vars.toml | 3 +++ .../event/random/sandwich_lady/SandwichLady.kt | 16 +++++++++++++--- .../activity/event/random/SandwichLadyTest.kt | 6 +++++- 3 files changed, 21 insertions(+), 4 deletions(-) diff --git a/data/activity/event/random/sandwich_lady/sandwich_lady.vars.toml b/data/activity/event/random/sandwich_lady/sandwich_lady.vars.toml index a02482b437..749a6950d8 100644 --- a/data/activity/event/random/sandwich_lady/sandwich_lady.vars.toml +++ b/data/activity/event/random/sandwich_lady/sandwich_lady.vars.toml @@ -1,2 +1,5 @@ [sandwich_lady_food] format = "string" + +[sandwich_lady_npc] +format = "int" diff --git a/game/src/main/kotlin/content/activity/event/random/sandwich_lady/SandwichLady.kt b/game/src/main/kotlin/content/activity/event/random/sandwich_lady/SandwichLady.kt index 3f18032e1b..f611e32be0 100644 --- a/game/src/main/kotlin/content/activity/event/random/sandwich_lady/SandwichLady.kt +++ b/game/src/main/kotlin/content/activity/event/random/sandwich_lady/SandwichLady.kt @@ -2,6 +2,7 @@ package content.activity.event.random.sandwich_lady import content.activity.event.random.RandomEvents import content.activity.event.random.startInPlaceEvent +import content.entity.combat.hit.directHit import content.entity.combat.inCombat import content.entity.player.dialogue.Happy import content.entity.player.dialogue.type.npc @@ -9,6 +10,8 @@ import world.gregs.voidps.engine.Script 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.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.name import world.gregs.voidps.engine.entity.item.floor.FloorItems @@ -35,7 +38,7 @@ class SandwichLady : Script { val food = get("sandwich_lady_food") ?: return@npcOperate if (inCombat) { // No time for the tray mid-fight; she just hands over the right one. - serve(food) + serve(lady, food) return@npcOperate } npc("sandwich_lady", "You look hungry to me. I tell you what - have a ${description(food)} on me.") @@ -45,10 +48,13 @@ class SandwichLady : Script { interfaceOption("Choose refreshment", "sandwich_lady_select:*") { val food = get("sandwich_lady_food") ?: return@interfaceOption + val lady = lady() close("sandwich_lady_select") if (it.component == food) { - serve(food) + serve(lady, food) } else { + lady?.say("Hey, I didn't say you could have that!") + lady?.let { l -> directHit(l, 3) } message("The sandwich lady knocks you out and you wake up somewhere... different.") RandomEvents.noteAndTeleport(this) } @@ -59,14 +65,18 @@ class SandwichLady : Script { val food = FOODS.random(random) set("sandwich_lady_food", food) val lady = startInPlaceEvent("sandwich_lady", nagLines(), nagInterval = 30) ?: return + set("sandwich_lady_npc", lady.index) lady.say("Sandwich delivery for $name!") } - private fun Player.serve(food: String) { + private fun Player.lady(): NPC? = NPCs.indexed(get("sandwich_lady_npc", -1))?.takeIf { it.id == "sandwich_lady" } + + private fun Player.serve(lady: NPC?, food: String) { message("The sandwich lady gives you a ${description(food)}!") if (!inventory.add(food)) { FloorItems.add(tile, food, 1, disappearTicks = 300, owner = this) } + lady?.say("Hope that fills you up!") // Clearing the event state removes the following NPC on its next tick. RandomEvents.completeInPlace(this) } diff --git a/game/src/test/kotlin/content/activity/event/random/SandwichLadyTest.kt b/game/src/test/kotlin/content/activity/event/random/SandwichLadyTest.kt index ef06f52af6..75614d23ec 100644 --- a/game/src/test/kotlin/content/activity/event/random/SandwichLadyTest.kt +++ b/game/src/test/kotlin/content/activity/event/random/SandwichLadyTest.kt @@ -8,6 +8,7 @@ import skipDialogues 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.inv.add import world.gregs.voidps.engine.inv.inventory import world.gregs.voidps.type.Tile @@ -27,6 +28,7 @@ class SandwichLadyTest : WorldTest() { player["sandwich_lady_food"] = food val lady = createNPC("sandwich_lady", spot.addX(1)) lady["owner"] = player.accountName + player["sandwich_lady_npc"] = lady.index return player to lady } @@ -65,15 +67,17 @@ class SandwichLadyTest : WorldTest() { } @Test - fun `Choosing the wrong food knocks the player out with the ignore penalty`() { + fun `Choosing the wrong food hits the player and applies the ignore penalty`() { val (player, lady) = setup("sl_wrong", "meat_pie") player.inventory.add("logs", 4) + val hpBefore = player.levels.get(Skill.Constitution) openTray(player, lady) player.interfaceOption("sandwich_lady_select", "baguette", "Choose refreshment") tick() assertEquals(0, player.inventory.count("baguette")) + assertEquals(hpBefore - 3, player.levels.get(Skill.Constitution)) // she smacks you for 3 assertEquals(4, player.inventory.count("logs_noted")) assertNull(player.get("random_event")) assertTrue(player.contains("random_event_cooldown")) From f126a116413c21c7dcbfed0a0b0979c7d020f374 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Tue, 7 Jul 2026 19:21:09 -0700 Subject: [PATCH 07/89] Give the Sandwich Lady knockout its animation and dialogue - Show "Hope that fills you up!" as a chatbox line on a correct pick so the player actually sees it, instead of a fleeting overhead force-chat. - On a wrong pick she swings her baguette (animation), smacks the player, who plays the death animation and fades out before waking up elsewhere. --- .../sandwich_lady/sandwich_lady.anims.toml | 3 +++ .../random/sandwich_lady/SandwichLady.kt | 26 +++++++++++++------ .../activity/event/random/SandwichLadyTest.kt | 4 ++- 3 files changed, 24 insertions(+), 9 deletions(-) create mode 100644 data/activity/event/random/sandwich_lady/sandwich_lady.anims.toml diff --git a/data/activity/event/random/sandwich_lady/sandwich_lady.anims.toml b/data/activity/event/random/sandwich_lady/sandwich_lady.anims.toml new file mode 100644 index 0000000000..1b280d1dbf --- /dev/null +++ b/data/activity/event/random/sandwich_lady/sandwich_lady.anims.toml @@ -0,0 +1,3 @@ +# The Sandwich Lady's baguette swing when she knocks out a player who picked wrong. +[sandwich_lady_knockout] +id = 3045 diff --git a/game/src/main/kotlin/content/activity/event/random/sandwich_lady/SandwichLady.kt b/game/src/main/kotlin/content/activity/event/random/sandwich_lady/SandwichLady.kt index f611e32be0..929a42bfa7 100644 --- a/game/src/main/kotlin/content/activity/event/random/sandwich_lady/SandwichLady.kt +++ b/game/src/main/kotlin/content/activity/event/random/sandwich_lady/SandwichLady.kt @@ -38,7 +38,7 @@ class SandwichLady : Script { val food = get("sandwich_lady_food") ?: return@npcOperate if (inCombat) { // No time for the tray mid-fight; she just hands over the right one. - serve(lady, food) + serve(food) return@npcOperate } npc("sandwich_lady", "You look hungry to me. I tell you what - have a ${description(food)} on me.") @@ -51,12 +51,9 @@ class SandwichLady : Script { val lady = lady() close("sandwich_lady_select") if (it.component == food) { - serve(lady, food) + serve(food) } else { - lady?.say("Hey, I didn't say you could have that!") - lady?.let { l -> directHit(l, 3) } - message("The sandwich lady knocks you out and you wake up somewhere... different.") - RandomEvents.noteAndTeleport(this) + knockOut(lady) } } } @@ -69,14 +66,27 @@ class SandwichLady : Script { lady.say("Sandwich delivery for $name!") } + private suspend fun Player.knockOut(lady: NPC?) { + message("The sandwich lady knocks you out and you wake up somewhere... different.") + lady?.say("Hey, I didn't say you could have that!") + lady?.anim("sandwich_lady_knockout") // swings the baguette + lady?.let { directHit(it, 3) } + anim("human_death") + open("fade_out") + delay(3) + RandomEvents.noteAndTeleport(this) + clearAnim() + open("fade_in") + } + private fun Player.lady(): NPC? = NPCs.indexed(get("sandwich_lady_npc", -1))?.takeIf { it.id == "sandwich_lady" } - private fun Player.serve(lady: NPC?, food: String) { + private suspend fun Player.serve(food: String) { message("The sandwich lady gives you a ${description(food)}!") if (!inventory.add(food)) { FloorItems.add(tile, food, 1, disappearTicks = 300, owner = this) } - lady?.say("Hope that fills you up!") + npc("sandwich_lady", "Hope that fills you up!") // Clearing the event state removes the following NPC on its next tick. RandomEvents.completeInPlace(this) } diff --git a/game/src/test/kotlin/content/activity/event/random/SandwichLadyTest.kt b/game/src/test/kotlin/content/activity/event/random/SandwichLadyTest.kt index 75614d23ec..914ca0ed43 100644 --- a/game/src/test/kotlin/content/activity/event/random/SandwichLadyTest.kt +++ b/game/src/test/kotlin/content/activity/event/random/SandwichLadyTest.kt @@ -59,6 +59,8 @@ class SandwichLadyTest : WorldTest() { assertTrue(player.interfaces.contains("sandwich_lady_select")) player.interfaceOption("sandwich_lady_select", "meat_pie", "Choose refreshment") tick() + player.skipDialogues() // "Hope that fills you up!" + tick() assertEquals(1, player.inventory.count("meat_pie")) assertEquals(spot, player.tile) // in-place: not teleported anywhere @@ -74,7 +76,7 @@ class SandwichLadyTest : WorldTest() { openTray(player, lady) player.interfaceOption("sandwich_lady_select", "baguette", "Choose refreshment") - tick() + tick(5) // baguette swing, knockout animation, screen fade, then teleport assertEquals(0, player.inventory.count("baguette")) assertEquals(hpBefore - 3, player.levels.get(Skill.Constitution)) // she smacks you for 3 From 172d7449342b386d2a7444fc627bf2dc0a384eeb Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Tue, 7 Jul 2026 20:18:33 -0700 Subject: [PATCH 08/89] Add Drill Demon random event Sergeant Damien whisks the player to his exercise yard and barks an order. Each of the four mats shows an exercise on its sign (a varbit); the player uses the mat matching the order, performing the exercise animation. Four correct exercises earns a piece of the camouflage outfit (helmet/top/bottoms, first missing) or 500 coins if the set is complete. - Shuffles the four exercises across the mat signs each round - Exercise index 0 (jog) is read with a 0 default since the variable store clears vars set to their default value - Reuses the shared kidnap/complete and rewardCostumeOrCoins helpers Ref GregHib/void-private#254 --- .../random/drill_demon/drill_demon.anims.toml | 11 ++ .../random/drill_demon/drill_demon.objs.toml | 13 ++ .../drill_demon/drill_demon.varbits.toml | 16 +++ .../random/drill_demon/drill_demon.vars.toml | 7 ++ .../event/random/random_event.tables.toml | 4 + .../event/random/drill_demon/DrillDemon.kt | 105 +++++++++++++++++ .../activity/event/random/DrillDemonTest.kt | 111 ++++++++++++++++++ 7 files changed, 267 insertions(+) create mode 100644 data/activity/event/random/drill_demon/drill_demon.anims.toml create mode 100644 data/activity/event/random/drill_demon/drill_demon.objs.toml create mode 100644 data/activity/event/random/drill_demon/drill_demon.varbits.toml create mode 100644 data/activity/event/random/drill_demon/drill_demon.vars.toml create mode 100644 game/src/main/kotlin/content/activity/event/random/drill_demon/DrillDemon.kt create mode 100644 game/src/test/kotlin/content/activity/event/random/DrillDemonTest.kt diff --git a/data/activity/event/random/drill_demon/drill_demon.anims.toml b/data/activity/event/random/drill_demon/drill_demon.anims.toml new file mode 100644 index 0000000000..b289e68bd8 --- /dev/null +++ b/data/activity/event/random/drill_demon/drill_demon.anims.toml @@ -0,0 +1,11 @@ +[drill_demon_jog] +id = 2764 + +[drill_demon_situp] +id = 2763 + +[drill_demon_pushup] +id = 2762 + +[drill_demon_starjump] +id = 2761 diff --git a/data/activity/event/random/drill_demon/drill_demon.objs.toml b/data/activity/event/random/drill_demon/drill_demon.objs.toml new file mode 100644 index 0000000000..5793010e41 --- /dev/null +++ b/data/activity/event/random/drill_demon/drill_demon.objs.toml @@ -0,0 +1,13 @@ +# The four exercise mats. Each mat's sign shows an exercise via its varbit +# (drill_demon_sign_1..4); the player uses the mat matching the sergeant's order. +[drill_demon_mat_1] +id = 10076 + +[drill_demon_mat_2] +id = 10077 + +[drill_demon_mat_3] +id = 10078 + +[drill_demon_mat_4] +id = 10079 diff --git a/data/activity/event/random/drill_demon/drill_demon.varbits.toml b/data/activity/event/random/drill_demon/drill_demon.varbits.toml new file mode 100644 index 0000000000..da0c03251d --- /dev/null +++ b/data/activity/event/random/drill_demon/drill_demon.varbits.toml @@ -0,0 +1,16 @@ +# Each mat's sign display: 0 jog, 1 sit-ups, 2 push-ups, 3 star jumps. +[drill_demon_sign_1] +id = 1335 +format = "int" + +[drill_demon_sign_2] +id = 1336 +format = "int" + +[drill_demon_sign_3] +id = 1337 +format = "int" + +[drill_demon_sign_4] +id = 1338 +format = "int" diff --git a/data/activity/event/random/drill_demon/drill_demon.vars.toml b/data/activity/event/random/drill_demon/drill_demon.vars.toml new file mode 100644 index 0000000000..3959d3e84d --- /dev/null +++ b/data/activity/event/random/drill_demon/drill_demon.vars.toml @@ -0,0 +1,7 @@ +[drill_demon_task] +format = "int" +persist = true + +[drill_demon_correct] +format = "int" +persist = true diff --git a/data/activity/event/random/random_event.tables.toml b/data/activity/event/random/random_event.tables.toml index e52f42eeca..ab0d2c582d 100644 --- a/data/activity/event/random/random_event.tables.toml +++ b/data/activity/event/random/random_event.tables.toml @@ -14,6 +14,10 @@ weight = 10 event = "sandwich_lady" weight = 10 +[.drill_demon] +event = "drill_demon" +weight = 10 + [random_event_settings] value = "int" diff --git a/game/src/main/kotlin/content/activity/event/random/drill_demon/DrillDemon.kt b/game/src/main/kotlin/content/activity/event/random/drill_demon/DrillDemon.kt new file mode 100644 index 0000000000..5511e791f1 --- /dev/null +++ b/game/src/main/kotlin/content/activity/event/random/drill_demon/DrillDemon.kt @@ -0,0 +1,105 @@ +package content.activity.event.random.drill_demon + +import content.activity.event.random.RandomEvents +import content.activity.event.random.kidnap +import content.activity.event.random.mysteriousOldMan +import content.activity.event.random.rewardCostumeOrCoins +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.type.Tile +import world.gregs.voidps.type.random + +/** + * Drill Demon random event: Sergeant Damien whisks the player to his exercise yard and barks out an + * exercise. Each of the four mats shows one exercise on its sign (a varbit); the player must use the + * mat matching the order. Four correct exercises earns a piece of the camouflage outfit (or coins). + * https://runescape.wiki/w/Random_events?oldid=3667851#Drill_Demon + */ +class DrillDemon : Script { + + init { + RandomEvents.register("drill_demon") { startEvent() } + + npcOperate("Talk-to", "sergeant_damien") { + if (get("random_event") != "drill_demon") { + message("They aren't interested in talking to you.") + return@npcOperate + } + order(wrong = false) + } + + objectOperate("Use", "drill_demon_mat_*") { (mat) -> + if (get("random_event") != "drill_demon") { + message("You can't do that right now.") + return@objectOperate + } + val exercise = get("drill_demon_sign_${mat.id.removePrefix("drill_demon_mat_")}", 0) + face(mat.tile) + anim(EXERCISES[exercise].anim) + delay(EXERCISE_TICKS) + // 0 (jog) is a valid exercise, so read with a 0 default - the random_event guard above + // already rules out an unset value here. + val correct = exercise == get("drill_demon_task", 0) + if (correct) { + inc("drill_demon_correct") + } + if (get("drill_demon_correct", 0) >= REQUIRED) { + finish() + } else { + assignRound() + order(wrong = !correct) + } + } + } + + private suspend fun Player.startEvent() { + set("drill_demon_correct", 0) + assignRound() + mysteriousOldMan() + kidnap(YARD) + npc("sergeant_damien", "Move yourself private! Follow my orders and you may, just may, leave here in a fit state for my corps!") + order(wrong = false) + } + + /** Shuffle the four exercises across the mats' signs and pick the one the player must perform. */ + private fun Player.assignRound() { + val exercises = EXERCISES.indices.shuffled(random) + for (mat in 1..EXERCISES.size) { + set("drill_demon_sign_$mat", exercises[mat - 1]) + } + set("drill_demon_task", EXERCISES.indices.random(random)) + } + + private suspend fun Player.order(wrong: Boolean) { + val prefix = if (wrong) "Wrong exercise, worm! " else "" + npc("sergeant_damien", "$prefix${EXERCISES[get("drill_demon_task", 0)].order} private!") + } + + private suspend fun Player.finish() { + npc("sergeant_damien", "Well I'll be, you actually did it $name. Now take this and get yourself out of my sight.") + rewardCostumeOrCoins("camo_helmet", "camo_top", "camo_bottoms", coins = 500) + clear("drill_demon_task") + clear("drill_demon_correct") + RandomEvents.complete(this) + } + + private data class Exercise(val anim: String, val order: String) + + companion object { + private const val REQUIRED = 4 + private const val EXERCISE_TICKS = 4 + private val YARD = Tile(3163, 4820) + + // Index order matches the sign varbit values: 0 jog, 1 sit-ups, 2 push-ups, 3 star jumps. + private val EXERCISES = listOf( + Exercise("drill_demon_jog", "Get yourself over there and jog on that mat"), + Exercise("drill_demon_situp", "Get on that mat and give me sit ups"), + Exercise("drill_demon_pushup", "Drop and give me push ups on that mat"), + Exercise("drill_demon_starjump", "I want to see you on that mat doing star jumps"), + ) + } +} diff --git a/game/src/test/kotlin/content/activity/event/random/DrillDemonTest.kt b/game/src/test/kotlin/content/activity/event/random/DrillDemonTest.kt new file mode 100644 index 0000000000..90b474323c --- /dev/null +++ b/game/src/test/kotlin/content/activity/event/random/DrillDemonTest.kt @@ -0,0 +1,111 @@ +package content.activity.event.random + +import WorldTest +import objectOption +import org.junit.jupiter.api.Test +import skipDialogues +import world.gregs.voidps.engine.client.ui.dialogue +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.entity.obj.GameObject +import world.gregs.voidps.engine.entity.obj.GameObjects +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.assertNull +import kotlin.test.assertTrue + +class DrillDemonTest : WorldTest() { + + private val yard = Tile(3163, 4820) + private val matTiles = listOf(Tile(3160, 4819), Tile(3162, 4819), Tile(3164, 4819), Tile(3166, 4819)) + + private fun enter(name: String, origin: Tile = Tile(3221, 3218)): Player { + val player = createPlayer(yard, name) + player["random_event"] = "drill_demon" + player["random_event_origin"] = origin.id + player["drill_demon_correct"] = 0 + tick(2) // load the yard so the map mats resolve + return player + } + + private fun mats(): List = matTiles.mapIndexed { i, t -> GameObjects.find(t) { it.id == "drill_demon_mat_${i + 1}" } } + + // Deterministic signs: mat i shows exercise (i-1); task points at one exercise. + private fun Player.layout(task: Int) { + for (m in 1..4) set("drill_demon_sign_$m", m - 1) + set("drill_demon_task", task) + } + + /** Use whichever mat currently shows the assigned exercise, driving through the animation + order. */ + private fun useCorrectMat(player: Player, mats: List) { + val task = player.get("drill_demon_task", 0) + val index = (1..4).first { player.get("drill_demon_sign_$it", 0) == task } + player.objectOption(mats[index - 1], "Use") + tickIf { player.dialogue == null } // walk to the mat, exercise, then Damien responds + player.skipDialogues() + } + + @Test + fun `Event kidnaps the player to the yard and assigns exercises`() { + val player = createPlayer(Tile(3221, 3218), "dd_start") + RandomEvents.start(player, "drill_demon") + tick(10) + + assertEquals("drill_demon", player.get("random_event")) + assertTrue(player.tile.within(yard, 4), "Expected the player in the yard, was ${player.tile}") + assertTrue(player.get("drill_demon_task", 0) in 0..3) + // All four exercises are shown across the four mats. + assertEquals(setOf(0, 1, 2, 3), (1..4).map { player.get("drill_demon_sign_$it", 0) }.toSet()) + } + + @Test + fun `Using the mat with the ordered exercise counts as correct`() { + val player = enter("dd_correct") + player.layout(task = 2) // push-ups on mat 3 + player.objectOption(mats()[2], "Use") + tick(8) + + assertEquals(1, player.get("drill_demon_correct", 0)) + } + + @Test + fun `Using the wrong mat does not count`() { + val player = enter("dd_wrong") + player.layout(task = 2) + player.objectOption(mats()[0], "Use") // jog mat, not push-ups + tick(8) + + assertEquals(0, player.get("drill_demon_correct", 0)) + } + + @Test + fun `Four correct exercises reward a camo piece and return the player`() { + val origin = Tile(3221, 3218) + val player = enter("dd_finish", origin) + player.layout(task = 0) + val mats = mats() + + repeat(4) { useCorrectMat(player, mats) } + tick(2) + + assertEquals(1, player.inventory.count("camo_helmet")) + assertNull(player.get("random_event")) + assertEquals(origin, player.tile) + } + + @Test + fun `Owning the full camo set rewards coins`() { + val player = enter("dd_coins") + player.inventory.add("camo_helmet") + player.inventory.add("camo_top") + player.inventory.add("camo_bottoms") + player.layout(task = 0) + val mats = mats() + + repeat(4) { useCorrectMat(player, mats) } + tick(2) + + assertEquals(500, player.inventory.count("coins")) + } +} From 9bea6d19cae79639c5ba1c742027fb5f90603325 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Wed, 8 Jul 2026 04:39:51 -0700 Subject: [PATCH 09/89] Fix Drill Demon signs and mat positioning - Encode the four exercises as sign varbit values 1-4 (0 = no sign). The variable store clears any var set to its default 0, so a sign set to 0 never reached the client - one mat showed no sign and the client's signs drifted out of sync with the server, making the correct mat read as wrong. - Walk the player onto the mat before performing the exercise. --- .../event/random/drill_demon/DrillDemon.kt | 26 +++++++--------- .../activity/event/random/DrillDemonTest.kt | 31 +++++++++++-------- 2 files changed, 30 insertions(+), 27 deletions(-) diff --git a/game/src/main/kotlin/content/activity/event/random/drill_demon/DrillDemon.kt b/game/src/main/kotlin/content/activity/event/random/drill_demon/DrillDemon.kt index 5511e791f1..fcd3ee0dac 100644 --- a/game/src/main/kotlin/content/activity/event/random/drill_demon/DrillDemon.kt +++ b/game/src/main/kotlin/content/activity/event/random/drill_demon/DrillDemon.kt @@ -38,11 +38,9 @@ class DrillDemon : Script { return@objectOperate } val exercise = get("drill_demon_sign_${mat.id.removePrefix("drill_demon_mat_")}", 0) - face(mat.tile) - anim(EXERCISES[exercise].anim) + walkOverDelay(Tile(mat.tile.x, YARD.y)) // stand on the mat before exercising + anim(EXERCISES.getValue(exercise).anim) delay(EXERCISE_TICKS) - // 0 (jog) is a valid exercise, so read with a 0 default - the random_event guard above - // already rules out an unset value here. val correct = exercise == get("drill_demon_task", 0) if (correct) { inc("drill_demon_correct") @@ -67,16 +65,16 @@ class DrillDemon : Script { /** Shuffle the four exercises across the mats' signs and pick the one the player must perform. */ private fun Player.assignRound() { - val exercises = EXERCISES.indices.shuffled(random) - for (mat in 1..EXERCISES.size) { + val exercises = EXERCISES.keys.shuffled(random) + for (mat in 1..exercises.size) { set("drill_demon_sign_$mat", exercises[mat - 1]) } - set("drill_demon_task", EXERCISES.indices.random(random)) + set("drill_demon_task", EXERCISES.keys.random(random)) } private suspend fun Player.order(wrong: Boolean) { val prefix = if (wrong) "Wrong exercise, worm! " else "" - npc("sergeant_damien", "$prefix${EXERCISES[get("drill_demon_task", 0)].order} private!") + npc("sergeant_damien", "$prefix${EXERCISES.getValue(get("drill_demon_task", 1)).order} private!") } private suspend fun Player.finish() { @@ -94,12 +92,12 @@ class DrillDemon : Script { private const val EXERCISE_TICKS = 4 private val YARD = Tile(3163, 4820) - // Index order matches the sign varbit values: 0 jog, 1 sit-ups, 2 push-ups, 3 star jumps. - private val EXERCISES = listOf( - Exercise("drill_demon_jog", "Get yourself over there and jog on that mat"), - Exercise("drill_demon_situp", "Get on that mat and give me sit ups"), - Exercise("drill_demon_pushup", "Drop and give me push ups on that mat"), - Exercise("drill_demon_starjump", "I want to see you on that mat doing star jumps"), + // Sign varbit values, 1-based so a sign is never the cleared 0 default (0 = no sign shown). + private val EXERCISES = mapOf( + 1 to Exercise("drill_demon_jog", "Get yourself over there and jog on that mat"), + 2 to Exercise("drill_demon_situp", "Get on that mat and give me sit ups"), + 3 to Exercise("drill_demon_pushup", "Drop and give me push ups on that mat"), + 4 to Exercise("drill_demon_starjump", "I want to see you on that mat doing star jumps"), ) } } diff --git a/game/src/test/kotlin/content/activity/event/random/DrillDemonTest.kt b/game/src/test/kotlin/content/activity/event/random/DrillDemonTest.kt index 90b474323c..c4241c9269 100644 --- a/game/src/test/kotlin/content/activity/event/random/DrillDemonTest.kt +++ b/game/src/test/kotlin/content/activity/event/random/DrillDemonTest.kt @@ -31,19 +31,22 @@ class DrillDemonTest : WorldTest() { private fun mats(): List = matTiles.mapIndexed { i, t -> GameObjects.find(t) { it.id == "drill_demon_mat_${i + 1}" } } - // Deterministic signs: mat i shows exercise (i-1); task points at one exercise. + // Deterministic signs: mat m shows exercise m (1-4); task points at one exercise. private fun Player.layout(task: Int) { - for (m in 1..4) set("drill_demon_sign_$m", m - 1) + for (m in 1..4) set("drill_demon_sign_$m", m) set("drill_demon_task", task) } /** Use whichever mat currently shows the assigned exercise, driving through the animation + order. */ private fun useCorrectMat(player: Player, mats: List) { + while (player.dialogue != null) player.skipDialogues() // close the previous round's order + if (player.get("random_event") != "drill_demon") return val task = player.get("drill_demon_task", 0) + val before = player.get("drill_demon_correct", 0) val index = (1..4).first { player.get("drill_demon_sign_$it", 0) == task } player.objectOption(mats[index - 1], "Use") - tickIf { player.dialogue == null } // walk to the mat, exercise, then Damien responds - player.skipDialogues() + // Wait until the exercise is scored (or the event completes on the final rep). + tickIf(50) { player.get("drill_demon_correct", 0) == before && player.get("random_event") == "drill_demon" } } @Test @@ -54,17 +57,17 @@ class DrillDemonTest : WorldTest() { assertEquals("drill_demon", player.get("random_event")) assertTrue(player.tile.within(yard, 4), "Expected the player in the yard, was ${player.tile}") - assertTrue(player.get("drill_demon_task", 0) in 0..3) + assertTrue(player.get("drill_demon_task", 0) in 1..4) // All four exercises are shown across the four mats. - assertEquals(setOf(0, 1, 2, 3), (1..4).map { player.get("drill_demon_sign_$it", 0) }.toSet()) + assertEquals(setOf(1, 2, 3, 4), (1..4).map { player.get("drill_demon_sign_$it", 0) }.toSet()) } @Test fun `Using the mat with the ordered exercise counts as correct`() { val player = enter("dd_correct") - player.layout(task = 2) // push-ups on mat 3 - player.objectOption(mats()[2], "Use") - tick(8) + player.layout(task = 2) // exercise 2 is on mat 2 + player.objectOption(mats()[1], "Use") + tickIf { player.dialogue == null } assertEquals(1, player.get("drill_demon_correct", 0)) } @@ -73,8 +76,8 @@ class DrillDemonTest : WorldTest() { fun `Using the wrong mat does not count`() { val player = enter("dd_wrong") player.layout(task = 2) - player.objectOption(mats()[0], "Use") // jog mat, not push-ups - tick(8) + player.objectOption(mats()[0], "Use") // mat 1 shows exercise 1, not the ordered 2 + tickIf { player.dialogue == null } assertEquals(0, player.get("drill_demon_correct", 0)) } @@ -83,10 +86,11 @@ class DrillDemonTest : WorldTest() { fun `Four correct exercises reward a camo piece and return the player`() { val origin = Tile(3221, 3218) val player = enter("dd_finish", origin) - player.layout(task = 0) + player.layout(task = 1) val mats = mats() repeat(4) { useCorrectMat(player, mats) } + while (player.dialogue != null) player.skipDialogues() // Damien's closing line, then reward tick(2) assertEquals(1, player.inventory.count("camo_helmet")) @@ -100,10 +104,11 @@ class DrillDemonTest : WorldTest() { player.inventory.add("camo_helmet") player.inventory.add("camo_top") player.inventory.add("camo_bottoms") - player.layout(task = 0) + player.layout(task = 1) val mats = mats() repeat(4) { useCorrectMat(player, mats) } + while (player.dialogue != null) player.skipDialogues() tick(2) assertEquals(500, player.inventory.count("coins")) From 47328813dbf1ae993f56ee17e95821e000566228 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Wed, 8 Jul 2026 04:49:50 -0700 Subject: [PATCH 10/89] Polish Drill Demon exercise and sign-change effects - On using a mat the player now faces the sergeant, plays the exercise sound and waits two ticks before performing the exercise animation. - When the signs reshuffle, a puff of smoke (gfx 2000) plays on each sign tile in turn from sign 1 to sign 4, a tick apart, as it changes. --- .../random/drill_demon/drill_demon.gfx.toml | 3 ++ .../drill_demon/drill_demon.sounds.toml | 11 +++++ .../event/random/drill_demon/DrillDemon.kt | 46 +++++++++++++------ .../activity/event/random/DrillDemonTest.kt | 5 +- 4 files changed, 48 insertions(+), 17 deletions(-) create mode 100644 data/activity/event/random/drill_demon/drill_demon.gfx.toml create mode 100644 data/activity/event/random/drill_demon/drill_demon.sounds.toml diff --git a/data/activity/event/random/drill_demon/drill_demon.gfx.toml b/data/activity/event/random/drill_demon/drill_demon.gfx.toml new file mode 100644 index 0000000000..e8c2749ba3 --- /dev/null +++ b/data/activity/event/random/drill_demon/drill_demon.gfx.toml @@ -0,0 +1,3 @@ +# Puff shown on a sign tile as its exercise changes. +[drill_demon_sign_change] +id = 2000 diff --git a/data/activity/event/random/drill_demon/drill_demon.sounds.toml b/data/activity/event/random/drill_demon/drill_demon.sounds.toml new file mode 100644 index 0000000000..c64c4f74c4 --- /dev/null +++ b/data/activity/event/random/drill_demon/drill_demon.sounds.toml @@ -0,0 +1,11 @@ +[drill_demon_jog] +id = 2484 + +[drill_demon_situp] +id = 2486 + +[drill_demon_pushup] +id = 2481 + +[drill_demon_starjump] +id = 2492 diff --git a/game/src/main/kotlin/content/activity/event/random/drill_demon/DrillDemon.kt b/game/src/main/kotlin/content/activity/event/random/drill_demon/DrillDemon.kt index fcd3ee0dac..3b755331a8 100644 --- a/game/src/main/kotlin/content/activity/event/random/drill_demon/DrillDemon.kt +++ b/game/src/main/kotlin/content/activity/event/random/drill_demon/DrillDemon.kt @@ -4,12 +4,15 @@ import content.activity.event.random.RandomEvents import content.activity.event.random.kidnap import content.activity.event.random.mysteriousOldMan import content.activity.event.random.rewardCostumeOrCoins +import content.entity.gfx.areaGfx 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.character.sound +import world.gregs.voidps.type.Direction import world.gregs.voidps.type.Tile import world.gregs.voidps.type.random @@ -37,11 +40,15 @@ class DrillDemon : Script { message("You can't do that right now.") return@objectOperate } - val exercise = get("drill_demon_sign_${mat.id.removePrefix("drill_demon_mat_")}", 0) + val value = get("drill_demon_sign_${mat.id.removePrefix("drill_demon_mat_")}", 0) + val exercise = EXERCISES.getValue(value) walkOverDelay(Tile(mat.tile.x, YARD.y)) // stand on the mat before exercising - anim(EXERCISES.getValue(exercise).anim) + face(Direction.SOUTH) + sound(exercise.sound) + delay(2) + anim(exercise.anim) delay(EXERCISE_TICKS) - val correct = exercise == get("drill_demon_task", 0) + val correct = value == get("drill_demon_task", 0) if (correct) { inc("drill_demon_correct") } @@ -56,20 +63,28 @@ class DrillDemon : Script { private suspend fun Player.startEvent() { set("drill_demon_correct", 0) - assignRound() + assignRound(reveal = false) mysteriousOldMan() kidnap(YARD) npc("sergeant_damien", "Move yourself private! Follow my orders and you may, just may, leave here in a fit state for my corps!") order(wrong = false) } - /** Shuffle the four exercises across the mats' signs and pick the one the player must perform. */ - private fun Player.assignRound() { + /** + * Shuffle the four exercises across the mats' signs and pick the one the player must perform. + * When [reveal] is set the signs change one at a time from sign 1 to sign 4, a tick apart, each + * with a puff of smoke. + */ + private suspend fun Player.assignRound(reveal: Boolean = true) { val exercises = EXERCISES.keys.shuffled(random) - for (mat in 1..exercises.size) { - set("drill_demon_sign_$mat", exercises[mat - 1]) - } set("drill_demon_task", EXERCISES.keys.random(random)) + for (sign in 1..exercises.size) { + set("drill_demon_sign_$sign", exercises[sign - 1]) + if (reveal) { + areaGfx("drill_demon_sign_change", SIGN_TILES[sign - 1]) + delay(1) + } + } } private suspend fun Player.order(wrong: Boolean) { @@ -85,19 +100,22 @@ class DrillDemon : Script { RandomEvents.complete(this) } - private data class Exercise(val anim: String, val order: String) + private data class Exercise(val anim: String, val sound: String, val order: String) companion object { private const val REQUIRED = 4 private const val EXERCISE_TICKS = 4 private val YARD = Tile(3163, 4820) + // The sign board sits at the back (north) tile of each mat. + private val SIGN_TILES = listOf(Tile(3160, 4819), Tile(3162, 4819), Tile(3164, 4819), Tile(3166, 4819)) + // Sign varbit values, 1-based so a sign is never the cleared 0 default (0 = no sign shown). private val EXERCISES = mapOf( - 1 to Exercise("drill_demon_jog", "Get yourself over there and jog on that mat"), - 2 to Exercise("drill_demon_situp", "Get on that mat and give me sit ups"), - 3 to Exercise("drill_demon_pushup", "Drop and give me push ups on that mat"), - 4 to Exercise("drill_demon_starjump", "I want to see you on that mat doing star jumps"), + 1 to Exercise("drill_demon_jog", "drill_demon_jog", "Get yourself over there and jog on that mat"), + 2 to Exercise("drill_demon_situp", "drill_demon_situp", "Get on that mat and give me sit ups"), + 3 to Exercise("drill_demon_pushup", "drill_demon_pushup", "Drop and give me push ups on that mat"), + 4 to Exercise("drill_demon_starjump", "drill_demon_starjump", "I want to see you on that mat doing star jumps"), ) } } diff --git a/game/src/test/kotlin/content/activity/event/random/DrillDemonTest.kt b/game/src/test/kotlin/content/activity/event/random/DrillDemonTest.kt index c4241c9269..7d2a981d2d 100644 --- a/game/src/test/kotlin/content/activity/event/random/DrillDemonTest.kt +++ b/game/src/test/kotlin/content/activity/event/random/DrillDemonTest.kt @@ -42,11 +42,10 @@ class DrillDemonTest : WorldTest() { while (player.dialogue != null) player.skipDialogues() // close the previous round's order if (player.get("random_event") != "drill_demon") return val task = player.get("drill_demon_task", 0) - val before = player.get("drill_demon_correct", 0) val index = (1..4).first { player.get("drill_demon_sign_$it", 0) == task } player.objectOption(mats[index - 1], "Use") - // Wait until the exercise is scored (or the event completes on the final rep). - tickIf(50) { player.get("drill_demon_correct", 0) == before && player.get("random_event") == "drill_demon" } + // Wait for the exercise + sign sweep to finish and Damien to respond (or the event to complete). + tickIf(60) { player.dialogue == null && player.get("random_event") == "drill_demon" } } @Test From ed71edfa7483b3204720a774a46ce46be4f9208b Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Wed, 8 Jul 2026 04:53:46 -0700 Subject: [PATCH 11/89] Play the sign-change puff on the sign tile (mat y + 2) --- .../content/activity/event/random/drill_demon/DrillDemon.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/game/src/main/kotlin/content/activity/event/random/drill_demon/DrillDemon.kt b/game/src/main/kotlin/content/activity/event/random/drill_demon/DrillDemon.kt index 3b755331a8..72eb993e12 100644 --- a/game/src/main/kotlin/content/activity/event/random/drill_demon/DrillDemon.kt +++ b/game/src/main/kotlin/content/activity/event/random/drill_demon/DrillDemon.kt @@ -107,8 +107,8 @@ class DrillDemon : Script { private const val EXERCISE_TICKS = 4 private val YARD = Tile(3163, 4820) - // The sign board sits at the back (north) tile of each mat. - private val SIGN_TILES = listOf(Tile(3160, 4819), Tile(3162, 4819), Tile(3164, 4819), Tile(3166, 4819)) + // The sign board sits two tiles north of each mat (mat y + 2). + private val SIGN_TILES = listOf(Tile(3160, 4821), Tile(3162, 4821), Tile(3164, 4821), Tile(3166, 4821)) // Sign varbit values, 1-based so a sign is never the cleared 0 default (0 = no sign shown). private val EXERCISES = mapOf( From 68336f55cc6beaec24de74c1a2177ee3de9005c5 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Wed, 8 Jul 2026 05:03:55 -0700 Subject: [PATCH 12/89] Sweep the Drill Demon sign puffs half a tick apart (Option B) The four signs now all change on one tick and the smoke puffs sweep across them client-side, sign 1 to sign 4, half a tick (15 client ticks) apart, instead of one server tick between each change. --- .../activity/event/random/drill_demon/DrillDemon.kt | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/game/src/main/kotlin/content/activity/event/random/drill_demon/DrillDemon.kt b/game/src/main/kotlin/content/activity/event/random/drill_demon/DrillDemon.kt index 72eb993e12..acb1f6317b 100644 --- a/game/src/main/kotlin/content/activity/event/random/drill_demon/DrillDemon.kt +++ b/game/src/main/kotlin/content/activity/event/random/drill_demon/DrillDemon.kt @@ -72,17 +72,16 @@ class DrillDemon : Script { /** * Shuffle the four exercises across the mats' signs and pick the one the player must perform. - * When [reveal] is set the signs change one at a time from sign 1 to sign 4, a tick apart, each - * with a puff of smoke. + * When [reveal] is set a puff of smoke sweeps across the signs from sign 1 to sign 4, each half a + * tick apart (the signs themselves all change this tick; only the puffs are staggered client-side). */ - private suspend fun Player.assignRound(reveal: Boolean = true) { + private fun Player.assignRound(reveal: Boolean = true) { val exercises = EXERCISES.keys.shuffled(random) set("drill_demon_task", EXERCISES.keys.random(random)) for (sign in 1..exercises.size) { set("drill_demon_sign_$sign", exercises[sign - 1]) if (reveal) { - areaGfx("drill_demon_sign_change", SIGN_TILES[sign - 1]) - delay(1) + areaGfx("drill_demon_sign_change", SIGN_TILES[sign - 1], delay = (sign - 1) * HALF_TICK) } } } @@ -105,6 +104,7 @@ class DrillDemon : Script { companion object { private const val REQUIRED = 4 private const val EXERCISE_TICKS = 4 + private const val HALF_TICK = 15 // client ticks; a game tick is 30 private val YARD = Tile(3163, 4820) // The sign board sits two tiles north of each mat (mat y + 2). From d1ab2c4dc7a443683186e3f5dd8db0fe05f1ab75 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Wed, 8 Jul 2026 05:09:30 -0700 Subject: [PATCH 13/89] Use the large chathead for Sergeant Damien's dialogue --- .../content/activity/event/random/drill_demon/DrillDemon.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/game/src/main/kotlin/content/activity/event/random/drill_demon/DrillDemon.kt b/game/src/main/kotlin/content/activity/event/random/drill_demon/DrillDemon.kt index acb1f6317b..ad43efa259 100644 --- a/game/src/main/kotlin/content/activity/event/random/drill_demon/DrillDemon.kt +++ b/game/src/main/kotlin/content/activity/event/random/drill_demon/DrillDemon.kt @@ -66,7 +66,7 @@ class DrillDemon : Script { assignRound(reveal = false) mysteriousOldMan() kidnap(YARD) - npc("sergeant_damien", "Move yourself private! Follow my orders and you may, just may, leave here in a fit state for my corps!") + npc("sergeant_damien", "Move yourself private! Follow my orders and you may, just may, leave here in a fit state for my corps!", largeHead = true) order(wrong = false) } @@ -88,11 +88,11 @@ class DrillDemon : Script { private suspend fun Player.order(wrong: Boolean) { val prefix = if (wrong) "Wrong exercise, worm! " else "" - npc("sergeant_damien", "$prefix${EXERCISES.getValue(get("drill_demon_task", 1)).order} private!") + npc("sergeant_damien", "$prefix${EXERCISES.getValue(get("drill_demon_task", 1)).order} private!", largeHead = true) } private suspend fun Player.finish() { - npc("sergeant_damien", "Well I'll be, you actually did it $name. Now take this and get yourself out of my sight.") + npc("sergeant_damien", "Well I'll be, you actually did it $name. Now take this and get yourself out of my sight.", largeHead = true) rewardCostumeOrCoins("camo_helmet", "camo_top", "camo_bottoms", coins = 500) clear("drill_demon_task") clear("drill_demon_correct") From 36e58de91e0e99e4c7f9a7b389d528b06c7701ff Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Wed, 8 Jul 2026 05:15:17 -0700 Subject: [PATCH 14/89] Show the sign item dialogue after Drill Demon's order After Sergeant Damien names the exercise, an item box shows the matching sign with "Go to this mat and !" ("this mat" in red), matching 2009scape. Adds the missing push-up sign item (10946). Darkan doesn't implement Drill Demon. --- .../activity/event/random/random_event.items.toml | 4 ++++ .../event/random/drill_demon/DrillDemon.kt | 15 +++++++++------ 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/data/activity/event/random/random_event.items.toml b/data/activity/event/random/random_event.items.toml index 085fa8b700..e09ec7bddb 100644 --- a/data/activity/event/random/random_event.items.toml +++ b/data/activity/event/random/random_event.items.toml @@ -1,3 +1,7 @@ +[push_up] +id = 10946 +examine = "A sign." + [run] id = 10947 examine = "A sign." diff --git a/game/src/main/kotlin/content/activity/event/random/drill_demon/DrillDemon.kt b/game/src/main/kotlin/content/activity/event/random/drill_demon/DrillDemon.kt index ad43efa259..65dd94255c 100644 --- a/game/src/main/kotlin/content/activity/event/random/drill_demon/DrillDemon.kt +++ b/game/src/main/kotlin/content/activity/event/random/drill_demon/DrillDemon.kt @@ -6,6 +6,7 @@ import content.activity.event.random.mysteriousOldMan import content.activity.event.random.rewardCostumeOrCoins import content.entity.gfx.areaGfx import content.entity.player.dialogue.Neutral +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.message @@ -87,8 +88,10 @@ class DrillDemon : Script { } private suspend fun Player.order(wrong: Boolean) { + val task = EXERCISES.getValue(get("drill_demon_task", 1)) val prefix = if (wrong) "Wrong exercise, worm! " else "" - npc("sergeant_damien", "$prefix${EXERCISES.getValue(get("drill_demon_task", 1)).order} private!", largeHead = true) + npc("sergeant_damien", "$prefix${task.order} private!", largeHead = true) + item(task.sign, "Go to this mat and ${task.action}!") } private suspend fun Player.finish() { @@ -99,7 +102,7 @@ class DrillDemon : Script { RandomEvents.complete(this) } - private data class Exercise(val anim: String, val sound: String, val order: String) + private data class Exercise(val anim: String, val sound: String, val order: String, val sign: String, val action: String) companion object { private const val REQUIRED = 4 @@ -112,10 +115,10 @@ class DrillDemon : Script { // Sign varbit values, 1-based so a sign is never the cleared 0 default (0 = no sign shown). private val EXERCISES = mapOf( - 1 to Exercise("drill_demon_jog", "drill_demon_jog", "Get yourself over there and jog on that mat"), - 2 to Exercise("drill_demon_situp", "drill_demon_situp", "Get on that mat and give me sit ups"), - 3 to Exercise("drill_demon_pushup", "drill_demon_pushup", "Drop and give me push ups on that mat"), - 4 to Exercise("drill_demon_starjump", "drill_demon_starjump", "I want to see you on that mat doing star jumps"), + 1 to Exercise("drill_demon_jog", "drill_demon_jog", "Get yourself over there and jog on that mat", "run", "jog on the spot"), + 2 to Exercise("drill_demon_situp", "drill_demon_situp", "Get on that mat and give me sit ups", "sit_up", "do some sit-ups"), + 3 to Exercise("drill_demon_pushup", "drill_demon_pushup", "Drop and give me push ups on that mat", "push_up", "do some push-ups"), + 4 to Exercise("drill_demon_starjump", "drill_demon_starjump", "I want to see you on that mat doing star jumps", "star_jump", "do some star jumps"), ) } } From 97e7cc231743018bdb777d58fb54b07c2c414aeb Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Wed, 8 Jul 2026 05:23:45 -0700 Subject: [PATCH 15/89] Make Drill Demon wait before the first order On arrival the yard shows "Follow Sergeant Damien's orders!" and waits about five seconds before the sergeant barks the first exercise. Using a mat (or talking to him) before the order is given has him snap "I haven't given you the order yet, worm!" and earns no credit for that attempt. --- .../random/drill_demon/drill_demon.vars.toml | 4 ++++ .../event/random/drill_demon/DrillDemon.kt | 16 +++++++++++++++- .../activity/event/random/DrillDemonTest.kt | 12 ++++++++++++ 3 files changed, 31 insertions(+), 1 deletion(-) diff --git a/data/activity/event/random/drill_demon/drill_demon.vars.toml b/data/activity/event/random/drill_demon/drill_demon.vars.toml index 3959d3e84d..1b6771b327 100644 --- a/data/activity/event/random/drill_demon/drill_demon.vars.toml +++ b/data/activity/event/random/drill_demon/drill_demon.vars.toml @@ -5,3 +5,7 @@ persist = true [drill_demon_correct] format = "int" persist = true + +# Set once the sergeant has given the first order; using a mat before then is a wasted attempt. +[drill_demon_ready] +format = "boolean" diff --git a/game/src/main/kotlin/content/activity/event/random/drill_demon/DrillDemon.kt b/game/src/main/kotlin/content/activity/event/random/drill_demon/DrillDemon.kt index 65dd94255c..e08d42a5af 100644 --- a/game/src/main/kotlin/content/activity/event/random/drill_demon/DrillDemon.kt +++ b/game/src/main/kotlin/content/activity/event/random/drill_demon/DrillDemon.kt @@ -33,6 +33,10 @@ class DrillDemon : Script { message("They aren't interested in talking to you.") return@npcOperate } + if (!get("drill_demon_ready", false)) { + npc("sergeant_damien", "I haven't given you the order yet, worm!", largeHead = true) + return@npcOperate + } order(wrong = false) } @@ -41,6 +45,11 @@ class DrillDemon : Script { message("You can't do that right now.") return@objectOperate } + if (!get("drill_demon_ready", false)) { + // Jumping on a mat before the order is given is a wasted, uncredited attempt. + npc("sergeant_damien", "I haven't given you the order yet, worm!", largeHead = true) + return@objectOperate + } val value = get("drill_demon_sign_${mat.id.removePrefix("drill_demon_mat_")}", 0) val exercise = EXERCISES.getValue(value) walkOverDelay(Tile(mat.tile.x, YARD.y)) // stand on the mat before exercising @@ -64,10 +73,13 @@ class DrillDemon : Script { private suspend fun Player.startEvent() { set("drill_demon_correct", 0) + clear("drill_demon_ready") assignRound(reveal = false) mysteriousOldMan() kidnap(YARD) - npc("sergeant_damien", "Move yourself private! Follow my orders and you may, just may, leave here in a fit state for my corps!", largeHead = true) + message("Follow Sergeant Damien's orders!") + delay(ORDER_DELAY) // give the player a few seconds before barking the first order + set("drill_demon_ready", true) order(wrong = false) } @@ -97,6 +109,7 @@ class DrillDemon : Script { private suspend fun Player.finish() { npc("sergeant_damien", "Well I'll be, you actually did it $name. Now take this and get yourself out of my sight.", largeHead = true) rewardCostumeOrCoins("camo_helmet", "camo_top", "camo_bottoms", coins = 500) + clear("drill_demon_ready") clear("drill_demon_task") clear("drill_demon_correct") RandomEvents.complete(this) @@ -107,6 +120,7 @@ class DrillDemon : Script { companion object { private const val REQUIRED = 4 private const val EXERCISE_TICKS = 4 + private const val ORDER_DELAY = 8 // ~5 seconds before the first order private const val HALF_TICK = 15 // client ticks; a game tick is 30 private val YARD = Tile(3163, 4820) diff --git a/game/src/test/kotlin/content/activity/event/random/DrillDemonTest.kt b/game/src/test/kotlin/content/activity/event/random/DrillDemonTest.kt index 7d2a981d2d..be54c6a3ec 100644 --- a/game/src/test/kotlin/content/activity/event/random/DrillDemonTest.kt +++ b/game/src/test/kotlin/content/activity/event/random/DrillDemonTest.kt @@ -25,6 +25,7 @@ class DrillDemonTest : WorldTest() { player["random_event"] = "drill_demon" player["random_event_origin"] = origin.id player["drill_demon_correct"] = 0 + player["drill_demon_ready"] = true // the order has been given tick(2) // load the yard so the map mats resolve return player } @@ -71,6 +72,17 @@ class DrillDemonTest : WorldTest() { assertEquals(1, player.get("drill_demon_correct", 0)) } + @Test + fun `Using a mat before the order is given gives no credit`() { + val player = enter("dd_early") + player.clear("drill_demon_ready") // order not given yet + player.layout(task = 1) + player.objectOption(mats()[0], "Use") // mat 1 would be correct once the order is given + tickIf { player.dialogue == null } // Damien scolds instead + + assertEquals(0, player.get("drill_demon_correct", 0)) + } + @Test fun `Using the wrong mat does not count`() { val player = enter("dd_wrong") From 4fdf45c05b6bb536ada73564b8e12abb225a7c2c Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Wed, 8 Jul 2026 05:36:09 -0700 Subject: [PATCH 16/89] Add Certer random event One of the certificate brothers appears beside the player, bows and nags. Talking opens the identify interface (184) showing an item and three descriptions; picking the one that matches the item rolls the shared gem/coin reward table, a wrong pick gives nothing, and ignoring him applies the note-and-teleport penalty. Adds the shared random_event_certer loot table and its roller (reused later by Pillory). Ref GregHib/void-private#253 --- .../event/random/certer/certer.ifaces.toml | 15 +++ .../event/random/certer/certer.npcs.toml | 3 + .../event/random/certer/certer.vars.toml | 2 + .../event/random/random_event.tables.toml | 71 ++++++++++++ .../event/random/RandomEventRewards.kt | 17 +++ .../activity/event/random/certer/Certer.kt | 101 ++++++++++++++++++ .../activity/event/random/CerterTest.kt | 81 ++++++++++++++ 7 files changed, 290 insertions(+) create mode 100644 data/activity/event/random/certer/certer.ifaces.toml create mode 100644 data/activity/event/random/certer/certer.npcs.toml create mode 100644 data/activity/event/random/certer/certer.vars.toml create mode 100644 game/src/main/kotlin/content/activity/event/random/certer/Certer.kt create mode 100644 game/src/test/kotlin/content/activity/event/random/CerterTest.kt diff --git a/data/activity/event/random/certer/certer.ifaces.toml b/data/activity/event/random/certer/certer.ifaces.toml new file mode 100644 index 0000000000..4c6ed07980 --- /dev/null +++ b/data/activity/event/random/certer/certer.ifaces.toml @@ -0,0 +1,15 @@ +# The certer's "identify this item" interface: three description options and the item model. +[certer_identify] +id = 184 + +[.option_1] +id = 4 + +[.option_2] +id = 5 + +[.option_3] +id = 6 + +[.item] +id = 8 diff --git a/data/activity/event/random/certer/certer.npcs.toml b/data/activity/event/random/certer/certer.npcs.toml new file mode 100644 index 0000000000..8bc4900b92 --- /dev/null +++ b/data/activity/event/random/certer/certer.npcs.toml @@ -0,0 +1,3 @@ +[giles] +id = 2538 +examine = "One of the certificate brothers." diff --git a/data/activity/event/random/certer/certer.vars.toml b/data/activity/event/random/certer/certer.vars.toml new file mode 100644 index 0000000000..610f4e4549 --- /dev/null +++ b/data/activity/event/random/certer/certer.vars.toml @@ -0,0 +1,2 @@ +[certer_answer] +format = "int" diff --git a/data/activity/event/random/random_event.tables.toml b/data/activity/event/random/random_event.tables.toml index ab0d2c582d..abc40ffa61 100644 --- a/data/activity/event/random/random_event.tables.toml +++ b/data/activity/event/random/random_event.tables.toml @@ -18,6 +18,10 @@ weight = 10 event = "drill_demon" weight = 10 +[.certer] +event = "certer" +weight = 10 + [random_event_settings] value = "int" @@ -51,3 +55,70 @@ area = "random_event_exile_draynor" [.al_kharid] area = "random_event_exile_al_kharid" + +# Shared reward table for the Certer and Pillory events. +[random_event_certer] +item = "item" +weight = "int" +min = "int" +max = "int" + +[.coins] +item = "coins" +weight = 20 +min = 80 +max = 640 + +[.kebab] +item = "kebab" +weight = 15 +min = 1 +max = 1 + +[.spinach_roll] +item = "spinach_roll" +weight = 15 +min = 1 +max = 1 + +[.uncut_sapphire] +item = "uncut_sapphire" +weight = 10 +min = 1 +max = 1 + +[.uncut_emerald] +item = "uncut_emerald" +weight = 8 +min = 1 +max = 1 + +[.cosmic_talisman] +item = "cosmic_talisman" +weight = 8 +min = 1 +max = 1 + +[.uncut_ruby] +item = "uncut_ruby" +weight = 5 +min = 1 +max = 1 + +[.uncut_diamond] +item = "uncut_diamond" +weight = 3 +min = 1 +max = 1 + +[.tooth_half_of_a_key] +item = "tooth_half_of_a_key" +weight = 2 +min = 1 +max = 1 + +[.loop_half_of_a_key] +item = "loop_half_of_a_key" +weight = 2 +min = 1 +max = 1 diff --git a/game/src/main/kotlin/content/activity/event/random/RandomEventRewards.kt b/game/src/main/kotlin/content/activity/event/random/RandomEventRewards.kt index 39c61912ec..4b0e36d5e0 100644 --- a/game/src/main/kotlin/content/activity/event/random/RandomEventRewards.kt +++ b/game/src/main/kotlin/content/activity/event/random/RandomEventRewards.kt @@ -1,10 +1,12 @@ package content.activity.event.random import content.entity.player.bank.ownsItem +import world.gregs.voidps.engine.data.definition.Tables import world.gregs.voidps.engine.entity.character.player.Player import world.gregs.voidps.engine.entity.item.floor.FloorItems import world.gregs.voidps.engine.inv.add import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.type.random /** * "Collect the set" reward shared by the costume random events (Drill Demon's camouflage set, @@ -21,6 +23,21 @@ fun Player.rewardCostumeOrCoins(vararg pieces: String, coins: Int) { } } +/** + * Award a single weighted roll of the shared `random_event_certer` gem/coin table (Certer, Pillory). + */ +fun Player.rewardCerterLoot() { + val rows = Tables.get("random_event_certer").rows() + var roll = random.nextInt(rows.sumOf { it.int("weight") }) + for (row in rows) { + roll -= row.int("weight") + if (roll < 0) { + giveOrDrop(row.item("item"), random.nextInt(row.int("min"), row.int("max") + 1)) + return + } + } +} + 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/certer/Certer.kt b/game/src/main/kotlin/content/activity/event/random/certer/Certer.kt new file mode 100644 index 0000000000..9f4816c79c --- /dev/null +++ b/game/src/main/kotlin/content/activity/event/random/certer/Certer.kt @@ -0,0 +1,101 @@ +package content.activity.event.random.certer + +import content.activity.event.random.RandomEvents +import content.activity.event.random.rewardCerterLoot +import content.activity.event.random.startInPlaceEvent +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.client.ui.close +import world.gregs.voidps.engine.client.ui.open +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.Item +import world.gregs.voidps.type.random + +/** + * Certer random event: one of the certificate brothers appears beside the player and nags until + * spoken to. He shows an item and three descriptions (interface 184); picking the description that + * matches the item earns a roll of the gem/coin reward table, a wrong pick earns nothing. Ignoring + * him applies the note-and-teleport penalty. + * https://runescape.wiki/w/Random_events?oldid=3667851#Certer + */ +class Certer : Script { + + init { + RandomEvents.register("certer") { startEvent() } + + npcOperate("Talk-to", "giles") { (giles) -> + if (giles.owner != this) { + message("They aren't interested in talking to you.") + return@npcOperate + } + npc("giles", "Ah, hello, $name. Could you please help me identify this?") + openPuzzle() + } + + interfaceOption("Select", "certer_identify:option_*") { + val correct = it.component == "option_${get("certer_answer", 0)}" + close("certer_identify") + if (correct) { + npc("giles", "Thank you, I hope you like your present. I must be leaving now though.") + rewardCerterLoot() + } else { + npc("giles", "Sorry, I don't think so.") + } + RandomEvents.completeInPlace(this) + } + } + + private fun Player.startEvent() { + val giles = startInPlaceEvent("giles", nagLines()) ?: return + giles.anim("emote_bow") + } + + private fun Player.openPuzzle() { + val (item, description) = ITEMS.entries.random(random) + val answer = (1..3).random(random) + set("certer_answer", answer) + val falses = FALSE_OPTIONS.shuffled(random).iterator() + open("certer_identify") + for (option in 1..3) { + interfaces.sendText("certer_identify", "option_$option", if (option == answer) description else falses.next()) + } + interfaces.sendItem("certer_identify", "item", Item(item)) + } + + private fun Player.nagLines() = listOf( + "Greetings $name, I need your help.", + "ehem... Hello $name, please talk to me!", + "Hello, are you there $name?", + "It's really rude to ignore someone, $name!", + "No-one ignores me!", + ) + + companion object { + private val ITEMS = mapOf( + "bowl" to "A bowl.", + "raw_shrimps" to "A fish.", + "raw_bass" to "A bass.", + "bronze_sword" to "A sword.", + "bronze_battleaxe" to "A battleaxe.", + "bronze_med_helm" to "A helmet.", + "bronze_kiteshield" to "A kiteshield.", + "shears" to "A pair of shears.", + "spade" to "A shovel.", + "gold_ring" to "A ring.", + "gold_necklace" to "A necklace.", + ) + private val FALSE_OPTIONS = listOf( + "An axe.", + "An arrow.", + "A pair of boots.", + "A pair of gloves.", + "A staff.", + "A bow.", + "A feather.", + ) + } +} diff --git a/game/src/test/kotlin/content/activity/event/random/CerterTest.kt b/game/src/test/kotlin/content/activity/event/random/CerterTest.kt new file mode 100644 index 0000000000..ec19b6b589 --- /dev/null +++ b/game/src/test/kotlin/content/activity/event/random/CerterTest.kt @@ -0,0 +1,81 @@ +package content.activity.event.random + +import WorldTest +import interfaceOption +import npcOption +import org.junit.jupiter.api.Test +import skipDialogues +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.inv.inventory +import world.gregs.voidps.type.Tile +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class CerterTest : WorldTest() { + + private val spot = Tile(3221, 3218) + + private fun setup(name: String): Pair { + val player = createPlayer(spot, name) + player["random_event"] = "certer" + player["random_event_origin"] = spot.id + val giles = createNPC("giles", spot.addX(1)) + giles["owner"] = player.accountName + return player to giles + } + + private fun openPuzzle(player: Player, giles: NPC) { + player.npcOption(giles, "Talk-to") + tick() + player.skipDialogues() // "Ah, hello... identify this?" -> opens the interface + tick() + } + + @Test + fun `Event spawns a certer beside the player`() { + val player = createPlayer(spot, "certer_spawn") + RandomEvents.start(player, "certer") + tick(2) + + val giles = (-2..2).flatMap { dx -> (-2..2).map { dy -> player.tile.add(dx, dy) } } + .firstNotNullOfOrNull { t -> NPCs.firstOrNull(t) { it.id == "giles" } } + assertTrue(giles != null, "Expected a certer spawned near the player") + } + + @Test + fun `Correctly identifying the item rewards loot and ends the event`() { + val (player, giles) = setup("certer_correct") + openPuzzle(player, giles) + + assertTrue(player.interfaces.contains("certer_identify")) + val answer = player.get("certer_answer", 0) + player.interfaceOption("certer_identify", "option_$answer", "Select") + tick() + player.skipDialogues() // "Thank you, I hope you like your present..." + tick() + + assertFalse(player.inventory.isEmpty(), "Expected the certer reward") + assertNull(player.get("random_event")) + assertTrue(player.contains("random_event_cooldown")) + } + + @Test + fun `A wrong answer ends the event with no reward`() { + val (player, giles) = setup("certer_wrong") + openPuzzle(player, giles) + + val answer = player.get("certer_answer", 0) + val wrong = if (answer == 1) 2 else 1 + player.interfaceOption("certer_identify", "option_$wrong", "Select") + tick() + player.skipDialogues() // "Sorry, I don't think so." + tick() + + assertTrue(player.inventory.isEmpty(), "Expected no reward for a wrong answer") + assertNull(player.get("random_event")) + assertTrue(player.contains("random_event_cooldown")) + } +} From f0b2ee8da9d4cbe9922cc06a3bdbc1af25167e2c Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Wed, 8 Jul 2026 06:50:46 -0700 Subject: [PATCH 17/89] Rework Certer to use the item box and a choice menu Interface 184 isn't the Certer window in the 634 cache (only five components, and the runtime item definition can't supply a model to render on it), so the puzzle didn't display. Show the item with the standard item dialogue box and pick the description from a choice menu instead - reliable and still faithful to the identify-the-item mechanic. Certer isn't implemented in Darkan (only 2009scape). --- .../event/random/certer/certer.ifaces.toml | 15 ------ .../activity/event/random/certer/Certer.kt | 46 ++++++++----------- .../activity/event/random/CerterTest.kt | 9 ++-- 3 files changed, 22 insertions(+), 48 deletions(-) delete mode 100644 data/activity/event/random/certer/certer.ifaces.toml diff --git a/data/activity/event/random/certer/certer.ifaces.toml b/data/activity/event/random/certer/certer.ifaces.toml deleted file mode 100644 index 4c6ed07980..0000000000 --- a/data/activity/event/random/certer/certer.ifaces.toml +++ /dev/null @@ -1,15 +0,0 @@ -# The certer's "identify this item" interface: three description options and the item model. -[certer_identify] -id = 184 - -[.option_1] -id = 4 - -[.option_2] -id = 5 - -[.option_3] -id = 6 - -[.item] -id = 8 diff --git a/game/src/main/kotlin/content/activity/event/random/certer/Certer.kt b/game/src/main/kotlin/content/activity/event/random/certer/Certer.kt index 9f4816c79c..ae9b336a43 100644 --- a/game/src/main/kotlin/content/activity/event/random/certer/Certer.kt +++ b/game/src/main/kotlin/content/activity/event/random/certer/Certer.kt @@ -5,21 +5,20 @@ import content.activity.event.random.rewardCerterLoot import content.activity.event.random.startInPlaceEvent import content.entity.player.dialogue.Happy import content.entity.player.dialogue.Neutral +import content.entity.player.dialogue.type.choice +import content.entity.player.dialogue.type.item 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.client.ui.close -import world.gregs.voidps.engine.client.ui.open 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.Item import world.gregs.voidps.type.random /** * Certer random event: one of the certificate brothers appears beside the player and nags until - * spoken to. He shows an item and three descriptions (interface 184); picking the description that - * matches the item earns a roll of the gem/coin reward table, a wrong pick earns nothing. Ignoring - * him applies the note-and-teleport penalty. + * spoken to. He shows an item and asks the player to identify it from three descriptions; the right + * answer earns a roll of the gem/coin reward table, a wrong one earns nothing. Ignoring him applies + * the note-and-teleport penalty. * https://runescape.wiki/w/Random_events?oldid=3667851#Certer */ class Certer : Script { @@ -32,20 +31,7 @@ class Certer : Script { message("They aren't interested in talking to you.") return@npcOperate } - npc("giles", "Ah, hello, $name. Could you please help me identify this?") - openPuzzle() - } - - interfaceOption("Select", "certer_identify:option_*") { - val correct = it.component == "option_${get("certer_answer", 0)}" - close("certer_identify") - if (correct) { - npc("giles", "Thank you, I hope you like your present. I must be leaving now though.") - rewardCerterLoot() - } else { - npc("giles", "Sorry, I don't think so.") - } - RandomEvents.completeInPlace(this) + identify() } } @@ -54,16 +40,20 @@ class Certer : Script { giles.anim("emote_bow") } - private fun Player.openPuzzle() { + private suspend fun Player.identify() { val (item, description) = ITEMS.entries.random(random) - val answer = (1..3).random(random) - set("certer_answer", answer) - val falses = FALSE_OPTIONS.shuffled(random).iterator() - open("certer_identify") - for (option in 1..3) { - interfaces.sendText("certer_identify", "option_$option", if (option == answer) description else falses.next()) + val options = (FALSE_OPTIONS.shuffled(random).take(2) + description).shuffled(random) + set("certer_answer", options.indexOf(description) + 1) + npc("giles", "Ah, hello, $name. Could you please help me identify this?") + item(item, "Take a good look. What would you say this item is?") + val pick = choice(options, "What is it?") + if (pick == get("certer_answer", 0)) { + npc("giles", "Thank you, I hope you like your present. I must be leaving now though.") + rewardCerterLoot() + } else { + npc("giles", "Sorry, I don't think so.") } - interfaces.sendItem("certer_identify", "item", Item(item)) + RandomEvents.completeInPlace(this) } private fun Player.nagLines() = listOf( diff --git a/game/src/test/kotlin/content/activity/event/random/CerterTest.kt b/game/src/test/kotlin/content/activity/event/random/CerterTest.kt index ec19b6b589..bf95d9b8f5 100644 --- a/game/src/test/kotlin/content/activity/event/random/CerterTest.kt +++ b/game/src/test/kotlin/content/activity/event/random/CerterTest.kt @@ -1,7 +1,7 @@ package content.activity.event.random import WorldTest -import interfaceOption +import dialogueOption import npcOption import org.junit.jupiter.api.Test import skipDialogues @@ -30,7 +30,7 @@ class CerterTest : WorldTest() { private fun openPuzzle(player: Player, giles: NPC) { player.npcOption(giles, "Talk-to") tick() - player.skipDialogues() // "Ah, hello... identify this?" -> opens the interface + player.skipDialogues() // greeting + item box -> stops on the choice menu tick() } @@ -50,9 +50,8 @@ class CerterTest : WorldTest() { val (player, giles) = setup("certer_correct") openPuzzle(player, giles) - assertTrue(player.interfaces.contains("certer_identify")) val answer = player.get("certer_answer", 0) - player.interfaceOption("certer_identify", "option_$answer", "Select") + player.dialogueOption(answer) tick() player.skipDialogues() // "Thank you, I hope you like your present..." tick() @@ -69,7 +68,7 @@ class CerterTest : WorldTest() { val answer = player.get("certer_answer", 0) val wrong = if (answer == 1) 2 else 1 - player.interfaceOption("certer_identify", "option_$wrong", "Select") + player.dialogueOption(wrong) tick() player.skipDialogues() // "Sorry, I don't think so." tick() From c5b9e99500b1faadc7d91edc4abdc60855929c0d Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Wed, 8 Jul 2026 07:02:01 -0700 Subject: [PATCH 18/89] Use interface 184 for Certer with the real 634 layout Decoded interface 184's full component types: it has a text prompt (comp 1) and three item-model options (comps 4-6, not text), so the 634 version reads out a description and the player picks the matching item model - the reverse of the older cache. The certer now sends the description to the prompt and three item models to the options. --- .../event/random/certer/certer.ifaces.toml | 16 ++++ .../activity/event/random/certer/Certer.kt | 79 ++++++++++--------- .../activity/event/random/CerterTest.kt | 9 ++- 3 files changed, 61 insertions(+), 43 deletions(-) create mode 100644 data/activity/event/random/certer/certer.ifaces.toml diff --git a/data/activity/event/random/certer/certer.ifaces.toml b/data/activity/event/random/certer/certer.ifaces.toml new file mode 100644 index 0000000000..a81527c916 --- /dev/null +++ b/data/activity/event/random/certer/certer.ifaces.toml @@ -0,0 +1,16 @@ +# Certer's "identify" interface (184): a text prompt describing an item and three item models to +# pick from - the player selects the model that matches the description. +[certer_identify] +id = 184 + +[.prompt] +id = 1 + +[.option_1] +id = 4 + +[.option_2] +id = 5 + +[.option_3] +id = 6 diff --git a/game/src/main/kotlin/content/activity/event/random/certer/Certer.kt b/game/src/main/kotlin/content/activity/event/random/certer/Certer.kt index ae9b336a43..46292cb932 100644 --- a/game/src/main/kotlin/content/activity/event/random/certer/Certer.kt +++ b/game/src/main/kotlin/content/activity/event/random/certer/Certer.kt @@ -5,20 +5,21 @@ import content.activity.event.random.rewardCerterLoot import content.activity.event.random.startInPlaceEvent import content.entity.player.dialogue.Happy import content.entity.player.dialogue.Neutral -import content.entity.player.dialogue.type.choice -import content.entity.player.dialogue.type.item 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.client.ui.close +import world.gregs.voidps.engine.client.ui.open 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.Item import world.gregs.voidps.type.random /** * Certer random event: one of the certificate brothers appears beside the player and nags until - * spoken to. He shows an item and asks the player to identify it from three descriptions; the right - * answer earns a roll of the gem/coin reward table, a wrong one earns nothing. Ignoring him applies - * the note-and-teleport penalty. + * spoken to. He describes an item and shows three item models on interface 184; picking the model + * that matches his description earns a roll of the gem/coin reward table, a wrong pick earns nothing. + * Ignoring him applies the note-and-teleport penalty. * https://runescape.wiki/w/Random_events?oldid=3667851#Certer */ class Certer : Script { @@ -31,7 +32,20 @@ class Certer : Script { message("They aren't interested in talking to you.") return@npcOperate } - identify() + npc("giles", "Ah, hello, $name. Could you please help me identify this?") + openPuzzle() + } + + interfaceOption("Select", "certer_identify:option_*") { + val correct = it.component == "option_${get("certer_answer", 0)}" + close("certer_identify") + if (correct) { + npc("giles", "Thank you, I hope you like your present. I must be leaving now though.") + rewardCerterLoot() + } else { + npc("giles", "Sorry, I don't think so.") + } + RandomEvents.completeInPlace(this) } } @@ -40,20 +54,15 @@ class Certer : Script { giles.anim("emote_bow") } - private suspend fun Player.identify() { - val (item, description) = ITEMS.entries.random(random) - val options = (FALSE_OPTIONS.shuffled(random).take(2) + description).shuffled(random) - set("certer_answer", options.indexOf(description) + 1) - npc("giles", "Ah, hello, $name. Could you please help me identify this?") - item(item, "Take a good look. What would you say this item is?") - val pick = choice(options, "What is it?") - if (pick == get("certer_answer", 0)) { - npc("giles", "Thank you, I hope you like your present. I must be leaving now though.") - rewardCerterLoot() - } else { - npc("giles", "Sorry, I don't think so.") + private fun Player.openPuzzle() { + val options = ITEMS.entries.shuffled(random).take(3) + val answer = options.random(random) + set("certer_answer", options.indexOf(answer) + 1) + open("certer_identify") + interfaces.sendText("certer_identify", "prompt", "Which of these is ${answer.value}?") + for ((index, option) in options.withIndex()) { + interfaces.sendItem("certer_identify", "option_${index + 1}", Item(option.key)) } - RandomEvents.completeInPlace(this) } private fun Player.nagLines() = listOf( @@ -65,27 +74,19 @@ class Certer : Script { ) companion object { + // item id -> the description the certer reads out. private val ITEMS = mapOf( - "bowl" to "A bowl.", - "raw_shrimps" to "A fish.", - "raw_bass" to "A bass.", - "bronze_sword" to "A sword.", - "bronze_battleaxe" to "A battleaxe.", - "bronze_med_helm" to "A helmet.", - "bronze_kiteshield" to "A kiteshield.", - "shears" to "A pair of shears.", - "spade" to "A shovel.", - "gold_ring" to "A ring.", - "gold_necklace" to "A necklace.", - ) - private val FALSE_OPTIONS = listOf( - "An axe.", - "An arrow.", - "A pair of boots.", - "A pair of gloves.", - "A staff.", - "A bow.", - "A feather.", + "bowl" to "a bowl", + "raw_shrimps" to "a fish", + "raw_bass" to "a bass", + "bronze_sword" to "a sword", + "bronze_battleaxe" to "a battleaxe", + "bronze_med_helm" to "a helmet", + "bronze_kiteshield" to "a kiteshield", + "shears" to "a pair of shears", + "spade" to "a shovel", + "gold_ring" to "a ring", + "gold_necklace" to "a necklace", ) } } diff --git a/game/src/test/kotlin/content/activity/event/random/CerterTest.kt b/game/src/test/kotlin/content/activity/event/random/CerterTest.kt index bf95d9b8f5..ec19b6b589 100644 --- a/game/src/test/kotlin/content/activity/event/random/CerterTest.kt +++ b/game/src/test/kotlin/content/activity/event/random/CerterTest.kt @@ -1,7 +1,7 @@ package content.activity.event.random import WorldTest -import dialogueOption +import interfaceOption import npcOption import org.junit.jupiter.api.Test import skipDialogues @@ -30,7 +30,7 @@ class CerterTest : WorldTest() { private fun openPuzzle(player: Player, giles: NPC) { player.npcOption(giles, "Talk-to") tick() - player.skipDialogues() // greeting + item box -> stops on the choice menu + player.skipDialogues() // "Ah, hello... identify this?" -> opens the interface tick() } @@ -50,8 +50,9 @@ class CerterTest : WorldTest() { val (player, giles) = setup("certer_correct") openPuzzle(player, giles) + assertTrue(player.interfaces.contains("certer_identify")) val answer = player.get("certer_answer", 0) - player.dialogueOption(answer) + player.interfaceOption("certer_identify", "option_$answer", "Select") tick() player.skipDialogues() // "Thank you, I hope you like your present..." tick() @@ -68,7 +69,7 @@ class CerterTest : WorldTest() { val answer = player.get("certer_answer", 0) val wrong = if (answer == 1) 2 else 1 - player.dialogueOption(wrong) + player.interfaceOption("certer_identify", "option_$wrong", "Select") tick() player.skipDialogues() // "Sorry, I don't think so." tick() From 0c6bab3c150f2d8c4964e36cdb5ce4e27d43f3d3 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Wed, 8 Jul 2026 07:07:30 -0700 Subject: [PATCH 19/89] Render Certer option items with sendModel The type-6 model option components don't render items sent via INTERFACE_ITEM, so send each item's model id directly. The runtime item definition doesn't expose model ids, so the eleven pool items' models (decoded from the cache) are listed alongside their descriptions. --- .../activity/event/random/certer/Certer.kt | 35 ++++++++++--------- 1 file changed, 18 insertions(+), 17 deletions(-) diff --git a/game/src/main/kotlin/content/activity/event/random/certer/Certer.kt b/game/src/main/kotlin/content/activity/event/random/certer/Certer.kt index 46292cb932..6286782b34 100644 --- a/game/src/main/kotlin/content/activity/event/random/certer/Certer.kt +++ b/game/src/main/kotlin/content/activity/event/random/certer/Certer.kt @@ -12,7 +12,6 @@ import world.gregs.voidps.engine.client.ui.close import world.gregs.voidps.engine.client.ui.open 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.Item import world.gregs.voidps.type.random /** @@ -55,13 +54,13 @@ class Certer : Script { } private fun Player.openPuzzle() { - val options = ITEMS.entries.shuffled(random).take(3) + val options = ITEMS.shuffled(random).take(3) val answer = options.random(random) set("certer_answer", options.indexOf(answer) + 1) open("certer_identify") - interfaces.sendText("certer_identify", "prompt", "Which of these is ${answer.value}?") + interfaces.sendText("certer_identify", "prompt", "Which of these is ${answer.description}?") for ((index, option) in options.withIndex()) { - interfaces.sendItem("certer_identify", "option_${index + 1}", Item(option.key)) + interfaces.sendModel("certer_identify", "option_${index + 1}", option.model) } } @@ -73,20 +72,22 @@ class Certer : Script { "No-one ignores me!", ) + private data class Candidate(val description: String, val model: Int) + companion object { - // item id -> the description the certer reads out. - private val ITEMS = mapOf( - "bowl" to "a bowl", - "raw_shrimps" to "a fish", - "raw_bass" to "a bass", - "bronze_sword" to "a sword", - "bronze_battleaxe" to "a battleaxe", - "bronze_med_helm" to "a helmet", - "bronze_kiteshield" to "a kiteshield", - "shears" to "a pair of shears", - "spade" to "a shovel", - "gold_ring" to "a ring", - "gold_necklace" to "a necklace", + // The description the certer reads out and the item model shown as an option. + private val ITEMS = listOf( + Candidate("a bowl", 2807), + Candidate("a fish", 2590), + Candidate("a bass", 2355), + Candidate("a sword", 2604), + Candidate("a battleaxe", 2778), + Candidate("a helmet", 2833), + Candidate("a kiteshield", 2339), + Candidate("a pair of shears", 2620), + Candidate("a shovel", 7304), + Candidate("a ring", 2784), + Candidate("a necklace", 2506), ) } } From 5004418d622bcb0d1ba36cbfe53fa523cd815fe6 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Wed, 8 Jul 2026 07:19:30 -0700 Subject: [PATCH 20/89] Fix Certer interface: item model in the box, text on the options The item to identify is a separate model component (comp 0, the box) and the three A/B/C Select components hold the description text. Send the item's model to the box and the three descriptions (one correct, two false) to the options; the player picks the description matching the shown item. --- .../event/random/certer/certer.ifaces.toml | 8 ++-- .../activity/event/random/certer/Certer.kt | 43 +++++++++++-------- 2 files changed, 30 insertions(+), 21 deletions(-) diff --git a/data/activity/event/random/certer/certer.ifaces.toml b/data/activity/event/random/certer/certer.ifaces.toml index a81527c916..1e75a1887d 100644 --- a/data/activity/event/random/certer/certer.ifaces.toml +++ b/data/activity/event/random/certer/certer.ifaces.toml @@ -1,10 +1,10 @@ -# Certer's "identify" interface (184): a text prompt describing an item and three item models to -# pick from - the player selects the model that matches the description. +# Certer's "identify" interface (184): the item to identify shows as a model (comp 0) and the three +# Select options (comps 4-6) hold the text descriptions - the player picks the matching description. [certer_identify] id = 184 -[.prompt] -id = 1 +[.item] +id = 0 [.option_1] id = 4 diff --git a/game/src/main/kotlin/content/activity/event/random/certer/Certer.kt b/game/src/main/kotlin/content/activity/event/random/certer/Certer.kt index 6286782b34..71379c9b0b 100644 --- a/game/src/main/kotlin/content/activity/event/random/certer/Certer.kt +++ b/game/src/main/kotlin/content/activity/event/random/certer/Certer.kt @@ -54,13 +54,13 @@ class Certer : Script { } private fun Player.openPuzzle() { - val options = ITEMS.shuffled(random).take(3) - val answer = options.random(random) - set("certer_answer", options.indexOf(answer) + 1) + val answer = ITEMS.random(random) + val options = (FALSE_OPTIONS.shuffled(random).take(2) + answer.description).shuffled(random) + set("certer_answer", options.indexOf(answer.description) + 1) open("certer_identify") - interfaces.sendText("certer_identify", "prompt", "Which of these is ${answer.description}?") + interfaces.sendModel("certer_identify", "item", answer.model) // the item to identify for ((index, option) in options.withIndex()) { - interfaces.sendModel("certer_identify", "option_${index + 1}", option.model) + interfaces.sendText("certer_identify", "option_${index + 1}", option) } } @@ -75,19 +75,28 @@ class Certer : Script { private data class Candidate(val description: String, val model: Int) companion object { - // The description the certer reads out and the item model shown as an option. + // The item model shown in the box and the matching description shown as an option. private val ITEMS = listOf( - Candidate("a bowl", 2807), - Candidate("a fish", 2590), - Candidate("a bass", 2355), - Candidate("a sword", 2604), - Candidate("a battleaxe", 2778), - Candidate("a helmet", 2833), - Candidate("a kiteshield", 2339), - Candidate("a pair of shears", 2620), - Candidate("a shovel", 7304), - Candidate("a ring", 2784), - Candidate("a necklace", 2506), + Candidate("A bowl?", 2807), + Candidate("A fish?", 2590), + Candidate("A bass?", 2355), + Candidate("A sword?", 2604), + Candidate("A battleaxe?", 2778), + Candidate("A helmet?", 2833), + Candidate("A kiteshield?", 2339), + Candidate("A pair of shears?", 2620), + Candidate("A shovel?", 7304), + Candidate("A ring?", 2784), + Candidate("A necklace?", 2506), + ) + private val FALSE_OPTIONS = listOf( + "An axe?", + "An arrow?", + "A pair of boots?", + "A pair of gloves?", + "A staff?", + "A bow?", + "A feather?", ) } } From 9031e21872d165ea518c727cc94db6221578475d Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Wed, 8 Jul 2026 08:27:52 -0700 Subject: [PATCH 21/89] Drive Certer's interface 184 via its varbits Interface 184 builds itself from a load script (cs2 2136): it reads the three option descriptions from enum 2224 and the item model from enum 2225, all selected by varbits (5855-5863). So instead of pushing text and models (which the script overwrites), the event now sets those varbits - three description keys, the model key for the answer ((desc+2)%9 in enum 2225), and rotation/scale - then opens the interface. The dynamically created option rows declare their Select action so clicks validate. Removes the temporary interface-click debug log. --- .../event/random/certer/certer.ifaces.toml | 17 +++--- .../event/random/certer/certer.varbits.toml | 30 ++++++++++ .../activity/event/random/certer/Certer.kt | 56 +++++++------------ 3 files changed, 60 insertions(+), 43 deletions(-) create mode 100644 data/activity/event/random/certer/certer.varbits.toml diff --git a/data/activity/event/random/certer/certer.ifaces.toml b/data/activity/event/random/certer/certer.ifaces.toml index 1e75a1887d..0fc2c542be 100644 --- a/data/activity/event/random/certer/certer.ifaces.toml +++ b/data/activity/event/random/certer/certer.ifaces.toml @@ -1,16 +1,17 @@ -# Certer's "identify" interface (184): the item to identify shows as a model (comp 0) and the three -# Select options (comps 4-6) hold the text descriptions - the player picks the matching description. +# Certer's identify interface (184). Its load script builds the three option rows (children 0-2) +# and the item model from varbits, so we set those and open it. The option rows are created +# dynamically with a "Select" action, declared here so clicks on them validate. [certer_identify] id = 184 -[.item] -id = 0 - [.option_1] -id = 4 +id = 0 +options = { Select = 0 } [.option_2] -id = 5 +id = 1 +options = { Select = 0 } [.option_3] -id = 6 +id = 2 +options = { Select = 0 } diff --git a/data/activity/event/random/certer/certer.varbits.toml b/data/activity/event/random/certer/certer.varbits.toml new file mode 100644 index 0000000000..67d66d7d7f --- /dev/null +++ b/data/activity/event/random/certer/certer.varbits.toml @@ -0,0 +1,30 @@ +# Read by interface 184's load script (cs2 2136). +# The three option descriptions (keys into enum 2224): +[certer_desc_1] +id = 5856 + +[certer_desc_2] +id = 5858 + +[certer_desc_3] +id = 5860 + +# The item model: enum selector (2225-2228) + key into it. +[certer_model_enum] +id = 5857 + +[certer_model_key] +id = 5862 + +# Model display: rotation, scale and spin. +[certer_rotation_x] +id = 5855 + +[certer_rotation_y] +id = 5859 + +[certer_scale] +id = 5861 + +[certer_spin] +id = 5863 diff --git a/game/src/main/kotlin/content/activity/event/random/certer/Certer.kt b/game/src/main/kotlin/content/activity/event/random/certer/Certer.kt index 71379c9b0b..1b9da4013b 100644 --- a/game/src/main/kotlin/content/activity/event/random/certer/Certer.kt +++ b/game/src/main/kotlin/content/activity/event/random/certer/Certer.kt @@ -16,9 +16,12 @@ import world.gregs.voidps.type.random /** * Certer random event: one of the certificate brothers appears beside the player and nags until - * spoken to. He describes an item and shows three item models on interface 184; picking the model - * that matches his description earns a roll of the gem/coin reward table, a wrong pick earns nothing. + * spoken to. He shows an item (interface 184) and three descriptions to choose from; picking the + * one that matches the item earns a roll of the gem/coin reward table, a wrong pick earns nothing. * Ignoring him applies the note-and-teleport penalty. + * + * The interface builds itself from varbits: its load script reads the three option descriptions + * (keys into enum 2224) and the item model (a key into enum 2225) - so we just set those. * https://runescape.wiki/w/Random_events?oldid=3667851#Certer */ class Certer : Script { @@ -36,7 +39,7 @@ class Certer : Script { } interfaceOption("Select", "certer_identify:option_*") { - val correct = it.component == "option_${get("certer_answer", 0)}" + val correct = it.component.removePrefix("option_").toInt() == get("certer_answer", 0) close("certer_identify") if (correct) { npc("giles", "Thank you, I hope you like your present. I must be leaving now though.") @@ -54,14 +57,21 @@ class Certer : Script { } private fun Player.openPuzzle() { - val answer = ITEMS.random(random) - val options = (FALSE_OPTIONS.shuffled(random).take(2) + answer.description).shuffled(random) - set("certer_answer", options.indexOf(answer.description) + 1) + val answer = (0 until ITEMS).random(random) + val options = ((0 until ITEMS).filter { it != answer }.shuffled(random).take(2) + answer).shuffled(random) + set("certer_answer", options.indexOf(answer) + 1) + set("certer_desc_1", options[0]) + set("certer_desc_2", options[1]) + set("certer_desc_3", options[2]) + // Show the model matching the answer's description (enum 2225: model key = (desc + 2) % 9). + set("certer_model_enum", 0) + set("certer_model_key", (answer + 2) % ITEMS) + // Present the model upright at a sensible size. + set("certer_rotation_x", 5) + set("certer_rotation_y", 4) + set("certer_scale", 10) + set("certer_spin", 0) open("certer_identify") - interfaces.sendModel("certer_identify", "item", answer.model) // the item to identify - for ((index, option) in options.withIndex()) { - interfaces.sendText("certer_identify", "option_${index + 1}", option) - } } private fun Player.nagLines() = listOf( @@ -72,31 +82,7 @@ class Certer : Script { "No-one ignores me!", ) - private data class Candidate(val description: String, val model: Int) - companion object { - // The item model shown in the box and the matching description shown as an option. - private val ITEMS = listOf( - Candidate("A bowl?", 2807), - Candidate("A fish?", 2590), - Candidate("A bass?", 2355), - Candidate("A sword?", 2604), - Candidate("A battleaxe?", 2778), - Candidate("A helmet?", 2833), - Candidate("A kiteshield?", 2339), - Candidate("A pair of shears?", 2620), - Candidate("A shovel?", 7304), - Candidate("A ring?", 2784), - Candidate("A necklace?", 2506), - ) - private val FALSE_OPTIONS = listOf( - "An axe?", - "An arrow?", - "A pair of boots?", - "A pair of gloves?", - "A staff?", - "A bow?", - "A feather?", - ) + private const val ITEMS = 9 // enum 2224 holds nine item descriptions (keys 0-8) } } From 82a3ff95deed7327c9cc5f8abed0530ad0582042 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Wed, 8 Jul 2026 08:44:37 -0700 Subject: [PATCH 22/89] Point Certer's option clicks at the right components The load script's clickable option rows are components 4-6, not 0-2, so map option_1/2/3 to them; clicks now register and the matching answer rewards. --- data/activity/event/random/certer/certer.ifaces.toml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/data/activity/event/random/certer/certer.ifaces.toml b/data/activity/event/random/certer/certer.ifaces.toml index 0fc2c542be..345098c8fd 100644 --- a/data/activity/event/random/certer/certer.ifaces.toml +++ b/data/activity/event/random/certer/certer.ifaces.toml @@ -1,17 +1,17 @@ -# Certer's identify interface (184). Its load script builds the three option rows (children 0-2) -# and the item model from varbits, so we set those and open it. The option rows are created -# dynamically with a "Select" action, declared here so clicks on them validate. +# Certer's identify interface (184). Its load script builds the three option rows and the item +# model from varbits, so we set those and open it. The clickable option rows are components 4-6 +# (A/B/C, top to bottom); their "Select" action is declared here so clicks validate. [certer_identify] id = 184 [.option_1] -id = 0 +id = 4 options = { Select = 0 } [.option_2] -id = 1 +id = 5 options = { Select = 0 } [.option_3] -id = 2 +id = 6 options = { Select = 0 } From 89831ff1502da8352e490107142a1133cca0937a Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Wed, 8 Jul 2026 09:12:51 -0700 Subject: [PATCH 23/89] Add Mime random event The player is whisked to a stage where the mime performs emotes to copy. A per-player mime spawns in an instance and repeats the target emote on a timer; the event hooks the emote tab (alongside its own handler) to check the copy. Four correct emotes in a row unlock the mime emotes (glass wall/box, climb rope, lean) and award a mime costume piece, or coins if the set is complete. No 2009scape/Darkan reference - built from the wiki. Ref GregHib/void-private#258 --- .../mime_theatre/mime_theatre.vars.toml | 7 ++ .../event/random/random_event.tables.toml | 4 + .../event/random/mime_theatre/Mime.kt | 104 ++++++++++++++++++ .../content/activity/event/random/MimeTest.kt | 99 +++++++++++++++++ 4 files changed, 214 insertions(+) create mode 100644 data/activity/event/random/mime_theatre/mime_theatre.vars.toml create mode 100644 game/src/main/kotlin/content/activity/event/random/mime_theatre/Mime.kt create mode 100644 game/src/test/kotlin/content/activity/event/random/MimeTest.kt diff --git a/data/activity/event/random/mime_theatre/mime_theatre.vars.toml b/data/activity/event/random/mime_theatre/mime_theatre.vars.toml new file mode 100644 index 0000000000..96f333ef9f --- /dev/null +++ b/data/activity/event/random/mime_theatre/mime_theatre.vars.toml @@ -0,0 +1,7 @@ +[mime_emote] +format = "string" +persist = true + +[mime_correct] +format = "int" +persist = true diff --git a/data/activity/event/random/random_event.tables.toml b/data/activity/event/random/random_event.tables.toml index abc40ffa61..5f3184644a 100644 --- a/data/activity/event/random/random_event.tables.toml +++ b/data/activity/event/random/random_event.tables.toml @@ -22,6 +22,10 @@ weight = 10 event = "certer" weight = 10 +[.mime] +event = "mime" +weight = 10 + [random_event_settings] value = "int" diff --git a/game/src/main/kotlin/content/activity/event/random/mime_theatre/Mime.kt b/game/src/main/kotlin/content/activity/event/random/mime_theatre/Mime.kt new file mode 100644 index 0000000000..ea86c3cee0 --- /dev/null +++ b/game/src/main/kotlin/content/activity/event/random/mime_theatre/Mime.kt @@ -0,0 +1,104 @@ +package content.activity.event.random.mime_theatre + +import content.activity.event.random.RandomEvents +import content.activity.event.random.kidnap +import content.activity.event.random.mysteriousOldMan +import content.activity.event.random.rewardCostumeOrCoins +import content.quest.instanceOffset +import content.quest.setInstanceLogout +import content.quest.smallInstance +import net.pearx.kasechange.toSnakeCase +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.client.message +import world.gregs.voidps.engine.entity.character.npc.NPC +import world.gregs.voidps.engine.entity.character.npc.NPCs +import world.gregs.voidps.engine.entity.character.player.Player +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 + +/** + * Mime random event: the player is whisked to a stage where the mime performs emotes for them to + * copy. Copying four in a row unlocks the mime emotes (glass wall/box, climb rope, lean) and awards + * a piece of the mime costume (or coins if the set is complete). + * https://runescape.wiki/w/Random_events?oldid=3667851#Mime + */ +class Mime : Script { + + init { + RandomEvents.register("mime") { startEvent() } + + npcTimerStart(PERFORM_TIMER) { PERFORM_INTERVAL } + npcTimerTick(PERFORM_TIMER) { + val owner = owner + if (owner == null || owner.get("random_event") != "mime") { + NPCs.remove(this) + return@npcTimerTick Timer.CANCEL + } + owner.get("mime_emote")?.let { anim("emote_$it") } + Timer.CONTINUE + } + + // Runs alongside the emote tab's own handler, checking whether the copied emote matches. + interfaceOption(id = "emotes:*") { + if (get("random_event") != "mime") { + return@interfaceOption + } + copyEmote(it.option.toSnakeCase()) + } + } + + private suspend fun Player.startEvent() { + smallInstance(Region(MIME_REGION), levels = 1) + setInstanceLogout(Tile(this["random_event_origin", tile.id])) + mysteriousOldMan() + kidnap(STAGE.add(instanceOffset())) + val mime: NPC = NPCs.add("mime", MIME_TILE.add(instanceOffset()), ticks = -1, owner = this) + mime.watch(this) + mime.softTimers.start(PERFORM_TIMER) + set("mime_correct", 0) + nextEmote() + face(mime.tile) + message("Copy the mime's actions to earn your reward.") + } + + private fun Player.nextEmote() { + set("mime_emote", EMOTES.random(random)) + } + + private suspend fun Player.copyEmote(emote: String) { + if (emote != get("mime_emote")) { + return // wrong emote earns no credit + } + if (inc("mime_correct") >= REQUIRED) { + finish() + } else { + nextEmote() + } + } + + private suspend fun Player.finish() { + for (unlock in MIME_EMOTES) { + set("unlocked_emote_$unlock", true) + } + rewardCostumeOrCoins("mime_mask", "mime_top", "mime_legs", "mime_gloves", "mime_boots", coins = 500) + clear("mime_emote") + clear("mime_correct") + RandomEvents.complete(this) + } + + companion object { + private const val REQUIRED = 4 + private const val PERFORM_TIMER = "mime_perform" + private const val PERFORM_INTERVAL = 5 + private const val MIME_REGION = 8010 + private val STAGE = Tile(2011, 4759) + private val MIME_TILE = Tile(2011, 4762) + + private val EMOTES = listOf( + "think", "cry", "laugh", "dance", "cheer", "clap", "wave", "bow", "angry", "jump_for_joy", + ) + private val MIME_EMOTES = listOf("glass_wall", "glass_box", "climb_rope", "lean") + } +} diff --git a/game/src/test/kotlin/content/activity/event/random/MimeTest.kt b/game/src/test/kotlin/content/activity/event/random/MimeTest.kt new file mode 100644 index 0000000000..ed0c6c9165 --- /dev/null +++ b/game/src/test/kotlin/content/activity/event/random/MimeTest.kt @@ -0,0 +1,99 @@ +package content.activity.event.random + +import WorldTest +import content.quest.instance +import content.quest.instanceOffset +import org.junit.jupiter.api.Test +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.client.ui.InterfaceApi +import world.gregs.voidps.engine.client.ui.InterfaceOption +import world.gregs.voidps.engine.entity.character.npc.NPCs +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.entity.item.Item +import world.gregs.voidps.engine.inv.add +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.type.Tile +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class MimeTest : WorldTest() { + + private val stage = Tile(2011, 4759) + + private fun Player.performEmote(emote: String) { + val player = this + Script.launch { InterfaceApi.option(player, InterfaceOption(Item.EMPTY, -1, emote, 0, "emotes:$emote")) } + tick(6) // let the emote animation + copy check resolve + } + + private fun enter(name: String, origin: Tile = Tile(3221, 3218)): Player { + val player = createPlayer(stage, name) + player["random_event"] = "mime" + player["random_event_origin"] = origin.id + player["mime_correct"] = 0 + player["mime_emote"] = "think" + return player + } + + @Test + fun `Event kidnaps the player to the stage and assigns an emote`() { + val player = createPlayer(Tile(3221, 3218), "mime_start") + RandomEvents.start(player, "mime") + tick(12) + + assertEquals("mime", player.get("random_event")) + assertNotNull(player.instance()) + assertTrue(player.get("mime_emote") != null) + val off = player.instanceOffset() + assertNotNull(NPCs.firstOrNull(Tile(2011, 4762).add(off.x, off.y)) { it.id == "mime" }) + } + + @Test + fun `Copying the mime's emote counts, a wrong one does not`() { + val player = enter("mime_copy") + + player.performEmote("dance") // not the assigned "think" + tick() + assertEquals(0, player.get("mime_correct", 0)) + + player.performEmote("think") + tick() + assertEquals(1, player.get("mime_correct", 0)) + } + + @Test + fun `Four correct emotes unlock the mime emotes, reward a costume and return the player`() { + val origin = Tile(3221, 3218) + val player = enter("mime_finish", origin) + + repeat(4) { + player.performEmote(player.get("mime_emote")!!) + tick() + } + tick(2) + + assertTrue(player["unlocked_emote_glass_wall", false]) + assertTrue(player["unlocked_emote_lean", false]) + assertEquals(1, player.inventory.count("mime_mask")) + assertNull(player.get("random_event")) + assertEquals(origin, player.tile) + } + + @Test + fun `Owning the full mime costume rewards coins`() { + val player = enter("mime_coins") + for (piece in listOf("mime_mask", "mime_top", "mime_legs", "mime_gloves", "mime_boots")) { + player.inventory.add(piece) + } + + repeat(4) { + player.performEmote(player.get("mime_emote")!!) + tick() + } + tick(2) + + assertEquals(500, player.inventory.count("coins")) + } +} From f0af1740e02304fccf5e16f3bf6bbc63033bf4b4 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Wed, 8 Jul 2026 09:16:54 -0700 Subject: [PATCH 24/89] Teleport the mime event player to 2008, 4762 --- .../kotlin/content/activity/event/random/mime_theatre/Mime.kt | 2 +- game/src/test/kotlin/content/activity/event/random/MimeTest.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/game/src/main/kotlin/content/activity/event/random/mime_theatre/Mime.kt b/game/src/main/kotlin/content/activity/event/random/mime_theatre/Mime.kt index ea86c3cee0..257ecbb181 100644 --- a/game/src/main/kotlin/content/activity/event/random/mime_theatre/Mime.kt +++ b/game/src/main/kotlin/content/activity/event/random/mime_theatre/Mime.kt @@ -93,7 +93,7 @@ class Mime : Script { private const val PERFORM_TIMER = "mime_perform" private const val PERFORM_INTERVAL = 5 private const val MIME_REGION = 8010 - private val STAGE = Tile(2011, 4759) + private val STAGE = Tile(2008, 4762) private val MIME_TILE = Tile(2011, 4762) private val EMOTES = listOf( diff --git a/game/src/test/kotlin/content/activity/event/random/MimeTest.kt b/game/src/test/kotlin/content/activity/event/random/MimeTest.kt index ed0c6c9165..b3f5b509f5 100644 --- a/game/src/test/kotlin/content/activity/event/random/MimeTest.kt +++ b/game/src/test/kotlin/content/activity/event/random/MimeTest.kt @@ -20,7 +20,7 @@ import kotlin.test.assertTrue class MimeTest : WorldTest() { - private val stage = Tile(2011, 4759) + private val stage = Tile(2008, 4762) private fun Player.performEmote(emote: String) { val player = this From 5fcd24a01f4ba1c9da617d6062fc236329d17b1a Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Wed, 8 Jul 2026 10:19:34 -0700 Subject: [PATCH 25/89] Add Evil Bob random event An evil incarnation of Bob the cat teleports the player to the Scape2009 island and demands fish. A terrified servant pans the camera to the fishing spot Bob likes; the player nets there (the fish comes out already cooked), uncooks it at the cold fire, and serves the raw fish to Bob. The right fish sends him to sleep and opens the exit portal for a 650 Fishing or Magic XP reward; a wrong one makes Bob more attentive so an extra correct fish is needed. Ported from 2009scape (no Darkan version). Ref GregHib/void-private#262 --- .../event/random/evil_bob/evil_bob.npcs.toml | 11 + .../event/random/evil_bob/evil_bob.objs.toml | 11 + .../event/random/evil_bob/evil_bob.vars.toml | 26 ++ .../event/random/random_event.tables.toml | 4 + .../activity/event/random/evil_bob/EvilBob.kt | 367 ++++++++++++++++++ .../activity/event/random/EvilBobTest.kt | 157 ++++++++ 6 files changed, 576 insertions(+) create mode 100644 data/activity/event/random/evil_bob/evil_bob.npcs.toml create mode 100644 data/activity/event/random/evil_bob/evil_bob.objs.toml create mode 100644 data/activity/event/random/evil_bob/evil_bob.vars.toml create mode 100644 game/src/main/kotlin/content/activity/event/random/evil_bob/EvilBob.kt create mode 100644 game/src/test/kotlin/content/activity/event/random/EvilBobTest.kt diff --git a/data/activity/event/random/evil_bob/evil_bob.npcs.toml b/data/activity/event/random/evil_bob/evil_bob.npcs.toml new file mode 100644 index 0000000000..277addbc46 --- /dev/null +++ b/data/activity/event/random/evil_bob/evil_bob.npcs.toml @@ -0,0 +1,11 @@ +[evil_bob_cat] +id = 2478 +examine = "It's a cat with an evil glint in its eye." + +[evil_bob] +id = 2479 +examine = "An evil incarnation of Bob the cat." + +[evil_bob_servant] +id = 2481 +examine = "A terrified human servant." diff --git a/data/activity/event/random/evil_bob/evil_bob.objs.toml b/data/activity/event/random/evil_bob/evil_bob.objs.toml new file mode 100644 index 0000000000..572950fd4c --- /dev/null +++ b/data/activity/event/random/evil_bob/evil_bob.objs.toml @@ -0,0 +1,11 @@ +[evil_bob_fishing_spot] +id = 8986 +examine = "You could catch a fish-like thing here." + +[evil_bob_uncooking_pot] +id = 8985 +examine = "A cold fire for uncooking fish." + +[evil_bob_exit_portal] +id = 8987 +examine = "A portal leading away from this dreadful place." diff --git a/data/activity/event/random/evil_bob/evil_bob.vars.toml b/data/activity/event/random/evil_bob/evil_bob.vars.toml new file mode 100644 index 0000000000..03c702c8a8 --- /dev/null +++ b/data/activity/event/random/evil_bob/evil_bob.vars.toml @@ -0,0 +1,26 @@ +[evil_bob_npc] +format = "int" + +[evil_bob_zone] +format = "int" +persist = true + +[evil_bob_complete] +format = "boolean" +persist = true + +[evil_bob_attentive] +format = "boolean" +persist = true + +[evil_bob_new_spot] +format = "boolean" +persist = true + +[evil_bob_seen_intro] +format = "boolean" +persist = true + +[evil_bob_servant_helped] +format = "boolean" +persist = true diff --git a/data/activity/event/random/random_event.tables.toml b/data/activity/event/random/random_event.tables.toml index 5f3184644a..5be490355e 100644 --- a/data/activity/event/random/random_event.tables.toml +++ b/data/activity/event/random/random_event.tables.toml @@ -26,6 +26,10 @@ weight = 10 event = "mime" weight = 10 +[.evil_bob] +event = "evil_bob" +weight = 10 + [random_event_settings] value = "int" diff --git a/game/src/main/kotlin/content/activity/event/random/evil_bob/EvilBob.kt b/game/src/main/kotlin/content/activity/event/random/evil_bob/EvilBob.kt new file mode 100644 index 0000000000..7eb824434c --- /dev/null +++ b/game/src/main/kotlin/content/activity/event/random/evil_bob/EvilBob.kt @@ -0,0 +1,367 @@ +package content.activity.event.random.evil_bob + +import content.activity.event.random.RandomEvents +import content.activity.event.random.kidnap +import content.entity.player.dialogue.Angry +import content.entity.player.dialogue.Evil +import content.entity.player.dialogue.EvilGrin +import content.entity.player.dialogue.EvilLaugh +import content.entity.player.dialogue.Neutral +import content.entity.player.dialogue.Sad +import content.entity.player.dialogue.Scared +import content.entity.player.dialogue.type.choice +import content.entity.player.dialogue.type.item +import content.entity.player.dialogue.type.npc +import content.entity.player.dialogue.type.player +import content.entity.player.dialogue.type.statement +import content.quest.instanceOffset +import content.quest.setInstanceLogout +import content.quest.smallInstance +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.client.clearCamera +import world.gregs.voidps.engine.client.message +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.npc.NPCs +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.entity.character.player.name +import world.gregs.voidps.engine.entity.character.player.skill.Skill +import world.gregs.voidps.engine.entity.character.player.skill.exp.exp +import world.gregs.voidps.engine.entity.obj.GameObjects +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.Region +import world.gregs.voidps.type.Tile +import world.gregs.voidps.type.random + +/** + * Evil Bob random event: an evil incarnation of Bob the cat whisks the player to the "Scape2009" + * island and demands to be fed. A terrified servant hints which of the four fishing spots holds the + * fish Bob likes; the player nets it (it comes out already cooked), uncooks it at the cold fire, and + * serves the raw fish to Bob. The right fish sends him to sleep and opens the exit portal; a wrong + * one makes him more attentive so an extra correct fish is needed. Reward: 650 Fishing or Magic XP. + * https://runescape.wiki/w/Random_events?oldid=3667851#Evil_Bob + */ +class EvilBob : Script { + + init { + RandomEvents.register("evil_bob") { startEvent() } + + npcOperate("Talk-to", "evil_bob") { (bob) -> + if (bob.owner != this) { + message("It pays you no attention.") + return@npcOperate + } + evilBobDialogue() + } + + itemOnNPCOperate("raw_fish_like_thing", "evil_bob") { (bob) -> + if (bob.owner == this) serveCorrect() + } + itemOnNPCOperate("raw_fish_like_thing_incorrect", "evil_bob") { (bob) -> + if (bob.owner == this) serveWrong() + } + + npcOperate("Talk-to", "evil_bob_servant") { (servant) -> + if (servant.owner != this) { + message("They're too frightened to speak to you.") + return@npcOperate + } + servantDialogue() + } + + objectOperate("Net", "evil_bob_fishing_spot") { + netFishingSpot() + } + + itemOnObjectOperate("fish_like_thing", "evil_bob_uncooking_pot") { + uncook("fish_like_thing", "raw_fish_like_thing") + } + itemOnObjectOperate("fish_like_thing_incorrect", "evil_bob_uncooking_pot") { + uncook("fish_like_thing_incorrect", "raw_fish_like_thing_incorrect") + } + + objectOperate("Enter", "evil_bob_exit_portal", arrive = false) { (portal) -> + if (!get("evil_bob_complete", false)) { + npc("evil_bob", "You're going nowhere, human!") + return@objectOperate + } + walkOverDelay(portal.tile) + face(ISLAND.add(instanceOffset())) + anim("emote_raspberry") + say("Be seeing you!") + delay(2) + reward() + clearState() + RandomEvents.complete(this) + message("Welcome back.") + } + } + + private suspend fun Player.startEvent() { + smallInstance(Region(ISLAND_REGION), levels = 1) + setInstanceLogout(Tile(this["random_event_origin", tile.id])) + val offset = instanceOffset() + for (zone in ZONES) { + GameObjects.add("evil_bob_fishing_spot", zone.spot.add(offset), collision = false) + } + GameObjects.add("evil_bob_uncooking_pot", POT.add(offset), collision = false) + GameObjects.add("evil_bob_exit_portal", PORTAL.add(offset), collision = false) + + evilBobCatIntro() + kidnap(ISLAND.add(offset)) + + val bob = NPCs.add("evil_bob", BOB_TILE.add(offset), ticks = -1, owner = this) + bob.watch(this) + set("evil_bob_npc", bob.index) + val servant = NPCs.add("evil_bob_servant", SERVANT_TILE.add(offset), ticks = -1, owner = this) + servant.watch(this) + + assignZone() + inventory.add("small_fishing_net") + face(bob.tile) + message("Welcome to Scape2009.") + evilBobDialogue() + } + + private suspend fun Player.evilBobCatIntro() { + val cat = NPCs.addRandom("evil_bob_cat", tile.toCuboid(1), ticks = 25, owner = this) + ?: NPCs.add("evil_bob_cat", tile, ticks = 25, owner = this) + cat.watch(this) + cat.say("Meow.") + delay(2) + } + + private fun Player.assignZone() { + set("evil_bob_zone", random.nextInt(ZONES.size) + 1) // 1-based; 0 means "unassigned" + } + + private fun Player.bob(): NPC? = NPCs.indexed(get("evil_bob_npc", -1))?.takeIf { it.id == "evil_bob" } + + private fun Player.currentZone(): Int { + val local = tile.minus(instanceOffset()) + return ZONES.indexOfFirst { it.contains(local) } + 1 // 0 when in no zone + } + + private suspend fun Player.netFishingSpot() { + when { + get("evil_bob_complete", false) -> + statement("Evil Bob's had his fill; there's no need to fish any more.") + get("evil_bob_new_spot", false) -> + statement("You don't know if this is a good place to go fishing. Perhaps you should ask someone, like one of the human servants.") + !inventory.contains("small_fishing_net") -> + npc("evil_bob_servant", "You'll need a fishing net. There are plenty scattered around the beach.") + inventory.isFull() -> + message("You don't have enough space in your inventory.") + holdsFish() -> + npc("evil_bob_servant", "You've already got a fish. Come over here to uncook it, then serve it to Evil Bob.") + else -> { + anim("fish_small_fishing_net") + message("You cast out your net...") + delay(5) + val correct = currentZone() == get("evil_bob_zone", 0) + inventory.add(if (correct) "fish_like_thing" else "fish_like_thing_incorrect") + clearAnim() + item("fish_like_thing", "You catch a... what is this?? Is this a fish?? And it's cooked already??") + } + } + } + + private fun Player.holdsFish() = inventory.contains("fish_like_thing") || + inventory.contains("fish_like_thing_incorrect") || + inventory.contains("raw_fish_like_thing") || + inventory.contains("raw_fish_like_thing_incorrect") + + private suspend fun Player.uncook(cooked: String, raw: String) { + anim("cook_fire") + delay(2) + if (inventory.remove(cooked)) { + inventory.add(raw) + } + clearAnim() + } + + private suspend fun Player.serveCorrect() { + if (!inventory.remove("raw_fish_like_thing")) { + return + } + npc("evil_bob", "Mmm, mmm... that's delicious.") + if (get("evil_bob_attentive", false)) { + set("evil_bob_attentive", false) + assignZone() + set("evil_bob_new_spot", true) + npc("evil_bob", "Now get me another, you no-good human.") + statement("Evil Bob seems slightly less attentive of you.") + } else { + npc("evil_bob", "Now, let me take... a little... catnap.") + set("evil_bob_complete", true) + bob()?.say("ZZZzzz") + statement("Evil Bob has fallen asleep. Slip away through the portal while you can!") + } + } + + private suspend fun Player.serveWrong() { + if (!inventory.remove("raw_fish_like_thing_incorrect")) { + return + } + set("evil_bob_attentive", true) + set("evil_bob_new_spot", true) + npc("evil_bob", "What was this? That was absolutely disgusting!") + npc("evil_bob", "Don't you know what kind of fish I like? Talk to my other servants for some advice.") + statement("Evil Bob seems more attentive of you.") + } + + private suspend fun Player.evilBobDialogue() { + when { + get("evil_bob_complete", false) -> + statement("Evil Bob appears to be sleeping, best not to wake him up.") + inventory.contains("raw_fish_like_thing") -> serveCorrect() + inventory.contains("raw_fish_like_thing_incorrect") -> serveWrong() + inventory.contains("fish_like_thing") || inventory.contains("fish_like_thing_incorrect") -> + npc("evil_bob", "What, are you giving me cooked fish? What am I going to do with that? Uncook it first!") + !get("evil_bob_seen_intro", false) -> { + set("evil_bob_seen_intro", true) + introDialogue() + } + else -> reasonsDialogue() + } + } + + private suspend fun Player.introDialogue() { + player("Where am I?") + npc("evil_bob", "On my island.") + player("Who brought me here?") + npc("evil_bob", "That would be telling.") + player("Take me to your leader!") + npc("evil_bob", "I am your leader, you are but a slave.") + player("I am not a slave, I am a free man!") + npc("evil_bob", "Ah-ha-ha-ha-ha-ha!") + npc("evil_bob", "Now catch me some fish, I'm hungry. Talk to my other servants, and hurry it up!") + } + + private suspend fun Player.reasonsDialogue() { + player("Let me out of here!") + npc("evil_bob", "I will never let you go, $name!") + choice { + option("Why not?") { + npc("evil_bob", "Because I say so! And because I can never have enough servants!") + npc("evil_bob", "Now catch me some fish, I'm hungry.") + } + option("What's it all about?") { + npc("evil_bob", "You are a skilled worker. A human like you is worth a great deal as a slave.") + player("A slave?? I will have nothing to do with you.") + npc("evil_bob", "It's just a matter of time before you do everything I ask. Just ask my servants!") + } + option("How is it possible that you're talking?") { + npc("evil_bob", "How is it possible that you're not meowing?") + player("Meowing?? Why would I be meowing?") + npc("evil_bob", "Most humans do; that's why I wear this amulet of Man speak.") + } + option("What did you do to Bob?") { + npc("evil_bob", "Bob? I am Bob! An incarnation of Bob here on Scape2009.") + npc("evil_bob", "You work just as well for me. Now get to work, human! Fish for me!") + } + } + } + + private suspend fun Player.servantDialogue() { + when { + get("evil_bob_complete", false) -> { + player("Evil Bob has fallen asleep, come quickly!") + npc("evil_bob_servant", "You go, $name. I don't belong there... this is the only place I can ever go.") + } + !get("evil_bob_servant_helped", false) -> { + player("I need help, I've been kidnapped by an evil cat!") + npc("evil_bob_servant", "Meow! Errr... I c-c-c-can't help you... He'll kill us all!") + player("He's just a little cat! There must be something I can do!") + npc("evil_bob_servant", "F-f-f-fish... give him the f-f-f-fish he likes and he might f-f-f-fall asleep.") + set("evil_bob_servant_helped", true) + showSpot() + } + get("evil_bob_new_spot", false) -> { + npc("evil_bob_servant", "Look... over t-t-there! That fishing spot c-c-contains the f-f-f-fish he likes.") + showSpot() + } + else -> + npc("evil_bob_servant", "F-f-f-fish... give him the f-f-f-fish he likes and he might f-f-f-fall asleep.") + } + } + + /** Pans the camera to the assigned fishing spot, then hands control back and unlocks netting. */ + private suspend fun Player.showSpot() { + val zone = ZONES[get("evil_bob_zone", 1).coerceIn(1, ZONES.size) - 1] + val offset = instanceOffset() + moveCamera(zone.cameraMove.add(offset), zone.cameraMoveHeight) + turnCamera(zone.cameraTurn.add(offset), zone.cameraTurnHeight) + delay(5) + clearCamera() + set("evil_bob_new_spot", false) + } + + private fun Player.reward() { + if (levels.getMax(Skill.Magic) > 50 && random.nextBoolean()) { + exp(Skill.Magic, REWARD_XP) + message("You feel somehow that you've become better at magic.") + } else { + exp(Skill.Fishing, REWARD_XP) + message("You feel somehow that you've become better at fishing.") + } + } + + private fun Player.clearState() { + for (id in FISH_ITEMS) { + while (inventory.remove(id)) { + // strip every fish-like thing so none survive the trip home + } + } + inventory.remove("small_fishing_net") + clear("evil_bob_zone") + clear("evil_bob_complete") + clear("evil_bob_attentive") + clear("evil_bob_new_spot") + clear("evil_bob_seen_intro") + clear("evil_bob_servant_helped") + } + + private data class Zone( + val minX: Int, + val minY: Int, + val maxX: Int, + val maxY: Int, + val spot: Tile, + val cameraMove: Tile, + val cameraMoveHeight: Int, + val cameraTurn: Tile, + val cameraTurnHeight: Int, + ) { + fun contains(tile: Tile) = tile.x in minX..maxX && tile.y in minY..maxY + } + + companion object { + private const val ISLAND_REGION = 13642 + private const val REWARD_XP = 650.0 + private val ISLAND = Tile(3419, 4776) + private val BOB_TILE = Tile(3420, 4778) + private val SERVANT_TILE = Tile(3423, 4777) + private val POT = Tile(3423, 4780) + private val PORTAL = Tile(3416, 4777) + + private val FISH_ITEMS = listOf( + "fish_like_thing", + "fish_like_thing_incorrect", + "raw_fish_like_thing", + "raw_fish_like_thing_incorrect", + ) + + // Zones and camera framings ported from 2009scape's EvilBobUtils / ServantCutscene[NSEW] + // (region-13642 base 3392,4736 + local coords). Index order = zone id 1..4. + private val ZONES = listOf( + Zone(3421, 4789, 3427, 4792, Tile(3424, 4791), Tile(3422, 4773), 400, Tile(3422, 4786), 400), // north + Zone(3437, 4774, 3440, 4780, Tile(3438, 4777), Tile(3417, 4777), 440, Tile(3434, 4777), 440), // east + Zone(3419, 4763, 3426, 4765, Tile(3422, 4764), Tile(3423, 4782), 365, Tile(3421, 4766), 365), // south + Zone(3405, 4773, 3408, 4779, Tile(3406, 4776), Tile(3426, 4777), 325, Tile(3408, 4776), 300), // west + ) + } +} diff --git a/game/src/test/kotlin/content/activity/event/random/EvilBobTest.kt b/game/src/test/kotlin/content/activity/event/random/EvilBobTest.kt new file mode 100644 index 0000000000..488148a9c7 --- /dev/null +++ b/game/src/test/kotlin/content/activity/event/random/EvilBobTest.kt @@ -0,0 +1,157 @@ +package content.activity.event.random + +import WorldTest +import content.quest.instance +import content.quest.instanceOffset +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.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.skill.Skill +import world.gregs.voidps.engine.entity.obj.GameObject +import world.gregs.voidps.engine.entity.obj.GameObjects +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.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class EvilBobTest : WorldTest() { + + private val origin = Tile(3221, 3218) + + // east zone (id 2) and west zone (id 4) stand/spot tiles (template coords). + private val eastSpot = Tile(3438, 4777) + private val westSpot = Tile(3406, 4776) + private val potTile = Tile(3423, 4780) + private val portalTile = Tile(3416, 4777) + + /** Runs the event, clears the intro dialogue, and pins the answer zone for determinism. */ + private fun enter(name: String, zone: Int = 2): Player { + val player = createPlayer(origin, name) + RandomEvents.start(player, "evil_bob") + tick(10) + while (player.dialogue != null) player.skipDialogues() + player["evil_bob_zone"] = zone + player.clear("evil_bob_new_spot") + return player + } + + private fun Player.spot(tile: Tile): GameObject = GameObjects.find(tile.add(instanceOffset())) { it.id == "evil_bob_fishing_spot" } + private fun Player.bob() = NPCs.indexed(get("evil_bob_npc", -1))!! + + private fun Player.serve() { + tele(bob().tile.addX(1)) // stand beside Evil Bob so the talk lands immediately + tick() + npcOption(bob(), "Talk-to") + tickIf { dialogue == null } // wait for the first line to open + while (dialogue != null) { + skipDialogues() + tick() + } + } + + @Test + fun `Event kidnaps the player to the island and sets up the event`() { + val player = enter("eb_start") + + assertEquals("evil_bob", player.get("random_event")) + assertNotNull(player.instance()) + assertTrue(player.get("evil_bob_zone", 0) in 1..4) + assertTrue(player.inventory.contains("small_fishing_net")) + assertEquals("evil_bob", player.bob().id) + } + + @Test + fun `Netting the assigned zone yields the fish Evil Bob likes`() { + val player = enter("eb_net_right", zone = 2) + player.tele(eastSpot.add(player.instanceOffset())) + tick() + + player.objectOption(player.spot(eastSpot), "Net") + tick(7) + + assertTrue(player.inventory.contains("fish_like_thing")) + assertFalse(player.inventory.contains("fish_like_thing_incorrect")) + } + + @Test + fun `Netting a different zone yields the wrong fish`() { + val player = enter("eb_net_wrong", zone = 2) // east is correct... + player.tele(westSpot.add(player.instanceOffset())) // ...but we fish the west + tick() + + player.objectOption(player.spot(westSpot), "Net") + tick(7) + + assertTrue(player.inventory.contains("fish_like_thing_incorrect")) + } + + @Test + fun `Uncooking a cooked fish at the pot produces the raw fish`() { + val player = enter("eb_uncook") + val offset = player.instanceOffset() + player.tele(potTile.addX(1).add(offset)) // stand beside the cold fire + tick() + player.inventory.add("fish_like_thing") + val pot = GameObjects.find(potTile.add(offset)) { it.id == "evil_bob_uncooking_pot" } + + player.itemOnObject(pot, player.inventory.indexOf("fish_like_thing")) + tickIf { player.inventory.contains("fish_like_thing") } + + assertFalse(player.inventory.contains("fish_like_thing")) + assertTrue(player.inventory.contains("raw_fish_like_thing")) + } + + @Test + fun `Serving the correct raw fish then leaving rewards 650 Fishing XP and returns the player`() { + val player = enter("eb_finish") + player.inventory.add("raw_fish_like_thing") + player.serve() + + assertTrue(player["evil_bob_complete", false]) + + val portal = GameObjects.find(portalTile.add(player.instanceOffset())) { it.id == "evil_bob_exit_portal" } + player.tele(portalTile.add(player.instanceOffset())) + tick() + player.objectOption(portal, "Enter") + tick(6) + while (player.dialogue != null) player.skipDialogues() + tick(2) + + assertEquals(650.0, player.experience.get(Skill.Fishing)) + assertNull(player.get("random_event")) + assertEquals(origin, player.tile) + assertTrue(player.contains("random_event_cooldown")) + } + + @Test + fun `A wrong fish makes Evil Bob attentive so an extra correct fish is needed`() { + val player = enter("eb_attentive") + + player.inventory.add("raw_fish_like_thing_incorrect") + player.serve() + assertTrue(player["evil_bob_attentive", false]) + assertFalse(player["evil_bob_complete", false]) + + // While attentive, a correct fish only resets him (new spot) instead of completing. + player.inventory.add("raw_fish_like_thing") + player.serve() + assertFalse(player["evil_bob_attentive", false]) + assertFalse(player["evil_bob_complete", false]) + + // Now, no longer attentive, the correct fish finishes the event. + player["evil_bob_zone"] = 2 + player.inventory.add("raw_fish_like_thing") + player.serve() + assertTrue(player["evil_bob_complete", false]) + } +} From 4cc0d4d997b4970942700c01600469f9b0fad5d0 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Wed, 8 Jul 2026 10:19:34 -0700 Subject: [PATCH 26/89] Add Evil Bob random event An evil incarnation of Bob the cat teleports the player to the ScapeRune island and demands fish. A terrified servant pans the camera to the fishing spot Bob likes; the player nets there (the fish comes out already cooked), uncooks it at the cold fire, and serves the raw fish to Bob. The right fish sends him to sleep and opens the exit portal for a 650 Fishing or Magic XP reward; a wrong one makes Bob more attentive so an extra correct fish is needed. Ref GregHib/void-private#262 --- .../kotlin/content/activity/event/random/evil_bob/EvilBob.kt | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/game/src/main/kotlin/content/activity/event/random/evil_bob/EvilBob.kt b/game/src/main/kotlin/content/activity/event/random/evil_bob/EvilBob.kt index 7eb824434c..90729e00e9 100644 --- a/game/src/main/kotlin/content/activity/event/random/evil_bob/EvilBob.kt +++ b/game/src/main/kotlin/content/activity/event/random/evil_bob/EvilBob.kt @@ -37,7 +37,7 @@ import world.gregs.voidps.type.Tile import world.gregs.voidps.type.random /** - * Evil Bob random event: an evil incarnation of Bob the cat whisks the player to the "Scape2009" + * Evil Bob random event: an evil incarnation of Bob the cat whisks the player to the "ScapeRune" * island and demands to be fed. A terrified servant hints which of the four fishing spots holds the * fish Bob likes; the player nets it (it comes out already cooked), uncooks it at the cold fire, and * serves the raw fish to Bob. The right fish sends him to sleep and opens the exit portal; a wrong @@ -122,7 +122,7 @@ class EvilBob : Script { assignZone() inventory.add("small_fishing_net") face(bob.tile) - message("Welcome to Scape2009.") + message("Welcome to ScapeRune.") evilBobDialogue() } @@ -355,7 +355,6 @@ class EvilBob : Script { "raw_fish_like_thing_incorrect", ) - // Zones and camera framings ported from 2009scape's EvilBobUtils / ServantCutscene[NSEW] // (region-13642 base 3392,4736 + local coords). Index order = zone id 1..4. private val ZONES = listOf( Zone(3421, 4789, 3427, 4792, Tile(3424, 4791), Tile(3422, 4773), 400, Tile(3422, 4786), 400), // north From 86c5721f40e93e9b144093e5e1be999a827c81cb Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Wed, 8 Jul 2026 10:29:50 -0700 Subject: [PATCH 27/89] Face Evil Bob and pause before the raspberry send-off --- .../kotlin/content/activity/event/random/evil_bob/EvilBob.kt | 3 ++- .../test/kotlin/content/activity/event/random/EvilBobTest.kt | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/game/src/main/kotlin/content/activity/event/random/evil_bob/EvilBob.kt b/game/src/main/kotlin/content/activity/event/random/evil_bob/EvilBob.kt index 90729e00e9..fb16b503ec 100644 --- a/game/src/main/kotlin/content/activity/event/random/evil_bob/EvilBob.kt +++ b/game/src/main/kotlin/content/activity/event/random/evil_bob/EvilBob.kt @@ -89,7 +89,8 @@ class EvilBob : Script { return@objectOperate } walkOverDelay(portal.tile) - face(ISLAND.add(instanceOffset())) + bob()?.let { face(it.tile) } + delay(2) anim("emote_raspberry") say("Be seeing you!") delay(2) diff --git a/game/src/test/kotlin/content/activity/event/random/EvilBobTest.kt b/game/src/test/kotlin/content/activity/event/random/EvilBobTest.kt index 488148a9c7..8a6c095b81 100644 --- a/game/src/test/kotlin/content/activity/event/random/EvilBobTest.kt +++ b/game/src/test/kotlin/content/activity/event/random/EvilBobTest.kt @@ -123,7 +123,7 @@ class EvilBobTest : WorldTest() { player.tele(portalTile.add(player.instanceOffset())) tick() player.objectOption(portal, "Enter") - tick(6) + tickIf { player.get("random_event") != null } // wait out the raspberry send-off while (player.dialogue != null) player.skipDialogues() tick(2) From efd18c75e2b68b473c4c882289159767e9e136ca Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Wed, 8 Jul 2026 10:29:50 -0700 Subject: [PATCH 28/89] Face Evil Bob and pause before the raspberry send-off --- .../kotlin/content/activity/event/random/evil_bob/EvilBob.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/game/src/main/kotlin/content/activity/event/random/evil_bob/EvilBob.kt b/game/src/main/kotlin/content/activity/event/random/evil_bob/EvilBob.kt index fb16b503ec..ff7b837ec8 100644 --- a/game/src/main/kotlin/content/activity/event/random/evil_bob/EvilBob.kt +++ b/game/src/main/kotlin/content/activity/event/random/evil_bob/EvilBob.kt @@ -261,7 +261,7 @@ class EvilBob : Script { npc("evil_bob", "Most humans do; that's why I wear this amulet of Man speak.") } option("What did you do to Bob?") { - npc("evil_bob", "Bob? I am Bob! An incarnation of Bob here on Scape2009.") + npc("evil_bob", "Bob? I am Bob! An incarnation of Bob here on ScapeRune.") npc("evil_bob", "You work just as well for me. Now get to work, human! Fish for me!") } } From 79be78435a2e931da299c04029d714b1f2f6c59a Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Wed, 8 Jul 2026 10:41:23 -0700 Subject: [PATCH 29/89] Fix Evil Bob's spawn posiiton. --- .../kotlin/content/activity/event/random/evil_bob/EvilBob.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/game/src/main/kotlin/content/activity/event/random/evil_bob/EvilBob.kt b/game/src/main/kotlin/content/activity/event/random/evil_bob/EvilBob.kt index ff7b837ec8..5d1f657206 100644 --- a/game/src/main/kotlin/content/activity/event/random/evil_bob/EvilBob.kt +++ b/game/src/main/kotlin/content/activity/event/random/evil_bob/EvilBob.kt @@ -344,7 +344,7 @@ class EvilBob : Script { private const val ISLAND_REGION = 13642 private const val REWARD_XP = 650.0 private val ISLAND = Tile(3419, 4776) - private val BOB_TILE = Tile(3420, 4778) + private val BOB_TILE = Tile(3420, 4777) private val SERVANT_TILE = Tile(3423, 4777) private val POT = Tile(3423, 4780) private val PORTAL = Tile(3416, 4777) From a885458c5ab36ef6ba7e6f7d52351e72af81204c Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Wed, 8 Jul 2026 10:48:30 -0700 Subject: [PATCH 30/89] Use the cat chathead expressions for Evil Bob's dialogue --- .../event/random/evil_bob/evil_bob.npcs.toml | 1 + .../activity/event/random/evil_bob/EvilBob.kt | 43 +++++++++---------- 2 files changed, 22 insertions(+), 22 deletions(-) diff --git a/data/activity/event/random/evil_bob/evil_bob.npcs.toml b/data/activity/event/random/evil_bob/evil_bob.npcs.toml index 277addbc46..581854190a 100644 --- a/data/activity/event/random/evil_bob/evil_bob.npcs.toml +++ b/data/activity/event/random/evil_bob/evil_bob.npcs.toml @@ -4,6 +4,7 @@ examine = "It's a cat with an evil glint in its eye." [evil_bob] id = 2479 +dialogue = "cat" examine = "An evil incarnation of Bob the cat." [evil_bob_servant] diff --git a/game/src/main/kotlin/content/activity/event/random/evil_bob/EvilBob.kt b/game/src/main/kotlin/content/activity/event/random/evil_bob/EvilBob.kt index 5d1f657206..82de1d20b5 100644 --- a/game/src/main/kotlin/content/activity/event/random/evil_bob/EvilBob.kt +++ b/game/src/main/kotlin/content/activity/event/random/evil_bob/EvilBob.kt @@ -3,12 +3,11 @@ package content.activity.event.random.evil_bob import content.activity.event.random.RandomEvents import content.activity.event.random.kidnap import content.entity.player.dialogue.Angry -import content.entity.player.dialogue.Evil -import content.entity.player.dialogue.EvilGrin -import content.entity.player.dialogue.EvilLaugh 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.Unamused import content.entity.player.dialogue.type.choice import content.entity.player.dialogue.type.item import content.entity.player.dialogue.type.npc @@ -85,7 +84,7 @@ class EvilBob : Script { objectOperate("Enter", "evil_bob_exit_portal", arrive = false) { (portal) -> if (!get("evil_bob_complete", false)) { - npc("evil_bob", "You're going nowhere, human!") + npc("evil_bob", "You're going nowhere, human!") return@objectOperate } walkOverDelay(portal.tile) @@ -188,12 +187,12 @@ class EvilBob : Script { if (!inventory.remove("raw_fish_like_thing")) { return } - npc("evil_bob", "Mmm, mmm... that's delicious.") + npc("evil_bob", "Mmm, mmm... that's delicious.") if (get("evil_bob_attentive", false)) { set("evil_bob_attentive", false) assignZone() set("evil_bob_new_spot", true) - npc("evil_bob", "Now get me another, you no-good human.") + npc("evil_bob", "Now get me another, you no-good human.") statement("Evil Bob seems slightly less attentive of you.") } else { npc("evil_bob", "Now, let me take... a little... catnap.") @@ -210,7 +209,7 @@ class EvilBob : Script { set("evil_bob_attentive", true) set("evil_bob_new_spot", true) npc("evil_bob", "What was this? That was absolutely disgusting!") - npc("evil_bob", "Don't you know what kind of fish I like? Talk to my other servants for some advice.") + npc("evil_bob", "Don't you know what kind of fish I like? Talk to my other servants for some advice.") statement("Evil Bob seems more attentive of you.") } @@ -221,7 +220,7 @@ class EvilBob : Script { inventory.contains("raw_fish_like_thing") -> serveCorrect() inventory.contains("raw_fish_like_thing_incorrect") -> serveWrong() inventory.contains("fish_like_thing") || inventory.contains("fish_like_thing_incorrect") -> - npc("evil_bob", "What, are you giving me cooked fish? What am I going to do with that? Uncook it first!") + npc("evil_bob", "What, are you giving me cooked fish? What am I going to do with that? Uncook it first!") !get("evil_bob_seen_intro", false) -> { set("evil_bob_seen_intro", true) introDialogue() @@ -232,37 +231,37 @@ class EvilBob : Script { private suspend fun Player.introDialogue() { player("Where am I?") - npc("evil_bob", "On my island.") + npc("evil_bob", "On my island.") player("Who brought me here?") - npc("evil_bob", "That would be telling.") + npc("evil_bob", "That would be telling.") player("Take me to your leader!") - npc("evil_bob", "I am your leader, you are but a slave.") + npc("evil_bob", "I am your leader, you are but a slave.") player("I am not a slave, I am a free man!") - npc("evil_bob", "Ah-ha-ha-ha-ha-ha!") - npc("evil_bob", "Now catch me some fish, I'm hungry. Talk to my other servants, and hurry it up!") + npc("evil_bob", "Ah-ha-ha-ha-ha-ha!") + npc("evil_bob", "Now catch me some fish, I'm hungry. Talk to my other servants, and hurry it up!") } private suspend fun Player.reasonsDialogue() { player("Let me out of here!") - npc("evil_bob", "I will never let you go, $name!") + npc("evil_bob", "I will never let you go, $name!") choice { option("Why not?") { - npc("evil_bob", "Because I say so! And because I can never have enough servants!") - npc("evil_bob", "Now catch me some fish, I'm hungry.") + npc("evil_bob", "Because I say so! And because I can never have enough servants!") + npc("evil_bob", "Now catch me some fish, I'm hungry.") } option("What's it all about?") { - npc("evil_bob", "You are a skilled worker. A human like you is worth a great deal as a slave.") + npc("evil_bob", "You are a skilled worker. A human like you is worth a great deal as a slave.") player("A slave?? I will have nothing to do with you.") - npc("evil_bob", "It's just a matter of time before you do everything I ask. Just ask my servants!") + npc("evil_bob", "It's just a matter of time before you do everything I ask. Just ask my servants!") } option("How is it possible that you're talking?") { - npc("evil_bob", "How is it possible that you're not meowing?") + npc("evil_bob", "How is it possible that you're not meowing?") player("Meowing?? Why would I be meowing?") - npc("evil_bob", "Most humans do; that's why I wear this amulet of Man speak.") + npc("evil_bob", "Most humans do; that's why I wear this amulet of Man speak.") } option("What did you do to Bob?") { - npc("evil_bob", "Bob? I am Bob! An incarnation of Bob here on ScapeRune.") - npc("evil_bob", "You work just as well for me. Now get to work, human! Fish for me!") + npc("evil_bob", "Bob? I am Bob! An incarnation of Bob here on ScapeRune.") + npc("evil_bob", "You work just as well for me. Now get to work, human! Fish for me!") } } } From 8f16db2f5ef53757bf52697632f44aa23d5f60f6 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Wed, 8 Jul 2026 11:36:31 -0700 Subject: [PATCH 31/89] Add Quiz Master random event Odd One Out on interface 191: pick the golden model that doesn't belong; four correct wins a choice of 1000 coins or a random item. Ported from 2009scape. Ref GregHib/void-private#260 --- .../quiz_master/quiz_master.tables.toml | 51 +++++++ .../random/quiz_master/quiz_master.vars.toml | 7 + .../event/random/random_event.tables.toml | 4 + .../player/dialogue/dialogue.ifaces.toml | 14 ++ .../event/random/quiz_master/QuizMaster.kt | 130 ++++++++++++++++++ .../activity/event/random/QuizMasterTest.kt | 94 +++++++++++++ 6 files changed, 300 insertions(+) create mode 100644 data/activity/event/random/quiz_master/quiz_master.tables.toml create mode 100644 data/activity/event/random/quiz_master/quiz_master.vars.toml create mode 100644 game/src/main/kotlin/content/activity/event/random/quiz_master/QuizMaster.kt create mode 100644 game/src/test/kotlin/content/activity/event/random/QuizMasterTest.kt diff --git a/data/activity/event/random/quiz_master/quiz_master.tables.toml b/data/activity/event/random/quiz_master/quiz_master.tables.toml new file mode 100644 index 0000000000..f1abcc0850 --- /dev/null +++ b/data/activity/event/random/quiz_master/quiz_master.tables.toml @@ -0,0 +1,51 @@ +[random_event_quiz] +item = "item" +amount = "int" + +[.lamp] +item = "lamp" +amount = 1 + +[.cabbage] +item = "cabbage" +amount = 1 + +[.diamond] +item = "diamond" +amount = 1 + +[.bucket] +item = "bucket" +amount = 1 + +[.flier] +item = "flier" +amount = 1 + +[.old_boot] +item = "old_boot" +amount = 1 + +[.body_rune] +item = "body_rune" +amount = 1 + +[.onion] +item = "onion" +amount = 1 + +[.mithril_scimitar] +item = "mithril_scimitar" +amount = 1 + +[.casket] +item = "casket" +amount = 1 + +[.steel_platebody] +item = "steel_platebody" +amount = 1 + +[.nature_rune] +item = "nature_rune" +amount = 20 diff --git a/data/activity/event/random/quiz_master/quiz_master.vars.toml b/data/activity/event/random/quiz_master/quiz_master.vars.toml new file mode 100644 index 0000000000..2530f7c093 --- /dev/null +++ b/data/activity/event/random/quiz_master/quiz_master.vars.toml @@ -0,0 +1,7 @@ +[quiz_answer] +format = "int" +persist = true + +[quiz_correct] +format = "int" +persist = true diff --git a/data/activity/event/random/random_event.tables.toml b/data/activity/event/random/random_event.tables.toml index 5be490355e..259f1d42f2 100644 --- a/data/activity/event/random/random_event.tables.toml +++ b/data/activity/event/random/random_event.tables.toml @@ -30,6 +30,10 @@ weight = 10 event = "evil_bob" weight = 10 +[.quiz_master] +event = "quiz_master" +weight = 10 + [random_event_settings] value = "int" diff --git a/data/entity/player/dialogue/dialogue.ifaces.toml b/data/entity/player/dialogue/dialogue.ifaces.toml index 510aca9210..f855c928bf 100644 --- a/data/entity/player/dialogue/dialogue.ifaces.toml +++ b/data/entity/player/dialogue/dialogue.ifaces.toml @@ -277,6 +277,20 @@ type = "dialogue_box" id = 191 type = "dialogue_box" +# The three golden models to compare; declaring a Select option makes them clickable so the +# player can pick the odd one out (the cache marks them non-clickable by default). +[.model_1] +id = 6 +options = { Select = 0 } + +[.model_2] +id = 7 +options = { Select = 0 } + +[.model_3] +id = 8 +options = { Select = 0 } + [chat1_double_model] id = 173 diff --git a/game/src/main/kotlin/content/activity/event/random/quiz_master/QuizMaster.kt b/game/src/main/kotlin/content/activity/event/random/quiz_master/QuizMaster.kt new file mode 100644 index 0000000000..7291e428c8 --- /dev/null +++ b/game/src/main/kotlin/content/activity/event/random/quiz_master/QuizMaster.kt @@ -0,0 +1,130 @@ +package content.activity.event.random.quiz_master + +import content.activity.event.random.RandomEvents +import content.activity.event.random.kidnap +import content.entity.player.dialogue.Confused +import content.entity.player.dialogue.Goofy +import content.entity.player.dialogue.Hysterics +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.client.message +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.npc.NPCs +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.entity.character.player.name +import world.gregs.voidps.engine.entity.item.floor.FloorItems +import world.gregs.voidps.engine.inv.add +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.type.Tile +import world.gregs.voidps.type.random + +/** + * Quiz Master random event: the Quiz Master whisks the player to his studio for a game of + * "Odd One Out". Three golden models are shown on interface 191; the player picks the one that + * doesn't belong with the other two. Four correct answers win a choice of 1000 coins or a random + * item. Wrong answers cost nothing but a turn. + * https://runescape.wiki/w/Random_events?oldid=3667851#Quiz_Master + */ +class QuizMaster : Script { + + init { + RandomEvents.register("quiz_master") { startEvent() } + + interfaceOption("Select", "dialogue_macro_quiz_show:model_*") { + if (get("random_event") != "quiz_master") { + return@interfaceOption + } + answer(it.component.removePrefix("model_").toInt()) + } + } + + private suspend fun Player.startEvent() { + set("quiz_correct", 0) + quizHerald() + kidnap(ROOM) + face(QUIZ_MASTER) + intro() + openQuestion() + } + + private suspend fun Player.quizHerald() { + val herald = NPCs.addRandom("quiz_master", tile.toCuboid(1), ticks = 25, owner = this) + ?: NPCs.add("quiz_master", tile, ticks = 25, owner = this) + herald.watch(this) + herald.say("Hey $name! It's your lucky day!") + delay(2) + } + + private suspend fun Player.intro() { + npc("quiz_master", "WELCOME to the GREATEST QUIZ SHOW in the whole of RuneScape: O D D O N E O U T") + player("I'm sure I didn't ask to take part in a quiz show...") + npc("quiz_master", "Please welcome our newest contestant: $name! Just pick the O D D O N E O U T. Four questions right, and then you win!") + } + + private fun Player.openQuestion() { + val set = SETS.random(random) + val answer = set[0] + val models = set.toList().shuffled(random) + set("quiz_answer", models.indexOf(answer) + 1) // 1-based slot of the odd one out + interfaces.sendModel("dialogue_macro_quiz_show", "model_1", models[0]) + interfaces.sendModel("dialogue_macro_quiz_show", "model_2", models[1]) + interfaces.sendModel("dialogue_macro_quiz_show", "model_3", models[2]) + open("dialogue_macro_quiz_show") + } + + private suspend fun Player.answer(slot: Int) { + close("dialogue_macro_quiz_show") + if (slot == get("quiz_answer", 0)) { + if (inc("quiz_correct") >= REQUIRED) { + win() + return + } + npc("quiz_master", "Wow, you're a smart one! You're absolutely RIGHT! Okay, next question!") + } else { + npc("quiz_master", "WRONG! That's just WRONG! Okay, next question!") + } + openQuestion() + } + + private suspend fun Player.win() { + npc("quiz_master", "CONGRATULATIONS! You are a WINNER! Please choose your PRIZE!") + choice { + option("1000 Coins") { giveOrDrop("coins", 1000) } + option("Random Item") { + val row = Tables.get("random_event_quiz").rows().random(random) + giveOrDrop(row.item("item"), row.int("amount")) + } + } + clear("quiz_answer") + clear("quiz_correct") + RandomEvents.complete(this) + message("Welcome back.") + } + + private fun Player.giveOrDrop(item: String, amount: Int) { + if (!inventory.add(item, amount)) { + FloorItems.add(tile, item, amount, disappearTicks = 300, owner = this) + } + } + + companion object { + private const val REQUIRED = 4 + private val ROOM = Tile(1952, 4766, 1) + private val QUIZ_MASTER = Tile(1952, 4768, 1) + + // Golden models (8828-8837) grouped so the first of each triple is the odd one out. + // 28 battleaxe, 29 salmon, 30 trout, 31 necklace, 32 shield, 33 helm, 34 ring, + // 35 secateurs, 36 sword, 37 trowel. + private val SETS = listOf( + intArrayOf(8828, 8829, 8829), // weapon vs two fish + intArrayOf(8831, 8837, 8835), // jewellery vs two gardening tools + intArrayOf(8830, 8832, 8833), // fish vs two pieces of armour + intArrayOf(8835, 8834, 8831), // gardening tool vs two pieces of jewellery + intArrayOf(8837, 8836, 8828), // gardening tool vs two weapons + ) + } +} diff --git a/game/src/test/kotlin/content/activity/event/random/QuizMasterTest.kt b/game/src/test/kotlin/content/activity/event/random/QuizMasterTest.kt new file mode 100644 index 0000000000..ee1c869233 --- /dev/null +++ b/game/src/test/kotlin/content/activity/event/random/QuizMasterTest.kt @@ -0,0 +1,94 @@ +package content.activity.event.random + +import WorldTest +import dialogueOption +import interfaceOption +import org.junit.jupiter.api.Test +import skipDialogues +import world.gregs.voidps.engine.entity.character.player.Player +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 QuizMasterTest : WorldTest() { + + private val origin = Tile(3221, 3218) + private val quiz = "dialogue_macro_quiz_show" + + /** Runs the event and skips the intro so the first question interface is open. */ + private fun enter(name: String): Player { + val player = createPlayer(origin, name) + RandomEvents.start(player, "quiz_master") + tick(8) + player.skipDialogues() // advance the three intro lines -> opens the quiz + tick() + return player + } + + private fun Player.answerCorrectly() { + interfaceOption(quiz, "model_${get("quiz_answer", 0)}", "Select") + tick() + } + + @Test + fun `Event whisks the player to the studio and opens the quiz`() { + val player = enter("quiz_start") + + assertEquals("quiz_master", player.get("random_event")) + assertTrue(player.interfaces.contains(quiz)) + assertTrue(player.get("quiz_answer", 0) in 1..3) + } + + @Test + fun `A correct answer counts and a wrong one does not`() { + val player = enter("quiz_answer") + + val answer = player.get("quiz_answer", 0) + val wrong = if (answer == 1) 2 else 1 + player.interfaceOption(quiz, "model_$wrong", "Select") + tick() + assertEquals(0, player.get("quiz_correct", 0)) + player.skipDialogues() // "WRONG!" -> reopens the quiz + tick() + + player.answerCorrectly() + assertEquals(1, player.get("quiz_correct", 0)) + } + + @Test + fun `Four correct answers let the player choose 1000 coins`() { + val player = enter("quiz_coins") + + repeat(4) { + player.answerCorrectly() + player.skipDialogues() // "RIGHT!" (or the winner line on the 4th) -> reopens quiz / opens the prize choice + tick() + } + player.dialogueOption(1) // 1000 Coins + tick() + + assertEquals(1000, player.inventory.count("coins")) + assertNull(player.get("random_event")) + assertEquals(origin, player.tile) + assertTrue(player.contains("random_event_cooldown")) + } + + @Test + fun `The random item prize awards an item`() { + val player = enter("quiz_item") + + repeat(4) { + player.answerCorrectly() + player.skipDialogues() + tick() + } + player.dialogueOption(2) // Random Item + tick() + + assertFalse(player.inventory.isEmpty()) + assertNull(player.get("random_event")) + } +} From cdd428a7664c777621af08cc932861e710efb0dc Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Wed, 8 Jul 2026 11:43:55 -0700 Subject: [PATCH 32/89] Make the Quiz Master answer models clickable Interface 191's models are non-clickable in the cache, so send the access mask on open; without it the interface has no way to answer. --- .../content/activity/event/random/quiz_master/QuizMaster.kt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/game/src/main/kotlin/content/activity/event/random/quiz_master/QuizMaster.kt b/game/src/main/kotlin/content/activity/event/random/quiz_master/QuizMaster.kt index 7291e428c8..0b4e8b40f9 100644 --- a/game/src/main/kotlin/content/activity/event/random/quiz_master/QuizMaster.kt +++ b/game/src/main/kotlin/content/activity/event/random/quiz_master/QuizMaster.kt @@ -74,6 +74,11 @@ class QuizMaster : Script { interfaces.sendModel("dialogue_macro_quiz_show", "model_2", models[1]) interfaces.sendModel("dialogue_macro_quiz_show", "model_3", models[2]) open("dialogue_macro_quiz_show") + // The cache marks these models non-clickable, so send the access mask that lets the client + // report a click on each one; without it the interface just sits there with no way to answer. + for (slot in 1..3) { + interfaceOptions.unlockAll("dialogue_macro_quiz_show", "model_$slot") + } } private suspend fun Player.answer(slot: Int) { From ece58e66afa4d048ee4f93e3d6dfdd97d8460d9b Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Wed, 8 Jul 2026 12:04:46 -0700 Subject: [PATCH 33/89] Wire the Quiz Master answers to the interface's real buttons The clickable answer buttons are the invisible components 3-5 (setting=1 in the cache), not the display models on 6-8. Target those instead of trying to unlock the non-clickable model components. --- .../player/dialogue/dialogue.ifaces.toml | 18 ++++++++++++++---- .../event/random/quiz_master/QuizMaster.kt | 11 +++-------- .../activity/event/random/QuizMasterTest.kt | 4 ++-- 3 files changed, 19 insertions(+), 14 deletions(-) diff --git a/data/entity/player/dialogue/dialogue.ifaces.toml b/data/entity/player/dialogue/dialogue.ifaces.toml index f855c928bf..0f1f171cc7 100644 --- a/data/entity/player/dialogue/dialogue.ifaces.toml +++ b/data/entity/player/dialogue/dialogue.ifaces.toml @@ -277,18 +277,28 @@ type = "dialogue_box" id = 191 type = "dialogue_box" -# The three golden models to compare; declaring a Select option makes them clickable so the -# player can pick the odd one out (the cache marks them non-clickable by default). +# The three golden models to compare, left to right (the cache runs a client script to spin them). [.model_1] id = 6 -options = { Select = 0 } [.model_2] id = 7 -options = { Select = 0 } [.model_3] id = 8 + +# The invisible clickable buttons layered over the three models (cache-clickable via setting=1); a +# Select action lets the server resolve the pick to the model at the same position. +[.button_1] +id = 3 +options = { Select = 0 } + +[.button_2] +id = 4 +options = { Select = 0 } + +[.button_3] +id = 5 options = { Select = 0 } [chat1_double_model] diff --git a/game/src/main/kotlin/content/activity/event/random/quiz_master/QuizMaster.kt b/game/src/main/kotlin/content/activity/event/random/quiz_master/QuizMaster.kt index 0b4e8b40f9..3efb8c7c3b 100644 --- a/game/src/main/kotlin/content/activity/event/random/quiz_master/QuizMaster.kt +++ b/game/src/main/kotlin/content/activity/event/random/quiz_master/QuizMaster.kt @@ -34,11 +34,11 @@ class QuizMaster : Script { init { RandomEvents.register("quiz_master") { startEvent() } - interfaceOption("Select", "dialogue_macro_quiz_show:model_*") { + interfaceOption("Select", "dialogue_macro_quiz_show:button_*") { if (get("random_event") != "quiz_master") { return@interfaceOption } - answer(it.component.removePrefix("model_").toInt()) + answer(it.component.removePrefix("button_").toInt()) } } @@ -74,11 +74,6 @@ class QuizMaster : Script { interfaces.sendModel("dialogue_macro_quiz_show", "model_2", models[1]) interfaces.sendModel("dialogue_macro_quiz_show", "model_3", models[2]) open("dialogue_macro_quiz_show") - // The cache marks these models non-clickable, so send the access mask that lets the client - // report a click on each one; without it the interface just sits there with no way to answer. - for (slot in 1..3) { - interfaceOptions.unlockAll("dialogue_macro_quiz_show", "model_$slot") - } } private suspend fun Player.answer(slot: Int) { @@ -118,7 +113,7 @@ class QuizMaster : Script { companion object { private const val REQUIRED = 4 - private val ROOM = Tile(1952, 4766, 1) + private val ROOM = Tile(1952, 4764, 1) private val QUIZ_MASTER = Tile(1952, 4768, 1) // Golden models (8828-8837) grouped so the first of each triple is the odd one out. diff --git a/game/src/test/kotlin/content/activity/event/random/QuizMasterTest.kt b/game/src/test/kotlin/content/activity/event/random/QuizMasterTest.kt index ee1c869233..6fc98cfa45 100644 --- a/game/src/test/kotlin/content/activity/event/random/QuizMasterTest.kt +++ b/game/src/test/kotlin/content/activity/event/random/QuizMasterTest.kt @@ -29,7 +29,7 @@ class QuizMasterTest : WorldTest() { } private fun Player.answerCorrectly() { - interfaceOption(quiz, "model_${get("quiz_answer", 0)}", "Select") + interfaceOption(quiz, "button_${get("quiz_answer", 0)}", "Select") tick() } @@ -48,7 +48,7 @@ class QuizMasterTest : WorldTest() { val answer = player.get("quiz_answer", 0) val wrong = if (answer == 1) 2 else 1 - player.interfaceOption(quiz, "model_$wrong", "Select") + player.interfaceOption(quiz, "button_$wrong", "Select") tick() assertEquals(0, player.get("quiz_correct", 0)) player.skipDialogues() // "WRONG!" -> reopens the quiz From 985bcf29779632f7e1088286c161d8b46331d146 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Wed, 8 Jul 2026 12:15:17 -0700 Subject: [PATCH 34/89] Handle Quiz Master answers as dialogue continues Interface 191's setting=1 buttons deliver clicks as dialogue-continue events (like choice() options), not interface options. Suspend each question on pauseInt and resume it from a continueDialogue handler keyed to the button clicked. --- .../player/dialogue/dialogue.ifaces.toml | 7 +-- .../event/random/quiz_master/QuizMaster.kt | 47 +++++++++++-------- .../activity/event/random/QuizMasterTest.kt | 20 ++++---- 3 files changed, 39 insertions(+), 35 deletions(-) diff --git a/data/entity/player/dialogue/dialogue.ifaces.toml b/data/entity/player/dialogue/dialogue.ifaces.toml index 0f1f171cc7..5a461e355e 100644 --- a/data/entity/player/dialogue/dialogue.ifaces.toml +++ b/data/entity/player/dialogue/dialogue.ifaces.toml @@ -287,19 +287,16 @@ id = 7 [.model_3] id = 8 -# The invisible clickable buttons layered over the three models (cache-clickable via setting=1); a -# Select action lets the server resolve the pick to the model at the same position. +# The invisible clickable buttons layered over the three models. They're dialogue-box components +# (setting=1 in the cache), so a click arrives as a dialogue continue keyed by these names. [.button_1] id = 3 -options = { Select = 0 } [.button_2] id = 4 -options = { Select = 0 } [.button_3] id = 5 -options = { Select = 0 } [chat1_double_model] id = 173 diff --git a/game/src/main/kotlin/content/activity/event/random/quiz_master/QuizMaster.kt b/game/src/main/kotlin/content/activity/event/random/quiz_master/QuizMaster.kt index 3efb8c7c3b..868e2af97c 100644 --- a/game/src/main/kotlin/content/activity/event/random/quiz_master/QuizMaster.kt +++ b/game/src/main/kotlin/content/activity/event/random/quiz_master/QuizMaster.kt @@ -19,6 +19,8 @@ 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.suspend.Suspension +import world.gregs.voidps.engine.suspend.pauseInt import world.gregs.voidps.type.Tile import world.gregs.voidps.type.random @@ -34,11 +36,12 @@ class QuizMaster : Script { init { RandomEvents.register("quiz_master") { startEvent() } - interfaceOption("Select", "dialogue_macro_quiz_show:button_*") { - if (get("random_event") != "quiz_master") { - return@interfaceOption - } - answer(it.component.removePrefix("button_").toInt()) + // The three answer buttons are dialogue-box components (setting=1 in the cache), so a click + // arrives as a dialogue continue rather than an interface option; resume the waiting question + // with the button's slot. + continueDialogue("dialogue_macro_quiz_show:button_*") { + val slot = it.substringAfter(":button_").toIntOrNull() ?: return@continueDialogue + (suspension as? Suspension.IntEntry)?.resume(slot) } } @@ -48,7 +51,21 @@ class QuizMaster : Script { kidnap(ROOM) face(QUIZ_MASTER) intro() - openQuestion() + runQuiz() + } + + private suspend fun Player.runQuiz() { + while (true) { + if (askQuestion() == get("quiz_answer", 0)) { + if (inc("quiz_correct") >= REQUIRED) { + break + } + npc("quiz_master", "Wow, you're a smart one! You're absolutely RIGHT! Okay, next question!") + } else { + npc("quiz_master", "WRONG! That's just WRONG! Okay, next question!") + } + } + win() } private suspend fun Player.quizHerald() { @@ -65,7 +82,8 @@ class QuizMaster : Script { npc("quiz_master", "Please welcome our newest contestant: $name! Just pick the O D D O N E O U T. Four questions right, and then you win!") } - private fun Player.openQuestion() { + /** Shows a fresh "odd one out" and suspends until the player picks a button, returning its slot. */ + private suspend fun Player.askQuestion(): Int { val set = SETS.random(random) val answer = set[0] val models = set.toList().shuffled(random) @@ -74,20 +92,9 @@ class QuizMaster : Script { interfaces.sendModel("dialogue_macro_quiz_show", "model_2", models[1]) interfaces.sendModel("dialogue_macro_quiz_show", "model_3", models[2]) open("dialogue_macro_quiz_show") - } - - private suspend fun Player.answer(slot: Int) { + val slot = pauseInt() close("dialogue_macro_quiz_show") - if (slot == get("quiz_answer", 0)) { - if (inc("quiz_correct") >= REQUIRED) { - win() - return - } - npc("quiz_master", "Wow, you're a smart one! You're absolutely RIGHT! Okay, next question!") - } else { - npc("quiz_master", "WRONG! That's just WRONG! Okay, next question!") - } - openQuestion() + return slot } private suspend fun Player.win() { diff --git a/game/src/test/kotlin/content/activity/event/random/QuizMasterTest.kt b/game/src/test/kotlin/content/activity/event/random/QuizMasterTest.kt index 6fc98cfa45..92868d9548 100644 --- a/game/src/test/kotlin/content/activity/event/random/QuizMasterTest.kt +++ b/game/src/test/kotlin/content/activity/event/random/QuizMasterTest.kt @@ -2,7 +2,6 @@ package content.activity.event.random import WorldTest import dialogueOption -import interfaceOption import org.junit.jupiter.api.Test import skipDialogues import world.gregs.voidps.engine.entity.character.player.Player @@ -23,16 +22,19 @@ class QuizMasterTest : WorldTest() { val player = createPlayer(origin, name) RandomEvents.start(player, "quiz_master") tick(8) - player.skipDialogues() // advance the three intro lines -> opens the quiz + player.skipDialogues() // advance the three intro lines -> opens the first question tick() return player } - private fun Player.answerCorrectly() { - interfaceOption(quiz, "button_${get("quiz_answer", 0)}", "Select") + /** Clicks the button at [slot] (a dialogue-continue) and lets the answer resolve. */ + private fun Player.pick(slot: Int) { + dialogueOption("button_$slot", quiz) tick() } + private fun Player.pickAnswer() = pick(get("quiz_answer", 0)) + @Test fun `Event whisks the player to the studio and opens the quiz`() { val player = enter("quiz_start") @@ -47,14 +49,12 @@ class QuizMasterTest : WorldTest() { val player = enter("quiz_answer") val answer = player.get("quiz_answer", 0) - val wrong = if (answer == 1) 2 else 1 - player.interfaceOption(quiz, "button_$wrong", "Select") - tick() + player.pick(if (answer == 1) 2 else 1) // wrong button assertEquals(0, player.get("quiz_correct", 0)) player.skipDialogues() // "WRONG!" -> reopens the quiz tick() - player.answerCorrectly() + player.pickAnswer() assertEquals(1, player.get("quiz_correct", 0)) } @@ -63,7 +63,7 @@ class QuizMasterTest : WorldTest() { val player = enter("quiz_coins") repeat(4) { - player.answerCorrectly() + player.pickAnswer() player.skipDialogues() // "RIGHT!" (or the winner line on the 4th) -> reopens quiz / opens the prize choice tick() } @@ -81,7 +81,7 @@ class QuizMasterTest : WorldTest() { val player = enter("quiz_item") repeat(4) { - player.answerCorrectly() + player.pickAnswer() player.skipDialogues() tick() } From 911357998aeb98485b166baec6cc1ab04f01e6f6 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Wed, 8 Jul 2026 12:43:31 -0700 Subject: [PATCH 35/89] Add Pillory random event A guard arrests the player into a pillory; unlock it (interface 189) by picking the swinging key matching the spinning lock, N-in-a-row (starts 3, +1 and resets on a wrong pick, cap 6). Escaping rewards a roll of the shared certer table. Ported from 2009scape; object/tile ids remapped for the 634 cache (cage 777, city pillory tiles). Ref GregHib/void-private#268 --- .../event/random/pillory/pillory.ifaces.toml | 47 +++++++ .../event/random/pillory/pillory.npcs.toml | 4 + .../event/random/pillory/pillory.objs.toml | 3 + .../event/random/pillory/pillory.vars.toml | 10 ++ .../event/random/random_event.tables.toml | 4 + .../activity/event/random/pillory/Pillory.kt | 125 ++++++++++++++++++ .../activity/event/random/PilloryTest.kt | 92 +++++++++++++ 7 files changed, 285 insertions(+) create mode 100644 data/activity/event/random/pillory/pillory.ifaces.toml create mode 100644 data/activity/event/random/pillory/pillory.npcs.toml create mode 100644 data/activity/event/random/pillory/pillory.objs.toml create mode 100644 data/activity/event/random/pillory/pillory.vars.toml create mode 100644 game/src/main/kotlin/content/activity/event/random/pillory/Pillory.kt create mode 100644 game/src/test/kotlin/content/activity/event/random/PilloryTest.kt diff --git a/data/activity/event/random/pillory/pillory.ifaces.toml b/data/activity/event/random/pillory/pillory.ifaces.toml new file mode 100644 index 0000000000..d2238ec676 --- /dev/null +++ b/data/activity/event/random/pillory/pillory.ifaces.toml @@ -0,0 +1,47 @@ +# Pillory lock puzzle (interface 189). The spinning lock (child 4) and three swinging keys (children +# 5-7) are model components we set each round; children 8-10 are the clickable key selectors +# (setting=2 in the cache => interface option), and children 11-16 are the progress padlocks. +[pillory_lock] +id = 189 + +[.lock] +id = 4 + +[.key_1] +id = 5 + +[.key_2] +id = 6 + +[.key_3] +id = 7 + +[.button_1] +id = 8 +options = { Select = 0 } + +[.button_2] +id = 9 +options = { Select = 0 } + +[.button_3] +id = 10 +options = { Select = 0 } + +[.padlock_1] +id = 11 + +[.padlock_2] +id = 12 + +[.padlock_3] +id = 13 + +[.padlock_4] +id = 14 + +[.padlock_5] +id = 15 + +[.padlock_6] +id = 16 diff --git a/data/activity/event/random/pillory/pillory.npcs.toml b/data/activity/event/random/pillory/pillory.npcs.toml new file mode 100644 index 0000000000..f89ed634f0 --- /dev/null +++ b/data/activity/event/random/pillory/pillory.npcs.toml @@ -0,0 +1,4 @@ +[pillory_guard] +id = 2791 +categories = ["human"] +examine = "He locks miscreants in the pillory." diff --git a/data/activity/event/random/pillory/pillory.objs.toml b/data/activity/event/random/pillory/pillory.objs.toml new file mode 100644 index 0000000000..6997d1ccd8 --- /dev/null +++ b/data/activity/event/random/pillory/pillory.objs.toml @@ -0,0 +1,3 @@ +[pillory_cage] +id = 777 +examine = "A pillory to lock up miscreants." diff --git a/data/activity/event/random/pillory/pillory.vars.toml b/data/activity/event/random/pillory/pillory.vars.toml new file mode 100644 index 0000000000..5a7b1aa0d6 --- /dev/null +++ b/data/activity/event/random/pillory/pillory.vars.toml @@ -0,0 +1,10 @@ +[pillory_target] +format = "int" +persist = true + +[pillory_correct] +format = "int" +persist = true + +[pillory_answer] +format = "int" diff --git a/data/activity/event/random/random_event.tables.toml b/data/activity/event/random/random_event.tables.toml index 259f1d42f2..bb4f911727 100644 --- a/data/activity/event/random/random_event.tables.toml +++ b/data/activity/event/random/random_event.tables.toml @@ -34,6 +34,10 @@ weight = 10 event = "quiz_master" weight = 10 +[.pillory] +event = "pillory" +weight = 10 + [random_event_settings] value = "int" diff --git a/game/src/main/kotlin/content/activity/event/random/pillory/Pillory.kt b/game/src/main/kotlin/content/activity/event/random/pillory/Pillory.kt new file mode 100644 index 0000000000..95090f7ee4 --- /dev/null +++ b/game/src/main/kotlin/content/activity/event/random/pillory/Pillory.kt @@ -0,0 +1,125 @@ +package content.activity.event.random.pillory + +import content.activity.event.random.RandomEvents +import content.activity.event.random.kidnap +import content.activity.event.random.rewardCerterLoot +import world.gregs.voidps.engine.Script +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.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.type.Tile +import world.gregs.voidps.type.random +import kotlin.math.min + +/** + * Pillory random event: a guard arrests the player and locks them in a pillory. To escape they + * unlock it (interface 189) by picking the swinging key whose shape matches the spinning lock, a set + * number of times in a row. A wrong pick resets the streak and raises the target (up to six). Escaping + * rewards a single roll of the shared gem/coin table. + * https://runescape.wiki/w/Random_events?oldid=3667851#Pillory + */ +class Pillory : Script { + + init { + RandomEvents.register("pillory") { startEvent() } + + objectOperate("Unlock", "pillory_cage", arrive = false) { + if (get("random_event") != "pillory") { + message("You can't unlock the pillory, you'll let all the prisoners out!") + return@objectOperate + } + renderPuzzle() + open("pillory_lock") + message("Pick the swinging key that matches the hole in the spinning lock.") + } + + // The key selectors (components 8-10) are option buttons (setting=2 in the cache). + interfaceOption("Select", "pillory_lock:button_*") { + if (get("random_event") != "pillory") { + return@interfaceOption + } + answer(it.component.removePrefix("button_").toInt()) + } + } + + private suspend fun Player.startEvent() { + set("pillory_target", 3) + set("pillory_correct", 0) + pilloryGuard() + kidnap(LOCATIONS.random(random)) + message("Solve the pillory puzzle to be returned to where you came from.") + } + + private suspend fun Player.pilloryGuard() { + val guard = NPCs.addRandom("pillory_guard", tile.toCuboid(1), ticks = 25, owner = this) + ?: NPCs.add("pillory_guard", tile, ticks = 25, owner = this) + guard.watch(this) + guard.say("$name, you're under arrest!") + delay(2) + } + + /** Shuffle three distinct keys onto the interface, pick one as the lock, and update the padlocks. */ + private fun Player.renderPuzzle() { + val keys = (0..3).shuffled(random).take(3) + val lock = keys.random(random) + set("pillory_answer", keys.indexOf(lock) + 1) // 1-based slot of the matching key + interfaces.sendModel("pillory_lock", "lock", LOCK_MODEL + lock) + for (slot in 1..3) { + interfaces.sendModel("pillory_lock", "key_$slot", KEY_MODEL + keys[slot - 1]) + } + val target = get("pillory_target", 3) + val correct = get("pillory_correct", 0) + for (i in 1..6) { + interfaces.sendModel("pillory_lock", "padlock_$i", if (i <= correct) PADLOCK_GREEN else PADLOCK_RED) + interfaces.sendVisibility("pillory_lock", "padlock_$i", i <= target) + } + } + + private suspend fun Player.answer(slot: Int) { + if (slot == get("pillory_answer", 0)) { + val correct = inc("pillory_correct") + if (correct >= get("pillory_target", 3)) { + escape() + } else { + renderPuzzle() // the interface stays open for the next round + message("Correct! $correct down, ${get("pillory_target", 3) - correct} to go!") + } + } else { + set("pillory_correct", 0) + set("pillory_target", min(MAX_TARGET, get("pillory_target", 3) + 1)) + close("pillory_lock") + message("Bah, that's not right. Use the key that matches the hole in the spinning lock.") + } + } + + private fun Player.escape() { + close("pillory_lock") + message("You've escaped!") + rewardCerterLoot() + clear("pillory_target") + clear("pillory_correct") + clear("pillory_answer") + RandomEvents.complete(this) + } + + companion object { + private const val LOCK_MODEL = 9753 // lock type L -> model 9753 + L + private const val KEY_MODEL = 9749 // key type K -> model 9749 + K + private const val PADLOCK_RED = 9757 + private const val PADLOCK_GREEN = 9758 + private const val MAX_TARGET = 6 + + // The pillory cages (object 777) as placed in the 634 map: Varrock, Seers' Village, Yanille. + private val LOCATIONS = listOf( + Tile(3228, 3408), + Tile(3230, 3408), + Tile(2681, 3488), + Tile(2683, 3488), + Tile(2685, 3488), + Tile(2608, 3104), + ) + } +} diff --git a/game/src/test/kotlin/content/activity/event/random/PilloryTest.kt b/game/src/test/kotlin/content/activity/event/random/PilloryTest.kt new file mode 100644 index 0000000000..443624f519 --- /dev/null +++ b/game/src/test/kotlin/content/activity/event/random/PilloryTest.kt @@ -0,0 +1,92 @@ +package content.activity.event.random + +import WorldTest +import interfaceOption +import objectOption +import org.junit.jupiter.api.Test +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.entity.obj.GameObjects +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 PilloryTest : WorldTest() { + + private val origin = Tile(3221, 3218) + private val cages = listOf( + Tile(3228, 3408), + Tile(3230, 3408), + Tile(2681, 3488), + Tile(2683, 3488), + Tile(2685, 3488), + Tile(2608, 3104), + ) + + private fun enter(name: String): Player { + val player = createPlayer(origin, name) + RandomEvents.start(player, "pillory") + tick(8) + return player + } + + private fun openPuzzle(player: Player) { + val cage = GameObjects.add("pillory_cage", player.tile) + player.objectOption(cage, "Unlock") + tick() + } + + private fun Player.pickAnswer() { + interfaceOption("pillory_lock", "button_${get("pillory_answer", 0)}", "Select") + tick() + } + + @Test + fun `Guard arrests the player into a pillory cage`() { + val player = enter("pil_start") + + assertEquals("pillory", player.get("random_event")) + assertTrue(cages.contains(player.tile), "Expected a cage tile, was ${player.tile}") + assertEquals(3, player.get("pillory_target", 0)) + } + + @Test + fun `A correct key increments the streak`() { + val player = enter("pil_correct") + openPuzzle(player) + + player.pickAnswer() + + assertEquals(1, player.get("pillory_correct", 0)) + } + + @Test + fun `A wrong key resets the streak and raises the target`() { + val player = enter("pil_wrong") + openPuzzle(player) + + val answer = player.get("pillory_answer", 0) + val wrong = (1..3).first { it != answer } + player.interfaceOption("pillory_lock", "button_$wrong", "Select") + tick() + + assertEquals(0, player.get("pillory_correct", 0)) + assertEquals(4, player.get("pillory_target", 0)) + } + + @Test + fun `Three correct keys free the player and reward loot`() { + val player = enter("pil_finish") + openPuzzle(player) + + repeat(3) { player.pickAnswer() } + tick(2) + + assertFalse(player.inventory.isEmpty()) + assertNull(player.get("random_event")) + assertEquals(origin, player.tile) + assertTrue(player.contains("random_event_cooldown")) + } +} From 7cbb7de748a808c07942adbfc3ff70f44e1c10b3 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Wed, 8 Jul 2026 12:43:31 -0700 Subject: [PATCH 36/89] Add Pillory random event A guard arrests the player into a pillory; unlock it (interface 189) by picking the swinging key matching the spinning lock, N-in-a-row (starts 3, +1 and resets on a wrong pick, cap 6). Escaping rewards a roll of the shared certer table. Ported from 2009scape; object/tile ids remapped for the 634 cache (cage 777, city pillory tiles). Ref GregHib/void-private#268 --- .../content/activity/event/random/pillory/Pillory.kt | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/game/src/main/kotlin/content/activity/event/random/pillory/Pillory.kt b/game/src/main/kotlin/content/activity/event/random/pillory/Pillory.kt index 95090f7ee4..a859dbba6c 100644 --- a/game/src/main/kotlin/content/activity/event/random/pillory/Pillory.kt +++ b/game/src/main/kotlin/content/activity/event/random/pillory/Pillory.kt @@ -114,12 +114,12 @@ class Pillory : Script { // The pillory cages (object 777) as placed in the 634 map: Varrock, Seers' Village, Yanille. private val LOCATIONS = listOf( - Tile(3228, 3408), - Tile(3230, 3408), - Tile(2681, 3488), - Tile(2683, 3488), - Tile(2685, 3488), - Tile(2608, 3104), + Tile(3228, 3407), + Tile(3230, 3407), + Tile(2681, 3489), + Tile(2683, 3489), + Tile(2685, 3489), + Tile(2608, 3105), ) } } From 1e1d8bb604e040f53baa239957a6ef0be9bcacb1 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Wed, 8 Jul 2026 13:20:15 -0700 Subject: [PATCH 37/89] Add Surprise Exam random event The Mysterious Old Man teleports the player into Mr Mordaut's classroom; talking to Mordaut opens a 'what comes next?' pattern quiz (interface 103) - pick the item that completes the three shown. Three correct passes the exam and Mordaut names one of four coloured doors; leaving through it returns the player with a Book of Knowledge (an XP lamp). Ported from 2009scape. Ref GregHib/void-private#263 Also relax the flaky Pillory arrest assertion to allow landing beside a cage when the stocks block the tile. --- .../event/random/random_event.tables.toml | 4 + .../surprise_exam/surprise_exam.ifaces.toml | 30 ++++ .../surprise_exam/surprise_exam.objs.toml | 12 ++ .../surprise_exam/surprise_exam.vars.toml | 10 ++ .../random/surprise_exam/SurpriseExam.kt | 154 ++++++++++++++++++ .../activity/event/random/PilloryTest.kt | 3 +- .../activity/event/random/SurpriseExamTest.kt | 95 +++++++++++ 7 files changed, 307 insertions(+), 1 deletion(-) create mode 100644 data/activity/event/random/surprise_exam/surprise_exam.ifaces.toml create mode 100644 data/activity/event/random/surprise_exam/surprise_exam.objs.toml create mode 100644 data/activity/event/random/surprise_exam/surprise_exam.vars.toml create mode 100644 game/src/main/kotlin/content/activity/event/random/surprise_exam/SurpriseExam.kt create mode 100644 game/src/test/kotlin/content/activity/event/random/SurpriseExamTest.kt diff --git a/data/activity/event/random/random_event.tables.toml b/data/activity/event/random/random_event.tables.toml index bb4f911727..993fbaecee 100644 --- a/data/activity/event/random/random_event.tables.toml +++ b/data/activity/event/random/random_event.tables.toml @@ -38,6 +38,10 @@ weight = 10 event = "pillory" weight = 10 +[.surprise_exam] +event = "surprise_exam" +weight = 10 + [random_event_settings] value = "int" diff --git a/data/activity/event/random/surprise_exam/surprise_exam.ifaces.toml b/data/activity/event/random/surprise_exam/surprise_exam.ifaces.toml new file mode 100644 index 0000000000..8cc41b7dc2 --- /dev/null +++ b/data/activity/event/random/surprise_exam/surprise_exam.ifaces.toml @@ -0,0 +1,30 @@ +# Mr Mordaut's "what comes next?" pattern quiz (interface 103). The three pattern icons sit on +# children 6-8; the four answer icons on children 10-13 are the clickable options (setting=2 in the +# cache => interface option), declared with a Select action so clicks resolve. +[surprise_exam_pattern] +id = 103 + +[.pattern_1] +id = 6 + +[.pattern_2] +id = 7 + +[.pattern_3] +id = 8 + +[.option_1] +id = 10 +options = { Select = 0 } + +[.option_2] +id = 11 +options = { Select = 0 } + +[.option_3] +id = 12 +options = { Select = 0 } + +[.option_4] +id = 13 +options = { Select = 0 } diff --git a/data/activity/event/random/surprise_exam/surprise_exam.objs.toml b/data/activity/event/random/surprise_exam/surprise_exam.objs.toml new file mode 100644 index 0000000000..e76edbe4de --- /dev/null +++ b/data/activity/event/random/surprise_exam/surprise_exam.objs.toml @@ -0,0 +1,12 @@ +# The four coloured exam doors in Mr Mordaut's classroom (region 7502). Ids match the 634 cache. +[exam_door_red] +id = 2188 + +[exam_door_blue] +id = 2189 + +[exam_door_purple] +id = 2192 + +[exam_door_green] +id = 2193 diff --git a/data/activity/event/random/surprise_exam/surprise_exam.vars.toml b/data/activity/event/random/surprise_exam/surprise_exam.vars.toml new file mode 100644 index 0000000000..5004f1c460 --- /dev/null +++ b/data/activity/event/random/surprise_exam/surprise_exam.vars.toml @@ -0,0 +1,10 @@ +[surprise_exam_answer] +format = "int" + +[surprise_exam_correct] +format = "int" +persist = true + +[surprise_exam_door] +format = "string" +persist = true diff --git a/game/src/main/kotlin/content/activity/event/random/surprise_exam/SurpriseExam.kt b/game/src/main/kotlin/content/activity/event/random/surprise_exam/SurpriseExam.kt new file mode 100644 index 0000000000..2f0241c0ca --- /dev/null +++ b/game/src/main/kotlin/content/activity/event/random/surprise_exam/SurpriseExam.kt @@ -0,0 +1,154 @@ +package content.activity.event.random.surprise_exam + +import content.activity.event.random.RandomEvents +import content.activity.event.random.kidnap +import content.activity.event.random.mysteriousOldMan +import content.entity.player.dialogue.Neutral +import content.entity.player.dialogue.type.npc +import content.entity.player.dialogue.type.skillLamp +import content.entity.player.dialogue.type.statement +import world.gregs.voidps.engine.Script +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.definition.ItemDefinitions +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.exp.exp +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 + +/** + * Surprise Exam random event: the Mysterious Old Man teleports the player into Mr Mordaut's classroom. + * Talking to Mordaut opens a "what comes next?" pattern quiz (interface 103) - pick the item that + * belongs with the three shown. Three correct answers pass the exam; Mordaut then names one of four + * coloured doors, and leaving through it returns the player with a Book of Knowledge (an XP lamp). + * https://runescape.wiki/w/Random_events?oldid=3667851#Surprise_Exam + */ +class SurpriseExam : Script { + + init { + RandomEvents.register("surprise_exam") { startEvent() } + + npcOperate("Talk-to", "mr_mordau") { (mordaut) -> + if (get("random_event") != "surprise_exam") { + npc("mr_mordau", "I'm rather busy, please don't interrupt my class.") + return@npcOperate + } + face(mordaut.tile) + val door = get("surprise_exam_door") + if (door != null) { + npc("mr_mordau", "Well done! Please exit through the ${DOOR_TEXT[door]} door.") + } else { + npc("mr_mordau", "Please answer these questions for me.") + openQuestion() + } + } + + interfaceOption("Select", "surprise_exam_pattern:option_*") { + if (get("random_event") != "surprise_exam") { + return@interfaceOption + } + answer(it.component.removePrefix("option_").toInt()) // option_1..4 + } + + objectOperate("Open", "exam_door_*") { (door) -> + if (get("random_event") != "surprise_exam") { + return@objectOperate + } + when (get("surprise_exam_door")) { + null -> statement("I should probably speak with Mr. Mordaut first.") + door.id -> finish() + else -> statement("The door won't budge. Perhaps I should ask for directions.") + } + } + + itemOption("Read", "book_of_knowledge") { (item, slot) -> + val skill = skillLamp() + if (inventory.remove(slot, item.id)) { + exp(skill, levels.getMax(skill) * XP_PER_LEVEL) + statement("You feel more knowledgeable about ${skill.name.lowercase()}.") + } + } + } + + private suspend fun Player.startEvent() { + set("surprise_exam_correct", 0) + clear("surprise_exam_door") + mysteriousOldMan() + kidnap(CLASSROOM) + message("Speak to Mr Mordaut to begin your exam.") + } + + /** Show three icons of one category plus four options (the matching item among decoys). */ + private fun Player.openQuestion() { + val set = SETS.random(random) + val shuffled = set.shuffled(random) + val correct = shuffled.random(random) + val index = shuffled.indexOf(correct) // which option slot holds the answer + val pattern = shuffled.filter { it != correct } + val decoy = SETS.filter { it != set }.random(random) + set("surprise_exam_answer", index + 1) // 1-based to dodge the set-clears-0 gotcha + for (i in 1..3) { + interfaces.sendItem("surprise_exam_pattern", "pattern_$i", ItemDefinitions.get(pattern[i - 1]).id) + } + for (slot in 0..3) { + val item = if (slot == index) correct else decoy[slot] + interfaces.sendItem("surprise_exam_pattern", "option_${slot + 1}", ItemDefinitions.get(item).id) + } + open("surprise_exam_pattern") + } + + private suspend fun Player.answer(option: Int) { + close("surprise_exam_pattern") + if (option == get("surprise_exam_answer", 0)) { + if (inc("surprise_exam_correct") >= REQUIRED) { + set("surprise_exam_door", DOORS.random(random)) + npc("mr_mordau", "Excellent work! You've passed. Please exit through the ${DOOR_TEXT[get("surprise_exam_door")]} door.") + return + } + npc("mr_mordau", "Excellent work! Now for another...") + } else { + npc("mr_mordau", "I'm afraid that isn't correct. Now for another...") + } + openQuestion() + } + + private fun Player.finish() { + message("You've passed the exam!") + if (!inventory.add("book_of_knowledge")) { + FloorItems.add(tile, "book_of_knowledge", 1, disappearTicks = 300, owner = this) + } + clear("surprise_exam_answer") + clear("surprise_exam_correct") + clear("surprise_exam_door") + RandomEvents.complete(this) + jingle("surprise_exam_passed") + } + + companion object { + private const val REQUIRED = 3 + private const val XP_PER_LEVEL = 15.0 + private val CLASSROOM = Tile(1886, 5025) + + private val DOOR_TEXT = mapOf( + "exam_door_red" to "red cross", + "exam_door_blue" to "blue star", + "exam_door_purple" to "purple circle", + "exam_door_green" to "green square", + ) + private val DOORS = DOOR_TEXT.keys.toList() + + // Category sets: pick the option that completes the three shown (from 2009scape). + private val SETS = listOf( + listOf("gardening_trowel", "secateurs", "seed_dibber", "rake"), + listOf("salmon", "shark", "trout", "shrimps"), + listOf("bronze_sword", "wooden_shield", "bronze_med_helm", "adamant_battleaxe"), + listOf("fly_fishing_rod", "barbarian_rod", "small_fishing_net", "harpoon"), + ) + } +} diff --git a/game/src/test/kotlin/content/activity/event/random/PilloryTest.kt b/game/src/test/kotlin/content/activity/event/random/PilloryTest.kt index 443624f519..f859c41f17 100644 --- a/game/src/test/kotlin/content/activity/event/random/PilloryTest.kt +++ b/game/src/test/kotlin/content/activity/event/random/PilloryTest.kt @@ -48,7 +48,8 @@ class PilloryTest : WorldTest() { val player = enter("pil_start") assertEquals("pillory", player.get("random_event")) - assertTrue(cages.contains(player.tile), "Expected a cage tile, was ${player.tile}") + // The player lands on (or, if the stocks block the tile, right beside) a pillory cage. + assertTrue(cages.any { player.tile.within(it, 1) }, "Expected by a cage, was ${player.tile}") assertEquals(3, player.get("pillory_target", 0)) } diff --git a/game/src/test/kotlin/content/activity/event/random/SurpriseExamTest.kt b/game/src/test/kotlin/content/activity/event/random/SurpriseExamTest.kt new file mode 100644 index 0000000000..d33c219ce1 --- /dev/null +++ b/game/src/test/kotlin/content/activity/event/random/SurpriseExamTest.kt @@ -0,0 +1,95 @@ +package content.activity.event.random + +import WorldTest +import interfaceOption +import npcOption +import objectOption +import org.junit.jupiter.api.Test +import skipDialogues +import world.gregs.voidps.engine.entity.character.npc.NPC +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.entity.obj.GameObjects +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 SurpriseExamTest : WorldTest() { + + private val origin = Tile(3221, 3218) + private val classroom = Tile(1886, 5025) + private val iface = "surprise_exam_pattern" + + private fun enter(name: String): Player { + val player = createPlayer(origin, name) + RandomEvents.start(player, "surprise_exam") + tick(8) + return player + } + + /** Spawn Mordaut beside the player and talk to him to open the first question. */ + private fun openExam(player: Player): NPC { + val mordaut = createNPC("mr_mordau", player.tile.addX(1)) + player.npcOption(mordaut, "Talk-to") + tick() + player.skipDialogues() // "Please answer these questions" -> opens the interface + tick() + return mordaut + } + + private fun Player.pickCorrect() { + interfaceOption(iface, "option_${get("surprise_exam_answer", 0)}", "Select") + tick() + } + + @Test + fun `Old man teleports the player into the classroom`() { + val player = enter("se_start") + + assertEquals("surprise_exam", player.get("random_event")) + assertTrue(player.tile.within(classroom, 6), "Expected the classroom, was ${player.tile}") + } + + @Test + fun `A correct answer counts and a wrong one does not`() { + val player = enter("se_answer") + openExam(player) + + val answer = player.get("surprise_exam_answer", 0) + val wrong = (1..4).first { it != answer } + player.interfaceOption(iface, "option_$wrong", "Select") + tick() + assertEquals(0, player.get("surprise_exam_correct", 0)) + player.skipDialogues() // "isn't correct" -> reopens + tick() + + player.pickCorrect() + assertEquals(1, player.get("surprise_exam_correct", 0)) + } + + @Test + fun `Passing assigns a door, and the right door frees the player with a book`() { + val player = enter("se_finish") + openExam(player) + + repeat(3) { + player.pickCorrect() + player.skipDialogues() // "Excellent, another" (or the pass line on the 3rd) + tick() + } + assertNotNull(player.get("surprise_exam_door")) + + // Leave via the assigned door (spawn it beside the player to interact deterministically). + player["surprise_exam_door"] = "exam_door_blue" + val door = GameObjects.add("exam_door_blue", player.tile) + player.objectOption(door, "Open") + tick(2) + + assertEquals(1, player.inventory.count("book_of_knowledge")) + assertNull(player.get("random_event")) + assertEquals(origin, player.tile) + assertTrue(player.contains("random_event_cooldown")) + } +} From 1ad84431deb4476f4dbe2007912a60b025ec4ed3 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Wed, 8 Jul 2026 13:28:11 -0700 Subject: [PATCH 38/89] Handle Surprise Exam answers as dialogue continues Interface 103 opens in the chatbox, so answer clicks arrive as dialogue-continue events (like Quiz Master's 191), not interface options. Open it as a dialogue_box, suspend each question on pauseInt, and resume from a continueDialogue handler keyed to the option clicked. --- .../surprise_exam/surprise_exam.ifaces.toml | 10 ++-- .../random/surprise_exam/SurpriseExam.kt | 49 ++++++++++--------- .../activity/event/random/SurpriseExamTest.kt | 11 +++-- 3 files changed, 36 insertions(+), 34 deletions(-) diff --git a/data/activity/event/random/surprise_exam/surprise_exam.ifaces.toml b/data/activity/event/random/surprise_exam/surprise_exam.ifaces.toml index 8cc41b7dc2..9c7f61b7d1 100644 --- a/data/activity/event/random/surprise_exam/surprise_exam.ifaces.toml +++ b/data/activity/event/random/surprise_exam/surprise_exam.ifaces.toml @@ -1,8 +1,8 @@ -# Mr Mordaut's "what comes next?" pattern quiz (interface 103). The three pattern icons sit on -# children 6-8; the four answer icons on children 10-13 are the clickable options (setting=2 in the -# cache => interface option), declared with a Select action so clicks resolve. +# Mr Mordaut's "what comes next?" pattern quiz (interface 103). It opens in the chatbox, so a click +# on an answer arrives as a dialogue continue keyed by the option child (not an interface option). [surprise_exam_pattern] id = 103 +type = "dialogue_box" [.pattern_1] id = 6 @@ -15,16 +15,12 @@ id = 8 [.option_1] id = 10 -options = { Select = 0 } [.option_2] id = 11 -options = { Select = 0 } [.option_3] id = 12 -options = { Select = 0 } [.option_4] id = 13 -options = { Select = 0 } diff --git a/game/src/main/kotlin/content/activity/event/random/surprise_exam/SurpriseExam.kt b/game/src/main/kotlin/content/activity/event/random/surprise_exam/SurpriseExam.kt index 2f0241c0ca..98a5668851 100644 --- a/game/src/main/kotlin/content/activity/event/random/surprise_exam/SurpriseExam.kt +++ b/game/src/main/kotlin/content/activity/event/random/surprise_exam/SurpriseExam.kt @@ -19,6 +19,8 @@ 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.engine.suspend.Suspension +import world.gregs.voidps.engine.suspend.pauseInt import world.gregs.voidps.type.Tile import world.gregs.voidps.type.random @@ -45,15 +47,15 @@ class SurpriseExam : Script { npc("mr_mordau", "Well done! Please exit through the ${DOOR_TEXT[door]} door.") } else { npc("mr_mordau", "Please answer these questions for me.") - openQuestion() + runExam() } } - interfaceOption("Select", "surprise_exam_pattern:option_*") { - if (get("random_event") != "surprise_exam") { - return@interfaceOption - } - answer(it.component.removePrefix("option_").toInt()) // option_1..4 + // The answer icons are chatbox components, so a click arrives as a dialogue continue; resume + // the waiting question with the option number. + continueDialogue("surprise_exam_pattern:option_*") { + val option = it.substringAfter(":option_").toIntOrNull() ?: return@continueDialogue + (suspension as? Suspension.IntEntry)?.resume(option) } objectOperate("Open", "exam_door_*") { (door) -> @@ -84,8 +86,23 @@ class SurpriseExam : Script { message("Speak to Mr Mordaut to begin your exam.") } - /** Show three icons of one category plus four options (the matching item among decoys). */ - private fun Player.openQuestion() { + private suspend fun Player.runExam() { + while (true) { + if (askQuestion() == get("surprise_exam_answer", 0)) { + if (inc("surprise_exam_correct") >= REQUIRED) { + set("surprise_exam_door", DOORS.random(random)) + npc("mr_mordau", "Excellent work! You've passed. Please exit through the ${DOOR_TEXT[get("surprise_exam_door")]} door.") + return + } + npc("mr_mordau", "Excellent work! Now for another...") + } else { + npc("mr_mordau", "I'm afraid that isn't correct. Now for another...") + } + } + } + + /** Show three icons of one category plus four options and suspend until the player picks one. */ + private suspend fun Player.askQuestion(): Int { val set = SETS.random(random) val shuffled = set.shuffled(random) val correct = shuffled.random(random) @@ -101,21 +118,9 @@ class SurpriseExam : Script { interfaces.sendItem("surprise_exam_pattern", "option_${slot + 1}", ItemDefinitions.get(item).id) } open("surprise_exam_pattern") - } - - private suspend fun Player.answer(option: Int) { + val option = pauseInt() close("surprise_exam_pattern") - if (option == get("surprise_exam_answer", 0)) { - if (inc("surprise_exam_correct") >= REQUIRED) { - set("surprise_exam_door", DOORS.random(random)) - npc("mr_mordau", "Excellent work! You've passed. Please exit through the ${DOOR_TEXT[get("surprise_exam_door")]} door.") - return - } - npc("mr_mordau", "Excellent work! Now for another...") - } else { - npc("mr_mordau", "I'm afraid that isn't correct. Now for another...") - } - openQuestion() + return option } private fun Player.finish() { diff --git a/game/src/test/kotlin/content/activity/event/random/SurpriseExamTest.kt b/game/src/test/kotlin/content/activity/event/random/SurpriseExamTest.kt index d33c219ce1..f69f0cbe07 100644 --- a/game/src/test/kotlin/content/activity/event/random/SurpriseExamTest.kt +++ b/game/src/test/kotlin/content/activity/event/random/SurpriseExamTest.kt @@ -1,7 +1,7 @@ package content.activity.event.random import WorldTest -import interfaceOption +import dialogueOption import npcOption import objectOption import org.junit.jupiter.api.Test @@ -39,11 +39,13 @@ class SurpriseExamTest : WorldTest() { return mordaut } - private fun Player.pickCorrect() { - interfaceOption(iface, "option_${get("surprise_exam_answer", 0)}", "Select") + private fun Player.pick(option: Int) { + dialogueOption("option_$option", iface) tick() } + private fun Player.pickCorrect() = pick(get("surprise_exam_answer", 0)) + @Test fun `Old man teleports the player into the classroom`() { val player = enter("se_start") @@ -59,8 +61,7 @@ class SurpriseExamTest : WorldTest() { val answer = player.get("surprise_exam_answer", 0) val wrong = (1..4).first { it != answer } - player.interfaceOption(iface, "option_$wrong", "Select") - tick() + player.pick(wrong) assertEquals(0, player.get("surprise_exam_correct", 0)) player.skipDialogues() // "isn't correct" -> reopens tick() From b293bd61bf206a33e2a53c7ac90754c34ffcd56a Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Wed, 8 Jul 2026 13:56:11 -0700 Subject: [PATCH 39/89] Open the Surprise Exam quiz in the main game window Interface 103 is a centred main-window modal, not a chatbox dialogue; set type=main_screen and take answers as interface options again. --- .../surprise_exam/surprise_exam.ifaces.toml | 11 ++-- .../random/surprise_exam/SurpriseExam.kt | 50 +++++++++---------- .../activity/event/random/SurpriseExamTest.kt | 4 +- 3 files changed, 33 insertions(+), 32 deletions(-) diff --git a/data/activity/event/random/surprise_exam/surprise_exam.ifaces.toml b/data/activity/event/random/surprise_exam/surprise_exam.ifaces.toml index 9c7f61b7d1..4b021e7647 100644 --- a/data/activity/event/random/surprise_exam/surprise_exam.ifaces.toml +++ b/data/activity/event/random/surprise_exam/surprise_exam.ifaces.toml @@ -1,8 +1,9 @@ -# Mr Mordaut's "what comes next?" pattern quiz (interface 103). It opens in the chatbox, so a click -# on an answer arrives as a dialogue continue keyed by the option child (not an interface option). +# Mr Mordaut's "what comes next?" pattern quiz (interface 103), a centred main-window modal. The +# three pattern icons sit on children 6-8; the four answer icons on children 10-13 are the clickable +# options, declared with a Select action so clicks resolve as interface options. [surprise_exam_pattern] id = 103 -type = "dialogue_box" +type = "main_screen" [.pattern_1] id = 6 @@ -15,12 +16,16 @@ id = 8 [.option_1] id = 10 +options = { Select = 0 } [.option_2] id = 11 +options = { Select = 0 } [.option_3] id = 12 +options = { Select = 0 } [.option_4] id = 13 +options = { Select = 0 } diff --git a/game/src/main/kotlin/content/activity/event/random/surprise_exam/SurpriseExam.kt b/game/src/main/kotlin/content/activity/event/random/surprise_exam/SurpriseExam.kt index 98a5668851..b5608c5065 100644 --- a/game/src/main/kotlin/content/activity/event/random/surprise_exam/SurpriseExam.kt +++ b/game/src/main/kotlin/content/activity/event/random/surprise_exam/SurpriseExam.kt @@ -19,8 +19,6 @@ 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.engine.suspend.Suspension -import world.gregs.voidps.engine.suspend.pauseInt import world.gregs.voidps.type.Tile import world.gregs.voidps.type.random @@ -47,15 +45,16 @@ class SurpriseExam : Script { npc("mr_mordau", "Well done! Please exit through the ${DOOR_TEXT[door]} door.") } else { npc("mr_mordau", "Please answer these questions for me.") - runExam() + openQuestion() } } - // The answer icons are chatbox components, so a click arrives as a dialogue continue; resume - // the waiting question with the option number. - continueDialogue("surprise_exam_pattern:option_*") { - val option = it.substringAfter(":option_").toIntOrNull() ?: return@continueDialogue - (suspension as? Suspension.IntEntry)?.resume(option) + // The answer icons are option buttons on a main-window interface. + interfaceOption("Select", "surprise_exam_pattern:option_*") { + if (get("random_event") != "surprise_exam") { + return@interfaceOption + } + answer(it.component.removePrefix("option_").toInt()) // option_1..4 } objectOperate("Open", "exam_door_*") { (door) -> @@ -86,23 +85,8 @@ class SurpriseExam : Script { message("Speak to Mr Mordaut to begin your exam.") } - private suspend fun Player.runExam() { - while (true) { - if (askQuestion() == get("surprise_exam_answer", 0)) { - if (inc("surprise_exam_correct") >= REQUIRED) { - set("surprise_exam_door", DOORS.random(random)) - npc("mr_mordau", "Excellent work! You've passed. Please exit through the ${DOOR_TEXT[get("surprise_exam_door")]} door.") - return - } - npc("mr_mordau", "Excellent work! Now for another...") - } else { - npc("mr_mordau", "I'm afraid that isn't correct. Now for another...") - } - } - } - - /** Show three icons of one category plus four options and suspend until the player picks one. */ - private suspend fun Player.askQuestion(): Int { + /** Show three icons of one category plus four options (the matching item among decoys). */ + private fun Player.openQuestion() { val set = SETS.random(random) val shuffled = set.shuffled(random) val correct = shuffled.random(random) @@ -118,9 +102,21 @@ class SurpriseExam : Script { interfaces.sendItem("surprise_exam_pattern", "option_${slot + 1}", ItemDefinitions.get(item).id) } open("surprise_exam_pattern") - val option = pauseInt() + } + + private suspend fun Player.answer(option: Int) { close("surprise_exam_pattern") - return option + if (option == get("surprise_exam_answer", 0)) { + if (inc("surprise_exam_correct") >= REQUIRED) { + set("surprise_exam_door", DOORS.random(random)) + npc("mr_mordau", "Excellent work! You've passed. Please exit through the ${DOOR_TEXT[get("surprise_exam_door")]} door.") + return + } + npc("mr_mordau", "Excellent work! Now for another...") + } else { + npc("mr_mordau", "I'm afraid that isn't correct. Now for another...") + } + openQuestion() } private fun Player.finish() { diff --git a/game/src/test/kotlin/content/activity/event/random/SurpriseExamTest.kt b/game/src/test/kotlin/content/activity/event/random/SurpriseExamTest.kt index f69f0cbe07..ac325d11d0 100644 --- a/game/src/test/kotlin/content/activity/event/random/SurpriseExamTest.kt +++ b/game/src/test/kotlin/content/activity/event/random/SurpriseExamTest.kt @@ -1,7 +1,7 @@ package content.activity.event.random import WorldTest -import dialogueOption +import interfaceOption import npcOption import objectOption import org.junit.jupiter.api.Test @@ -40,7 +40,7 @@ class SurpriseExamTest : WorldTest() { } private fun Player.pick(option: Int) { - dialogueOption("option_$option", iface) + interfaceOption(iface, "option_$option", "Select") tick() } From 0a0cd8ef71356ff9774c5cb8a1054d42fe70da9f Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Wed, 8 Jul 2026 14:03:42 -0700 Subject: [PATCH 40/89] Send the access mask for the Surprise Exam answer buttons Interface 103's answer icons are enabled in the cache but the client won't report a click until the server sends the interface settings; unlock them on open. --- .../activity/event/random/surprise_exam/SurpriseExam.kt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/game/src/main/kotlin/content/activity/event/random/surprise_exam/SurpriseExam.kt b/game/src/main/kotlin/content/activity/event/random/surprise_exam/SurpriseExam.kt index b5608c5065..5d70ca2abe 100644 --- a/game/src/main/kotlin/content/activity/event/random/surprise_exam/SurpriseExam.kt +++ b/game/src/main/kotlin/content/activity/event/random/surprise_exam/SurpriseExam.kt @@ -102,6 +102,11 @@ class SurpriseExam : Script { interfaces.sendItem("surprise_exam_pattern", "option_${slot + 1}", ItemDefinitions.get(item).id) } open("surprise_exam_pattern") + // Send the access mask so the client reports clicks on the answer icons; the cache marks the + // option enabled but doesn't send it, so without this the icons don't respond. + for (slot in 1..4) { + interfaceOptions.unlockAll("surprise_exam_pattern", "option_$slot") + } } private suspend fun Player.answer(option: Int) { From 4ed7e403b86ad52365076f23f4f92d5699f04d38 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Wed, 8 Jul 2026 14:09:38 -0700 Subject: [PATCH 41/89] Unlock Surprise Exam answer buttons over their item slot Send the interface settings for slot 0..0 of each answer component so the client enables the click. --- .../content/activity/event/random/surprise_exam/SurpriseExam.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/game/src/main/kotlin/content/activity/event/random/surprise_exam/SurpriseExam.kt b/game/src/main/kotlin/content/activity/event/random/surprise_exam/SurpriseExam.kt index 5d70ca2abe..f7d8e312cd 100644 --- a/game/src/main/kotlin/content/activity/event/random/surprise_exam/SurpriseExam.kt +++ b/game/src/main/kotlin/content/activity/event/random/surprise_exam/SurpriseExam.kt @@ -105,7 +105,7 @@ class SurpriseExam : Script { // Send the access mask so the client reports clicks on the answer icons; the cache marks the // option enabled but doesn't send it, so without this the icons don't respond. for (slot in 1..4) { - interfaceOptions.unlockAll("surprise_exam_pattern", "option_$slot") + interfaceOptions.unlockAll("surprise_exam_pattern", "option_$slot", 0..0) } } From 5ece6d87591c0ceb685c18b05cf9277eb5789a3e Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Wed, 8 Jul 2026 14:19:17 -0700 Subject: [PATCH 42/89] Dispatch clicks on the Surprise Exam item icons getInterfaceItem tried to resolve the clicked answer icon against an inventory the display-only interface doesn't have, so the click was silently dropped. Allowlist surprise_exam_pattern like the other display-only item interfaces (farming_equipment_store, common_item_costs). --- .../gregs/voidps/engine/client/instruction/InterfaceHandler.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/engine/src/main/kotlin/world/gregs/voidps/engine/client/instruction/InterfaceHandler.kt b/engine/src/main/kotlin/world/gregs/voidps/engine/client/instruction/InterfaceHandler.kt index e642b155ff..048797975a 100644 --- a/engine/src/main/kotlin/world/gregs/voidps/engine/client/instruction/InterfaceHandler.kt +++ b/engine/src/main/kotlin/world/gregs/voidps/engine/client/instruction/InterfaceHandler.kt @@ -36,6 +36,7 @@ class InterfaceHandler( item = Item(ItemDefinitions.get(expected).stringId) } id == "common_item_costs" -> item = Item(ItemDefinitions.get(itemId).stringId) + id == "surprise_exam_pattern" -> item = Item(ItemDefinitions.get(itemId).stringId) id == "farming_equipment_store" || id == "farming_equipment_store_side" -> {} else -> { val inventory = getInventory(player, id, component, componentDefinition) ?: return null From db639e44fe3d25e7c9b968e646d3a6c525273538 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Wed, 8 Jul 2026 14:19:17 -0700 Subject: [PATCH 43/89] Dispatch clicks on the Surprise Exam item icons getInterfaceItem tried to resolve the clicked answer icon against an inventory the display-only interface doesn't have, so the click was silently dropped. Allowlist surprise_exam_pattern like the other display-only item interfaces (farming_equipment_store, common_item_costs). --- .../content/activity/event/random/surprise_exam/SurpriseExam.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/game/src/main/kotlin/content/activity/event/random/surprise_exam/SurpriseExam.kt b/game/src/main/kotlin/content/activity/event/random/surprise_exam/SurpriseExam.kt index f7d8e312cd..64fea2d8be 100644 --- a/game/src/main/kotlin/content/activity/event/random/surprise_exam/SurpriseExam.kt +++ b/game/src/main/kotlin/content/activity/event/random/surprise_exam/SurpriseExam.kt @@ -149,7 +149,6 @@ class SurpriseExam : Script { ) private val DOORS = DOOR_TEXT.keys.toList() - // Category sets: pick the option that completes the three shown (from 2009scape). private val SETS = listOf( listOf("gardening_trowel", "secateurs", "seed_dibber", "rake"), listOf("salmon", "shark", "trout", "shrimps"), From 109ca73cbf2bd5f7b76187884836dc129a955d43 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Wed, 8 Jul 2026 14:28:00 -0700 Subject: [PATCH 44/89] Use the large chathead for Mr Mordaut's dialogue --- .../event/random/surprise_exam/SurpriseExam.kt | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/game/src/main/kotlin/content/activity/event/random/surprise_exam/SurpriseExam.kt b/game/src/main/kotlin/content/activity/event/random/surprise_exam/SurpriseExam.kt index 64fea2d8be..ee01724121 100644 --- a/game/src/main/kotlin/content/activity/event/random/surprise_exam/SurpriseExam.kt +++ b/game/src/main/kotlin/content/activity/event/random/surprise_exam/SurpriseExam.kt @@ -36,15 +36,15 @@ class SurpriseExam : Script { npcOperate("Talk-to", "mr_mordau") { (mordaut) -> if (get("random_event") != "surprise_exam") { - npc("mr_mordau", "I'm rather busy, please don't interrupt my class.") + npc("mr_mordau", "I'm rather busy, please don't interrupt my class.", largeHead = true) return@npcOperate } face(mordaut.tile) val door = get("surprise_exam_door") if (door != null) { - npc("mr_mordau", "Well done! Please exit through the ${DOOR_TEXT[door]} door.") + npc("mr_mordau", "Well done! Please exit through the ${DOOR_TEXT[door]} door.", largeHead = true) } else { - npc("mr_mordau", "Please answer these questions for me.") + npc("mr_mordau", "Please answer these questions for me.", largeHead = true) openQuestion() } } @@ -114,12 +114,12 @@ class SurpriseExam : Script { if (option == get("surprise_exam_answer", 0)) { if (inc("surprise_exam_correct") >= REQUIRED) { set("surprise_exam_door", DOORS.random(random)) - npc("mr_mordau", "Excellent work! You've passed. Please exit through the ${DOOR_TEXT[get("surprise_exam_door")]} door.") + npc("mr_mordau", "Excellent work! You've passed. Please exit through the ${DOOR_TEXT[get("surprise_exam_door")]} door.", largeHead = true) return } - npc("mr_mordau", "Excellent work! Now for another...") + npc("mr_mordau", "Excellent work! Now for another...", largeHead = true) } else { - npc("mr_mordau", "I'm afraid that isn't correct. Now for another...") + npc("mr_mordau", "I'm afraid that isn't correct. Now for another...", largeHead = true) } openQuestion() } From 023822c4be38d05c6e557d0920dc0c7fd3317ec3 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Wed, 8 Jul 2026 14:38:14 -0700 Subject: [PATCH 45/89] Address review: clear random events on reload, reuse addOrDrop Make RandomEvents AutoCloseable and register it in ContentLoader so a script reload resets the launcher map instead of keeping stale entries. Replace the duplicated give-or-drop helpers with the existing Player.addOrDrop. --- game/src/main/kotlin/ContentLoader.kt | 2 ++ .../activity/event/random/RandomEventRewards.kt | 16 ++++------------ .../activity/event/random/RandomEvents.kt | 7 ++++++- .../content/activity/event/random/maze/Maze.kt | 11 +++-------- .../event/random/quiz_master/QuizMaster.kt | 14 +++----------- .../event/random/sandwich_lady/SandwichLady.kt | 8 ++------ .../event/random/surprise_exam/SurpriseExam.kt | 7 ++----- 7 files changed, 22 insertions(+), 43 deletions(-) diff --git a/game/src/main/kotlin/ContentLoader.kt b/game/src/main/kotlin/ContentLoader.kt index 9bb4bb0a07..3ac7416ccd 100644 --- a/game/src/main/kotlin/ContentLoader.kt +++ b/game/src/main/kotlin/ContentLoader.kt @@ -1,4 +1,5 @@ import com.github.michaelbull.logging.InlineLogger +import content.activity.event.random.RandomEvents import content.skill.prayer.PrayerApi import world.gregs.voidps.engine.Script import world.gregs.voidps.engine.client.ui.chat.plural @@ -44,6 +45,7 @@ class ContentLoader { private fun loadContentApis() { Script.interfaces.add(PrayerApi) + Script.interfaces.add(RandomEvents) } private fun loadScript(name: String): Any { diff --git a/game/src/main/kotlin/content/activity/event/random/RandomEventRewards.kt b/game/src/main/kotlin/content/activity/event/random/RandomEventRewards.kt index 4b0e36d5e0..2a2f34e643 100644 --- a/game/src/main/kotlin/content/activity/event/random/RandomEventRewards.kt +++ b/game/src/main/kotlin/content/activity/event/random/RandomEventRewards.kt @@ -1,11 +1,9 @@ package content.activity.event.random import content.entity.player.bank.ownsItem +import content.entity.player.inv.item.addOrDrop import world.gregs.voidps.engine.data.definition.Tables import world.gregs.voidps.engine.entity.character.player.Player -import world.gregs.voidps.engine.entity.item.floor.FloorItems -import world.gregs.voidps.engine.inv.add -import world.gregs.voidps.engine.inv.inventory import world.gregs.voidps.type.random /** @@ -17,9 +15,9 @@ import world.gregs.voidps.type.random fun Player.rewardCostumeOrCoins(vararg pieces: String, coins: Int) { val missing = pieces.firstOrNull { !ownsItem(it) } if (missing != null) { - giveOrDrop(missing, 1) + addOrDrop(missing) } else { - giveOrDrop("coins", coins) + addOrDrop("coins", coins) } } @@ -32,14 +30,8 @@ fun Player.rewardCerterLoot() { for (row in rows) { roll -= row.int("weight") if (roll < 0) { - giveOrDrop(row.item("item"), random.nextInt(row.int("min"), row.int("max") + 1)) + addOrDrop(row.item("item"), random.nextInt(row.int("min"), row.int("max") + 1)) return } } } - -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/RandomEvents.kt b/game/src/main/kotlin/content/activity/event/random/RandomEvents.kt index 404b90bfed..645408d5cf 100644 --- a/game/src/main/kotlin/content/activity/event/random/RandomEvents.kt +++ b/game/src/main/kotlin/content/activity/event/random/RandomEvents.kt @@ -25,7 +25,7 @@ import java.util.concurrent.TimeUnit * 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 { +object RandomEvents : AutoCloseable { private val events = mutableMapOf Unit>() @@ -34,6 +34,11 @@ object RandomEvents { events[id] = launcher } + // Reset registrations between script (re)loads so a reload can't leave stale launchers behind. + override fun close() { + events.clear() + } + /** * Weighted pick over the `random_events` table, skipping rows without a registered launcher. */ 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 d36d7f6c40..7b3f46f946 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 @@ -6,6 +6,7 @@ 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 +import content.entity.player.inv.item.addOrDrop import content.quest.closeTabs import content.quest.instance import content.quest.instanceOffset @@ -23,10 +24,8 @@ 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 @@ -119,9 +118,7 @@ class Maze : Script { 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) - } + addOrDrop(reward, amount) item(reward, row.string("text")) } @@ -155,9 +152,7 @@ class Maze : Script { return } val item = row.item("item") - if (!player.inventory.add(item, amount)) { - FloorItems.add(player.tile, item, amount, disappearTicks = 300, owner = player) - } + player.addOrDrop(item, amount) } companion object { diff --git a/game/src/main/kotlin/content/activity/event/random/quiz_master/QuizMaster.kt b/game/src/main/kotlin/content/activity/event/random/quiz_master/QuizMaster.kt index 868e2af97c..63e8da5d82 100644 --- a/game/src/main/kotlin/content/activity/event/random/quiz_master/QuizMaster.kt +++ b/game/src/main/kotlin/content/activity/event/random/quiz_master/QuizMaster.kt @@ -8,6 +8,7 @@ import content.entity.player.dialogue.Hysterics 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.inv.item.addOrDrop import world.gregs.voidps.engine.Script import world.gregs.voidps.engine.client.message import world.gregs.voidps.engine.client.ui.close @@ -16,9 +17,6 @@ import world.gregs.voidps.engine.data.definition.Tables import world.gregs.voidps.engine.entity.character.npc.NPCs import world.gregs.voidps.engine.entity.character.player.Player import world.gregs.voidps.engine.entity.character.player.name -import world.gregs.voidps.engine.entity.item.floor.FloorItems -import world.gregs.voidps.engine.inv.add -import world.gregs.voidps.engine.inv.inventory import world.gregs.voidps.engine.suspend.Suspension import world.gregs.voidps.engine.suspend.pauseInt import world.gregs.voidps.type.Tile @@ -100,10 +98,10 @@ class QuizMaster : Script { private suspend fun Player.win() { npc("quiz_master", "CONGRATULATIONS! You are a WINNER! Please choose your PRIZE!") choice { - option("1000 Coins") { giveOrDrop("coins", 1000) } + option("1000 Coins") { addOrDrop("coins", 1000) } option("Random Item") { val row = Tables.get("random_event_quiz").rows().random(random) - giveOrDrop(row.item("item"), row.int("amount")) + addOrDrop(row.item("item"), row.int("amount")) } } clear("quiz_answer") @@ -112,12 +110,6 @@ class QuizMaster : Script { message("Welcome back.") } - private fun Player.giveOrDrop(item: String, amount: Int) { - if (!inventory.add(item, amount)) { - FloorItems.add(tile, item, amount, disappearTicks = 300, owner = this) - } - } - companion object { private const val REQUIRED = 4 private val ROOM = Tile(1952, 4764, 1) diff --git a/game/src/main/kotlin/content/activity/event/random/sandwich_lady/SandwichLady.kt b/game/src/main/kotlin/content/activity/event/random/sandwich_lady/SandwichLady.kt index 929a42bfa7..b3ae1ab135 100644 --- a/game/src/main/kotlin/content/activity/event/random/sandwich_lady/SandwichLady.kt +++ b/game/src/main/kotlin/content/activity/event/random/sandwich_lady/SandwichLady.kt @@ -6,6 +6,7 @@ import content.entity.combat.hit.directHit import content.entity.combat.inCombat import content.entity.player.dialogue.Happy import content.entity.player.dialogue.type.npc +import content.entity.player.inv.item.addOrDrop import world.gregs.voidps.engine.Script import world.gregs.voidps.engine.client.message import world.gregs.voidps.engine.client.ui.close @@ -14,9 +15,6 @@ 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.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.type.random /** @@ -83,9 +81,7 @@ class SandwichLady : Script { private suspend fun Player.serve(food: String) { message("The sandwich lady gives you a ${description(food)}!") - if (!inventory.add(food)) { - FloorItems.add(tile, food, 1, disappearTicks = 300, owner = this) - } + addOrDrop(food) npc("sandwich_lady", "Hope that fills you up!") // Clearing the event state removes the following NPC on its next tick. RandomEvents.completeInPlace(this) diff --git a/game/src/main/kotlin/content/activity/event/random/surprise_exam/SurpriseExam.kt b/game/src/main/kotlin/content/activity/event/random/surprise_exam/SurpriseExam.kt index ee01724121..0ae5efe5ee 100644 --- a/game/src/main/kotlin/content/activity/event/random/surprise_exam/SurpriseExam.kt +++ b/game/src/main/kotlin/content/activity/event/random/surprise_exam/SurpriseExam.kt @@ -7,6 +7,7 @@ import content.entity.player.dialogue.Neutral import content.entity.player.dialogue.type.npc import content.entity.player.dialogue.type.skillLamp import content.entity.player.dialogue.type.statement +import content.entity.player.inv.item.addOrDrop import world.gregs.voidps.engine.Script import world.gregs.voidps.engine.client.message import world.gregs.voidps.engine.client.ui.close @@ -15,8 +16,6 @@ import world.gregs.voidps.engine.data.definition.ItemDefinitions 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.exp.exp -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 @@ -126,9 +125,7 @@ class SurpriseExam : Script { private fun Player.finish() { message("You've passed the exam!") - if (!inventory.add("book_of_knowledge")) { - FloorItems.add(tile, "book_of_knowledge", 1, disappearTicks = 300, owner = this) - } + addOrDrop("book_of_knowledge") clear("surprise_exam_answer") clear("surprise_exam_correct") clear("surprise_exam_door") From 276410ee5bffaecc6c8d7db53577b027b35d8819 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Wed, 8 Jul 2026 15:16:06 -0700 Subject: [PATCH 46/89] Rework the Mime event around interface 188 and the theatre The old man drops the player into the theatre (2008,4764), challenges them, then they walk to the watch spot. The Mime performs one of eight emotes under the spotlight, bows to the player, and the player copies it by picking the matching emote on interface 188 (dialogue_macro_mime_emotes). Correct picks say 'Correct!', pause ~8s, and after three the player is teleported home with the mime emotes unlocked and a costume piece. Spotlight objects (3644) are wired but their on/off animation is left as a TODO pending the anim ids. --- .../mime_theatre/mime_theatre.objs.toml | 3 + .../player/dialogue/dialogue.ifaces.toml | 26 ++++ .../event/random/mime_theatre/Mime.kt | 133 ++++++++++++------ .../content/activity/event/random/MimeTest.kt | 88 +++++------- 4 files changed, 150 insertions(+), 100 deletions(-) create mode 100644 data/activity/event/random/mime_theatre/mime_theatre.objs.toml diff --git a/data/activity/event/random/mime_theatre/mime_theatre.objs.toml b/data/activity/event/random/mime_theatre/mime_theatre.objs.toml new file mode 100644 index 0000000000..7df8bfe623 --- /dev/null +++ b/data/activity/event/random/mime_theatre/mime_theatre.objs.toml @@ -0,0 +1,3 @@ +[mime_spotlight] +id = 3644 +examine = "A theatre spotlight." diff --git a/data/entity/player/dialogue/dialogue.ifaces.toml b/data/entity/player/dialogue/dialogue.ifaces.toml index 5a461e355e..020d4ca93f 100644 --- a/data/entity/player/dialogue/dialogue.ifaces.toml +++ b/data/entity/player/dialogue/dialogue.ifaces.toml @@ -273,6 +273,32 @@ type = "dialogue_box" id = 188 type = "dialogue_box" +# The eight emote buttons (cache option names Think/Cry/.../Glass Wall); named by the emote so the +# handler can read the pick straight off the clicked component. +[.think] +id = 2 + +[.cry] +id = 3 + +[.laugh] +id = 4 + +[.dance] +id = 5 + +[.climb_rope] +id = 6 + +[.lean] +id = 7 + +[.glass_box] +id = 8 + +[.glass_wall] +id = 9 + [dialogue_macro_quiz_show] id = 191 type = "dialogue_box" diff --git a/game/src/main/kotlin/content/activity/event/random/mime_theatre/Mime.kt b/game/src/main/kotlin/content/activity/event/random/mime_theatre/Mime.kt index 257ecbb181..27395b7c67 100644 --- a/game/src/main/kotlin/content/activity/event/random/mime_theatre/Mime.kt +++ b/game/src/main/kotlin/content/activity/event/random/mime_theatre/Mime.kt @@ -4,24 +4,31 @@ import content.activity.event.random.RandomEvents import content.activity.event.random.kidnap import content.activity.event.random.mysteriousOldMan import content.activity.event.random.rewardCostumeOrCoins +import content.entity.player.dialogue.Neutral +import content.entity.player.dialogue.type.npc +import content.entity.player.dialogue.type.statement import content.quest.instanceOffset import content.quest.setInstanceLogout import content.quest.smallInstance -import net.pearx.kasechange.toSnakeCase import world.gregs.voidps.engine.Script 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.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.engine.suspend.Suspension +import world.gregs.voidps.engine.suspend.pauseString +import world.gregs.voidps.type.Delta import world.gregs.voidps.type.Region import world.gregs.voidps.type.Tile import world.gregs.voidps.type.random /** - * Mime random event: the player is whisked to a stage where the mime performs emotes for them to - * copy. Copying four in a row unlocks the mime emotes (glass wall/box, climb rope, lean) and awards - * a piece of the mime costume (or coins if the set is complete). + * Mime random event: the Mysterious Old Man drops the player into the theatre where the Mime performs + * emotes under a spotlight. After each performance the Mime bows to the player, who copies it by + * picking the matching emote on the mime interface (188). Three correct copies unlock the mime emotes + * and award a piece of the mime costume, then the player is teleported home. * https://runescape.wiki/w/Random_events?oldid=3667851#Mime */ class Mime : Script { @@ -29,53 +36,81 @@ class Mime : Script { init { RandomEvents.register("mime") { startEvent() } - npcTimerStart(PERFORM_TIMER) { PERFORM_INTERVAL } - npcTimerTick(PERFORM_TIMER) { - val owner = owner - if (owner == null || owner.get("random_event") != "mime") { - NPCs.remove(this) - return@npcTimerTick Timer.CANCEL - } - owner.get("mime_emote")?.let { anim("emote_$it") } - Timer.CONTINUE - } - - // Runs alongside the emote tab's own handler, checking whether the copied emote matches. - interfaceOption(id = "emotes:*") { + // Each emote button is named after its emote, so the clicked component is the pick; hand it + // back to the round that's waiting. + interfaceOption(id = "$INTERFACE:*") { if (get("random_event") != "mime") { return@interfaceOption } - copyEmote(it.option.toSnakeCase()) + (suspension as? Suspension.StringEntry)?.resume(it.component) } } private suspend fun Player.startEvent() { - smallInstance(Region(MIME_REGION), levels = 1) + smallInstance(Region(REGION), levels = 1) setInstanceLogout(Tile(this["random_event_origin", tile.id])) + val offset = instanceOffset() + mysteriousOldMan() - kidnap(STAGE.add(instanceOffset())) - val mime: NPC = NPCs.add("mime", MIME_TILE.add(instanceOffset()), ticks = -1, owner = this) - mime.watch(this) - mime.softTimers.start(PERFORM_TIMER) + kidnap(SPAWN.add(offset)) + npc("mysterious_old_man", "Here's a little challenge for you:
Copy the Mime's performance, then you'll be released.") + walkOverDelay(WATCH.add(offset)) + statement("Watch the Mime.
See what emote he performs.") + + val mime = NPCs.add("mime", MIME_TILE.add(offset), ticks = -1, owner = this) set("mime_correct", 0) - nextEmote() - face(mime.tile) - message("Copy the mime's actions to earn your reward.") + runMime(mime, offset) } - private fun Player.nextEmote() { - set("mime_emote", EMOTES.random(random)) - } + private suspend fun Player.runMime(mime: NPC, offset: Delta) { + while (get("mime_correct", 0) < REQUIRED) { + val expected = EMOTES.random(random) + set("mime_emote", expected) - private suspend fun Player.copyEmote(emote: String) { - if (emote != get("mime_emote")) { - return // wrong emote earns no credit - } - if (inc("mime_correct") >= REQUIRED) { - finish() - } else { - nextEmote() + // Spotlight the Mime and perform the emote facing the audience. + lightMime(offset, on = true) + lightPlayer(offset, on = false) + mime.face(AUDIENCE.add(offset)) + mime.anim("emote_$expected") + delay(PERFORM_TICKS) + + // Bow to the player, then hand over to them. + mime.face(tile) + mime.anim("emote_bow") + delay(BOW_TICKS) + lightMime(offset, on = false) + lightPlayer(offset, on = true) + + val chosen = awaitEmote() + anim("emote_$chosen") // the player performs the emote they picked + if (chosen == expected) { + message("Correct!") + inc("mime_correct") + mime.face(AUDIENCE.add(offset)) + delay(CORRECT_TICKS) + } else { + message("That wasn't quite right. Watch the Mime again.") + } } + finish() + } + + private suspend fun Player.awaitEmote(): String { + open(INTERFACE) + val emote = pauseString() + close(INTERFACE) + return emote + } + + /** Spotlight over the Mime (2010, 4761). */ + private fun Player.lightMime(offset: Delta, on: Boolean) = spotlight(MIME_LIGHT.add(offset), on) + + /** Spotlight over the player's spot (2007, 4761). */ + private fun Player.lightPlayer(offset: Delta, on: Boolean) = spotlight(PLAYER_LIGHT.add(offset), on) + + private fun spotlight(tile: Tile, on: Boolean) { + // TODO: object 3644 (Spotlight) has no baked animation - wire the on/off animation ids here + // once known (GameObjects.findOrNull(tile) { it.id == "mime_spotlight" }?.anim(...)). } private suspend fun Player.finish() { @@ -89,16 +124,22 @@ class Mime : Script { } companion object { - private const val REQUIRED = 4 - private const val PERFORM_TIMER = "mime_perform" - private const val PERFORM_INTERVAL = 5 - private const val MIME_REGION = 8010 - private val STAGE = Tile(2008, 4762) + private const val REQUIRED = 3 + private const val REGION = 8010 + private const val INTERFACE = "dialogue_macro_mime_emotes" + private const val PERFORM_TICKS = 4 + private const val BOW_TICKS = 3 + private const val CORRECT_TICKS = 13 // ~8 seconds before the next performance + + private val SPAWN = Tile(2008, 4764) + private val WATCH = Tile(2008, 4762) private val MIME_TILE = Tile(2011, 4762) + private val AUDIENCE = Tile(2011, 4756) // south of the Mime, where the watchers sit + private val MIME_LIGHT = Tile(2010, 4761) + private val PLAYER_LIGHT = Tile(2007, 4761) - private val EMOTES = listOf( - "think", "cry", "laugh", "dance", "cheer", "clap", "wave", "bow", "angry", "jump_for_joy", - ) + // The eight emotes the Mime can perform, matching the buttons on interface 188. + private val EMOTES = listOf("think", "cry", "laugh", "dance", "climb_rope", "lean", "glass_box", "glass_wall") private val MIME_EMOTES = listOf("glass_wall", "glass_box", "climb_rope", "lean") } } diff --git a/game/src/test/kotlin/content/activity/event/random/MimeTest.kt b/game/src/test/kotlin/content/activity/event/random/MimeTest.kt index b3f5b509f5..8ee04f28e3 100644 --- a/game/src/test/kotlin/content/activity/event/random/MimeTest.kt +++ b/game/src/test/kotlin/content/activity/event/random/MimeTest.kt @@ -3,14 +3,11 @@ package content.activity.event.random import WorldTest import content.quest.instance import content.quest.instanceOffset +import interfaceOption import org.junit.jupiter.api.Test -import world.gregs.voidps.engine.Script -import world.gregs.voidps.engine.client.ui.InterfaceApi -import world.gregs.voidps.engine.client.ui.InterfaceOption +import skipDialogues import world.gregs.voidps.engine.entity.character.npc.NPCs import world.gregs.voidps.engine.entity.character.player.Player -import world.gregs.voidps.engine.entity.item.Item -import world.gregs.voidps.engine.inv.add import world.gregs.voidps.engine.inv.inventory import world.gregs.voidps.type.Tile import kotlin.test.assertEquals @@ -20,80 +17,63 @@ import kotlin.test.assertTrue class MimeTest : WorldTest() { - private val stage = Tile(2008, 4762) + private val origin = Tile(3221, 3218) + private val iface = "dialogue_macro_mime_emotes" - private fun Player.performEmote(emote: String) { - val player = this - Script.launch { InterfaceApi.option(player, InterfaceOption(Item.EMPTY, -1, emote, 0, "emotes:$emote")) } - tick(6) // let the emote animation + copy check resolve + /** Runs the event through the intro up to the first emote selection (interface 188 open). */ + private fun enter(name: String): Player { + val player = createPlayer(origin, name) + RandomEvents.start(player, "mime") + tick(6) // mysterious old man + kidnap + player.skipDialogues() // "Here's a little challenge..." + tick(3) // walk to the watch spot + player.skipDialogues() // "Watch the Mime." + tickIf { !player.interfaces.contains(iface) } // mime performs + bows -> opens the interface + return player } - private fun enter(name: String, origin: Tile = Tile(3221, 3218)): Player { - val player = createPlayer(stage, name) - player["random_event"] = "mime" - player["random_event_origin"] = origin.id - player["mime_correct"] = 0 - player["mime_emote"] = "think" - return player + private fun Player.pick(emote: String) { + interfaceOption(iface, emote, optionIndex = 0) + tick() } + private fun Player.pickCorrect() = pick(get("mime_emote")!!) + @Test - fun `Event kidnaps the player to the stage and assigns an emote`() { - val player = createPlayer(Tile(3221, 3218), "mime_start") - RandomEvents.start(player, "mime") - tick(12) + fun `Event drops the player into the theatre and opens the mime interface`() { + val player = enter("mime_start") assertEquals("mime", player.get("random_event")) assertNotNull(player.instance()) - assertTrue(player.get("mime_emote") != null) + assertTrue(player.interfaces.contains(iface)) + assertNotNull(player.get("mime_emote")) val off = player.instanceOffset() assertNotNull(NPCs.firstOrNull(Tile(2011, 4762).add(off.x, off.y)) { it.id == "mime" }) } @Test - fun `Copying the mime's emote counts, a wrong one does not`() { - val player = enter("mime_copy") + fun `A wrong emote earns no credit`() { + val player = enter("mime_wrong") + val expected = player.get("mime_emote")!! - player.performEmote("dance") // not the assigned "think" - tick() - assertEquals(0, player.get("mime_correct", 0)) + player.pick(if (expected == "think") "cry" else "think") - player.performEmote("think") - tick() - assertEquals(1, player.get("mime_correct", 0)) + assertEquals(0, player.get("mime_correct", 0)) } @Test - fun `Four correct emotes unlock the mime emotes, reward a costume and return the player`() { - val origin = Tile(3221, 3218) - val player = enter("mime_finish", origin) + fun `Copying three emotes unlocks the emotes, rewards a costume and returns the player`() { + val player = enter("mime_finish") - repeat(4) { - player.performEmote(player.get("mime_emote")!!) - tick() + repeat(3) { + player.pickCorrect() + tickIf { !player.interfaces.contains(iface) && player.get("random_event") == "mime" } } tick(2) - assertTrue(player["unlocked_emote_glass_wall", false]) - assertTrue(player["unlocked_emote_lean", false]) assertEquals(1, player.inventory.count("mime_mask")) + assertTrue(player["unlocked_emote_glass_wall", false]) assertNull(player.get("random_event")) assertEquals(origin, player.tile) } - - @Test - fun `Owning the full mime costume rewards coins`() { - val player = enter("mime_coins") - for (piece in listOf("mime_mask", "mime_top", "mime_legs", "mime_gloves", "mime_boots")) { - player.inventory.add(piece) - } - - repeat(4) { - player.performEmote(player.get("mime_emote")!!) - tick() - } - tick(2) - - assertEquals(500, player.inventory.count("coins")) - } } From 813e8dd932f25b7d726c3c9dbac3b9318dac7a25 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Wed, 8 Jul 2026 15:22:11 -0700 Subject: [PATCH 47/89] Run the Mime event in the real theatre instead of an instance Teleport to the actual region 8010 coordinates and use the statically-spawned Mime rather than copying the region into an instance. --- .../event/random/mime_theatre/Mime.kt | 38 +++++++------------ .../content/activity/event/random/MimeTest.kt | 6 +-- 2 files changed, 15 insertions(+), 29 deletions(-) diff --git a/game/src/main/kotlin/content/activity/event/random/mime_theatre/Mime.kt b/game/src/main/kotlin/content/activity/event/random/mime_theatre/Mime.kt index 27395b7c67..e991532df9 100644 --- a/game/src/main/kotlin/content/activity/event/random/mime_theatre/Mime.kt +++ b/game/src/main/kotlin/content/activity/event/random/mime_theatre/Mime.kt @@ -7,9 +7,6 @@ import content.activity.event.random.rewardCostumeOrCoins import content.entity.player.dialogue.Neutral import content.entity.player.dialogue.type.npc import content.entity.player.dialogue.type.statement -import content.quest.instanceOffset -import content.quest.setInstanceLogout -import content.quest.smallInstance import world.gregs.voidps.engine.Script import world.gregs.voidps.engine.client.message import world.gregs.voidps.engine.client.ui.close @@ -19,8 +16,6 @@ import world.gregs.voidps.engine.entity.character.npc.NPCs import world.gregs.voidps.engine.entity.character.player.Player import world.gregs.voidps.engine.suspend.Suspension import world.gregs.voidps.engine.suspend.pauseString -import world.gregs.voidps.type.Delta -import world.gregs.voidps.type.Region import world.gregs.voidps.type.Tile import world.gregs.voidps.type.random @@ -47,30 +42,26 @@ class Mime : Script { } private suspend fun Player.startEvent() { - smallInstance(Region(REGION), levels = 1) - setInstanceLogout(Tile(this["random_event_origin", tile.id])) - val offset = instanceOffset() - + set("mime_correct", 0) mysteriousOldMan() - kidnap(SPAWN.add(offset)) + kidnap(SPAWN) npc("mysterious_old_man", "Here's a little challenge for you:
Copy the Mime's performance, then you'll be released.") - walkOverDelay(WATCH.add(offset)) + walkOverDelay(WATCH) statement("Watch the Mime.
See what emote he performs.") - val mime = NPCs.add("mime", MIME_TILE.add(offset), ticks = -1, owner = this) - set("mime_correct", 0) - runMime(mime, offset) + val mime = NPCs.firstOrNull(MIME_TILE) { it.id == "mime" } ?: NPCs.add("mime", MIME_TILE, ticks = -1, owner = this) + runMime(mime) } - private suspend fun Player.runMime(mime: NPC, offset: Delta) { + private suspend fun Player.runMime(mime: NPC) { while (get("mime_correct", 0) < REQUIRED) { val expected = EMOTES.random(random) set("mime_emote", expected) // Spotlight the Mime and perform the emote facing the audience. - lightMime(offset, on = true) - lightPlayer(offset, on = false) - mime.face(AUDIENCE.add(offset)) + lightMime(on = true) + lightPlayer(on = false) + mime.face(AUDIENCE) mime.anim("emote_$expected") delay(PERFORM_TICKS) @@ -78,15 +69,15 @@ class Mime : Script { mime.face(tile) mime.anim("emote_bow") delay(BOW_TICKS) - lightMime(offset, on = false) - lightPlayer(offset, on = true) + lightMime(on = false) + lightPlayer(on = true) val chosen = awaitEmote() anim("emote_$chosen") // the player performs the emote they picked if (chosen == expected) { message("Correct!") inc("mime_correct") - mime.face(AUDIENCE.add(offset)) + mime.face(AUDIENCE) delay(CORRECT_TICKS) } else { message("That wasn't quite right. Watch the Mime again.") @@ -103,10 +94,10 @@ class Mime : Script { } /** Spotlight over the Mime (2010, 4761). */ - private fun Player.lightMime(offset: Delta, on: Boolean) = spotlight(MIME_LIGHT.add(offset), on) + private fun lightMime(on: Boolean) = spotlight(MIME_LIGHT, on) /** Spotlight over the player's spot (2007, 4761). */ - private fun Player.lightPlayer(offset: Delta, on: Boolean) = spotlight(PLAYER_LIGHT.add(offset), on) + private fun lightPlayer(on: Boolean) = spotlight(PLAYER_LIGHT, on) private fun spotlight(tile: Tile, on: Boolean) { // TODO: object 3644 (Spotlight) has no baked animation - wire the on/off animation ids here @@ -125,7 +116,6 @@ class Mime : Script { companion object { private const val REQUIRED = 3 - private const val REGION = 8010 private const val INTERFACE = "dialogue_macro_mime_emotes" private const val PERFORM_TICKS = 4 private const val BOW_TICKS = 3 diff --git a/game/src/test/kotlin/content/activity/event/random/MimeTest.kt b/game/src/test/kotlin/content/activity/event/random/MimeTest.kt index 8ee04f28e3..e25dd75d93 100644 --- a/game/src/test/kotlin/content/activity/event/random/MimeTest.kt +++ b/game/src/test/kotlin/content/activity/event/random/MimeTest.kt @@ -1,8 +1,6 @@ package content.activity.event.random import WorldTest -import content.quest.instance -import content.quest.instanceOffset import interfaceOption import org.junit.jupiter.api.Test import skipDialogues @@ -44,11 +42,9 @@ class MimeTest : WorldTest() { val player = enter("mime_start") assertEquals("mime", player.get("random_event")) - assertNotNull(player.instance()) assertTrue(player.interfaces.contains(iface)) assertNotNull(player.get("mime_emote")) - val off = player.instanceOffset() - assertNotNull(NPCs.firstOrNull(Tile(2011, 4762).add(off.x, off.y)) { it.id == "mime" }) + assertNotNull(NPCs.firstOrNull(Tile(2011, 4762)) { it.id == "mime" }) } @Test From a2a5d3cdab2dde7942c82f014113587028ea489a Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Wed, 8 Jul 2026 15:29:26 -0700 Subject: [PATCH 48/89] Show Mime prompts as chatbox dialogue Display 'Watch the Mime...' as a no-prompt chatbox line during each performance, and make 'Correct!' a chatbox statement instead of a game message. --- .../content/activity/event/random/mime_theatre/Mime.kt | 10 +++++----- .../kotlin/content/activity/event/random/MimeTest.kt | 3 +-- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/game/src/main/kotlin/content/activity/event/random/mime_theatre/Mime.kt b/game/src/main/kotlin/content/activity/event/random/mime_theatre/Mime.kt index e991532df9..1e47724435 100644 --- a/game/src/main/kotlin/content/activity/event/random/mime_theatre/Mime.kt +++ b/game/src/main/kotlin/content/activity/event/random/mime_theatre/Mime.kt @@ -8,7 +8,6 @@ import content.entity.player.dialogue.Neutral import content.entity.player.dialogue.type.npc import content.entity.player.dialogue.type.statement import world.gregs.voidps.engine.Script -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.entity.character.npc.NPC @@ -47,7 +46,6 @@ class Mime : Script { kidnap(SPAWN) npc("mysterious_old_man", "Here's a little challenge for you:
Copy the Mime's performance, then you'll be released.") walkOverDelay(WATCH) - statement("Watch the Mime.
See what emote he performs.") val mime = NPCs.firstOrNull(MIME_TILE) { it.id == "mime" } ?: NPCs.add("mime", MIME_TILE, ticks = -1, owner = this) runMime(mime) @@ -58,7 +56,9 @@ class Mime : Script { val expected = EMOTES.random(random) set("mime_emote", expected) - // Spotlight the Mime and perform the emote facing the audience. + // Spotlight the Mime and perform the emote facing the audience; the prompt sits in the + // chatbox (no continue button) while the player watches. + statement("Watch the Mime.
See what emote he performs.", clickToContinue = false) lightMime(on = true) lightPlayer(on = false) mime.face(AUDIENCE) @@ -75,12 +75,12 @@ class Mime : Script { val chosen = awaitEmote() anim("emote_$chosen") // the player performs the emote they picked if (chosen == expected) { - message("Correct!") + statement("Correct!", clickToContinue = false) inc("mime_correct") mime.face(AUDIENCE) delay(CORRECT_TICKS) } else { - message("That wasn't quite right. Watch the Mime again.") + statement("That wasn't quite right. Watch the Mime again.", clickToContinue = false) } } finish() diff --git a/game/src/test/kotlin/content/activity/event/random/MimeTest.kt b/game/src/test/kotlin/content/activity/event/random/MimeTest.kt index e25dd75d93..2d9fca74a6 100644 --- a/game/src/test/kotlin/content/activity/event/random/MimeTest.kt +++ b/game/src/test/kotlin/content/activity/event/random/MimeTest.kt @@ -25,8 +25,7 @@ class MimeTest : WorldTest() { tick(6) // mysterious old man + kidnap player.skipDialogues() // "Here's a little challenge..." tick(3) // walk to the watch spot - player.skipDialogues() // "Watch the Mime." - tickIf { !player.interfaces.contains(iface) } // mime performs + bows -> opens the interface + tickIf { !player.interfaces.contains(iface) } // "Watch the Mime." + performance -> opens the interface return player } From faae829c53c7165829c0a776c0e7155ccdac0f85 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Wed, 8 Jul 2026 15:36:40 -0700 Subject: [PATCH 49/89] Drop the Mime 'Correct!' text down a line Show it on the lower line of a two-line message box (empty first line), since the box top-aligns its already-centred text. --- .../activity/event/random/mime_theatre/Mime.kt | 13 +++++++++++-- tools/build.gradle.kts | 1 - 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/game/src/main/kotlin/content/activity/event/random/mime_theatre/Mime.kt b/game/src/main/kotlin/content/activity/event/random/mime_theatre/Mime.kt index 1e47724435..4c7d751df8 100644 --- a/game/src/main/kotlin/content/activity/event/random/mime_theatre/Mime.kt +++ b/game/src/main/kotlin/content/activity/event/random/mime_theatre/Mime.kt @@ -75,7 +75,7 @@ class Mime : Script { val chosen = awaitEmote() anim("emote_$chosen") // the player performs the emote they picked if (chosen == expected) { - statement("Correct!", clickToContinue = false) + showCorrect() inc("mime_correct") mime.face(AUDIENCE) delay(CORRECT_TICKS) @@ -93,6 +93,15 @@ class Mime : Script { return emote } + /** Show "Correct!" on the lower line of the message box (the box top-aligns its text). */ + private fun Player.showCorrect() { + if (!open("dialogue_message_np2")) { + return + } + interfaces.sendText("dialogue_message_np2", "line1", "") + interfaces.sendText("dialogue_message_np2", "line2", "Correct!") + } + /** Spotlight over the Mime (2010, 4761). */ private fun lightMime(on: Boolean) = spotlight(MIME_LIGHT, on) @@ -119,7 +128,7 @@ class Mime : Script { private const val INTERFACE = "dialogue_macro_mime_emotes" private const val PERFORM_TICKS = 4 private const val BOW_TICKS = 3 - private const val CORRECT_TICKS = 13 // ~8 seconds before the next performance + private const val CORRECT_TICKS = 8 // ~8 ticks private val SPAWN = Tile(2008, 4764) private val WATCH = Tile(2008, 4762) diff --git a/tools/build.gradle.kts b/tools/build.gradle.kts index 4ddfcff2cb..4e5cdb117d 100644 --- a/tools/build.gradle.kts +++ b/tools/build.gradle.kts @@ -68,4 +68,3 @@ tasks.register("renderPhotoBooth") { val cliArgs = (findProperty("args") as String?)?.split(" ")?.filter { it.isNotBlank() } ?: emptyList() args = cliArgs } - From 8995b576854e0050d863d68c34b74b90b58d6da2 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Wed, 8 Jul 2026 15:46:48 -0700 Subject: [PATCH 50/89] Bump the Mime 'Watch the Mime' prompt down a line too Generalise the empty-first-line message helper and use it for both the watch prompt and 'Correct!'. --- .../activity/event/random/mime_theatre/Mime.kt | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/game/src/main/kotlin/content/activity/event/random/mime_theatre/Mime.kt b/game/src/main/kotlin/content/activity/event/random/mime_theatre/Mime.kt index 4c7d751df8..db940baea0 100644 --- a/game/src/main/kotlin/content/activity/event/random/mime_theatre/Mime.kt +++ b/game/src/main/kotlin/content/activity/event/random/mime_theatre/Mime.kt @@ -58,7 +58,7 @@ class Mime : Script { // Spotlight the Mime and perform the emote facing the audience; the prompt sits in the // chatbox (no continue button) while the player watches. - statement("Watch the Mime.
See what emote he performs.", clickToContinue = false) + centeredMessage("Watch the Mime.", "See what emote he performs.") lightMime(on = true) lightPlayer(on = false) mime.face(AUDIENCE) @@ -75,7 +75,7 @@ class Mime : Script { val chosen = awaitEmote() anim("emote_$chosen") // the player performs the emote they picked if (chosen == expected) { - showCorrect() + centeredMessage("Correct!") inc("mime_correct") mime.face(AUDIENCE) delay(CORRECT_TICKS) @@ -93,13 +93,14 @@ class Mime : Script { return emote } - /** Show "Correct!" on the lower line of the message box (the box top-aligns its text). */ - private fun Player.showCorrect() { - if (!open("dialogue_message_np2")) { + /** Show a no-prompt message with an empty first line so its (centred) text sits a line lower. */ + private fun Player.centeredMessage(vararg lines: String) { + val padded = listOf("", *lines) + val id = "dialogue_message_np${padded.size}" + if (!open(id)) { return } - interfaces.sendText("dialogue_message_np2", "line1", "") - interfaces.sendText("dialogue_message_np2", "line2", "Correct!") + padded.forEachIndexed { index, line -> interfaces.sendText(id, "line${index + 1}", line) } } /** Spotlight over the Mime (2010, 4761). */ From 1a688d3ce6441ff5b0b6479301e7fa67b2b4705a Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Wed, 8 Jul 2026 15:55:45 -0700 Subject: [PATCH 51/89] Animate the Mime spotlights on and off Wire object 3644's turn-on (1136) and turn-off (1135) animations into the spotlight helper: the Mime's light comes on while it performs, the player's while they copy. --- .../event/random/mime_theatre/mime_theatre.anims.toml | 5 +++++ .../content/activity/event/random/mime_theatre/Mime.kt | 5 +++-- 2 files changed, 8 insertions(+), 2 deletions(-) create mode 100644 data/activity/event/random/mime_theatre/mime_theatre.anims.toml diff --git a/data/activity/event/random/mime_theatre/mime_theatre.anims.toml b/data/activity/event/random/mime_theatre/mime_theatre.anims.toml new file mode 100644 index 0000000000..79318becc6 --- /dev/null +++ b/data/activity/event/random/mime_theatre/mime_theatre.anims.toml @@ -0,0 +1,5 @@ +[mime_spotlight_off] +id = 1135 + +[mime_spotlight_on] +id = 1136 diff --git a/game/src/main/kotlin/content/activity/event/random/mime_theatre/Mime.kt b/game/src/main/kotlin/content/activity/event/random/mime_theatre/Mime.kt index db940baea0..bd9e2885fb 100644 --- a/game/src/main/kotlin/content/activity/event/random/mime_theatre/Mime.kt +++ b/game/src/main/kotlin/content/activity/event/random/mime_theatre/Mime.kt @@ -13,6 +13,7 @@ import world.gregs.voidps.engine.client.ui.open 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.obj.GameObjects import world.gregs.voidps.engine.suspend.Suspension import world.gregs.voidps.engine.suspend.pauseString import world.gregs.voidps.type.Tile @@ -110,8 +111,8 @@ class Mime : Script { private fun lightPlayer(on: Boolean) = spotlight(PLAYER_LIGHT, on) private fun spotlight(tile: Tile, on: Boolean) { - // TODO: object 3644 (Spotlight) has no baked animation - wire the on/off animation ids here - // once known (GameObjects.findOrNull(tile) { it.id == "mime_spotlight" }?.anim(...)). + GameObjects.at(tile).firstOrNull { it.id == "mime_spotlight" } + ?.anim(if (on) "mime_spotlight_on" else "mime_spotlight_off") } private suspend fun Player.finish() { From 462563c07c22acddd38cd3394d634e6488f4ef0c Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Wed, 8 Jul 2026 16:03:31 -0700 Subject: [PATCH 52/89] Let the Mime spotlight finish before the emote The spotlight on/off animation runs ~3 ticks; wait that out after lighting the Mime (before it performs) and after lighting the player (before they pick and copy). --- .../activity/event/random/mime_theatre/mime_theatre.anims.toml | 2 ++ .../kotlin/content/activity/event/random/mime_theatre/Mime.kt | 3 +++ 2 files changed, 5 insertions(+) diff --git a/data/activity/event/random/mime_theatre/mime_theatre.anims.toml b/data/activity/event/random/mime_theatre/mime_theatre.anims.toml index 79318becc6..be149657a2 100644 --- a/data/activity/event/random/mime_theatre/mime_theatre.anims.toml +++ b/data/activity/event/random/mime_theatre/mime_theatre.anims.toml @@ -1,5 +1,7 @@ [mime_spotlight_off] id = 1135 +ticks = 3 [mime_spotlight_on] id = 1136 +ticks = 3 diff --git a/game/src/main/kotlin/content/activity/event/random/mime_theatre/Mime.kt b/game/src/main/kotlin/content/activity/event/random/mime_theatre/Mime.kt index bd9e2885fb..f681804325 100644 --- a/game/src/main/kotlin/content/activity/event/random/mime_theatre/Mime.kt +++ b/game/src/main/kotlin/content/activity/event/random/mime_theatre/Mime.kt @@ -62,6 +62,7 @@ class Mime : Script { centeredMessage("Watch the Mime.", "See what emote he performs.") lightMime(on = true) lightPlayer(on = false) + delay(SPOTLIGHT_TICKS) // let the spotlight finish turning on before the Mime performs mime.face(AUDIENCE) mime.anim("emote_$expected") delay(PERFORM_TICKS) @@ -72,6 +73,7 @@ class Mime : Script { delay(BOW_TICKS) lightMime(on = false) lightPlayer(on = true) + delay(SPOTLIGHT_TICKS) // let the spotlight finish turning on before the player performs val chosen = awaitEmote() anim("emote_$chosen") // the player performs the emote they picked @@ -130,6 +132,7 @@ class Mime : Script { private const val INTERFACE = "dialogue_macro_mime_emotes" private const val PERFORM_TICKS = 4 private const val BOW_TICKS = 3 + private const val SPOTLIGHT_TICKS = 3 // duration of the spotlight on/off animation private const val CORRECT_TICKS = 8 // ~8 ticks private val SPAWN = Tile(2008, 4764) From d7e3ace48b5486f427faeb705a45fbf936ff23d7 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Wed, 8 Jul 2026 16:08:13 -0700 Subject: [PATCH 53/89] Teleport the Mime player out with the modern teleport Play the modern teleport animation and gfx, wind up, then return the player and land with the modern landing gfx. --- .../content/activity/event/random/mime_theatre/Mime.kt | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/game/src/main/kotlin/content/activity/event/random/mime_theatre/Mime.kt b/game/src/main/kotlin/content/activity/event/random/mime_theatre/Mime.kt index f681804325..a59bf361e8 100644 --- a/game/src/main/kotlin/content/activity/event/random/mime_theatre/Mime.kt +++ b/game/src/main/kotlin/content/activity/event/random/mime_theatre/Mime.kt @@ -124,7 +124,14 @@ class Mime : Script { rewardCostumeOrCoins("mime_mask", "mime_top", "mime_legs", "mime_gloves", "mime_boots", coins = 500) clear("mime_emote") clear("mime_correct") + + // Teleport home with the modern teleport animation. + anim("teleport_modern") + gfx("teleport_modern") + delay(TELEPORT_TICKS) + clearAnim() RandomEvents.complete(this) + gfx("teleport_land_modern") } companion object { @@ -133,6 +140,7 @@ class Mime : Script { private const val PERFORM_TICKS = 4 private const val BOW_TICKS = 3 private const val SPOTLIGHT_TICKS = 3 // duration of the spotlight on/off animation + private const val TELEPORT_TICKS = 3 // modern teleport wind-up before leaving private const val CORRECT_TICKS = 8 // ~8 ticks private val SPAWN = Tile(2008, 4764) From 5240bf8ce92987998e0a27d2133b843c0b07634a Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Wed, 8 Jul 2026 16:14:13 -0700 Subject: [PATCH 54/89] Use the modern teleport helper for the Mime exit Replace the hand-rolled teleport animation with teleport(origin, "modern") after clearing the event state. --- .../activity/event/random/mime_theatre/Mime.kt | 15 ++++++--------- .../content/activity/event/random/MimeTest.kt | 2 +- 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/game/src/main/kotlin/content/activity/event/random/mime_theatre/Mime.kt b/game/src/main/kotlin/content/activity/event/random/mime_theatre/Mime.kt index a59bf361e8..701cf13801 100644 --- a/game/src/main/kotlin/content/activity/event/random/mime_theatre/Mime.kt +++ b/game/src/main/kotlin/content/activity/event/random/mime_theatre/Mime.kt @@ -7,6 +7,7 @@ import content.activity.event.random.rewardCostumeOrCoins import content.entity.player.dialogue.Neutral import content.entity.player.dialogue.type.npc import content.entity.player.dialogue.type.statement +import content.skill.magic.jewellery.teleport import world.gregs.voidps.engine.Script import world.gregs.voidps.engine.client.ui.close import world.gregs.voidps.engine.client.ui.open @@ -117,7 +118,7 @@ class Mime : Script { ?.anim(if (on) "mime_spotlight_on" else "mime_spotlight_off") } - private suspend fun Player.finish() { + private fun Player.finish() { for (unlock in MIME_EMOTES) { set("unlocked_emote_$unlock", true) } @@ -125,13 +126,10 @@ class Mime : Script { clear("mime_emote") clear("mime_correct") - // Teleport home with the modern teleport animation. - anim("teleport_modern") - gfx("teleport_modern") - delay(TELEPORT_TICKS) - clearAnim() - RandomEvents.complete(this) - gfx("teleport_land_modern") + // Clear the event state (so the teleport isn't blocked) then modern-teleport home. + val origin = Tile(this["random_event_origin", tile.id]) + RandomEvents.completeInPlace(this) + teleport(origin, "modern") } companion object { @@ -140,7 +138,6 @@ class Mime : Script { private const val PERFORM_TICKS = 4 private const val BOW_TICKS = 3 private const val SPOTLIGHT_TICKS = 3 // duration of the spotlight on/off animation - private const val TELEPORT_TICKS = 3 // modern teleport wind-up before leaving private const val CORRECT_TICKS = 8 // ~8 ticks private val SPAWN = Tile(2008, 4764) diff --git a/game/src/test/kotlin/content/activity/event/random/MimeTest.kt b/game/src/test/kotlin/content/activity/event/random/MimeTest.kt index 2d9fca74a6..2a3ddbdbfd 100644 --- a/game/src/test/kotlin/content/activity/event/random/MimeTest.kt +++ b/game/src/test/kotlin/content/activity/event/random/MimeTest.kt @@ -64,7 +64,7 @@ class MimeTest : WorldTest() { player.pickCorrect() tickIf { !player.interfaces.contains(iface) && player.get("random_event") == "mime" } } - tick(2) + tickIf { player.tile != origin } // wait out the modern teleport home assertEquals(1, player.inventory.count("mime_mask")) assertTrue(player["unlocked_emote_glass_wall", false]) From 4e16f024ac84edc77ffceb0539f8d858098ef41a Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Wed, 8 Jul 2026 17:55:36 -0700 Subject: [PATCH 55/89] Add Kiss the Frog random event The Frog Herald hops up and whisks the player to the Land of the Frogs, where one of the identical frogs wears a crown - the Frog Prince (female player) or Princess (male). Kissing the crowned frog restores it to human form and gives a gift box; talking to a plain frog turns the player into a frog until they apologise to the crowned one. Ported from 2009scape (content/global/random/event/frog_princess); ids/anims mapped to the 634 cache. Ref GregHib/void-private#256 --- .../event/random/kiss_the_frog.anims.toml | 11 ++ .../event/random/kiss_the_frog.npcs.toml | 7 + .../event/random/kiss_the_frog.vars.toml | 6 + .../event/random/random_event.tables.toml | 4 + .../event/random/kiss_the_frog/KissTheFrog.kt | 177 ++++++++++++++++++ .../activity/event/random/KissTheFrogTest.kt | 97 ++++++++++ 6 files changed, 302 insertions(+) create mode 100644 data/activity/event/random/kiss_the_frog.anims.toml create mode 100644 data/activity/event/random/kiss_the_frog.npcs.toml create mode 100644 data/activity/event/random/kiss_the_frog.vars.toml create mode 100644 game/src/main/kotlin/content/activity/event/random/kiss_the_frog/KissTheFrog.kt create mode 100644 game/src/test/kotlin/content/activity/event/random/KissTheFrogTest.kt diff --git a/data/activity/event/random/kiss_the_frog.anims.toml b/data/activity/event/random/kiss_the_frog.anims.toml new file mode 100644 index 0000000000..caa7abc7d3 --- /dev/null +++ b/data/activity/event/random/kiss_the_frog.anims.toml @@ -0,0 +1,11 @@ +[frog_kiss] +id = 2374 + +[morph_from_frog] +id = 2375 + +[human_kiss_the_frog] +id = 2376 + +[morph_to_frog] +id = 2377 diff --git a/data/activity/event/random/kiss_the_frog.npcs.toml b/data/activity/event/random/kiss_the_frog.npcs.toml new file mode 100644 index 0000000000..4ab216eca9 --- /dev/null +++ b/data/activity/event/random/kiss_the_frog.npcs.toml @@ -0,0 +1,7 @@ +[frog_prince] +id = 2474 +examine = "A handsome prince." + +[frog_princess] +id = 2475 +examine = "A beautiful princess." diff --git a/data/activity/event/random/kiss_the_frog.vars.toml b/data/activity/event/random/kiss_the_frog.vars.toml new file mode 100644 index 0000000000..ec359081fd --- /dev/null +++ b/data/activity/event/random/kiss_the_frog.vars.toml @@ -0,0 +1,6 @@ +[ktf_fail] +format = "boolean" +persist = true + +[ktf_crown] +format = "int" diff --git a/data/activity/event/random/random_event.tables.toml b/data/activity/event/random/random_event.tables.toml index 993fbaecee..7373086b9e 100644 --- a/data/activity/event/random/random_event.tables.toml +++ b/data/activity/event/random/random_event.tables.toml @@ -42,6 +42,10 @@ weight = 10 event = "surprise_exam" weight = 10 +[.kiss_the_frog] +event = "kiss_the_frog" +weight = 10 + [random_event_settings] value = "int" diff --git a/game/src/main/kotlin/content/activity/event/random/kiss_the_frog/KissTheFrog.kt b/game/src/main/kotlin/content/activity/event/random/kiss_the_frog/KissTheFrog.kt new file mode 100644 index 0000000000..a3b6ac8b87 --- /dev/null +++ b/game/src/main/kotlin/content/activity/event/random/kiss_the_frog/KissTheFrog.kt @@ -0,0 +1,177 @@ +package content.activity.event.random.kiss_the_frog + +import content.activity.event.random.RandomEvents +import content.activity.event.random.endInPlaceEvent +import content.activity.event.random.kidnap +import content.activity.event.random.startInPlaceEvent +import content.entity.effect.clearTransform +import content.entity.effect.transform +import content.entity.player.dialogue.Angry +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.type.npc +import content.entity.player.dialogue.type.player +import content.entity.player.inv.item.addOrDrop +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.client.message +import world.gregs.voidps.engine.client.ui.open +import world.gregs.voidps.engine.entity.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.name +import world.gregs.voidps.type.Tile +import world.gregs.voidps.type.random + +/** + * Kiss the Frog (Frog Princess) random event: the Frog Herald hops up to the player and whisks them + * to the Land of the Frogs. Among the identical frogs one wears a crown - the Frog Prince (for female + * players) or Princess (for male players). Kissing the crowned frog restores it to human form and + * earns a gift box; kissing (or talking to) the wrong frog turns the player into a frog until they + * apologise to the crowned one. + * https://runescape.wiki/w/Random_events?oldid=3667851#Frog + */ +class KissTheFrog : Script { + + init { + RandomEvents.register("kiss_the_frog") { startEvent() } + + npcOperate("Talk-to", "frog_herald") { (herald) -> + if (get("random_event") != "kiss_the_frog") { + message("It hops away from you.") + return@npcOperate + } + if (herald.owner == this) { + // The herald that appeared beside the player: whisk them to the Land of the Frogs. + npc("frog_herald", "Hey, $name, the Frog ${royal()} needs your help!") + endInPlaceEvent(herald) + kidnap(LAND) + spawnCrown() + explain() + } else { + explain() + } + } + + npcOperate("Talk-to", "frog_*_frogland") { (frog) -> + if (get("random_event") != "kiss_the_frog") { + return@npcOperate + } + if (frog.index == get("ktf_crown", -1)) { + royalFrog(frog) + } else { + plainFrog(frog) + } + } + } + + private fun Player.startEvent() { + clear("ktf_fail") + startInPlaceEvent("frog_herald", nagLines()) + } + + /** The royal the player must rescue - a male player helps the Princess, a female player the Prince. */ + private fun Player.royal() = if (male) "Princess" else "Prince" + + private fun Player.crownFrog() = if (male) "frog_princess" else "frog_prince" + + private fun Player.spawnCrown() { + val frog = NPCs.add("frog_10_frogland", CROWN_TILES.random(random), ticks = -1, owner = this) + set("ktf_crown", frog.index) + } + + private suspend fun Player.explain() { + val royal = royal() + val subject = if (male) "She" else "He" + val him = if (male) "Her" else "Him" + npc("frog_herald", "Welcome to the Land of the Frogs.") + player("What am I doing here?") + npc("frog_herald", "The Frog $royal sent for you.") + player("Who is the Frog $royal?") + npc("frog_herald", "$subject is the frog with the crown. Make sure you speak to $him, not the other frogs, or $subject'll be offended.") + } + + private suspend fun Player.royalFrog(frog: NPC) { + val id = frog.id + if (get("ktf_fail", false)) { + npc(id, "Hmph. Have you come to apologise for ignoring me?") + player("I'm very sorry. Please change me back!") + npc(id, "All right. But in future be more polite.") + clearTransform() + finishEvent() + return + } + npc(id, "$name, you must help me! I have been turned into a frog by a well-meaning wizard with an unfortunate obsession with frogs.") + npc(id, "The only thing that will restore my true form is a kiss.") + player("Excuses, excuses! Okay, if that's what you want...") + kiss(frog) + } + + private suspend fun Player.kiss(frog: NPC) { + face(frog.tile) + frog.face(tile) + frog.anim("frog_kiss") + anim("human_kiss_the_frog") + delay(3) + frog.gfx("spell_splash") + frog.anim("morph_from_frog") + delay(2) + frog.transform(crownFrog()) // the frog stands up as the human royal + delay(2) + npc(crownFrog(), "Thank you so much, $name. I must return to my fairy tale kingdom now, but I will leave you a reward for your kindness.") + frog.anim("emote_blow_kiss") + frog.gfx("emote_blow_kiss") + delay(3) + open("fade_out") + delay(2) + addOrDrop("random_event_gift") + message("You've been given a gift!") + NPCs.remove(frog) + finishEvent() + open("fade_in") + } + + private suspend fun Player.plainFrog(frog: NPC) { + if (get("ktf_fail", false)) { + npc(frog.id, "Don't talk to me! Speak to the frog ${royal()}!") + return + } + npc(frog.id, "Well, we'll see how you like being a frog!") + set("ktf_fail", true) + anim("morph_to_frog") + delay(1) + transform("frog_9_frogland") + } + + private fun Player.finishEvent() { + clearTransform() + NPCs.indexed(get("ktf_crown", -1))?.let { if (it.owner == this) NPCs.remove(it) } + clear("ktf_fail") + clear("ktf_crown") + RandomEvents.complete(this) + } + + private fun Player.nagLines() = listOf( + "$name, the Frog ${royal()} needs your help.", + "Greetings from the Frog ${royal()}, $name!", + "Talk to the Frog ${royal()}, $name!", + "Please respond to the Frog ${royal()}, $name!", + ) + + companion object { + private val LAND = Tile(2463, 4781) + + // Spots among the plain frogs where the crowned royal frog can be hidden. + private val CROWN_TILES = listOf( + Tile(2456, 4787), + Tile(2458, 4774), + Tile(2463, 4774), + Tile(2464, 4782), + Tile(2470, 4774), + Tile(2454, 4783), + ) + } +} diff --git a/game/src/test/kotlin/content/activity/event/random/KissTheFrogTest.kt b/game/src/test/kotlin/content/activity/event/random/KissTheFrogTest.kt new file mode 100644 index 0000000000..061a8756d8 --- /dev/null +++ b/game/src/test/kotlin/content/activity/event/random/KissTheFrogTest.kt @@ -0,0 +1,97 @@ +package content.activity.event.random + +import WorldTest +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.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.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 KissTheFrogTest : WorldTest() { + + private val origin = Tile(3221, 3218) + private val land = Tile(2463, 4781) + + private fun start(name: String): Player { + val player = createPlayer(origin, name) + RandomEvents.start(player, "kiss_the_frog") + tick(2) + return player + } + + private fun Player.herald(): NPC? = (-2..2).flatMap { dx -> (-2..2).map { dy -> tile.add(dx, dy) } } + .firstNotNullOfOrNull { t -> NPCs.firstOrNull(t) { it.id == "frog_herald" && it.owner == this } } + + /** Tick and clear any dialogue until the event ends. */ + private fun Player.driveUntilDone() { + tickIf(200) { + while (dialogue != null) skipDialogues() + get("random_event") == "kiss_the_frog" + } + } + + private fun Player.enterLand() { + npcOption(herald()!!, "Talk-to") + tickIf(40) { + while (dialogue != null) skipDialogues() + get("ktf_crown", -1) == -1 + } + while (dialogue != null) skipDialogues() // finish the herald's explanation + tick() + } + + @Test + fun `The Frog Herald appears beside the player`() { + val player = start("ktf_start") + + assertEquals("kiss_the_frog", player.get("random_event")) + assertNotNull(player.herald()) + } + + @Test + fun `Talking to the herald whisks the player to the land and hides a crowned frog`() { + val player = start("ktf_land") + player.enterLand() + + assertTrue(player.tile.within(land, 20), "Expected the land, was ${player.tile}") + val crown = NPCs.indexed(player.get("ktf_crown", -1)) + assertNotNull(crown) + assertEquals("frog_10_frogland", crown!!.id) + } + + @Test + fun `Kissing the crowned frog rewards a gift and returns the player`() { + val player = start("ktf_kiss") + player.enterLand() + val crown = NPCs.indexed(player.get("ktf_crown", -1))!! + + player.npcOption(crown, "Talk-to") + player.driveUntilDone() + + assertEquals(1, player.inventory.count("random_event_gift")) + assertNull(player.get("random_event")) + assertEquals(origin, player.tile) + } + + @Test + fun `Talking to a plain frog turns the player into a frog`() { + val player = start("ktf_wrong") + player.enterLand() + val plain = createNPC("frog_9_frogland", player.tile.addX(1)) + + player.npcOption(plain, "Talk-to") + tick() + while (player.dialogue != null) player.skipDialogues() + tick(2) + + assertTrue(player.get("ktf_fail", false)) + } +} From 970f847448c48647dc1d235bf6f4b3ec827b3141 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Wed, 8 Jul 2026 18:18:18 -0700 Subject: [PATCH 56/89] Fix frog chatheads and face-before-kiss in Kiss the Frog Give the frog NPCs the 'old' dialogue namespace (2009scape used FaceAnim.OLD_*) so their chatheads animate, and pause after turning to face each other before the kiss. --- data/activity/event/random/random-events.npcs.toml | 12 ++++++++++++ .../event/random/kiss_the_frog/KissTheFrog.kt | 2 ++ 2 files changed, 14 insertions(+) diff --git a/data/activity/event/random/random-events.npcs.toml b/data/activity/event/random/random-events.npcs.toml index 7acfd6c2d2..7ae6f29d85 100644 --- a/data/activity/event/random/random-events.npcs.toml +++ b/data/activity/event/random/random-events.npcs.toml @@ -1,35 +1,43 @@ [frog_frogland] id = 390 +dialogue = "old" wander_range = 3 examine = "A frog." [frog_2_frogland] clone = "frog_frogland" id = 391 +dialogue = "old" [frog_3_frogland] clone = "frog_frogland" id = 392 +dialogue = "old" [frog_4_frogland] clone = "frog_frogland" id = 393 +dialogue = "old" [frog_5_frogland] clone = "frog_frogland" id = 394 +dialogue = "old" [frog_6_frogland] clone = "frog_frogland" id = 395 +dialogue = "old" [frog_7_frogland] clone = "frog_frogland" id = 396 +dialogue = "old" [frog_8_frogland] clone = "frog_frogland" id = 403 +dialogue = "old" [giant_surprise_exam] id = 487 @@ -41,6 +49,7 @@ examine = "If the mummy is at school, where are the kids?" [frog_herald] id = 2472 +dialogue = "old" examine = "Speaks on behalf of His and Her Royal Frogness." [quiz_master] @@ -56,6 +65,7 @@ examine = "He wants to escape!" [frog_9_frogland] clone = "frog_frogland" id = 3300 +dialogue = "old" [mr_mordau] id = 6117 @@ -84,10 +94,12 @@ examine = "The sandwich lady." [frog_10_frogland] clone = "frog_frogland" id = 8636 +dialogue = "old" [frog_11_frogland] clone = "frog_frogland" id = 8637 +dialogue = "old" [balloon_animal_dog] id = 11232 diff --git a/game/src/main/kotlin/content/activity/event/random/kiss_the_frog/KissTheFrog.kt b/game/src/main/kotlin/content/activity/event/random/kiss_the_frog/KissTheFrog.kt index a3b6ac8b87..476249c75e 100644 --- a/game/src/main/kotlin/content/activity/event/random/kiss_the_frog/KissTheFrog.kt +++ b/game/src/main/kotlin/content/activity/event/random/kiss_the_frog/KissTheFrog.kt @@ -111,8 +111,10 @@ class KissTheFrog : Script { } private suspend fun Player.kiss(frog: NPC) { + // Turn to face each other before leaning in. face(frog.tile) frog.face(tile) + delay(1) frog.anim("frog_kiss") anim("human_kiss_the_frog") delay(3) From baa892a8204094fffec92e1bf42078271ddeb76b Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Thu, 9 Jul 2026 06:01:24 -0700 Subject: [PATCH 57/89] Rework Kiss the Frog fail/escape flow and fix royal detection - Crowned royal is now frog_9_frogland (3300); the rogue decorative 3300 spawn that looked crowned but gave plain dialogue is now a plain frog. - Talking to wrong frogs offends the royal; a third offence morphs the player into a plain frog and banishes them to a frog cave, where a second crowned royal releases them (random exile, no reward). - Broaden the plain-frog handler to frog_*frogland so the bare frogland frogs baked into the region respond instead of "Nothing interesting". - Restrict the Land of the Frogs to the six social tabs; land arrival moved to 2445,4770 with crown-spawn tiles near it. - Make the kiss and cave-escape sequences walk-off safe: a walkTrigger fades the player out and completes/escapes so they can't be stranded mid-dialogue. --- .../random/kiss_the_frog.npc-spawns.toml | 2 +- .../event/random/kiss_the_frog.vars.toml | 7 + .../event/random/kiss_the_frog/KissTheFrog.kt | 150 ++++++++++++++---- .../activity/event/random/KissTheFrogTest.kt | 52 +++++- 4 files changed, 172 insertions(+), 39 deletions(-) diff --git a/data/activity/event/random/kiss_the_frog.npc-spawns.toml b/data/activity/event/random/kiss_the_frog.npc-spawns.toml index 5d7fcc92b0..b8841a1c70 100644 --- a/data/activity/event/random/kiss_the_frog.npc-spawns.toml +++ b/data/activity/event/random/kiss_the_frog.npc-spawns.toml @@ -13,5 +13,5 @@ spawns = [ { id = "frog_11_frogland", x = 2442, y = 4770 }, { id = "frog_11_frogland", x = 2441, y = 4766 }, { id = "frog_herald", x = 2446, y = 4770, direction = "WEST" }, - { id = "frog_9_frogland", x = 2440, y = 4774 }, + { id = "frog_2_frogland", x = 2440, y = 4774 }, ] \ No newline at end of file diff --git a/data/activity/event/random/kiss_the_frog.vars.toml b/data/activity/event/random/kiss_the_frog.vars.toml index ec359081fd..7a4aa08c8f 100644 --- a/data/activity/event/random/kiss_the_frog.vars.toml +++ b/data/activity/event/random/kiss_the_frog.vars.toml @@ -2,5 +2,12 @@ format = "boolean" persist = true +[ktf_offended] +format = "int" +persist = true + [ktf_crown] format = "int" + +[ktf_escape] +format = "int" diff --git a/game/src/main/kotlin/content/activity/event/random/kiss_the_frog/KissTheFrog.kt b/game/src/main/kotlin/content/activity/event/random/kiss_the_frog/KissTheFrog.kt index 476249c75e..c5439e45bc 100644 --- a/game/src/main/kotlin/content/activity/event/random/kiss_the_frog/KissTheFrog.kt +++ b/game/src/main/kotlin/content/activity/event/random/kiss_the_frog/KissTheFrog.kt @@ -6,7 +6,6 @@ import content.activity.event.random.kidnap import content.activity.event.random.startInPlaceEvent import content.entity.effect.clearTransform import content.entity.effect.transform -import content.entity.player.dialogue.Angry import content.entity.player.dialogue.Happy import content.entity.player.dialogue.Laugh import content.entity.player.dialogue.Neutral @@ -15,14 +14,18 @@ import content.entity.player.dialogue.Sad import content.entity.player.dialogue.type.npc import content.entity.player.dialogue.type.player import content.entity.player.inv.item.addOrDrop +import content.quest.closeTabs +import content.quest.openTabs import world.gregs.voidps.engine.Script import world.gregs.voidps.engine.client.message import world.gregs.voidps.engine.client.ui.open +import world.gregs.voidps.engine.entity.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.name +import world.gregs.voidps.engine.queue.queue import world.gregs.voidps.type.Tile import world.gregs.voidps.type.random @@ -30,8 +33,11 @@ import world.gregs.voidps.type.random * Kiss the Frog (Frog Princess) random event: the Frog Herald hops up to the player and whisks them * to the Land of the Frogs. Among the identical frogs one wears a crown - the Frog Prince (for female * players) or Princess (for male players). Kissing the crowned frog restores it to human form and - * earns a gift box; kissing (or talking to) the wrong frog turns the player into a frog until they - * apologise to the crowned one. + * earns a gift box. + * + * Talking to the other frogs offends the royal; do it too often and the royal turns the player into a + * frog and banishes them to a small frog cave, where a second crowned royal will send them home (empty + * handed, dumped somewhere random in Gielinor) if spoken to. * https://runescape.wiki/w/Random_events?oldid=3667851#Frog */ class KissTheFrog : Script { @@ -50,26 +56,32 @@ class KissTheFrog : Script { endInPlaceEvent(herald) kidnap(LAND) spawnCrown() + restrictTabs() explain() } else { explain() } } - npcOperate("Talk-to", "frog_*_frogland") { (frog) -> + // `frog_*frogland` (no mandatory underscore) matches the bare `frog_frogland` baked into the + // region as well as the numbered `frog_N_frogland` variants; `frog_*_frogland` would miss the + // bare frogs, so talking to them gave "Nothing interesting happens". + npcOperate("Talk-to", "frog_*frogland") { (frog) -> if (get("random_event") != "kiss_the_frog") { return@npcOperate } - if (frog.index == get("ktf_crown", -1)) { - royalFrog(frog) - } else { - plainFrog(frog) + when (frog.index) { + get("ktf_crown", -1) -> royalFrog(frog) + get("ktf_escape", -1) -> escapeFrog(frog) + else -> plainFrog(frog) } } } private fun Player.startEvent() { clear("ktf_fail") + clear("ktf_offended") + clear("ktf_escape") startInPlaceEvent("frog_herald", nagLines()) } @@ -78,11 +90,21 @@ class KissTheFrog : Script { private fun Player.crownFrog() = if (male) "frog_princess" else "frog_prince" + /** Spawn the crowned royal (npc 3300) hidden among the plain frogs of the Land of the Frogs. */ private fun Player.spawnCrown() { - val frog = NPCs.add("frog_10_frogland", CROWN_TILES.random(random), ticks = -1, owner = this) + val frog = NPCs.add(ROYAL, CROWN_TILES.random(random), ticks = -1, owner = this) set("ktf_crown", frog.index) } + /** + * In the Land of the Frogs the player only keeps six tabs: Friends List, Ignore List, Clan Chat, + * Options, Music Player and Notes. [closeTabs] closes everything else (and Notes), so re-open Notes. + */ + private fun Player.restrictTabs() { + closeTabs() + open("notes") + } + private suspend fun Player.explain() { val royal = royal() val subject = if (male) "She" else "He" @@ -94,16 +116,9 @@ class KissTheFrog : Script { npc("frog_herald", "$subject is the frog with the crown. Make sure you speak to $him, not the other frogs, or $subject'll be offended.") } + /** The crowned royal in the Land of the Frogs: kiss to win. */ private suspend fun Player.royalFrog(frog: NPC) { val id = frog.id - if (get("ktf_fail", false)) { - npc(id, "Hmph. Have you come to apologise for ignoring me?") - player("I'm very sorry. Please change me back!") - npc(id, "All right. But in future be more polite.") - clearTransform() - finishEvent() - return - } npc(id, "$name, you must help me! I have been turned into a frog by a well-meaning wizard with an unfortunate obsession with frogs.") npc(id, "The only thing that will restore my true form is a kiss.") player("Excuses, excuses! Okay, if that's what you want...") @@ -111,6 +126,10 @@ class KissTheFrog : Script { } private suspend fun Player.kiss(frog: NPC) { + // If the player clicks off the "Thank you" dialogue (which cancels this handler), the walk fires + // this trigger and they still fade out, collect the reward and teleport home; reading it through + // does the same at the end. + walkTrigger = { queue("ktf_kiss") { completeKiss() } } // Turn to face each other before leaning in. face(frog.tile) frog.face(tile) @@ -127,33 +146,98 @@ class KissTheFrog : Script { frog.anim("emote_blow_kiss") frog.gfx("emote_blow_kiss") delay(3) + completeKiss() + } + + /** Fade to black, hand over the reward and teleport the player home. */ + private suspend fun Player.completeKiss() { + if (get("random_event") != "kiss_the_frog") { + return + } + walkTrigger = null open("fade_out") delay(2) addOrDrop("random_event_gift") message("You've been given a gift!") - NPCs.remove(frog) finishEvent() open("fade_in") } + /** The crowned royal in the frog cave: talking to it releases a hexed player (no reward). */ + private suspend fun Player.escapeFrog(frog: NPC) { + // The cave is enclosed, so the player must not be able to strand themselves by walking off. If + // they click off the dialogue (which cancels this handler), the walk fires this trigger and they + // fade out and teleport home anyway; reading the dialogue through does the same at the end. + walkTrigger = { queue("ktf_escape_cave") { escapeCave() } } + npc(frog.id, "Oh, another poor soul hexed into a frog for their bad manners.") + npc(frog.id, "Here, I'll send you on your way. Do be more polite next time.") + escapeCave() + } + + /** Fade to black and dump the hexed player somewhere random (no reward). */ + private suspend fun Player.escapeCave() { + if (get("random_event") != "kiss_the_frog") { + return + } + walkTrigger = null + open("fade_out") + delay(2) + failEvent() + open("fade_in") + } + private suspend fun Player.plainFrog(frog: NPC) { if (get("ktf_fail", false)) { - npc(frog.id, "Don't talk to me! Speak to the frog ${royal()}!") + // Already a frog - the plain frogs have nothing to say to another frog. + npc(frog.id, "Ribbit.") + return + } + // Ignoring the royal to talk to plain frogs offends them; do it too often and the royal turns + // the player into a frog and banishes them to the frog cave. + val offences = inc("ktf_offended") + if (offences < OFFENCE_LIMIT) { + npc(frog.id, "Ribbit.") + message("The Frog ${royal()} looks offended that you spoke to another frog.") return } npc(frog.id, "Well, we'll see how you like being a frog!") + banishToCave() + } + + /** Third offence: morph the player into a frog and teleport them to the frog cave to escape from. */ + private suspend fun Player.banishToCave() { set("ktf_fail", true) + message("You've been turned into a frog!") anim("morph_to_frog") - delay(1) - transform("frog_9_frogland") + delay(2) + transform(PLAYER_FROG) + delay(2) + tele(FAIL_CAVE) + val escape = NPCs.add(ROYAL, ESCAPE_TILE, ticks = -1, owner = this) + set("ktf_escape", escape.index) } + /** Successful kiss: return the player home. */ private fun Player.finishEvent() { + cleanup() + RandomEvents.complete(this) + } + + /** Escaped the frog cave: dump the player somewhere random with no reward. */ + private fun Player.failEvent() { + cleanup() + RandomEvents.fail(this) + } + + private fun Player.cleanup() { + openTabs() clearTransform() NPCs.indexed(get("ktf_crown", -1))?.let { if (it.owner == this) NPCs.remove(it) } + NPCs.indexed(get("ktf_escape", -1))?.let { if (it.owner == this) NPCs.remove(it) } clear("ktf_fail") + clear("ktf_offended") clear("ktf_crown") - RandomEvents.complete(this) + clear("ktf_escape") } private fun Player.nagLines() = listOf( @@ -164,16 +248,22 @@ class KissTheFrog : Script { ) companion object { - private val LAND = Tile(2463, 4781) + // Number of times the player can talk to the wrong frogs before the royal turns them into one. + private const val OFFENCE_LIMIT = 3 + + // The crowned royal frog (Frog Prince/Princess) and the plain frog the player is morphed into. + private const val ROYAL = "frog_9_frogland" + private const val PLAYER_FROG = "frog_frogland" + + private val LAND = Tile(2445, 4770) + + // The small cave the player is banished to on failure, and the escape royal's spot within it. + private val FAIL_CAVE = Tile(2464, 4782) + private val ESCAPE_TILE = Tile(2464, 4784) - // Spots among the plain frogs where the crowned royal frog can be hidden. + // Spots among the plain frogs near the arrival tile [LAND] where the crowned royal is hidden. private val CROWN_TILES = listOf( - Tile(2456, 4787), - Tile(2458, 4774), - Tile(2463, 4774), - Tile(2464, 4782), - Tile(2470, 4774), - Tile(2454, 4783), + Tile(2442, 4774), ) } } diff --git a/game/src/test/kotlin/content/activity/event/random/KissTheFrogTest.kt b/game/src/test/kotlin/content/activity/event/random/KissTheFrogTest.kt index 061a8756d8..3ae37975cd 100644 --- a/game/src/test/kotlin/content/activity/event/random/KissTheFrogTest.kt +++ b/game/src/test/kotlin/content/activity/event/random/KissTheFrogTest.kt @@ -5,6 +5,7 @@ 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.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 @@ -18,7 +19,7 @@ import kotlin.test.assertTrue class KissTheFrogTest : WorldTest() { private val origin = Tile(3221, 3218) - private val land = Tile(2463, 4781) + private val land = Tile(2445, 4770) private fun start(name: String): Player { val player = createPlayer(origin, name) @@ -64,7 +65,11 @@ class KissTheFrogTest : WorldTest() { assertTrue(player.tile.within(land, 20), "Expected the land, was ${player.tile}") val crown = NPCs.indexed(player.get("ktf_crown", -1)) assertNotNull(crown) - assertEquals("frog_10_frogland", crown!!.id) + assertEquals("frog_9_frogland", crown!!.id) + // Only the six social tabs remain; combat gear and inventory are hidden. + assertTrue(!player.interfaces.contains("inventory"), "Inventory tab should be hidden") + assertTrue(player.interfaces.contains("music_player"), "Music Player tab should remain") + assertTrue(player.interfaces.contains("notes"), "Notes tab should remain") } @Test @@ -72,6 +77,7 @@ class KissTheFrogTest : WorldTest() { val player = start("ktf_kiss") player.enterLand() val crown = NPCs.indexed(player.get("ktf_crown", -1))!! + player.tele(crown.tile.addX(1)) player.npcOption(crown, "Talk-to") player.driveUntilDone() @@ -81,17 +87,47 @@ class KissTheFrogTest : WorldTest() { assertEquals(origin, player.tile) } + private fun Player.talk(npc: NPC) { + npcOption(npc, "Talk-to") + tick() + while (dialogue != null) skipDialogues() + tick(2) + } + @Test - fun `Talking to a plain frog turns the player into a frog`() { + fun `Offending the royal repeatedly turns the player into a frog`() { val player = start("ktf_wrong") player.enterLand() - val plain = createNPC("frog_9_frogland", player.tile.addX(1)) + val plain = createNPC("frog_frogland", player.tile.addX(1)) - player.npcOption(plain, "Talk-to") - tick() - while (player.dialogue != null) player.skipDialogues() - tick(2) + // The first offences only annoy the royal; the player stays human. + player.talk(plain) + assertTrue(!player.get("ktf_fail", false)) + player.talk(plain) + assertTrue(!player.get("ktf_fail", false)) + // The third offence transforms them. + player.talk(plain) assertTrue(player.get("ktf_fail", false)) } + + @Test + fun `A transformed player who talks to the royal is dumped somewhere with no reward`() { + val player = start("ktf_dump") + player.enterLand() + val plain = createNPC("frog_frogland", player.tile.addX(1)) + repeat(3) { player.talk(plain) } + assertTrue(player.get("ktf_fail", false)) + // The third offence banishes the player to the cave and spawns the escape royal. + tickIf(20) { NPCs.indexed(player.get("ktf_escape", -1)) == null } + val escape = NPCs.indexed(player.get("ktf_escape", -1))!! + player.tele(escape.tile.addX(1)) + + player.npcOption(escape, "Talk-to") + player.driveUntilDone() + + assertEquals(0, player.inventory.count("random_event_gift")) + assertNull(player.get("random_event")) + assertTrue(!player.get("ktf_fail", false)) + } } From e340761e3469dc9075eac2edce2cdf9c8ed9a120 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Thu, 9 Jul 2026 06:29:41 -0700 Subject: [PATCH 58/89] Seat the player during the Quiz Master show Play the sit animation (2378) when the contestant takes their seat in the studio, and stand back up on winning before teleporting home. --- data/activity/event/random/quiz_master/quiz_master.anims.toml | 2 ++ .../content/activity/event/random/quiz_master/QuizMaster.kt | 2 ++ 2 files changed, 4 insertions(+) create mode 100644 data/activity/event/random/quiz_master/quiz_master.anims.toml diff --git a/data/activity/event/random/quiz_master/quiz_master.anims.toml b/data/activity/event/random/quiz_master/quiz_master.anims.toml new file mode 100644 index 0000000000..ab10397a03 --- /dev/null +++ b/data/activity/event/random/quiz_master/quiz_master.anims.toml @@ -0,0 +1,2 @@ +[quiz_show_sit] +id = 2378 diff --git a/game/src/main/kotlin/content/activity/event/random/quiz_master/QuizMaster.kt b/game/src/main/kotlin/content/activity/event/random/quiz_master/QuizMaster.kt index 63e8da5d82..a0ef9f1090 100644 --- a/game/src/main/kotlin/content/activity/event/random/quiz_master/QuizMaster.kt +++ b/game/src/main/kotlin/content/activity/event/random/quiz_master/QuizMaster.kt @@ -48,6 +48,7 @@ class QuizMaster : Script { quizHerald() kidnap(ROOM) face(QUIZ_MASTER) + anim("quiz_show_sit") // the contestant takes their seat for the show intro() runQuiz() } @@ -104,6 +105,7 @@ class QuizMaster : Script { addOrDrop(row.item("item"), row.int("amount")) } } + clearAnim() // stand up out of the contestant's seat clear("quiz_answer") clear("quiz_correct") RandomEvents.complete(this) From 9d0d1eb18107fd644008342921d9bd5507048cdd Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Thu, 9 Jul 2026 11:39:05 -0700 Subject: [PATCH 59/89] Add Pinball random event --- .../event/random/pinball/pinball.anims.toml | 5 + .../event/random/pinball/pinball.ifaces.toml | 6 + .../random/pinball/pinball.npc-spawns.toml | 4 - .../event/random/pinball/pinball.objs.toml | 17 ++ .../event/random/pinball/pinball.vars.toml | 7 + .../event/random/random_event.tables.toml | 4 + .../activity/event/random/pinball/Pinball.kt | 234 ++++++++++++++++++ 7 files changed, 273 insertions(+), 4 deletions(-) create mode 100644 data/activity/event/random/pinball/pinball.anims.toml create mode 100644 data/activity/event/random/pinball/pinball.ifaces.toml delete mode 100644 data/activity/event/random/pinball/pinball.npc-spawns.toml create mode 100644 data/activity/event/random/pinball/pinball.objs.toml create mode 100644 data/activity/event/random/pinball/pinball.vars.toml create mode 100644 game/src/main/kotlin/content/activity/event/random/pinball/Pinball.kt diff --git a/data/activity/event/random/pinball/pinball.anims.toml b/data/activity/event/random/pinball/pinball.anims.toml new file mode 100644 index 0000000000..cdb72258c5 --- /dev/null +++ b/data/activity/event/random/pinball/pinball.anims.toml @@ -0,0 +1,5 @@ +[pinball_post_flash] +id = 4005 + +[pinball_post_reset] +id = 4006 diff --git a/data/activity/event/random/pinball/pinball.ifaces.toml b/data/activity/event/random/pinball/pinball.ifaces.toml new file mode 100644 index 0000000000..33f8985092 --- /dev/null +++ b/data/activity/event/random/pinball/pinball.ifaces.toml @@ -0,0 +1,6 @@ +[pinball_overlay] +id = 263 +type = "overlay" + +[.score] +id = 1 diff --git a/data/activity/event/random/pinball/pinball.npc-spawns.toml b/data/activity/event/random/pinball/pinball.npc-spawns.toml deleted file mode 100644 index 69e5d3680d..0000000000 --- a/data/activity/event/random/pinball/pinball.npc-spawns.toml +++ /dev/null @@ -1,4 +0,0 @@ -spawns = [ - { id = "flippa_pinball", x = 1973, y = 5038 }, - { id = "tilt_pinball", x = 1970, y = 5038 }, -] diff --git a/data/activity/event/random/pinball/pinball.objs.toml b/data/activity/event/random/pinball/pinball.objs.toml new file mode 100644 index 0000000000..6c97501865 --- /dev/null +++ b/data/activity/event/random/pinball/pinball.objs.toml @@ -0,0 +1,17 @@ +[pinball_post_1] +id = 15001 + +[pinball_post_2] +id = 15003 + +[pinball_post_3] +id = 15005 + +[pinball_post_4] +id = 15007 + +[pinball_post_5] +id = 15009 + +[pinball_cave_exit] +id = 15010 diff --git a/data/activity/event/random/pinball/pinball.vars.toml b/data/activity/event/random/pinball/pinball.vars.toml new file mode 100644 index 0000000000..df4e4227d9 --- /dev/null +++ b/data/activity/event/random/pinball/pinball.vars.toml @@ -0,0 +1,7 @@ +[pinball_score] +format = "int" +persist = true + +[pinball_target] +format = "int" +persist = true diff --git a/data/activity/event/random/random_event.tables.toml b/data/activity/event/random/random_event.tables.toml index 7373086b9e..6f6dca1210 100644 --- a/data/activity/event/random/random_event.tables.toml +++ b/data/activity/event/random/random_event.tables.toml @@ -46,6 +46,10 @@ weight = 10 event = "kiss_the_frog" weight = 10 +[.pinball] +event = "pinball" +weight = 10 + [random_event_settings] value = "int" diff --git a/game/src/main/kotlin/content/activity/event/random/pinball/Pinball.kt b/game/src/main/kotlin/content/activity/event/random/pinball/Pinball.kt new file mode 100644 index 0000000000..93b34aa25b --- /dev/null +++ b/game/src/main/kotlin/content/activity/event/random/pinball/Pinball.kt @@ -0,0 +1,234 @@ +package content.activity.event.random.pinball + +import content.activity.event.random.RandomEvents +import content.activity.event.random.kidnap +import content.activity.event.random.mysteriousOldMan +import content.entity.player.dialogue.Confused +import content.entity.player.dialogue.Neutral +import content.entity.player.dialogue.Quiz +import content.entity.player.dialogue.type.npc +import content.entity.player.dialogue.type.player +import content.entity.player.dialogue.type.statement +import content.entity.player.inv.item.addOrDrop +import content.quest.closeTabs +import content.quest.instanceOffset +import content.quest.openTabs +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.entity.character.npc.NPCs +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.entity.obj.GameObject +import world.gregs.voidps.engine.entity.obj.GameObjects +import world.gregs.voidps.engine.get +import world.gregs.voidps.engine.map.zone.DynamicZones +import world.gregs.voidps.type.Delta +import world.gregs.voidps.type.Direction +import world.gregs.voidps.type.Region +import world.gregs.voidps.type.Tile +import world.gregs.voidps.type.Zone +import world.gregs.voidps.type.random + +/** + * Pinball random event: the Mysterious Old Man teleports the player into a private copy of the trolls' + * pinball arena. One of five posts flashes with rings; tagging it scores a point and lights a new one, + * while tagging an unlit post resets the score to zero. Score ten points, then leave through the cave + * exit (the troll guards Flippa and Tilt block the way until then) for a handful of uncut gems. + * https://runescape.wiki/w/Random_events?oldid=3667851#Pinball + */ +class Pinball : Script { + + init { + RandomEvents.register("pinball") { startEvent() } + + objectOperate("Tag", "pinball_post_*") { (post) -> + if (get("random_event") != "pinball") { + return@objectOperate + } + tagPost(post) + } + + objectOperate("Exit", "pinball_cave_exit") { + if (get("random_event") != "pinball") { + return@objectOperate + } + exitArena() + } + + // The trolls' cache option is "Talk-To" (note the capital O), not the usual "Talk-to". + for (guard in GUARDS) { + npcOperate("Talk-To", guard) { + if (get("random_event") != "pinball") { + message("He's busy right now.") + return@npcOperate + } + guardTalk(guard) + } + } + } + + private suspend fun Player.startEvent() { + set("pinball_score", 0) + clear("pinball_target") + mysteriousOldMan() + val offset = copyArena() + kidnap(ARENA.add(offset)) + NPCs.add("flippa_pinball", FLIPPA.add(offset), Direction.NORTH, ticks = -1, owner = this) + NPCs.add("tilt_pinball", TILT.add(offset), Direction.NORTH, ticks = -1, owner = this) + closeTabs() + minimap(Minimap.HideMap) + open("pinball_overlay") + sendScore() + rules(offset) + lightNextPost() + } + + /** + * Allocate a private instance and copy only the arena's zones (the box between [AREA_SW] and + * [AREA_NE]) into it, leaving the rest of the instance empty. Returns the tile offset from the real + * arena to its copy. + */ + private fun Player.copyArena(): Delta { + val instance = smallInstance() + val offset = instance.offset(REGION) + val zones = get() + for (zoneX in (AREA_SW.x shr 3)..(AREA_NE.x shr 3)) { + for (zoneY in (AREA_SW.y shr 3)..(AREA_NE.y shr 3)) { + val from = Zone(zoneX, zoneY, 0) + val base = from.tile.add(offset) + zones.copy(from, Zone(base.x shr 3, base.y shr 3, base.level)) + } + } + set("instance_offset", offset.id) + return offset + } + + private suspend fun Player.rules(offset: Delta) { + val oldMan = NPCs.add("mysterious_old_man", OLD_MAN.add(offset), ticks = -1, owner = this) + oldMan.face(this) + face(oldMan) + npc("mysterious_old_man", "The rules of the game are quite simple. You have to score 10 points by tagging the flashing pillars.") + npc("mysterious_old_man", "Don't tag the ones that do not have rings around the base, as that will reset your points, and don't try and get past those trolls until you are done!") + npc("mysterious_old_man", "See you later!") + oldMan.anim("emote_wave") + delay(4) + oldMan.gfx("imp_puff") + delay(1) + NPCs.remove(oldMan) + statement("Tag the post with the $FLASHING_RINGS.", clickToContinue = false) + } + + private suspend fun Player.tagPost(post: GameObject) { + if (get("pinball_score", 0) >= REQUIRED) { + return + } + anim("take") + val tagged = POSTS.indexOfFirst { it.id == post.id } + if (tagged != get("pinball_target", 0) - 1) { + set("pinball_score", 0) + sendScore() + lightNextPost() + statement("Wrong post! Your score has been reset. Tag the post with the $FLASHING_RINGS.", clickToContinue = false) + return + } + val score = inc("pinball_score") + sendScore() + if (score >= REQUIRED) { + resetPost(tagged) + clear("pinball_target") + statement("Congratulations - you can now leave the arena.", clickToContinue = false) + } else { + lightNextPost() + statement("Well done! Now tag the next post.", clickToContinue = false) + } + } + + private suspend fun Player.exitArena() { + if (get("pinball_score", 0) < REQUIRED) { + guardTalk(GUARDS.first()) + return + } + reward() + close("pinball_overlay") + openTabs() + clearMinimap() + clear("pinball_score") + clear("pinball_target") + RandomEvents.complete(this) // clears the instance and teleports the player home + } + + private suspend fun Player.guardTalk(guard: String) { + if (get("pinball_score", 0) >= REQUIRED) { + player("So...I'm free to go now, right?") + npc(guard, "Yer, get going. We get break now.") + player("Ok. Err...have a nice break.") + } else { + player("Err...what am I supposed to do here again?") + npc(guard, "You gotta poke dem pillars.") + player("What? All of them?") + npc(guard, "No, no. If you poke dem ones dat don't have a dem flashin' rings, den dats bad.") + player("So I have to poke the pillars that are flashing?") + npc(guard, "Yer. You do dat ten times, you get prize.") + } + } + + private fun Player.reward() { + val gem = REWARDS.random(random) + addOrDrop(gem, if (gem == "uncut_diamond") 2 else (3..5).random(random)) + } + + /** Reset the previous flashing post and start a new random one flashing; the target is 1-based. */ + private fun Player.lightNextPost() { + resetPost(get("pinball_target", 0) - 1) + val next = POSTS.indices.random(random) + animatePost(next, "pinball_post_flash") + set("pinball_target", next + 1) + } + + private fun Player.resetPost(index: Int) { + if (index in POSTS.indices) { + animatePost(index, "pinball_post_reset") + } + } + + private fun Player.animatePost(index: Int, anim: String) { + val post = POSTS[index] + GameObjects.at(post.tile.add(instanceOffset())).firstOrNull { it.id == post.id }?.anim(anim) + } + + private fun Player.sendScore() { + interfaces.sendText("pinball_overlay", "score", "Score: ${get("pinball_score", 0)}") + } + + private data class Post(val id: String, val tile: Tile) + + companion object { + private const val REQUIRED = 10 + private const val FLASHING_RINGS = "flashing rings" + private val REGION = Region(30, 78) + + // Only this box is copied into the instance: south-west and north-east corners of the arena. + private val AREA_SW = Tile(1960, 5032) + private val AREA_NE = Tile(1982, 5055) + + private val ARENA = Tile(1972, 5046) + private val OLD_MAN = Tile(1971, 5046) + private val FLIPPA = Tile(1973, 5038) + private val TILT = Tile(1970, 5038) + private val GUARDS = listOf("flippa_pinball", "tilt_pinball") + private val REWARDS = listOf("uncut_diamond", "uncut_ruby", "uncut_emerald", "uncut_sapphire") + + private val POSTS = listOf( + Post("pinball_post_1", Tile(1967, 5046)), + Post("pinball_post_2", Tile(1969, 5049)), + Post("pinball_post_3", Tile(1972, 5050)), + Post("pinball_post_4", Tile(1975, 5049)), + Post("pinball_post_5", Tile(1977, 5046)), + ) + } +} From 4ed2a898c3260671e8cc96f8c290a4ef3b0adb74 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Thu, 9 Jul 2026 12:07:55 -0700 Subject: [PATCH 60/89] Add Prison Pete random event --- .../random/prison_pete/prison_pete.anims.toml | 11 + .../random/prison_pete/prison_pete.gfx.toml | 2 + .../prison_pete/prison_pete.ifaces.toml | 6 + .../prison_pete/prison_pete.obj-spawns.toml | 4 + .../random/prison_pete/prison_pete.objs.toml | 8 + .../prison_pete/prison_pete.sounds.toml | 2 + .../prison_pete/prison_pete.tables.toml | 72 +++++ .../random/prison_pete/prison_pete.vars.toml | 11 + .../event/random/random_event.npc-spawns.toml | 2 +- .../event/random/random_event.tables.toml | 4 + .../event/random/RandomEventRewards.kt | 7 +- .../activity/event/random/certer/Certer.kt | 4 +- .../activity/event/random/pillory/Pillory.kt | 4 +- .../event/random/prison_pete/PrisonPete.kt | 268 ++++++++++++++++++ .../activity/event/random/PrisonPeteTest.kt | 167 +++++++++++ 15 files changed, 564 insertions(+), 8 deletions(-) create mode 100644 data/activity/event/random/prison_pete/prison_pete.anims.toml create mode 100644 data/activity/event/random/prison_pete/prison_pete.gfx.toml create mode 100644 data/activity/event/random/prison_pete/prison_pete.ifaces.toml create mode 100644 data/activity/event/random/prison_pete/prison_pete.obj-spawns.toml create mode 100644 data/activity/event/random/prison_pete/prison_pete.objs.toml create mode 100644 data/activity/event/random/prison_pete/prison_pete.sounds.toml create mode 100644 data/activity/event/random/prison_pete/prison_pete.tables.toml create mode 100644 data/activity/event/random/prison_pete/prison_pete.vars.toml create mode 100644 game/src/main/kotlin/content/activity/event/random/prison_pete/PrisonPete.kt create mode 100644 game/src/test/kotlin/content/activity/event/random/PrisonPeteTest.kt diff --git a/data/activity/event/random/prison_pete/prison_pete.anims.toml b/data/activity/event/random/prison_pete/prison_pete.anims.toml new file mode 100644 index 0000000000..10396be4b2 --- /dev/null +++ b/data/activity/event/random/prison_pete/prison_pete.anims.toml @@ -0,0 +1,11 @@ +[prison_pete_pull_lever] +id = 798 + +[prison_pete_lever_pull] +id = 2899 + +[prison_pete_stomp] +id = 794 + +[prison_pete_pick_up] +id = 827 diff --git a/data/activity/event/random/prison_pete/prison_pete.gfx.toml b/data/activity/event/random/prison_pete/prison_pete.gfx.toml new file mode 100644 index 0000000000..e24ff18c72 --- /dev/null +++ b/data/activity/event/random/prison_pete/prison_pete.gfx.toml @@ -0,0 +1,2 @@ +[prison_pete_balloon_pop] +id = 524 diff --git a/data/activity/event/random/prison_pete/prison_pete.ifaces.toml b/data/activity/event/random/prison_pete/prison_pete.ifaces.toml new file mode 100644 index 0000000000..6ae0f31b79 --- /dev/null +++ b/data/activity/event/random/prison_pete/prison_pete.ifaces.toml @@ -0,0 +1,6 @@ +[prison_pete_balloons] +id = 273 +type = "main_screen" + +[.model] +id = 3 diff --git a/data/activity/event/random/prison_pete/prison_pete.obj-spawns.toml b/data/activity/event/random/prison_pete/prison_pete.obj-spawns.toml new file mode 100644 index 0000000000..ff4415114f --- /dev/null +++ b/data/activity/event/random/prison_pete/prison_pete.obj-spawns.toml @@ -0,0 +1,4 @@ +spawns = [ + { id = "prison_pete_gate_west", x = 2085, y = 4459, type = 0, rotation = 1 }, + { id = "prison_pete_gate_east", x = 2086, y = 4459, type = 0, rotation = 1 }, +] diff --git a/data/activity/event/random/prison_pete/prison_pete.objs.toml b/data/activity/event/random/prison_pete/prison_pete.objs.toml new file mode 100644 index 0000000000..e2164e8a81 --- /dev/null +++ b/data/activity/event/random/prison_pete/prison_pete.objs.toml @@ -0,0 +1,8 @@ +[prison_pete_lever] +id = 10817 + +[prison_pete_gate_west] +id = 2134 + +[prison_pete_gate_east] +id = 2138 diff --git a/data/activity/event/random/prison_pete/prison_pete.sounds.toml b/data/activity/event/random/prison_pete/prison_pete.sounds.toml new file mode 100644 index 0000000000..d6fc4e3bbf --- /dev/null +++ b/data/activity/event/random/prison_pete/prison_pete.sounds.toml @@ -0,0 +1,2 @@ +[prison_pete_balloon_pop] +id = 3252 diff --git a/data/activity/event/random/prison_pete/prison_pete.tables.toml b/data/activity/event/random/prison_pete/prison_pete.tables.toml new file mode 100644 index 0000000000..b7e7242cf0 --- /dev/null +++ b/data/activity/event/random/prison_pete/prison_pete.tables.toml @@ -0,0 +1,72 @@ +# Reward for freeing Prison Pete. +[random_event_prison_pete] +item = "item" +weight = "int" +min = "int" +max = "int" + +[.coins] +item = "coins" +weight = 20 +min = 527 +max = 527 + +[.coins_pouch] +item = "coins" +weight = 15 +min = 100 +max = 500 + +[.sapphire] +item = "sapphire" +weight = 25 +min = 5 +max = 5 + +[.law_rune] +item = "law_rune" +weight = 22 +min = 10 +max = 10 + +[.chaos_rune] +item = "chaos_rune" +weight = 20 +min = 1 +max = 1 + +[.uncut_ruby] +item = "uncut_ruby" +weight = 18 +min = 4 +max = 4 + +[.mithril_arrowtips] +item = "mithril_arrowtips" +weight = 16 +min = 47 +max = 50 + +[.ugthanki_kebab] +item = "ugthanki_kebab" +weight = 13 +min = 2 +max = 2 + +[.grimy_toadflax] +item = "grimy_toadflax" +weight = 12 +min = 6 +max = 6 + +[.uncut_diamond] +item = "uncut_diamond" +weight = 9 +min = 3 +max = 3 + +[.grimy_snapdragon] +item = "grimy_snapdragon" +weight = 6 +min = 4 +max = 4 diff --git a/data/activity/event/random/prison_pete/prison_pete.vars.toml b/data/activity/event/random/prison_pete/prison_pete.vars.toml new file mode 100644 index 0000000000..8a910d372c --- /dev/null +++ b/data/activity/event/random/prison_pete/prison_pete.vars.toml @@ -0,0 +1,11 @@ +[prison_pete_keys] +format = "int" +persist = true + +[prison_pete_target] +format = "string" +persist = true + +[prison_pete_wrong] +format = "boolean" +persist = true diff --git a/data/activity/event/random/random_event.npc-spawns.toml b/data/activity/event/random/random_event.npc-spawns.toml index c3603f8004..7c0c26dcd0 100644 --- a/data/activity/event/random/random_event.npc-spawns.toml +++ b/data/activity/event/random/random_event.npc-spawns.toml @@ -2,7 +2,7 @@ spawns = [ # 7754 { id = "quiz_master", x = 1952, y = 4768, level = 1, direction = "SOUTH" }, # 8261 - { id = "prison_pete", x = 2084, y = 4466, direction = "NORTH" }, + { id = "prison_pete", x = 2084, y = 4460, direction = "NORTH" }, { id = "balloon_animal_dog", x = 2078, y = 4462 }, { id = "balloon_animal_dog", x = 2090, y = 4464 }, { id = "balloon_animal_dog", x = 2091, y = 4461 }, diff --git a/data/activity/event/random/random_event.tables.toml b/data/activity/event/random/random_event.tables.toml index 6f6dca1210..3346d6bdeb 100644 --- a/data/activity/event/random/random_event.tables.toml +++ b/data/activity/event/random/random_event.tables.toml @@ -50,6 +50,10 @@ weight = 10 event = "pinball" weight = 10 +[.prison_pete] +event = "prison_pete" +weight = 10 + [random_event_settings] value = "int" diff --git a/game/src/main/kotlin/content/activity/event/random/RandomEventRewards.kt b/game/src/main/kotlin/content/activity/event/random/RandomEventRewards.kt index 2a2f34e643..0639847973 100644 --- a/game/src/main/kotlin/content/activity/event/random/RandomEventRewards.kt +++ b/game/src/main/kotlin/content/activity/event/random/RandomEventRewards.kt @@ -22,10 +22,11 @@ fun Player.rewardCostumeOrCoins(vararg pieces: String, coins: Int) { } /** - * Award a single weighted roll of the shared `random_event_certer` gem/coin table (Certer, Pillory). + * Award a single weighted roll of a random event loot table, e.g. the shared `random_event_certer` + * gem/coin table (Certer, Pillory) or `random_event_prison_pete`. */ -fun Player.rewardCerterLoot() { - val rows = Tables.get("random_event_certer").rows() +fun Player.rewardEventLoot(table: String) { + val rows = Tables.get(table).rows() var roll = random.nextInt(rows.sumOf { it.int("weight") }) for (row in rows) { roll -= row.int("weight") diff --git a/game/src/main/kotlin/content/activity/event/random/certer/Certer.kt b/game/src/main/kotlin/content/activity/event/random/certer/Certer.kt index 1b9da4013b..e23eb130c5 100644 --- a/game/src/main/kotlin/content/activity/event/random/certer/Certer.kt +++ b/game/src/main/kotlin/content/activity/event/random/certer/Certer.kt @@ -1,7 +1,7 @@ package content.activity.event.random.certer import content.activity.event.random.RandomEvents -import content.activity.event.random.rewardCerterLoot +import content.activity.event.random.rewardEventLoot import content.activity.event.random.startInPlaceEvent import content.entity.player.dialogue.Happy import content.entity.player.dialogue.Neutral @@ -43,7 +43,7 @@ class Certer : Script { close("certer_identify") if (correct) { npc("giles", "Thank you, I hope you like your present. I must be leaving now though.") - rewardCerterLoot() + rewardEventLoot("random_event_certer") } else { npc("giles", "Sorry, I don't think so.") } diff --git a/game/src/main/kotlin/content/activity/event/random/pillory/Pillory.kt b/game/src/main/kotlin/content/activity/event/random/pillory/Pillory.kt index a859dbba6c..65d5a5bafb 100644 --- a/game/src/main/kotlin/content/activity/event/random/pillory/Pillory.kt +++ b/game/src/main/kotlin/content/activity/event/random/pillory/Pillory.kt @@ -2,7 +2,7 @@ package content.activity.event.random.pillory import content.activity.event.random.RandomEvents import content.activity.event.random.kidnap -import content.activity.event.random.rewardCerterLoot +import content.activity.event.random.rewardEventLoot import world.gregs.voidps.engine.Script import world.gregs.voidps.engine.client.message import world.gregs.voidps.engine.client.ui.close @@ -98,7 +98,7 @@ class Pillory : Script { private fun Player.escape() { close("pillory_lock") message("You've escaped!") - rewardCerterLoot() + rewardEventLoot("random_event_certer") clear("pillory_target") clear("pillory_correct") clear("pillory_answer") diff --git a/game/src/main/kotlin/content/activity/event/random/prison_pete/PrisonPete.kt b/game/src/main/kotlin/content/activity/event/random/prison_pete/PrisonPete.kt new file mode 100644 index 0000000000..52ed2f980d --- /dev/null +++ b/game/src/main/kotlin/content/activity/event/random/prison_pete/PrisonPete.kt @@ -0,0 +1,268 @@ +package content.activity.event.random.prison_pete + +import content.activity.event.random.RandomEvents +import content.activity.event.random.kidnap +import content.activity.event.random.rewardEventLoot +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.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.client.message +import world.gregs.voidps.engine.client.ui.open +import world.gregs.voidps.engine.entity.World +import world.gregs.voidps.engine.entity.character.jingle +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.sound +import world.gregs.voidps.engine.entity.obj.GameObject +import world.gregs.voidps.engine.entity.obj.GameObjects +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.Direction +import world.gregs.voidps.type.Tile +import world.gregs.voidps.type.random + +/** + * Prison Pete random event: Evil Bob's cat throws the player into the ScapeRune prison alongside + * Pete. Pulling the big lever shows one of the four balloon animal shapes; popping a balloon of that + * shape yields a key that opens one of the door's locks, while popping the wrong shape yields a key + * that doesn't fit. Hand Pete three correct keys to unlock the door, escape and receive a reward. + * https://runescape.wiki/w/Random_events?oldid=3667851#Prison_Pete + */ +class PrisonPete : Script { + + init { + RandomEvents.register("prison_pete") { startEvent() } + + objectOperate("Pull", "prison_pete_lever") { (lever) -> + if (get("random_event") != "prison_pete") { + return@objectOperate + } + pullLever(lever) + } + + // The prison's decoy doors ("This doesn't look to be the way out.") never open. + objectOperate("Open", "locked_door_10_closed") { + message("The doors won't open.") + } + + // The exit gates only open once Pete has unlocked all three locks. + objectOperate("Open", "prison_pete_gate_*") { (gate) -> + if (get("random_event") == "prison_pete" && get("prison_pete_keys", 0) >= REQUIRED) { + escape(gate) + } else { + message("The doors won't open.") + } + } + + npcOperate("Pop", "balloon_animal_*") { (balloon) -> + if (get("random_event") != "prison_pete") { + return@npcOperate + } + popBalloon(balloon) + } + + npcOperate("Talk-to", "prison_pete") { (pete) -> + if (get("random_event") != "prison_pete") { + return@npcOperate + } + peteTalk(pete) + } + + itemOnNPCOperate("prison_key_prison_pete", "prison_pete") { (pete) -> + if (get("random_event") != "prison_pete") { + return@itemOnNPCOperate + } + returnKey(pete) + } + } + + private suspend fun Player.startEvent() { + set("prison_pete_keys", 0) + clear("prison_pete_target") + clear("prison_pete_wrong") + catIntro() + kidnap(PRISON) + message("Welcome to ScapeRune.") + } + + /** The prison is Evil Bob's, so his cat does the kidnapping instead of the Mysterious Old Man. */ + private suspend fun Player.catIntro() { + val cat = NPCs.addRandom("evil_bob_cat", tile.toCuboid(1), ticks = 25, owner = this) + ?: NPCs.add("evil_bob_cat", tile, ticks = 25, owner = this) + cat.watch(this) + cat.say("Meow.") + delay(2) + } + + private suspend fun Player.pullLever(lever: GameObject) { + if (get("prison_pete_keys", 0) >= REQUIRED) { + message("You've found all the keys.") + return + } + face(lever) + delay(2) + anim("prison_pete_pull_lever") + lever.anim("prison_pete_lever_pull") + delay(2) + val balloon = BALLOONS.random(random) + set("prison_pete_target", balloon.npc) + open("prison_pete_balloons") + interfaces.sendModel("prison_pete_balloons", "model", balloon.model) + } + + private suspend fun Player.popBalloon(balloon: NPC) { + val target: String? = get("prison_pete_target") + if (target == null) { + message("Pull the lever to see which balloon to pop.") + return + } + if (get("prison_pete_keys", 0) >= REQUIRED) { + message("You've found all the keys.") + return + } + if (inventory.isFull()) { + message("You don't have enough inventory space.") + return + } + walkOverDelay(balloon.tile) + clear("prison_pete_target") + anim("prison_pete_stomp") + sound("prison_pete_balloon_pop") + balloon.gfx("prison_pete_balloon_pop") + if (balloon.id == target) { + inc("prison_pete_keys") + } else { + set("prison_pete_wrong", true) + } + delay(1) + val spawn = balloon.tile + NPCs.remove(balloon) + World.queue("prison_pete_balloon_respawn_${balloon.index}", BALLOON_RESPAWN_TICKS) { + NPCs.add(balloon.id, spawn) + } + anim("prison_pete_pick_up") + delay(1) + inventory.add("prison_key_prison_pete") + npc("prison_pete", "Great, now you've got a key! Bring it to me so I can try it on the door.") + } + + private suspend fun Player.peteTalk(pete: NPC) { + if (inventory.contains("prison_key_prison_pete")) { + returnKey(pete) + return + } + choice("What would you like to say?") { + option("What is this place?") { + npc("prison_pete", "Don't you remember? This is ScapeRune's prison. Evil Bob caught you and brought you here.") + player("What gives him the right to lock me up? I demand to see a lawyer! I know my rights!") + npc("prison_pete", "Evil Bob doesn't care about people's rights. He's cruel and utterly merciless. He's a cat.") + player("How do I get out of here? I can't be held captive by a cat!") + escapeInfo() + } + option("How do I get out of here?") { + escapeInfo() + } + } + } + + private suspend fun Player.escapeInfo() { + npc("prison_pete", "Some of these balloon animals have keys in them, and if you pull the big lever it tells you which shape animal contains the correct key, but I can never find it.") + npc("prison_pete", "You need to pull the lever to find out which shape animal contains the key, then pop that sort of animal to get the key.") + npc("prison_pete", "Bring me any keys you get and I'll try them on the doors.") + player("What happens if I get it wrong?") + npc("prison_pete", "You haven't got a life sentence, so they'll let you out eventually. You should be able to escape much faster if you go pull that lever and pop the right balloon animals.") + } + + private suspend fun Player.returnKey(pete: NPC) { + if (!inventory.remove("prison_key_prison_pete")) { + return + } + face(pete) + pete.face(this) + npc("prison_pete", "Ooh, thanks! I'll see if it's the right one...") + when { + get("prison_pete_keys", 0) >= REQUIRED -> { + npc("prison_pete", "You did it, you got all the keys right!") + npc("prison_pete", "Thank you! You're my friend FOREVER!") + player("Let's get out of here before that cat notices.") + } + get("prison_pete_wrong", false) -> { + clear("prison_pete_wrong") + jingle("prison_pete_failure") + npc("prison_pete", "Aww, that was the wrong key! Try the lever again to see which balloon you need.") + } + else -> { + pete.face(Direction.SOUTH_EAST) + pete.anim("pick_pocket") + delay(2) + pete.face(this) + npc("prison_pete", "Hooray, you got the right one! Now pull the lever again and let's get the next lock unlocked.") + } + } + } + + private suspend fun Player.escape(gate: GameObject) { + openGates() + walkToDelay(gate.tile.addY(-1), forceWalk = true) + message("You quickly escape the prison with Pete.") + npc("prison_pete", "Thanks a lot for your help! Here, have a present:") + rewardEventLoot("random_event_prison_pete") + player("Thanks! See you around!") + while (inventory.remove("prison_key_prison_pete")) { + // strip any dud keys so none survive the trip home + } + clear("prison_pete_keys") + clear("prison_pete_target") + clear("prison_pete_wrong") + anim("teleport_modern") + sound("teleport") + gfx("teleport_modern") + delay(3) + RandomEvents.complete(this) + anim("teleport_land_modern") + gfx("teleport_land_modern") + sound("teleport_land") + message("Welcome back.") + } + + /** Swing both gate leaves outward (south); they swing shut again behind the player. */ + private fun openGates() { + swingOpen("prison_pete_gate_west", GATE_WEST, rotation = 0) + swingOpen("prison_pete_gate_east", GATE_EAST, rotation = 2) + } + + private fun swingOpen(id: String, tile: Tile, rotation: Int) { + val gate = GameObjects.getShape(tile, 0) ?: return + if (gate.id != id) { + return + } + GameObjects.replace(gate, id, rotation = rotation, ticks = GATE_OPEN_TICKS) + } + + private data class Balloon(val npc: String, val model: Int) + + companion object { + private const val REQUIRED = 3 + private const val GATE_OPEN_TICKS = 15 + private const val BALLOON_RESPAWN_TICKS = 25 + private val PRISON = Tile(2086, 4462) + private val GATE_WEST = Tile(2085, 4459) + private val GATE_EAST = Tile(2086, 4459) + + // Interface models paired with the balloon shape they depict. + private val BALLOONS = listOf( + Balloon("balloon_animal_dog", 10749), + Balloon("balloon_animal_cat", 10750), + Balloon("balloon_animal_sheep", 10751), + Balloon("balloon_animal_goat", 10752), + ) + } +} diff --git a/game/src/test/kotlin/content/activity/event/random/PrisonPeteTest.kt b/game/src/test/kotlin/content/activity/event/random/PrisonPeteTest.kt new file mode 100644 index 0000000000..a45c5f0c6b --- /dev/null +++ b/game/src/test/kotlin/content/activity/event/random/PrisonPeteTest.kt @@ -0,0 +1,167 @@ +package content.activity.event.random + +import WorldTest +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.entity.character.npc.NPC +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.entity.obj.GameObjects +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 PrisonPeteTest : WorldTest() { + + private val origin = Tile(3221, 3218) + private val prison = Tile(2086, 4462) + private val balloons = listOf( + "balloon_animal_dog", + "balloon_animal_cat", + "balloon_animal_sheep", + "balloon_animal_goat", + ) + + private fun enter(name: String): Player { + val player = createPlayer(origin, name) + RandomEvents.start(player, "prison_pete") + tick(8) + return player + } + + private fun Player.pullLever() { + val lever = GameObjects.add("prison_pete_lever", tile) + objectOption(lever, "Pull") + tick(6) + } + + private fun Player.popBalloon(correct: Boolean) { + val target = get("prison_pete_target")!! + val id = if (correct) target else balloons.first { it != target } + val balloon = createNPC(id, tile.addY(1)) + npcOption(balloon, "Pop") + tick(7) + skipDialogues() + tick() + } + + private fun Player.handKeyToPete(): NPC { + val pete = createNPC("prison_pete", tile.addY(1)) + npcOption(pete, "Talk-to") + tick(2) + skipDialogues() + // A correct key adds a pickpocket animation delay before Pete's response. + tick(3) + if (dialogue != null) { + skipDialogues() + } + tick() + return pete + } + + @Test + fun `Evil Bob cat kidnaps the player to the prison`() { + val player = enter("pp_start") + + assertEquals("prison_pete", player.get("random_event")) + assertEquals(prison, player.tile) + assertEquals(0, player.get("prison_pete_keys", 0)) + } + + @Test + fun `Popping without pulling the lever does nothing`() { + val player = enter("pp_no_lever") + val balloon = createNPC(balloons.first(), player.tile.addY(1)) + + player.npcOption(balloon, "Pop") + tick(3) + + assertFalse(player.inventory.contains("prison_key_prison_pete")) + } + + @Test + fun `The lever picks a target balloon shape`() { + val player = enter("pp_lever") + + player.pullLever() + + assertTrue(player.get("prison_pete_target") in balloons) + } + + @Test + fun `Popping the right balloon earns a counted key`() { + val player = enter("pp_correct") + player.pullLever() + + player.popBalloon(correct = true) + + assertEquals(1, player.get("prison_pete_keys", 0)) + assertTrue(player.inventory.contains("prison_key_prison_pete")) + assertFalse(player.get("prison_pete_wrong", false)) + assertNull(player.get("prison_pete_target")) + } + + @Test + fun `Popping the wrong balloon gives a dud key`() { + val player = enter("pp_wrong") + player.pullLever() + + player.popBalloon(correct = false) + + assertEquals(0, player.get("prison_pete_keys", 0)) + assertTrue(player.inventory.contains("prison_key_prison_pete")) + assertTrue(player.get("prison_pete_wrong", false)) + } + + @Test + fun `Pete rejects a dud key`() { + val player = enter("pp_reject") + player.pullLever() + player.popBalloon(correct = false) + + player.handKeyToPete() + + assertFalse(player.inventory.contains("prison_key_prison_pete")) + assertFalse(player.get("prison_pete_wrong", false)) + assertEquals("prison_pete", player.get("random_event")) + } + + @Test + fun `The gates stay shut until Pete is freed`() { + val player = enter("pp_gate_shut") + + player.objectOption(gate(), "Open") + tick(8) + + assertEquals("prison_pete", player.get("random_event")) + assertNull(player.get("prison_pete_target")) + } + + @Test + fun `Three correct keys open the gates and reward the player`() { + val player = enter("pp_finish") + + repeat(3) { + player.pullLever() + player.popBalloon(correct = true) + player.handKeyToPete() + } + player.objectOption(gate(), "Open") + tick(10) + player.skipDialogues() + tick(10) + + assertFalse(player.inventory.isEmpty()) + assertFalse(player.inventory.contains("prison_key_prison_pete")) + assertNull(player.get("random_event")) + assertEquals(origin, player.tile) + assertTrue(player.contains("random_event_cooldown")) + } + + private fun gate() = GameObjects.getShape(Tile(2085, 4459), 0)!! +} From 04a992ece557f5072c4174d0526e753cb3017ff0 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Thu, 9 Jul 2026 13:28:55 -0700 Subject: [PATCH 61/89] Add Lost and Found random event --- .../lost_and_found/lost_and_found.objs.toml | 23 ++++ .../lost_and_found.varbits.toml | 15 +++ .../lost_and_found/lost_and_found.vars.toml | 3 + .../event/random/RandomEventTrigger.kt | 34 +---- .../activity/event/random/RandomEvents.kt | 36 ++++++ .../random/lost_and_found/LostAndFound.kt | 118 ++++++++++++++++++ .../activity/event/random/LostAndFoundTest.kt | 96 ++++++++++++++ 7 files changed, 292 insertions(+), 33 deletions(-) create mode 100644 data/activity/event/random/lost_and_found/lost_and_found.objs.toml create mode 100644 data/activity/event/random/lost_and_found/lost_and_found.varbits.toml create mode 100644 data/activity/event/random/lost_and_found/lost_and_found.vars.toml create mode 100644 game/src/main/kotlin/content/activity/event/random/lost_and_found/LostAndFound.kt create mode 100644 game/src/test/kotlin/content/activity/event/random/LostAndFoundTest.kt diff --git a/data/activity/event/random/lost_and_found/lost_and_found.objs.toml b/data/activity/event/random/lost_and_found/lost_and_found.objs.toml new file mode 100644 index 0000000000..36bc6a230f --- /dev/null +++ b/data/activity/event/random/lost_and_found/lost_and_found.objs.toml @@ -0,0 +1,23 @@ +[abyss_appendage_1] +id = 8998 + +[abyss_appendage_2] +id = 8999 + +[abyss_appendage_3] +id = 9000 + +[abyss_appendage_4] +id = 9001 + +[abyss_appendage_5] +id = 9002 + +[abyss_appendage_6] +id = 9003 + +[abyss_appendage_7] +id = 9004 + +[abyss_appendage_8] +id = 9005 diff --git a/data/activity/event/random/lost_and_found/lost_and_found.varbits.toml b/data/activity/event/random/lost_and_found/lost_and_found.varbits.toml new file mode 100644 index 0000000000..0a02409c00 --- /dev/null +++ b/data/activity/event/random/lost_and_found/lost_and_found.varbits.toml @@ -0,0 +1,15 @@ +[lost_and_found_appendage_1] +id = 874 +format = "int" + +[lost_and_found_appendage_2] +id = 875 +format = "int" + +[lost_and_found_appendage_3] +id = 876 +format = "int" + +[lost_and_found_appendage_4] +id = 877 +format = "int" diff --git a/data/activity/event/random/lost_and_found/lost_and_found.vars.toml b/data/activity/event/random/lost_and_found/lost_and_found.vars.toml new file mode 100644 index 0000000000..a03945e2c6 --- /dev/null +++ b/data/activity/event/random/lost_and_found/lost_and_found.vars.toml @@ -0,0 +1,3 @@ +[laf_odd] +format = "int" +persist = true 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 b538dc2dbe..a77cdb0a1a 100644 --- a/game/src/main/kotlin/content/activity/event/random/RandomEventTrigger.kt +++ b/game/src/main/kotlin/content/activity/event/random/RandomEventTrigger.kt @@ -1,21 +1,9 @@ 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. @@ -26,16 +14,7 @@ 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) - RandomEvents.start(this) - } + RandomEvents.roll(this) } adminCommand("randomevent", stringArg("event", optional = true), desc = "Start a random event") { args -> @@ -45,15 +24,4 @@ class RandomEventTrigger : Script { } } } - - 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 index 645408d5cf..ccf31a168e 100644 --- a/game/src/main/kotlin/content/activity/event/random/RandomEvents.kt +++ b/game/src/main/kotlin/content/activity/event/random/RandomEvents.kt @@ -1,12 +1,19 @@ package content.activity.event.random +import content.bot.isBot +import content.entity.combat.inCombat import content.quest.clearInstance +import content.quest.instance import world.gregs.voidps.engine.Script 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.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.mode.EmptyMode 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 @@ -57,6 +64,35 @@ object RandomEvents : AutoCloseable { return null } + /** + * Arm the initial cooldown on first activity, then a 1-in-`roll_chance` shot at [event] + * (or a weighted [pick]) for players who are eligible and off cooldown. + */ + fun roll(player: Player, event: String? = null): Boolean { + if (!player.contains("random_event_cooldown")) { + // First activity; events unlock once the initial cooldown expires + cooldown(player) + return false + } + if (!eligible(player) || random.nextInt(Tables.int("random_event_settings.roll_chance.value")) != 0) { + return false + } + // Restart the cooldown immediately so a failed pick can't re-roll every attempt + cooldown(player) + return start(player, event ?: pick()) + } + + 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") } + fun start(player: Player, id: String? = pick()): Boolean { val launcher = events[id ?: return false] ?: return false player["random_event"] = id diff --git a/game/src/main/kotlin/content/activity/event/random/lost_and_found/LostAndFound.kt b/game/src/main/kotlin/content/activity/event/random/lost_and_found/LostAndFound.kt new file mode 100644 index 0000000000..a8ef0a0c2e --- /dev/null +++ b/game/src/main/kotlin/content/activity/event/random/lost_and_found/LostAndFound.kt @@ -0,0 +1,118 @@ +package content.activity.event.random.lost_and_found + +import content.activity.event.random.RandomEvents +import content.entity.player.dialogue.type.statement +import content.entity.player.inv.item.addOrDrop +import content.quest.closeTabs +import content.quest.openTabs +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.open +import world.gregs.voidps.engine.entity.character.move.tele +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.entity.character.player.skill.Skill +import world.gregs.voidps.engine.entity.obj.GameObject +import world.gregs.voidps.type.Tile +import world.gregs.voidps.type.random + +/** + * Lost and Found random event: a magic spellbook teleport can fault, dropping the player onto the + * Abyssal plane where four strange appendages surround them. Three appendages share the same shape; + * operating the odd one out forwards the player back to where they came from, while a wrong pick + * reshuffles the shapes and drains some Magic. Players snatched from the rune essence mine receive + * a handful of essence as recompense. + * https://runescape.wiki/w/Random_events?oldid=3667851#Lost_and_Found + */ +class LostAndFound : Script { + + init { + RandomEvents.register("lost_and_found") { startEvent() } + + // Only a magic spellbook teleport can slip through; the destination becomes the return point. + teleportLand("modern") { + RandomEvents.roll(this, "lost_and_found") + } + + objectOperate("Operate", "abyss_appendage_*") { (appendage) -> + if (get("random_event") != "lost_and_found") { + return@objectOperate + } + operate(appendage) + } + } + + private suspend fun Player.startEvent() { + open("fade_out") + delay(2) + shuffleAppendages() + closeTabs() + minimap(Minimap.HideMap) + tele(PLANE) + message("You have slipped through to the Abyssal plane!") + open("fade_in") + statement("There has been a fault in the teleportation matrix. Please operate the odd appendage out to be forwarded to your destination.", clickToContinue = false) + } + + /** Show three appendages with one shape and a single odd one with another; the odd slot is 1-based. */ + private fun Player.shuffleAppendages() { + val odd = random.nextInt(APPENDAGES.size) + val shape = random.nextInt(4) * 2 + val flip = random.nextInt(2) + for (index in APPENDAGES.indices) { + set("lost_and_found_appendage_${index + 1}", shape + if (index == odd) 1 - flip else flip) + } + set("laf_odd", odd + 1) + } + + private suspend fun Player.operate(appendage: GameObject) { + val index = APPENDAGES.indexOf(appendage.tile) + 1 + if (index == 0) { + return + } + if (index == get("laf_odd", 0)) { + escape() + } else { + shuffleAppendages() + message("That was not the correct appendage!") + levels.drain(Skill.Magic, if (levels.get(Skill.Magic) >= 10) random.nextInt(1, 11) else 1) + } + } + + private suspend fun Player.escape() { + open("fade_out") + delay(2) + reward() + clear("laf_odd") + openTabs() + clearMinimap() + RandomEvents.complete(this) + open("fade_in") + statement("The Abyssal Services Department apologises for the inconvenience.", clickToContinue = false) + } + + /** Players taken from the rune essence mine are compensated with a handful of essence. */ + private fun Player.reward() { + val origin = Tile(this["random_event_origin", tile.id]) + if (origin.region.id != ESSENCE_MINE_REGION) { + return + } + val essence = if (levels.getMax(Skill.Mining) > 30) "pure_essence" else "rune_essence" + addOrDrop(essence, random.nextInt(8, 37)) + } + + companion object { + private const val ESSENCE_MINE_REGION = 11595 + private val PLANE = Tile(2332, 4770) + + // The four appendage tiles, in the same order as the lost_and_found_appendage_1-4 varbits. + private val APPENDAGES = listOf( + Tile(2336, 4771), + Tile(2332, 4775), + Tile(2327, 4771), + Tile(2332, 4766), + ) + } +} diff --git a/game/src/test/kotlin/content/activity/event/random/LostAndFoundTest.kt b/game/src/test/kotlin/content/activity/event/random/LostAndFoundTest.kt new file mode 100644 index 0000000000..737874dda1 --- /dev/null +++ b/game/src/test/kotlin/content/activity/event/random/LostAndFoundTest.kt @@ -0,0 +1,96 @@ +package content.activity.event.random + +import WorldTest +import objectOption +import org.junit.jupiter.api.Test +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.entity.character.player.skill.Skill +import world.gregs.voidps.engine.entity.obj.GameObjects +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.type.Tile +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class LostAndFoundTest : WorldTest() { + + private val origin = Tile(3221, 3218) + private val plane = Tile(2332, 4770) + private val appendages = listOf( + Tile(2336, 4771), + Tile(2332, 4775), + Tile(2327, 4771), + Tile(2332, 4766), + ) + + private fun enter(name: String, tile: Tile = origin): Player { + val player = createPlayer(tile, name) + RandomEvents.start(player, "lost_and_found") + tick(6) + return player + } + + private fun Player.operate(tile: Tile) { + val appendage = GameObjects.getShape(tile, 10)!! + objectOption(appendage, "Operate") + tick(8) + } + + private fun Player.oddTile() = appendages[get("laf_odd", 0) - 1] + + private fun Player.wrongTile() = appendages.first { it != oddTile() } + + @Test + fun `The player slips through to the Abyssal plane`() { + val player = enter("laf_start") + + assertEquals("lost_and_found", player.get("random_event")) + assertEquals(plane, player.tile) + assertTrue(player.get("laf_odd", 0) in 1..4) + } + + @Test + fun `Three appendages match and the odd one differs`() { + val player = enter("laf_shapes") + + val shapes = (1..4).map { player.get("lost_and_found_appendage_$it", -1) } + val odd = player.get("laf_odd", 0) + + assertEquals(1, shapes.count { it == shapes[odd - 1] }) + assertEquals(3, shapes.count { it != shapes[odd - 1] }) + assertEquals(1, shapes.distinct().size - 1) + } + + @Test + fun `The wrong appendage drains Magic and reshuffles`() { + val player = enter("laf_wrong") + player.levels.set(Skill.Magic, 50) + + player.operate(player.wrongTile()) + + assertEquals("lost_and_found", player.get("random_event")) + assertTrue(player.levels.get(Skill.Magic) < 50) + } + + @Test + fun `The odd appendage forwards the player home`() { + val player = enter("laf_right") + + player.operate(player.oddTile()) + + assertNull(player.get("random_event")) + assertEquals(origin, player.tile) + assertTrue(player.contains("random_event_cooldown")) + assertTrue(player.inventory.isEmpty()) + } + + @Test + fun `Players taken from the essence mine receive essence`() { + val player = enter("laf_essence", tile = Tile(2911, 4832)) + + player.operate(player.oddTile()) + + assertEquals(Tile(2911, 4832), player.tile) + assertTrue(player.inventory.count("rune_essence") >= 8) + } +} From f1fcaed5ad4ac5660c5a3bb42f14dc1010f91962 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Thu, 9 Jul 2026 14:01:52 -0700 Subject: [PATCH 62/89] Add Cap'n Arnav's Chest random event --- .../random/capn_arnav/capn_arnav.ifaces.toml | 27 +++ .../capn_arnav/capn_arnav.npc-spawns.toml | 4 + .../random/capn_arnav/capn_arnav.npcs.toml | 3 + .../random/capn_arnav/capn_arnav.objs.toml | 9 + .../random/capn_arnav/capn_arnav.varbits.toml | 13 ++ .../random/capn_arnav/capn_arnav.vars.toml | 11 + .../event/random/random_event.tables.toml | 4 + .../event/random/capn_arnav/CapnArnav.kt | 219 ++++++++++++++++++ .../activity/event/random/CapnArnavTest.kt | 132 +++++++++++ 9 files changed, 422 insertions(+) create mode 100644 data/activity/event/random/capn_arnav/capn_arnav.ifaces.toml create mode 100644 data/activity/event/random/capn_arnav/capn_arnav.npc-spawns.toml create mode 100644 data/activity/event/random/capn_arnav/capn_arnav.npcs.toml create mode 100644 data/activity/event/random/capn_arnav/capn_arnav.objs.toml create mode 100644 data/activity/event/random/capn_arnav/capn_arnav.varbits.toml create mode 100644 data/activity/event/random/capn_arnav/capn_arnav.vars.toml create mode 100644 game/src/main/kotlin/content/activity/event/random/capn_arnav/CapnArnav.kt create mode 100644 game/src/test/kotlin/content/activity/event/random/CapnArnavTest.kt diff --git a/data/activity/event/random/capn_arnav/capn_arnav.ifaces.toml b/data/activity/event/random/capn_arnav/capn_arnav.ifaces.toml new file mode 100644 index 0000000000..11897f6163 --- /dev/null +++ b/data/activity/event/random/capn_arnav/capn_arnav.ifaces.toml @@ -0,0 +1,27 @@ +[capn_arnav_lock] +id = 185 +type = "main_screen" + +[.down_1] +id = 2 + +[.up_1] +id = 3 + +[.down_2] +id = 9 + +[.up_2] +id = 10 + +[.down_3] +id = 16 + +[.up_3] +id = 17 + +[.unlock] +id = 28 + +[.close] +id = 30 diff --git a/data/activity/event/random/capn_arnav/capn_arnav.npc-spawns.toml b/data/activity/event/random/capn_arnav/capn_arnav.npc-spawns.toml new file mode 100644 index 0000000000..7bd93584a1 --- /dev/null +++ b/data/activity/event/random/capn_arnav/capn_arnav.npc-spawns.toml @@ -0,0 +1,4 @@ +spawns = [ +# 6480 + { id = "capn_arnav", x = 1625, y = 5163, direction = "EAST" }, +] diff --git a/data/activity/event/random/capn_arnav/capn_arnav.npcs.toml b/data/activity/event/random/capn_arnav/capn_arnav.npcs.toml new file mode 100644 index 0000000000..87f05926b6 --- /dev/null +++ b/data/activity/event/random/capn_arnav/capn_arnav.npcs.toml @@ -0,0 +1,3 @@ +[capn_arnav] +id = 2308 +examine = "Distinctly piratical." diff --git a/data/activity/event/random/capn_arnav/capn_arnav.objs.toml b/data/activity/event/random/capn_arnav/capn_arnav.objs.toml new file mode 100644 index 0000000000..100cfc05af --- /dev/null +++ b/data/activity/event/random/capn_arnav/capn_arnav.objs.toml @@ -0,0 +1,9 @@ +[capn_arnav_chest] +id = 42337 +examine = "Cap'n Arnav's treasure chest." + +[capn_arnav_chest_open] +id = 42338 + +[capn_arnav_exit_portal] +id = 11369 diff --git a/data/activity/event/random/capn_arnav/capn_arnav.varbits.toml b/data/activity/event/random/capn_arnav/capn_arnav.varbits.toml new file mode 100644 index 0000000000..42d3e22925 --- /dev/null +++ b/data/activity/event/random/capn_arnav/capn_arnav.varbits.toml @@ -0,0 +1,13 @@ +# Client script 2112 repositions each cylinder's four models from these varbits: +# 0 = coins centred, 1 = bowl, 2 = bar, 3 = ring. +[arnav_lock_1] +id = 693 +format = "int" + +[arnav_lock_2] +id = 694 +format = "int" + +[arnav_lock_3] +id = 695 +format = "int" diff --git a/data/activity/event/random/capn_arnav/capn_arnav.vars.toml b/data/activity/event/random/capn_arnav/capn_arnav.vars.toml new file mode 100644 index 0000000000..0d33037e8a --- /dev/null +++ b/data/activity/event/random/capn_arnav/capn_arnav.vars.toml @@ -0,0 +1,11 @@ +[arnav_target] +format = "string" +persist = true + +[arnav_tries] +format = "int" +persist = true + +[arnav_solved] +format = "boolean" +persist = true diff --git a/data/activity/event/random/random_event.tables.toml b/data/activity/event/random/random_event.tables.toml index 3346d6bdeb..2f348a81ad 100644 --- a/data/activity/event/random/random_event.tables.toml +++ b/data/activity/event/random/random_event.tables.toml @@ -54,6 +54,10 @@ weight = 10 event = "prison_pete" weight = 10 +[.capn_arnav] +event = "capn_arnav" +weight = 10 + [random_event_settings] value = "int" diff --git a/game/src/main/kotlin/content/activity/event/random/capn_arnav/CapnArnav.kt b/game/src/main/kotlin/content/activity/event/random/capn_arnav/CapnArnav.kt new file mode 100644 index 0000000000..ff3081b601 --- /dev/null +++ b/game/src/main/kotlin/content/activity/event/random/capn_arnav/CapnArnav.kt @@ -0,0 +1,219 @@ +package content.activity.event.random.capn_arnav + +import content.activity.event.random.RandomEvents +import content.activity.event.random.kidnap +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.type.choice +import content.entity.player.dialogue.type.npc +import content.entity.player.inv.item.addOrDrop +import world.gregs.voidps.cache.definition.data.InterfaceDefinition +import world.gregs.voidps.engine.Script +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.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.name +import world.gregs.voidps.engine.entity.obj.GameObjects +import world.gregs.voidps.network.login.protocol.encode.interfaceText +import world.gregs.voidps.type.Tile +import world.gregs.voidps.type.random + +/** + * Cap'n Arnav's Chest random event: the old pirate whisks the player to his shark-circled island and + * asks for help with the fiddly combination lock on his freshly dug-up chest. The lock (interface 185) + * has three cylinders of items; rolling each with the arrows until the middle row matches the target + * word and clicking Unlock opens the chest for a random event gift, and the exit portal sends the + * player back. Five wrong combinations and Arnav clobbers them with his bottle instead. + * https://runescape.wiki/w/Random_events?oldid=3667851#Cap'n_Arnav's_Chest + */ +class CapnArnav : Script { + + init { + RandomEvents.register("capn_arnav") { startEvent() } + + npcOperate("Talk-to", "capn_arnav") { + if (get("random_event") != "capn_arnav") { + npc("capn_arnav", "Ah, I cannot be stoppin' to chat to ye now. There be treasure out there, and I wants it.") + return@npcOperate + } + arnavTalk() + } + + objectOperate("Open", "capn_arnav_chest") { + if (get("random_event") != "capn_arnav" || get("arnav_solved", false)) { + return@objectOperate + } + openLock() + } + + interfaceOption("Up", "capn_arnav_lock:up_*") { + roll(it.component.removePrefix("up_").toInt(), 1) + } + + interfaceOption("Down", "capn_arnav_lock:down_*") { + roll(it.component.removePrefix("down_").toInt(), -1) + } + + interfaceOption("Unlock", "capn_arnav_lock:unlock") { + if (get("random_event") != "capn_arnav") { + return@interfaceOption + } + unlock() + } + + objectOperate("Enter", "capn_arnav_exit_portal") { + if (get("random_event") != "capn_arnav") { + return@objectOperate + } + if (!get("arnav_solved", false)) { + npc("capn_arnav", "Ye're not goin' anywhere till ye've opened me chest, matey!") + return@objectOperate + } + clearState() + RandomEvents.complete(this) + } + } + + private suspend fun Player.startEvent() { + set("arnav_tries", 0) + clear("arnav_solved") + clear("arnav_target") + arnavHerald() + kidnap(ISLAND) + } + + private suspend fun Player.arnavHerald() { + val arnav = NPCs.addRandom("capn_arnav", tile.toCuboid(1), ticks = 25, owner = this) + ?: NPCs.add("capn_arnav", tile, ticks = 25, owner = this) + arnav.watch(this) + val greetings = listOf( + "Avast there, $name!", + "Ahoy, $name!", + "I got treasure here, $name!", + "Will ye help an old pirate, $name?", + ) + arnav.say(greetings.random(random)) + delay(2) + } + + private suspend fun Player.arnavTalk() { + if (get("arnav_solved", false)) { + npc("capn_arnav", "Ah, well done matey, that's the right combination. Now be off with ye, through the portal!") + return + } + npc("capn_arnav", "Ah, hello there, ${if (male) "laddie" else "lassie"}! I've just dug up an old treasure chest of mine.") + npc("capn_arnav", "Problem is, these old hands o'mine aren't as useful as they used t'be, and the lock on that chest is a little bit too fiddly for ol' Cap'n Arnav. Could you help me out?") + choice { + option("Yes, I'll help you unlock your chest.") { + npc("capn_arnav", "There are three columns. What you need to do for me is to match up each picture with the word underneath the column, and then unlock the chest.") + openLock() + } + option("No, sorry.") + } + } + + private fun Player.openLock() { + if (!contains("arnav_target")) { + set("arnav_target", WORDS.random(random)) + } + for (column in 1..3) { + set("arnav_lock_$column", START) + } + open("capn_arnav_lock") + // Send the access masks so the client reports clicks on the arrows and unlock button; the + // cache marks the options enabled but doesn't send them, so the buttons don't respond otherwise. + for (component in listOf("down_1", "up_1", "down_2", "up_2", "down_3", "up_3", "unlock")) { + interfaceOptions.unlockAll("capn_arnav_lock", component, 0..0) + } + sendWord(get("arnav_target", "")) + } + + /** + * Interface 185's target-word children (31 and 32) are static text components the cache decoder + * drops, so the text is sent by raw packed component id instead of through [Player.interfaces]. + */ + private fun Player.sendWord(word: String) { + client?.interfaceText(InterfaceDefinition.pack(LOCK_ID, 31), word) + client?.interfaceText(InterfaceDefinition.pack(LOCK_ID, 32), word) + } + + /** The centred symbol of a column, as an index into [WORDS]. */ + private fun Player.column(column: Int) = get("arnav_lock_$column", START) + + // The cylinders are a wrapping wheel driven entirely by the arnav_lock varbits: client script + // 2112 repositions the four models whenever a varbit changes, so the arrows just step the value. + private fun Player.roll(column: Int, direction: Int) { + if (get("random_event") != "capn_arnav" || column !in 1..3) { + return + } + set("arnav_lock_$column", (column(column) + direction + WORDS.size) % WORDS.size) + } + + private suspend fun Player.unlock() { + val target = get("arnav_target") ?: return + close("capn_arnav_lock") + if ((1..3).all { WORDS[column(it)] == target }) { + solve() + } else { + wrongCombination() + } + } + + private suspend fun Player.solve() { + set("arnav_solved", true) + val chest = GameObjects.getShape(CHEST, 10) + if (chest != null && chest.id == "capn_arnav_chest") { + GameObjects.replace(chest, "capn_arnav_chest_open", ticks = CHEST_OPEN_TICKS) + } + npc("capn_arnav", "Ah, well done matey, that's the right combination. Here, have a little somethin' for helpin' me out.") + addOrDrop("random_event_gift") + message("You've been given a gift!") + } + + private suspend fun Player.wrongCombination() { + val tries = inc("arnav_tries") + when { + tries >= MAX_TRIES -> { + npc("capn_arnav", "Arrr! I'd better find someone else.") + message("Cap'n Arnav hits you over the head with his bottle.") + clearState() + RandomEvents.fail(this) + } + tries == 1 -> { + npc("capn_arnav", "Arrr! That be nowhere near close! Did ye not listen the first time around?") + npc("capn_arnav", "Right, right. What you need to do is click on the arrows to roll the cylinders round to the different items.") + npc("capn_arnav", "When the items in the middle row all match the words written under the column then you'll have solved the lock.") + npc("capn_arnav", "Is that a little clearer?") + } + else -> npc("capn_arnav", "Arrr! That be nowhere near close!") + } + } + + private fun Player.clearState() { + clear("arnav_target") + clear("arnav_tries") + clear("arnav_solved") + for (column in 1..3) { + clear("arnav_lock_$column") + } + } + + companion object { + private const val LOCK_ID = 185 + private const val MAX_TRIES = 5 + private const val CHEST_OPEN_TICKS = 25 + + // The centred symbol per varbit value (client script 2112's wheel order); coins start centred. + private val WORDS = listOf("Coins", "Bowl", "Bar", "Ring") + private const val START = 0 + + private val ISLAND = Tile(1626, 5163) + private val CHEST = Tile(1627, 5162) + } +} diff --git a/game/src/test/kotlin/content/activity/event/random/CapnArnavTest.kt b/game/src/test/kotlin/content/activity/event/random/CapnArnavTest.kt new file mode 100644 index 0000000000..731e651482 --- /dev/null +++ b/game/src/test/kotlin/content/activity/event/random/CapnArnavTest.kt @@ -0,0 +1,132 @@ +package content.activity.event.random + +import WorldTest +import interfaceOption +import objectOption +import org.junit.jupiter.api.Test +import skipDialogues +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.entity.obj.GameObjects +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.type.Tile +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class CapnArnavTest : WorldTest() { + + private val origin = Tile(3221, 3218) + private val island = Tile(1626, 5163) + private val words = listOf("Coins", "Bowl", "Bar", "Ring") + + private fun enter(name: String): Player { + val player = createPlayer(origin, name) + RandomEvents.start(player, "capn_arnav") + tick(8) + return player + } + + private fun Player.openLock() { + val chest = GameObjects.getShape(Tile(1627, 5162), 10)!! + objectOption(chest, "Open") + tick(2) + } + + private fun Player.solveColumns() { + val presses = words.indexOf(get("arnav_target", "")) + for (column in 1..3) { + repeat(presses) { + interfaceOption("capn_arnav_lock", "up_$column", "Up") + tick() + } + } + } + + private fun Player.unlock() { + interfaceOption("capn_arnav_lock", "unlock", "Unlock") + tick(2) + skipDialogues() + tick() + } + + @Test + fun `Cap'n Arnav kidnaps the player to his island`() { + val player = enter("arnav_start") + + assertEquals("capn_arnav", player.get("random_event")) + assertEquals(island, player.tile) + } + + @Test + fun `Opening the chest picks a target and opens the lock`() { + val player = enter("arnav_lock") + + player.openLock() + + assertTrue(player.get("arnav_target", "") in words) + assertEquals(3, (1..3).count { player.get("arnav_lock_$it", 0) == 0 }) + } + + @Test + fun `The right combination opens the chest for a gift`() { + val player = enter("arnav_solve") + player.openLock() + + player.solveColumns() + player.unlock() + + assertTrue(player.get("arnav_solved", false)) + assertEquals(1, player.inventory.count("random_event_gift")) + } + + @Test + fun `A wrong combination counts a try`() { + val player = enter("arnav_wrong") + player.openLock() + + player.interfaceOption("capn_arnav_lock", "up_1", "Up") + tick() + player.unlock() + + assertEquals(1, player.get("arnav_tries", 0)) + assertEquals("capn_arnav", player.get("random_event")) + assertFalse(player.get("arnav_solved", false)) + } + + @Test + fun `Five wrong combinations get the player clobbered and exiled`() { + val player = enter("arnav_fail") + + repeat(5) { + player.openLock() + player.interfaceOption("capn_arnav_lock", "up_1", "Up") + tick() + player.unlock() + } + tick(2) + + assertNull(player.get("random_event")) + assertNotEquals(island, player.tile) + assertNotEquals(origin, player.tile) + assertTrue(player.contains("random_event_cooldown")) + assertTrue(player.inventory.isEmpty()) + } + + @Test + fun `The exit portal returns the player home after solving`() { + val player = enter("arnav_finish") + player.openLock() + player.solveColumns() + player.unlock() + + val portal = GameObjects.getShape(Tile(1626, 5165), 10)!! + player.objectOption(portal, "Enter") + tick(4) + + assertNull(player.get("random_event")) + assertEquals(origin, player.tile) + assertTrue(player.contains("random_event_cooldown")) + } +} From f30ab07abdd2825ff431091e3efa5da3a9f7ef47 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Fri, 10 Jul 2026 03:42:09 -0700 Subject: [PATCH 63/89] Add Beekeeper random event --- .../random/beekeeper/beekeeper.ifaces.toml | 33 +++ .../random/beekeeper/beekeeper.npcs.toml | 3 + .../random/beekeeper/beekeeper.varbits.toml | 37 ++++ .../random/beekeeper/beekeeper.vars.toml | 3 + .../event/random/random_event.tables.toml | 4 + .../event/random/beekeeper/Beekeeper.kt | 204 ++++++++++++++++++ .../activity/event/random/BeekeeperTest.kt | 143 ++++++++++++ 7 files changed, 427 insertions(+) create mode 100644 data/activity/event/random/beekeeper/beekeeper.ifaces.toml create mode 100644 data/activity/event/random/beekeeper/beekeeper.npcs.toml create mode 100644 data/activity/event/random/beekeeper/beekeeper.varbits.toml create mode 100644 data/activity/event/random/beekeeper/beekeeper.vars.toml create mode 100644 game/src/main/kotlin/content/activity/event/random/beekeeper/Beekeeper.kt create mode 100644 game/src/test/kotlin/content/activity/event/random/BeekeeperTest.kt diff --git a/data/activity/event/random/beekeeper/beekeeper.ifaces.toml b/data/activity/event/random/beekeeper/beekeeper.ifaces.toml new file mode 100644 index 0000000000..7fe6080766 --- /dev/null +++ b/data/activity/event/random/beekeeper/beekeeper.ifaces.toml @@ -0,0 +1,33 @@ +[beehive_build] +id = 420 +type = "main_screen" + +[.part_1] +id = 12 + +[.part_2] +id = 13 + +[.part_3] +id = 14 + +[.part_4] +id = 15 + +[.slot_1] +id = 16 + +[.slot_2] +id = 17 + +[.slot_3] +id = 18 + +[.slot_4] +id = 19 + +[.close] +id = 38 + +[.build] +id = 39 diff --git a/data/activity/event/random/beekeeper/beekeeper.npcs.toml b/data/activity/event/random/beekeeper/beekeeper.npcs.toml new file mode 100644 index 0000000000..a6189e12d6 --- /dev/null +++ b/data/activity/event/random/beekeeper/beekeeper.npcs.toml @@ -0,0 +1,3 @@ +[bee_keeper] +id = 8649 +examine = "He looks after all these bees." diff --git a/data/activity/event/random/beekeeper/beekeeper.varbits.toml b/data/activity/event/random/beekeeper/beekeeper.varbits.toml new file mode 100644 index 0000000000..6521dec12d --- /dev/null +++ b/data/activity/event/random/beekeeper/beekeeper.varbits.toml @@ -0,0 +1,37 @@ +# Varp 805 drives the beehive-build interface: the client renders each source and frame slot's +# hive part (1-4) from these varbits; 0 is an empty slot. +[beekeeper_part_1] +id = 5893 +format = "int" + +[beekeeper_part_2] +id = 5894 +format = "int" + +[beekeeper_part_3] +id = 5895 +format = "int" + +[beekeeper_part_4] +id = 5896 +format = "int" + +[beekeeper_slot_1] +id = 5897 +format = "int" + +[beekeeper_slot_2] +id = 5898 +format = "int" + +[beekeeper_slot_3] +id = 5899 +format = "int" + +[beekeeper_slot_4] +id = 5900 +format = "int" + +[beekeeper_tries] +id = 5901 +format = "int" diff --git a/data/activity/event/random/beekeeper/beekeeper.vars.toml b/data/activity/event/random/beekeeper/beekeeper.vars.toml new file mode 100644 index 0000000000..1cdec579ec --- /dev/null +++ b/data/activity/event/random/beekeeper/beekeeper.vars.toml @@ -0,0 +1,3 @@ +[beekeeper_intro] +format = "boolean" +persist = true diff --git a/data/activity/event/random/random_event.tables.toml b/data/activity/event/random/random_event.tables.toml index 2f348a81ad..af40b4e554 100644 --- a/data/activity/event/random/random_event.tables.toml +++ b/data/activity/event/random/random_event.tables.toml @@ -58,6 +58,10 @@ weight = 10 event = "capn_arnav" weight = 10 +[.beekeeper] +event = "beekeeper" +weight = 10 + [random_event_settings] value = "int" diff --git a/game/src/main/kotlin/content/activity/event/random/beekeeper/Beekeeper.kt b/game/src/main/kotlin/content/activity/event/random/beekeeper/Beekeeper.kt new file mode 100644 index 0000000000..38851aa6b4 --- /dev/null +++ b/game/src/main/kotlin/content/activity/event/random/beekeeper/Beekeeper.kt @@ -0,0 +1,204 @@ +package content.activity.event.random.beekeeper + +import content.activity.event.random.RandomEvents +import content.activity.event.random.kidnap +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.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.smallInstance +import world.gregs.voidps.cache.definition.data.InterfaceDefinition +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.client.message +import world.gregs.voidps.engine.client.sendInterfaceSettings +import world.gregs.voidps.engine.client.ui.close +import world.gregs.voidps.engine.client.ui.open +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.get +import world.gregs.voidps.engine.map.zone.DynamicZones +import world.gregs.voidps.type.Delta +import world.gregs.voidps.type.Direction +import world.gregs.voidps.type.Region +import world.gregs.voidps.type.Tile +import world.gregs.voidps.type.Zone +import world.gregs.voidps.type.random + +/** + * Beekeeper random event: the Bee keeper drags the player to his field of beehives and asks for help + * building a new hive. The four hive components (interface 420, driven by varp 805's varbits) start + * jumbled beside the frame; drag them into the frame in the right order and click Build. Six wrong + * builds and the bees lose patience, chasing the player off empty-handed; success earns a random + * event gift before the trip home. + * https://runescape.wiki/w/Beekeeper + */ +class Beekeeper : Script { + + init { + RandomEvents.register("beekeeper") { startEvent() } + + // The Bee keeper's cache option is "Talk-To" (capital O), like the pinball trolls. + npcOperate("Talk-To", "bee_keeper") { (keeper) -> + if (get("random_event") != "beekeeper" || keeper.owner != this) { + npc("bee_keeper", "Sorry, you're not the person I need.") + return@npcOperate + } + beekeeperTalk() + } + + interfaceSwap("beehive_build:*", "beehive_build:*") { fromId, toId, _, _ -> + if (get("random_event") != "beekeeper") { + return@interfaceSwap + } + swapParts(fromId.substringAfter(":"), toId.substringAfter(":")) + } + + interfaceOption("Build", "beehive_build:build") { + if (get("random_event") != "beekeeper") { + return@interfaceOption + } + build() + } + } + + private suspend fun Player.startEvent() { + shuffleParts() + set("beekeeper_tries", TRIES) + clear("beekeeper_intro") + herald() + val offset = copyField() + kidnap(FIELD.add(offset)) + NPCs.add("bee_keeper", KEEPER.add(offset), Direction.SOUTH, ticks = -1, owner = this) + } + + /** + * Allocate a private instance and copy only the apiary's zones (the box between [AREA_SW] and + * [AREA_NE]) into it, leaving the rest of the instance empty. Returns the tile offset from the + * real field to its copy. + */ + private fun Player.copyField(): Delta { + val instance = smallInstance() + val offset = instance.offset(REGION) + val zones = get() + for (zoneX in (AREA_SW.x shr 3)..(AREA_NE.x shr 3)) { + for (zoneY in (AREA_SW.y shr 3)..(AREA_NE.y shr 3)) { + val from = Zone(zoneX, zoneY, 0) + val base = from.tile.add(offset) + zones.copy(from, Zone(base.x shr 3, base.y shr 3, base.level)) + } + } + set("instance_offset", offset.id) + return offset + } + + private suspend fun Player.herald() { + val keeper = NPCs.addRandom("bee_keeper", tile.toCuboid(1), ticks = 25, owner = this) + ?: NPCs.add("bee_keeper", tile, ticks = 25, owner = this) + keeper.watch(this) + keeper.say("I need some help, $name!") + delay(2) + } + + private suspend fun Player.beekeeperTalk() { + if (!get("beekeeper_intro", false)) { + set("beekeeper_intro", true) + npc("bee_keeper", "Ah, $name. I'm sorry to drag you away like this, but I need some help building a new hive for my bees.") + player("What do you want me to do?") + npc("bee_keeper", "All the components of the beehive are jumbled up. You've got to put them in the correct order for building a hive.") + player("Oh, very well. Let's take a look...") + } + openHive() + } + + /** Deal the four hive parts randomly across the source slots, leaving the frame empty. */ + private fun Player.shuffleParts() { + val parts = (1..4).shuffled(random) + for (slot in 1..4) { + set("beekeeper_part_$slot", parts[slot - 1]) + set("beekeeper_slot_$slot", 0) + } + } + + private fun Player.openHive() { + open("beehive_build") + // Send the access masks the cache doesn't: the Build button's click option, and drag flags + // (drag depth + drag-onto) for the part and frame slots so the client reports the drags. + interfaceOptions.unlockAll("beehive_build", "build", 0..0) + for (component in 12..19) { + sendInterfaceSettings(InterfaceDefinition.pack(HIVE_ID, component), 0, 0, DRAG_SETTINGS) + } + } + + /** Dragging one slot onto another swaps their contents; every slot pairing is allowed. */ + private fun Player.swapParts(from: String, to: String) { + val source = get("beekeeper_$from", 0) + val target = get("beekeeper_$to", 0) + set("beekeeper_$from", target) + set("beekeeper_$to", source) + } + + private suspend fun Player.build() { + if ((1..4).any { get("beekeeper_slot_$it", 0) == 0 }) { + statement("You need to move all 4 of the spinning components to the frame in the centre of the display.", clickToContinue = false) + return + } + if ((1..4).all { get("beekeeper_slot_$it", 0) == it }) { + succeed() + } else { + fail() + } + } + + private suspend fun Player.succeed() { + close("beehive_build") + npc("bee_keeper", "That's perfect! I'll get some bees moved in immediately. Now, I'm sure I must have something to offer you for all your help...") + addOrDrop("random_event_gift") + message("You've been given a gift!") + clearState() + RandomEvents.complete(this) + } + + private suspend fun Player.fail() { + val tries = dec("beekeeper_tries") + if (tries > 0) { + npc("bee_keeper", "No, that doesn't look right. You have to put the components in the right order, otherwise it'll be no good as a beehive. I'll let you have $tries more ${if (tries == 1) "try" else "tries"}.") + return + } + close("beehive_build") + npc("bee_keeper", "Uh-oh, the bees are fed up with waiting for you to build them a home!") + say("Aaaaargh - BEES!") + delay(2) + clearState() + RandomEvents.complete(this) + } + + private fun Player.clearState() { + clear("beekeeper_intro") + clear("beekeeper_tries") + for (slot in 1..4) { + clear("beekeeper_part_$slot") + clear("beekeeper_slot_$slot") + } + } + + companion object { + private const val HIVE_ID = 420 + private const val TRIES = 6 + private val REGION = Region(30, 78) + + // Only this box is copied into the instance: south-west and north-east corners of the apiary. + private val AREA_SW = Tile(1920, 5031) + private val AREA_NE = Tile(1943, 5055) + + private val FIELD = Tile(1931, 5044) + private val KEEPER = Tile(1931, 5045) + + // Access mask flags: drag depth 1 (bit 17) and drag-onto (bit 19). + private const val DRAG_SETTINGS = (1 shl 17) or (1 shl 19) + } +} diff --git a/game/src/test/kotlin/content/activity/event/random/BeekeeperTest.kt b/game/src/test/kotlin/content/activity/event/random/BeekeeperTest.kt new file mode 100644 index 0000000000..228ed422ce --- /dev/null +++ b/game/src/test/kotlin/content/activity/event/random/BeekeeperTest.kt @@ -0,0 +1,143 @@ +package content.activity.event.random + +import WorldTest +import content.quest.instanceOffset +import interfaceOption +import kotlinx.coroutines.test.runTest +import npcOption +import org.junit.jupiter.api.Test +import skipDialogues +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.inv.inventory +import world.gregs.voidps.network.client.instruction.MoveInventoryItem +import world.gregs.voidps.type.Tile +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class BeekeeperTest : WorldTest() { + + private val origin = Tile(3221, 3218) + private val field = Tile(1931, 5044) + + private fun enter(name: String): Player { + val player = createPlayer(origin, name) + RandomEvents.start(player, "beekeeper") + tick(8) + return player + } + + private fun Player.keeper(): NPC? = (-2..2).flatMap { dx -> (-2..2).map { dy -> tile.add(dx, dy) } } + .firstNotNullOfOrNull { t -> NPCs.firstOrNull(t) { it.id == "bee_keeper" && it.owner == this } } + + private fun Player.talkThroughIntro() { + npcOption(keeper()!!, "Talk-To") + tick(2) + skipDialogues() + tick() + } + + private fun Player.drag(from: Int, to: Int) = runTest { + instructions.send(MoveInventoryItem(420, from, -1, 0, 420, to, -1, 0)) + } + + private fun Player.placeParts(correct: Boolean) { + for (source in 1..4) { + val part = get("beekeeper_part_$source", 0) + val destination = if (correct) part else (part % 4) + 1 + drag(11 + source, 15 + destination) + tick() + } + } + + private fun Player.build() { + interfaceOption("beehive_build", "build", "Build") + tick(2) + skipDialogues() + tick() + } + + @Test + fun `The Bee keeper drags the player to a private copy of his field`() { + val player = enter("bees_start") + + assertEquals("beekeeper", player.get("random_event")) + assertEquals(field, player.tile.minus(player.instanceOffset())) + assertTrue(player.contains("instance_offset")) + assertEquals(6, player.get("beekeeper_tries", 0)) + assertEquals(listOf(1, 2, 3, 4), (1..4).map { player.get("beekeeper_part_$it", 0) }.sorted()) + } + + @Test + fun `Building with missing components is rejected`() { + val player = enter("bees_missing") + player.talkThroughIntro() + + // No skipDialogues: the rejection is a no-prompt statement that stays open. + player.interfaceOption("beehive_build", "build", "Build") + tick(2) + + assertEquals(6, player.get("beekeeper_tries", 0)) + assertEquals("beekeeper", player.get("random_event")) + } + + @Test + fun `Dragging a part moves it into the frame`() { + val player = enter("bees_drag") + player.talkThroughIntro() + + val part = player.get("beekeeper_part_1", 0) + player.drag(12, 16) + tick() + + assertEquals(0, player.get("beekeeper_part_1", 0)) + assertEquals(part, player.get("beekeeper_slot_1", 0)) + } + + @Test + fun `The correct order builds the hive for a gift`() { + val player = enter("bees_win") + player.talkThroughIntro() + + player.placeParts(correct = true) + player.build() + tick(2) + + assertEquals(1, player.inventory.count("random_event_gift")) + assertNull(player.get("random_event")) + assertEquals(origin, player.tile) + assertTrue(player.contains("random_event_cooldown")) + } + + @Test + fun `A wrong order costs a try`() { + val player = enter("bees_wrong") + player.talkThroughIntro() + + player.placeParts(correct = false) + player.build() + + assertEquals(5, player.get("beekeeper_tries", 0)) + assertEquals("beekeeper", player.get("random_event")) + } + + @Test + fun `Six wrong builds send the player home empty-handed`() { + val player = enter("bees_fail") + player.talkThroughIntro() + + player.placeParts(correct = false) + repeat(6) { + player.build() + } + tick(4) + + assertNull(player.get("random_event")) + assertEquals(origin, player.tile) + assertTrue(player.contains("random_event_cooldown")) + assertFalse(player.inventory.contains("random_event_gift")) + } +} From 94e643816ee21e3867c27f0bcd7be10473bcf87e Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Fri, 10 Jul 2026 07:59:54 -0700 Subject: [PATCH 64/89] Add Evil Twin random event --- .../random/evil_twin/evil_twin.anims.toml | 14 + .../event/random/evil_twin/evil_twin.gfx.toml | 2 + .../random/evil_twin/evil_twin.ifaces.toml | 28 ++ .../random/evil_twin/evil_twin.npcs.toml | 242 +++++++++++ .../random/evil_twin/evil_twin.objs.toml | 7 + .../random/evil_twin/evil_twin.sounds.toml | 8 + .../random/evil_twin/evil_twin.tables.toml | 30 ++ .../random/evil_twin/evil_twin.vars.toml | 19 + .../event/random/random_event.tables.toml | 4 + .../event/random/evil_twin/EvilTwin.kt | 388 ++++++++++++++++++ .../activity/event/random/EvilTwinTest.kt | 201 +++++++++ 11 files changed, 943 insertions(+) create mode 100644 data/activity/event/random/evil_twin/evil_twin.anims.toml create mode 100644 data/activity/event/random/evil_twin/evil_twin.gfx.toml create mode 100644 data/activity/event/random/evil_twin/evil_twin.ifaces.toml create mode 100644 data/activity/event/random/evil_twin/evil_twin.npcs.toml create mode 100644 data/activity/event/random/evil_twin/evil_twin.objs.toml create mode 100644 data/activity/event/random/evil_twin/evil_twin.sounds.toml create mode 100644 data/activity/event/random/evil_twin/evil_twin.tables.toml create mode 100644 data/activity/event/random/evil_twin/evil_twin.vars.toml create mode 100644 game/src/main/kotlin/content/activity/event/random/evil_twin/EvilTwin.kt create mode 100644 game/src/test/kotlin/content/activity/event/random/EvilTwinTest.kt diff --git a/data/activity/event/random/evil_twin/evil_twin.anims.toml b/data/activity/event/random/evil_twin/evil_twin.anims.toml new file mode 100644 index 0000000000..e35050c330 --- /dev/null +++ b/data/activity/event/random/evil_twin/evil_twin.anims.toml @@ -0,0 +1,14 @@ +[evil_twin_claw_grab] +id = 4000 + +[evil_twin_lifted] +id = 4001 + +[evil_twin_claw_lower] +id = 4003 + +[evil_twin_operate] +id = 4004 + +[evil_twin_jailed] +id = 859 diff --git a/data/activity/event/random/evil_twin/evil_twin.gfx.toml b/data/activity/event/random/evil_twin/evil_twin.gfx.toml new file mode 100644 index 0000000000..b0d9d0910c --- /dev/null +++ b/data/activity/event/random/evil_twin/evil_twin.gfx.toml @@ -0,0 +1,2 @@ +[evil_twin_lifted] +id = 666 diff --git a/data/activity/event/random/evil_twin/evil_twin.ifaces.toml b/data/activity/event/random/evil_twin/evil_twin.ifaces.toml new file mode 100644 index 0000000000..b77f7479ff --- /dev/null +++ b/data/activity/event/random/evil_twin/evil_twin.ifaces.toml @@ -0,0 +1,28 @@ +# Evil Twin crane controls (240). The arrow buttons move the claw one tile per click and the +# red button lowers it onto whoever is standing on the glowing mark. The buttons' option names +# ("Up", "Grab"...) come from the interface definition in the cache; the camera looks south over +# the pen, so screen-up is south. +[evil_twin_crane] +id = 240 +type = "inventory_tab" + +[.tries] +id = 27 + +[.grab] +id = 28 + +[.up] +id = 29 + +[.down] +id = 30 + +[.left] +id = 31 + +[.right] +id = 32 + +[.close] +id = 33 diff --git a/data/activity/event/random/evil_twin/evil_twin.npcs.toml b/data/activity/event/random/evil_twin/evil_twin.npcs.toml new file mode 100644 index 0000000000..23f67a21f7 --- /dev/null +++ b/data/activity/event/random/evil_twin/evil_twin.npcs.toml @@ -0,0 +1,242 @@ +# Molly and her lookalikes. Appearance index n = colour + model * 4 (colour 0-3, model 0-4); +# molly_n, twin_suspect_n and twin_suspect_carried_n share the same look for a given n. +[molly_0] +id = 3892 +examine = "She seems worried." + +[molly_1] +clone = "molly_0" +id = 3893 + +[molly_2] +clone = "molly_0" +id = 3894 + +[molly_3] +clone = "molly_0" +id = 3895 + +[molly_4] +clone = "molly_0" +id = 3896 + +[molly_5] +clone = "molly_0" +id = 3897 + +[molly_6] +clone = "molly_0" +id = 3898 + +[molly_7] +clone = "molly_0" +id = 3899 + +[molly_8] +clone = "molly_0" +id = 3900 + +[molly_9] +clone = "molly_0" +id = 3901 + +[molly_10] +clone = "molly_0" +id = 3902 + +[molly_11] +clone = "molly_0" +id = 3903 + +[molly_12] +clone = "molly_0" +id = 3904 + +[molly_13] +clone = "molly_0" +id = 3905 + +[molly_14] +clone = "molly_0" +id = 3906 + +[molly_15] +clone = "molly_0" +id = 3907 + +[molly_16] +clone = "molly_0" +id = 3908 + +[molly_17] +clone = "molly_0" +id = 3909 + +[molly_18] +clone = "molly_0" +id = 3910 + +[molly_19] +clone = "molly_0" +id = 3911 + +[twin_suspect_0] +id = 3852 +wander_range = 3 +examine = "Could this be Molly's evil twin?" + +[twin_suspect_1] +clone = "twin_suspect_0" +id = 3853 + +[twin_suspect_2] +clone = "twin_suspect_0" +id = 3854 + +[twin_suspect_3] +clone = "twin_suspect_0" +id = 3855 + +[twin_suspect_4] +clone = "twin_suspect_0" +id = 3856 + +[twin_suspect_5] +clone = "twin_suspect_0" +id = 3857 + +[twin_suspect_6] +clone = "twin_suspect_0" +id = 3858 + +[twin_suspect_7] +clone = "twin_suspect_0" +id = 3859 + +[twin_suspect_8] +clone = "twin_suspect_0" +id = 3860 + +[twin_suspect_9] +clone = "twin_suspect_0" +id = 3861 + +[twin_suspect_10] +clone = "twin_suspect_0" +id = 3862 + +[twin_suspect_11] +clone = "twin_suspect_0" +id = 3863 + +[twin_suspect_12] +clone = "twin_suspect_0" +id = 3864 + +[twin_suspect_13] +clone = "twin_suspect_0" +id = 3865 + +[twin_suspect_14] +clone = "twin_suspect_0" +id = 3866 + +[twin_suspect_15] +clone = "twin_suspect_0" +id = 3867 + +[twin_suspect_16] +clone = "twin_suspect_0" +id = 3868 + +[twin_suspect_17] +clone = "twin_suspect_0" +id = 3869 + +[twin_suspect_18] +clone = "twin_suspect_0" +id = 3870 + +[twin_suspect_19] +clone = "twin_suspect_0" +id = 3871 + +# Dangling-from-the-claw variants shown while a suspect is carried to the jail. +[twin_suspect_carried_0] +id = 3872 + +[twin_suspect_carried_1] +clone = "twin_suspect_carried_0" +id = 3873 + +[twin_suspect_carried_2] +clone = "twin_suspect_carried_0" +id = 3874 + +[twin_suspect_carried_3] +clone = "twin_suspect_carried_0" +id = 3875 + +[twin_suspect_carried_4] +clone = "twin_suspect_carried_0" +id = 3876 + +[twin_suspect_carried_5] +clone = "twin_suspect_carried_0" +id = 3877 + +[twin_suspect_carried_6] +clone = "twin_suspect_carried_0" +id = 3878 + +[twin_suspect_carried_7] +clone = "twin_suspect_carried_0" +id = 3879 + +[twin_suspect_carried_8] +clone = "twin_suspect_carried_0" +id = 3880 + +[twin_suspect_carried_9] +clone = "twin_suspect_carried_0" +id = 3881 + +[twin_suspect_carried_10] +clone = "twin_suspect_carried_0" +id = 3882 + +[twin_suspect_carried_11] +clone = "twin_suspect_carried_0" +id = 3883 + +[twin_suspect_carried_12] +clone = "twin_suspect_carried_0" +id = 3884 + +[twin_suspect_carried_13] +clone = "twin_suspect_carried_0" +id = 3885 + +[twin_suspect_carried_14] +clone = "twin_suspect_carried_0" +id = 3886 + +[twin_suspect_carried_15] +clone = "twin_suspect_carried_0" +id = 3887 + +[twin_suspect_carried_16] +clone = "twin_suspect_carried_0" +id = 3888 + +[twin_suspect_carried_17] +clone = "twin_suspect_carried_0" +id = 3889 + +[twin_suspect_carried_18] +clone = "twin_suspect_carried_0" +id = 3890 + +[twin_suspect_carried_19] +clone = "twin_suspect_carried_0" +id = 3891 diff --git a/data/activity/event/random/evil_twin/evil_twin.objs.toml b/data/activity/event/random/evil_twin/evil_twin.objs.toml new file mode 100644 index 0000000000..e7137284aa --- /dev/null +++ b/data/activity/event/random/evil_twin/evil_twin.objs.toml @@ -0,0 +1,7 @@ +[evil_twin_claw] +id = 14976 +examine = "A giant mechanical claw." + +# Glowing mark on the floor showing where the claw will drop. +[evil_twin_claw_marker] +id = 14977 diff --git a/data/activity/event/random/evil_twin/evil_twin.sounds.toml b/data/activity/event/random/evil_twin/evil_twin.sounds.toml new file mode 100644 index 0000000000..109fa842fb --- /dev/null +++ b/data/activity/event/random/evil_twin/evil_twin.sounds.toml @@ -0,0 +1,8 @@ +[evil_twin_claw_move] +id = 2273 + +[evil_twin_claw_lower] +id = 2272 + +[evil_twin_claw_drop] +id = 2271 diff --git a/data/activity/event/random/evil_twin/evil_twin.tables.toml b/data/activity/event/random/evil_twin/evil_twin.tables.toml new file mode 100644 index 0000000000..e7b1200655 --- /dev/null +++ b/data/activity/event/random/evil_twin/evil_twin.tables.toml @@ -0,0 +1,30 @@ +# Molly's reward for catching her evil twin. +[random_event_evil_twin] +item = "item" +weight = "int" +min = "int" +max = "int" + +[.uncut_diamond] +item = "uncut_diamond" +weight = 25 +min = 1 +max = 2 + +[.uncut_ruby] +item = "uncut_ruby" +weight = 25 +min = 1 +max = 3 + +[.uncut_emerald] +item = "uncut_emerald" +weight = 25 +min = 1 +max = 3 + +[.uncut_sapphire] +item = "uncut_sapphire" +weight = 25 +min = 1 +max = 4 diff --git a/data/activity/event/random/evil_twin/evil_twin.vars.toml b/data/activity/event/random/evil_twin/evil_twin.vars.toml new file mode 100644 index 0000000000..f261c3d2aa --- /dev/null +++ b/data/activity/event/random/evil_twin/evil_twin.vars.toml @@ -0,0 +1,19 @@ +# Appearance index shared by Molly and her twin: colour + model * 4 (0-19). +# Hash, tries and caught persist so logging out mid-event resumes where the player left off. +[evil_twin_hash] +format = "int" +persist = true + +[evil_twin_tries] +format = "int" +persist = true + +[evil_twin_claw_x] +format = "int" + +[evil_twin_claw_y] +format = "int" + +[evil_twin_caught] +format = "boolean" +persist = true diff --git a/data/activity/event/random/random_event.tables.toml b/data/activity/event/random/random_event.tables.toml index af40b4e554..d456104362 100644 --- a/data/activity/event/random/random_event.tables.toml +++ b/data/activity/event/random/random_event.tables.toml @@ -62,6 +62,10 @@ weight = 10 event = "beekeeper" weight = 10 +[.evil_twin] +event = "evil_twin" +weight = 10 + [random_event_settings] value = "int" diff --git a/game/src/main/kotlin/content/activity/event/random/evil_twin/EvilTwin.kt b/game/src/main/kotlin/content/activity/event/random/evil_twin/EvilTwin.kt new file mode 100644 index 0000000000..01f3261751 --- /dev/null +++ b/game/src/main/kotlin/content/activity/event/random/evil_twin/EvilTwin.kt @@ -0,0 +1,388 @@ +package content.activity.event.random.evil_twin + +import content.activity.event.random.RandomEvents +import content.activity.event.random.kidnap +import content.activity.event.random.rewardEventLoot +import content.entity.effect.clearTransform +import content.entity.effect.transform +import content.entity.player.dialogue.Angry +import content.entity.player.dialogue.Happy +import content.entity.player.dialogue.Neutral +import content.entity.player.dialogue.Sad +import content.entity.player.dialogue.type.choice +import content.entity.player.dialogue.type.npc +import content.entity.player.dialogue.type.player +import content.entity.player.dialogue.type.statement +import content.entity.player.modal.Tab +import content.entity.player.modal.tab +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.clearCamera +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.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.mode.EmptyMode +import world.gregs.voidps.engine.entity.character.mode.Wander +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.name +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.ObjectShape +import world.gregs.voidps.type.Direction +import world.gregs.voidps.type.Region +import world.gregs.voidps.type.Tile +import world.gregs.voidps.type.area.Cuboid +import world.gregs.voidps.type.random + +/** + * Evil Twin random event: Molly appears beside the player, pleads for help and whisks them away to + * her house, where her evil twin sister hides in a pen of lookalike civilians next door. The + * player drives a giant mechanical claw from the control panel (interface 240) and has two tries + * to grab the sister - she's the one dressed exactly like Molly. Success earns a roll of the uncut + * gem table; running out of tries gets the player thrown out with nothing. + * https://runescape.wiki/w/Random_events?oldid=3667851#Evil_Twin + */ +class EvilTwin : Script { + + init { + RandomEvents.register("evil_twin") { startEvent() } + + npcOperate("Talk-to", "molly_*") { (molly) -> + if (molly.owner != this) { + message("She isn't interested in talking to you.") + return@npcOperate + } + if (instance() == null) { + return@npcOperate + } + if (get("evil_twin_caught", false)) { + success() + } else { + reminderDialogue() + } + } + + objectOperate("Use", "control_panel") { + if (get("random_event") != "evil_twin") { + return@objectOperate + } + if (get("evil_twin_caught", false)) { + message("You've already caught the evil twin.") + return@objectOperate + } + open("evil_twin_crane") + } + + interfaceOpened("evil_twin_crane") { + tab(Tab.Inventory) + sendTries() + clawCamera() + } + + interfaceClosed("evil_twin_crane") { + clearCamera() + if (get("random_event") == "evil_twin" && instance() != null) { + removeClaw() + placeClaw(CLAW_HOME) + } + } + + // The camera looks south over the pen, so the on-screen directions flip. + interfaceOption("Up", "evil_twin_crane:up") { + moveClaw(Direction.SOUTH) + } + + interfaceOption("Down", "evil_twin_crane:down") { + moveClaw(Direction.NORTH) + } + + interfaceOption("Left", "evil_twin_crane:left") { + moveClaw(Direction.EAST) + } + + interfaceOption("Right", "evil_twin_crane:right") { + moveClaw(Direction.WEST) + } + + interfaceOption("Grab", "evil_twin_crane:grab") { + grab() + } + + interfaceOption("Close", "evil_twin_crane:close") { + close("evil_twin_crane") + } + } + + private suspend fun Player.startEvent() { + // A logout mid-house restarts here on login; rebuild the house and carry on where + // they left off rather than starting the event over. + if (get("evil_twin_tries", 0) > 0 || get("evil_twin_caught", false)) { + setupHouse() + return + } + val hash = random.nextInt(LOOKS) + set("evil_twin_hash", hash) + // There's no choice about taking part: Molly appears, pleads, and whisks the player away. + val molly = NPCs.addRandom("molly_$hash", tile.toCuboid(1), ticks = 25, owner = this) + ?: NPCs.add("molly_$hash", tile, ticks = 25, owner = this) + molly.watch(this) + molly.say(nagLines().random(random)) + delay(3) + set("evil_twin_tries", TRIES) + setupHouse() + houseDialogue() + } + + /** + * Copy Molly's house into a private instance and take the player and Molly there. The house + * furniture (control panel, cage, crates) comes with the map; the claw and its floor marker + * are spawned at [CLAW_HOME], and the five suspects - one per look sharing the twin's colour, + * only one matching her exactly - are set wandering the pen. Once the twin has been caught + * only she remains, waiting in the jail. + */ + private suspend fun Player.setupHouse() { + smallInstance(Region(HOUSE_REGION), levels = 1) + setInstanceLogout(Tile(this["random_event_origin", tile.id])) + val offset = instanceOffset() + set("evil_twin_claw_x", CLAW_HOME.x) + set("evil_twin_claw_y", CLAW_HOME.y) + if (GameObjects.at(CLAW_HOME.add(offset)).none { it.id == "evil_twin_claw" }) { + GameObjects.add("evil_twin_claw", CLAW_HOME.add(offset), collision = false) + } + if (GameObjects.at(CLAW_HOME.add(offset)).none { it.id == "evil_twin_claw_marker" }) { + GameObjects.add("evil_twin_claw_marker", CLAW_HOME.add(offset), shape = ObjectShape.GROUND_DECOR, collision = false) + } + kidnap(ARRIVAL.add(offset)) + closeTabs() + minimap(Minimap.HideMap) + val hash = get("evil_twin_hash", 0) + val host = NPCs.add("molly_$hash", MOLLY_TILE.add(offset), ticks = -1, owner = this) + host.watch(this) + if (get("evil_twin_caught", false)) { + NPCs.add("twin_suspect_$hash", JAIL.add(offset), ticks = -1, owner = this) + } else { + spawnSuspects(hash, PEN.offset(offset)) + } + } + + private fun Player.spawnSuspects(hash: Int, pen: Cuboid) { + val colour = hash % COLOURS + for (model in 0 until MODELS) { + val id = "twin_suspect_${colour + model * COLOURS}" + val npc = NPCs.addRandom(id, pen, owner = this) ?: continue + npc.mode = Wander(npc, npc.tile) + } + } + + private fun Player.clawTile(): Tile = Tile(get("evil_twin_claw_x", CLAW_HOME.x), get("evil_twin_claw_y", CLAW_HOME.y)) + + private fun Player.clawObject(): GameObject? = GameObjects.at(clawTile().add(instanceOffset())) + .firstOrNull { it.id == "evil_twin_claw" } + + private fun Player.markerObject(): GameObject? = GameObjects.at(clawTile().add(instanceOffset())) + .firstOrNull { it.id == "evil_twin_claw_marker" } + + /** Bird's-eye view over the pen, looking at wherever the claw is. */ + private fun Player.clawCamera() { + val offset = instanceOffset() + moveCamera(CAMERA.add(offset), CAMERA_HEIGHT) + turnCamera(clawTile().add(offset), CLAW_HEIGHT) + } + + private fun Player.moveClaw(direction: Direction) { + val next = clawTile().add(direction.delta) + if (!TRAVEL.contains(next.x, next.y, 0)) { + return + } + removeClaw() + placeClaw(next) + sound("evil_twin_claw_move") + clawCamera() + } + + private fun Player.removeClaw() { + clawObject()?.let { GameObjects.remove(it, collision = false) } + markerObject()?.let { GameObjects.remove(it, collision = false) } + } + + private fun Player.placeClaw(next: Tile) { + val tile = next.add(instanceOffset()) + if (GameObjects.at(tile).none { it.id == "evil_twin_claw" }) { + GameObjects.add("evil_twin_claw", tile, collision = false) + } + if (GameObjects.at(tile).none { it.id == "evil_twin_claw_marker" }) { + GameObjects.add("evil_twin_claw_marker", tile, shape = ObjectShape.GROUND_DECOR, collision = false) + } + set("evil_twin_claw_x", next.x) + set("evil_twin_claw_y", next.y) + } + + /** + * Drop the claw on whoever is standing on the marker right now - wander a tile off it and the + * claw comes up empty. A grabbed suspect gets hauled over the cage wall to the jail; the rest + * puff away if she was the twin, or the tries tick down if she wasn't. Either way the claw + * swings back to its home corner afterwards, ready for another go. + */ + private suspend fun Player.grab() { + if (get("evil_twin_caught", false)) { + return + } + val offset = instanceOffset() + val target = clawTile().add(offset) + sound("evil_twin_claw_move") + clawObject()?.anim("evil_twin_claw_grab") + val suspect = NPCs.at(target).firstOrNull { it.id.startsWith("twin_suspect_") } + if (suspect == null) { + delay(2) + message("The claw comes up empty.") + useTry() + return + } + val look = suspect.id.removePrefix("twin_suspect_").toInt() + val twin = look == get("evil_twin_hash", 0) + suspect.mode = EmptyMode + suspect.anim("evil_twin_lifted") + suspect.gfx("evil_twin_lifted") + turnCamera(JAIL.add(offset), CLAW_HEIGHT) + delay(5) + anim("evil_twin_operate") + removeClaw() // The claw leaves with her, and swings back once she's been dropped off + suspect.transform("twin_suspect_carried_$look", collision = false) + suspect.walkOverDelay(JAIL.add(offset)) + sound("evil_twin_claw_lower") + suspect.anim("evil_twin_claw_lower") + delay(3) + suspect.clearTransform() + suspect.face(tile) + sound("evil_twin_claw_drop") + if (twin) { + catchTwin(suspect) + } else { + message("You caught an innocent civilian!") + suspect.say("You're putting me in prison?!") + delay(2) + placeClaw(CLAW_HOME) + clawCamera() + useTry() + } + } + + private suspend fun Player.catchTwin(twin: NPC) { + message("You caught the evil twin!") + set("evil_twin_caught", true) + jingle("twin_is_caught") + twin.face(Direction.NORTH) + delay(2) + twin.anim("evil_twin_jailed") + for (npc in NPCs.at(twin.tile.regionLevel)) { + if (npc.id.startsWith("twin_suspect_") && npc != twin) { + npc.gfx("imp_puff") + NPCs.remove(npc) + } + } + delay(2) + placeClaw(CLAW_HOME) + close("evil_twin_crane") + statement("You've caught Molly's evil twin! Talk to Molly to collect your reward.") + } + + private suspend fun Player.useTry() { + val remaining = dec("evil_twin_tries") + sendTries() + if (remaining > 0) { + return + } + close("evil_twin_crane") + npc(mollyId(), "Such incompetence! I should never have asked a baboon like you to do a complex task like this!") + npc(mollyId(), "Get out of my sight!") + clearState() + openTabs() + clearMinimap() + RandomEvents.fail(this) + } + + private fun Player.sendTries() { + interfaces.sendText("evil_twin_crane", "tries", "Tries: ${get("evil_twin_tries", 0)}") + } + + private suspend fun Player.success() { + player("Well, I've managed to get her into the cage.") + npc(mollyId(), "Fantastic! For so many years I've had to put up with her and now she's locked up for good.") + npc(mollyId(), "Thank you for all your help. Take this as a reward.") + rewardEventLoot("random_event_evil_twin") + clearState() + openTabs() + clearMinimap() + RandomEvents.complete(this) + } + + private suspend fun Player.houseDialogue() { + npc(mollyId(), "Thank you for coming, $name. I'm sorry to drag you away like this, but I really need your help.") + npc(mollyId(), "My evil twin sister has been committing crimes and everyone blames me! We look exactly alike - even our clothes are the same.") + npc(mollyId(), "I've managed to trap her next door, but she dragged several other women in with her.") + npc(mollyId(), "Through that door is a control panel that operates a giant mechanical claw. Use it to catch my sister!") + npc(mollyId(), "Be careful though; the claw's magic is running low, so you only have two attempts.") + choice { + option("Tell me about the controls.") { + npc(mollyId(), "The arrow buttons each move the claw one square; the glowing mark on the floor shows where it's aiming.") + npc(mollyId(), "When my sister stands on the mark, hit the red button to lower the claw and grab her.") + npc(mollyId(), "Make sure it's really her and not an innocent bystander!") + } + option("I'll get right to it.") + } + } + + private suspend fun Player.reminderDialogue() { + npc(mollyId(), "My sister is still on the loose! Use the control panel next door to catch her.") + npc(mollyId(), "Remember, she's the one that looks exactly like me.") + } + + private fun Player.mollyId() = "molly_${get("evil_twin_hash", 0)}" + + private fun Player.nagLines() = listOf( + "Please, could you help me, $name?", + "I need your help urgently, $name!", + ) + + private fun Player.clearState() { + clear("evil_twin_hash") + clear("evil_twin_tries") + clear("evil_twin_claw_x") + clear("evil_twin_claw_y") + clear("evil_twin_caught") + } + + companion object { + private const val HOUSE_REGION = 7504 // Region(29, 80), base 1856,5120 + private const val LOOKS = 20 // colour (0-3) + model (0-4) * 4 + private const val COLOURS = 4 + private const val MODELS = 5 + private const val TRIES = 2 + private const val CAMERA_HEIGHT = 800 + private const val CLAW_HEIGHT = 250 + + private val ARRIVAL = Tile(1860, 5136) + private val MOLLY_TILE = Tile(1860, 5135) + private val CLAW_HOME = Tile(1870, 5132) + private val JAIL = Tile(1866, 5124) + private val CAMERA = Tile(1870, 5140) + + // The pen the suspects wander, and the box the claw can be driven anywhere within. + private val PEN = Cuboid(1867, 5126, 1874, 5131, 0, 0) + private val TRAVEL = Cuboid(1865, 5124, 1875, 5133, 0, 0) + } +} diff --git a/game/src/test/kotlin/content/activity/event/random/EvilTwinTest.kt b/game/src/test/kotlin/content/activity/event/random/EvilTwinTest.kt new file mode 100644 index 0000000000..b7c562b3e8 --- /dev/null +++ b/game/src/test/kotlin/content/activity/event/random/EvilTwinTest.kt @@ -0,0 +1,201 @@ +package content.activity.event.random + +import WorldTest +import content.quest.instance +import content.quest.instanceOffset +import dialogueOption +import interfaceOption +import npcOption +import objectOption +import org.junit.jupiter.api.Test +import skipDialogues +import world.gregs.voidps.engine.entity.character.mode.EmptyMode +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.obj.GameObjects +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.type.Tile +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class EvilTwinTest : WorldTest() { + + private val origin = Tile(3221, 3218) + + private fun start(name: String): Pair { + val player = createPlayer(origin, name) + RandomEvents.start(player, "evil_twin") + tick(2) + val molly = (-2..2).flatMap { dx -> (-2..2).map { dy -> player.tile.add(dx, dy) } } + .firstNotNullOfOrNull { tile -> NPCs.firstOrNull(tile) { it.id.startsWith("molly_") } } + assertNotNull(molly, "Expected Molly spawned near the player") + return player to molly + } + + private fun enterHouse(name: String): Player { + val player = createPlayer(origin, name) + RandomEvents.start(player, "evil_twin") + tick(10) // Molly appears, pleads, and teleports the player away + player.skipDialogues() // Her explanation in the house... + player.dialogueOption(2) // ..."I'll get right to it." + tick() + return player + } + + private fun Player.openCrane() { + val panel = GameObjects.add("control_panel", tile) + objectOption(panel, "Use") + tick(2) + } + + private fun Player.clawTile(): Tile = Tile(get("evil_twin_claw_x", 0), get("evil_twin_claw_y", 0)).add(instanceOffset()) + + private fun Player.suspects(): List = NPCs.at(tile.regionLevel).filter { it.id.startsWith("twin_suspect_") } + + private fun Player.grab(suspect: NPC?) { + if (suspect != null) { + suspect.mode = EmptyMode + suspect.tele(clawTile()) + tick() + } + interfaceOption("evil_twin_crane", "grab", "Grab") + tick(25) // The claw sequence walks the suspect over to the jail + } + + @Test + fun `Event spawns Molly beside the player`() { + val (player, molly) = start("et_spawn") + + assertEquals("evil_twin", player.get("random_event")) + assertEquals(player.get("evil_twin_hash", 0), molly.id.removePrefix("molly_").toInt()) + } + + @Test + fun `Molly takes the player to her house`() { + val player = enterHouse("et_house") + + assertNotNull(player.instance()) + val suspects = player.suspects() + assertEquals(5, suspects.size) + assertTrue( + suspects.any { it.id == "twin_suspect_${player.get("evil_twin_hash", 0)}" }, + "Expected one suspect matching Molly's look", + ) + } + + @Test + fun `The claw moves around the pen but not beyond it`() { + val player = enterHouse("et_claw") + player.openCrane() + assertTrue(player.interfaces.contains("evil_twin_crane")) + val home = player.clawTile() + + player.interfaceOption("evil_twin_crane", "up", "Up") // South, over the pen + tick() + assertEquals(home.addY(-1), player.clawTile()) + assertTrue(GameObjects.at(player.clawTile()).any { it.id == "evil_twin_claw" }) + assertTrue(GameObjects.at(player.clawTile()).any { it.id == "evil_twin_claw_marker" }) + assertTrue(GameObjects.at(home).none { it.id == "evil_twin_claw" }, "claw left behind at home") + assertTrue(GameObjects.at(home).none { it.id == "evil_twin_claw_marker" }, "marker left behind at home") + + player.interfaceOption("evil_twin_crane", "down", "Down") // Back north to home + tick() + player.interfaceOption("evil_twin_crane", "down", "Down") // The north edge of its reach + tick() + assertEquals(home.addY(1), player.clawTile()) + + player.interfaceOption("evil_twin_crane", "down", "Down") // Past the edge - blocked + tick() + assertEquals(home.addY(1), player.clawTile()) + } + + @Test + fun `Grabbing the twin cages her and Molly pays out`() { + val player = enterHouse("et_catch") + player.openCrane() + + val twin = player.suspects().first { it.id == "twin_suspect_${player.get("evil_twin_hash", 0)}" } + player.grab(twin) + player.skipDialogues() // "You've caught Molly's evil twin!..." + tick() + + assertTrue(player.get("evil_twin_caught", false)) + assertEquals(1, player.suspects().size) + // The claw only swings back home once she's been dropped at the cage + val home = Tile(1870, 5132).add(player.instanceOffset()) + assertTrue(GameObjects.at(home).any { it.id == "evil_twin_claw" }) + assertTrue(GameObjects.at(home).any { it.id == "evil_twin_claw_marker" }) + + val molly = NPCs.at(player.tile.regionLevel).first { it.id.startsWith("molly_") } + player.npcOption(molly, "Talk-to") + tick(2) + player.skipDialogues() + tick(2) + + assertFalse(player.inventory.isEmpty(), "Expected a gem reward from Molly") + assertNull(player.get("random_event")) + assertEquals(origin, player.tile) + assertTrue(player.contains("random_event_cooldown")) + } + + @Test + fun `Catching the twin after a failed grab still succeeds`() { + val player = enterHouse("et_retry") + player.openCrane() + + val innocent = player.suspects().first { it.id != "twin_suspect_${player.get("evil_twin_hash", 0)}" } + player.grab(innocent) + assertEquals(1, player.get("evil_twin_tries", 0)) + + // Second grab with the twin still wandering: the tile check happens on the click itself, + // so she can't step off the mark before the claw drops. + val twin = player.suspects().first { it.id == "twin_suspect_${player.get("evil_twin_hash", 0)}" } + twin.tele(player.clawTile()) + player.interfaceOption("evil_twin_crane", "grab", "Grab") + tick(25) + + assertTrue(player.get("evil_twin_caught", false), "Expected the twin to be caught on the second try") + } + + @Test + fun `Logging out mid-event puts the player back in the house`() { + val player = enterHouse("et_relog") + player.openCrane() + player.grab(null) // One try used before "logging out" + val hash = player.get("evil_twin_hash", 0) + + // A login restarts the active event, exactly like RandomEventKidnap.playerSpawn + RandomEvents.start(player, "evil_twin") + tick(8) + + assertNotNull(player.instance()) + assertEquals(hash, player.get("evil_twin_hash", 0)) + assertEquals(1, player.get("evil_twin_tries", 0)) + assertEquals(5, player.suspects().size) + } + + @Test + fun `Two failed grabs get the player thrown out with nothing`() { + val player = enterHouse("et_fail") + player.openCrane() + + player.grab(null) // Nobody on the mark + assertEquals(1, player.get("evil_twin_tries", 0)) + + val innocent = player.suspects().first { it.id != "twin_suspect_${player.get("evil_twin_hash", 0)}" } + player.grab(innocent) + player.skipDialogues() // "Such incompetence!..." + tick(2) + + assertTrue(player.inventory.isEmpty(), "Expected no reward after failing") + assertNull(player.get("random_event")) + assertNotEquals(origin, player.tile) + assertTrue(player.contains("random_event_cooldown")) + } +} From ab8546d0fc136e37ff7a994ea931f449f308a858 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Fri, 10 Jul 2026 08:40:25 -0700 Subject: [PATCH 65/89] Add Gravedigger random event --- .../gravedigger/gravedigger.ifaces.toml | 39 +++ .../random/gravedigger/gravedigger.npcs.toml | 3 + .../random/gravedigger/gravedigger.objs.toml | 65 ++++ .../random/gravedigger/gravedigger.vars.toml | 25 ++ .../event/random/random_event.tables.toml | 4 + .../seers_village/seers_village.npcs.toml | 3 - .../event/random/gravedigger/Gravedigger.kt | 307 ++++++++++++++++++ .../activity/event/random/GravediggerTest.kt | 168 ++++++++++ 8 files changed, 611 insertions(+), 3 deletions(-) create mode 100644 data/activity/event/random/gravedigger/gravedigger.ifaces.toml create mode 100644 data/activity/event/random/gravedigger/gravedigger.npcs.toml create mode 100644 data/activity/event/random/gravedigger/gravedigger.objs.toml create mode 100644 data/activity/event/random/gravedigger/gravedigger.vars.toml create mode 100644 game/src/main/kotlin/content/activity/event/random/gravedigger/Gravedigger.kt create mode 100644 game/src/test/kotlin/content/activity/event/random/GravediggerTest.kt diff --git a/data/activity/event/random/gravedigger/gravedigger.ifaces.toml b/data/activity/event/random/gravedigger/gravedigger.ifaces.toml new file mode 100644 index 0000000000..ebe5ed3d48 --- /dev/null +++ b/data/activity/event/random/gravedigger/gravedigger.ifaces.toml @@ -0,0 +1,39 @@ +# Checking a coffin (141) shows the nine items found inside; reading a gravestone (143) shows +# an icon of the profession of whoever ought to be buried beneath it. +[gravedigger_coffin] +id = 141 +type = "main_screen" + +[.item_1] +id = 3 + +[.item_2] +id = 4 + +[.item_3] +id = 5 + +[.item_4] +id = 6 + +[.item_5] +id = 7 + +[.item_6] +id = 8 + +[.item_7] +id = 9 + +[.item_8] +id = 10 + +[.item_9] +id = 11 + +[gravedigger_gravestone] +id = 143 +type = "main_screen" + +[.icon] +id = 2 diff --git a/data/activity/event/random/gravedigger/gravedigger.npcs.toml b/data/activity/event/random/gravedigger/gravedigger.npcs.toml new file mode 100644 index 0000000000..860aaf6e05 --- /dev/null +++ b/data/activity/event/random/gravedigger/gravedigger.npcs.toml @@ -0,0 +1,3 @@ +[leo_gravedigger] +id = 3508 +examine = "He's a busy man." diff --git a/data/activity/event/random/gravedigger/gravedigger.objs.toml b/data/activity/event/random/gravedigger/gravedigger.objs.toml new file mode 100644 index 0000000000..4cccb9ef2d --- /dev/null +++ b/data/activity/event/random/gravedigger/gravedigger.objs.toml @@ -0,0 +1,65 @@ +# Filled graves - the object id shows which profession's coffin lies inside +# (0 lumberjack, 1 cook, 2 miner, 3 farmer, 4 potter). +[gravedigger_grave_0] +id = 12721 +examine = "A grave with a coffin in it." + +[gravedigger_grave_1] +clone = "gravedigger_grave_0" +id = 12722 + +[gravedigger_grave_2] +clone = "gravedigger_grave_0" +id = 12723 + +[gravedigger_grave_3] +clone = "gravedigger_grave_0" +id = 12724 + +[gravedigger_grave_4] +clone = "gravedigger_grave_0" +id = 12725 + +[gravedigger_open_grave_0] +id = 12726 +examine = "An open, empty grave." + +[gravedigger_open_grave_1] +clone = "gravedigger_open_grave_0" +id = 12727 + +[gravedigger_open_grave_2] +clone = "gravedigger_open_grave_0" +id = 12728 + +[gravedigger_open_grave_3] +clone = "gravedigger_open_grave_0" +id = 12729 + +[gravedigger_open_grave_4] +clone = "gravedigger_open_grave_0" +id = 12730 + +[gravedigger_gravestone_0] +id = 12716 +examine = "It shows who ought to be buried here." + +[gravedigger_gravestone_1] +clone = "gravedigger_gravestone_0" +id = 12717 + +[gravedigger_gravestone_2] +clone = "gravedigger_gravestone_0" +id = 12718 + +[gravedigger_gravestone_3] +clone = "gravedigger_gravestone_0" +id = 12719 + +[gravedigger_gravestone_4] +clone = "gravedigger_gravestone_0" +id = 12720 + +[gravedigger_mausoleum] +id = 12731 +examine = "Like a bank, but spookier." diff --git a/data/activity/event/random/gravedigger/gravedigger.vars.toml b/data/activity/event/random/gravedigger/gravedigger.vars.toml new file mode 100644 index 0000000000..1073ef00db --- /dev/null +++ b/data/activity/event/random/gravedigger/gravedigger.vars.toml @@ -0,0 +1,25 @@ +# What lies in each grave site: 0 = open and empty, 1-5 = that coffin (index + 1). +# Everything persists so logging out mid-event resumes where the player left off. +[gravedigger_started] +format = "boolean" +persist = true + +[gravedigger_site_0] +format = "int" +persist = true + +[gravedigger_site_1] +format = "int" +persist = true + +[gravedigger_site_2] +format = "int" +persist = true + +[gravedigger_site_3] +format = "int" +persist = true + +[gravedigger_site_4] +format = "int" +persist = true diff --git a/data/activity/event/random/random_event.tables.toml b/data/activity/event/random/random_event.tables.toml index d456104362..ab049c1bf3 100644 --- a/data/activity/event/random/random_event.tables.toml +++ b/data/activity/event/random/random_event.tables.toml @@ -66,6 +66,10 @@ weight = 10 event = "evil_twin" weight = 10 +[.gravedigger] +event = "gravedigger" +weight = 10 + [random_event_settings] value = "int" diff --git a/data/area/kandarin/seers_village/seers_village.npcs.toml b/data/area/kandarin/seers_village/seers_village.npcs.toml index 30aab92e77..608c621f14 100644 --- a/data/area/kandarin/seers_village/seers_village.npcs.toml +++ b/data/area/kandarin/seers_village/seers_village.npcs.toml @@ -72,9 +72,6 @@ id = 11456 [barbarian_seers_village_6] id = 12189 -[barbarian_seers_village_7] -id = 3508 - [barbarian_seers_village_8] id = 12234 diff --git a/game/src/main/kotlin/content/activity/event/random/gravedigger/Gravedigger.kt b/game/src/main/kotlin/content/activity/event/random/gravedigger/Gravedigger.kt new file mode 100644 index 0000000000..b632d75bf3 --- /dev/null +++ b/game/src/main/kotlin/content/activity/event/random/gravedigger/Gravedigger.kt @@ -0,0 +1,307 @@ +package content.activity.event.random.gravedigger + +import content.activity.event.random.RandomEvents +import content.activity.event.random.kidnap +import content.activity.event.random.rewardCostumeOrCoins +import content.entity.player.dialogue.Happy +import content.entity.player.dialogue.Neutral +import content.entity.player.dialogue.Sad +import content.entity.player.dialogue.type.choice +import content.entity.player.dialogue.type.npc +import content.entity.player.dialogue.type.player +import content.entity.player.modal.Tab +import content.entity.player.modal.tab +import content.quest.instance +import content.quest.instanceOffset +import content.quest.openTabs +import content.quest.setInstanceLogout +import content.quest.smallInstance +import content.skill.magic.spell.spellBook +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.entity.character.npc.NPCs +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.entity.character.player.name +import world.gregs.voidps.engine.entity.obj.GameObject +import world.gregs.voidps.engine.entity.obj.GameObjects +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.Region +import world.gregs.voidps.type.Tile +import world.gregs.voidps.type.random + +/** + * Gravedigger random event: Leo the gravedigger appears and whisks the player to his graveyard, + * where he's buried five coffins in the wrong graves. Checking a coffin shows the belongings of + * whoever's inside and each gravestone shows the profession of whoever ought to lie beneath it; + * the player digs the coffins up and reburies each in its proper grave, banking spare items at + * the mausoleum. Telling Leo it's done when all five match earns a piece of the zombie outfit + * (or 500 coins) and the zombie emotes; he'll also take anyone home who'd rather give up. + * https://runescape.wiki/w/Random_events?oldid=3667851#Gravedigger + */ +class Gravedigger : Script { + + init { + RandomEvents.register("gravedigger") { startEvent() } + + npcOperate("Talk-to", "leo_gravedigger") { (leo) -> + if (leo.owner != this) { + message("He's too busy to talk.") + return@npcOperate + } + if (instance() == null) { + return@npcOperate + } + leoDialogue() + } + + objectOperate("Take-coffin", "gravedigger_grave_*") { (grave) -> + if (get("random_event") != "gravedigger") { + return@objectOperate + } + takeCoffin(grave) + } + + itemOnObjectOperate("coffin*", "gravedigger_open_grave_*") { (grave, coffin) -> + if (get("random_event") != "gravedigger") { + return@itemOnObjectOperate + } + buryCoffin(grave, coffin.id) + } + + itemOption("Check", "coffin*") { (coffin) -> + open("gravedigger_coffin") + for ((index, item) in CONTENTS[coffinIndex(coffin.id)].withIndex()) { + interfaces.sendItem("gravedigger_coffin", "item_${index + 1}", item) + } + } + + objectOperate("Read", "gravedigger_gravestone_*") { (stone) -> + open("gravedigger_gravestone") + val profession = stone.id.removePrefix("gravedigger_gravestone_").toInt() + interfaces.sendItem("gravedigger_gravestone", "icon", ICONS[profession]) + } + + objectOperate("Deposit", "gravedigger_mausoleum") { + if (get("random_event") != "gravedigger") { + return@objectOperate + } + open("bank_deposit_box") + } + } + + private suspend fun Player.startEvent() { + // A logout mid-event restarts here on login; rebuild the graveyard and carry on + // where they left off rather than starting the event over. + if (get("gravedigger_started", false)) { + setupGraveyard() + return + } + // There's no choice about taking part: Leo appears, asks, and whisks the player away. + val leo = NPCs.addRandom("leo_gravedigger", tile.toCuboid(1), ticks = 25, owner = this) + ?: NPCs.add("leo_gravedigger", tile, ticks = 25, owner = this) + leo.watch(this) + leo.say("Can I borrow you for a minute, $name?") + delay(3) + set("gravedigger_started", true) + scrambleGraves() + setupGraveyard() + introDialogue() + } + + /** + * Bury the coffins in a shuffled arrangement - the map's graves come pre-solved, so reshuffle + * until at least one coffin is in the wrong grave. + */ + private fun Player.scrambleGraves() { + var shuffle: List + do { + shuffle = SITES.indices.shuffled(random) + } while (shuffle.withIndex().all { it.index == it.value }) + for ((site, coffin) in shuffle.withIndex()) { + set("gravedigger_site_$site", coffin + 1) + } + } + + /** + * Copy the graveyard into a private instance and take the player and Leo there. The graves, + * gravestones and mausoleum come with the map; each grave is then replaced to match whichever + * coffin lies in it. + */ + private suspend fun Player.setupGraveyard() { + smallInstance(Region(GRAVEYARD_REGION), levels = 1) + setInstanceLogout(Tile(this["random_event_origin", tile.id])) + val offset = instanceOffset() + for (site in SITES.indices) { + refreshGrave(site) + } + kidnap(ARRIVAL.add(offset)) + // Close every tab except the inventory - the coffins live there + for (tabName in HIDDEN_TABS) { + close(tabName) + } + set("spell_book", spellBook) + close(spellBook) + minimap(Minimap.HideMap) + tab(Tab.Inventory) + val leo = NPCs.add("leo_gravedigger", LEO_TILE.add(offset), ticks = -1, owner = this) + leo.watch(this) + } + + /** Swap the object at a grave site to match its contents var. */ + private fun Player.refreshGrave(site: Int) { + val tile = SITES[site].add(instanceOffset()) + for (obj in GameObjects.at(tile)) { + if (obj.id.startsWith("gravedigger_grave_") || obj.id.startsWith("gravedigger_open_grave_")) { + GameObjects.remove(obj) + } + } + val contents = get("gravedigger_site_$site", 0) + val id = if (contents == 0) "gravedigger_open_grave_$site" else "gravedigger_grave_${contents - 1}" + GameObjects.add(id, tile) + } + + private fun Player.siteAt(tile: Tile): Int = SITES.indexOf(tile.minus(instanceOffset())) + + private suspend fun Player.takeCoffin(grave: GameObject) { + val site = siteAt(grave.tile) + if (site == -1) { + return + } + val contents = get("gravedigger_site_$site", 0) + if (contents == 0) { + return + } + if (inventory.isFull()) { + message("You need space in your inventory to take the coffin.") + return + } + anim("climb_down") // Kneel down and dig + delay(1) + if (!inventory.add(coffinName(contents - 1))) { + return + } + set("gravedigger_site_$site", 0) + refreshGrave(site) + } + + private suspend fun Player.buryCoffin(grave: GameObject, coffin: String) { + val site = siteAt(grave.tile) + if (site == -1 || get("gravedigger_site_$site", 0) != 0) { + return + } + anim("climb_down") + delay(1) + if (!inventory.remove(coffin)) { + return + } + set("gravedigger_site_$site", coffinIndex(coffin) + 1) + refreshGrave(site) + message("You put the coffin into the grave.") + } + + private fun Player.solved(): Boolean = SITES.indices.all { get("gravedigger_site_$it", 0) == it + 1 } + + private suspend fun Player.introDialogue() { + npc("leo_gravedigger", "Sorry to interrupt, but I could really use your help.") + npc("leo_gravedigger", "I've been reburying the coffins from these five graves, but I've clean forgotten which coffin came from which grave!") + npc("leo_gravedigger", "Check the coffins to see whose remains are inside, and read the gravestones to see who ought to be buried where. Then put each coffin in its proper grave.") + npc("leo_gravedigger", "Don't forget to store any items that you don't need in the mausoleum. I'll take them to the bank while you work.") + } + + private suspend fun Player.leoDialogue() { + npc("leo_gravedigger", "How are you getting on?") + choice { + option("There, finished!") { + if (solved()) { + success() + } else { + npc("leo_gravedigger", "Well, that's a good attempt, but it's just not right.") + npc("leo_gravedigger", "Try looking in the coffins to get a better idea of who is in them, and then read the gravestones to find who needs to be in there.") + player("All right, I'll give it another shot.") + } + } + option("How do I do this again?") { + npc("leo_gravedigger", "Check the coffins to see whose remains are inside, and read the gravestones to see who ought to be buried where. Then put each coffin in its proper grave.") + } + option("I want to leave.") { + npc("leo_gravedigger", "In that case, I'll take you back to where I found you.") + exitGraveyard() + } + } + } + + private suspend fun Player.success() { + npc("leo_gravedigger", "Wonderful! That's taken care of all of them.") + npc("leo_gravedigger", "Here, I'll take you back to where I found you, and give you your reward.") + rewardCostumeOrCoins("zombie_mask", "zombie_shirt", "zombie_trousers", "zombie_gloves", "zombie_boots", coins = 500) + set("unlocked_emote_zombie_walk", true) + set("unlocked_emote_zombie_dance", true) + exitGraveyard() + } + + private fun Player.exitGraveyard() { + for (coffin in SITES.indices) { + while (inventory.remove(coffinName(coffin))) { + // Leo keeps his coffins + } + } + for (site in SITES.indices) { + clear("gravedigger_site_$site") + } + clear("gravedigger_started") + openTabs() + clearMinimap() + RandomEvents.complete(this) + } + + private fun coffinName(index: Int) = if (index == 0) "coffin" else "coffin_${index + 1}" + + private fun coffinIndex(name: String) = if (name == "coffin") 0 else name.removePrefix("coffin_").toInt() - 1 + + companion object { + private const val GRAVEYARD_REGION = 7758 // Region(30, 78), base 1920,4992 + + private val ARRIVAL = Tile(1928, 5002) + private val LEO_TILE = Tile(1928, 5003) + + // Cutscene.closeTabs minus the inventory + private val HIDDEN_TABS = listOf( + "combat_styles", + "task_system", + "stats", + "quest_journals", + "worn_equipment", + "prayer_list", + "emotes", + "notes", + ) + + // Grave sites in profession order: lumberjack, cook, miner, farmer, potter. + // Each has a matching gravestone (12716-12720) beside it in the map. + private val SITES = listOf( + Tile(1924, 4996), + Tile(1926, 4999), + Tile(1928, 4996), + Tile(1930, 4999), + Tile(1932, 4996), + ) + + // What's found inside each profession's coffin (interface 141), and the profession + // icon shown when reading a gravestone (interface 143). + private val CONTENTS = listOf( + listOf(7611, 7598, 7598, 7598, 7598, 7603, 7598, 7605, 7612), // lumberjack + listOf(7604, 7601, 7598, 7600, 7598, 7611, 7598, 7598, 7598), // cook + listOf(7598, 7598, 7606, 7598, 7597, 7598, 7607, 7598, 7611), // miner + listOf(7598, 7598, 7610, 7611, 7609, 7598, 7602, 7598, 7598), // farmer + listOf(7598, 7599, 7608, 7613, 7598, 7598, 7598, 7611, 7598), // potter + ) + private val ICONS = listOf(7614, 7615, 7616, 7617, 7618) + } +} diff --git a/game/src/test/kotlin/content/activity/event/random/GravediggerTest.kt b/game/src/test/kotlin/content/activity/event/random/GravediggerTest.kt new file mode 100644 index 0000000000..b82abb9233 --- /dev/null +++ b/game/src/test/kotlin/content/activity/event/random/GravediggerTest.kt @@ -0,0 +1,168 @@ +package content.activity.event.random + +import WorldTest +import content.quest.instance +import content.quest.instanceOffset +import dialogueOption +import equipItem +import itemOnObject +import npcOption +import objectOption +import org.junit.jupiter.api.Test +import skipDialogues +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.obj.GameObject +import world.gregs.voidps.engine.entity.obj.GameObjects +import world.gregs.voidps.engine.inv.inventory +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 GravediggerTest : WorldTest() { + + private val origin = Tile(3221, 3218) + private val sites = listOf( + Tile(1924, 4996), + Tile(1926, 4999), + Tile(1928, 4996), + Tile(1930, 4999), + Tile(1932, 4996), + ) + + private fun enter(name: String): Player { + val player = createPlayer(origin, name) + RandomEvents.start(player, "gravedigger") + tick(10) // Leo appears, asks, and teleports the player away + player.skipDialogues() // His explanation of the task + tick() + return player + } + + private fun Player.graveAt(site: Int): GameObject = GameObjects.at(sites[site].add(instanceOffset())) + .first { it.id.startsWith("gravedigger_grave_") || it.id.startsWith("gravedigger_open_grave_") } + + private fun Player.takeCoffin(site: Int) { + objectOption(graveAt(site), "Take-coffin") + tick(15) // Walk over and dig it up + } + + private fun Player.buryCoffin(site: Int, coffin: String) { + itemOnObject(graveAt(site), inventory.indexOf(coffin)) + tick(15) + } + + private fun Player.leo(): NPC = NPCs.at(tile.regionLevel).first { it.id == "leo_gravedigger" } + + private fun Player.claimFinished() { + npcOption(leo(), "Talk-to") + tick(10) // Walk over to Leo + skipDialogues() // "How are you getting on?" + dialogueOption(1) // "There, finished!" + tick() + skipDialogues() + tick(2) + } + + private fun coffinName(index: Int) = if (index == 0) "coffin" else "coffin_${index + 1}" + + @Test + fun `Leo kidnaps the player to a scrambled graveyard`() { + val player = enter("gd_start") + + assertEquals("gravedigger", player.get("random_event")) + assertNotNull(player.instance()) + val contents = sites.indices.map { player.get("gravedigger_site_$it", 0) } + assertTrue(contents.all { it in 1..5 }, "Expected every grave filled: $contents") + assertFalse(contents.withIndex().all { it.value == it.index + 1 }, "Expected a scrambled arrangement") + for (site in sites.indices) { + assertEquals("gravedigger_grave_${contents[site] - 1}", player.graveAt(site).id) + } + } + + @Test + fun `Take a coffin, check it and read the gravestone`() { + val player = enter("gd_check") + val contents = player.get("gravedigger_site_0", 0) + + player.takeCoffin(0) + val coffin = coffinName(contents - 1) + assertTrue(player.inventory.contains(coffin)) + assertEquals("gravedigger_open_grave_0", player.graveAt(0).id) + + player.equipItem(coffin, option = "Check") + tick() + assertTrue(player.interfaces.contains("gravedigger_coffin")) + + val stone = GameObjects.at(Tile(1924, 4998).add(player.instanceOffset())).first { it.id.startsWith("gravedigger_gravestone_") } + player.objectOption(stone, "Read") + tick(5) + assertTrue(player.interfaces.contains("gravedigger_gravestone")) + } + + @Test + fun `A wrong arrangement earns a hint, the right one pays out`() { + val player = enter("gd_solve") + + // Dig up all five coffins then claim it's done - Leo isn't fooled + for (site in sites.indices) { + player.takeCoffin(site) + } + player.claimFinished() + assertEquals("gravedigger", player.get("random_event")) + + // Rebury each in its proper grave + for (site in sites.indices) { + player.buryCoffin(site, coffinName(site)) + } + player.claimFinished() + + assertTrue(player.inventory.contains("zombie_mask"), "Expected the first zombie outfit piece") + assertTrue(sites.indices.none { player.inventory.contains(coffinName(it)) }, "Expected Leo to keep his coffins") + assertTrue(player.get("unlocked_emote_zombie_walk", false)) + assertTrue(player.get("unlocked_emote_zombie_dance", false)) + assertNull(player.get("random_event")) + assertEquals(origin, player.tile) + assertTrue(player.contains("random_event_cooldown")) + } + + @Test + fun `Leaving early returns the player with nothing`() { + val player = enter("gd_leave") + player.takeCoffin(0) + + player.npcOption(player.leo(), "Talk-to") + tick(10) // Walk over to Leo + player.skipDialogues() + player.dialogueOption(3) // "I want to leave." + tick() + player.skipDialogues() + tick(2) + + assertTrue(player.inventory.isEmpty(), "Expected no reward and no coffins kept") + assertNull(player.get("random_event")) + assertEquals(origin, player.tile) + assertTrue(player.contains("random_event_cooldown")) + } + + @Test + fun `Logging out mid-event puts the player back in the graveyard`() { + val player = enter("gd_relog") + val contents = player.get("gravedigger_site_0", 0) + player.takeCoffin(0) + val arrangement = sites.indices.map { player.get("gravedigger_site_$it", 0) } + + // A login restarts the active event, exactly like RandomEventKidnap.playerSpawn + RandomEvents.start(player, "gravedigger") + tick(8) + + assertNotNull(player.instance()) + assertEquals(arrangement, sites.indices.map { player.get("gravedigger_site_$it", 0) }) + assertEquals("gravedigger_open_grave_0", player.graveAt(0).id) + assertTrue(player.inventory.contains(coffinName(contents - 1)), "Expected the dug-up coffin kept across relog") + } +} From 0c6ef61585e8236497bb69da382245922197086a Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Fri, 10 Jul 2026 10:43:41 -0700 Subject: [PATCH 66/89] Reward all random events with a random event gift --- .../random/evil_twin/evil_twin.tables.toml | 30 -- .../event/random/gift/gift.drops.toml | 19 + .../event/random/gift/gift.ifaces.toml | 18 + .../activity/event/random/gift/gift.invs.toml | 7 + .../event/random/gift/gift.tables.toml | 427 ++++++++++++++++++ .../activity/event/random/gift/gift.vars.toml | 8 + .../event/random/maze/maze.tables.toml | 40 -- .../prison_pete/prison_pete.tables.toml | 72 --- .../quiz_master/quiz_master.tables.toml | 51 --- .../event/random/random_event.tables.toml | 66 --- data/entity/player/misc.invs.toml | 3 - .../client/instruction/InterfaceHandler.kt | 1 + .../activity/event/random/RandomEventGift.kt | 215 +++++++++ .../event/random/RandomEventRewards.kt | 38 -- .../activity/event/random/certer/Certer.kt | 6 +- .../event/random/drill_demon/DrillDemon.kt | 6 +- .../activity/event/random/evil_bob/EvilBob.kt | 14 +- .../event/random/evil_twin/EvilTwin.kt | 8 +- .../random/freaky_forester/FreakyForester.kt | 14 +- .../event/random/gravedigger/Gravedigger.kt | 8 +- .../random/lost_and_found/LostAndFound.kt | 12 +- .../activity/event/random/maze/Maze.kt | 18 +- .../event/random/mime_theatre/Mime.kt | 6 +- .../activity/event/random/pillory/Pillory.kt | 6 +- .../activity/event/random/pinball/Pinball.kt | 3 +- .../event/random/prison_pete/PrisonPete.kt | 6 +- .../event/random/quiz_master/QuizMaster.kt | 16 +- .../random/sandwich_lady/SandwichLady.kt | 1 + .../random/surprise_exam/SurpriseExam.kt | 4 +- .../activity/event/random/DrillDemonTest.kt | 19 +- .../activity/event/random/EvilBobTest.kt | 5 +- .../event/random/FreakyForesterTest.kt | 21 +- .../activity/event/random/GravediggerTest.kt | 2 +- .../activity/event/random/LostAndFoundTest.kt | 12 +- .../content/activity/event/random/MazeTest.kt | 2 +- .../content/activity/event/random/MimeTest.kt | 4 +- .../activity/event/random/QuizMasterTest.kt | 24 +- .../event/random/RandomEventGiftTest.kt | 178 ++++++++ .../activity/event/random/RandomEventsTest.kt | 19 - .../activity/event/random/SurpriseExamTest.kt | 4 +- 40 files changed, 930 insertions(+), 483 deletions(-) delete mode 100644 data/activity/event/random/evil_twin/evil_twin.tables.toml create mode 100644 data/activity/event/random/gift/gift.drops.toml create mode 100644 data/activity/event/random/gift/gift.ifaces.toml create mode 100644 data/activity/event/random/gift/gift.invs.toml create mode 100644 data/activity/event/random/gift/gift.tables.toml create mode 100644 data/activity/event/random/gift/gift.vars.toml delete mode 100644 data/activity/event/random/prison_pete/prison_pete.tables.toml delete mode 100644 data/activity/event/random/quiz_master/quiz_master.tables.toml create mode 100644 game/src/main/kotlin/content/activity/event/random/RandomEventGift.kt delete mode 100644 game/src/main/kotlin/content/activity/event/random/RandomEventRewards.kt create mode 100644 game/src/test/kotlin/content/activity/event/random/RandomEventGiftTest.kt diff --git a/data/activity/event/random/evil_twin/evil_twin.tables.toml b/data/activity/event/random/evil_twin/evil_twin.tables.toml deleted file mode 100644 index e7b1200655..0000000000 --- a/data/activity/event/random/evil_twin/evil_twin.tables.toml +++ /dev/null @@ -1,30 +0,0 @@ -# Molly's reward for catching her evil twin. -[random_event_evil_twin] -item = "item" -weight = "int" -min = "int" -max = "int" - -[.uncut_diamond] -item = "uncut_diamond" -weight = 25 -min = 1 -max = 2 - -[.uncut_ruby] -item = "uncut_ruby" -weight = 25 -min = 1 -max = 3 - -[.uncut_emerald] -item = "uncut_emerald" -weight = 25 -min = 1 -max = 3 - -[.uncut_sapphire] -item = "uncut_sapphire" -weight = 25 -min = 1 -max = 4 diff --git a/data/activity/event/random/gift/gift.drops.toml b/data/activity/event/random/gift/gift.drops.toml new file mode 100644 index 0000000000..3d4a757348 --- /dev/null +++ b/data/activity/event/random/gift/gift.drops.toml @@ -0,0 +1,19 @@ +# What's inside a mystery box - an equal shot at each oddment, with one slot rolling the +# rare drop table. https://runescape.wiki/w/Mystery_box_(random_events) +[mystery_box] +roll = 13 +drops = [ + { id = "cabbage" }, + { id = "diamond" }, + { id = "bucket" }, + { id = "coins", amount = 500 }, + { id = "flier" }, + { id = "old_boot" }, + { id = "body_rune" }, + { id = "onion" }, + { id = "mithril_scimitar" }, + { id = "casket" }, + { id = "steel_platebody" }, + { id = "nature_rune", amount = 20 }, + { table = "rare_drop_table" }, +] diff --git a/data/activity/event/random/gift/gift.ifaces.toml b/data/activity/event/random/gift/gift.ifaces.toml new file mode 100644 index 0000000000..0fb421292a --- /dev/null +++ b/data/activity/event/random/gift/gift.ifaces.toml @@ -0,0 +1,18 @@ +# The "Choose Your Reward!" interface (202) opened from a random event gift. The reward choices +# live in the random_event_gift inventory drawn by the grid (component 15); clicking one selects +# it and the confirm button (26) claims it. +[random_event_gift_select] +id = 202 +type = "main_screen" + +[.close] +id = 14 + +[.rewards] +id = 15 +inventory = "random_event_gift" +options = { Select = 0 } + +[.confirm] +id = 26 +options = { Confirm = 0 } diff --git a/data/activity/event/random/gift/gift.invs.toml b/data/activity/event/random/gift/gift.invs.toml new file mode 100644 index 0000000000..63fbcda048 --- /dev/null +++ b/data/activity/event/random/gift/gift.invs.toml @@ -0,0 +1,7 @@ +# The rewards on offer inside a player's random event gift; rolled once when first opened and +# kept until one is claimed. +[random_event_gift] +id = 307 +width = 4 +height = 7 +stack = "Always" diff --git a/data/activity/event/random/gift/gift.tables.toml b/data/activity/event/random/gift/gift.tables.toml new file mode 100644 index 0000000000..2ec5e66a49 --- /dev/null +++ b/data/activity/event/random/gift/gift.tables.toml @@ -0,0 +1,427 @@ +# Category pools for a random event gift's reward slots - one weighted pick per category. +# https://runescape.wiki/w/Random_event_gift +[gift_runes] +item = "item" +weight = "int" +min = "int" +max = "int" + +[.death_rune] +item = "death_rune" +weight = 8 +min = 23 +max = 23 + +[.chaos_rune] +item = "chaos_rune" +weight = 10 +min = 17 +max = 26 + +[.nature_rune] +item = "nature_rune" +weight = 8 +min = 17 +max = 20 + +[.law_rune] +item = "law_rune" +weight = 8 +min = 20 +max = 20 + +[.cosmic_rune] +item = "cosmic_rune" +weight = 8 +min = 20 +max = 23 + +[.mind_rune] +item = "mind_rune" +weight = 10 +min = 17 +max = 23 + +[.body_rune] +item = "body_rune" +weight = 10 +min = 26 +max = 26 + +[.fire_rune] +item = "fire_rune" +weight = 12 +min = 17 +max = 26 + +[.earth_rune] +item = "earth_rune" +weight = 12 +min = 20 +max = 40 + +[.air_rune] +item = "air_rune" +weight = 12 +min = 26 +max = 26 + +[.water_rune] +item = "water_rune" +weight = 12 +min = 20 +max = 26 + +[gift_ores] +item = "item" +weight = "int" +min = "int" +max = "int" + +[.clay] +item = "clay" +weight = 10 +min = 7 +max = 7 + +[.soft_clay] +item = "soft_clay" +weight = 10 +min = 7 +max = 15 + +[.copper_ore] +item = "copper_ore" +weight = 10 +min = 5 +max = 5 + +[.iron_ore] +item = "iron_ore" +weight = 12 +min = 7 +max = 7 + +[.gold_ore] +item = "gold_ore" +weight = 8 +min = 6 +max = 10 + +[.mithril_ore] +item = "mithril_ore" +weight = 6 +min = 9 +max = 25 + +[.adamantite_ore] +item = "adamantite_ore" +weight = 4 +min = 4 +max = 7 + +[.runite_ore] +item = "runite_ore" +weight = 1 +min = 1 +max = 1 + +[gift_bars] +item = "item" +weight = "int" +min = "int" +max = "int" + +[.bronze_bar] +item = "bronze_bar" +weight = 12 +min = 1 +max = 3 + +[.iron_bar] +item = "iron_bar" +weight = 12 +min = 2 +max = 6 + +[.silver_bar] +item = "silver_bar" +weight = 10 +min = 2 +max = 9 + +[.steel_bar] +item = "steel_bar" +weight = 10 +min = 2 +max = 10 + +[.gold_bar] +item = "gold_bar" +weight = 8 +min = 5 +max = 10 + +[.mithril_bar] +item = "mithril_bar" +weight = 6 +min = 5 +max = 10 + +[.adamant_bar] +item = "adamant_bar" +weight = 4 +min = 2 +max = 5 + +[gift_gems] +item = "item" +weight = "int" +min = "int" +max = "int" + +[.uncut_sapphire] +item = "uncut_sapphire" +weight = 12 +min = 1 +max = 6 + +[.uncut_emerald] +item = "uncut_emerald" +weight = 10 +min = 1 +max = 2 + +[.uncut_ruby] +item = "uncut_ruby" +weight = 8 +min = 1 +max = 2 + +[.uncut_diamond] +item = "uncut_diamond" +weight = 5 +min = 1 +max = 3 + +[.sapphire] +item = "sapphire" +weight = 6 +min = 1 +max = 1 + +[.emerald] +item = "emerald" +weight = 5 +min = 1 +max = 1 + +[.ruby] +item = "ruby" +weight = 4 +min = 1 +max = 1 + +[.diamond] +item = "diamond" +weight = 2 +min = 1 +max = 1 + +[gift_herbs] +item = "item" +weight = "int" +min = "int" +max = "int" + +[.grimy_tarromin] +item = "grimy_tarromin" +weight = 12 +min = 1 +max = 4 + +[.grimy_harralander] +item = "grimy_harralander" +weight = 12 +min = 3 +max = 4 + +[.grimy_ranarr] +item = "grimy_ranarr" +weight = 6 +min = 1 +max = 2 + +[.grimy_toadflax] +item = "grimy_toadflax" +weight = 8 +min = 2 +max = 3 + +[.grimy_irit] +item = "grimy_irit" +weight = 10 +min = 2 +max = 4 + +[.grimy_avantoe] +item = "grimy_avantoe" +weight = 8 +min = 2 +max = 4 + +[.grimy_kwuarm] +item = "grimy_kwuarm" +weight = 8 +min = 2 +max = 3 + +[.grimy_snapdragon] +item = "grimy_snapdragon" +weight = 5 +min = 1 +max = 3 + +[.grimy_cadantine] +item = "grimy_cadantine" +weight = 8 +min = 3 +max = 3 + +[.grimy_lantadyme] +item = "grimy_lantadyme" +weight = 6 +min = 1 +max = 2 + +[.grimy_dwarf_weed] +item = "grimy_dwarf_weed" +weight = 6 +min = 2 +max = 2 + +[gift_seeds] +item = "item" +weight = "int" +min = "int" +max = "int" + +[.barley_seed] +item = "barley_seed" +weight = 12 +min = 8 +max = 8 + +[.sweetcorn_seed] +item = "sweetcorn_seed" +weight = 10 +min = 2 +max = 4 + +[.yanillian_seed] +item = "yanillian_seed" +weight = 10 +min = 4 +max = 4 + +[.jute_seed] +item = "jute_seed" +weight = 10 +min = 6 +max = 6 + +[.potato_seed] +item = "potato_seed" +weight = 12 +min = 8 +max = 8 + +[.marigold_seed] +item = "marigold_seed" +weight = 8 +min = 1 +max = 1 + +[.snapdragon_seed] +item = "snapdragon_seed" +weight = 2 +min = 1 +max = 1 + +[gift_charms] +item = "item" +weight = "int" +min = "int" +max = "int" + +[.gold_charm] +item = "gold_charm" +weight = 4 +min = 2 +max = 2 + +[.green_charm] +item = "green_charm" +weight = 3 +min = 2 +max = 2 + +[.crimson_charm] +item = "crimson_charm" +weight = 2 +min = 2 +max = 2 + +[.blue_charm] +item = "blue_charm" +weight = 1 +min = 1 +max = 1 + +[gift_other] +item = "item" +weight = "int" +min = "int" +max = "int" + +[.flax] +item = "flax" +weight = 8 +min = 64 +max = 99 + +[.steel_platebody] +item = "steel_platebody" +weight = 6 +min = 1 +max = 1 + +[.book_of_knowledge] +item = "book_of_knowledge" +weight = 6 +min = 1 +max = 1 + +[.tooth_half_of_a_key] +item = "tooth_half_of_a_key" +weight = 1 +min = 1 +max = 1 + +[.loop_half_of_a_key] +item = "loop_half_of_a_key" +weight = 1 +min = 1 +max = 1 + +[.cabbage] +item = "cabbage" +weight = 4 +min = 1 +max = 1 + +[.bucket] +item = "bucket" +weight = 4 +min = 1 +max = 1 diff --git a/data/activity/event/random/gift/gift.vars.toml b/data/activity/event/random/gift/gift.vars.toml new file mode 100644 index 0000000000..0e32fc2f8c --- /dev/null +++ b/data/activity/event/random/gift/gift.vars.toml @@ -0,0 +1,8 @@ +# The gift reward slot the player has selected (slot + 1; 0 = nothing selected yet). +[gift_selected] +format = "int" + +# Saved up from gifts; spent at the costume store (not yet implemented). +[costume_points] +format = "int" +persist = true diff --git a/data/activity/event/random/maze/maze.tables.toml b/data/activity/event/random/maze/maze.tables.toml index d3cc442945..9fd0166198 100644 --- a/data/activity/event/random/maze/maze.tables.toml +++ b/data/activity/event/random/maze/maze.tables.toml @@ -1,43 +1,3 @@ -[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" diff --git a/data/activity/event/random/prison_pete/prison_pete.tables.toml b/data/activity/event/random/prison_pete/prison_pete.tables.toml deleted file mode 100644 index b7e7242cf0..0000000000 --- a/data/activity/event/random/prison_pete/prison_pete.tables.toml +++ /dev/null @@ -1,72 +0,0 @@ -# Reward for freeing Prison Pete. -[random_event_prison_pete] -item = "item" -weight = "int" -min = "int" -max = "int" - -[.coins] -item = "coins" -weight = 20 -min = 527 -max = 527 - -[.coins_pouch] -item = "coins" -weight = 15 -min = 100 -max = 500 - -[.sapphire] -item = "sapphire" -weight = 25 -min = 5 -max = 5 - -[.law_rune] -item = "law_rune" -weight = 22 -min = 10 -max = 10 - -[.chaos_rune] -item = "chaos_rune" -weight = 20 -min = 1 -max = 1 - -[.uncut_ruby] -item = "uncut_ruby" -weight = 18 -min = 4 -max = 4 - -[.mithril_arrowtips] -item = "mithril_arrowtips" -weight = 16 -min = 47 -max = 50 - -[.ugthanki_kebab] -item = "ugthanki_kebab" -weight = 13 -min = 2 -max = 2 - -[.grimy_toadflax] -item = "grimy_toadflax" -weight = 12 -min = 6 -max = 6 - -[.uncut_diamond] -item = "uncut_diamond" -weight = 9 -min = 3 -max = 3 - -[.grimy_snapdragon] -item = "grimy_snapdragon" -weight = 6 -min = 4 -max = 4 diff --git a/data/activity/event/random/quiz_master/quiz_master.tables.toml b/data/activity/event/random/quiz_master/quiz_master.tables.toml deleted file mode 100644 index f1abcc0850..0000000000 --- a/data/activity/event/random/quiz_master/quiz_master.tables.toml +++ /dev/null @@ -1,51 +0,0 @@ -[random_event_quiz] -item = "item" -amount = "int" - -[.lamp] -item = "lamp" -amount = 1 - -[.cabbage] -item = "cabbage" -amount = 1 - -[.diamond] -item = "diamond" -amount = 1 - -[.bucket] -item = "bucket" -amount = 1 - -[.flier] -item = "flier" -amount = 1 - -[.old_boot] -item = "old_boot" -amount = 1 - -[.body_rune] -item = "body_rune" -amount = 1 - -[.onion] -item = "onion" -amount = 1 - -[.mithril_scimitar] -item = "mithril_scimitar" -amount = 1 - -[.casket] -item = "casket" -amount = 1 - -[.steel_platebody] -item = "steel_platebody" -amount = 1 - -[.nature_rune] -item = "nature_rune" -amount = 20 diff --git a/data/activity/event/random/random_event.tables.toml b/data/activity/event/random/random_event.tables.toml index ab049c1bf3..0517496ac0 100644 --- a/data/activity/event/random/random_event.tables.toml +++ b/data/activity/event/random/random_event.tables.toml @@ -104,69 +104,3 @@ area = "random_event_exile_draynor" [.al_kharid] area = "random_event_exile_al_kharid" -# Shared reward table for the Certer and Pillory events. -[random_event_certer] -item = "item" -weight = "int" -min = "int" -max = "int" - -[.coins] -item = "coins" -weight = 20 -min = 80 -max = 640 - -[.kebab] -item = "kebab" -weight = 15 -min = 1 -max = 1 - -[.spinach_roll] -item = "spinach_roll" -weight = 15 -min = 1 -max = 1 - -[.uncut_sapphire] -item = "uncut_sapphire" -weight = 10 -min = 1 -max = 1 - -[.uncut_emerald] -item = "uncut_emerald" -weight = 8 -min = 1 -max = 1 - -[.cosmic_talisman] -item = "cosmic_talisman" -weight = 8 -min = 1 -max = 1 - -[.uncut_ruby] -item = "uncut_ruby" -weight = 5 -min = 1 -max = 1 - -[.uncut_diamond] -item = "uncut_diamond" -weight = 3 -min = 1 -max = 1 - -[.tooth_half_of_a_key] -item = "tooth_half_of_a_key" -weight = 2 -min = 1 -max = 1 - -[.loop_half_of_a_key] -item = "loop_half_of_a_key" -weight = 2 -min = 1 -max = 1 diff --git a/data/entity/player/misc.invs.toml b/data/entity/player/misc.invs.toml index 2d7186df11..6af1e7e303 100644 --- a/data/entity/player/misc.invs.toml +++ b/data/entity/player/misc.invs.toml @@ -306,9 +306,6 @@ id = 299 [skill_guide_farming_belladonna] id = 300 -[macro_certer] -id = 307 - [roguetrader_toughsudukuinv] id = 308 diff --git a/engine/src/main/kotlin/world/gregs/voidps/engine/client/instruction/InterfaceHandler.kt b/engine/src/main/kotlin/world/gregs/voidps/engine/client/instruction/InterfaceHandler.kt index 048797975a..e81d10a959 100644 --- a/engine/src/main/kotlin/world/gregs/voidps/engine/client/instruction/InterfaceHandler.kt +++ b/engine/src/main/kotlin/world/gregs/voidps/engine/client/instruction/InterfaceHandler.kt @@ -74,6 +74,7 @@ class InterfaceHandler( itemSlot == -1 && inventoryId == "returned_lent_items" -> 0 id == "price_checker" -> itemSlot / 2 id == "shop" -> itemSlot / 6 + id == "random_event_gift_select" -> itemSlot / 7 id == "grand_exchange" -> componentDefinition.stringId.removePrefix("collect_slot_").toInt() else -> itemSlot } diff --git a/game/src/main/kotlin/content/activity/event/random/RandomEventGift.kt b/game/src/main/kotlin/content/activity/event/random/RandomEventGift.kt new file mode 100644 index 0000000000..3974dd94b4 --- /dev/null +++ b/game/src/main/kotlin/content/activity/event/random/RandomEventGift.kt @@ -0,0 +1,215 @@ +package content.activity.event.random + +import content.entity.combat.inCombat +import content.entity.player.bank.noted +import content.entity.player.dialogue.type.skillLamp +import content.entity.player.dialogue.type.statement +import content.entity.player.inv.item.addOrDrop +import world.gregs.voidps.engine.Script +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.definition.Tables +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.entity.character.player.skill.Skill +import world.gregs.voidps.engine.entity.character.player.skill.exp.exp +import world.gregs.voidps.engine.entity.item.Item +import world.gregs.voidps.engine.inv.Inventory +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.engine.inv.remove +import world.gregs.voidps.engine.inv.sendInventory +import world.gregs.voidps.type.random + +/** + * Random event gift: the reward box every random event hands out. Opening it deals one reward per + * category - coins scaled to the player's total level, a rune, coal, essence by Mining level, an + * ore, bar, gem, herb, seed, charm, an experience lamp, an oddment and a mystery box - into the gift's own inventory, + * alongside the emote unlock and save-up-for-a-costume choices, shown on the "Choose Your Reward!" + * interface (202). The choices keep until the player selects one and confirms, consuming the gift. + * https://runescape.wiki/w/Random_event_gift + */ +class RandomEventGift : Script { + + init { + itemOption("Open", "random_event_gift") { + if (inCombat) { + message("You won't be able to choose a reward during combat.") + return@itemOption + } + if (gift.isEmpty()) { + fillGift() + } + set("gift_selected", 0) + // The interface's own load script draws the reward grid from the gift inventory, so it + // only needs the container contents up front and click masks - no options script. + sendInventory(gift) + open("random_event_gift_select") + } + + interfaceOpened("random_event_gift_select") { id -> + interfaceOptions.unlockAll(id, "rewards", 0 until gift.size * 8) + interfaceOptions.unlockAll(id, "confirm", 0 until gift.size * 8) + } + + interfaceOption("Select", "random_event_gift_select:rewards") { + // The grid packs seven sub-components per reward and doesn't attach the item to clicks + val slot = if (it.item.isEmpty()) it.itemSlot / 7 else gift.indexOf(it.item.id) + // The emote and costume cells are drawn by the interface itself, so their inventory + // slots stay empty + if (slot == EMOTE_SLOT || slot == COSTUME_SLOT || (slot in gift.indices && gift[slot].isNotEmpty())) { + set("gift_selected", slot + 1) + } + } + + interfaceOption("Confirm", "random_event_gift_select:confirm") { + confirm() + } + + itemOption("Rub", "lamp") { (item, slot) -> + val skill = skillLamp() + // Constitution is measured in tenths internally (level 10 = 100) + val level = if (skill == Skill.Constitution) levels.getMax(skill) / 10 else levels.getMax(skill) + val experience = level * XP_PER_LEVEL + if (inventory.remove(slot, item.id)) { + exp(skill, experience) + statement("Your wish has been granted!
You have been awarded ${experience.toInt()} ${skill.name} experience!") + } + } + } + + private val Player.gift: Inventory + get() = inventories.inventory("random_event_gift") + + /** + * Deal one reward into each category's slot; the arrangement mirrors the official interface + * layout with coins first and the surprises at the bottom. + */ + private fun Player.fillGift() { + val mining = levels.getMax(Skill.Mining) + gift.transaction { + set(COINS_SLOT, Item("coins", coins())) + set(RUNE_SLOT, roll("gift_runes")) + set(COAL_SLOT, Item("coal", random.nextInt(3, 31))) + set( + ESSENCE_SLOT, + if (mining >= 30) { + Item("pure_essence", random.nextInt(18, 47)) + } else { + Item("rune_essence", random.nextInt(14, 22)) + }, + ) + set(ORE_SLOT, roll("gift_ores")) + set(BAR_SLOT, roll("gift_bars")) + set(GEM_SLOT, roll("gift_gems")) + set(HERB_SLOT, roll("gift_herbs")) + set(SEED_SLOT, roll("gift_seeds")) + set(CHARM_SLOT, roll("gift_charms")) + set(LAMP_SLOT, Item("lamp")) + set(OTHER_SLOT, roll("gift_other")) + set(SURPRISE_SLOT, Item("mystery_box")) + // The grid only draws cells whose slot holds an item; these two stand in for the + // "Unlock emote!" and "Save up for a costume!" choices and are never granted. + set(EMOTE_SLOT, Item("mime_mask")) + set(COSTUME_SLOT, Item("zombie_mask")) + } + } + + /** https://runescape.wiki/w/Random_event_gift - coins scale to a third of the total level. */ + private fun Player.coins(): Int { + val total = Skill.all.sumOf { if (it == Skill.Constitution) levels.getMax(it) / 10 else levels.getMax(it) } + return (total * 0.33).toInt().coerceAtLeast(30) + } + + private fun roll(table: String): Item { + val rows = Tables.get(table).rows() + var roll = random.nextInt(rows.sumOf { it.int("weight") }) + val row = rows.first { + roll -= it.int("weight") + roll < 0 + } + return Item(row.item("item"), random.nextInt(row.int("min"), row.int("max") + 1)) + } + + private fun Player.confirm() { + val slot = get("gift_selected", 0) - 1 + if (slot < 0) { + message("Choose a reward first.") + return + } + if (slot == EMOTE_SLOT) { + unlockEmote() + return + } + if (slot == COSTUME_SLOT) { + saveForCostume() + return + } + if (slot !in gift.indices) { + return + } + val reward = gift[slot] + if (reward.isEmpty() || !inventory.remove("random_event_gift")) { + return + } + inventories.clear("random_event_gift") + // Unstackable rewards come noted so a multi-item prize doesn't flood the inventory + val prize = if (reward.def.stackable != 1) reward.noted ?: reward else reward + addOrDrop(prize.id, prize.amount) + close("random_event_gift_select") + message("Enjoy your gift.") + } + + private fun Player.saveForCostume() { + if (!inventory.remove("random_event_gift")) { + return + } + val points = inc("costume_points") + inventories.clear("random_event_gift") + close("random_event_gift_select") + message("Enjoy your gift.") + message("You save up for a costume; you now have $points costume point${if (points == 1) "" else "s"}.") + } + + private fun Player.unlockEmote() { + val locked = EMOTES.filter { !get("unlocked_emote_$it", false) } + if (locked.isEmpty()) { + message("You've already unlocked all of the emotes.") + return + } + if (!inventory.remove("random_event_gift")) { + return + } + val emote = locked.random(random) + set("unlocked_emote_$emote", true) + inventories.clear("random_event_gift") + close("random_event_gift_select") + message("Enjoy your gift.") + message("You've unlocked the ${emote.replace('_', ' ')} emote!") + } + + companion object { + // The interface's category slots, matching the official layout. + private const val COINS_SLOT = 0 + private const val RUNE_SLOT = 1 + private const val COAL_SLOT = 2 + private const val ESSENCE_SLOT = 3 + private const val ORE_SLOT = 4 + private const val BAR_SLOT = 5 + private const val GEM_SLOT = 6 + private const val HERB_SLOT = 7 + private const val SEED_SLOT = 8 + private const val CHARM_SLOT = 9 + private const val LAMP_SLOT = 10 + + // A genie lamp grants ten experience per level in the chosen skill + private const val XP_PER_LEVEL = 10.0 + + private const val OTHER_SLOT = 24 + private const val SURPRISE_SLOT = 25 + private const val EMOTE_SLOT = 26 + private const val COSTUME_SLOT = 27 + + // The random event emotes handed out by the emote choice. + private val EMOTES = listOf("idea", "stomp", "flap", "slap_head") + } +} diff --git a/game/src/main/kotlin/content/activity/event/random/RandomEventRewards.kt b/game/src/main/kotlin/content/activity/event/random/RandomEventRewards.kt deleted file mode 100644 index 0639847973..0000000000 --- a/game/src/main/kotlin/content/activity/event/random/RandomEventRewards.kt +++ /dev/null @@ -1,38 +0,0 @@ -package content.activity.event.random - -import content.entity.player.bank.ownsItem -import content.entity.player.inv.item.addOrDrop -import world.gregs.voidps.engine.data.definition.Tables -import world.gregs.voidps.engine.entity.character.player.Player -import world.gregs.voidps.type.random - -/** - * "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) { - addOrDrop(missing) - } else { - addOrDrop("coins", coins) - } -} - -/** - * Award a single weighted roll of a random event loot table, e.g. the shared `random_event_certer` - * gem/coin table (Certer, Pillory) or `random_event_prison_pete`. - */ -fun Player.rewardEventLoot(table: String) { - val rows = Tables.get(table).rows() - var roll = random.nextInt(rows.sumOf { it.int("weight") }) - for (row in rows) { - roll -= row.int("weight") - if (roll < 0) { - addOrDrop(row.item("item"), random.nextInt(row.int("min"), row.int("max") + 1)) - return - } - } -} diff --git a/game/src/main/kotlin/content/activity/event/random/certer/Certer.kt b/game/src/main/kotlin/content/activity/event/random/certer/Certer.kt index e23eb130c5..fde0a1a8be 100644 --- a/game/src/main/kotlin/content/activity/event/random/certer/Certer.kt +++ b/game/src/main/kotlin/content/activity/event/random/certer/Certer.kt @@ -1,11 +1,11 @@ package content.activity.event.random.certer import content.activity.event.random.RandomEvents -import content.activity.event.random.rewardEventLoot import content.activity.event.random.startInPlaceEvent import content.entity.player.dialogue.Happy import content.entity.player.dialogue.Neutral import content.entity.player.dialogue.type.npc +import content.entity.player.inv.item.addOrDrop import world.gregs.voidps.engine.Script import world.gregs.voidps.engine.client.message import world.gregs.voidps.engine.client.ui.close @@ -17,7 +17,7 @@ import world.gregs.voidps.type.random /** * Certer random event: one of the certificate brothers appears beside the player and nags until * spoken to. He shows an item (interface 184) and three descriptions to choose from; picking the - * one that matches the item earns a roll of the gem/coin reward table, a wrong pick earns nothing. + * one that matches the item earns a random event gift, a wrong pick earns nothing. * Ignoring him applies the note-and-teleport penalty. * * The interface builds itself from varbits: its load script reads the three option descriptions @@ -43,7 +43,7 @@ class Certer : Script { close("certer_identify") if (correct) { npc("giles", "Thank you, I hope you like your present. I must be leaving now though.") - rewardEventLoot("random_event_certer") + addOrDrop("random_event_gift") } else { npc("giles", "Sorry, I don't think so.") } diff --git a/game/src/main/kotlin/content/activity/event/random/drill_demon/DrillDemon.kt b/game/src/main/kotlin/content/activity/event/random/drill_demon/DrillDemon.kt index e08d42a5af..e84549ad73 100644 --- a/game/src/main/kotlin/content/activity/event/random/drill_demon/DrillDemon.kt +++ b/game/src/main/kotlin/content/activity/event/random/drill_demon/DrillDemon.kt @@ -3,11 +3,11 @@ package content.activity.event.random.drill_demon import content.activity.event.random.RandomEvents import content.activity.event.random.kidnap import content.activity.event.random.mysteriousOldMan -import content.activity.event.random.rewardCostumeOrCoins import content.entity.gfx.areaGfx import content.entity.player.dialogue.Neutral import content.entity.player.dialogue.type.item import content.entity.player.dialogue.type.npc +import content.entity.player.inv.item.addOrDrop import world.gregs.voidps.engine.Script import world.gregs.voidps.engine.client.message import world.gregs.voidps.engine.entity.character.player.Player @@ -20,7 +20,7 @@ import world.gregs.voidps.type.random /** * Drill Demon random event: Sergeant Damien whisks the player to his exercise yard and barks out an * exercise. Each of the four mats shows one exercise on its sign (a varbit); the player must use the - * mat matching the order. Four correct exercises earns a piece of the camouflage outfit (or coins). + * mat matching the order. Four correct exercises earns a random event gift. * https://runescape.wiki/w/Random_events?oldid=3667851#Drill_Demon */ class DrillDemon : Script { @@ -108,7 +108,7 @@ class DrillDemon : Script { private suspend fun Player.finish() { npc("sergeant_damien", "Well I'll be, you actually did it $name. Now take this and get yourself out of my sight.", largeHead = true) - rewardCostumeOrCoins("camo_helmet", "camo_top", "camo_bottoms", coins = 500) + addOrDrop("random_event_gift") clear("drill_demon_ready") clear("drill_demon_task") clear("drill_demon_correct") diff --git a/game/src/main/kotlin/content/activity/event/random/evil_bob/EvilBob.kt b/game/src/main/kotlin/content/activity/event/random/evil_bob/EvilBob.kt index 82de1d20b5..e77991389e 100644 --- a/game/src/main/kotlin/content/activity/event/random/evil_bob/EvilBob.kt +++ b/game/src/main/kotlin/content/activity/event/random/evil_bob/EvilBob.kt @@ -13,6 +13,7 @@ import content.entity.player.dialogue.type.item 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.instanceOffset import content.quest.setInstanceLogout import content.quest.smallInstance @@ -25,8 +26,6 @@ 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.name -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.obj.GameObjects import world.gregs.voidps.engine.inv.add import world.gregs.voidps.engine.inv.inventory @@ -40,7 +39,7 @@ import world.gregs.voidps.type.random * island and demands to be fed. A terrified servant hints which of the four fishing spots holds the * fish Bob likes; the player nets it (it comes out already cooked), uncooks it at the cold fire, and * serves the raw fish to Bob. The right fish sends him to sleep and opens the exit portal; a wrong - * one makes him more attentive so an extra correct fish is needed. Reward: 650 Fishing or Magic XP. + * one makes him more attentive so an extra correct fish is needed. Reward: a random event gift. * https://runescape.wiki/w/Random_events?oldid=3667851#Evil_Bob */ class EvilBob : Script { @@ -301,13 +300,7 @@ class EvilBob : Script { } private fun Player.reward() { - if (levels.getMax(Skill.Magic) > 50 && random.nextBoolean()) { - exp(Skill.Magic, REWARD_XP) - message("You feel somehow that you've become better at magic.") - } else { - exp(Skill.Fishing, REWARD_XP) - message("You feel somehow that you've become better at fishing.") - } + addOrDrop("random_event_gift") } private fun Player.clearState() { @@ -341,7 +334,6 @@ class EvilBob : Script { companion object { private const val ISLAND_REGION = 13642 - private const val REWARD_XP = 650.0 private val ISLAND = Tile(3419, 4776) private val BOB_TILE = Tile(3420, 4777) private val SERVANT_TILE = Tile(3423, 4777) diff --git a/game/src/main/kotlin/content/activity/event/random/evil_twin/EvilTwin.kt b/game/src/main/kotlin/content/activity/event/random/evil_twin/EvilTwin.kt index 01f3261751..c947f93e57 100644 --- a/game/src/main/kotlin/content/activity/event/random/evil_twin/EvilTwin.kt +++ b/game/src/main/kotlin/content/activity/event/random/evil_twin/EvilTwin.kt @@ -2,7 +2,6 @@ package content.activity.event.random.evil_twin import content.activity.event.random.RandomEvents import content.activity.event.random.kidnap -import content.activity.event.random.rewardEventLoot import content.entity.effect.clearTransform import content.entity.effect.transform import content.entity.player.dialogue.Angry @@ -13,6 +12,7 @@ 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.entity.player.modal.Tab import content.entity.player.modal.tab import content.quest.closeTabs @@ -52,8 +52,8 @@ import world.gregs.voidps.type.random * Evil Twin random event: Molly appears beside the player, pleads for help and whisks them away to * her house, where her evil twin sister hides in a pen of lookalike civilians next door. The * player drives a giant mechanical claw from the control panel (interface 240) and has two tries - * to grab the sister - she's the one dressed exactly like Molly. Success earns a roll of the uncut - * gem table; running out of tries gets the player thrown out with nothing. + * to grab the sister - she's the one dressed exactly like Molly. Success earns a random event + * gift; running out of tries gets the player thrown out with nothing. * https://runescape.wiki/w/Random_events?oldid=3667851#Evil_Twin */ class EvilTwin : Script { @@ -323,7 +323,7 @@ class EvilTwin : Script { player("Well, I've managed to get her into the cage.") npc(mollyId(), "Fantastic! For so many years I've had to put up with her and now she's locked up for good.") npc(mollyId(), "Thank you for all your help. Take this as a reward.") - rewardEventLoot("random_event_evil_twin") + addOrDrop("random_event_gift") clearState() openTabs() clearMinimap() 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 index 6c6534c4d8..a69b2c14cd 100644 --- 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 @@ -2,12 +2,11 @@ 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 content.entity.player.inv.item.addOrDrop import world.gregs.voidps.engine.Script import world.gregs.voidps.engine.client.message import world.gregs.voidps.engine.entity.character.player.Player @@ -21,7 +20,7 @@ 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). + * pheasant; handing it back rewards a random event gift. * https://runescape.wiki/w/Random_events?oldid=3667851#Freaky_Forester */ class FreakyForester : Script { @@ -93,13 +92,8 @@ class FreakyForester : Script { } 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) + npc("freaky_forester", "Please take this gift as a reward for your help, many thanks!") + addOrDrop("random_event_gift") } private fun Player.carriesRawPheasant() = inventory.contains("raw_pheasant") || inventory.contains("raw_pheasant_incorrect") diff --git a/game/src/main/kotlin/content/activity/event/random/gravedigger/Gravedigger.kt b/game/src/main/kotlin/content/activity/event/random/gravedigger/Gravedigger.kt index b632d75bf3..63079afcc3 100644 --- a/game/src/main/kotlin/content/activity/event/random/gravedigger/Gravedigger.kt +++ b/game/src/main/kotlin/content/activity/event/random/gravedigger/Gravedigger.kt @@ -2,13 +2,13 @@ package content.activity.event.random.gravedigger import content.activity.event.random.RandomEvents import content.activity.event.random.kidnap -import content.activity.event.random.rewardCostumeOrCoins import content.entity.player.dialogue.Happy import content.entity.player.dialogue.Neutral import content.entity.player.dialogue.Sad import content.entity.player.dialogue.type.choice import content.entity.player.dialogue.type.npc import content.entity.player.dialogue.type.player +import content.entity.player.inv.item.addOrDrop import content.entity.player.modal.Tab import content.entity.player.modal.tab import content.quest.instance @@ -41,8 +41,8 @@ import world.gregs.voidps.type.random * where he's buried five coffins in the wrong graves. Checking a coffin shows the belongings of * whoever's inside and each gravestone shows the profession of whoever ought to lie beneath it; * the player digs the coffins up and reburies each in its proper grave, banking spare items at - * the mausoleum. Telling Leo it's done when all five match earns a piece of the zombie outfit - * (or 500 coins) and the zombie emotes; he'll also take anyone home who'd rather give up. + * the mausoleum. Telling Leo it's done when all five match earns a random event gift + * and the zombie emotes; he'll also take anyone home who'd rather give up. * https://runescape.wiki/w/Random_events?oldid=3667851#Gravedigger */ class Gravedigger : Script { @@ -240,7 +240,7 @@ class Gravedigger : Script { private suspend fun Player.success() { npc("leo_gravedigger", "Wonderful! That's taken care of all of them.") npc("leo_gravedigger", "Here, I'll take you back to where I found you, and give you your reward.") - rewardCostumeOrCoins("zombie_mask", "zombie_shirt", "zombie_trousers", "zombie_gloves", "zombie_boots", coins = 500) + addOrDrop("random_event_gift") set("unlocked_emote_zombie_walk", true) set("unlocked_emote_zombie_dance", true) exitGraveyard() diff --git a/game/src/main/kotlin/content/activity/event/random/lost_and_found/LostAndFound.kt b/game/src/main/kotlin/content/activity/event/random/lost_and_found/LostAndFound.kt index a8ef0a0c2e..b222a4f50e 100644 --- a/game/src/main/kotlin/content/activity/event/random/lost_and_found/LostAndFound.kt +++ b/game/src/main/kotlin/content/activity/event/random/lost_and_found/LostAndFound.kt @@ -22,8 +22,7 @@ import world.gregs.voidps.type.random * Lost and Found random event: a magic spellbook teleport can fault, dropping the player onto the * Abyssal plane where four strange appendages surround them. Three appendages share the same shape; * operating the odd one out forwards the player back to where they came from, while a wrong pick - * reshuffles the shapes and drains some Magic. Players snatched from the rune essence mine receive - * a handful of essence as recompense. + * reshuffles the shapes and drains some Magic. Escaping earns a random event gift. * https://runescape.wiki/w/Random_events?oldid=3667851#Lost_and_Found */ class LostAndFound : Script { @@ -93,18 +92,11 @@ class LostAndFound : Script { statement("The Abyssal Services Department apologises for the inconvenience.", clickToContinue = false) } - /** Players taken from the rune essence mine are compensated with a handful of essence. */ private fun Player.reward() { - val origin = Tile(this["random_event_origin", tile.id]) - if (origin.region.id != ESSENCE_MINE_REGION) { - return - } - val essence = if (levels.getMax(Skill.Mining) > 30) "pure_essence" else "rune_essence" - addOrDrop(essence, random.nextInt(8, 37)) + addOrDrop("random_event_gift") } companion object { - private const val ESSENCE_MINE_REGION = 11595 private val PLANE = Tile(2332, 4770) // The four appendage tiles, in the same order as the lost_and_found_appendage_1-4 varbits. 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 7b3f46f946..f412ec6543 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 @@ -23,7 +23,6 @@ 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.obj.replace import world.gregs.voidps.engine.inv.add import world.gregs.voidps.engine.timer.Timer @@ -32,8 +31,7 @@ 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. + * Maze random event; reach the shrine at the centre before the time runs out for a random event gift. * https://runescape.wiki/w/Maze */ class Maze : Script { @@ -80,7 +78,6 @@ class Maze : Script { } 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. @@ -89,7 +86,7 @@ class Maze : Script { delay(2) anim("emote_cheer") delay(2) - reward(this, ticks) + addOrDrop("random_event_gift") RandomEvents.complete(this) jingle("maze_complete") } @@ -144,17 +141,6 @@ class Maze : Script { 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") - player.addOrDrop(item, amount) - } - companion object { private const val MAZE_REGION = 11591 private const val MAX_CHESTS = 10 diff --git a/game/src/main/kotlin/content/activity/event/random/mime_theatre/Mime.kt b/game/src/main/kotlin/content/activity/event/random/mime_theatre/Mime.kt index 701cf13801..4828e62c8d 100644 --- a/game/src/main/kotlin/content/activity/event/random/mime_theatre/Mime.kt +++ b/game/src/main/kotlin/content/activity/event/random/mime_theatre/Mime.kt @@ -3,10 +3,10 @@ package content.activity.event.random.mime_theatre import content.activity.event.random.RandomEvents import content.activity.event.random.kidnap import content.activity.event.random.mysteriousOldMan -import content.activity.event.random.rewardCostumeOrCoins import content.entity.player.dialogue.Neutral import content.entity.player.dialogue.type.npc import content.entity.player.dialogue.type.statement +import content.entity.player.inv.item.addOrDrop import content.skill.magic.jewellery.teleport import world.gregs.voidps.engine.Script import world.gregs.voidps.engine.client.ui.close @@ -24,7 +24,7 @@ import world.gregs.voidps.type.random * Mime random event: the Mysterious Old Man drops the player into the theatre where the Mime performs * emotes under a spotlight. After each performance the Mime bows to the player, who copies it by * picking the matching emote on the mime interface (188). Three correct copies unlock the mime emotes - * and award a piece of the mime costume, then the player is teleported home. + * and award a random event gift, then the player is teleported home. * https://runescape.wiki/w/Random_events?oldid=3667851#Mime */ class Mime : Script { @@ -122,7 +122,7 @@ class Mime : Script { for (unlock in MIME_EMOTES) { set("unlocked_emote_$unlock", true) } - rewardCostumeOrCoins("mime_mask", "mime_top", "mime_legs", "mime_gloves", "mime_boots", coins = 500) + addOrDrop("random_event_gift") clear("mime_emote") clear("mime_correct") diff --git a/game/src/main/kotlin/content/activity/event/random/pillory/Pillory.kt b/game/src/main/kotlin/content/activity/event/random/pillory/Pillory.kt index 65d5a5bafb..7375cb7deb 100644 --- a/game/src/main/kotlin/content/activity/event/random/pillory/Pillory.kt +++ b/game/src/main/kotlin/content/activity/event/random/pillory/Pillory.kt @@ -2,7 +2,7 @@ package content.activity.event.random.pillory import content.activity.event.random.RandomEvents import content.activity.event.random.kidnap -import content.activity.event.random.rewardEventLoot +import content.entity.player.inv.item.addOrDrop import world.gregs.voidps.engine.Script import world.gregs.voidps.engine.client.message import world.gregs.voidps.engine.client.ui.close @@ -18,7 +18,7 @@ import kotlin.math.min * Pillory random event: a guard arrests the player and locks them in a pillory. To escape they * unlock it (interface 189) by picking the swinging key whose shape matches the spinning lock, a set * number of times in a row. A wrong pick resets the streak and raises the target (up to six). Escaping - * rewards a single roll of the shared gem/coin table. + * rewards a random event gift. * https://runescape.wiki/w/Random_events?oldid=3667851#Pillory */ class Pillory : Script { @@ -98,7 +98,7 @@ class Pillory : Script { private fun Player.escape() { close("pillory_lock") message("You've escaped!") - rewardEventLoot("random_event_certer") + addOrDrop("random_event_gift") clear("pillory_target") clear("pillory_correct") clear("pillory_answer") diff --git a/game/src/main/kotlin/content/activity/event/random/pinball/Pinball.kt b/game/src/main/kotlin/content/activity/event/random/pinball/Pinball.kt index 93b34aa25b..fa45edfe72 100644 --- a/game/src/main/kotlin/content/activity/event/random/pinball/Pinball.kt +++ b/game/src/main/kotlin/content/activity/event/random/pinball/Pinball.kt @@ -178,8 +178,7 @@ class Pinball : Script { } private fun Player.reward() { - val gem = REWARDS.random(random) - addOrDrop(gem, if (gem == "uncut_diamond") 2 else (3..5).random(random)) + addOrDrop("random_event_gift") } /** Reset the previous flashing post and start a new random one flashing; the target is 1-based. */ diff --git a/game/src/main/kotlin/content/activity/event/random/prison_pete/PrisonPete.kt b/game/src/main/kotlin/content/activity/event/random/prison_pete/PrisonPete.kt index 52ed2f980d..5b1a28223c 100644 --- a/game/src/main/kotlin/content/activity/event/random/prison_pete/PrisonPete.kt +++ b/game/src/main/kotlin/content/activity/event/random/prison_pete/PrisonPete.kt @@ -2,7 +2,6 @@ package content.activity.event.random.prison_pete import content.activity.event.random.RandomEvents import content.activity.event.random.kidnap -import content.activity.event.random.rewardEventLoot import content.entity.player.dialogue.Angry import content.entity.player.dialogue.Happy import content.entity.player.dialogue.Neutral @@ -11,6 +10,7 @@ import content.entity.player.dialogue.Sad import content.entity.player.dialogue.type.choice import content.entity.player.dialogue.type.npc import content.entity.player.dialogue.type.player +import content.entity.player.inv.item.addOrDrop import world.gregs.voidps.engine.Script import world.gregs.voidps.engine.client.message import world.gregs.voidps.engine.client.ui.open @@ -33,7 +33,7 @@ import world.gregs.voidps.type.random * Prison Pete random event: Evil Bob's cat throws the player into the ScapeRune prison alongside * Pete. Pulling the big lever shows one of the four balloon animal shapes; popping a balloon of that * shape yields a key that opens one of the door's locks, while popping the wrong shape yields a key - * that doesn't fit. Hand Pete three correct keys to unlock the door, escape and receive a reward. + * that doesn't fit. Hand Pete three correct keys to unlock the door, escape and receive a random event gift. * https://runescape.wiki/w/Random_events?oldid=3667851#Prison_Pete */ class PrisonPete : Script { @@ -214,7 +214,7 @@ class PrisonPete : Script { walkToDelay(gate.tile.addY(-1), forceWalk = true) message("You quickly escape the prison with Pete.") npc("prison_pete", "Thanks a lot for your help! Here, have a present:") - rewardEventLoot("random_event_prison_pete") + addOrDrop("random_event_gift") player("Thanks! See you around!") while (inventory.remove("prison_key_prison_pete")) { // strip any dud keys so none survive the trip home diff --git a/game/src/main/kotlin/content/activity/event/random/quiz_master/QuizMaster.kt b/game/src/main/kotlin/content/activity/event/random/quiz_master/QuizMaster.kt index a0ef9f1090..e645bb785b 100644 --- a/game/src/main/kotlin/content/activity/event/random/quiz_master/QuizMaster.kt +++ b/game/src/main/kotlin/content/activity/event/random/quiz_master/QuizMaster.kt @@ -5,7 +5,6 @@ import content.activity.event.random.kidnap import content.entity.player.dialogue.Confused import content.entity.player.dialogue.Goofy import content.entity.player.dialogue.Hysterics -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.inv.item.addOrDrop @@ -13,7 +12,6 @@ import world.gregs.voidps.engine.Script 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.definition.Tables 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 @@ -25,8 +23,8 @@ import world.gregs.voidps.type.random /** * Quiz Master random event: the Quiz Master whisks the player to his studio for a game of * "Odd One Out". Three golden models are shown on interface 191; the player picks the one that - * doesn't belong with the other two. Four correct answers win a choice of 1000 coins or a random - * item. Wrong answers cost nothing but a turn. + * doesn't belong with the other two. Four correct answers win a random event + * gift. Wrong answers cost nothing but a turn. * https://runescape.wiki/w/Random_events?oldid=3667851#Quiz_Master */ class QuizMaster : Script { @@ -97,14 +95,8 @@ class QuizMaster : Script { } private suspend fun Player.win() { - npc("quiz_master", "CONGRATULATIONS! You are a WINNER! Please choose your PRIZE!") - choice { - option("1000 Coins") { addOrDrop("coins", 1000) } - option("Random Item") { - val row = Tables.get("random_event_quiz").rows().random(random) - addOrDrop(row.item("item"), row.int("amount")) - } - } + npc("quiz_master", "CONGRATULATIONS! You are a WINNER! Please take your PRIZE!") + addOrDrop("random_event_gift") clearAnim() // stand up out of the contestant's seat clear("quiz_answer") clear("quiz_correct") diff --git a/game/src/main/kotlin/content/activity/event/random/sandwich_lady/SandwichLady.kt b/game/src/main/kotlin/content/activity/event/random/sandwich_lady/SandwichLady.kt index b3ae1ab135..7b12997ce2 100644 --- a/game/src/main/kotlin/content/activity/event/random/sandwich_lady/SandwichLady.kt +++ b/game/src/main/kotlin/content/activity/event/random/sandwich_lady/SandwichLady.kt @@ -82,6 +82,7 @@ class SandwichLady : Script { private suspend fun Player.serve(food: String) { message("The sandwich lady gives you a ${description(food)}!") addOrDrop(food) + addOrDrop("random_event_gift") npc("sandwich_lady", "Hope that fills you up!") // Clearing the event state removes the following NPC on its next tick. RandomEvents.completeInPlace(this) diff --git a/game/src/main/kotlin/content/activity/event/random/surprise_exam/SurpriseExam.kt b/game/src/main/kotlin/content/activity/event/random/surprise_exam/SurpriseExam.kt index 0ae5efe5ee..2d45ca4f03 100644 --- a/game/src/main/kotlin/content/activity/event/random/surprise_exam/SurpriseExam.kt +++ b/game/src/main/kotlin/content/activity/event/random/surprise_exam/SurpriseExam.kt @@ -25,7 +25,7 @@ import world.gregs.voidps.type.random * Surprise Exam random event: the Mysterious Old Man teleports the player into Mr Mordaut's classroom. * Talking to Mordaut opens a "what comes next?" pattern quiz (interface 103) - pick the item that * belongs with the three shown. Three correct answers pass the exam; Mordaut then names one of four - * coloured doors, and leaving through it returns the player with a Book of Knowledge (an XP lamp). + * coloured doors, and leaving through it returns the player with a random event gift. * https://runescape.wiki/w/Random_events?oldid=3667851#Surprise_Exam */ class SurpriseExam : Script { @@ -125,7 +125,7 @@ class SurpriseExam : Script { private fun Player.finish() { message("You've passed the exam!") - addOrDrop("book_of_knowledge") + addOrDrop("random_event_gift") clear("surprise_exam_answer") clear("surprise_exam_correct") clear("surprise_exam_door") diff --git a/game/src/test/kotlin/content/activity/event/random/DrillDemonTest.kt b/game/src/test/kotlin/content/activity/event/random/DrillDemonTest.kt index be54c6a3ec..fdf496c080 100644 --- a/game/src/test/kotlin/content/activity/event/random/DrillDemonTest.kt +++ b/game/src/test/kotlin/content/activity/event/random/DrillDemonTest.kt @@ -8,7 +8,6 @@ import world.gregs.voidps.engine.client.ui.dialogue import world.gregs.voidps.engine.entity.character.player.Player import world.gregs.voidps.engine.entity.obj.GameObject import world.gregs.voidps.engine.entity.obj.GameObjects -import world.gregs.voidps.engine.inv.add import world.gregs.voidps.engine.inv.inventory import world.gregs.voidps.type.Tile import kotlin.test.assertEquals @@ -104,24 +103,8 @@ class DrillDemonTest : WorldTest() { while (player.dialogue != null) player.skipDialogues() // Damien's closing line, then reward tick(2) - assertEquals(1, player.inventory.count("camo_helmet")) + assertEquals(1, player.inventory.count("random_event_gift")) assertNull(player.get("random_event")) assertEquals(origin, player.tile) } - - @Test - fun `Owning the full camo set rewards coins`() { - val player = enter("dd_coins") - player.inventory.add("camo_helmet") - player.inventory.add("camo_top") - player.inventory.add("camo_bottoms") - player.layout(task = 1) - val mats = mats() - - repeat(4) { useCorrectMat(player, mats) } - while (player.dialogue != null) player.skipDialogues() - tick(2) - - assertEquals(500, player.inventory.count("coins")) - } } diff --git a/game/src/test/kotlin/content/activity/event/random/EvilBobTest.kt b/game/src/test/kotlin/content/activity/event/random/EvilBobTest.kt index 8a6c095b81..fdf4dd5738 100644 --- a/game/src/test/kotlin/content/activity/event/random/EvilBobTest.kt +++ b/game/src/test/kotlin/content/activity/event/random/EvilBobTest.kt @@ -12,7 +12,6 @@ import world.gregs.voidps.engine.client.ui.dialogue 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.skill.Skill import world.gregs.voidps.engine.entity.obj.GameObject import world.gregs.voidps.engine.entity.obj.GameObjects import world.gregs.voidps.engine.inv.add @@ -112,7 +111,7 @@ class EvilBobTest : WorldTest() { } @Test - fun `Serving the correct raw fish then leaving rewards 650 Fishing XP and returns the player`() { + fun `Serving the correct raw fish then leaving rewards a gift and returns the player`() { val player = enter("eb_finish") player.inventory.add("raw_fish_like_thing") player.serve() @@ -127,7 +126,7 @@ class EvilBobTest : WorldTest() { while (player.dialogue != null) player.skipDialogues() tick(2) - assertEquals(650.0, player.experience.get(Skill.Fishing)) + assertEquals(1, player.inventory.count("random_event_gift")) assertNull(player.get("random_event")) assertEquals(origin, player.tile) assertTrue(player.contains("random_event_cooldown")) diff --git a/game/src/test/kotlin/content/activity/event/random/FreakyForesterTest.kt b/game/src/test/kotlin/content/activity/event/random/FreakyForesterTest.kt index 7a5bd759c4..8b1f4494cf 100644 --- a/game/src/test/kotlin/content/activity/event/random/FreakyForesterTest.kt +++ b/game/src/test/kotlin/content/activity/event/random/FreakyForesterTest.kt @@ -67,7 +67,7 @@ class FreakyForesterTest : WorldTest() { } @Test - fun `Handing in the correct pheasant rewards a costume piece and returns the player`() { + fun `Handing in the correct pheasant rewards a gift and returns the player`() { val origin = Tile(3221, 3218) val player = startInClearing("ff_handin", task = 3, origin = origin) player.inventory.add("raw_pheasant") @@ -78,7 +78,7 @@ class FreakyForesterTest : WorldTest() { player.skipDialogues() tick(2) - assertEquals(1, player.inventory.count("lederhosen_hat")) + assertEquals(1, player.inventory.count("random_event_gift")) assertFalse(player.inventory.contains("raw_pheasant")) assertNull(player.get("random_event")) assertEquals(origin, player.tile) @@ -99,23 +99,6 @@ class FreakyForesterTest : WorldTest() { 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) diff --git a/game/src/test/kotlin/content/activity/event/random/GravediggerTest.kt b/game/src/test/kotlin/content/activity/event/random/GravediggerTest.kt index b82abb9233..0449a57868 100644 --- a/game/src/test/kotlin/content/activity/event/random/GravediggerTest.kt +++ b/game/src/test/kotlin/content/activity/event/random/GravediggerTest.kt @@ -121,7 +121,7 @@ class GravediggerTest : WorldTest() { } player.claimFinished() - assertTrue(player.inventory.contains("zombie_mask"), "Expected the first zombie outfit piece") + assertTrue(player.inventory.contains("random_event_gift"), "Expected a random event gift") assertTrue(sites.indices.none { player.inventory.contains(coffinName(it)) }, "Expected Leo to keep his coffins") assertTrue(player.get("unlocked_emote_zombie_walk", false)) assertTrue(player.get("unlocked_emote_zombie_dance", false)) diff --git a/game/src/test/kotlin/content/activity/event/random/LostAndFoundTest.kt b/game/src/test/kotlin/content/activity/event/random/LostAndFoundTest.kt index 737874dda1..738390b720 100644 --- a/game/src/test/kotlin/content/activity/event/random/LostAndFoundTest.kt +++ b/game/src/test/kotlin/content/activity/event/random/LostAndFoundTest.kt @@ -81,16 +81,6 @@ class LostAndFoundTest : WorldTest() { assertNull(player.get("random_event")) assertEquals(origin, player.tile) assertTrue(player.contains("random_event_cooldown")) - assertTrue(player.inventory.isEmpty()) - } - - @Test - fun `Players taken from the essence mine receive essence`() { - val player = enter("laf_essence", tile = Tile(2911, 4832)) - - player.operate(player.oddTile()) - - assertEquals(Tile(2911, 4832), player.tile) - assertTrue(player.inventory.count("rune_essence") >= 8) + assertEquals(1, player.inventory.count("random_event_gift")) } } diff --git a/game/src/test/kotlin/content/activity/event/random/MazeTest.kt b/game/src/test/kotlin/content/activity/event/random/MazeTest.kt index b70f7caa5b..a3e1680527 100644 --- a/game/src/test/kotlin/content/activity/event/random/MazeTest.kt +++ b/game/src/test/kotlin/content/activity/event/random/MazeTest.kt @@ -37,7 +37,7 @@ class MazeTest : WorldTest() { assertEquals(Tile(3221, 3218), player.tile) assertNull(player.get("random_event")) assertTrue(player.contains("random_event_cooldown")) - assertTrue(player.inventory.count("coins") > 0) + assertEquals(1, player.inventory.count("random_event_gift")) } @Test diff --git a/game/src/test/kotlin/content/activity/event/random/MimeTest.kt b/game/src/test/kotlin/content/activity/event/random/MimeTest.kt index 2a3ddbdbfd..fe5bd345b1 100644 --- a/game/src/test/kotlin/content/activity/event/random/MimeTest.kt +++ b/game/src/test/kotlin/content/activity/event/random/MimeTest.kt @@ -57,7 +57,7 @@ class MimeTest : WorldTest() { } @Test - fun `Copying three emotes unlocks the emotes, rewards a costume and returns the player`() { + fun `Copying three emotes unlocks the emotes, rewards a gift and returns the player`() { val player = enter("mime_finish") repeat(3) { @@ -66,7 +66,7 @@ class MimeTest : WorldTest() { } tickIf { player.tile != origin } // wait out the modern teleport home - assertEquals(1, player.inventory.count("mime_mask")) + assertEquals(1, player.inventory.count("random_event_gift")) assertTrue(player["unlocked_emote_glass_wall", false]) assertNull(player.get("random_event")) assertEquals(origin, player.tile) diff --git a/game/src/test/kotlin/content/activity/event/random/QuizMasterTest.kt b/game/src/test/kotlin/content/activity/event/random/QuizMasterTest.kt index 92868d9548..64b8c9619a 100644 --- a/game/src/test/kotlin/content/activity/event/random/QuizMasterTest.kt +++ b/game/src/test/kotlin/content/activity/event/random/QuizMasterTest.kt @@ -8,7 +8,6 @@ import world.gregs.voidps.engine.entity.character.player.Player 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 @@ -59,36 +58,19 @@ class QuizMasterTest : WorldTest() { } @Test - fun `Four correct answers let the player choose 1000 coins`() { + fun `Four correct answers win a random event gift`() { val player = enter("quiz_coins") repeat(4) { player.pickAnswer() - player.skipDialogues() // "RIGHT!" (or the winner line on the 4th) -> reopens quiz / opens the prize choice + player.skipDialogues() // "RIGHT!" (or the winner line on the 4th) -> the prize tick() } - player.dialogueOption(1) // 1000 Coins tick() - assertEquals(1000, player.inventory.count("coins")) + assertEquals(1, player.inventory.count("random_event_gift")) assertNull(player.get("random_event")) assertEquals(origin, player.tile) assertTrue(player.contains("random_event_cooldown")) } - - @Test - fun `The random item prize awards an item`() { - val player = enter("quiz_item") - - repeat(4) { - player.pickAnswer() - player.skipDialogues() - tick() - } - player.dialogueOption(2) // Random Item - tick() - - assertFalse(player.inventory.isEmpty()) - assertNull(player.get("random_event")) - } } diff --git a/game/src/test/kotlin/content/activity/event/random/RandomEventGiftTest.kt b/game/src/test/kotlin/content/activity/event/random/RandomEventGiftTest.kt new file mode 100644 index 0000000000..6af38c2e64 --- /dev/null +++ b/game/src/test/kotlin/content/activity/event/random/RandomEventGiftTest.kt @@ -0,0 +1,178 @@ +package content.activity.event.random + +import WorldTest +import equipItem +import interfaceOption +import org.junit.jupiter.api.Test +import skipDialogues +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.type.Tile +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class RandomEventGiftTest : WorldTest() { + + private fun openGift(name: String): Player { + val player = createPlayer(Tile(3221, 3218), name) + player.inventory.add("random_event_gift") + player.equipItem("random_event_gift", option = "Open") + tick() + return player + } + + private val Player.gift + get() = inventories.inventory("random_event_gift") + + private fun Player.select(slot: Int) { + // The grid packs seven sub-components per reward, like the client sends + interfaceOption("random_event_gift_select", "rewards", "Select", item = gift[slot], slot = slot * 7) + tick() + } + + @Test + fun `Opening a gift deals a reward for every category`() { + val player = openGift("gift_open") + + assertTrue(player.interfaces.contains("random_event_gift_select")) + assertEquals("coins", player.gift[0].id) + assertTrue(player.gift[0].amount >= 30) + assertEquals("coal", player.gift[2].id) + assertTrue(player.gift[3].id.endsWith("essence")) + assertEquals("lamp", player.gift[10].id) + assertEquals("mystery_box", player.gift[25].id) + assertTrue(player.gift[24].isNotEmpty(), "Expected an oddment reward") + } + + @Test + fun `Reopening an unclaimed gift keeps the same choices`() { + val player = openGift("gift_reopen") + val rewards = player.gift.items.toList() + + player.interfaceOption("random_event_gift_select", "close", "Close") + tick() + player.equipItem("random_event_gift", option = "Open") + tick() + + assertEquals(rewards, player.gift.items.toList()) + } + + @Test + fun `Selecting with a bare grid click like the client sends works`() { + val player = openGift("gift_bare") + val amount = player.gift[2].amount + + // The client sends the packed sub-component slot with no item attached + player.interfaceOption("random_event_gift_select", "rewards", "Select", slot = 2 * 7) + tick() + player.interfaceOption("random_event_gift_select", "confirm", "Confirm") + tick() + + assertEquals(amount, player.inventory.count("coal_noted"), "Expected unstackable coal granted noted") + assertFalse(player.inventory.contains("random_event_gift")) + } + + @Test + fun `Choosing the emote unlocks a random event emote`() { + val player = openGift("gift_emote") + + // The emote cell is drawn by the interface; its container slot is empty + player.interfaceOption("random_event_gift_select", "rewards", "Select", slot = 26 * 7) + tick() + player.interfaceOption("random_event_gift_select", "confirm", "Confirm") + tick() + + assertTrue(player.get("unlocked_emote_idea", false), "Expected an emote unlocked") + assertFalse(player.inventory.contains("random_event_gift")) + } + + @Test + fun `Confirming without choosing keeps the gift`() { + val player = openGift("gift_none") + + player.interfaceOption("random_event_gift_select", "confirm", "Confirm") + tick() + + assertTrue(player.inventory.contains("random_event_gift")) + assertFalse(player.inventory.contains("coins")) + } + + @Test + fun `Choosing coins and confirming consumes the gift`() { + val player = openGift("gift_coins") + val amount = player.gift[0].amount + + player.select(0) + player.interfaceOption("random_event_gift_select", "confirm", "Confirm") + tick() + + assertEquals(amount, player.inventory.count("coins")) + assertFalse(player.inventory.contains("random_event_gift")) + assertFalse(player.interfaces.contains("random_event_gift_select")) + assertTrue(player.gift.isEmpty(), "Expected the unclaimed choices cleared") + } + + @Test + fun `Rubbing the lamp grants ten experience per level in the chosen skill`() { + val player = createPlayer(Tile(3221, 3218), "gift_lamp") + player.inventory.add("lamp") + + player.equipItem("lamp", option = "Rub") + tick() + player.interfaceOption("skill_stat_advance", "thieving", "Select", optionIndex = 0) + player.interfaceOption("skill_stat_advance", "confirm", "Confirm") + tick() + player.skipDialogues() + + assertEquals(10.0, player.experience.get(Skill.Thieving)) + assertFalse(player.inventory.contains("lamp")) + } + + @Test + fun `Constitution lamp experience uses the real level not hitpoints`() { + val player = createPlayer(Tile(3221, 3218), "gift_lamp_con") + player.inventory.add("lamp") + val before = player.experience.get(Skill.Constitution) + + player.equipItem("lamp", option = "Rub") + tick() + player.interfaceOption("skill_stat_advance", "constitution", "Select", optionIndex = 0) + player.interfaceOption("skill_stat_advance", "confirm", "Confirm") + tick() + player.skipDialogues() + + // Level 10 Constitution (stored as 100 internally) grants 100 xp, not 1000 + assertEquals(100.0, player.experience.get(Skill.Constitution) - before) + assertFalse(player.inventory.contains("lamp")) + } + + @Test + fun `Opening a mystery box rewards a random item`() { + val player = createPlayer(Tile(3221, 3218), "gift_mystery") + player.inventory.add("mystery_box") + + player.equipItem("mystery_box", option = "Open") + tick(2) + + assertFalse(player.inventory.contains("mystery_box")) + assertFalse(player.inventory.isEmpty(), "Expected something inside the box") + assertTrue(player["messages", emptyList()].any { it.startsWith("Inside the box you find") }) + } + + @Test + fun `Saving up for a costume banks a costume point`() { + val player = openGift("gift_costume") + + // The costume cell is drawn by the interface; its container slot is empty + player.interfaceOption("random_event_gift_select", "rewards", "Select", slot = 27 * 7) + tick() + player.interfaceOption("random_event_gift_select", "confirm", "Confirm") + tick() + + assertEquals(1, player.get("costume_points", 0)) + assertFalse(player.inventory.contains("random_event_gift")) + } +} diff --git a/game/src/test/kotlin/content/activity/event/random/RandomEventsTest.kt b/game/src/test/kotlin/content/activity/event/random/RandomEventsTest.kt index 38ecf93c05..6fe5343bba 100644 --- a/game/src/test/kotlin/content/activity/event/random/RandomEventsTest.kt +++ b/game/src/test/kotlin/content/activity/event/random/RandomEventsTest.kt @@ -64,25 +64,6 @@ class RandomEventsTest : WorldTest() { 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") diff --git a/game/src/test/kotlin/content/activity/event/random/SurpriseExamTest.kt b/game/src/test/kotlin/content/activity/event/random/SurpriseExamTest.kt index ac325d11d0..7b2c913333 100644 --- a/game/src/test/kotlin/content/activity/event/random/SurpriseExamTest.kt +++ b/game/src/test/kotlin/content/activity/event/random/SurpriseExamTest.kt @@ -71,7 +71,7 @@ class SurpriseExamTest : WorldTest() { } @Test - fun `Passing assigns a door, and the right door frees the player with a book`() { + fun `Passing assigns a door, and the right door frees the player with a gift`() { val player = enter("se_finish") openExam(player) @@ -88,7 +88,7 @@ class SurpriseExamTest : WorldTest() { player.objectOption(door, "Open") tick(2) - assertEquals(1, player.inventory.count("book_of_knowledge")) + assertEquals(1, player.inventory.count("random_event_gift")) assertNull(player.get("random_event")) assertEquals(origin, player.tile) assertTrue(player.contains("random_event_cooldown")) From 54d46217eea67847e70870860e448255afb064fc Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Fri, 10 Jul 2026 10:43:41 -0700 Subject: [PATCH 67/89] Add Mystery box random event reward --- .../activity/event/random/MysteryBox.kt | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 game/src/main/kotlin/content/activity/event/random/MysteryBox.kt diff --git a/game/src/main/kotlin/content/activity/event/random/MysteryBox.kt b/game/src/main/kotlin/content/activity/event/random/MysteryBox.kt new file mode 100644 index 0000000000..5d85e1c9b2 --- /dev/null +++ b/game/src/main/kotlin/content/activity/event/random/MysteryBox.kt @@ -0,0 +1,43 @@ +package content.activity.event.random + +import content.entity.player.inv.item.addOrDrop +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.client.message +import world.gregs.voidps.engine.entity.item.drop.DropTables +import world.gregs.voidps.engine.get +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.engine.inv.remove + +/** + * Mystery box: the surprise choice from a random event gift. Opening it rolls the `mystery_box` + * drop table - usually an oddment, with a slim shot at the rare drop table. + * https://runescape.wiki/w/Mystery_box_(random_events) + */ +class MysteryBox : Script { + + init { + itemOption("Open", "mystery_box") { (item, slot) -> + if (!inventory.remove(slot, item.id)) { + return@itemOption + } + val drop = get().getValue("mystery_box").roll(player = this).firstOrNull()?.toItem() + if (drop == null || drop.isEmpty()) { + message("Inside the box you find nothing! Better luck next time!") + return@itemOption + } + addOrDrop(drop.id, drop.amount) + val name = drop.def.name.lowercase() + val found = when { + drop.amount > 1 -> "${drop.amount} x $name" + name.first() in "aeiou" -> "an $name" + else -> "a $name" + } + val flavour = when (drop.def.cost * drop.amount) { + in 0..100 -> "Better luck next time!" + in 101..500 -> "Well, it could have been worse." + else -> "Excellent!" + } + message("Inside the box you find $found! $flavour") + } + } +} From 7952c2443a9d71bb156160dbac1bc74d2b1dbce3 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Fri, 10 Jul 2026 10:43:41 -0700 Subject: [PATCH 68/89] Add Casket treasure --- data/skill/fishing/fishing.drops.toml | 18 +++++++++ .../kotlin/content/skill/fishing/Casket.kt | 39 +++++++++++++++++++ .../content/skill/fishing/CasketTest.kt | 26 +++++++++++++ 3 files changed, 83 insertions(+) create mode 100644 data/skill/fishing/fishing.drops.toml create mode 100644 game/src/main/kotlin/content/skill/fishing/Casket.kt create mode 100644 game/src/test/kotlin/content/skill/fishing/CasketTest.kt diff --git a/data/skill/fishing/fishing.drops.toml b/data/skill/fishing/fishing.drops.toml new file mode 100644 index 0000000000..85be8ef367 --- /dev/null +++ b/data/skill/fishing/fishing.drops.toml @@ -0,0 +1,18 @@ +# What's inside a casket. https://runescape.wiki/w/Casket +[casket] +roll = 128 +drops = [ + { id = "uncut_sapphire", chance = 32 }, + { id = "uncut_emerald", chance = 16 }, + { id = "uncut_ruby", chance = 8 }, + { id = "uncut_diamond", chance = 2 }, + { id = "cosmic_talisman", chance = 8 }, + { id = "loop_half_of_a_key" }, + { id = "tooth_half_of_a_key" }, + { id = "coins", amount = 20, chance = 10 }, + { id = "coins", amount = 40, chance = 10 }, + { id = "coins", amount = 80, chance = 10 }, + { id = "coins", amount = 160, chance = 10 }, + { id = "coins", amount = 320, chance = 10 }, + { id = "coins", amount = 640, chance = 10 }, +] diff --git a/game/src/main/kotlin/content/skill/fishing/Casket.kt b/game/src/main/kotlin/content/skill/fishing/Casket.kt new file mode 100644 index 0000000000..85ebd214be --- /dev/null +++ b/game/src/main/kotlin/content/skill/fishing/Casket.kt @@ -0,0 +1,39 @@ +package content.skill.fishing + +import content.entity.player.inv.item.addOrDrop +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.client.message +import world.gregs.voidps.engine.entity.character.sound +import world.gregs.voidps.engine.entity.item.drop.DropTables +import world.gregs.voidps.engine.get +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.engine.inv.remove + +/** + * Caskets from big net fishing and mystery boxes hold a little treasure: usually coins or an + * uncut gem, with a slim shot at a key half. + * https://runescape.wiki/w/Casket + */ +class Casket : Script { + + init { + itemOption("Open", "casket") { (item, slot) -> + if (!inventory.remove(slot, item.id)) { + return@itemOption + } + sound("casket_open") + val drop = get().getValue("casket").roll(player = this).firstOrNull()?.toItem() + if (drop == null || drop.isEmpty()) { + return@itemOption + } + addOrDrop(drop.id, drop.amount) + val name = drop.def.name.lowercase() + val found = when { + drop.amount > 1 -> "${drop.amount} x $name" + name.first() in "aeiou" -> "an $name" + else -> "a $name" + } + message("You open the casket and find $found.") + } + } +} diff --git a/game/src/test/kotlin/content/skill/fishing/CasketTest.kt b/game/src/test/kotlin/content/skill/fishing/CasketTest.kt new file mode 100644 index 0000000000..c77208d472 --- /dev/null +++ b/game/src/test/kotlin/content/skill/fishing/CasketTest.kt @@ -0,0 +1,26 @@ +package content.skill.fishing + +import WorldTest +import equipItem +import org.junit.jupiter.api.Test +import world.gregs.voidps.engine.inv.add +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.type.Tile +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class CasketTest : WorldTest() { + + @Test + fun `Opening a casket rewards a little treasure`() { + val player = createPlayer(Tile(3221, 3218), "casket_open") + player.inventory.add("casket") + + player.equipItem("casket", option = "Open") + tick(2) + + assertFalse(player.inventory.contains("casket")) + assertFalse(player.inventory.isEmpty(), "Expected treasure inside the casket") + assertTrue(player["messages", emptyList()].any { it.startsWith("You open the casket and find") }) + } +} From c5b47e5ef2a20e795f74c99c46d0e8cc65117fcf Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Fri, 10 Jul 2026 13:50:27 -0700 Subject: [PATCH 69/89] Add Iffie's costume store --- .../event/random/gift/costume.ifaces.toml | 14 ++ .../event/random/gift/costume.varbits.toml | 47 ++++++ .../activity/event/random/gift/gift.vars.toml | 23 ++- .../client/instruction/InterfaceHandler.kt | 1 + .../activity/event/random/CostumePoints.kt | 15 ++ .../event/random/drill_demon/DrillDemon.kt | 2 + .../random/freaky_forester/FreakyForester.kt | 2 + .../event/random/gravedigger/Gravedigger.kt | 2 + .../event/random/kiss_the_frog/KissTheFrog.kt | 2 + .../event/random/mime_theatre/Mime.kt | 2 + .../content/area/misthalin/varrock/Iffie.kt | 96 +++++++++++- .../activity/event/random/DrillDemonTest.kt | 1 + .../event/random/FreakyForesterTest.kt | 1 + .../activity/event/random/GravediggerTest.kt | 1 + .../activity/event/random/KissTheFrogTest.kt | 1 + .../content/activity/event/random/MimeTest.kt | 1 + .../area/misthalin/varrock/IffieTest.kt | 138 ++++++++++++++++++ 17 files changed, 344 insertions(+), 5 deletions(-) create mode 100644 data/activity/event/random/gift/costume.ifaces.toml create mode 100644 data/activity/event/random/gift/costume.varbits.toml create mode 100644 game/src/main/kotlin/content/activity/event/random/CostumePoints.kt create mode 100644 game/src/test/kotlin/content/area/misthalin/varrock/IffieTest.kt diff --git a/data/activity/event/random/gift/costume.ifaces.toml b/data/activity/event/random/gift/costume.ifaces.toml new file mode 100644 index 0000000000..19479af704 --- /dev/null +++ b/data/activity/event/random/gift/costume.ifaces.toml @@ -0,0 +1,14 @@ +# Iffie's "Cash in your points for costume items" interface (201). The costume rows are drawn +# entirely by its load script (cs2 2121) - set icons from constants and the "Points:" values from +# the costume_points varbits - as clickable children of the list (component 15) at offsets +# 1, 11, 21, 31, 41 (Mime, Frog, Zombie, Camo, Lederhosen). Component 16 is the scrollbar. +[costume_reward_select] +id = 201 +type = "main_screen" + +[.close] +id = 14 + +[.rewards] +id = 15 +options = { Claim = 0, Examine = 9 } diff --git a/data/activity/event/random/gift/costume.varbits.toml b/data/activity/event/random/gift/costume.varbits.toml new file mode 100644 index 0000000000..db651fc754 --- /dev/null +++ b/data/activity/event/random/gift/costume.varbits.toml @@ -0,0 +1,47 @@ +# Read by interface 201's load script (cs2 2121/2122) - the "Points:" shown beside each +# costume row. All five mirror the player's costume_points balance. +[costume_points_mime] +id = 5903 +format = "int" + +[costume_points_frog] +id = 5904 +format = "int" + +[costume_points_zombie] +id = 5905 +format = "int" + +[costume_points_camo] +id = 5906 +format = "int" + +[costume_points_lederhosen] +id = 5907 +format = "int" + +# Also read by cs2 2123 - a claimed (fully owned) costume greys its row out. The frog mask has +# its own flag separate from the rest of the royal outfit. +[costume_claimed_mime] +id = 2698 +format = "boolean" + +[costume_claimed_frog] +id = 2699 +format = "boolean" + +[costume_claimed_zombie] +id = 2700 +format = "boolean" + +[costume_claimed_camo] +id = 2701 +format = "boolean" + +[costume_claimed_lederhosen] +id = 2702 +format = "boolean" + +[costume_claimed_frog_mask] +id = 2776 +format = "boolean" diff --git a/data/activity/event/random/gift/gift.vars.toml b/data/activity/event/random/gift/gift.vars.toml index 0e32fc2f8c..b162feb537 100644 --- a/data/activity/event/random/gift/gift.vars.toml +++ b/data/activity/event/random/gift/gift.vars.toml @@ -2,7 +2,28 @@ [gift_selected] format = "int" -# Saved up from gifts; spent at the costume store (not yet implemented). +# Saved up from gifts; spent on any outfit at Iffie's costume store. [costume_points] format = "int" persist = true + +# Earned from each outfit's own random event; only spendable on that outfit. +[mime_costume_points] +format = "int" +persist = true + +[frog_costume_points] +format = "int" +persist = true + +[zombie_costume_points] +format = "int" +persist = true + +[camo_costume_points] +format = "int" +persist = true + +[lederhosen_costume_points] +format = "int" +persist = true diff --git a/engine/src/main/kotlin/world/gregs/voidps/engine/client/instruction/InterfaceHandler.kt b/engine/src/main/kotlin/world/gregs/voidps/engine/client/instruction/InterfaceHandler.kt index e81d10a959..e7ec56b7ac 100644 --- a/engine/src/main/kotlin/world/gregs/voidps/engine/client/instruction/InterfaceHandler.kt +++ b/engine/src/main/kotlin/world/gregs/voidps/engine/client/instruction/InterfaceHandler.kt @@ -37,6 +37,7 @@ class InterfaceHandler( } id == "common_item_costs" -> item = Item(ItemDefinitions.get(itemId).stringId) id == "surprise_exam_pattern" -> item = Item(ItemDefinitions.get(itemId).stringId) + id == "costume_reward_select" -> item = Item(ItemDefinitions.get(itemId).stringId) id == "farming_equipment_store" || id == "farming_equipment_store_side" -> {} else -> { val inventory = getInventory(player, id, component, componentDefinition) ?: return null diff --git a/game/src/main/kotlin/content/activity/event/random/CostumePoints.kt b/game/src/main/kotlin/content/activity/event/random/CostumePoints.kt new file mode 100644 index 0000000000..e494c633b1 --- /dev/null +++ b/game/src/main/kotlin/content/activity/event/random/CostumePoints.kt @@ -0,0 +1,15 @@ +package content.activity.event.random + +import world.gregs.voidps.engine.client.message +import world.gregs.voidps.engine.entity.character.player.Player + +/** + * Award a costume point locked to one outfit - each costume's own random event grants one on + * completion, spendable only on that outfit at Iffie's costume store. Points from the random + * event gift's "Save up for a costume!" choice go to the any-outfit `costume_points` balance + * instead. + */ +fun Player.rewardCostumePoint(costume: String) { + inc("${costume}_costume_points") + message("You've earned a point towards a costume; visit Iffie in Varrock's clothes shop to spend it.") +} diff --git a/game/src/main/kotlin/content/activity/event/random/drill_demon/DrillDemon.kt b/game/src/main/kotlin/content/activity/event/random/drill_demon/DrillDemon.kt index e84549ad73..4ec6165309 100644 --- a/game/src/main/kotlin/content/activity/event/random/drill_demon/DrillDemon.kt +++ b/game/src/main/kotlin/content/activity/event/random/drill_demon/DrillDemon.kt @@ -3,6 +3,7 @@ package content.activity.event.random.drill_demon import content.activity.event.random.RandomEvents import content.activity.event.random.kidnap import content.activity.event.random.mysteriousOldMan +import content.activity.event.random.rewardCostumePoint import content.entity.gfx.areaGfx import content.entity.player.dialogue.Neutral import content.entity.player.dialogue.type.item @@ -109,6 +110,7 @@ class DrillDemon : Script { private suspend fun Player.finish() { npc("sergeant_damien", "Well I'll be, you actually did it $name. Now take this and get yourself out of my sight.", largeHead = true) addOrDrop("random_event_gift") + rewardCostumePoint("camo") clear("drill_demon_ready") clear("drill_demon_task") clear("drill_demon_correct") 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 index a69b2c14cd..8bc59ef2af 100644 --- 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 @@ -2,6 +2,7 @@ package content.activity.event.random.freaky_forester import content.activity.event.random.RandomEvents import content.activity.event.random.kidnap +import content.activity.event.random.rewardCostumePoint import content.entity.combat.killer import content.entity.player.dialogue.Happy import content.entity.player.dialogue.Neutral @@ -94,6 +95,7 @@ class FreakyForester : Script { private suspend fun Player.reward() { npc("freaky_forester", "Please take this gift as a reward for your help, many thanks!") addOrDrop("random_event_gift") + rewardCostumePoint("lederhosen") } private fun Player.carriesRawPheasant() = inventory.contains("raw_pheasant") || inventory.contains("raw_pheasant_incorrect") diff --git a/game/src/main/kotlin/content/activity/event/random/gravedigger/Gravedigger.kt b/game/src/main/kotlin/content/activity/event/random/gravedigger/Gravedigger.kt index 63079afcc3..6bcac4a482 100644 --- a/game/src/main/kotlin/content/activity/event/random/gravedigger/Gravedigger.kt +++ b/game/src/main/kotlin/content/activity/event/random/gravedigger/Gravedigger.kt @@ -2,6 +2,7 @@ package content.activity.event.random.gravedigger import content.activity.event.random.RandomEvents import content.activity.event.random.kidnap +import content.activity.event.random.rewardCostumePoint import content.entity.player.dialogue.Happy import content.entity.player.dialogue.Neutral import content.entity.player.dialogue.Sad @@ -241,6 +242,7 @@ class Gravedigger : Script { npc("leo_gravedigger", "Wonderful! That's taken care of all of them.") npc("leo_gravedigger", "Here, I'll take you back to where I found you, and give you your reward.") addOrDrop("random_event_gift") + rewardCostumePoint("zombie") set("unlocked_emote_zombie_walk", true) set("unlocked_emote_zombie_dance", true) exitGraveyard() diff --git a/game/src/main/kotlin/content/activity/event/random/kiss_the_frog/KissTheFrog.kt b/game/src/main/kotlin/content/activity/event/random/kiss_the_frog/KissTheFrog.kt index c5439e45bc..f8e932059b 100644 --- a/game/src/main/kotlin/content/activity/event/random/kiss_the_frog/KissTheFrog.kt +++ b/game/src/main/kotlin/content/activity/event/random/kiss_the_frog/KissTheFrog.kt @@ -3,6 +3,7 @@ package content.activity.event.random.kiss_the_frog import content.activity.event.random.RandomEvents import content.activity.event.random.endInPlaceEvent import content.activity.event.random.kidnap +import content.activity.event.random.rewardCostumePoint import content.activity.event.random.startInPlaceEvent import content.entity.effect.clearTransform import content.entity.effect.transform @@ -158,6 +159,7 @@ class KissTheFrog : Script { open("fade_out") delay(2) addOrDrop("random_event_gift") + rewardCostumePoint("frog") message("You've been given a gift!") finishEvent() open("fade_in") diff --git a/game/src/main/kotlin/content/activity/event/random/mime_theatre/Mime.kt b/game/src/main/kotlin/content/activity/event/random/mime_theatre/Mime.kt index 4828e62c8d..284d9ff8b5 100644 --- a/game/src/main/kotlin/content/activity/event/random/mime_theatre/Mime.kt +++ b/game/src/main/kotlin/content/activity/event/random/mime_theatre/Mime.kt @@ -3,6 +3,7 @@ package content.activity.event.random.mime_theatre import content.activity.event.random.RandomEvents import content.activity.event.random.kidnap import content.activity.event.random.mysteriousOldMan +import content.activity.event.random.rewardCostumePoint import content.entity.player.dialogue.Neutral import content.entity.player.dialogue.type.npc import content.entity.player.dialogue.type.statement @@ -123,6 +124,7 @@ class Mime : Script { set("unlocked_emote_$unlock", true) } addOrDrop("random_event_gift") + rewardCostumePoint("mime") clear("mime_emote") clear("mime_correct") diff --git a/game/src/main/kotlin/content/area/misthalin/varrock/Iffie.kt b/game/src/main/kotlin/content/area/misthalin/varrock/Iffie.kt index 6f54e038d2..9bbf69225c 100644 --- a/game/src/main/kotlin/content/area/misthalin/varrock/Iffie.kt +++ b/game/src/main/kotlin/content/area/misthalin/varrock/Iffie.kt @@ -1,12 +1,24 @@ package content.area.misthalin.varrock -import content.entity.npc.shop.openShop +import content.entity.player.bank.ownsItem import content.entity.player.dialogue.* import content.entity.player.dialogue.type.choice import content.entity.player.dialogue.type.npc +import content.entity.player.inv.item.addOrDrop import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.client.message +import world.gregs.voidps.engine.client.ui.open +import world.gregs.voidps.engine.entity.character.player.Player import world.gregs.voidps.engine.entity.character.player.male +import world.gregs.voidps.engine.inv.sendInventory +/** + * Iffie's costume store: costume points saved up from random event gifts are spent here on the + * random event costumes, one piece at a time. The "Cash in your points" interface (201) draws + * the five costume rows itself and shows the player's balance from the costume_points varbits; + * clicking a piece claims it for a point - the outfit's own event points spend before the + * any-outfit gift points. + */ class Iffie : Script { init { @@ -14,9 +26,15 @@ class Iffie : Script { npc("Hello, dearie! Were you wanting to collect a random event costume, or is there something else I can do for you today?") choice { option("I've come for a random event costume.") { + val generic = get("costume_points", 0) + val total = generic + COSTUMES.sumOf { get("${it.name}_costume_points", 0) } + if (total == 0) { + npc("Then you'll be needing a costume point, dearie. Complete a costume's random event, or pick 'Save up for a costume!' from a random event gift, and come back to see me.") + return@option + } npc("Some of these costumes even come with a free emote!") - npc("Just buy one piece of the mine of zombie costumes and I'll show you the relevant moves.") - openShop("iffies_random_costume_shop") + npc("You have $generic point${if (generic == 1) "" else "s"} to spend on any outfit; points earned from an outfit's own event show on its row. Each piece costs one point.") + openCostumeStore() } option("Aren't you selling anything?") { npc("Oh, yes, but only costumes. Thessalia sells some other clothes and runs the makeover service.") @@ -28,7 +46,77 @@ class Iffie : Script { } npcOperate("Claim-costume", "iffie") { - openShop("iffies_random_costume_shop") + openCostumeStore() } + + interfaceOpened("costume_reward_select") { id -> + interfaceOptions.unlockAll(id, "rewards", 0 until ROWS * ROW_STRIDE) + } + + // Each piece child carries its item (from enum 2240), so the click names the exact piece + interfaceOption("Claim", "costume_reward_select:rewards") { + claim(it.item.id) + } + } + + private fun Player.openCostumeStore() { + syncPoints() + // The interface dims owned pieces by checking the bank container client-side, which the + // client only knows about once it's been sent + sendInventory("bank") + open("costume_reward_select") + } + + /** + * The interface reads each row's "Points:" from its own varbit - mirror the balance to all - + * and greys out a costume once every piece is owned. The frog mask greys separately from the + * rest of the royal outfit. + */ + private fun Player.syncPoints() { + val generic = get("costume_points", 0) + for (costume in COSTUMES) { + set("costume_points_${costume.name}", generic + get("${costume.name}_costume_points", 0)) + val pieces = if (costume.name == "frog") costume.pieces - "frog_mask" else costume.pieces + set("costume_claimed_${costume.name}", pieces.all { ownsItem(it) }) + } + set("costume_claimed_frog_mask", ownsItem("frog_mask")) + } + + private fun Player.claim(piece: String) { + val costume = COSTUMES.firstOrNull { piece in it.pieces } ?: return + if (ownsItem(piece)) { + message("You already have that piece, dearie.") + return + } + // Points from the outfit's own event spend first, saving the any-outfit gift points + val locked = get("${costume.name}_costume_points", 0) + val generic = get("costume_points", 0) + when { + locked > 0 -> set("${costume.name}_costume_points", locked - 1) + generic > 0 -> set("costume_points", generic - 1) + else -> { + message("You don't have any points for that outfit; complete its random event or pick 'Save up for a costume!' from a random event gift.") + return + } + } + addOrDrop(piece) + syncPoints() + message("You spend a costume point on the ${piece.replace('_', ' ')}.") + } + + private data class Costume(val name: String, val pieces: List) + + companion object { + private const val ROWS = 5 + private const val ROW_STRIDE = 10 + + // In the interface's row order. + private val COSTUMES = listOf( + Costume("mime", listOf("mime_mask", "mime_top", "mime_legs", "mime_gloves", "mime_boots")), + Costume("frog", listOf("frog_mask", "princess_blouse", "princess_skirt", "prince_tunic", "prince_leggings")), + Costume("zombie", listOf("zombie_shirt", "zombie_trousers", "zombie_gloves", "zombie_boots", "zombie_mask")), + Costume("camo", listOf("camo_top", "camo_bottoms", "camo_helmet")), + Costume("lederhosen", listOf("lederhosen_top", "lederhosen_hat", "lederhosen_shorts")), + ) } } diff --git a/game/src/test/kotlin/content/activity/event/random/DrillDemonTest.kt b/game/src/test/kotlin/content/activity/event/random/DrillDemonTest.kt index fdf496c080..e103229221 100644 --- a/game/src/test/kotlin/content/activity/event/random/DrillDemonTest.kt +++ b/game/src/test/kotlin/content/activity/event/random/DrillDemonTest.kt @@ -104,6 +104,7 @@ class DrillDemonTest : WorldTest() { tick(2) assertEquals(1, player.inventory.count("random_event_gift")) + assertEquals(1, player.get("camo_costume_points", 0)) assertNull(player.get("random_event")) assertEquals(origin, player.tile) } diff --git a/game/src/test/kotlin/content/activity/event/random/FreakyForesterTest.kt b/game/src/test/kotlin/content/activity/event/random/FreakyForesterTest.kt index 8b1f4494cf..83d3f27f43 100644 --- a/game/src/test/kotlin/content/activity/event/random/FreakyForesterTest.kt +++ b/game/src/test/kotlin/content/activity/event/random/FreakyForesterTest.kt @@ -79,6 +79,7 @@ class FreakyForesterTest : WorldTest() { tick(2) assertEquals(1, player.inventory.count("random_event_gift")) + assertEquals(1, player.get("lederhosen_costume_points", 0)) assertFalse(player.inventory.contains("raw_pheasant")) assertNull(player.get("random_event")) assertEquals(origin, player.tile) diff --git a/game/src/test/kotlin/content/activity/event/random/GravediggerTest.kt b/game/src/test/kotlin/content/activity/event/random/GravediggerTest.kt index 0449a57868..aec515f1d7 100644 --- a/game/src/test/kotlin/content/activity/event/random/GravediggerTest.kt +++ b/game/src/test/kotlin/content/activity/event/random/GravediggerTest.kt @@ -122,6 +122,7 @@ class GravediggerTest : WorldTest() { player.claimFinished() assertTrue(player.inventory.contains("random_event_gift"), "Expected a random event gift") + assertEquals(1, player.get("zombie_costume_points", 0)) assertTrue(sites.indices.none { player.inventory.contains(coffinName(it)) }, "Expected Leo to keep his coffins") assertTrue(player.get("unlocked_emote_zombie_walk", false)) assertTrue(player.get("unlocked_emote_zombie_dance", false)) diff --git a/game/src/test/kotlin/content/activity/event/random/KissTheFrogTest.kt b/game/src/test/kotlin/content/activity/event/random/KissTheFrogTest.kt index 3ae37975cd..7f26414907 100644 --- a/game/src/test/kotlin/content/activity/event/random/KissTheFrogTest.kt +++ b/game/src/test/kotlin/content/activity/event/random/KissTheFrogTest.kt @@ -83,6 +83,7 @@ class KissTheFrogTest : WorldTest() { player.driveUntilDone() assertEquals(1, player.inventory.count("random_event_gift")) + assertEquals(1, player.get("frog_costume_points", 0)) assertNull(player.get("random_event")) assertEquals(origin, player.tile) } diff --git a/game/src/test/kotlin/content/activity/event/random/MimeTest.kt b/game/src/test/kotlin/content/activity/event/random/MimeTest.kt index fe5bd345b1..5040da9b88 100644 --- a/game/src/test/kotlin/content/activity/event/random/MimeTest.kt +++ b/game/src/test/kotlin/content/activity/event/random/MimeTest.kt @@ -67,6 +67,7 @@ class MimeTest : WorldTest() { tickIf { player.tile != origin } // wait out the modern teleport home assertEquals(1, player.inventory.count("random_event_gift")) + assertEquals(1, player.get("mime_costume_points", 0)) assertTrue(player["unlocked_emote_glass_wall", false]) assertNull(player.get("random_event")) assertEquals(origin, player.tile) diff --git a/game/src/test/kotlin/content/area/misthalin/varrock/IffieTest.kt b/game/src/test/kotlin/content/area/misthalin/varrock/IffieTest.kt new file mode 100644 index 0000000000..5bb5c8281b --- /dev/null +++ b/game/src/test/kotlin/content/area/misthalin/varrock/IffieTest.kt @@ -0,0 +1,138 @@ +package content.area.misthalin.varrock + +import WorldTest +import dialogueOption +import interfaceOption +import npcOption +import org.junit.jupiter.api.Test +import skipDialogues +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.entity.item.Item +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.type.Tile +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class IffieTest : WorldTest() { + + private fun visit(name: String, points: Int): Player { + val player = createPlayer(Tile(3204, 3418), name) + if (points > 0) { + player["costume_points"] = points + } + val iffie = createNPC("iffie", Tile(3204, 3419)) + player.npcOption(iffie, "Claim-costume") + tick(3) + return player + } + + @Test + fun `The store shows the player's points on every costume row`() { + val player = visit("iffie_stock", points = 3) + + assertTrue(player.interfaces.contains("costume_reward_select")) + assertEquals(3, player.get("costume_points_mime", 0)) + assertEquals(3, player.get("costume_points_lederhosen", 0)) + } + + @Test + fun `Claiming a costume spends a point on its next missing piece`() { + val player = visit("iffie_claim", points = 2) + + // The piece children carry their item, so the click names the exact piece + player.interfaceOption("costume_reward_select", "rewards", "Claim", item = Item("mime_mask", 1), slot = 1) + tick() + + assertEquals(1, player.inventory.count("mime_mask")) + assertEquals(1, player.get("costume_points", 0)) + assertEquals(1, player.get("costume_points_mime", 0), "Expected the shown balance refreshed") + + player.interfaceOption("costume_reward_select", "rewards", "Claim", item = Item("camo_helmet", 1), slot = 31) + tick() + + assertEquals(1, player.inventory.count("camo_helmet")) + assertEquals(0, player.get("costume_points", 0)) + } + + @Test + fun `Outfit points combine with generic on the row and spend first`() { + val player = createPlayer(Tile(3204, 3418), "iffie_locked") + player["costume_points"] = 1 + player["mime_costume_points"] = 2 + val iffie = createNPC("iffie", Tile(3204, 3419)) + player.npcOption(iffie, "Claim-costume") + tick(3) + + assertEquals(3, player.get("costume_points_mime", 0), "Expected locked + generic combined on the row") + assertEquals(1, player.get("costume_points_camo", 0), "Expected only generic on other rows") + + player.interfaceOption("costume_reward_select", "rewards", "Claim", item = Item("mime_mask", 1), slot = 1) + tick() + + assertEquals(1, player.inventory.count("mime_mask")) + assertEquals(1, player.get("mime_costume_points", 0), "Expected the locked point spent first") + assertEquals(1, player.get("costume_points", 0), "Expected the generic point kept") + } + + @Test + fun `Outfit points can't buy another outfit`() { + val player = createPlayer(Tile(3204, 3418), "iffie_cross") + player["mime_costume_points"] = 1 + val iffie = createNPC("iffie", Tile(3204, 3419)) + player.npcOption(iffie, "Claim-costume") + tick(3) + + player.interfaceOption("costume_reward_select", "rewards", "Claim", item = Item("camo_top", 1), slot = 31) + tick() + + assertTrue(player.inventory.isEmpty(), "Expected a mime point not to buy camo") + assertEquals(1, player.get("mime_costume_points", 0)) + } + + @Test + fun `A completed costume greys its row out`() { + val player = visit("iffie_complete", points = 4) + + for (piece in listOf("camo_helmet", "camo_top", "camo_bottoms")) { + player.interfaceOption("costume_reward_select", "rewards", "Claim", item = Item(piece, 1), slot = 31) + tick() + } + + assertTrue(player.get("costume_claimed_camo", false), "Expected the camo row greyed out") + assertFalse(player.get("costume_claimed_mime", false)) + + player.interfaceOption("costume_reward_select", "rewards", "Claim", item = Item("frog_mask", 1), slot = 11) + tick() + + assertTrue(player.get("costume_claimed_frog_mask", false), "Expected the frog mask greyed out") + assertFalse(player.get("costume_claimed_frog", false), "Expected the royal outfit still available") + } + + @Test + fun `No costume points means no costume`() { + val player = visit("iffie_broke", points = 0) + + player.interfaceOption("costume_reward_select", "rewards", "Claim", item = Item("mime_mask", 1), slot = 1) + tick() + + assertTrue(player.inventory.isEmpty()) + assertEquals(0, player.get("costume_points", 0)) + } + + @Test + fun `Iffie explains costume points to newcomers`() { + val player = createPlayer(Tile(3204, 3418), "iffie_chat") + val iffie = createNPC("iffie", Tile(3204, 3419)) + + player.npcOption(iffie, "Talk-to") + tick(3) + player.skipDialogues() + player.dialogueOption(1) // "I've come for a random event costume." + tick() + player.skipDialogues() + tick() + + assertFalse(player.interfaces.contains("costume_reward_select"), "Expected no store without points") + } +} From 8ee2579367846987fb1d6d6a4f85925ae7e5fd3b Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Fri, 10 Jul 2026 14:54:48 -0700 Subject: [PATCH 70/89] Remove dead code and unnecessary comments from random events --- .../content/activity/event/random/InPlaceRandomEvent.kt | 4 ++-- .../kotlin/content/activity/event/random/RandomEventGift.kt | 2 -- .../content/activity/event/random/RandomEventKidnap.kt | 1 - .../kotlin/content/activity/event/random/RandomEvents.kt | 2 +- .../kotlin/content/activity/event/random/certer/Certer.kt | 2 +- .../content/activity/event/random/drill_demon/DrillDemon.kt | 1 - .../main/kotlin/content/activity/event/random/maze/Maze.kt | 1 - .../content/activity/event/random/mime_theatre/Mime.kt | 4 ++-- .../kotlin/content/activity/event/random/pinball/Pinball.kt | 3 +-- .../content/activity/event/random/quiz_master/QuizMaster.kt | 2 +- .../activity/event/random/sandwich_lady/SandwichLady.kt | 2 +- .../kotlin/content/activity/event/random/RandomEventsTest.kt | 5 ++--- 12 files changed, 11 insertions(+), 18 deletions(-) diff --git a/game/src/main/kotlin/content/activity/event/random/InPlaceRandomEvent.kt b/game/src/main/kotlin/content/activity/event/random/InPlaceRandomEvent.kt index 248f4e07c3..7659cf185b 100644 --- a/game/src/main/kotlin/content/activity/event/random/InPlaceRandomEvent.kt +++ b/game/src/main/kotlin/content/activity/event/random/InPlaceRandomEvent.kt @@ -62,14 +62,14 @@ class InPlaceRandomEvent : Script { /** * 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. + * penalty after [lifetime] ticks. Returns the spawned NPC. */ fun Player.startInPlaceEvent( id: String, nagLines: List, lifetime: Int = 300, nagInterval: Int = 25, -): NPC? { +): 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 diff --git a/game/src/main/kotlin/content/activity/event/random/RandomEventGift.kt b/game/src/main/kotlin/content/activity/event/random/RandomEventGift.kt index 3974dd94b4..5534e7614b 100644 --- a/game/src/main/kotlin/content/activity/event/random/RandomEventGift.kt +++ b/game/src/main/kotlin/content/activity/event/random/RandomEventGift.kt @@ -188,7 +188,6 @@ class RandomEventGift : Script { } companion object { - // The interface's category slots, matching the official layout. private const val COINS_SLOT = 0 private const val RUNE_SLOT = 1 private const val COAL_SLOT = 2 @@ -209,7 +208,6 @@ class RandomEventGift : Script { private const val EMOTE_SLOT = 26 private const val COSTUME_SLOT = 27 - // The random event emotes handed out by the emote choice. private val EMOTES = listOf("idea", "stomp", "flap", "slap_head") } } 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 9110b933f8..d8b57757dc 100644 --- a/game/src/main/kotlin/content/activity/event/random/RandomEventKidnap.kt +++ b/game/src/main/kotlin/content/activity/event/random/RandomEventKidnap.kt @@ -42,7 +42,6 @@ class RandomEventKidnap : Script { "dungeoneering", "scroll", "tablet", - "lumbridge_home_teleport", "jewellery", "fairy", "fairy_ring", 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 ccf31a168e..23cdba827b 100644 --- a/game/src/main/kotlin/content/activity/event/random/RandomEvents.kt +++ b/game/src/main/kotlin/content/activity/event/random/RandomEvents.kt @@ -162,7 +162,7 @@ object RandomEvents : AutoCloseable { /** * Delay the next event by `random_event_settings` min to max minutes. */ - fun cooldown(player: Player) { + private 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) diff --git a/game/src/main/kotlin/content/activity/event/random/certer/Certer.kt b/game/src/main/kotlin/content/activity/event/random/certer/Certer.kt index fde0a1a8be..2b9ab7e85e 100644 --- a/game/src/main/kotlin/content/activity/event/random/certer/Certer.kt +++ b/game/src/main/kotlin/content/activity/event/random/certer/Certer.kt @@ -52,7 +52,7 @@ class Certer : Script { } private fun Player.startEvent() { - val giles = startInPlaceEvent("giles", nagLines()) ?: return + val giles = startInPlaceEvent("giles", nagLines()) giles.anim("emote_bow") } diff --git a/game/src/main/kotlin/content/activity/event/random/drill_demon/DrillDemon.kt b/game/src/main/kotlin/content/activity/event/random/drill_demon/DrillDemon.kt index 4ec6165309..b5ca4ad8a7 100644 --- a/game/src/main/kotlin/content/activity/event/random/drill_demon/DrillDemon.kt +++ b/game/src/main/kotlin/content/activity/event/random/drill_demon/DrillDemon.kt @@ -47,7 +47,6 @@ class DrillDemon : Script { return@objectOperate } if (!get("drill_demon_ready", false)) { - // Jumping on a mat before the order is given is a wasted, uncredited attempt. npc("sergeant_damien", "I haven't given you the order yet, worm!", largeHead = true) return@objectOperate } 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 f412ec6543..a0406cc596 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 @@ -24,7 +24,6 @@ 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.obj.replace -import world.gregs.voidps.engine.inv.add import world.gregs.voidps.engine.timer.Timer import world.gregs.voidps.type.Region import world.gregs.voidps.type.Tile diff --git a/game/src/main/kotlin/content/activity/event/random/mime_theatre/Mime.kt b/game/src/main/kotlin/content/activity/event/random/mime_theatre/Mime.kt index 284d9ff8b5..1844dcb78d 100644 --- a/game/src/main/kotlin/content/activity/event/random/mime_theatre/Mime.kt +++ b/game/src/main/kotlin/content/activity/event/random/mime_theatre/Mime.kt @@ -78,7 +78,7 @@ class Mime : Script { delay(SPOTLIGHT_TICKS) // let the spotlight finish turning on before the player performs val chosen = awaitEmote() - anim("emote_$chosen") // the player performs the emote they picked + anim("emote_$chosen") if (chosen == expected) { centeredMessage("Correct!") inc("mime_correct") @@ -140,7 +140,7 @@ class Mime : Script { private const val PERFORM_TICKS = 4 private const val BOW_TICKS = 3 private const val SPOTLIGHT_TICKS = 3 // duration of the spotlight on/off animation - private const val CORRECT_TICKS = 8 // ~8 ticks + private const val CORRECT_TICKS = 8 private val SPAWN = Tile(2008, 4764) private val WATCH = Tile(2008, 4762) diff --git a/game/src/main/kotlin/content/activity/event/random/pinball/Pinball.kt b/game/src/main/kotlin/content/activity/event/random/pinball/Pinball.kt index fa45edfe72..c754cd5df5 100644 --- a/game/src/main/kotlin/content/activity/event/random/pinball/Pinball.kt +++ b/game/src/main/kotlin/content/activity/event/random/pinball/Pinball.kt @@ -38,7 +38,7 @@ import world.gregs.voidps.type.random * Pinball random event: the Mysterious Old Man teleports the player into a private copy of the trolls' * pinball arena. One of five posts flashes with rings; tagging it scores a point and lights a new one, * while tagging an unlit post resets the score to zero. Score ten points, then leave through the cave - * exit (the troll guards Flippa and Tilt block the way until then) for a handful of uncut gems. + * exit (the troll guards Flippa and Tilt block the way until then) for a random event gift. * https://runescape.wiki/w/Random_events?oldid=3667851#Pinball */ class Pinball : Script { @@ -220,7 +220,6 @@ class Pinball : Script { private val FLIPPA = Tile(1973, 5038) private val TILT = Tile(1970, 5038) private val GUARDS = listOf("flippa_pinball", "tilt_pinball") - private val REWARDS = listOf("uncut_diamond", "uncut_ruby", "uncut_emerald", "uncut_sapphire") private val POSTS = listOf( Post("pinball_post_1", Tile(1967, 5046)), diff --git a/game/src/main/kotlin/content/activity/event/random/quiz_master/QuizMaster.kt b/game/src/main/kotlin/content/activity/event/random/quiz_master/QuizMaster.kt index e645bb785b..72125942c1 100644 --- a/game/src/main/kotlin/content/activity/event/random/quiz_master/QuizMaster.kt +++ b/game/src/main/kotlin/content/activity/event/random/quiz_master/QuizMaster.kt @@ -46,7 +46,7 @@ class QuizMaster : Script { quizHerald() kidnap(ROOM) face(QUIZ_MASTER) - anim("quiz_show_sit") // the contestant takes their seat for the show + anim("quiz_show_sit") intro() runQuiz() } diff --git a/game/src/main/kotlin/content/activity/event/random/sandwich_lady/SandwichLady.kt b/game/src/main/kotlin/content/activity/event/random/sandwich_lady/SandwichLady.kt index 7b12997ce2..99be1b2fe7 100644 --- a/game/src/main/kotlin/content/activity/event/random/sandwich_lady/SandwichLady.kt +++ b/game/src/main/kotlin/content/activity/event/random/sandwich_lady/SandwichLady.kt @@ -59,7 +59,7 @@ class SandwichLady : Script { private fun Player.startEvent() { val food = FOODS.random(random) set("sandwich_lady_food", food) - val lady = startInPlaceEvent("sandwich_lady", nagLines(), nagInterval = 30) ?: return + val lady = startInPlaceEvent("sandwich_lady", nagLines(), nagInterval = 30) set("sandwich_lady_npc", lady.index) lady.say("Sandwich delivery for $name!") } diff --git a/game/src/test/kotlin/content/activity/event/random/RandomEventsTest.kt b/game/src/test/kotlin/content/activity/event/random/RandomEventsTest.kt index 6fe5343bba..7a44042b9c 100644 --- a/game/src/test/kotlin/content/activity/event/random/RandomEventsTest.kt +++ b/game/src/test/kotlin/content/activity/event/random/RandomEventsTest.kt @@ -42,7 +42,6 @@ class RandomEventsTest : WorldTest() { 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") @@ -61,7 +60,7 @@ class RandomEventsTest : WorldTest() { 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") + assertNull(NPCs.indexed(npc.index), "Event NPC should be removed after the penalty") } @Test @@ -69,7 +68,7 @@ class RandomEventsTest : WorldTest() { val player = createPlayer(Tile(3221, 3218), "re_inplace_end") player["random_event"] = "certer" - val npc = player.startInPlaceEvent("mysterious_old_man", listOf("You there!"))!! + val npc = player.startInPlaceEvent("mysterious_old_man", listOf("You there!")) tick() endInPlaceEvent(npc) tick() From 1895287c6e51b7efb0f83464c9087ce330b5f33a Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Fri, 10 Jul 2026 14:54:55 -0700 Subject: [PATCH 71/89] Exile without noting items on sandwich lady knockout --- .../activity/event/random/sandwich_lady/SandwichLady.kt | 6 ++---- .../content/activity/event/random/SandwichLadyTest.kt | 6 ++---- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/game/src/main/kotlin/content/activity/event/random/sandwich_lady/SandwichLady.kt b/game/src/main/kotlin/content/activity/event/random/sandwich_lady/SandwichLady.kt index 99be1b2fe7..33df2b8372 100644 --- a/game/src/main/kotlin/content/activity/event/random/sandwich_lady/SandwichLady.kt +++ b/game/src/main/kotlin/content/activity/event/random/sandwich_lady/SandwichLady.kt @@ -2,7 +2,6 @@ package content.activity.event.random.sandwich_lady import content.activity.event.random.RandomEvents import content.activity.event.random.startInPlaceEvent -import content.entity.combat.hit.directHit import content.entity.combat.inCombat import content.entity.player.dialogue.Happy import content.entity.player.dialogue.type.npc @@ -20,7 +19,7 @@ import world.gregs.voidps.type.random /** * Sandwich Lady random event: she appears beside the player pushing a refreshment tray and nags * until spoken to. Talking opens her tray (interface 297); picking the food she offered hands it - * over, picking any other knocks the player out (notes their items and teleports them away). + * over, picking any other knocks the player out and teleports them away with nothing. * https://runescape.wiki/w/Random_events?oldid=3667851#Sandwich_Lady */ class SandwichLady : Script { @@ -68,11 +67,10 @@ class SandwichLady : Script { message("The sandwich lady knocks you out and you wake up somewhere... different.") lady?.say("Hey, I didn't say you could have that!") lady?.anim("sandwich_lady_knockout") // swings the baguette - lady?.let { directHit(it, 3) } anim("human_death") open("fade_out") delay(3) - RandomEvents.noteAndTeleport(this) + RandomEvents.fail(this) clearAnim() open("fade_in") } diff --git a/game/src/test/kotlin/content/activity/event/random/SandwichLadyTest.kt b/game/src/test/kotlin/content/activity/event/random/SandwichLadyTest.kt index 914ca0ed43..fad963f2f1 100644 --- a/game/src/test/kotlin/content/activity/event/random/SandwichLadyTest.kt +++ b/game/src/test/kotlin/content/activity/event/random/SandwichLadyTest.kt @@ -69,18 +69,16 @@ class SandwichLadyTest : WorldTest() { } @Test - fun `Choosing the wrong food hits the player and applies the ignore penalty`() { + fun `Choosing the wrong food knocks the player out and exiles them empty-handed`() { val (player, lady) = setup("sl_wrong", "meat_pie") player.inventory.add("logs", 4) - val hpBefore = player.levels.get(Skill.Constitution) openTray(player, lady) player.interfaceOption("sandwich_lady_select", "baguette", "Choose refreshment") tick(5) // baguette swing, knockout animation, screen fade, then teleport assertEquals(0, player.inventory.count("baguette")) - assertEquals(hpBefore - 3, player.levels.get(Skill.Constitution)) // she smacks you for 3 - assertEquals(4, player.inventory.count("logs_noted")) + assertEquals(4, player.inventory.count("logs")) // knockout doesn't note items assertNull(player.get("random_event")) assertTrue(player.contains("random_event_cooldown")) } From cf039a1bab918f1eadab5cc3a907ba3c60f78b3b Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Fri, 10 Jul 2026 14:54:55 -0700 Subject: [PATCH 72/89] Allow players to disable random events with ::randomevents --- .../event/random/random_event.vars.toml | 4 +++ .../event/random/RandomEventTrigger.kt | 14 ++++++++++ .../activity/event/random/RandomEvents.kt | 8 ++++++ game/src/main/resources/game.properties | 3 +++ .../event/random/RandomEventTriggerTest.kt | 27 +++++++++++++++++++ 5 files changed, 56 insertions(+) diff --git a/data/activity/event/random/random_event.vars.toml b/data/activity/event/random/random_event.vars.toml index e243e95cef..b3f409ad21 100644 --- a/data/activity/event/random/random_event.vars.toml +++ b/data/activity/event/random/random_event.vars.toml @@ -9,3 +9,7 @@ persist = true [random_event_cooldown] format = "int" persist = true + +[random_events_disabled] +format = "boolean" +persist = true 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 a77cdb0a1a..d26f177c7f 100644 --- a/game/src/main/kotlin/content/activity/event/random/RandomEventTrigger.kt +++ b/game/src/main/kotlin/content/activity/event/random/RandomEventTrigger.kt @@ -2,8 +2,10 @@ package content.activity.event.random import world.gregs.voidps.engine.Script import world.gregs.voidps.engine.client.command.adminCommand +import world.gregs.voidps.engine.client.command.playerCommand import world.gregs.voidps.engine.client.command.stringArg import world.gregs.voidps.engine.client.message +import world.gregs.voidps.engine.data.Settings /** * Rolls for a random event on each experience drop, so only active players are targeted. @@ -17,6 +19,18 @@ class RandomEventTrigger : Script { RandomEvents.roll(this) } + playerCommand("randomevents", desc = "Toggle random events on or off") { + if (!Settings["events.randomEvents.optOut", false]) { + message("Random events can't be turned off on this world.") + return@playerCommand + } + if (toggle("random_events_disabled")) { + message("Random events are now off.") + } else { + message("Random events are now on.") + } + } + adminCommand("randomevent", stringArg("event", optional = true), desc = "Start a random event") { args -> val event = args.getOrNull(0) ?: RandomEvents.pick() if (event == null || !RandomEvents.start(this, event)) { 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 23cdba827b..ce2f4125ed 100644 --- a/game/src/main/kotlin/content/activity/event/random/RandomEvents.kt +++ b/game/src/main/kotlin/content/activity/event/random/RandomEvents.kt @@ -10,6 +10,7 @@ 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.client.variable.start +import world.gregs.voidps.engine.data.Settings import world.gregs.voidps.engine.data.definition.Areas import world.gregs.voidps.engine.data.definition.ItemDefinitions import world.gregs.voidps.engine.data.definition.Tables @@ -83,6 +84,7 @@ object RandomEvents : AutoCloseable { } private fun eligible(player: Player): Boolean = !player.isBot && + !optedOut(player) && !player.contains("random_event") && player.instance() == null && !player.inCombat && @@ -93,6 +95,12 @@ object RandomEvents : AutoCloseable { !player.hasClock("random_event_cooldown", epochSeconds()) && Areas.get(player.tile.zone).none { it.tags.contains("no_random_events") } + /** + * Whether the player has turned events off with `::randomevents`; only honoured on worlds + * with the `events.randomEvents.optOut` setting enabled. + */ + fun optedOut(player: Player): Boolean = Settings["events.randomEvents.optOut", false] && player["random_events_disabled", false] + fun start(player: Player, id: String? = pick()): Boolean { val launcher = events[id ?: return false] ?: return false player["random_event"] = id diff --git a/game/src/main/resources/game.properties b/game/src/main/resources/game.properties index 01b077c95b..7bbd59b86c 100644 --- a/game/src/main/resources/game.properties +++ b/game/src/main/resources/game.properties @@ -222,6 +222,9 @@ events.shootingStars.enabled=true events.shootingStars.minRespawnTimeMinutes=60 events.shootingStars.maxRespawnTimeMinutes=120 +# Whether players can turn off random events for themselves with the ::randomevents command +events.randomEvents.optOut=false + # Whether Stronghold of Security doors should give questions or not strongholdOfSecurity.quiz=true 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 ad749cc21f..bd1f893ad3 100644 --- a/game/src/test/kotlin/content/activity/event/random/RandomEventTriggerTest.kt +++ b/game/src/test/kotlin/content/activity/event/random/RandomEventTriggerTest.kt @@ -5,6 +5,7 @@ import content.bot.Bot import content.quest.instance import io.mockk.mockk import org.junit.jupiter.api.Test +import world.gregs.voidps.engine.data.Settings 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 @@ -63,6 +64,32 @@ class RandomEventTriggerTest : WorldTest() { assertFalse(player.queue.contains("random_event_start")) } + @Test + fun `Players who opt out don't receive random events`() { + Settings.load(mapOf("events.randomEvents.optOut" to "true")) + val player = createPlayer(Tile(3221, 3218), "re_opt_out") + player["random_event_cooldown"] = 1 + player["random_events_disabled"] = true + + player.exp(Skill.Woodcutting, 100.0) + tick(10) + + assertNull(player.get("random_event")) + } + + @Test + fun `Opting out is ignored on worlds without the opt out setting`() { + Settings.load(mapOf("events.randomEvents.optOut" to "false")) + val player = createPlayer(Tile(3221, 3218), "re_opt_out_off") + player["random_event_cooldown"] = 1 + player["random_events_disabled"] = true + + player.exp(Skill.Woodcutting, 100.0) + tick(10) + + assertEquals("maze", player.get("random_event")) + } + @Test fun `No random event in areas tagged no_random_events`() { val player = createPlayer(Tile(3098, 3107), "re_tutorial") From 00c639db6d4773801631bda76b533556325b86be Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Fri, 10 Jul 2026 15:15:10 -0700 Subject: [PATCH 73/89] Skip already-noted items in the random event ignore penalty --- .../main/kotlin/content/activity/event/random/RandomEvents.kt | 3 ++- .../kotlin/content/activity/event/random/RandomEventsTest.kt | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) 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 ce2f4125ed..b4cd2eec74 100644 --- a/game/src/main/kotlin/content/activity/event/random/RandomEvents.kt +++ b/game/src/main/kotlin/content/activity/event/random/RandomEvents.kt @@ -2,6 +2,7 @@ package content.activity.event.random import content.bot.isBot import content.entity.combat.inCombat +import content.entity.player.bank.isNote import content.quest.clearInstance import content.quest.instance import world.gregs.voidps.engine.Script @@ -146,7 +147,7 @@ object RandomEvents : AutoCloseable { player.inventory.transaction { for (index in player.inventory.indices) { val item = player.inventory[index] - if (item.isEmpty()) { + if (item.isEmpty() || item.isNote) { continue } val noteId = item.def.noteId diff --git a/game/src/test/kotlin/content/activity/event/random/RandomEventsTest.kt b/game/src/test/kotlin/content/activity/event/random/RandomEventsTest.kt index 7a44042b9c..242fb5fde7 100644 --- a/game/src/test/kotlin/content/activity/event/random/RandomEventsTest.kt +++ b/game/src/test/kotlin/content/activity/event/random/RandomEventsTest.kt @@ -53,13 +53,14 @@ class RandomEventsTest : WorldTest() { player["random_event"] = "certer" player["random_event_origin"] = Tile(3221, 3218).id player.inventory.add("logs", 3) + player.inventory.add("logs_noted", 5) 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")) + assertEquals(8, player.inventory.count("logs_noted")) // already-noted items stay noted assertNull(NPCs.indexed(npc.index), "Event NPC should be removed after the penalty") } From 45aae8bc68e2caf4af54d466ab7d29493d86d1fd Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Fri, 10 Jul 2026 15:15:10 -0700 Subject: [PATCH 74/89] Guard the maze shrine and reset the chest cap between mazes --- .../main/kotlin/content/activity/event/random/maze/Maze.kt | 4 ++++ 1 file changed, 4 insertions(+) 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 a0406cc596..4a0acf191a 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 @@ -74,9 +74,13 @@ class Maze : Script { openTabs() clearMinimap() clear("maze_ticks") + clear("maze_chests_opened") } objectOperate("Touch", "strange_shrine") { (target) -> + if (get("random_event") != "maze") { + return@objectOperate + } 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. From 598740cae92ea20bb78922a713825796cb4ab166 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Fri, 10 Jul 2026 15:15:10 -0700 Subject: [PATCH 75/89] Only unlock Prison Pete's locks when keys are handed over --- .../random/prison_pete/prison_pete.vars.toml | 4 +- .../event/random/prison_pete/PrisonPete.kt | 50 +++++++++---------- .../activity/event/random/PrisonPeteTest.kt | 10 ++-- 3 files changed, 32 insertions(+), 32 deletions(-) diff --git a/data/activity/event/random/prison_pete/prison_pete.vars.toml b/data/activity/event/random/prison_pete/prison_pete.vars.toml index 8a910d372c..fb23cbfe96 100644 --- a/data/activity/event/random/prison_pete/prison_pete.vars.toml +++ b/data/activity/event/random/prison_pete/prison_pete.vars.toml @@ -6,6 +6,6 @@ persist = true format = "string" persist = true -[prison_pete_wrong] -format = "boolean" +[prison_pete_pending] +format = "int" persist = true diff --git a/game/src/main/kotlin/content/activity/event/random/prison_pete/PrisonPete.kt b/game/src/main/kotlin/content/activity/event/random/prison_pete/PrisonPete.kt index 5b1a28223c..8387ed89e9 100644 --- a/game/src/main/kotlin/content/activity/event/random/prison_pete/PrisonPete.kt +++ b/game/src/main/kotlin/content/activity/event/random/prison_pete/PrisonPete.kt @@ -85,9 +85,12 @@ class PrisonPete : Script { } private suspend fun Player.startEvent() { - set("prison_pete_keys", 0) - clear("prison_pete_target") - clear("prison_pete_wrong") + if (!contains("prison_pete_keys")) { + // prison_pete_keys is set for the whole event; a relog resumes with progress intact. + set("prison_pete_keys", 0) + clear("prison_pete_target") + clear("prison_pete_pending") + } catIntro() kidnap(PRISON) message("Welcome to ScapeRune.") @@ -138,9 +141,8 @@ class PrisonPete : Script { sound("prison_pete_balloon_pop") balloon.gfx("prison_pete_balloon_pop") if (balloon.id == target) { - inc("prison_pete_keys") - } else { - set("prison_pete_wrong", true) + // The key only opens a lock once it's handed to Pete. + inc("prison_pete_pending") } delay(1) val spawn = balloon.tile @@ -188,24 +190,22 @@ class PrisonPete : Script { face(pete) pete.face(this) npc("prison_pete", "Ooh, thanks! I'll see if it's the right one...") - when { - get("prison_pete_keys", 0) >= REQUIRED -> { - npc("prison_pete", "You did it, you got all the keys right!") - npc("prison_pete", "Thank you! You're my friend FOREVER!") - player("Let's get out of here before that cat notices.") - } - get("prison_pete_wrong", false) -> { - clear("prison_pete_wrong") - jingle("prison_pete_failure") - npc("prison_pete", "Aww, that was the wrong key! Try the lever again to see which balloon you need.") - } - else -> { - pete.face(Direction.SOUTH_EAST) - pete.anim("pick_pocket") - delay(2) - pete.face(this) - npc("prison_pete", "Hooray, you got the right one! Now pull the lever again and let's get the next lock unlocked.") - } + if (get("prison_pete_pending", 0) <= 0) { + jingle("prison_pete_failure") + npc("prison_pete", "Aww, that was the wrong key! Try the lever again to see which balloon you need.") + return + } + dec("prison_pete_pending") + pete.face(Direction.SOUTH_EAST) + pete.anim("pick_pocket") + delay(2) + pete.face(this) + if (inc("prison_pete_keys") >= REQUIRED) { + npc("prison_pete", "You did it, you got all the keys right!") + npc("prison_pete", "Thank you! You're my friend FOREVER!") + player("Let's get out of here before that cat notices.") + } else { + npc("prison_pete", "Hooray, you got the right one! Now pull the lever again and let's get the next lock unlocked.") } } @@ -221,7 +221,7 @@ class PrisonPete : Script { } clear("prison_pete_keys") clear("prison_pete_target") - clear("prison_pete_wrong") + clear("prison_pete_pending") anim("teleport_modern") sound("teleport") gfx("teleport_modern") diff --git a/game/src/test/kotlin/content/activity/event/random/PrisonPeteTest.kt b/game/src/test/kotlin/content/activity/event/random/PrisonPeteTest.kt index a45c5f0c6b..bb002416af 100644 --- a/game/src/test/kotlin/content/activity/event/random/PrisonPeteTest.kt +++ b/game/src/test/kotlin/content/activity/event/random/PrisonPeteTest.kt @@ -94,15 +94,15 @@ class PrisonPeteTest : WorldTest() { } @Test - fun `Popping the right balloon earns a counted key`() { + fun `Popping the right balloon earns a key for Pete`() { val player = enter("pp_correct") player.pullLever() player.popBalloon(correct = true) - assertEquals(1, player.get("prison_pete_keys", 0)) + assertEquals(0, player.get("prison_pete_keys", 0)) // locks only open once Pete gets the key + assertEquals(1, player.get("prison_pete_pending", 0)) assertTrue(player.inventory.contains("prison_key_prison_pete")) - assertFalse(player.get("prison_pete_wrong", false)) assertNull(player.get("prison_pete_target")) } @@ -114,8 +114,8 @@ class PrisonPeteTest : WorldTest() { player.popBalloon(correct = false) assertEquals(0, player.get("prison_pete_keys", 0)) + assertEquals(0, player.get("prison_pete_pending", 0)) assertTrue(player.inventory.contains("prison_key_prison_pete")) - assertTrue(player.get("prison_pete_wrong", false)) } @Test @@ -127,7 +127,7 @@ class PrisonPeteTest : WorldTest() { player.handKeyToPete() assertFalse(player.inventory.contains("prison_key_prison_pete")) - assertFalse(player.get("prison_pete_wrong", false)) + assertEquals(0, player.get("prison_pete_keys", 0)) assertEquals("prison_pete", player.get("random_event")) } From cb1b01fc5a68a9d3d23e098396957a59fb8935df Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Fri, 10 Jul 2026 15:15:10 -0700 Subject: [PATCH 76/89] Exile the player when the beekeeper's hive build fails --- .../content/activity/event/random/beekeeper/Beekeeper.kt | 8 ++++---- .../kotlin/content/activity/event/random/BeekeeperTest.kt | 5 +++-- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/game/src/main/kotlin/content/activity/event/random/beekeeper/Beekeeper.kt b/game/src/main/kotlin/content/activity/event/random/beekeeper/Beekeeper.kt index 38851aa6b4..2d14aa71d0 100644 --- a/game/src/main/kotlin/content/activity/event/random/beekeeper/Beekeeper.kt +++ b/game/src/main/kotlin/content/activity/event/random/beekeeper/Beekeeper.kt @@ -33,8 +33,8 @@ import world.gregs.voidps.type.random * Beekeeper random event: the Bee keeper drags the player to his field of beehives and asks for help * building a new hive. The four hive components (interface 420, driven by varp 805's varbits) start * jumbled beside the frame; drag them into the frame in the right order and click Build. Six wrong - * builds and the bees lose patience, chasing the player off empty-handed; success earns a random - * event gift before the trip home. + * builds and the bees lose patience, chasing the player off to exile empty-handed; success earns a + * random event gift before the trip home. * https://runescape.wiki/w/Beekeeper */ class Beekeeper : Script { @@ -67,9 +67,9 @@ class Beekeeper : Script { } private suspend fun Player.startEvent() { + // beekeeper_intro persists so a relogged player isn't lectured twice; it's cleared on event end. shuffleParts() set("beekeeper_tries", TRIES) - clear("beekeeper_intro") herald() val offset = copyField() kidnap(FIELD.add(offset)) @@ -174,7 +174,7 @@ class Beekeeper : Script { say("Aaaaargh - BEES!") delay(2) clearState() - RandomEvents.complete(this) + RandomEvents.fail(this) } private fun Player.clearState() { diff --git a/game/src/test/kotlin/content/activity/event/random/BeekeeperTest.kt b/game/src/test/kotlin/content/activity/event/random/BeekeeperTest.kt index 228ed422ce..edbd2184de 100644 --- a/game/src/test/kotlin/content/activity/event/random/BeekeeperTest.kt +++ b/game/src/test/kotlin/content/activity/event/random/BeekeeperTest.kt @@ -15,6 +15,7 @@ import world.gregs.voidps.network.client.instruction.MoveInventoryItem import world.gregs.voidps.type.Tile import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertNotEquals import kotlin.test.assertNull import kotlin.test.assertTrue @@ -125,7 +126,7 @@ class BeekeeperTest : WorldTest() { } @Test - fun `Six wrong builds send the player home empty-handed`() { + fun `Six wrong builds exile the player empty-handed`() { val player = enter("bees_fail") player.talkThroughIntro() @@ -136,7 +137,7 @@ class BeekeeperTest : WorldTest() { tick(4) assertNull(player.get("random_event")) - assertEquals(origin, player.tile) + assertNotEquals(origin, player.tile) assertTrue(player.contains("random_event_cooldown")) assertFalse(player.inventory.contains("random_event_gift")) } From a7b6bcd25c12e88231a960bafcf2543e5e6c656c Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Fri, 10 Jul 2026 15:15:10 -0700 Subject: [PATCH 77/89] Let the Quiz Master resume an interrupted quiz --- .../event/random/quiz_master/QuizMaster.kt | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/game/src/main/kotlin/content/activity/event/random/quiz_master/QuizMaster.kt b/game/src/main/kotlin/content/activity/event/random/quiz_master/QuizMaster.kt index 72125942c1..a62730600a 100644 --- a/game/src/main/kotlin/content/activity/event/random/quiz_master/QuizMaster.kt +++ b/game/src/main/kotlin/content/activity/event/random/quiz_master/QuizMaster.kt @@ -39,10 +39,22 @@ class QuizMaster : Script { val slot = it.substringAfter(":button_").toIntOrNull() ?: return@continueDialogue (suspension as? Suspension.IntEntry)?.resume(slot) } + + // Any other interaction cancels the suspended question; talking to the Quiz Master + // resumes the show from the current score. + npcOperate("Talk-to", "quiz_master") { + if (get("random_event") != "quiz_master") { + return@npcOperate + } + runQuiz() + } } private suspend fun Player.startEvent() { - set("quiz_correct", 0) + if (!contains("quiz_answer")) { + // quiz_answer is only set mid-show; keep the score when resuming after a relog. + set("quiz_correct", 0) + } quizHerald() kidnap(ROOM) face(QUIZ_MASTER) @@ -113,7 +125,7 @@ class QuizMaster : Script { // 28 battleaxe, 29 salmon, 30 trout, 31 necklace, 32 shield, 33 helm, 34 ring, // 35 secateurs, 36 sword, 37 trowel. private val SETS = listOf( - intArrayOf(8828, 8829, 8829), // weapon vs two fish + intArrayOf(8828, 8829, 8830), // weapon vs two fish intArrayOf(8831, 8837, 8835), // jewellery vs two gardening tools intArrayOf(8830, 8832, 8833), // fish vs two pieces of armour intArrayOf(8835, 8834, 8831), // gardening tool vs two pieces of jewellery From 9c7985cce1286b63b5c0581541f72ba7c1e9b811 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Fri, 10 Jul 2026 15:15:10 -0700 Subject: [PATCH 78/89] Clean up Kiss the Frog royals on logout and resume on relog --- .../event/random/kiss_the_frog/KissTheFrog.kt | 39 ++++++++++++++++--- 1 file changed, 33 insertions(+), 6 deletions(-) diff --git a/game/src/main/kotlin/content/activity/event/random/kiss_the_frog/KissTheFrog.kt b/game/src/main/kotlin/content/activity/event/random/kiss_the_frog/KissTheFrog.kt index f8e932059b..7d2b0118b0 100644 --- a/game/src/main/kotlin/content/activity/event/random/kiss_the_frog/KissTheFrog.kt +++ b/game/src/main/kotlin/content/activity/event/random/kiss_the_frog/KissTheFrog.kt @@ -77,13 +77,36 @@ class KissTheFrog : Script { else -> plainFrog(frog) } } + + // The crown and escape royals have no lifetime timer, so remove them on logout; the + // relog resume in startEvent spawns fresh ones. + playerDespawn { + if (get("random_event") == "kiss_the_frog") { + removeRoyals() + } + } } private fun Player.startEvent() { - clear("ktf_fail") - clear("ktf_offended") - clear("ktf_escape") - startInPlaceEvent("frog_herald", nagLines()) + when { + get("ktf_fail", false) -> { + // Relogged inside the frog cave: restore the frog form and the escape royal. + transform(PLAYER_FROG) + tele(FAIL_CAVE) + restrictTabs() + val escape = NPCs.add(ROYAL, ESCAPE_TILE, ticks = -1, owner = this) + set("ktf_escape", escape.index) + } + tile.region == LAND.region -> { + // Relogged in the Land of the Frogs: hide a fresh crowned royal among the frogs. + spawnCrown() + restrictTabs() + } + else -> { + clear("ktf_offended") + startInPlaceEvent("frog_herald", nagLines()) + } + } } /** The royal the player must rescue - a male player helps the Princess, a female player the Prince. */ @@ -234,10 +257,14 @@ class KissTheFrog : Script { private fun Player.cleanup() { openTabs() clearTransform() - NPCs.indexed(get("ktf_crown", -1))?.let { if (it.owner == this) NPCs.remove(it) } - NPCs.indexed(get("ktf_escape", -1))?.let { if (it.owner == this) NPCs.remove(it) } + removeRoyals() clear("ktf_fail") clear("ktf_offended") + } + + private fun Player.removeRoyals() { + NPCs.indexed(get("ktf_crown", -1))?.let { if (it.owner == this) NPCs.remove(it) } + NPCs.indexed(get("ktf_escape", -1))?.let { if (it.owner == this) NPCs.remove(it) } clear("ktf_crown") clear("ktf_escape") } From 5e53da36438d0392c568f41dd2f049a219293a90 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Fri, 10 Jul 2026 15:15:10 -0700 Subject: [PATCH 79/89] Resume random event progress on relog and clean up leftover state --- .../random/drill_demon/drill_demon.varbits.toml | 2 +- .../lost_and_found/lost_and_found.vars.toml | 2 +- .../activity/event/random/capn_arnav/CapnArnav.kt | 9 ++++++--- .../event/random/drill_demon/DrillDemon.kt | 15 ++++++++++++--- .../activity/event/random/evil_bob/EvilBob.kt | 9 +++++++-- .../random/freaky_forester/FreakyForester.kt | 11 ++++++++--- .../activity/event/random/mime_theatre/Mime.kt | 5 ++++- .../activity/event/random/pinball/Pinball.kt | 5 ++++- .../event/random/sandwich_lady/SandwichLady.kt | 7 +++++++ .../event/random/surprise_exam/SurpriseExam.kt | 6 ++++-- .../activity/event/random/SandwichLadyTest.kt | 1 - 11 files changed, 54 insertions(+), 18 deletions(-) diff --git a/data/activity/event/random/drill_demon/drill_demon.varbits.toml b/data/activity/event/random/drill_demon/drill_demon.varbits.toml index da0c03251d..62b5ceb05c 100644 --- a/data/activity/event/random/drill_demon/drill_demon.varbits.toml +++ b/data/activity/event/random/drill_demon/drill_demon.varbits.toml @@ -1,4 +1,4 @@ -# Each mat's sign display: 0 jog, 1 sit-ups, 2 push-ups, 3 star jumps. +# Each mat's sign display: 1 jog, 2 sit-ups, 3 push-ups, 4 star jumps (0 = no sign shown). [drill_demon_sign_1] id = 1335 format = "int" diff --git a/data/activity/event/random/lost_and_found/lost_and_found.vars.toml b/data/activity/event/random/lost_and_found/lost_and_found.vars.toml index a03945e2c6..50b537a60b 100644 --- a/data/activity/event/random/lost_and_found/lost_and_found.vars.toml +++ b/data/activity/event/random/lost_and_found/lost_and_found.vars.toml @@ -1,3 +1,3 @@ +# Not persisted: the appendage varbits reshuffle on relog and the odd slot is re-picked with them. [laf_odd] format = "int" -persist = true diff --git a/game/src/main/kotlin/content/activity/event/random/capn_arnav/CapnArnav.kt b/game/src/main/kotlin/content/activity/event/random/capn_arnav/CapnArnav.kt index ff3081b601..690af50485 100644 --- a/game/src/main/kotlin/content/activity/event/random/capn_arnav/CapnArnav.kt +++ b/game/src/main/kotlin/content/activity/event/random/capn_arnav/CapnArnav.kt @@ -81,9 +81,12 @@ class CapnArnav : Script { } private suspend fun Player.startEvent() { - set("arnav_tries", 0) - clear("arnav_solved") - clear("arnav_target") + if (!contains("arnav_target")) { + // arnav_target is only set once the lock is opened; a relog mid-event resumes + // with tries and the solved flag intact so the reward can't be re-earned. + set("arnav_tries", 0) + clear("arnav_solved") + } arnavHerald() kidnap(ISLAND) } diff --git a/game/src/main/kotlin/content/activity/event/random/drill_demon/DrillDemon.kt b/game/src/main/kotlin/content/activity/event/random/drill_demon/DrillDemon.kt index b5ca4ad8a7..c8610c8120 100644 --- a/game/src/main/kotlin/content/activity/event/random/drill_demon/DrillDemon.kt +++ b/game/src/main/kotlin/content/activity/event/random/drill_demon/DrillDemon.kt @@ -72,9 +72,14 @@ class DrillDemon : Script { } private suspend fun Player.startEvent() { - set("drill_demon_correct", 0) clear("drill_demon_ready") - assignRound(reveal = false) + if (contains("drill_demon_task")) { + // Resuming after a relog: keep the ordered task and progress, just restore the signs. + shuffleSigns(reveal = false) + } else { + set("drill_demon_correct", 0) + assignRound(reveal = false) + } mysteriousOldMan() kidnap(YARD) message("Follow Sergeant Damien's orders!") @@ -89,8 +94,12 @@ class DrillDemon : Script { * tick apart (the signs themselves all change this tick; only the puffs are staggered client-side). */ private fun Player.assignRound(reveal: Boolean = true) { - val exercises = EXERCISES.keys.shuffled(random) set("drill_demon_task", EXERCISES.keys.random(random)) + shuffleSigns(reveal) + } + + private fun Player.shuffleSigns(reveal: Boolean) { + val exercises = EXERCISES.keys.shuffled(random) for (sign in 1..exercises.size) { set("drill_demon_sign_$sign", exercises[sign - 1]) if (reveal) { diff --git a/game/src/main/kotlin/content/activity/event/random/evil_bob/EvilBob.kt b/game/src/main/kotlin/content/activity/event/random/evil_bob/EvilBob.kt index e77991389e..5052fef83a 100644 --- a/game/src/main/kotlin/content/activity/event/random/evil_bob/EvilBob.kt +++ b/game/src/main/kotlin/content/activity/event/random/evil_bob/EvilBob.kt @@ -118,8 +118,12 @@ class EvilBob : Script { val servant = NPCs.add("evil_bob_servant", SERVANT_TILE.add(offset), ticks = -1, owner = this) servant.watch(this) - assignZone() - inventory.add("small_fishing_net") + if (get("evil_bob_zone", 0) == 0) { + assignZone() + } + if (!inventory.contains("small_fishing_net")) { + inventory.add("small_fishing_net") + } face(bob.tile) message("Welcome to ScapeRune.") evilBobDialogue() @@ -310,6 +314,7 @@ class EvilBob : Script { } } inventory.remove("small_fishing_net") + clear("evil_bob_npc") clear("evil_bob_zone") clear("evil_bob_complete") clear("evil_bob_attentive") 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 index 8bc59ef2af..202b20f00a 100644 --- 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 @@ -61,7 +61,10 @@ class FreakyForester : Script { } private suspend fun Player.startEvent() { - set("freaky_forester_task", random.nextInt(1, TAILS + 1)) + if (!contains("freaky_forester_task")) { + // Keep the assigned pheasant on a relog resume. + set("freaky_forester_task", random.nextInt(1, TAILS + 1)) + } kidnap(CLEARING) giveTask() } @@ -69,15 +72,17 @@ class FreakyForester : Script { private suspend fun Player.foresterDialogue() { when { inventory.contains("raw_pheasant") -> { - inventory.remove("raw_pheasant") + // Dialogue first: walking off cancels the handler, so only take the bird + // once the suspending lines are done and the reward is guaranteed. npc("freaky_forester", "Thanks, $name, you may leave the area now.") reward() + inventory.remove("raw_pheasant") 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.") + inventory.remove("raw_pheasant_incorrect") } else -> giveTask() } diff --git a/game/src/main/kotlin/content/activity/event/random/mime_theatre/Mime.kt b/game/src/main/kotlin/content/activity/event/random/mime_theatre/Mime.kt index 1844dcb78d..100e8df373 100644 --- a/game/src/main/kotlin/content/activity/event/random/mime_theatre/Mime.kt +++ b/game/src/main/kotlin/content/activity/event/random/mime_theatre/Mime.kt @@ -44,7 +44,10 @@ class Mime : Script { } private suspend fun Player.startEvent() { - set("mime_correct", 0) + if (!contains("mime_emote")) { + // mime_emote is only set mid-performance; keep the score when resuming after a relog. + set("mime_correct", 0) + } mysteriousOldMan() kidnap(SPAWN) npc("mysterious_old_man", "Here's a little challenge for you:
Copy the Mime's performance, then you'll be released.") diff --git a/game/src/main/kotlin/content/activity/event/random/pinball/Pinball.kt b/game/src/main/kotlin/content/activity/event/random/pinball/Pinball.kt index c754cd5df5..a6255ba47b 100644 --- a/game/src/main/kotlin/content/activity/event/random/pinball/Pinball.kt +++ b/game/src/main/kotlin/content/activity/event/random/pinball/Pinball.kt @@ -73,7 +73,10 @@ class Pinball : Script { } private suspend fun Player.startEvent() { - set("pinball_score", 0) + if (!contains("pinball_score")) { + // pinball_score is set for the whole event; a relog resumes with the score intact. + set("pinball_score", 0) + } clear("pinball_target") mysteriousOldMan() val offset = copyArena() diff --git a/game/src/main/kotlin/content/activity/event/random/sandwich_lady/SandwichLady.kt b/game/src/main/kotlin/content/activity/event/random/sandwich_lady/SandwichLady.kt index 33df2b8372..2db5669227 100644 --- a/game/src/main/kotlin/content/activity/event/random/sandwich_lady/SandwichLady.kt +++ b/game/src/main/kotlin/content/activity/event/random/sandwich_lady/SandwichLady.kt @@ -70,11 +70,17 @@ class SandwichLady : Script { anim("human_death") open("fade_out") delay(3) + clearEvent() RandomEvents.fail(this) clearAnim() open("fade_in") } + private fun Player.clearEvent() { + clear("sandwich_lady_food") + clear("sandwich_lady_npc") + } + private fun Player.lady(): NPC? = NPCs.indexed(get("sandwich_lady_npc", -1))?.takeIf { it.id == "sandwich_lady" } private suspend fun Player.serve(food: String) { @@ -82,6 +88,7 @@ class SandwichLady : Script { addOrDrop(food) addOrDrop("random_event_gift") npc("sandwich_lady", "Hope that fills you up!") + clearEvent() // Clearing the event state removes the following NPC on its next tick. RandomEvents.completeInPlace(this) } diff --git a/game/src/main/kotlin/content/activity/event/random/surprise_exam/SurpriseExam.kt b/game/src/main/kotlin/content/activity/event/random/surprise_exam/SurpriseExam.kt index 2d45ca4f03..eefb3cce6e 100644 --- a/game/src/main/kotlin/content/activity/event/random/surprise_exam/SurpriseExam.kt +++ b/game/src/main/kotlin/content/activity/event/random/surprise_exam/SurpriseExam.kt @@ -77,8 +77,10 @@ class SurpriseExam : Script { } private suspend fun Player.startEvent() { - set("surprise_exam_correct", 0) - clear("surprise_exam_door") + if (!contains("surprise_exam_correct")) { + // surprise_exam_correct is set for the whole exam; a relog resumes with progress intact. + set("surprise_exam_correct", 0) + } mysteriousOldMan() kidnap(CLASSROOM) message("Speak to Mr Mordaut to begin your exam.") diff --git a/game/src/test/kotlin/content/activity/event/random/SandwichLadyTest.kt b/game/src/test/kotlin/content/activity/event/random/SandwichLadyTest.kt index fad963f2f1..10ae5c2599 100644 --- a/game/src/test/kotlin/content/activity/event/random/SandwichLadyTest.kt +++ b/game/src/test/kotlin/content/activity/event/random/SandwichLadyTest.kt @@ -8,7 +8,6 @@ import skipDialogues 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.inv.add import world.gregs.voidps.engine.inv.inventory import world.gregs.voidps.type.Tile From d5d1be8c4631967ad11953fdc820a938d8ee003b Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Fri, 10 Jul 2026 15:15:10 -0700 Subject: [PATCH 80/89] Add Pinball random event tests --- .../activity/event/random/PinballTest.kt | 108 ++++++++++++++++++ 1 file changed, 108 insertions(+) create mode 100644 game/src/test/kotlin/content/activity/event/random/PinballTest.kt diff --git a/game/src/test/kotlin/content/activity/event/random/PinballTest.kt b/game/src/test/kotlin/content/activity/event/random/PinballTest.kt new file mode 100644 index 0000000000..ba4d933236 --- /dev/null +++ b/game/src/test/kotlin/content/activity/event/random/PinballTest.kt @@ -0,0 +1,108 @@ +package content.activity.event.random + +import WorldTest +import content.quest.instance +import objectOption +import org.junit.jupiter.api.Test +import skipDialogues +import world.gregs.voidps.engine.client.ui.dialogue +import world.gregs.voidps.engine.entity.character.player.Player +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 PinballTest : WorldTest() { + + private val origin = Tile(3221, 3218) + + private fun enter(name: String): Player { + val player = createPlayer(origin, name) + RandomEvents.start(player, "pinball") + tick(8) + while (player.dialogue != null) { + player.skipDialogues() + tick() + } + tick(6) // the old man waves, puffs away and the first post lights up + return player + } + + private fun Player.tag(post: String) { + val obj = createObject(post, tile.addY(1)) + objectOption(obj, "Tag") + tick(4) + } + + @Test + fun `The old man kidnaps the player to a private pinball arena`() { + val player = enter("pb_start") + + assertEquals("pinball", player.get("random_event")) + assertNotNull(player.instance()) + assertTrue(player.interfaces.contains("pinball_overlay")) + assertEquals(1, player.get("pinball_target", 0)) // deterministic first post in tests + assertEquals(0, player.get("pinball_score", 0)) + } + + @Test + fun `Tagging the flashing post scores a point and lights the next`() { + val player = enter("pb_score") + + player.tag("pinball_post_1") + + assertEquals(1, player.get("pinball_score", 0)) + assertEquals(1, player.get("pinball_target", 0)) + } + + @Test + fun `Tagging an unlit post resets the score`() { + val player = enter("pb_reset") + player.tag("pinball_post_1") + assertEquals(1, player.get("pinball_score", 0)) + + player.tag("pinball_post_2") + + assertEquals(0, player.get("pinball_score", 0)) + assertEquals("pinball", player.get("random_event")) + } + + @Test + fun `The trolls block the exit until ten points`() { + val player = enter("pb_blocked") + val exit = createObject("pinball_cave_exit", player.tile.addY(1)) + + player.objectOption(exit, "Exit") + tick(2) + while (player.dialogue != null) { + player.skipDialogues() + tick() + } + + assertEquals("pinball", player.get("random_event")) + assertNotNull(player.instance()) + assertTrue(player.inventory.isEmpty()) + } + + @Test + fun `Ten points opens the way out for a gift`() { + val player = enter("pb_win") + + repeat(10) { + player.tag("pinball_post_1") + } + assertEquals(10, player.get("pinball_score", 0)) + + val exit = createObject("pinball_cave_exit", player.tile.addY(1)) + player.objectOption(exit, "Exit") + tick(10) + + assertEquals(1, player.inventory.count("random_event_gift")) + assertNull(player.get("random_event")) + assertNull(player.instance()) + assertEquals(origin, player.tile) + assertTrue(player.contains("random_event_cooldown")) + } +} From 5aaa37c556b84355659694d3ea1218a7d0c08275 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Fri, 10 Jul 2026 15:22:34 -0700 Subject: [PATCH 81/89] Organise random event NPC definitions into per-event configs --- .../freaky_forester/freaky_forester.npcs.toml | 43 ++++++ .../event/random/kiss_the_frog.npcs.toml | 7 - .../kiss_the_frog.anims.toml | 0 .../kiss_the_frog.npc-spawns.toml | 0 .../kiss_the_frog/kiss_the_frog.npcs.toml | 68 ++++++++++ .../kiss_the_frog.vars.toml | 0 .../random/{ => pillory}/pillory.shops.toml | 0 .../random/prison_pete/prison_pete.npcs.toml | 23 ++++ .../random/quiz_master/quiz_master.npcs.toml | 5 + .../event/random/random-events.npcs.toml | 122 ------------------ .../event/random/random_event.npcs.toml | 44 ------- .../sandwich_lady/sandwich_lady.npcs.toml | 3 + .../surprise_exam/surprise_exam.npcs.toml | 27 ++++ 13 files changed, 169 insertions(+), 173 deletions(-) create mode 100644 data/activity/event/random/freaky_forester/freaky_forester.npcs.toml delete mode 100644 data/activity/event/random/kiss_the_frog.npcs.toml rename data/activity/event/random/{ => kiss_the_frog}/kiss_the_frog.anims.toml (100%) rename data/activity/event/random/{ => kiss_the_frog}/kiss_the_frog.npc-spawns.toml (100%) create mode 100644 data/activity/event/random/kiss_the_frog/kiss_the_frog.npcs.toml rename data/activity/event/random/{ => kiss_the_frog}/kiss_the_frog.vars.toml (100%) rename data/activity/event/random/{ => pillory}/pillory.shops.toml (100%) create mode 100644 data/activity/event/random/prison_pete/prison_pete.npcs.toml create mode 100644 data/activity/event/random/quiz_master/quiz_master.npcs.toml delete mode 100644 data/activity/event/random/random-events.npcs.toml create mode 100644 data/activity/event/random/sandwich_lady/sandwich_lady.npcs.toml create mode 100644 data/activity/event/random/surprise_exam/surprise_exam.npcs.toml diff --git a/data/activity/event/random/freaky_forester/freaky_forester.npcs.toml b/data/activity/event/random/freaky_forester/freaky_forester.npcs.toml new file mode 100644 index 0000000000..4b64fa2fb1 --- /dev/null +++ b/data/activity/event/random/freaky_forester/freaky_forester.npcs.toml @@ -0,0 +1,43 @@ +[freaky_forester] +id = 2458 +wander_range = 3 +examine = "He's at home in the forests." + +[pheasant_1_tail] +id = 4277 +hitpoints = 50 +att = 3 +str = 3 +def = 3 +style = "stab" +max_hit_melee = 10 +respawn_delay = 5 +examine = "A brightly coloured game bird." + +[pheasant_1_tail_2] +id = 7445 +clone = "pheasant_1_tail" + +[pheasant_2_tails] +id = 7446 +clone = "pheasant_1_tail" + +[pheasant_2_tails_2] +id = 13246 +clone = "pheasant_1_tail" + +[pheasant_3_tails] +id = 13247 +clone = "pheasant_1_tail" + +[pheasant_3_tails_2] +id = 13248 +clone = "pheasant_1_tail" + +[pheasant_4_tails] +id = 13249 +clone = "pheasant_1_tail" + +[pheasant_4_tails_2] +id = 13250 +clone = "pheasant_1_tail" diff --git a/data/activity/event/random/kiss_the_frog.npcs.toml b/data/activity/event/random/kiss_the_frog.npcs.toml deleted file mode 100644 index 4ab216eca9..0000000000 --- a/data/activity/event/random/kiss_the_frog.npcs.toml +++ /dev/null @@ -1,7 +0,0 @@ -[frog_prince] -id = 2474 -examine = "A handsome prince." - -[frog_princess] -id = 2475 -examine = "A beautiful princess." diff --git a/data/activity/event/random/kiss_the_frog.anims.toml b/data/activity/event/random/kiss_the_frog/kiss_the_frog.anims.toml similarity index 100% rename from data/activity/event/random/kiss_the_frog.anims.toml rename to data/activity/event/random/kiss_the_frog/kiss_the_frog.anims.toml diff --git a/data/activity/event/random/kiss_the_frog.npc-spawns.toml b/data/activity/event/random/kiss_the_frog/kiss_the_frog.npc-spawns.toml similarity index 100% rename from data/activity/event/random/kiss_the_frog.npc-spawns.toml rename to data/activity/event/random/kiss_the_frog/kiss_the_frog.npc-spawns.toml diff --git a/data/activity/event/random/kiss_the_frog/kiss_the_frog.npcs.toml b/data/activity/event/random/kiss_the_frog/kiss_the_frog.npcs.toml new file mode 100644 index 0000000000..7ea9aeea0f --- /dev/null +++ b/data/activity/event/random/kiss_the_frog/kiss_the_frog.npcs.toml @@ -0,0 +1,68 @@ +[frog_frogland] +id = 390 +dialogue = "old" +wander_range = 3 +examine = "A frog." + +[frog_2_frogland] +clone = "frog_frogland" +id = 391 +dialogue = "old" + +[frog_3_frogland] +clone = "frog_frogland" +id = 392 +dialogue = "old" + +[frog_4_frogland] +clone = "frog_frogland" +id = 393 +dialogue = "old" + +[frog_5_frogland] +clone = "frog_frogland" +id = 394 +dialogue = "old" + +[frog_6_frogland] +clone = "frog_frogland" +id = 395 +dialogue = "old" + +[frog_7_frogland] +clone = "frog_frogland" +id = 396 +dialogue = "old" + +[frog_8_frogland] +clone = "frog_frogland" +id = 403 +dialogue = "old" + +[frog_herald] +id = 2472 +dialogue = "old" +examine = "Speaks on behalf of His and Her Royal Frogness." + +[frog_prince] +id = 2474 +examine = "A handsome prince." + +[frog_princess] +id = 2475 +examine = "A beautiful princess." + +[frog_9_frogland] +clone = "frog_frogland" +id = 3300 +dialogue = "old" + +[frog_10_frogland] +clone = "frog_frogland" +id = 8636 +dialogue = "old" + +[frog_11_frogland] +clone = "frog_frogland" +id = 8637 +dialogue = "old" diff --git a/data/activity/event/random/kiss_the_frog.vars.toml b/data/activity/event/random/kiss_the_frog/kiss_the_frog.vars.toml similarity index 100% rename from data/activity/event/random/kiss_the_frog.vars.toml rename to data/activity/event/random/kiss_the_frog/kiss_the_frog.vars.toml diff --git a/data/activity/event/random/pillory.shops.toml b/data/activity/event/random/pillory/pillory.shops.toml similarity index 100% rename from data/activity/event/random/pillory.shops.toml rename to data/activity/event/random/pillory/pillory.shops.toml diff --git a/data/activity/event/random/prison_pete/prison_pete.npcs.toml b/data/activity/event/random/prison_pete/prison_pete.npcs.toml new file mode 100644 index 0000000000..6026852726 --- /dev/null +++ b/data/activity/event/random/prison_pete/prison_pete.npcs.toml @@ -0,0 +1,23 @@ +[prison_pete] +id = 3118 +examine = "He wants to escape!" + +[balloon_animal_dog] +id = 11232 +wander_range = 3 +examine = "It looks like an animal." + +[balloon_animal_cat] +id = 11233 +wander_range = 3 +examine = "It looks like an animal." + +[balloon_animal_sheep] +id = 11234 +wander_range = 3 +examine = "It looks like an animal." + +[balloon_animal_goat] +id = 11235 +wander_range = 3 +examine = "It looks like an animal." diff --git a/data/activity/event/random/quiz_master/quiz_master.npcs.toml b/data/activity/event/random/quiz_master/quiz_master.npcs.toml new file mode 100644 index 0000000000..0a5b8787d2 --- /dev/null +++ b/data/activity/event/random/quiz_master/quiz_master.npcs.toml @@ -0,0 +1,5 @@ +[quiz_master] +id = 2477 +interact_distance = 4 +categories = ["human"] +examine = "Apparently a master of quizzes!" diff --git a/data/activity/event/random/random-events.npcs.toml b/data/activity/event/random/random-events.npcs.toml deleted file mode 100644 index 7ae6f29d85..0000000000 --- a/data/activity/event/random/random-events.npcs.toml +++ /dev/null @@ -1,122 +0,0 @@ -[frog_frogland] -id = 390 -dialogue = "old" -wander_range = 3 -examine = "A frog." - -[frog_2_frogland] -clone = "frog_frogland" -id = 391 -dialogue = "old" - -[frog_3_frogland] -clone = "frog_frogland" -id = 392 -dialogue = "old" - -[frog_4_frogland] -clone = "frog_frogland" -id = 393 -dialogue = "old" - -[frog_5_frogland] -clone = "frog_frogland" -id = 394 -dialogue = "old" - -[frog_6_frogland] -clone = "frog_frogland" -id = 395 -dialogue = "old" - -[frog_7_frogland] -clone = "frog_frogland" -id = 396 -dialogue = "old" - -[frog_8_frogland] -clone = "frog_frogland" -id = 403 -dialogue = "old" - -[giant_surprise_exam] -id = 487 -examine = "I guess he wants to be more than just the muscle." - -[mummy_surprise_exam] -id = 490 -examine = "If the mummy is at school, where are the kids?" - -[frog_herald] -id = 2472 -dialogue = "old" -examine = "Speaks on behalf of His and Her Royal Frogness." - -[quiz_master] -id = 2477 -interact_distance = 4 -categories = ["human"] -examine = "Apparently a master of quizzes!" - -[prison_pete] -id = 3118 -examine = "He wants to escape!" - -[frog_9_frogland] -clone = "frog_frogland" -id = 3300 -dialogue = "old" - -[mr_mordau] -id = 6117 -examine = "Professor of Unnatural History." - -[zombie_surprise_exam] -id = 6131 -examine = "Brains!" - -[goblin_surprise_exam] -id = 6132 -examine = "The studious one." - -[goblin_2_surprise_exam] -id = 6133 -examine = "He looks bored." - -[dunce_surprise_exam] -id = 6134 -examine = "This imp is clearly the class clown." - -[sandwich_lady] -id = 8629 -examine = "The sandwich lady." - -[frog_10_frogland] -clone = "frog_frogland" -id = 8636 -dialogue = "old" - -[frog_11_frogland] -clone = "frog_frogland" -id = 8637 -dialogue = "old" - -[balloon_animal_dog] -id = 11232 -wander_range = 3 -examine = "It looks like an animal." - -[balloon_animal_cat] -id = 11233 -wander_range = 3 -examine = "It looks like an animal." - -[balloon_animal_sheep] -id = 11234 -wander_range = 3 -examine = "It looks like an animal." - -[balloon_animal_goat] -id = 11235 -wander_range = 3 -examine = "It looks like an animal." \ No newline at end of file diff --git a/data/activity/event/random/random_event.npcs.toml b/data/activity/event/random/random_event.npcs.toml index 63f7859bfe..77ad89d3ed 100644 --- a/data/activity/event/random/random_event.npcs.toml +++ b/data/activity/event/random/random_event.npcs.toml @@ -1,47 +1,3 @@ [mysterious_old_man] id = 410 examine = "An old man with an aura of magic." - -[freaky_forester] -id = 2458 -wander_range = 3 -examine = "He's at home in the forests." - -[pheasant_1_tail] -id = 4277 -hitpoints = 50 -att = 3 -str = 3 -def = 3 -style = "stab" -max_hit_melee = 10 -respawn_delay = 5 -examine = "A brightly coloured game bird." - -[pheasant_1_tail_2] -id = 7445 -clone = "pheasant_1_tail" - -[pheasant_2_tails] -id = 7446 -clone = "pheasant_1_tail" - -[pheasant_2_tails_2] -id = 13246 -clone = "pheasant_1_tail" - -[pheasant_3_tails] -id = 13247 -clone = "pheasant_1_tail" - -[pheasant_3_tails_2] -id = 13248 -clone = "pheasant_1_tail" - -[pheasant_4_tails] -id = 13249 -clone = "pheasant_1_tail" - -[pheasant_4_tails_2] -id = 13250 -clone = "pheasant_1_tail" \ No newline at end of file diff --git a/data/activity/event/random/sandwich_lady/sandwich_lady.npcs.toml b/data/activity/event/random/sandwich_lady/sandwich_lady.npcs.toml new file mode 100644 index 0000000000..3ce052a144 --- /dev/null +++ b/data/activity/event/random/sandwich_lady/sandwich_lady.npcs.toml @@ -0,0 +1,3 @@ +[sandwich_lady] +id = 8629 +examine = "The sandwich lady." diff --git a/data/activity/event/random/surprise_exam/surprise_exam.npcs.toml b/data/activity/event/random/surprise_exam/surprise_exam.npcs.toml new file mode 100644 index 0000000000..bf100e9c28 --- /dev/null +++ b/data/activity/event/random/surprise_exam/surprise_exam.npcs.toml @@ -0,0 +1,27 @@ +[giant_surprise_exam] +id = 487 +examine = "I guess he wants to be more than just the muscle." + +[mummy_surprise_exam] +id = 490 +examine = "If the mummy is at school, where are the kids?" + +[mr_mordau] +id = 6117 +examine = "Professor of Unnatural History." + +[zombie_surprise_exam] +id = 6131 +examine = "Brains!" + +[goblin_surprise_exam] +id = 6132 +examine = "The studious one." + +[goblin_2_surprise_exam] +id = 6133 +examine = "He looks bored." + +[dunce_surprise_exam] +id = 6134 +examine = "This imp is clearly the class clown." From 2bd9fefe47cdd3b49109d5a0d0ac010b8dd52332 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Fri, 10 Jul 2026 15:22:34 -0700 Subject: [PATCH 82/89] Fix the unreachable home teleport cooldown handler --- game/src/main/kotlin/content/skill/magic/spell/Teleports.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/game/src/main/kotlin/content/skill/magic/spell/Teleports.kt b/game/src/main/kotlin/content/skill/magic/spell/Teleports.kt index de717d9055..345090e54b 100644 --- a/game/src/main/kotlin/content/skill/magic/spell/Teleports.kt +++ b/game/src/main/kotlin/content/skill/magic/spell/Teleports.kt @@ -56,7 +56,11 @@ class Teleports : Script { } } - teleportTakeOff("lumbridge_home_teleport") { + // Registered under the book's type ("modern") because takeOff is keyed by type, not spell. + teleportTakeOff("modern") { + if (it != "lumbridge_home_teleport") { + return@teleportTakeOff true + } val seconds = remaining("home_teleport_timeout", epochSeconds()) if (seconds > 0) { val remaining = TimeUnit.SECONDS.toMinutes(seconds.toLong()) From 2b0fd8808419ba02a2d1065e94b9ca3cb4b3a9a6 Mon Sep 17 00:00:00 2001 From: GregHib Date: Sat, 11 Jul 2026 11:55:07 +0100 Subject: [PATCH 83/89] Move random event settings to game.properties --- data/activity/event/random/random_event.tables.toml | 13 ------------- .../content/activity/event/random/RandomEvents.kt | 11 +++++++---- game/src/main/resources/game.properties | 12 ++++++++++++ 3 files changed, 19 insertions(+), 17 deletions(-) diff --git a/data/activity/event/random/random_event.tables.toml b/data/activity/event/random/random_event.tables.toml index 0517496ac0..777c36c614 100644 --- a/data/activity/event/random/random_event.tables.toml +++ b/data/activity/event/random/random_event.tables.toml @@ -70,19 +70,6 @@ weight = 10 event = "gravedigger" 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" 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 b4cd2eec74..13698da983 100644 --- a/game/src/main/kotlin/content/activity/event/random/RandomEvents.kt +++ b/game/src/main/kotlin/content/activity/event/random/RandomEvents.kt @@ -71,12 +71,15 @@ object RandomEvents : AutoCloseable { * (or a weighted [pick]) for players who are eligible and off cooldown. */ fun roll(player: Player, event: String? = null): Boolean { + if (!Settings["events.randomEvents.active", true]) { + return false + } if (!player.contains("random_event_cooldown")) { // First activity; events unlock once the initial cooldown expires cooldown(player) return false } - if (!eligible(player) || random.nextInt(Tables.int("random_event_settings.roll_chance.value")) != 0) { + if (!eligible(player) || random.nextInt(Settings["events.randomEvents.chance", 300]) != 0) { return false } // Restart the cooldown immediately so a failed pick can't re-roll every attempt @@ -169,11 +172,11 @@ object RandomEvents : AutoCloseable { } /** - * Delay the next event by `random_event_settings` min to max minutes. + * Delay the next event by `events.randomEvents.cooldownMinutes` min to max minutes. */ private 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 min = Settings["events.randomEvents.cooldownMinutesMin", 45] + val max = Settings["events.randomEvents.cooldownMinutesMax", 90] 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/resources/game.properties b/game/src/main/resources/game.properties index 7bbd59b86c..573bfcf1aa 100644 --- a/game/src/main/resources/game.properties +++ b/game/src/main/resources/game.properties @@ -222,9 +222,21 @@ events.shootingStars.enabled=true events.shootingStars.minRespawnTimeMinutes=60 events.shootingStars.maxRespawnTimeMinutes=120 +# Whether random events are enabled for the server +events.randomEvents.active=true + # Whether players can turn off random events for themselves with the ::randomevents command events.randomEvents.optOut=false +# 1 in X chance of an event per experience drop +events.randomEvents.chance=300 + +# Min minutes until you can receive another a random event +events.randomEvents.cooldownMinutesMin=45 + +# Max minutes until you can receive another a random event +events.randomEvents.cooldownMinutesMax=90 + # Whether Stronghold of Security doors should give questions or not strongholdOfSecurity.quiz=true From 3070542409be033bfe99a452eb158f3948a75482 Mon Sep 17 00:00:00 2001 From: GregHib Date: Sat, 11 Jul 2026 11:55:29 +0100 Subject: [PATCH 84/89] Reuse String.an for MysteryBox.kt --- .../kotlin/content/activity/event/random/MysteryBox.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/game/src/main/kotlin/content/activity/event/random/MysteryBox.kt b/game/src/main/kotlin/content/activity/event/random/MysteryBox.kt index 5d85e1c9b2..3fbb2db336 100644 --- a/game/src/main/kotlin/content/activity/event/random/MysteryBox.kt +++ b/game/src/main/kotlin/content/activity/event/random/MysteryBox.kt @@ -3,6 +3,7 @@ package content.activity.event.random import content.entity.player.inv.item.addOrDrop import world.gregs.voidps.engine.Script import world.gregs.voidps.engine.client.message +import world.gregs.voidps.engine.client.ui.chat.an import world.gregs.voidps.engine.entity.item.drop.DropTables import world.gregs.voidps.engine.get import world.gregs.voidps.engine.inv.inventory @@ -28,16 +29,15 @@ class MysteryBox : Script { addOrDrop(drop.id, drop.amount) val name = drop.def.name.lowercase() val found = when { - drop.amount > 1 -> "${drop.amount} x $name" - name.first() in "aeiou" -> "an $name" - else -> "a $name" + drop.amount > 1 -> " ${drop.amount} x $name" + else -> "${name.an()} $name" } val flavour = when (drop.def.cost * drop.amount) { in 0..100 -> "Better luck next time!" in 101..500 -> "Well, it could have been worse." else -> "Excellent!" } - message("Inside the box you find $found! $flavour") + message("Inside the box you find${found}! $flavour") } } } From e6c1de35254c3d6c24ba1cfa53f34d9b8a282f4d Mon Sep 17 00:00:00 2001 From: GregHib Date: Sat, 11 Jul 2026 12:06:44 +0100 Subject: [PATCH 85/89] Add random event exception areas --- .../waterbirth_island/waterbirth_island.areas.toml | 9 +++++++-- .../kandarin/ancient_cavern/ancient_cavern.areas.toml | 6 ++++++ .../swamp/caves/lumbridge_swamp_caves.areas.toml | 2 +- .../varrock/grand_exchange/grand_exchange.areas.toml | 2 +- .../troll_country/god_wars_dungeon/god_wars.areas.toml | 2 +- 5 files changed, 16 insertions(+), 5 deletions(-) diff --git a/data/area/fremennik_province/waterbirth_island/waterbirth_island.areas.toml b/data/area/fremennik_province/waterbirth_island/waterbirth_island.areas.toml index 5ddd690f6d..753fc26cce 100644 --- a/data/area/fremennik_province/waterbirth_island/waterbirth_island.areas.toml +++ b/data/area/fremennik_province/waterbirth_island/waterbirth_island.areas.toml @@ -11,10 +11,15 @@ tags = ["multi_combat"] [waterbirth_island_multi_area] x = [1792, 1983] y = [4352, 4415] -tags = ["multi_combat"] +tags = ["multi_combat", "no_random_events"] [waterbirth] x = [2496, 2559] y = [3712, 3775] -tags = ["penguin_area"] +tags = ["penguin_area", "no_random_events"] hint = "on a small crescent island." + +[waterbirth_island_dungeon] +x = [2432, 2559] +y = [10112, 10175] +tags = ["no_random_events"] diff --git a/data/area/kandarin/ancient_cavern/ancient_cavern.areas.toml b/data/area/kandarin/ancient_cavern/ancient_cavern.areas.toml index b84a9fa55b..8da199eccf 100644 --- a/data/area/kandarin/ancient_cavern/ancient_cavern.areas.toml +++ b/data/area/kandarin/ancient_cavern/ancient_cavern.areas.toml @@ -1,8 +1,14 @@ [kuradals_dungeon] x = [1600, 1663] y = [5248, 5311] +tags = ["no_random_events"] [kuradals_teleport] x = [1736, 1739] y = [5311, 5313] level = 1 + +[ancient_cavern] +x = [1728, 1791] +y = [5312, 5375] +tags = ["no_random_events"] \ No newline at end of file diff --git a/data/area/misthalin/lumbridge/swamp/caves/lumbridge_swamp_caves.areas.toml b/data/area/misthalin/lumbridge/swamp/caves/lumbridge_swamp_caves.areas.toml index fc3c9aca8b..b72bf19ce7 100644 --- a/data/area/misthalin/lumbridge/swamp/caves/lumbridge_swamp_caves.areas.toml +++ b/data/area/misthalin/lumbridge/swamp/caves/lumbridge_swamp_caves.areas.toml @@ -14,7 +14,7 @@ tags = ["darkness"] [guthix_temple] x = [2496, 2623] y = [5696, 5823] -tags = ["multi_combat"] +tags = ["multi_combat", "no_random_events"] [wall_beast_spot_1] x = [3161] diff --git a/data/area/misthalin/varrock/grand_exchange/grand_exchange.areas.toml b/data/area/misthalin/varrock/grand_exchange/grand_exchange.areas.toml index c821e6aed3..8a413f223b 100644 --- a/data/area/misthalin/varrock/grand_exchange/grand_exchange.areas.toml +++ b/data/area/misthalin/varrock/grand_exchange/grand_exchange.areas.toml @@ -2,4 +2,4 @@ x = [3145, 3189] y = [3469, 3513] level = 0 -tags = ["grand_exchange"] +tags = ["grand_exchange", "no_random_events"] diff --git a/data/area/troll_country/god_wars_dungeon/god_wars.areas.toml b/data/area/troll_country/god_wars_dungeon/god_wars.areas.toml index 879b84f2f6..5e8cbf7d56 100644 --- a/data/area/troll_country/god_wars_dungeon/god_wars.areas.toml +++ b/data/area/troll_country/god_wars_dungeon/god_wars.areas.toml @@ -1,7 +1,7 @@ [godwars_dungeon_multi_area] x = [2816, 2943] y = [5248, 5375] -tags = ["multi_combat"] +tags = ["multi_combat", "no_random_events"] [godwars_dungeon] x = [2878, 2878, 2861, 2851, 2846, 2844, 2844, 2851, 2851, 2865, 2875, 2883, 2888, 2915, 2915, 2886, 2880] From 4a8464f1c56bdfb06a0262e4d948520c4ee585c0 Mon Sep 17 00:00:00 2001 From: GregHib Date: Sat, 11 Jul 2026 12:12:21 +0100 Subject: [PATCH 86/89] Include morphing rings/transforms in random event eligibility --- .../main/kotlin/content/activity/event/random/RandomEvents.kt | 2 ++ 1 file changed, 2 insertions(+) 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 13698da983..946b157b24 100644 --- a/game/src/main/kotlin/content/activity/event/random/RandomEvents.kt +++ b/game/src/main/kotlin/content/activity/event/random/RandomEvents.kt @@ -2,6 +2,7 @@ package content.activity.event.random import content.bot.isBot import content.entity.combat.inCombat +import content.entity.effect.transform import content.entity.player.bank.isNote import content.quest.clearInstance import content.quest.instance @@ -95,6 +96,7 @@ object RandomEvents : AutoCloseable { player.menu == null && player.dialogue == null && player.mode == EmptyMode && + player.transform.isEmpty() && !player.contains("delay") && !player.hasClock("random_event_cooldown", epochSeconds()) && Areas.get(player.tile.zone).none { it.tags.contains("no_random_events") } From dd210d92fd58c3424806676cc962d77357d1d25f Mon Sep 17 00:00:00 2001 From: GregHib Date: Sat, 11 Jul 2026 12:16:28 +0100 Subject: [PATCH 87/89] Trigger random events on a timer instead of experience drop Increase cooldown between 1-2 hours Add config for lost and found event chance --- .../activity/event/random/RandomEventTrigger.kt | 12 +++++++++++- .../content/activity/event/random/RandomEvents.kt | 8 ++++---- .../event/random/lost_and_found/LostAndFound.kt | 9 ++++++++- game/src/main/resources/game.properties | 10 +++++----- 4 files changed, 28 insertions(+), 11 deletions(-) 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 d26f177c7f..7fed2b2491 100644 --- a/game/src/main/kotlin/content/activity/event/random/RandomEventTrigger.kt +++ b/game/src/main/kotlin/content/activity/event/random/RandomEventTrigger.kt @@ -6,6 +6,9 @@ import world.gregs.voidps.engine.client.command.playerCommand import world.gregs.voidps.engine.client.command.stringArg import world.gregs.voidps.engine.client.message import world.gregs.voidps.engine.data.Settings +import world.gregs.voidps.engine.timer.Timer +import world.gregs.voidps.engine.timer.toTicks +import java.util.concurrent.TimeUnit /** * Rolls for a random event on each experience drop, so only active players are targeted. @@ -15,8 +18,15 @@ import world.gregs.voidps.engine.data.Settings class RandomEventTrigger : Script { init { - experience { _, _, _ -> + playerSpawn { + timers.startIfAbsent("random_event") + } + + timerStart("random_event") { TimeUnit.MINUTES.toTicks(5) } + + timerTick("random_event") { RandomEvents.roll(this) + Timer.CONTINUE } playerCommand("randomevents", desc = "Toggle random events on or off") { 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 946b157b24..8e397c9505 100644 --- a/game/src/main/kotlin/content/activity/event/random/RandomEvents.kt +++ b/game/src/main/kotlin/content/activity/event/random/RandomEvents.kt @@ -68,7 +68,7 @@ object RandomEvents : AutoCloseable { } /** - * Arm the initial cooldown on first activity, then a 1-in-`roll_chance` shot at [event] + * Arm the initial cooldown on first activity shot at [event] * (or a weighted [pick]) for players who are eligible and off cooldown. */ fun roll(player: Player, event: String? = null): Boolean { @@ -80,7 +80,7 @@ object RandomEvents : AutoCloseable { cooldown(player) return false } - if (!eligible(player) || random.nextInt(Settings["events.randomEvents.chance", 300]) != 0) { + if (!eligible(player)) { return false } // Restart the cooldown immediately so a failed pick can't re-roll every attempt @@ -177,8 +177,8 @@ object RandomEvents : AutoCloseable { * Delay the next event by `events.randomEvents.cooldownMinutes` min to max minutes. */ private fun cooldown(player: Player) { - val min = Settings["events.randomEvents.cooldownMinutesMin", 45] - val max = Settings["events.randomEvents.cooldownMinutesMax", 90] + val min = Settings["events.randomEvents.cooldownMinutesMin", 60] + val max = Settings["events.randomEvents.cooldownMinutesMax", 120] 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/lost_and_found/LostAndFound.kt b/game/src/main/kotlin/content/activity/event/random/lost_and_found/LostAndFound.kt index b222a4f50e..205a60749e 100644 --- a/game/src/main/kotlin/content/activity/event/random/lost_and_found/LostAndFound.kt +++ b/game/src/main/kotlin/content/activity/event/random/lost_and_found/LostAndFound.kt @@ -11,6 +11,7 @@ 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.open +import world.gregs.voidps.engine.data.Settings import world.gregs.voidps.engine.entity.character.move.tele import world.gregs.voidps.engine.entity.character.player.Player import world.gregs.voidps.engine.entity.character.player.skill.Skill @@ -32,7 +33,13 @@ class LostAndFound : Script { // Only a magic spellbook teleport can slip through; the destination becomes the return point. teleportLand("modern") { - RandomEvents.roll(this, "lost_and_found") + if (!Settings["events.randomEvents.active", true]) { + return@teleportLand + } + val chance = Settings["events.randomEvents.teleportChance", 300] + if (chance >= 0 && random.nextInt(chance) == 0) { + RandomEvents.roll(this, "lost_and_found") + } } objectOperate("Operate", "abyss_appendage_*") { (appendage) -> diff --git a/game/src/main/resources/game.properties b/game/src/main/resources/game.properties index 573bfcf1aa..5f02579d05 100644 --- a/game/src/main/resources/game.properties +++ b/game/src/main/resources/game.properties @@ -228,14 +228,14 @@ events.randomEvents.active=true # Whether players can turn off random events for themselves with the ::randomevents command events.randomEvents.optOut=false -# 1 in X chance of an event per experience drop -events.randomEvents.chance=300 - # Min minutes until you can receive another a random event -events.randomEvents.cooldownMinutesMin=45 +events.randomEvents.cooldownMinutesMin=60 # Max minutes until you can receive another a random event -events.randomEvents.cooldownMinutesMax=90 +events.randomEvents.cooldownMinutesMax=120 + +# 1 in X chance of receiving the lost and found teleport random event +events.randomEvents.teleportChance=300 # Whether Stronghold of Security doors should give questions or not strongholdOfSecurity.quiz=true From a6965836a1e529bb6d8722d36d081c49e238be51 Mon Sep 17 00:00:00 2001 From: GregHib Date: Sat, 11 Jul 2026 12:22:02 +0100 Subject: [PATCH 88/89] Disable random events in integration tests to avoid issues --- .../activity/event/random/RandomEventTrigger.kt | 10 ++++++++-- game/src/test/kotlin/WorldTest.kt | 1 + 2 files changed, 9 insertions(+), 2 deletions(-) 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 7fed2b2491..3c9c3bd213 100644 --- a/game/src/main/kotlin/content/activity/event/random/RandomEventTrigger.kt +++ b/game/src/main/kotlin/content/activity/event/random/RandomEventTrigger.kt @@ -19,6 +19,12 @@ class RandomEventTrigger : Script { init { playerSpawn { + if (!Settings["events.randomEvents.active", true]) { + return@playerSpawn + } + if (RandomEvents.optedOut(this)) { + return@playerSpawn + } timers.startIfAbsent("random_event") } @@ -29,7 +35,7 @@ class RandomEventTrigger : Script { Timer.CONTINUE } - playerCommand("randomevents", desc = "Toggle random events on or off") { + playerCommand("random_events", desc = "Toggle random events on or off") { if (!Settings["events.randomEvents.optOut", false]) { message("Random events can't be turned off on this world.") return@playerCommand @@ -41,7 +47,7 @@ class RandomEventTrigger : Script { } } - adminCommand("randomevent", stringArg("event", optional = true), desc = "Start a random event") { args -> + adminCommand("random_event", stringArg("event", optional = true), desc = "Start a random event") { args -> val event = args.getOrNull(0) ?: RandomEvents.pick() if (event == null || !RandomEvents.start(this, event)) { message("No random event found${if (args.isEmpty()) "" else " for '${args[0]}'"}.") diff --git a/game/src/test/kotlin/WorldTest.kt b/game/src/test/kotlin/WorldTest.kt index a11b670db7..5ac3769c29 100644 --- a/game/src/test/kotlin/WorldTest.kt +++ b/game/src/test/kotlin/WorldTest.kt @@ -306,6 +306,7 @@ abstract class WorldTest : KoinTest { properties["events.tearsOfGuthix.active"] = false properties["storage.autoSave.minutes"] = 0 properties["storage.disabled"] = true + properties["events.randomEvents.active"] = false properties["bots.count"] = 0 properties.remove("world.id") properties.remove("world.name") From b916a258a64f5212e1c6aa560ea6a689a566e8c7 Mon Sep 17 00:00:00 2001 From: GregHib Date: Sat, 11 Jul 2026 12:22:21 +0100 Subject: [PATCH 89/89] Fix random event trigger tests --- .../activity/event/random/MysteryBox.kt | 2 +- .../event/random/RandomEventTriggerTest.kt | 24 +++++++++---------- 2 files changed, 12 insertions(+), 14 deletions(-) diff --git a/game/src/main/kotlin/content/activity/event/random/MysteryBox.kt b/game/src/main/kotlin/content/activity/event/random/MysteryBox.kt index 3fbb2db336..6503c6ccca 100644 --- a/game/src/main/kotlin/content/activity/event/random/MysteryBox.kt +++ b/game/src/main/kotlin/content/activity/event/random/MysteryBox.kt @@ -37,7 +37,7 @@ class MysteryBox : Script { in 101..500 -> "Well, it could have been worse." else -> "Excellent!" } - message("Inside the box you find${found}! $flavour") + message("Inside the box you find$found! $flavour") } } } 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 bd1f893ad3..7f30a1b4fe 100644 --- a/game/src/test/kotlin/content/activity/event/random/RandomEventTriggerTest.kt +++ b/game/src/test/kotlin/content/activity/event/random/RandomEventTriggerTest.kt @@ -6,8 +6,6 @@ import content.quest.instance import io.mockk.mockk import org.junit.jupiter.api.Test import world.gregs.voidps.engine.data.Settings -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 @@ -18,23 +16,23 @@ import kotlin.test.assertTrue class RandomEventTriggerTest : WorldTest() { @Test - fun `Experience drop starts a random event once cooldown expires`() { + fun `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) + RandomEvents.roll(player) tick(10) - assertEquals("maze", player.get("random_event")) + assertEquals("maze", player["random_event"]) assertNotNull(player.instance()) assertTrue(player.contains("random_event_cooldown")) } @Test - fun `First experience drop arms the cooldown without an event`() { + fun `First event arms the cooldown without an event`() { val player = createPlayer(Tile(3221, 3218), "re_cooldown") - player.exp(Skill.Woodcutting, 100.0) + RandomEvents.roll(player) tick(10) assertNull(player.get("random_event")) @@ -46,7 +44,7 @@ class RandomEventTriggerTest : WorldTest() { val player = createPlayer(Tile(3221, 3218), "re_bot") { it["bot"] = mockk(relaxed = true) } player["random_event_cooldown"] = 1 - player.exp(Skill.Woodcutting, 100.0) + RandomEvents.roll(player) tick(10) assertNull(player.get("random_event")) @@ -58,7 +56,7 @@ class RandomEventTriggerTest : WorldTest() { player["random_event"] = "maze" player["random_event_cooldown"] = 1 - player.exp(Skill.Woodcutting, 100.0) + RandomEvents.roll(player) tick() assertFalse(player.queue.contains("random_event_start")) @@ -71,7 +69,7 @@ class RandomEventTriggerTest : WorldTest() { player["random_event_cooldown"] = 1 player["random_events_disabled"] = true - player.exp(Skill.Woodcutting, 100.0) + RandomEvents.roll(player) tick(10) assertNull(player.get("random_event")) @@ -84,10 +82,10 @@ class RandomEventTriggerTest : WorldTest() { player["random_event_cooldown"] = 1 player["random_events_disabled"] = true - player.exp(Skill.Woodcutting, 100.0) + RandomEvents.roll(player) tick(10) - assertEquals("maze", player.get("random_event")) + assertEquals("maze", player["random_event"]) } @Test @@ -95,7 +93,7 @@ class RandomEventTriggerTest : WorldTest() { val player = createPlayer(Tile(3098, 3107), "re_tutorial") player["random_event_cooldown"] = 1 - player.exp(Skill.Woodcutting, 100.0) + RandomEvents.roll(player) tick(10) assertNull(player.get("random_event"))