From 935584c8a99d55659c2273f9b979893e00b0023c Mon Sep 17 00:00:00 2001 From: reldo-dev <316375707+reldo-dev@users.noreply.github.com> Date: Wed, 26 Aug 2026 16:52:41 -0700 Subject: [PATCH 1/2] feat: scaffold the hunter module and its creature-table pack The module skeleton, the shared dbtable column block every creature table packs, and the trap-cap ladder. First slice of the hunter series; the techniques land one at a time on top of this. --- content/skills/hunter/build.gradle.kts | 10 +++ content/skills/hunter/pack/build.gradle.kts | 3 + .../skills/hunter/pack/HunterPluginPack.kt | 8 +++ .../skills/hunter/pack/HunterTables.kt | 37 ++++++++++ .../content/skills/hunter/HunterShared.kt | 26 +++++++ .../rsmod/content/skills/hunter/TrapLadder.kt | 17 +++++ .../content/skills/hunter/TrapLadderTest.kt | 41 +++++++++++ docs/hunter.md | 69 +++++++++++++++++++ 8 files changed, 211 insertions(+) create mode 100644 content/skills/hunter/build.gradle.kts create mode 100644 content/skills/hunter/pack/build.gradle.kts create mode 100644 content/skills/hunter/pack/src/main/kotlin/org/rsmod/content/skills/hunter/pack/HunterPluginPack.kt create mode 100644 content/skills/hunter/pack/src/main/kotlin/org/rsmod/content/skills/hunter/pack/HunterTables.kt create mode 100644 content/skills/hunter/src/main/kotlin/org/rsmod/content/skills/hunter/HunterShared.kt create mode 100644 content/skills/hunter/src/main/kotlin/org/rsmod/content/skills/hunter/TrapLadder.kt create mode 100644 content/skills/hunter/src/test/kotlin/org/rsmod/content/skills/hunter/TrapLadderTest.kt create mode 100644 docs/hunter.md diff --git a/content/skills/hunter/build.gradle.kts b/content/skills/hunter/build.gradle.kts new file mode 100644 index 000000000..0b6d76aba --- /dev/null +++ b/content/skills/hunter/build.gradle.kts @@ -0,0 +1,10 @@ +plugins { + id("base-conventions") +} + +dependencies { + implementation(projects.api.pluginCommons) + implementation(projects.api.registry) + implementation(projects.api.utils.utilsSkills) + implementation(projects.content.skills.utils) +} diff --git a/content/skills/hunter/pack/build.gradle.kts b/content/skills/hunter/pack/build.gradle.kts new file mode 100644 index 000000000..92f804a7e --- /dev/null +++ b/content/skills/hunter/pack/build.gradle.kts @@ -0,0 +1,3 @@ +plugins { + id("base-conventions") +} diff --git a/content/skills/hunter/pack/src/main/kotlin/org/rsmod/content/skills/hunter/pack/HunterPluginPack.kt b/content/skills/hunter/pack/src/main/kotlin/org/rsmod/content/skills/hunter/pack/HunterPluginPack.kt new file mode 100644 index 000000000..ecdfc4085 --- /dev/null +++ b/content/skills/hunter/pack/src/main/kotlin/org/rsmod/content/skills/hunter/pack/HunterPluginPack.kt @@ -0,0 +1,8 @@ +package org.rsmod.content.skills.hunter.pack + +import dev.openrune.definition.dbtables.DBTable +import dev.openrune.pack.PluginPack + +class HunterPluginPack : PluginPack() { + override fun dbTables(): List = listOf() +} diff --git a/content/skills/hunter/pack/src/main/kotlin/org/rsmod/content/skills/hunter/pack/HunterTables.kt b/content/skills/hunter/pack/src/main/kotlin/org/rsmod/content/skills/hunter/pack/HunterTables.kt new file mode 100644 index 000000000..b178d48be --- /dev/null +++ b/content/skills/hunter/pack/src/main/kotlin/org/rsmod/content/skills/hunter/pack/HunterTables.kt @@ -0,0 +1,37 @@ +package org.rsmod.content.skills.hunter.pack + +import dev.openrune.definition.dbtables.DBTableBuilder +import dev.openrune.definition.util.VarType + +/** + * The hunter creature dbtables. Every `npc` and `obj` is a cache symbol confirmed against the + * cache, never a wiki name transcribed directly. XP is stored x10 so fractional wiki values + * survive an int column. See docs/hunter.md. + */ +object HunterTables { + // Column ids must form a dense 0..n-1 set per table: the encoder writes columns sorted by id + // without the id itself, so a gap silently shifts every later column and drops the last, with + // no pack-time diagnostic (docs/hunter.md). Ids 0-7 are shared; per-technique columns start + // at 8, nested per table so one table's column cannot be typed into another's builder. + const val COL_NPC = 0 + const val COL_LEVEL = 1 + const val COL_XP = 2 + const val COL_SUCCESS_LOW = 3 + const val COL_SUCCESS_HIGH = 4 + const val COL_CAUGHT_ITEMS = 5 + const val COL_CAUGHT_MIN = 6 + const val COL_CAUGHT_MAX = 7 + + /** Columns 0-7, shared verbatim by every creature table. */ + private fun DBTableBuilder.creatureColumns() { + column("npc", COL_NPC, VarType.NPC) + column("level", COL_LEVEL, VarType.INT) + // Stored x10. + column("xp", COL_XP, VarType.INT) + column("success_low", COL_SUCCESS_LOW, VarType.INT) + column("success_high", COL_SUCCESS_HIGH, VarType.INT) + column("caught_items", COL_CAUGHT_ITEMS, VarType.OBJ) + column("caught_min", COL_CAUGHT_MIN, VarType.INT) + column("caught_max", COL_CAUGHT_MAX, VarType.INT) + } +} diff --git a/content/skills/hunter/src/main/kotlin/org/rsmod/content/skills/hunter/HunterShared.kt b/content/skills/hunter/src/main/kotlin/org/rsmod/content/skills/hunter/HunterShared.kt new file mode 100644 index 000000000..ec8c12d0c --- /dev/null +++ b/content/skills/hunter/src/main/kotlin/org/rsmod/content/skills/hunter/HunterShared.kt @@ -0,0 +1,26 @@ +package org.rsmod.content.skills.hunter + +import dev.openrune.ServerCacheManager +import dev.openrune.rscm.RSCM.asRSCM +import dev.openrune.rscm.RSCMType +import org.rsmod.api.random.GameRandom +import org.rsmod.game.inv.Inventory + +// Rules shared by every hunter technique. Design notes: docs/hunter.md. + +/** The `maxLevel` SkillingSuccessRate interpolates against; published charts run to level 99. */ +internal const val MAX_HUNTER_LEVEL: Int = 99 + +/** A fixed quantity must consume no random draw - tests script the RNG as a draw sequence. */ +internal fun rollQuantity(random: GameRandom, quantity: IntRange): Int = + if (quantity.first == quantity.last) quantity.first else random.of(quantity) + +/** A stackable already held needs no free slot, whatever the count. */ +internal fun hunterInvSlotsNeeded(inv: Inventory, internal: String, count: Int): Int { + val stackable = ServerCacheManager.getItem(internal.asRSCM(RSCMType.OBJ))?.isStackable == true + return when { + !stackable -> count + inv.contains(internal) -> 0 + else -> 1 + } +} diff --git a/content/skills/hunter/src/main/kotlin/org/rsmod/content/skills/hunter/TrapLadder.kt b/content/skills/hunter/src/main/kotlin/org/rsmod/content/skills/hunter/TrapLadder.kt new file mode 100644 index 000000000..067ad7f19 --- /dev/null +++ b/content/skills/hunter/src/main/kotlin/org/rsmod/content/skills/hunter/TrapLadder.kt @@ -0,0 +1,17 @@ +package org.rsmod.content.skills.hunter + +/** + * Live-trap cap per Hunter level, from the wiki's *Pitfall* "Multiple traps" table + * (oldid=15201220), read from the effective level. Crab trapping keeps its own cap - its published + * table has no below-20 rung. See docs/hunter.md. + */ +internal object TrapLadder { + fun cap(level: Int): Int = + when { + level >= 80 -> 5 + level >= 60 -> 4 + level >= 40 -> 3 + level >= 20 -> 2 + else -> 1 + } +} diff --git a/content/skills/hunter/src/test/kotlin/org/rsmod/content/skills/hunter/TrapLadderTest.kt b/content/skills/hunter/src/test/kotlin/org/rsmod/content/skills/hunter/TrapLadderTest.kt new file mode 100644 index 000000000..15bd97c83 --- /dev/null +++ b/content/skills/hunter/src/test/kotlin/org/rsmod/content/skills/hunter/TrapLadderTest.kt @@ -0,0 +1,41 @@ +package org.rsmod.content.skills.hunter + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +/** + * Pins every rung and both sides of every boundary. The literals are the wiki's, not + * [TrapLadder]'s - nothing here reads the function back as its own expected value. Pure + * arithmetic, no cache, so not serialised with the rest of the suite. + */ +class TrapLadderTest { + @Test + fun `one trap below level 20`() { + assertEquals(1, TrapLadder.cap(1)) + assertEquals(1, TrapLadder.cap(19)) + } + + @Test + fun `two traps from level 20`() { + assertEquals(2, TrapLadder.cap(20)) + assertEquals(2, TrapLadder.cap(39)) + } + + @Test + fun `three traps from level 40`() { + assertEquals(3, TrapLadder.cap(40)) + assertEquals(3, TrapLadder.cap(59)) + } + + @Test + fun `four traps from level 60`() { + assertEquals(4, TrapLadder.cap(60)) + assertEquals(4, TrapLadder.cap(79)) + } + + @Test + fun `five traps from level 80, including level 99`() { + assertEquals(5, TrapLadder.cap(80)) + assertEquals(5, TrapLadder.cap(99)) + } +} diff --git a/docs/hunter.md b/docs/hunter.md new file mode 100644 index 000000000..4fe6884b6 --- /dev/null +++ b/docs/hunter.md @@ -0,0 +1,69 @@ +# Hunter + +How hunter creatures are modelled: the shared data tables every technique reads, +the catch-rate model, and per-technique notes on where each number came from, +what is a guess, and what is deliberately not modelled. + +Sources are the OSRS wiki (pages pinned by `oldid` where a number was +transcribed) and the decoded client cache. Where a value has no published +source, the technique's section says so and explains the guess. + +## Module layout + +`content/skills/hunter` holds the gameplay code; its `pack` submodule declares +the dbtables the creature data packs into. Techniques are deliberately +independent of each other — rules they share live as top-level declarations in +`HunterShared.kt`, not as members of any one technique. + +## Creature tables + +Each technique's creatures are rows of a dbtable declared in `HunterTables.kt`. +Columns 0–7 are shared verbatim by every creature table — npc, level, xp, +success_low, success_high, caught_items, caught_min, caught_max — so a creature +row means the same thing whichever table it came from, and per-technique +columns all start at 8. + +- Every `npc` and `obj` is a cache symbol confirmed via `config/npc` / + `config/obj` lookups, never a wiki name transcribed directly — the two + frequently differ (the wiki's "Crimson swift" is `npc.hunting_bird_jungle`). +- XP is stored ×10 so the fractional values the wiki quotes survive an int + column; the content side divides by ten once, at the point it awards. + +### Column ids must form a dense 0..n-1 set, per table + +The gameval encoder writes a table's columns sorted by id and never writes the +id itself, just a name per column; on read, each `dbcol` is assigned its +ordinal purely from read position — a counter starting at 0, incremented per +column. Leave a gap in the ids and the two numbering schemes desync: every +ordinal past the gap resolves one column too low, the highest id has no ordinal +left to reach it and is silently dropped, and the pack still reports `BUILD +SUCCESSFUL` with no diagnostic. Ids are per-table, so sharing 0–7 across tables +is safe; numbering per-technique columns from a common base above the shared +block is exactly how a gap would get introduced, which is why they are declared +nested per table in `HunterTables`. + +## Catch rates + +Creatures carry a `(success_low, success_high)` pair interpolated by +`SkillingSuccessRate` against the player's Hunter level, with `maxLevel = 99`. +That constant is not a "max hunter level" rule: it is the scale of the +published catch-rate charts every pair was read from or fitted to, which run +from level 1 to 99. Where a pair was fitted or guessed rather than published, +the technique's section below says which and why. + +## Trap cap + +`TrapLadder` transcribes the "Multiple traps" table on the wiki's *Pitfall* +page (oldid=15201220): one trap below level 20, then 2, 3, 4 and 5 at levels +20, 40, 60 and 80, read from the effective (boostable) level. A technique whose +published cap table disagrees keeps its own ladder rather than reusing this +one — crab trapping's starts at 2 and has no below-20 rung, because its lowest +site needs level 21; folding it in would grant a rung its source does not have. + +## Randomness + +A fixed reward quantity (`first == last` in `rollQuantity`) consumes no random +draw at all. This is load-bearing, not an optimisation: the unit tests script +the RNG as a fixed sequence of draws, so an unconditional draw for a flat +quantity would shift every subsequent roll and change what the next one +returns. From 1073377718eb02ef53220631ada32b31074641e7 Mon Sep 17 00:00:00 2001 From: reldo-dev <316375707+reldo-dev@users.noreply.github.com> Date: Wed, 26 Aug 2026 17:11:30 -0700 Subject: [PATCH 2/2] feat: add bird snares and box traps The shared trap engine - lay, tick, catch roll, collect, collapse, and the coord-based trap cap - plus the first two families that run on it. A laid trap is a controller on its tile; its family and creature are varcons, so the whole of a trap's state survives a logout. Second slice of the hunter series, on top of the module scaffold. --- .data/raw-cache/server/loc/loc.toml | 60 +++ .data/raw-cache/server/varcon.toml | 9 + .data/raw-cache/server/varp.toml | 30 ++ content/skills/hunter/build.gradle.kts | 12 + .../skills/hunter/pack/HunterPluginPack.kt | 6 +- .../skills/hunter/pack/HunterTables.kt | 141 ++++++ .../content/skills/hunter/BirdSnareEvents.kt | 47 ++ .../content/skills/hunter/BoxTrapEvents.kt | 61 +++ .../content/skills/hunter/HunterCreature.kt | 87 ++++ .../content/skills/hunter/HunterCreatures.kt | 67 +++ .../content/skills/hunter/HunterModule.kt | 9 + .../content/skills/hunter/HunterShared.kt | 30 ++ .../rsmod/content/skills/hunter/HunterTrap.kt | 365 +++++++++++++++ .../content/skills/hunter/HunterTrapStates.kt | 51 +++ .../content/skills/hunter/HunterTrapTuning.kt | 42 ++ .../content/skills/hunter/HunterTrapVars.kt | 53 +++ .../hunter/src/main/resources/gamevals.toml | 39 ++ .../skills/hunter/HunterRateTablesTest.kt | 223 +++++++++ .../skills/hunter/HunterTrapOpsTest.kt | 237 ++++++++++ .../skills/hunter/HunterTrapTestFakes.kt | 431 ++++++++++++++++++ .../skills/hunter/HunterTrapTickTest.kt | 390 ++++++++++++++++ .../content/skills/hunter/HunterWiringTest.kt | 331 ++++++++++++++ .../skills/hunter/HunterXpModTestFakes.kt | 38 ++ .../wiki-charts/birdsnare-chance.tsv | 208 +++++++++ .../resources/wiki-charts/boxtrap-chance.tsv | 153 +++++++ .../wiki-charts/published-params.tsv | 71 +++ docs/hunter.md | 140 ++++++ 27 files changed, 3330 insertions(+), 1 deletion(-) create mode 100644 content/skills/hunter/src/main/kotlin/org/rsmod/content/skills/hunter/BirdSnareEvents.kt create mode 100644 content/skills/hunter/src/main/kotlin/org/rsmod/content/skills/hunter/BoxTrapEvents.kt create mode 100644 content/skills/hunter/src/main/kotlin/org/rsmod/content/skills/hunter/HunterCreature.kt create mode 100644 content/skills/hunter/src/main/kotlin/org/rsmod/content/skills/hunter/HunterCreatures.kt create mode 100644 content/skills/hunter/src/main/kotlin/org/rsmod/content/skills/hunter/HunterModule.kt create mode 100644 content/skills/hunter/src/main/kotlin/org/rsmod/content/skills/hunter/HunterTrap.kt create mode 100644 content/skills/hunter/src/main/kotlin/org/rsmod/content/skills/hunter/HunterTrapStates.kt create mode 100644 content/skills/hunter/src/main/kotlin/org/rsmod/content/skills/hunter/HunterTrapTuning.kt create mode 100644 content/skills/hunter/src/main/kotlin/org/rsmod/content/skills/hunter/HunterTrapVars.kt create mode 100644 content/skills/hunter/src/main/resources/gamevals.toml create mode 100644 content/skills/hunter/src/test/kotlin/org/rsmod/content/skills/hunter/HunterRateTablesTest.kt create mode 100644 content/skills/hunter/src/test/kotlin/org/rsmod/content/skills/hunter/HunterTrapOpsTest.kt create mode 100644 content/skills/hunter/src/test/kotlin/org/rsmod/content/skills/hunter/HunterTrapTestFakes.kt create mode 100644 content/skills/hunter/src/test/kotlin/org/rsmod/content/skills/hunter/HunterTrapTickTest.kt create mode 100644 content/skills/hunter/src/test/kotlin/org/rsmod/content/skills/hunter/HunterWiringTest.kt create mode 100644 content/skills/hunter/src/test/kotlin/org/rsmod/content/skills/hunter/HunterXpModTestFakes.kt create mode 100644 content/skills/hunter/src/test/resources/wiki-charts/birdsnare-chance.tsv create mode 100644 content/skills/hunter/src/test/resources/wiki-charts/boxtrap-chance.tsv create mode 100644 content/skills/hunter/src/test/resources/wiki-charts/published-params.tsv diff --git a/.data/raw-cache/server/loc/loc.toml b/.data/raw-cache/server/loc/loc.toml index 8014c7b7e..4fda34ca1 100644 --- a/.data/raw-cache/server/loc/loc.toml +++ b/.data/raw-cache/server/loc/loc.toml @@ -2133,3 +2133,63 @@ contentGroup = "content.minnow_rowboat_to_island" id = "loc.minnow_rowboat_leave" inherit = "loc.minnow_rowboat_leave" contentGroup = "content.minnow_rowboat_to_guild" + +[[object]] +id = "loc.hunting_ojibway_trap" +inherit = "loc.hunting_ojibway_trap" +contentGroup = "content.hunter_bird_snare" + +[[object]] +id = "loc.hunting_ojibway_trap_broken" +inherit = "loc.hunting_ojibway_trap_broken" +contentGroup = "content.hunter_bird_snare" + +[[object]] +id = "loc.hunting_ojibway_trap_full_desert" +inherit = "loc.hunting_ojibway_trap_full_desert" +contentGroup = "content.hunter_bird_snare" + +[[object]] +id = "loc.hunting_ojibway_trap_full_jungle" +inherit = "loc.hunting_ojibway_trap_full_jungle" +contentGroup = "content.hunter_bird_snare" + +[[object]] +id = "loc.hunting_ojibway_trap_full_polar" +inherit = "loc.hunting_ojibway_trap_full_polar" +contentGroup = "content.hunter_bird_snare" + +[[object]] +id = "loc.hunting_ojibway_trap_full_woodland" +inherit = "loc.hunting_ojibway_trap_full_woodland" +contentGroup = "content.hunter_bird_snare" + +[[object]] +id = "loc.hunting_boxtrap_empty" +inherit = "loc.hunting_boxtrap_empty" +contentGroup = "content.hunter_box_trap" + +[[object]] +id = "loc.hunting_boxtrap_failed" +inherit = "loc.hunting_boxtrap_failed" +contentGroup = "content.hunter_box_trap" + +[[object]] +id = "loc.hunting_boxtrap_full_chinchompa" +inherit = "loc.hunting_boxtrap_full_chinchompa" +contentGroup = "content.hunter_box_trap" + +[[object]] +id = "loc.hunting_boxtrap_full_chinchompa_big" +inherit = "loc.hunting_boxtrap_full_chinchompa_big" +contentGroup = "content.hunter_box_trap" + +[[object]] +id = "loc.hunting_boxtrap_full_chinchompa_black" +inherit = "loc.hunting_boxtrap_full_chinchompa_black" +contentGroup = "content.hunter_box_trap" + +[[object]] +id = "loc.hunting_boxtrap_full_letvek" +inherit = "loc.hunting_boxtrap_full_letvek" +contentGroup = "content.hunter_box_trap" diff --git a/.data/raw-cache/server/varcon.toml b/.data/raw-cache/server/varcon.toml index 99ce2e11d..85b151119 100644 --- a/.data/raw-cache/server/varcon.toml +++ b/.data/raw-cache/server/varcon.toml @@ -9,3 +9,12 @@ id = "varcon.woodcutting_tree_loc" [[varcon]] id = "varcon.firemaking_campfire_expiry_cycle" + +[[varcon]] +id = "varcon.hunter_trap_owner" + +[[varcon]] +id = "varcon.hunter_trap_family" + +[[varcon]] +id = "varcon.hunter_trap_creature" diff --git a/.data/raw-cache/server/varp.toml b/.data/raw-cache/server/varp.toml index 694c4b16c..d0223be98 100644 --- a/.data/raw-cache/server/varp.toml +++ b/.data/raw-cache/server/varp.toml @@ -821,3 +821,33 @@ isServerOnly = true id = "varp.drew_sandstone" scope = "Perm" transmit = "Never" + +[[varp]] +isServerOnly = true +id = "varp.hunter_trap_coord_1" +scope = "Perm" +transmit = "Never" + +[[varp]] +isServerOnly = true +id = "varp.hunter_trap_coord_2" +scope = "Perm" +transmit = "Never" + +[[varp]] +isServerOnly = true +id = "varp.hunter_trap_coord_3" +scope = "Perm" +transmit = "Never" + +[[varp]] +isServerOnly = true +id = "varp.hunter_trap_coord_4" +scope = "Perm" +transmit = "Never" + +[[varp]] +isServerOnly = true +id = "varp.hunter_trap_coord_5" +scope = "Perm" +transmit = "Never" diff --git a/content/skills/hunter/build.gradle.kts b/content/skills/hunter/build.gradle.kts index 0b6d76aba..464a141ae 100644 --- a/content/skills/hunter/build.gradle.kts +++ b/content/skills/hunter/build.gradle.kts @@ -3,8 +3,20 @@ plugins { } dependencies { + implementation(projects.api.dropTable) + implementation(projects.api.dropTablePlugin) implementation(projects.api.pluginCommons) implementation(projects.api.registry) implementation(projects.api.utils.utilsSkills) implementation(projects.content.skills.utils) + // For `clueScrollTransformObj`, which every clue-carrying drop table in the repo applies. + implementation(projects.content.drops) + + // `invAdd`/`invDel` route through a lateinit that `InvTransactionsScript` fills in at boot, + // so the collect-path tests start that script themselves. + testImplementation(projects.api.invStorage) + + // The default quest policy is `assume-completed`; the test that exercises the untransformed + // clue branch flips the `content/quest` policy singleton back and forth. + testImplementation(projects.content.quest) } diff --git a/content/skills/hunter/pack/src/main/kotlin/org/rsmod/content/skills/hunter/pack/HunterPluginPack.kt b/content/skills/hunter/pack/src/main/kotlin/org/rsmod/content/skills/hunter/pack/HunterPluginPack.kt index ecdfc4085..3a4e73cff 100644 --- a/content/skills/hunter/pack/src/main/kotlin/org/rsmod/content/skills/hunter/pack/HunterPluginPack.kt +++ b/content/skills/hunter/pack/src/main/kotlin/org/rsmod/content/skills/hunter/pack/HunterPluginPack.kt @@ -4,5 +4,9 @@ import dev.openrune.definition.dbtables.DBTable import dev.openrune.pack.PluginPack class HunterPluginPack : PluginPack() { - override fun dbTables(): List = listOf() + override fun dbTables(): List = + listOf( + HunterTables.snareCreatures(), + HunterTables.boxCreatures(), + ) } diff --git a/content/skills/hunter/pack/src/main/kotlin/org/rsmod/content/skills/hunter/pack/HunterTables.kt b/content/skills/hunter/pack/src/main/kotlin/org/rsmod/content/skills/hunter/pack/HunterTables.kt index b178d48be..bc895a26a 100644 --- a/content/skills/hunter/pack/src/main/kotlin/org/rsmod/content/skills/hunter/pack/HunterTables.kt +++ b/content/skills/hunter/pack/src/main/kotlin/org/rsmod/content/skills/hunter/pack/HunterTables.kt @@ -1,12 +1,18 @@ package org.rsmod.content.skills.hunter.pack +import dev.openrune.definition.dbtables.DBTable import dev.openrune.definition.dbtables.DBTableBuilder +import dev.openrune.definition.dbtables.dbTable import dev.openrune.definition.util.VarType /** * The hunter creature dbtables. Every `npc` and `obj` is a cache symbol confirmed against the * cache, never a wiki name transcribed directly. XP is stored x10 so fractional wiki values * survive an int column. See docs/hunter.md. + * + * Row order is load-bearing: a sprung trap persists its creature as an index into the combined + * creature list, read back sorted by dbrow id - a new technique's rows must sort after every row + * already shipped, never between them. */ object HunterTables { // Column ids must form a dense 0..n-1 set per table: the encoder writes columns sorted by id @@ -22,6 +28,14 @@ object HunterTables { const val COL_CAUGHT_MIN = 6 const val COL_CAUGHT_MAX = 7 + /** + * The loc-state name suffix is authored data, never derived from the npc symbol - not every + * creature's npc and loc names share a derivable stem. See docs/hunter.md. + */ + private object LocKeyed { + const val COL_LOC_KEY = 8 + } + /** Columns 0-7, shared verbatim by every creature table. */ private fun DBTableBuilder.creatureColumns() { column("npc", COL_NPC, VarType.NPC) @@ -34,4 +48,131 @@ object HunterTables { column("caught_min", COL_CAUGHT_MIN, VarType.INT) column("caught_max", COL_CAUGHT_MAX, VarType.INT) } + + /** + * Each pair was fit against the creature's charted per-level success curve and verified to + * reproduce every non-capped point exactly - see docs/hunter.md. A catch awards bones, meat + * and feathers in one go; only the feather count is rolled. + */ + fun snareCreatures(): DBTable = + dbTable("dbtable.hunter_snare_creatures", serverOnly = true) { + creatureColumns() + column("loc_key", LocKeyed.COL_LOC_KEY, VarType.STRING) + + row("dbrow.hunter_jungle_bird") { + columnRSCM(COL_NPC, "npc.hunting_bird_jungle") + column(COL_LEVEL, 1) + column(COL_XP, 340) + column(COL_SUCCESS_LOW, 100) + column(COL_SUCCESS_HIGH, 420) + columnRSCM( + COL_CAUGHT_ITEMS, + "obj.bones", + "obj.spit_raw_bird_meat", + "obj.hunting_jungle_feather", + ) + column(COL_CAUGHT_MIN, 1, 1, 5) + column(COL_CAUGHT_MAX, 1, 1, 10) + column(LocKeyed.COL_LOC_KEY, "jungle") + } + + row("dbrow.hunter_desert_bird") { + columnRSCM(COL_NPC, "npc.hunting_bird_desert") + column(COL_LEVEL, 5) + column(COL_XP, 470) + column(COL_SUCCESS_LOW, 92) + column(COL_SUCCESS_HIGH, 400) + columnRSCM( + COL_CAUGHT_ITEMS, + "obj.bones", + "obj.spit_raw_bird_meat", + "obj.hunting_desert_feather", + ) + column(COL_CAUGHT_MIN, 1, 1, 5) + column(COL_CAUGHT_MAX, 1, 1, 10) + column(LocKeyed.COL_LOC_KEY, "desert") + } + + row("dbrow.hunter_woodland_bird") { + columnRSCM(COL_NPC, "npc.hunting_bird_woodland") + column(COL_LEVEL, 9) + column(COL_XP, 612) + column(COL_SUCCESS_LOW, 85) + column(COL_SUCCESS_HIGH, 390) + columnRSCM( + COL_CAUGHT_ITEMS, + "obj.bones", + "obj.spit_raw_bird_meat", + "obj.hunting_woodland_feather", + ) + column(COL_CAUGHT_MIN, 1, 1, 5) + column(COL_CAUGHT_MAX, 1, 1, 10) + column(LocKeyed.COL_LOC_KEY, "woodland") + } + + row("dbrow.hunter_polar_bird") { + columnRSCM(COL_NPC, "npc.hunting_bird_polar") + column(COL_LEVEL, 11) + // The infobox states 64.5 xp, the parent summary table 64.6; the infobox ships. + column(COL_XP, 645) + column(COL_SUCCESS_LOW, 82) + column(COL_SUCCESS_HIGH, 380) + columnRSCM( + COL_CAUGHT_ITEMS, + "obj.bones", + "obj.spit_raw_bird_meat", + "obj.hunting_polar_feather", + ) + column(COL_CAUGHT_MIN, 1, 1, 5) + column(COL_CAUGHT_MAX, 1, 1, 10) + column(LocKeyed.COL_LOC_KEY, "polar") + } + } + + /** + * All three chinchompas state their success formula directly on the wiki, so those pairs are + * read off rather than fit. No `bait` column: nothing would read it (docs/hunter.md). + */ + fun boxCreatures(): DBTable = + dbTable("dbtable.hunter_box_creatures", serverOnly = true) { + creatureColumns() + column("loc_key", LocKeyed.COL_LOC_KEY, VarType.STRING) + + row("dbrow.hunter_chinchompa") { + columnRSCM(COL_NPC, "npc.hunting_chinchompa") + column(COL_LEVEL, 53) + column(COL_XP, 1984) + column(COL_SUCCESS_LOW, 6) + column(COL_SUCCESS_HIGH, 268) + columnRSCM(COL_CAUGHT_ITEMS, "obj.chinchompa_captured") + column(COL_CAUGHT_MIN, 1) + column(COL_CAUGHT_MAX, 1) + column(LocKeyed.COL_LOC_KEY, "chinchompa") + } + + row("dbrow.hunter_carnivorous_chinchompa") { + columnRSCM(COL_NPC, "npc.hunting_chinchompa_big") + column(COL_LEVEL, 63) + column(COL_XP, 2650) + // "Carnivorous and Black Chinchompas have the same catch rate" - both wiki pages. + column(COL_SUCCESS_LOW, -78) + column(COL_SUCCESS_HIGH, 228) + columnRSCM(COL_CAUGHT_ITEMS, "obj.chinchompa_big_captured") + column(COL_CAUGHT_MIN, 1) + column(COL_CAUGHT_MAX, 1) + column(LocKeyed.COL_LOC_KEY, "chinchompa_big") + } + + row("dbrow.hunter_black_chinchompa") { + columnRSCM(COL_NPC, "npc.hunting_chinchompa_black") + column(COL_LEVEL, 73) + column(COL_XP, 3150) + column(COL_SUCCESS_LOW, -78) + column(COL_SUCCESS_HIGH, 228) + columnRSCM(COL_CAUGHT_ITEMS, "obj.chinchompa_black") + column(COL_CAUGHT_MIN, 1) + column(COL_CAUGHT_MAX, 1) + column(LocKeyed.COL_LOC_KEY, "chinchompa_black") + } + } } diff --git a/content/skills/hunter/src/main/kotlin/org/rsmod/content/skills/hunter/BirdSnareEvents.kt b/content/skills/hunter/src/main/kotlin/org/rsmod/content/skills/hunter/BirdSnareEvents.kt new file mode 100644 index 000000000..a5d168f31 --- /dev/null +++ b/content/skills/hunter/src/main/kotlin/org/rsmod/content/skills/hunter/BirdSnareEvents.kt @@ -0,0 +1,47 @@ +package org.rsmod.content.skills.hunter + +import jakarta.inject.Inject +import org.rsmod.api.player.protect.ProtectedAccess +import org.rsmod.api.repo.controller.ControllerRepository +import org.rsmod.api.script.onAiConTimer +import org.rsmod.api.script.onOpContentLoc1 +import org.rsmod.api.script.onOpContentLoc2 +import org.rsmod.api.script.onOpHeld1 +import org.rsmod.game.loc.BoundLocInfo +import org.rsmod.plugin.scripts.PluginScript +import org.rsmod.plugin.scripts.ScriptContext + +/** + * The bird snare's player-facing ops, and the trap tick every family shares. Every op routed here + * already exists on the cache type; the `Dismantle`/`Check` ops are all op1, so one registration + * catches them and [HunterTrap.takeTrap] decides what the tile owes the player. + */ +class BirdSnareEvents +@Inject +constructor(private val traps: HunterTrap, private val conRepo: ControllerRepository) : + PluginScript() { + override fun ScriptContext.startup() { + onOpHeld1("obj.hunting_ojibway_bird_snare") { lay() } + onOpContentLoc1("content.hunter_bird_snare") { takeDown(it.loc) } + onOpContentLoc2("content.hunter_bird_snare") { investigate(it.loc) } + + // Registered exactly once in the codebase: the controller type is shared, and a second + // registration would run every laid trap's tick twice per cycle. + onAiConTimer(TRAP_CONTROLLER) { with(traps) { controller.hunterTrapTick() } } + } + + private fun ProtectedAccess.lay() { + with(traps) { layTrap(TrapFamily.SNARE, player.coords) } + } + + private suspend fun ProtectedAccess.takeDown(loc: BoundLocInfo) { + arriveDelay() + with(traps) { takeTrap(loc, TrapFamily.SNARE) } + } + + /** Live's server-sent `Investigate` wording is not recoverable offline; the strings are ours. */ + private suspend fun ProtectedAccess.investigate(loc: BoundLocInfo) = + investigateTrap(loc, noun = "snare") { + conRepo.findExact(it.coords, TRAP_CONTROLLER) + } +} diff --git a/content/skills/hunter/src/main/kotlin/org/rsmod/content/skills/hunter/BoxTrapEvents.kt b/content/skills/hunter/src/main/kotlin/org/rsmod/content/skills/hunter/BoxTrapEvents.kt new file mode 100644 index 000000000..10b4ee096 --- /dev/null +++ b/content/skills/hunter/src/main/kotlin/org/rsmod/content/skills/hunter/BoxTrapEvents.kt @@ -0,0 +1,61 @@ +package org.rsmod.content.skills.hunter + +import dev.openrune.rscm.RSCM.asRSCM +import dev.openrune.rscm.RSCMType +import jakarta.inject.Inject +import org.rsmod.api.player.protect.ProtectedAccess +import org.rsmod.api.player.stat.hunterLvl +import org.rsmod.api.repo.controller.ControllerRepository +import org.rsmod.api.script.onOpContentLoc1 +import org.rsmod.api.script.onOpContentLoc2 +import org.rsmod.api.script.onOpHeld1 +import org.rsmod.game.loc.BoundLocInfo +import org.rsmod.plugin.scripts.PluginScript +import org.rsmod.plugin.scripts.ScriptContext + +/** + * The box trap's player-facing ops. Every op routed here already exists on the cache type. The + * sprung/failed states carry a real op2 of their own (`Reset`, out of scope - docs/hunter.md), and + * [onOpContentLoc2] dispatches on group and slot, not label, so [investigate] guards on the armed + * loc id to avoid answering a Reset click. + */ +class BoxTrapEvents +@Inject +constructor(private val traps: HunterTrap, private val conRepo: ControllerRepository) : + PluginScript() { + override fun ScriptContext.startup() { + onOpHeld1("obj.hunting_box_trap") { lay() } + onOpContentLoc1("content.hunter_box_trap") { takeDown(it.loc) } + onOpContentLoc2("content.hunter_box_trap") { investigate(it.loc) } + } + + private fun ProtectedAccess.lay() { + // The family gate; the per-creature gate in the tick only stops a catch, not the lay. + if (player.hunterLvl < BOX_TRAP_LEVEL_REQ) { + mes("You need a Hunter level of $BOX_TRAP_LEVEL_REQ to lay a box trap.") + return + } + + // Live also gates on Eagles' Peak, which this repo does not model; left unenforced + // rather than fabricating a check (docs/hunter.md). + + with(traps) { layTrap(TrapFamily.BOX, player.coords) } + } + + private suspend fun ProtectedAccess.takeDown(loc: BoundLocInfo) { + arriveDelay() + with(traps) { takeTrap(loc, TrapFamily.BOX) } + } + + /** `Investigate` exists only on the armed state; other op2 clicks are `Reset` (see class doc). */ + private suspend fun ProtectedAccess.investigate(loc: BoundLocInfo) = + investigateTrap(loc, noun = "box trap", armed = { it.id == SET_LOC }) { + conRepo.findExact(it.coords, TRAP_CONTROLLER) + } + + private companion object { + private const val BOX_TRAP_LEVEL_REQ = 27 + private val SET_LOC = + checkNotNull(HunterTrapStates.setLoc(TrapFamily.BOX)).asRSCM(RSCMType.LOC) + } +} diff --git a/content/skills/hunter/src/main/kotlin/org/rsmod/content/skills/hunter/HunterCreature.kt b/content/skills/hunter/src/main/kotlin/org/rsmod/content/skills/hunter/HunterCreature.kt new file mode 100644 index 000000000..60ded3733 --- /dev/null +++ b/content/skills/hunter/src/main/kotlin/org/rsmod/content/skills/hunter/HunterCreature.kt @@ -0,0 +1,87 @@ +package org.rsmod.content.skills.hunter + +/** + * Which technique a creature is caught with, and how its trap behaves. + * + * **The order is persisted**: a laid trap stores this enum's `ordinal` in + * `varcon.hunter_trap_family`, so a new family may only ever be *appended*. + */ +enum class TrapFamily { + SNARE, + BOX; + + /** + * True for families laid from an inventory item onto an empty tile, false for ones armed in + * place on a loc the map already supplied - which must never be deleted, only changed. + */ + val portable: Boolean + get() = + when (this) { + SNARE, + BOX -> true + } + + /** Sourced per family, and *not* the same split as [portable] (docs/hunter.md). */ + val suppressedByPlayerOnTile: Boolean + get() = + when (this) { + SNARE, + BOX -> true + } + + /** Chebyshev tiles; only the box trap's radius is sourced (docs/hunter.md). */ + val triggerDistance: Int + get() = + when (this) { + SNARE -> SNARE_TRIGGER_DISTANCE + BOX -> BOX_TRAP_TRIGGER_DISTANCE + } + + /** How often an armed trap rolls for a catch, in cycles. Only the box trap's is sourced. */ + val attemptCycles: Int + get() = + when (this) { + SNARE -> SNARE_ATTEMPT_CYCLES + BOX -> BOX_TRAP_ATTEMPT_CYCLES + } +} + +data class HunterCatch(val obj: String, val quantity: IntRange = 1..1) + +/** + * The size guard is the point: a ragged column edit must fail by name at boot, not as an + * `IndexOutOfBounds` on the one tick that catches the one creature affected. + */ +internal fun parallelCatches( + rowId: Int, + objs: List, + min: List, + max: List, +): List { + require(min.size == objs.size && max.size == objs.size) { + "Row $rowId has mismatched caught reward sizes: items=${objs.size}, " + + "min=${min.size}, max=${max.size}" + } + return objs.mapIndexed { i, obj -> HunterCatch(obj, min[i]..max[i]) } +} + +/** + * A single laid-trap creature. + * + * [successLow] and [successHigh] are the engine-formula coefficients fit to (or published for) the + * creature's charted per-level success curve - see docs/hunter.md for the fit, the 1/256-vs-/255 + * scale, and why a negative low means "always fails under-level" with no guard code. + * + * [locKey] is the suffix the trap's loc states are named by; authored data, never derived from + * [npc] (docs/hunter.md). + */ +data class HunterCreature( + val family: TrapFamily, + val npc: String, + val level: Int, + val xp: Int, + val caught: List, + val successLow: Int, + val successHigh: Int, + val locKey: String? = null, +) diff --git a/content/skills/hunter/src/main/kotlin/org/rsmod/content/skills/hunter/HunterCreatures.kt b/content/skills/hunter/src/main/kotlin/org/rsmod/content/skills/hunter/HunterCreatures.kt new file mode 100644 index 000000000..f4c667b30 --- /dev/null +++ b/content/skills/hunter/src/main/kotlin/org/rsmod/content/skills/hunter/HunterCreatures.kt @@ -0,0 +1,67 @@ +package org.rsmod.content.skills.hunter + +import dev.openrune.rscm.RSCM.asRSCM +import dev.openrune.rscm.RSCMType +import org.rsmod.api.table.hunter.HunterBoxCreaturesRow +import org.rsmod.api.table.hunter.HunterSnareCreaturesRow + +/** + * The creature tables read back from the packed dbtables; values and provenance live in + * `HunterTables.kt` and docs/hunter.md. XP passes through unscaled (stored x10). + */ +object HunterCreatures { + /** + * **Append only.** A sprung trap persists its creature as an index into this list, so a new + * row has to land after every row already in it. Sorted by dbrow id across **all tables at + * once**, not per table and concatenated - global order is what makes "give a new row an id + * above everything" the entire rule (docs/hunter.md). + */ + val all: List by lazy { + val rows = + HunterSnareCreaturesRow.all().map { it.rowId to snare(it) } + + HunterBoxCreaturesRow.all().map { it.rowId to box(it) } + rows.sortedBy { it.first }.map { it.second } + } + + private val byNpc: Map by lazy { all.associateBy { it.npc } } + + // `RSCM.getReverseMapping` is an unmemoised linear scan - far too slow for the trap tick. + private val byNpcId: Map by lazy { + all.associateBy { it.npc.asRSCM(RSCMType.NPC) } + } + + fun byNpc(npc: String): HunterCreature? = byNpc[npc] + + fun byNpcId(npc: Int): HunterCreature? = byNpcId[npc] + + private fun snare(row: HunterSnareCreaturesRow): HunterCreature = + HunterCreature( + family = TrapFamily.SNARE, + npc = row.npc.internalName, + level = row.level, + xp = row.xp, + caught = + parallelCatches( + row.rowId, + row.caughtItems.map { it.internalName }, + row.caughtMin, + row.caughtMax, + ), + successLow = row.successLow, + successHigh = row.successHigh, + locKey = row.locKey, + ) + + private fun box(row: HunterBoxCreaturesRow): HunterCreature = + HunterCreature( + family = TrapFamily.BOX, + npc = row.npc.internalName, + level = row.level, + xp = row.xp, + caught = + listOf(HunterCatch(row.caughtItems.internalName, row.caughtMin..row.caughtMax)), + successLow = row.successLow, + successHigh = row.successHigh, + locKey = row.locKey, + ) +} diff --git a/content/skills/hunter/src/main/kotlin/org/rsmod/content/skills/hunter/HunterModule.kt b/content/skills/hunter/src/main/kotlin/org/rsmod/content/skills/hunter/HunterModule.kt new file mode 100644 index 000000000..bbb6fd0ba --- /dev/null +++ b/content/skills/hunter/src/main/kotlin/org/rsmod/content/skills/hunter/HunterModule.kt @@ -0,0 +1,9 @@ +package org.rsmod.content.skills.hunter + +import org.rsmod.plugin.module.PluginModule + +class HunterModule : PluginModule() { + override fun bind() { + bindInstance() + } +} diff --git a/content/skills/hunter/src/main/kotlin/org/rsmod/content/skills/hunter/HunterShared.kt b/content/skills/hunter/src/main/kotlin/org/rsmod/content/skills/hunter/HunterShared.kt index ec8c12d0c..478a8b062 100644 --- a/content/skills/hunter/src/main/kotlin/org/rsmod/content/skills/hunter/HunterShared.kt +++ b/content/skills/hunter/src/main/kotlin/org/rsmod/content/skills/hunter/HunterShared.kt @@ -3,8 +3,12 @@ package org.rsmod.content.skills.hunter import dev.openrune.ServerCacheManager import dev.openrune.rscm.RSCM.asRSCM import dev.openrune.rscm.RSCMType +import org.rsmod.api.player.output.mes +import org.rsmod.api.player.protect.ProtectedAccess import org.rsmod.api.random.GameRandom +import org.rsmod.game.entity.Controller import org.rsmod.game.inv.Inventory +import org.rsmod.game.loc.BoundLocInfo // Rules shared by every hunter technique. Design notes: docs/hunter.md. @@ -24,3 +28,29 @@ internal fun hunterInvSlotsNeeded(inv: Inventory, internal: String, count: Int): else -> 1 } } + +/** + * `Investigate` on an armed trap: walk to it, then say who owns it and whether it has caught. + * [controller] is deferred rather than resolved by the caller because [arriveDelay] suspends: the + * trap must be looked up *after* the walk, or one that collapsed en route still reports as set. + */ +internal suspend fun ProtectedAccess.investigateTrap( + loc: BoundLocInfo, + noun: String, + armed: (BoundLocInfo) -> Boolean = { true }, + controller: (BoundLocInfo) -> Controller?, +) { + arriveDelay() + + if (!armed(loc)) { + mes("Nothing interesting happens.") + return + } + + val found = controller(loc) + when { + found == null -> mes("This trap has collapsed.") + found.trapOwner != player.uid.packed -> mes("This isn't your trap.") + else -> mes("The $noun is set. Nothing has sprung it yet.") + } +} diff --git a/content/skills/hunter/src/main/kotlin/org/rsmod/content/skills/hunter/HunterTrap.kt b/content/skills/hunter/src/main/kotlin/org/rsmod/content/skills/hunter/HunterTrap.kt new file mode 100644 index 000000000..df3fec8cf --- /dev/null +++ b/content/skills/hunter/src/main/kotlin/org/rsmod/content/skills/hunter/HunterTrap.kt @@ -0,0 +1,365 @@ +package org.rsmod.content.skills.hunter + +import dev.openrune.ServerCacheManager +import dev.openrune.rscm.RSCM.asRSCM +import dev.openrune.rscm.RSCMType +import jakarta.inject.Inject +import org.rsmod.api.player.isValidTarget +import org.rsmod.api.player.output.mes +import org.rsmod.api.player.protect.ProtectedAccess +import org.rsmod.api.player.stat.hunterLvl +import org.rsmod.api.random.GameRandom +import org.rsmod.api.repo.controller.ControllerRepository +import org.rsmod.api.repo.loc.LocRepository +import org.rsmod.api.repo.npc.NpcRepository +import org.rsmod.api.repo.player.PlayerRepository +import org.rsmod.api.stats.xpmod.XpModifiers +import org.rsmod.api.utils.skills.SkillingSuccessRate +import org.rsmod.game.MapClock +import org.rsmod.game.entity.Controller +import org.rsmod.game.entity.Npc +import org.rsmod.game.entity.Player +import org.rsmod.game.entity.PlayerList +import org.rsmod.game.entity.player.PlayerUid +import org.rsmod.game.loc.BoundLocInfo +import org.rsmod.game.loc.LocAngle +import org.rsmod.game.loc.LocInfo +import org.rsmod.game.loc.LocShape +import org.rsmod.map.CoordGrid +import org.rsmod.map.zone.ZoneKey + +/** + * Lay, advance, collect and collapse for the trap families. + * + * A laid trap is a [Controller] anchored at its tile; the controller, the loc chain and the cap + * all resolve from the tile. The player-facing ops belong to the per-family scripts, which also + * register `onAiConTimer(TRAP_CONTROLLER)` exactly once, since it is family-agnostic. Design notes + * and sources: docs/hunter.md. + */ +class HunterTrap +@Inject +constructor( + private val locRepo: LocRepository, + private val conRepo: ControllerRepository, + private val npcRepo: NpcRepository, + private val playerRepo: PlayerRepository, + private val playerList: PlayerList, + private val random: GameRandom, + private val xpMods: XpModifiers, + private val mapClock: MapClock, +) { + fun ProtectedAccess.layTrap(family: TrapFamily, coords: CoordGrid): Boolean { + val laid = trapAllowance() ?: return false + + if (!canTakeTrap(coords)) { + mes("You can't set a trap here.") + return false + } + + val setLoc = HunterTrapStates.setLoc(family) ?: return false + val trapObj = trapObj(family) ?: return false + if (invDel(inv, trapObj, 1).failure) { + val name = ServerCacheManager.getItem(trapObj.asRSCM(RSCMType.OBJ))?.name?.lowercase() + mes("You don't have a ${name ?: "trap"} to lay.") + return false + } + + spawnTrapLoc(coords, setLoc) + + val spawn = Controller(TRAP_CONTROLLER, coords) + conRepo.add(spawn, TRAP_LIFETIME_CYCLES) + spawn.trapOwner = player.uid.packed + spawn.trapFamily = family.ordinal + spawn.trapCreature = CREATURE_NONE + spawn.aiTimer(1) + + player.hunterTrapCoords = laid + coords.packed + return true + } + + // Deliberately never resets an idle trap's duration: unattended traps decay toward collapse. + fun Controller.hunterTrapTick() { + val family = TrapFamily.entries.getOrNull(trapFamily) + if (family == null) { + // A corrupt ordinal must not strand a controller-less loc on the tile forever. + clearTrapLoc(coords) + conRepo.del(this) + return + } + + val loc = findTrapLoc(family, coords) + if (loc == null) { + check(mapClock > creationCycle + 1) { "Hunter trap loc deleted faster than expected." } + conRepo.del(this) + return + } + + // Traps belong to a logged-in owner: live despawns a player's traps when they leave. + val owner = PlayerUid(trapOwner).resolve(playerList) + if (owner == null) { + collapse(family, owner = null) + return + } + + // ControllerRepository deletes an expired controller silently, which would strand the + // loc, so collapse one cycle early instead. + if (duration <= 1) { + collapse(family, owner) + return + } + + if (trapCreature != CREATURE_NONE) { + // Already sprung: settle, and keep ticking so the collapse above can still reclaim an + // uncollected trap. + if (settle(family, owner)) { + aiTimer(1) + } + return + } + + // Re-armed every cycle whatever the family's attempt cadence is: this tick is also what + // notices the expiring lifetime above. + aiTimer(1) + + // Phased on the trap's own creation cycle so traps laid on different cycles do not all + // roll in lockstep. Cadence sources: docs/hunter.md. + if ((mapClock.cycle - creationCycle) % family.attemptCycles != 0) { + return + } + + // A player standing on the trap blocks the roll only - the trap still ages toward + // collapse. `isValidTarget()` is load-bearing: `PlayerRegistry.findAll` does not filter + // hidden or mid-logout players, and one parked here would suppress every catch silently. + // Sources and the accepted trap-camping consequence: docs/hunter.md. + val centre = loc.coords + if ( + family.suppressedByPlayerOnTile && + playerRepo.findAll(centre).any { it.isValidTarget() } + ) { + return + } + + val target = nearbyCreature(family, centre) ?: return + + val (npc, creature) = target + + // A positive `successLow` (regular chinchompa) gives a real catch chance below the level + // requirement, so the gate is explicit, and it short-circuits before the roll so an + // under-levelled attempt never consumes a random draw. See docs/hunter.md. + val caught = + owner.hunterLvl >= creature.level && + SkillingSuccessRate.successRate( + low = creature.successLow, + high = creature.successHigh, + level = owner.hunterLvl, + maxLevel = MAX_HUNTER_LEVEL, + ) > random.randomDouble() + + npcRepo.despawn(npc, npc.visType.respawnRate) + + if (caught) { + trapCreature = HunterCreatures.all.indexOf(creature) + val dx = npc.coords.x - centre.x + val dz = npc.coords.z - centre.z + advanceTrapLoc(family, coords, HunterTrapStates.trappingLoc(creature, dx, dz)) + } else { + trapCreature = CREATURE_FAILED + advanceTrapLoc(family, coords, HunterTrapStates.failingLoc(family, creature)) + } + + // A sprung trap waits for its owner rather than continuing to decay. + resetDuration() + aiTimer(TRAP_SPRING_CYCLES) + } + + fun ProtectedAccess.collectTrap(loc: BoundLocInfo): Boolean = collectTrapAt(loc.coords) + + private fun ProtectedAccess.collectTrapAt(coords: CoordGrid): Boolean { + val controller = conRepo.findExact(coords, TRAP_CONTROLLER) ?: return false + if (controller.trapOwner != player.uid.packed) { + mes("This isn't your trap.") + return false + } + + val family = TrapFamily.entries.getOrNull(controller.trapFamily) ?: return false + val creature = HunterCreatures.all.getOrNull(controller.trapCreature) + + // Rolled once, up front: the space check and the awards must agree on the same numbers. + // `this@HunterTrap.random`, not `random` - the `ProtectedAccess` receiver has a `random` + // of its own that silently shadows the injected field. + val awards = + creature?.caught.orEmpty().map { + it.obj to rollQuantity(this@HunterTrap.random, it.quantity) + } + + val returned = trapComponents(family) + + // A stackable award the player already holds costs no slot; see [hunterInvSlotsNeeded]. + val slotsNeeded = + awards.sumOf { (obj, count) -> hunterInvSlotsNeeded(inv, obj, count) } + + returned.sumOf { hunterInvSlotsNeeded(inv, it, 1) } + if (inv.freeSpace() < slotsNeeded) { + mes("Your inventory is too full to hold any more.") + soundSynth("synth.pillory_wrong") + return false + } + + for ((obj, count) in awards) { + invAdd(inv, obj, count) + } + for (obj in returned) { + invAdd(inv, obj, 1) + } + + if (creature != null) { + // Stored x10. + val xp = (creature.xp / 10.0) * xpMods.get(player, "stat.hunter") + statAdvance("stat.hunter", xp) + } + + endTrapLoc(family, coords) + conRepo.del(controller) + player.sweepTrapCoords() + return true + } + + // A collapsed trap outlives its controller, so a missing controller is an ordinary case: + // whoever clears the tile keeps the trap item, consumed once on lay - it cannot mint twice. + fun ProtectedAccess.takeTrap(loc: BoundLocInfo, family: TrapFamily): Boolean { + if (conRepo.findExact(loc.coords, TRAP_CONTROLLER) != null) { + return collectTrap(loc) + } + + val trapObj = trapObj(family) ?: return false + if (inv.freeSpace() < hunterInvSlotsNeeded(inv, trapObj, 1)) { + mes("Your inventory is too full to hold any more.") + soundSynth("synth.pillory_wrong") + return false + } + + invAdd(inv, trapObj, 1) + clearTrapLoc(loc.coords) + player.sweepTrapCoords() + return true + } + + private fun Player.sweepTrapCoords(): List { + val stored = hunterTrapCoords + val live = + stored.filter { packed -> + val controller = conRepo.findExact(CoordGrid(packed), TRAP_CONTROLLER) + controller != null && controller.trapOwner == uid.packed + } + if (live.size != stored.size) { + hunterTrapCoords = live + } + return live + } + + // Replaces the intermediate loc with the terminal one; false if the trap is finished and its + // controller deleted. + private fun Controller.settle(family: TrapFamily, owner: Player?): Boolean { + val settled = + if (trapCreature == CREATURE_FAILED) { + HunterTrapStates.failedLoc(family) + } else { + val creature = HunterCreatures.all.getOrNull(trapCreature) ?: return true + HunterTrapStates.fullLoc(creature) + } + val current = findTrapLoc(family, coords) ?: return true + if (current.id == settled.asRSCM(RSCMType.LOC)) { + return true + } + advanceTrapLoc(family, coords, settled) + return true + } + + // The wreck stays on the ground for a while, so the owner can still come back for the trap + // item. [owner] is null when the collapse *is* the owner logging out. + private fun Controller.collapse(family: TrapFamily, owner: Player?) { + spawnTrapLoc(coords, HunterTrapStates.failedLoc(family), TRAP_COLLAPSE_LINGER_CYCLES) + conRepo.del(this) + } + + private fun ProtectedAccess.trapAllowance(): List? { + // Sweep before the cap check: a trap that died while the player was away must not still + // occupy a slot. The cap reads the effective level, so boosts raise it. + val laid = player.sweepTrapCoords() + val cap = TrapLadder.cap(player.hunterLvl) + if (laid.size >= cap) { + val plural = if (cap == 1) "trap" else "traps" + mes("You can only lay $cap $plural at your Hunter level.") + return null + } + return laid + } + + // The visibility filter is load-bearing: despawn only *hides* a caught creature, so without + // it one creature is caught by several traps at once (docs/hunter.md). [Npc.isVisible] and + // deliberately not `isValidTarget()`, which requires `hitpoints > 0` - no creature declares any. + private fun nearbyCreature( + family: TrapFamily, + centre: CoordGrid, + ): Pair? = + npcRepo + .findAll(ZoneKey.from(centre), zoneRadius = 1) + .filter { npc -> + npc.isVisible && + npc.coords.level == centre.level && + npc.coords.chebyshevDistance(centre) <= family.triggerDistance + } + .mapNotNull { npc -> + val creature = HunterCreatures.byNpcId(npc.visType.id) + creature?.takeIf { it.family == family }?.let { npc to it } + } + .firstOrNull() + + private fun canTakeTrap(coords: CoordGrid): Boolean = + conRepo.findExact(coords, TRAP_CONTROLLER) == null && + locRepo.findExact(coords, LocShape.CentrepieceStraight) == null && + locRepo.findExact(coords, LocShape.CentrepieceDiagonal) == null + + private fun findTrapLoc(family: TrapFamily, coords: CoordGrid): LocInfo? = + when (family) { + TrapFamily.SNARE, + TrapFamily.BOX -> locRepo.findExact(coords, LocShape.CentrepieceStraight) + } + + private fun advanceTrapLoc(family: TrapFamily, coords: CoordGrid, internal: String) { + when (family) { + TrapFamily.SNARE, + TrapFamily.BOX -> spawnTrapLoc(coords, internal) + } + } + + private fun endTrapLoc(family: TrapFamily, coords: CoordGrid) { + when (family) { + TrapFamily.SNARE, + TrapFamily.BOX -> clearTrapLoc(coords) + } + } + + private fun spawnTrapLoc(coords: CoordGrid, internal: String, duration: Int = Int.MAX_VALUE) { + locRepo.add(coords, internal, duration, LocAngle.West, LocShape.CentrepieceStraight) + } + + private fun clearTrapLoc(coords: CoordGrid) { + val loc = locRepo.findExact(coords, LocShape.CentrepieceStraight) ?: return + locRepo.del(loc, Int.MAX_VALUE) + } + + private companion object { + private fun trapObj(family: TrapFamily): String? = + when (family) { + TrapFamily.SNARE -> "obj.hunting_ojibway_bird_snare" + TrapFamily.BOX -> "obj.hunting_box_trap" + } + + // What a successful collect hands back alongside the catch. + private fun trapComponents(family: TrapFamily): List = + when (family) { + TrapFamily.SNARE, + TrapFamily.BOX -> listOfNotNull(trapObj(family)) + } + } +} diff --git a/content/skills/hunter/src/main/kotlin/org/rsmod/content/skills/hunter/HunterTrapStates.kt b/content/skills/hunter/src/main/kotlin/org/rsmod/content/skills/hunter/HunterTrapStates.kt new file mode 100644 index 000000000..a97ab1b53 --- /dev/null +++ b/content/skills/hunter/src/main/kotlin/org/rsmod/content/skills/hunter/HunterTrapStates.kt @@ -0,0 +1,51 @@ +package org.rsmod.content.skills.hunter + +import kotlin.math.abs + +object HunterTrapStates { + // Authored data, never derived from the npc symbol (docs/hunter.md). + private fun locKey(creature: HunterCreature): String = + requireNotNull(creature.locKey) { + "Creature is missing its loc key: ${creature.npc}" + } + + fun setLoc(family: TrapFamily): String? = + when (family) { + TrapFamily.SNARE -> "loc.hunting_ojibway_trap" + TrapFamily.BOX -> "loc.hunting_boxtrap_empty" + } + + /** The mid-catch state, given where the creature stands relative to the trap ([dx], [dz]). */ + fun trappingLoc(creature: HunterCreature, dx: Int, dz: Int): String = + when (creature.family) { + TrapFamily.SNARE -> "loc.hunting_ojibway_trap_trapping_${locKey(creature)}" + TrapFamily.BOX -> "loc.hunting_boxtrap_trapping_${locKey(creature)}_${compass(dx, dz)}" + } + + fun fullLoc(creature: HunterCreature): String = + when (creature.family) { + TrapFamily.SNARE -> "loc.hunting_ojibway_trap_full_${locKey(creature)}" + TrapFamily.BOX -> "loc.hunting_boxtrap_full_${locKey(creature)}" + } + + fun failingLoc(family: TrapFamily, creature: HunterCreature? = null): String = + when (family) { + TrapFamily.SNARE -> "loc.hunting_ojibway_trap_failing" + TrapFamily.BOX -> "loc.hunting_boxtrap_failing" + } + + fun failedLoc(family: TrapFamily, creature: HunterCreature? = null): String = + when (family) { + TrapFamily.SNARE -> "loc.hunting_ojibway_trap_broken" + TrapFamily.BOX -> "loc.hunting_boxtrap_failed" + } + + // Ties and a same-tile creature fall to `n`; whether live picks this way is unverified. + private fun compass(dx: Int, dz: Int): Char = + when { + abs(dz) >= abs(dx) && dz >= 0 -> 'n' + abs(dz) >= abs(dx) -> 's' + dx >= 0 -> 'e' + else -> 'w' + } +} diff --git a/content/skills/hunter/src/main/kotlin/org/rsmod/content/skills/hunter/HunterTrapTuning.kt b/content/skills/hunter/src/main/kotlin/org/rsmod/content/skills/hunter/HunterTrapTuning.kt new file mode 100644 index 000000000..8d2784d3f --- /dev/null +++ b/content/skills/hunter/src/main/kotlin/org/rsmod/content/skills/hunter/HunterTrapTuning.kt @@ -0,0 +1,42 @@ +package org.rsmod.content.skills.hunter + +// Every tuned number the trap families run on. Full sourcing per constant: docs/hunter.md. + +/** The controller type every laid trap is anchored to, whichever family it belongs to. */ +const val TRAP_CONTROLLER: String = "controller.hunter_trap" + +/** + * How long an untouched trap has before it collapses. Seeded from RuneLite's `TRAP_TIME` overlay + * figure (~1 minute); not server truth. + */ +const val TRAP_LIFETIME_CYCLES: Int = 100 + +/** + * How long the `_trapping_` / `_failing_` loc is shown before it settles into `_full_` / + * `_failed_`. Live's real duration is not answerable offline; a fixed short step is the model. + */ +const val TRAP_SPRING_CYCLES: Int = 2 + +/** + * How long a collapsed trap is left on the ground after its controller is gone. Finite so the loc + * cleans itself up if the owner never comes back. + */ +const val TRAP_COLLAPSE_LINGER_CYCLES: Int = 100 + +/** "within a 2-tile radius of the box trap" (wiki, *Box trap > Mechanics*). */ +const val BOX_TRAP_TRIGGER_DISTANCE: Int = 2 + +/** + * Unsourced: no page or cache record states a snare radius. Adjacency is the conservative + * reading - do not promote it to the box trap's 2 without a source (docs/hunter.md). + */ +const val SNARE_TRIGGER_DISTANCE: Int = 1 + +/** "an attempt every 3 ticks (1.8 seconds)" (wiki, *Box trap > Mechanics*). */ +const val BOX_TRAP_ATTEMPT_CYCLES: Int = 3 + +/** Unsourced, like [SNARE_TRIGGER_DISTANCE]: the wiki gives a cadence for the box trap only. */ +const val SNARE_ATTEMPT_CYCLES: Int = 1 + +/** The most traps any player can have laid, reached at level 80. */ +const val MAX_LAID_TRAPS: Int = 5 diff --git a/content/skills/hunter/src/main/kotlin/org/rsmod/content/skills/hunter/HunterTrapVars.kt b/content/skills/hunter/src/main/kotlin/org/rsmod/content/skills/hunter/HunterTrapVars.kt new file mode 100644 index 000000000..1314c02e1 --- /dev/null +++ b/content/skills/hunter/src/main/kotlin/org/rsmod/content/skills/hunter/HunterTrapVars.kt @@ -0,0 +1,53 @@ +package org.rsmod.content.skills.hunter + +import org.rsmod.api.controller.vars.intVarCon +import org.rsmod.api.player.vars.intVarp +import org.rsmod.game.entity.Controller +import org.rsmod.game.entity.Player + +/** + * Everything a laid trap writes to save data - the module's contract with existing player saves. + * Two rules govern it: [Controller.trapFamily] holds a [TrapFamily] **ordinal** and + * [Controller.trapCreature] an index into **[HunterCreatures.all]**, so both may only be appended + * to. The sentinels are negative because an unset varcon reads 0, a legitimate index. + */ + +internal const val CREATURE_NONE: Int = -1 + +internal const val CREATURE_FAILED: Int = -2 + +/** An unwritten coord varp reads back 0; `CoordGrid.ZERO` is off-map, so no collision. */ +private const val EMPTY_TRAP_COORD: Int = 0 + +var Controller.trapOwner: Int by intVarCon("varcon.hunter_trap_owner") + +var Controller.trapFamily: Int by intVarCon("varcon.hunter_trap_family") + +var Controller.trapCreature: Int by intVarCon("varcon.hunter_trap_creature") + +private var Player.trapCoord1: Int by intVarp("varp.hunter_trap_coord_1") +private var Player.trapCoord2: Int by intVarp("varp.hunter_trap_coord_2") +private var Player.trapCoord3: Int by intVarp("varp.hunter_trap_coord_3") +private var Player.trapCoord4: Int by intVarp("varp.hunter_trap_coord_4") +private var Player.trapCoord5: Int by intVarp("varp.hunter_trap_coord_5") + +/** + * The packed coords of every trap this player believes it has laid. Coords, not a counter: a + * counter leaks a slot whenever a trap dies while its owner is away, where a coord can be + * re-checked against the world (docs/hunter.md). + */ +var Player.hunterTrapCoords: List + get() = + listOf(trapCoord1, trapCoord2, trapCoord3, trapCoord4, trapCoord5).filter { + it != EMPTY_TRAP_COORD + } + set(value) { + require(value.size <= MAX_LAID_TRAPS) { + "Cannot store more than $MAX_LAID_TRAPS trap coords: $value" + } + trapCoord1 = value.getOrElse(0) { EMPTY_TRAP_COORD } + trapCoord2 = value.getOrElse(1) { EMPTY_TRAP_COORD } + trapCoord3 = value.getOrElse(2) { EMPTY_TRAP_COORD } + trapCoord4 = value.getOrElse(3) { EMPTY_TRAP_COORD } + trapCoord5 = value.getOrElse(4) { EMPTY_TRAP_COORD } + } diff --git a/content/skills/hunter/src/main/resources/gamevals.toml b/content/skills/hunter/src/main/resources/gamevals.toml new file mode 100644 index 000000000..b494ed8de --- /dev/null +++ b/content/skills/hunter/src/main/resources/gamevals.toml @@ -0,0 +1,39 @@ +[gamevals.dbtable] +# 55543-55549 sit above the whole 555xx dbtable run in use today - mining's 55520 and firemaking's +# 55535-55537 - with 55538-55542 left clear for the fletching tables. +hunter_snare_creatures = 55543 +hunter_box_creatures = 55544 + +# Append-only block, clear of the 555xx and 56[0-2]xx dbrow runs with headroom. Ids ascend in the +# order the creatures appear in the combined list, which a sprung trap persists an index into, so +# each technique takes the next free decade rather than filling the gap above it. +[gamevals.dbrow] +hunter_jungle_bird = 56300 +hunter_desert_bird = 56301 +hunter_woodland_bird = 56302 +hunter_polar_bird = 56303 +hunter_chinchompa = 56304 +hunter_carnivorous_chinchompa = 56305 +hunter_black_chinchompa = 56306 + +[gamevals.controller] +hunter_trap = 2 + +[gamevals.varcon] +hunter_trap_owner = 4 +hunter_trap_family = 5 +hunter_trap_creature = 6 + +[gamevals.content] +hunter_bird_snare = 54 +hunter_box_trap = 55 + +# Custom varp ids grow downward from the low-water mark of the server-authored block: 65481-65501 +# is already claimed by runecrafting, sandstorm, slayer, instances, herblore and prayer, so hunter +# takes the next five below it. Coords rather than a counter: see docs/hunter.md. +[gamevals.varp] +hunter_trap_coord_1 = 65480 +hunter_trap_coord_2 = 65479 +hunter_trap_coord_3 = 65478 +hunter_trap_coord_4 = 65477 +hunter_trap_coord_5 = 65476 diff --git a/content/skills/hunter/src/test/kotlin/org/rsmod/content/skills/hunter/HunterRateTablesTest.kt b/content/skills/hunter/src/test/kotlin/org/rsmod/content/skills/hunter/HunterRateTablesTest.kt new file mode 100644 index 000000000..3b4ea855a --- /dev/null +++ b/content/skills/hunter/src/test/kotlin/org/rsmod/content/skills/hunter/HunterRateTablesTest.kt @@ -0,0 +1,223 @@ +package org.rsmod.content.skills.hunter + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.parallel.ResourceLock +import org.rsmod.api.utils.skills.SkillingSuccessRate + +/** + * Every shipped catch-rate pair, checked against the published chart it was fit against + * (docs/hunter.md). Every expected number is read off a wiki chart checked in under + * `src/test/resources/wiki-charts`, not copied out of `HunterTables`, and rates are read back out + * of the *packed* dbtable, so a column-id gap that shifts a table sideways fails here too. + */ +@ResourceLock(HUNTER_TEST_WORLD_LOCK) +class HunterRateTablesTest { + /** Every charted point is reproduced exactly by the pair the server ships for that creature. */ + @Test + fun everyChartedPointReproducesItsShippedPair() { + val charts = readCharts() + var checked = 0 + for (series in CHARTED) { + val rate = shippedRate(series.npc) + val points = charts.getValue(series.series) + for ((level, expected) in points) { + assertEquals( + expected, + charted(rate.low + series.netBonus, rate.high + series.netBonus, level), + "${series.series} (${series.npc}) at level $level: chart says $expected/256", + ) + checked++ + } + } + assertEquals(300, checked, "Chart point count changed; confirm the resources are intact.") + } + + /** + * Each chart begins at its creature's level requirement - the cross-check that catches a + * swapped pair of level requirements, which reproducing the curves alone would not. + */ + @Test + fun everyChartStartsAtItsCreaturesLevelRequirement() { + val charts = readCharts() + for (series in CHARTED) { + val first = charts.getValue(series.series).minOf { it.level } + assertEquals( + shippedRate(series.npc).level, + first, + "${series.series}: chart starts at L$first, so ${series.npc}'s requirement should too", + ) + } + } + + /** No chart sits in the resources unaccounted for, mapped to nothing and asserted by nothing. */ + @Test + fun everyChartedSeriesIsEitherMappedToARowOrDeclaredUnshipped() { + val mapped = CHARTED.map { it.series }.toSet() + for (series in readCharts().keys.sorted()) { + assertTrue( + series in mapped || series in UNSHIPPED_SERIES, + "Chart series '$series' is mapped to no creature and not declared unshipped.", + ) + } + } + + /** + * Every shipped pair is the chart template's **own published parameter**, exactly - strictly + * stronger than reproducing the chart, which does not pin a pair (docs/hunter.md). + */ + @Test + fun everyShippedPairIsThePublishedParameter() { + val params = readParams() + for (entry in PUBLISHED) { + val key = "${entry.page}|${entry.series}" + val published = checkNotNull(params[key]) { "No published parameters for $key" } + val rate = shippedRate(entry.npc) + assertEquals( + (published.low - entry.netBonus) to (published.high - entry.netBonus), + rate.low to rate.high, + "${entry.npc}: $key publishes (${published.low}, ${published.high})" + + if (entry.netBonus != 0) " less the ${entry.netBonus} net bonus" else "", + ) + } + } + + /** The template also publishes each creature's level requirement; it must be the shipped one. */ + @Test + fun everyShippedLevelIsThePublishedRequirement() { + val params = readParams() + for (entry in PUBLISHED) { + val published = params.getValue("${entry.page}|${entry.series}") + assertEquals( + published.req, + shippedRate(entry.npc).level, + "${entry.npc}: ${entry.page} publishes req=${published.req}", + ) + } + } + + /** No charted creature may rely on a fit when its parameters are published. */ + @Test + fun everyChartedCreatureAlsoHasItsPublishedParameterAsserted() { + assertEquals( + emptySet(), + CHARTED.map { it.npc }.toSet() - PUBLISHED.map { it.npc }.toSet(), + "Charted creatures whose published parameters are not asserted.", + ) + } + + private fun readParams(): Map = + checkNotNull(javaClass.getResourceAsStream("/wiki-charts/published-params.tsv")) { + "Missing /wiki-charts/published-params.tsv" + } + .bufferedReader() + .readLines() + .filter { it.isNotBlank() && !it.startsWith("#") } + .associate { line -> + val f = line.split("\t") + require(f.size == 6) { "Malformed params row: $line" } + "${f[0]}|${f[2]}" to PublishedParams(f[3].toInt(), f[4].toInt(), f[5].toInt()) + } + + private data class PublishedParams(val low: Int, val high: Int, val req: Int) + + /** A published chart series, and the shipped row whose pair it is the source for. */ + private data class Published( + val page: String, + val series: String, + val npc: String, + val netBonus: Int = 0, + ) + + private fun shippedRate(npc: String): ShippedRate = + checkNotNull(allShippedRates().firstOrNull { it.npc == npc }) { + "No shipped rate row for $npc" + } + + private fun allShippedRates(): List = + HunterCreatures.all.map { ShippedRate(it.npc, it.level, it.successLow, it.successHigh) } + + private fun firstCertainLevel(rate: ShippedRate): Int = + (1..99).first { charted(rate.low, rate.high, it) == 256 } + + /** What the engine rolls against, out of 256, uncapped. */ + private fun chance256(low: Int, high: Int, level: Int): Int = + Math.round(SkillingSuccessRate.successRate(low, high, level, 99) * 256).toInt() + + /** What the wiki *charts*, which is [chance256] capped at certainty. */ + private fun charted(low: Int, high: Int, level: Int): Int = + minOf(256, chance256(low, high, level)) + + private fun readCharts(): Map> = + CHART_FILES + .flatMap { file -> + val resource = "/wiki-charts/$file" + val text = + checkNotNull(javaClass.getResourceAsStream(resource)) { + "Missing chart resource $resource" + } + .bufferedReader() + .readText() + text.lineSequence() + .filter { it.isNotBlank() && !it.startsWith("#") } + .map { line -> + val fields = line.trim().split(Regex("\\s+")) + require(fields.size == 3) { "Malformed row in $file: $line" } + fields[0] to ChartPoint(fields[1].toInt(), fields[2].toInt()) + } + .toList() + } + .groupBy({ it.first }, { it.second }) + + private data class ChartPoint(val level: Int, val chance256: Int) + + private data class ShippedRate(val npc: String, val level: Int, val low: Int, val high: Int) + + /** A charted series and the row it is the source for. */ + private data class Charted(val series: String, val npc: String, val netBonus: Int = 0) + + companion object { + private val CHART_FILES = + listOf( + "birdsnare-chance.tsv", + "boxtrap-chance.tsv", + ) + + private val CHARTED = + listOf( + Charted("crimson_swift", "npc.hunting_bird_jungle"), + Charted("golden_warbler", "npc.hunting_bird_desert"), + Charted("copper_longtail", "npc.hunting_bird_woodland"), + Charted("cerulean_twitch", "npc.hunting_bird_polar"), + Charted("chinchompa", "npc.hunting_chinchompa"), + Charted("carnivorous_chinchompa", "npc.hunting_chinchompa_big"), + Charted("black_chinchompa", "npc.hunting_chinchompa_black"), + ) + + /** The published `{{Skilling success chart}}` parameters, keyed by page and series label. */ + private val PUBLISHED = + listOf( + Published("Crimson swift", "Crimson swift", "npc.hunting_bird_jungle"), + Published("Golden warbler", "Golden warbler", "npc.hunting_bird_desert"), + Published("Copper longtail", "Copper longtail", "npc.hunting_bird_woodland"), + Published("Cerulean twitch", "Cerulean twitch", "npc.hunting_bird_polar"), + Published("Chinchompa (Hunter)", "Grey", "npc.hunting_chinchompa"), + Published("Chinchompa (Hunter)", "Red", "npc.hunting_chinchompa_big"), + Published("Chinchompa (Hunter)", "Black", "npc.hunting_chinchompa_black"), + ) + + /** + * Charted but deliberately not shipped; see + * [theMoonlightMothIsNotOnTheOtherButterfliesCurve]. + */ + private val UNSHIPPED_SERIES = setOf("moonlight_moth", "moonlight_moth_magicnet") + + @JvmStatic + @BeforeAll + fun loadCache() { + HunterTestCache.load() + } + } +} diff --git a/content/skills/hunter/src/test/kotlin/org/rsmod/content/skills/hunter/HunterTrapOpsTest.kt b/content/skills/hunter/src/test/kotlin/org/rsmod/content/skills/hunter/HunterTrapOpsTest.kt new file mode 100644 index 000000000..c78e7ab8e --- /dev/null +++ b/content/skills/hunter/src/test/kotlin/org/rsmod/content/skills/hunter/HunterTrapOpsTest.kt @@ -0,0 +1,237 @@ +package org.rsmod.content.skills.hunter + +import dev.openrune.rscm.RSCM +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.parallel.Execution +import org.junit.jupiter.api.parallel.ExecutionMode +import org.junit.jupiter.api.parallel.ResourceLock +import org.rsmod.content.skills.hunter.HunterTrapTestWorld.Companion.TRAP_TILE +import org.rsmod.game.entity.Controller +import org.rsmod.game.entity.Player + +/** + * The player-facing half of the engine: the `ProtectedAccess` ops, run over + * `ProtectedAccessContextFactory.empty()` since no hunter op touches a context dependency. Two + * things stay out of reach and are covered nowhere: the *timed* half of a loc change + * (`LocRepository.processDurations` is internal and game-loop-driven), and `mes`/`soundSynth` + * output (a `NoopClient` records nothing, so a refusal is observable but its message is not). + * Serialised: `ServerCacheManager` is a singleton and `RSCM` memoises into a plain `HashMap`, + * which is not safe to fill from the parallel execution `test-conventions` turns on. + */ +@Execution(ExecutionMode.SAME_THREAD) +@ResourceLock(HUNTER_TEST_WORLD_LOCK) +class HunterTrapOpsTest { + private lateinit var world: HunterTrapTestWorld + + @BeforeEach + fun setUp() { + HunterTestCache.load() + world = HunterTrapTestWorld() + } + + /* layTrap. */ + + @Test + fun `laying a box trap consumes the trap item and records the coord`() { + val player = hunter(level = 99, carrying = listOf("obj.hunting_box_trap")) + + assertTrue(world.runProtected(player) { it.layTrap(TrapFamily.BOX, TRAP_TILE) }) + + assertEquals("loc.hunting_boxtrap_empty", world.locNameAt(TRAP_TILE)) + assertFalse(player.inv.contains("obj.hunting_box_trap")) + assertEquals(listOf(TRAP_TILE.packed), player.hunterTrapCoords) + assertNotNull(world.controllerAt(TRAP_TILE)) + } + + @Test + fun `laying a trap without the item is refused`() { + val player = hunter(level = 99, carrying = emptyList()) + + assertFalse(world.runProtected(player) { it.layTrap(TrapFamily.BOX, TRAP_TILE) }) + + assertNull(world.locAt(TRAP_TILE)) + assertNull(world.controllerAt(TRAP_TILE)) + } + + @Test + fun `a tile that already holds a trap cannot take another`() { + val player = + hunter(level = 99, carrying = listOf("obj.hunting_box_trap", "obj.hunting_box_trap")) + + assertTrue(world.runProtected(player) { it.layTrap(TrapFamily.BOX, TRAP_TILE) }) + assertFalse(world.runProtected(player) { it.layTrap(TrapFamily.BOX, TRAP_TILE) }) + + assertTrue(player.inv.contains("obj.hunting_box_trap"), "The second trap is not consumed.") + } + + /** + * The cap is read from the *effective* level, and a level-1 hunter gets one trap. The stored + * coords are what enforces it, so this is also the check that laying writes them. + */ + @Test + fun `a level-1 hunter can only lay one trap`() { + val snare = "obj.hunting_ojibway_bird_snare" + val player = hunter(level = 1, carrying = listOf(snare, snare)) + + assertTrue(world.runProtected(player) { it.layTrap(TrapFamily.SNARE, TRAP_TILE) }) + assertFalse( + world.runProtected(player) { it.layTrap(TrapFamily.SNARE, TRAP_TILE.translate(2, 0)) } + ) + + assertEquals(1, player.hunterTrapCoords.size) + assertNull(world.locAt(TRAP_TILE.translate(2, 0))) + } + + /* collectTrap and takeTrap. */ + + @Test + fun `collecting a sprung box trap awards the catch, returns the trap and grants xp`() { + val player = hunter(level = 99, carrying = listOf("obj.hunting_box_trap")) + val controller = springBoxTrapOn(player) + + val sprung = world.boundLocAt(TRAP_TILE)!! + assertTrue(world.runProtected(player) { it.collectTrap(sprung) }) + + assertTrue(player.inv.contains("obj.chinchompa_captured"), "The catch.") + assertTrue(player.inv.contains("obj.hunting_box_trap"), "The trap item comes back.") + assertNull(world.locAt(TRAP_TILE)) + assertNull(world.controllerAt(TRAP_TILE)) + assertEquals(emptyList(), player.hunterTrapCoords) + + // Creature xp is stored x10 in the packed table and divided by ten once, at the award. + val creature = HunterCreatures.all[controller.trapCreature] + assertEquals(creature.xp / 10, player.statMap.getXP("stat.hunter")) + } + + /** + * The Hunter xp modifier is *applied*, not merely injected. + * + * A world built with no modifiers is a flat 1.0, so the `* xpMods.get(player, "stat.hunter")` + * on the award site could be deleted with the rest of the suite still green. Running the same + * catch twice, once in a doubled world, is what makes the multiplication load-bearing. + */ + @Test + fun `the xp modifier scales the trap award`() { + val plain = collectedChinchompaFineXp(hunterXpBonus = 0.0) + val doubled = collectedChinchompaFineXp(hunterXpBonus = DOUBLE_HUNTER_XP) + + // The grey chinchompa's 198.4 xp, which is why the tables store tenths at all. + assertEquals(1984, plain, "unmodified, a grey chinchompa is 198.4 xp") + assertEquals(3968, doubled, "a +100% modifier makes it 396.8") + } + + /** + * One collected box trap holding a chinchompa, in tenths of a point. + * + * Replaces [world]: `setUp` puts a fresh default one back before the next test. + */ + private fun collectedChinchompaFineXp(hunterXpBonus: Double): Int { + world = HunterTrapTestWorld(hunterXpBonus = hunterXpBonus) + val player = hunter(level = 99, carrying = listOf("obj.hunting_box_trap")) + springBoxTrapOn(player) + val sprung = world.boundLocAt(TRAP_TILE)!! + + assertTrue(world.runProtected(player) { it.collectTrap(sprung) }) + + return player.statMap.getFineXP("stat.hunter") + } + + @Test + fun `collecting someone else's trap is refused and leaves it standing`() { + val owner = hunter(level = 99, carrying = listOf("obj.hunting_box_trap")) + springBoxTrapOn(owner) + + val thief = hunter(level = 99, carrying = emptyList()) + val sprung = world.boundLocAt(TRAP_TILE)!! + assertFalse(world.runProtected(thief) { it.collectTrap(sprung) }) + + assertFalse(thief.inv.contains("obj.chinchompa_captured")) + assertNotNull(world.controllerAt(TRAP_TILE)) + } + + /** + * A refused collect must be a no-op, not a partial one: the space check runs before anything is + * awarded, so the trap is still there to try again with a slot free. + */ + @Test + fun `a full inventory refuses the collect and awards nothing`() { + val player = hunter(level = 99, carrying = listOf("obj.hunting_box_trap")) + springBoxTrapOn(player) + val access = world.protectedAccess(player) + while (player.inv.freeSpace() > 0) { + access.invAdd(player.inv, "obj.bones", 1) + } + + val sprung = world.boundLocAt(TRAP_TILE)!! + assertFalse(world.runProtected(player) { it.collectTrap(sprung) }) + + assertFalse(player.inv.contains("obj.chinchompa_captured")) + assertNotNull(world.controllerAt(TRAP_TILE), "The trap is still there to come back to.") + assertEquals(0, player.statMap.getXP("stat.hunter"), "No xp on a refused collect.") + } + + /** + * A collapsed trap outlives its controller, so `takeTrap` on one has nobody to check ownership + * against; whoever clears the tile keeps the trap item. It cannot mint a second one because the + * loc is deleted in the same call. + */ + @Test + fun `taking a collapsed trap hands the item back and clears the tile`() { + val owner = hunter(level = 99, carrying = listOf("obj.hunting_box_trap")) + world.runProtected(owner) { it.layTrap(TrapFamily.BOX, TRAP_TILE) } + val controller = world.controllerAt(TRAP_TILE)!! + controller.duration = 1 + world.tick(controller) + assertNull(world.controllerAt(TRAP_TILE), "Collapsed.") + + val wreck = world.boundLocAt(TRAP_TILE)!! + assertTrue(world.runProtected(owner) { it.takeTrap(wreck, TrapFamily.BOX) }) + + assertTrue(owner.inv.contains("obj.hunting_box_trap")) + assertNull(world.locAt(TRAP_TILE)) + } + + /** `takeTrap` on a trap that still has a controller routes to the collect transaction. */ + @Test + fun `taking a sprung trap collects it instead`() { + val player = hunter(level = 99, carrying = listOf("obj.hunting_box_trap")) + springBoxTrapOn(player) + + val sprung = world.boundLocAt(TRAP_TILE)!! + assertTrue(world.runProtected(player) { it.takeTrap(sprung, TrapFamily.BOX) }) + + assertTrue(player.inv.contains("obj.chinchompa_captured")) + assertTrue(player.inv.contains("obj.hunting_box_trap")) + } + + /* Helpers. */ + + private fun hunter(level: Int, carrying: List): Player { + val player = world.addPlayer(TRAP_TILE.translate(3, 3), hunterLvl = level) + val access = world.protectedAccess(player) + for (obj in carrying) { + access.invAdd(player.inv, obj, 1) + } + return player + } + + /** Lays [player]'s box trap on [TRAP_TILE] and springs it on a chinchompa, then settles it. */ + private fun springBoxTrapOn(player: Player): Controller { + world.runProtected(player) { it.layTrap(TrapFamily.BOX, TRAP_TILE) } + val controller = world.controllerAt(TRAP_TILE)!! + world.addNpc("npc.hunting_chinchompa", TRAP_TILE.translate(0, 1)) + world.random.nextDouble = ScriptedRandom.ALWAYS_CATCH + world.tick(controller) + world.advance(TRAP_SPRING_CYCLES) + world.tick(controller) + return controller + } + + private fun objId(internal: String): Int = RSCM.getRSCM(internal) +} diff --git a/content/skills/hunter/src/test/kotlin/org/rsmod/content/skills/hunter/HunterTrapTestFakes.kt b/content/skills/hunter/src/test/kotlin/org/rsmod/content/skills/hunter/HunterTrapTestFakes.kt new file mode 100644 index 000000000..98f4800cb --- /dev/null +++ b/content/skills/hunter/src/test/kotlin/org/rsmod/content/skills/hunter/HunterTrapTestFakes.kt @@ -0,0 +1,431 @@ +package org.rsmod.content.skills.hunter + +import dev.openrune.ServerCacheManager +import dev.openrune.gamevals.GameValProvider +import dev.openrune.rscm.RSCM +import dev.openrune.rscm.RSCM.asRSCM +import dev.openrune.rscm.RSCMType +import java.io.File +import java.nio.file.Paths +import kotlin.coroutines.startCoroutine +import org.rsmod.api.inv.storage.PlayerItemStorage +import org.rsmod.api.invtx.InvTransactionsScript +import org.rsmod.api.player.protect.ProtectedAccess +import org.rsmod.api.player.protect.ProtectedAccessContextFactory +import org.rsmod.api.random.GameRandom +import org.rsmod.api.registry.controller.ControllerRegistry +import org.rsmod.api.registry.loc.LocRegistry +import org.rsmod.api.registry.loc.LocRegistryNormal +import org.rsmod.api.registry.loc.LocRegistryRegion +import org.rsmod.api.registry.npc.NpcRegistry +import org.rsmod.api.registry.obj.ObjRegistry +import org.rsmod.api.registry.player.PlayerRegistry +import org.rsmod.api.registry.region.RegionRegistry +import org.rsmod.api.registry.zone.ZonePlayerActivityBitSet +import org.rsmod.api.registry.zone.ZoneUpdateMap +import org.rsmod.api.repo.controller.ControllerRepository +import org.rsmod.api.repo.loc.LocRepository +import org.rsmod.api.repo.npc.NpcRepository +import org.rsmod.api.repo.obj.ObjRepository +import org.rsmod.api.repo.player.PlayerRepository +import org.rsmod.coroutine.GameCoroutine +import org.rsmod.coroutine.suspension.GameCoroutineSimpleCompletion +import org.rsmod.events.EventBus +import org.rsmod.game.MapClock +import org.rsmod.game.cheat.CheatCommandMap +import org.rsmod.game.entity.Controller +import org.rsmod.game.entity.ControllerList +import org.rsmod.game.entity.Npc +import org.rsmod.game.entity.NpcList +import org.rsmod.game.entity.Player +import org.rsmod.game.entity.PlayerList +import org.rsmod.game.inv.Inventory +import org.rsmod.game.loc.BoundLocInfo +import org.rsmod.game.loc.LocAngle +import org.rsmod.game.loc.LocEntity +import org.rsmod.game.loc.LocInfo +import org.rsmod.game.loc.LocShape +import org.rsmod.game.loc.LocZoneKey +import org.rsmod.game.map.LocZoneStorage +import org.rsmod.game.queue.EngineQueueCache +import org.rsmod.game.region.RegionListLarge +import org.rsmod.game.region.RegionListSmall +import org.rsmod.map.CoordGrid +import org.rsmod.map.zone.ZoneGrid +import org.rsmod.map.zone.ZoneKey +import org.rsmod.plugin.scripts.ScriptContext +import org.rsmod.routefinder.collision.CollisionFlagMap +import org.rsmod.routefinder.loc.LocLayerConstants + +/** + * The JUnit resource every world-driven hunter test locks, so no two of them run at once. + * + * `test-conventions` enables parallel execution repo-wide; see the note on [HunterTrapTestWorld]. + */ +const val HUNTER_TEST_WORLD_LOCK: String = "hunter-test-world" + +/** + * A [GameRandom] whose every draw is dictated by the test: `0.0` makes every non-zero rate a + * catch, `1.0` makes every rate a miss. The draw counters are part of the contract - several tests + * assert a code path consumed *no* draw, which a counter is the only way to observe. + */ +class ScriptedRandom(var nextDouble: Double = HIGHEST_DRAW, var nextInt: Int = 0) : GameRandom { + var doubleDraws: Int = 0 + private set + + var intDraws: Int = 0 + private set + + override fun randomDouble(): Double { + doubleDraws++ + return nextDouble + } + + override fun of(maxExclusive: Int): Int { + intDraws++ + return nextInt.coerceIn(0, maxExclusive - 1) + } + + override fun of(minInclusive: Int, maxInclusive: Int): Int { + intDraws++ + return nextInt.coerceIn(minInclusive, maxInclusive) + } + + companion object { + /** Lower than any success rate the engine formula can produce, so the catch always lands. */ + const val ALWAYS_CATCH: Double = 0.0 + + /** + * Misses any rate at or below `256/256` - but **not** every rate: the engine formula is + * unclamped and can exceed 1.0, so tests that need a miss set the owner to the creature's + * own requirement level, where the rate is a real fraction, rather than to 99. + */ + const val HIGHEST_DRAW: Double = 1.0 + } +} + +/** + * The packed cache and gameval mappings, loaded once per test JVM. Not a fake: a stub would only + * prove the stub agrees with itself, and the real `.data/cache/SERVER` decodes in ~2s. `user.dir` + * is repointed at the repo root first - `ServerCacheManager` resolves `.data` relative to it. + */ +object HunterTestCache { + private var loaded = false + + val repoRoot: File by lazy { + var dir = File("").absoluteFile + while (!File(dir, "settings.gradle.kts").exists()) { + dir = dir.parentFile ?: error("Not inside the OpenRune-Server checkout.") + } + dir + } + + @Synchronized + fun load() { + if (loaded) return + System.setProperty("user.dir", repoRoot.absolutePath) + GameValProvider.load("${repoRoot.absolutePath}/") + ServerCacheManager.init(Paths.get(repoRoot.absolutePath, ".data", "cache", "SERVER"), 240) + startInvTransactions() + loaded = true + } + + /** + * `invAdd`/`invDel` throw until [InvTransactionsScript] has filled in `api:invtx`'s lateinit + * globals; it is the only plugin script the harness starts, once per JVM. + */ + private fun startInvTransactions() { + val script = InvTransactionsScript(PlayerItemStorage(emptySet())) + val context = ScriptContext(EventBus(), CheatCommandMap(), EngineQueueCache()) + with(script) { context.startup() } + } +} + +/** + * A single tile's worth of game world: real repositories over hand-built registries, with nothing + * mocked but [random] - [HunterTrap]'s collaborators are final classes with no interface, and the + * real ones over empty registries are both possible and preferable. Coordinates default well below + * [RegionRegistry.INSTANCE_MIN_X] so locs take the normal, non-instanced path. + * + * @param hunterXpBonus Added to every `stat.hunter` award; see [hunterXpModifiers]. + */ +class HunterTrapTestWorld(hunterXpBonus: Double = 0.0) { + val mapClock: MapClock = MapClock() + val random: ScriptedRandom = ScriptedRandom() + + val playerList: PlayerList = PlayerList() + private val npcList: NpcList = NpcList() + private val controllerList: ControllerList = ControllerList() + + private val collision = CollisionFlagMap() + private val eventBus = EventBus() + private val zoneUpdates = ZoneUpdateMap() + private val zoneActivity = ZonePlayerActivityBitSet() + + val locZones: LocZoneStorage = LocZoneStorage() + + private val locRegNormal = LocRegistryNormal(zoneUpdates, collision, locZones) + private val conRegistry = ControllerRegistry(mapClock, controllerList) + private val npcRegistry = NpcRegistry(npcList, collision, eventBus) + private val regionRegistry = + RegionRegistry( + RegionListSmall(), + RegionListLarge(), + locRegNormal, + collision, + locZones, + npcRegistry, + conRegistry, + zoneActivity, + ) + private val locRegRegion = LocRegistryRegion(zoneUpdates, collision, locZones, regionRegistry) + private val locRegistry = LocRegistry(locZones, locRegNormal, locRegRegion) + private val playerRegistry = PlayerRegistry(playerList, collision, zoneActivity, eventBus) + private val objRegistry = ObjRegistry(zoneUpdates) + + val locRepo: LocRepository = LocRepository(mapClock, locRegistry, regionRegistry) + val conRepo: ControllerRepository = ControllerRepository(conRegistry, controllerList) + val npcRepo: NpcRepository = NpcRepository(mapClock, npcRegistry, npcList) + val objRepo: ObjRepository = ObjRepository(mapClock, objRegistry) + val playerRepo: PlayerRepository = PlayerRepository(playerRegistry) + + val trap: HunterTrap = + HunterTrap( + locRepo = locRepo, + conRepo = conRepo, + npcRepo = npcRepo, + playerRepo = playerRepo, + playerList = playerList, + random = random, + xpMods = hunterXpModifiers(hunterXpBonus), + mapClock = mapClock, + ) + + /** Runs one cycle of [controller]'s trap, exactly as `onAiConTimer(TRAP_CONTROLLER)` would. */ + fun tick(controller: Controller) { + with(trap) { controller.hunterTrapTick() } + } + + fun advance(cycles: Int = 1) { + repeat(cycles) { mapClock.tick() } + } + + /* Players */ + + private var nextUuid: Long = 1L + + fun addPlayer(coords: CoordGrid, hunterLvl: Int = 99, hitpoints: Int = 10): Player { + val player = Player() + player.coords = coords + player.slotId = playerList.nextFreeSlot() ?: error("No free player slot.") + player.uuid = nextUuid++ + // Set at login by the account layer, never null on a real player; `Obj.fromOwner` errors + // without it. + player.observerUUID = player.uuid + playerRegistry.add(player) + playerRegistry.change(player, ZoneKey.NULL, ZoneKey.from(coords)) + player.statMap.setBaseLevel("stat.hunter", hunterLvl.toByte()) + player.statMap.setCurrentLevel("stat.hunter", hunterLvl.toByte()) + player.statMap.setBaseLevel("stat.hitpoints", hitpoints.toByte()) + player.statMap.setCurrentLevel("stat.hitpoints", hitpoints.toByte()) + player.inv = Inventory.create("inv.inv") + player.inv.owner = player + return player + } + + /** + * A [ProtectedAccess] over [player] backed by [ProtectedAccessContextFactory.empty], whose + * every dependency throws on first touch; no hunter op touches one. + */ + fun protectedAccess(player: Player): ProtectedAccess = + ProtectedAccess(player, GameCoroutine(), ProtectedAccessContextFactory.empty()) + + /** + * Runs [op] as a protected-access op and returns its value, driving the world clock forward a + * cycle at a time until the coroutine finishes. + */ + fun runProtected( + player: Player, + maxCycles: Int = 20, + op: suspend HunterTrap.(ProtectedAccess) -> T, + ): T = startProtected(player, op).await(maxCycles) + + /** [runProtected] with the cycles left to the caller, to inspect the world *during* an op. */ + fun startProtected( + player: Player, + op: suspend HunterTrap.(ProtectedAccess) -> T, + ): ProtectedRun { + val coroutine = GameCoroutine() + val access = ProtectedAccess(player, coroutine, ProtectedAccessContextFactory.empty()) + val run = ProtectedRun(this, player, coroutine) + val body: suspend GameCoroutine.() -> Unit = { + run.complete(runCatching { trap.op(access) }) + } + syncPlayerClock(player) + // `resumeWithModalProtectedAccess` rejects a resume whose coroutine is not the player's + // active one, exactly as `Player.launch` would have set it. + player.activeCoroutine = coroutine + body.startCoroutine(coroutine, GameCoroutineSimpleCompletion) + return run + } + + /** + * `PathingEntity.isDelayed` reads [Player.processedMapClock], not [Player.currentMapClock]; a + * suspended `delay` never resumes if only the latter moves. + */ + internal fun syncPlayerClock(player: Player) { + player.currentMapClock = mapClock.cycle + player.processedMapClock = mapClock.cycle + } + + /** The [BoundLocInfo] an op on the loc currently at [coords] would hand to the content. */ + fun boundLocAt(coords: CoordGrid): BoundLocInfo? { + val loc = locAt(coords) ?: return null + val type = ServerCacheManager.getObject(loc.id) ?: error("Missing loc type: ${loc.id}") + return BoundLocInfo(loc, type) + } + + /** Drops [player] out of the world the way a logout does, leaving its traps orphaned. */ + fun removePlayer(player: Player) { + playerRegistry.del(player) + } + + /* Npcs */ + + fun addNpc(internal: String, coords: CoordGrid): Npc { + val type = + ServerCacheManager.getNpc(internal.asRSCM(RSCMType.NPC)) + ?: error("Missing npc type: $internal") + val npc = Npc(type, coords) + npcRegistry.add(npc) + return npc + } + + /** + * True while [npc] is still on the map: despawn hides an npc rather than unregistering it, so + * the hidden flag, not zone membership, is what "despawned" means here. + */ + fun npcIsSpawned(npc: Npc): Boolean = + npc.isVisible && npcRepo.findAll(ZoneKey.from(npc.coords)).any { it === npc } + + /** Un-hides a despawned creature on the spot, the way its respawn cycle eventually would. */ + fun revealNpc(npc: Npc) { + npcRegistry.reveal(npc) + } + + /* Locs */ + + /** + * Registers [internal] as a *map* loc on [coords] - a permanent one the game map supplied, not + * a spawn, which a delete with an infinite duration takes out of the world for good. + */ + fun addMapLoc( + coords: CoordGrid, + internal: String, + shape: LocShape = LocShape.CentrepieceStraight, + angle: Int = 0, + ): LocInfo { + val entity = LocEntity(internal.asRSCM(RSCMType.LOC), shape.id, angle) + val layer = LocLayerConstants.of(shape.id) + locZones.mapLocs[ZoneKey.from(coords), LocZoneKey(ZoneGrid.from(coords), layer)] = entity + return LocInfo(layer, coords, entity) + } + + /** The trap loc currently on [coords], whatever family or state it is in. */ + fun locAt(coords: CoordGrid): LocInfo? = locRepo.findAll(coords).firstOrNull() + + fun locNameAt(coords: CoordGrid): String? = + locAt(coords)?.let { RSCM.getReverseMapping(RSCMType.LOC, it.id) } + + /* Ground objs */ + + /** Every obj currently lying on [coords], by internal name. */ + fun objNamesAt(coords: CoordGrid): List = + objRepo + .findAll(coords) + .mapNotNull { RSCM.getReverseMapping(RSCMType.OBJ, it.type) } + .toList() + + /* Traps */ + + /** + * Lays a portable trap the way [HunterTrap.layTrap] does, minus the inventory half of it: + * `layTrap` itself is a `ProtectedAccess` extension and unreachable here. + */ + fun layPortableTrap(family: TrapFamily, coords: CoordGrid, owner: Player): Controller { + require(family.portable) { "Use `armDeadfall` or `armNetTrap` for the fixed-loc families." } + locRepo.add( + coords, + checkNotNull(HunterTrapStates.setLoc(family)), + Int.MAX_VALUE, + LocAngle.West, + LocShape.CentrepieceStraight, + ) + return addTrapController(family, coords, owner) + } + + private fun addTrapController( + family: TrapFamily, + coords: CoordGrid, + owner: Player, + ): Controller { + val controller = Controller(TRAP_CONTROLLER, coords) + conRepo.add(controller, TRAP_LIFETIME_CYCLES) + controller.trapOwner = owner.uid.packed + controller.trapFamily = family.ordinal + controller.trapCreature = TRAP_CREATURE_NONE + controller.aiTimer(1) + return controller + } + + fun controllerAt(coords: CoordGrid): Controller? = conRepo.findExact(coords, TRAP_CONTROLLER) + + companion object { + /** + * Far from [RegionRegistry.INSTANCE_MIN_X] and mid-zone, so a creature one tile away in + * any direction is still inside the tick's own zone sweep. + */ + val TRAP_TILE: CoordGrid = CoordGrid(3204, 3204, 0) + + /** [Controller.trapCreature] while the trap is armed and empty; see `HunterTrap`. */ + const val TRAP_CREATURE_NONE: Int = -1 + + /** [Controller.trapCreature] once the trap has sprung and failed; see `HunterTrap`. */ + const val TRAP_CREATURE_FAILED: Int = -2 + } +} + +/** A protected-access op in flight; see [HunterTrapTestWorld.startProtected]. */ +class ProtectedRun +internal constructor( + private val world: HunterTrapTestWorld, + private val player: Player, + private val coroutine: GameCoroutine, +) { + private var outcome: Result? = null + + val isFinished: Boolean + get() = outcome != null + + internal fun complete(result: Result) { + outcome = result + } + + /** Advances the world and the op by one cycle. */ + fun advanceCycle() { + world.advance() + world.syncPlayerClock(player) + coroutine.advance() + } + + fun await(maxCycles: Int = 20): T { + var cycles = 0 + while (outcome == null && cycles++ < maxCycles) { + advanceCycle() + } + val result = + checkNotNull(outcome) { "Protected-access op did not finish in $maxCycles cycles." } + player.activeCoroutine = null + return result.getOrThrow() + } +} diff --git a/content/skills/hunter/src/test/kotlin/org/rsmod/content/skills/hunter/HunterTrapTickTest.kt b/content/skills/hunter/src/test/kotlin/org/rsmod/content/skills/hunter/HunterTrapTickTest.kt new file mode 100644 index 000000000..3f8e9c317 --- /dev/null +++ b/content/skills/hunter/src/test/kotlin/org/rsmod/content/skills/hunter/HunterTrapTickTest.kt @@ -0,0 +1,390 @@ +package org.rsmod.content.skills.hunter + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.parallel.Execution +import org.junit.jupiter.api.parallel.ExecutionMode +import org.junit.jupiter.api.parallel.ResourceLock +import org.rsmod.content.skills.hunter.HunterTrapTestWorld.Companion.TRAP_CREATURE_FAILED +import org.rsmod.content.skills.hunter.HunterTrapTestWorld.Companion.TRAP_CREATURE_NONE +import org.rsmod.content.skills.hunter.HunterTrapTestWorld.Companion.TRAP_TILE +import org.rsmod.game.entity.Controller +import org.rsmod.game.entity.Npc +import org.rsmod.game.loc.LocShape + +/** + * [HunterTrap.hunterTrapTick] driven cycle by cycle against real repositories and a scripted + * [ScriptedRandom] - the parts of the engine a live client cannot reach, since a failed catch + * cannot be forced when the roll is genuinely random. The `ProtectedAccess` half has its own file, + * [HunterTrapOpsTest]; traps are set up here by the harness in the same shape those paths leave + * them in. Serialised: `ServerCacheManager` is a singleton and `RSCM` memoises into a plain `HashMap`, + * which is not safe to fill from the parallel execution `test-conventions` turns on. + */ +@Execution(ExecutionMode.SAME_THREAD) +@ResourceLock(HUNTER_TEST_WORLD_LOCK) +class HunterTrapTickTest { + private lateinit var world: HunterTrapTestWorld + + @BeforeEach + fun setUp() { + HunterTestCache.load() + world = HunterTrapTestWorld() + } + + /** A corrupt ordinal on a portable trap is tidied away instead, which is the whole contrast. */ + @Test + fun `a corrupt family ordinal clears a portable trap and deletes its controller`() { + val owner = world.addPlayer(TRAP_TILE.translate(3, 3), hunterLvl = 99) + val controller = world.layPortableTrap(TrapFamily.BOX, TRAP_TILE, owner) + + controller.trapFamily = TrapFamily.entries.size + world.tick(controller) + + assertNull(world.locAt(TRAP_TILE)) + assertNull(world.controllerAt(TRAP_TILE)) + } + + /* The occupancy guard. */ + + /** + * "Box traps won't trap prey if players are standing on the trap itself." (wiki, *Box trap > + * Mechanics*.) Any player, not just the owner. + */ + @Test + fun `a player standing on a box trap suppresses the catch`() { + val controller = boxTrapWithChinchompaInRange() + world.addPlayer(TRAP_TILE, hunterLvl = 99) + world.random.nextDouble = ScriptedRandom.ALWAYS_CATCH + + world.tick(controller) + + assertEquals(TRAP_CREATURE_NONE, controller.trapCreature) + assertEquals(0, world.random.doubleDraws, "The roll must not even happen.") + } + + /** "A bird snare will not catch birds if the user is standing directly on the bird snare." */ + @Test + fun `a player standing on a bird snare suppresses the catch`() { + val owner = world.addPlayer(TRAP_TILE.translate(3, 3), hunterLvl = 99) + val controller = world.layPortableTrap(TrapFamily.SNARE, TRAP_TILE, owner) + world.addNpc("npc.hunting_bird_jungle", TRAP_TILE.translate(1, 0)) + world.addPlayer(TRAP_TILE, hunterLvl = 99) + world.random.nextDouble = ScriptedRandom.ALWAYS_CATCH + + world.tick(controller) + + assertEquals(TRAP_CREATURE_NONE, controller.trapCreature) + } + + /** + * `PlayerRegistry.findAll` does not filter hidden or logging-out players, so the tick applies + * `isValidTarget()` on top. Without it an invisible player parked on a trap would suppress every + * roll with nothing observable to diagnose it by. + */ + @Test + fun `a hidden player on the tile does not suppress the catch`() { + val controller = boxTrapWithChinchompaInRange() + val camper = world.addPlayer(TRAP_TILE, hunterLvl = 99) + world.playerRepo.hide(camper) + world.random.nextDouble = ScriptedRandom.ALWAYS_CATCH + + world.tick(controller) + + assertTrue(controller.trapCreature >= 0, "Expected a catch, got ${controller.trapCreature}") + } + + /* Catch success and failure. */ + + @Test + fun `a successful catch shows the trapping loc, then settles into the full loc`() { + val controller = boxTrapWithChinchompaInRange() + world.random.nextDouble = ScriptedRandom.ALWAYS_CATCH + + world.tick(controller) + + val creature = HunterCreatures.all[controller.trapCreature] + assertEquals("npc.hunting_chinchompa", creature.npc) + // The chinchompa was placed one tile north, so the north-side trapping loc is expected. + assertEquals("loc.hunting_boxtrap_trapping_chinchompa_n", world.locNameAt(TRAP_TILE)) + + world.advance(TRAP_SPRING_CYCLES) + world.tick(controller) + assertEquals("loc.hunting_boxtrap_full_chinchompa", world.locNameAt(TRAP_TILE)) + assertNotNull(world.controllerAt(TRAP_TILE), "A caught trap waits for its owner.") + } + + /** + * The branch a live client cannot force. A failed catch must take the failing/failed pair, not + * the trapping/full pair, and must record [TRAP_CREATURE_FAILED] rather than a creature index - + * getting that wrong would hand out a free chinchompa on a miss. + */ + @Test + fun `a failed catch takes the failing loc, then settles into the failed loc`() { + val controller = boxTrapWithChinchompaInRange(CHINCHOMPA_LEVEL) + world.random.nextDouble = ScriptedRandom.HIGHEST_DRAW + + world.tick(controller) + + assertEquals(TRAP_CREATURE_FAILED, controller.trapCreature) + assertEquals("loc.hunting_boxtrap_failing", world.locNameAt(TRAP_TILE)) + + world.advance(TRAP_SPRING_CYCLES) + world.tick(controller) + assertEquals("loc.hunting_boxtrap_failed", world.locNameAt(TRAP_TILE)) + } + + /** Either outcome springs the trap, so either outcome must take the creature off the map. */ + @Test + fun `both outcomes despawn the creature`() { + val owner = world.addPlayer(TRAP_TILE.translate(3, 3), hunterLvl = 99) + val caught = world.layPortableTrap(TrapFamily.BOX, TRAP_TILE, owner) + val prey = world.addNpc("npc.hunting_chinchompa", TRAP_TILE.translate(0, 1)) + world.random.nextDouble = ScriptedRandom.ALWAYS_CATCH + world.tick(caught) + assertFalse(world.npcIsSpawned(prey), "A caught creature is despawned.") + + val missed = HunterTrapTestWorld() + val missedOwner = missed.addPlayer(TRAP_TILE.translate(3, 3), hunterLvl = CHINCHOMPA_LEVEL) + val controller = missed.layPortableTrap(TrapFamily.BOX, TRAP_TILE, missedOwner) + val escapee = missed.addNpc("npc.hunting_chinchompa", TRAP_TILE.translate(0, 1)) + missed.random.nextDouble = ScriptedRandom.HIGHEST_DRAW + missed.tick(controller) + assertFalse(missed.npcIsSpawned(escapee), "A creature that escaped is despawned too.") + } + + /** + * A creature that has already been caught must not be caught again: despawn only *hides* an + * npc and `NpcRegistry.findAll` does not filter hidden ones, so without the sweep's own + * visibility filter two traps catch one animal on the same cycle (docs/hunter.md). + */ + @Test + fun `a despawned creature cannot be caught by a second trap`() { + val owner = world.addPlayer(TRAP_TILE.translate(4, 4), hunterLvl = 99) + val first = world.layPortableTrap(TrapFamily.BOX, TRAP_TILE, owner) + val secondTile = TRAP_TILE.translate(0, 2) + val second = world.layPortableTrap(TrapFamily.BOX, secondTile, owner) + // One tile from both traps, so both are in range of it on the same cycle. + val prey = world.addNpc("npc.hunting_chinchompa", TRAP_TILE.translate(0, 1)) + world.random.nextDouble = ScriptedRandom.ALWAYS_CATCH + + world.tick(first) + assertTrue(first.trapCreature >= 0, "The first trap should catch it.") + assertFalse(world.npcIsSpawned(prey)) + + world.tick(second) + + assertEquals( + TRAP_CREATURE_NONE, + second.trapCreature, + "The chinchompa was already caught; the second trap must find nothing.", + ) + assertEquals(1, world.random.doubleDraws, "Only the first trap should have rolled.") + } + + /** + * "If the player's Hunter level is too low, the trap will always fail." (wiki.) The regular + * chinchompa's positive `successLow` makes the explicit gate load-bearing, and the draw + * counter pins that the gate short-circuits before the roll. + */ + @Test + fun `an under-levelled owner never catches and never rolls`() { + val owner = world.addPlayer(TRAP_TILE.translate(3, 3), hunterLvl = 1) + val controller = world.layPortableTrap(TrapFamily.BOX, TRAP_TILE, owner) + world.addNpc("npc.hunting_chinchompa", TRAP_TILE.translate(0, 1)) + world.random.nextDouble = ScriptedRandom.ALWAYS_CATCH + + world.tick(controller) + + assertEquals(TRAP_CREATURE_FAILED, controller.trapCreature) + assertEquals(0, world.random.doubleDraws) + } + + /* Cadence and range. */ + + /** + * "Once a box trap has been set, it will make an attempt every 3 ticks." (wiki.) The trap and + * the chinchompa are restored between cycles, which is what keeps the off-cycle assertions + * non-vacuous: a roll that happened is a cadence failure, not a missing target. + */ + @Test + fun `a box trap rolls only once every three cycles`() { + val owner = world.addPlayer(TRAP_TILE.translate(3, 3), hunterLvl = CHINCHOMPA_LEVEL) + val controller = world.layPortableTrap(TrapFamily.BOX, TRAP_TILE, owner) + val prey = world.addNpc("npc.hunting_chinchompa", TRAP_TILE.translate(0, 1)) + world.random.nextDouble = ScriptedRandom.HIGHEST_DRAW + + // Cycle 0 is the trap's own creation cycle, so it rolls; 1 and 2 must not. + world.tick(controller) + assertEquals(1, world.random.doubleDraws) + + repeat(2) { + rearm(controller, prey) + world.advance() + world.tick(controller) + } + assertEquals(1, world.random.doubleDraws, "No roll on the two off-cycles.") + + rearm(controller, prey) + world.advance() + world.tick(controller) + assertEquals(2, world.random.doubleDraws, "Rolls again on the third cycle.") + } + + /** + * The box trap's sourced 2-tile radius against the snare's conservative adjacency. A creature + * two tiles away is in range of one and out of range of the other, which is the only behavioural + * difference between the two constants. + */ + @Test + fun `a creature two tiles away is in range of a box trap but not a snare`() { + val owner = world.addPlayer(TRAP_TILE.translate(4, 4), hunterLvl = 99) + val box = world.layPortableTrap(TrapFamily.BOX, TRAP_TILE, owner) + world.addNpc("npc.hunting_chinchompa", TRAP_TILE.translate(2, 0)) + world.random.nextDouble = ScriptedRandom.ALWAYS_CATCH + world.tick(box) + assertTrue(box.trapCreature >= 0, "Box trap should reach two tiles.") + + val other = HunterTrapTestWorld() + val snareOwner = other.addPlayer(TRAP_TILE.translate(4, 4), hunterLvl = 99) + val snare = other.layPortableTrap(TrapFamily.SNARE, TRAP_TILE, snareOwner) + other.addNpc("npc.hunting_bird_jungle", TRAP_TILE.translate(2, 0)) + other.random.nextDouble = ScriptedRandom.ALWAYS_CATCH + other.tick(snare) + assertEquals(TRAP_CREATURE_NONE, snare.trapCreature, "Snare should not reach two tiles.") + } + + /** A trap only catches its own family's creatures, whatever wanders past. */ + @Test + fun `a box trap ignores a bird and a snare ignores a chinchompa`() { + val owner = world.addPlayer(TRAP_TILE.translate(4, 4), hunterLvl = 99) + val box = world.layPortableTrap(TrapFamily.BOX, TRAP_TILE, owner) + world.addNpc("npc.hunting_bird_jungle", TRAP_TILE.translate(1, 0)) + world.random.nextDouble = ScriptedRandom.ALWAYS_CATCH + world.tick(box) + assertEquals(TRAP_CREATURE_NONE, box.trapCreature) + + val other = HunterTrapTestWorld() + val snareOwner = other.addPlayer(TRAP_TILE.translate(4, 4), hunterLvl = 99) + val snare = other.layPortableTrap(TrapFamily.SNARE, TRAP_TILE, snareOwner) + other.addNpc("npc.hunting_chinchompa", TRAP_TILE.translate(1, 0)) + other.random.nextDouble = ScriptedRandom.ALWAYS_CATCH + other.tick(snare) + assertEquals(TRAP_CREATURE_NONE, snare.trapCreature) + } + + /* Lifetime, collapse and expiry. */ + + /** + * "`duration` is the trap's remaining lifetime. ControllerRepository deletes an expired + * controller silently, which would strand the loc, so collapse one cycle early instead." A + * portable trap leaves its wreck on the ground so the owner can still come back for the item. + */ + @Test + fun `an expiring portable trap leaves a wreck and deletes its controller`() { + val owner = world.addPlayer(TRAP_TILE.translate(3, 3), hunterLvl = 99) + val controller = world.layPortableTrap(TrapFamily.BOX, TRAP_TILE, owner) + + controller.duration = 1 + world.tick(controller) + + assertEquals("loc.hunting_boxtrap_failed", world.locNameAt(TRAP_TILE)) + assertNull(world.controllerAt(TRAP_TILE)) + } + + /** "Traps belong to a logged-in owner ... live despawns a player's traps when they leave." */ + @Test + fun `a trap whose owner logged out collapses on the next tick`() { + val owner = world.addPlayer(TRAP_TILE.translate(3, 3), hunterLvl = 99) + val controller = world.layPortableTrap(TrapFamily.BOX, TRAP_TILE, owner) + + world.removePlayer(owner) + world.tick(controller) + + assertEquals("loc.hunting_boxtrap_failed", world.locNameAt(TRAP_TILE)) + assertNull(world.controllerAt(TRAP_TILE)) + } + + /** + * "A sprung trap waits for its owner rather than continuing to decay from wherever its lifetime + * happened to be when the creature arrived." Without the reset, a trap that sprang late in its + * life would collapse before the player could walk back to it and take the catch with it. + */ + @Test + fun `springing a trap resets its remaining lifetime`() { + val controller = boxTrapWithChinchompaInRange() + controller.duration = 5 + world.random.nextDouble = ScriptedRandom.ALWAYS_CATCH + + world.tick(controller) + + assertEquals(TRAP_LIFETIME_CYCLES, controller.duration) + } + + /** + * An unattended trap must *not* reset its duration, or a trap laid in an empty field would sit + * armed forever and the cap would never free up. + */ + @Test + fun `an unattended trap does not reset its lifetime`() { + val owner = world.addPlayer(TRAP_TILE.translate(3, 3), hunterLvl = 99) + val controller = world.layPortableTrap(TrapFamily.BOX, TRAP_TILE, owner) + + controller.duration = 42 + world.tick(controller) + + assertEquals(42, controller.duration) + } + + /** A controller whose loc has gone is deleted rather than left ticking against nothing. */ + @Test + fun `a controller whose trap loc vanished deletes itself`() { + val owner = world.addPlayer(TRAP_TILE.translate(3, 3), hunterLvl = 99) + val controller = world.layPortableTrap(TrapFamily.BOX, TRAP_TILE, owner) + + val loc = world.locRepo.findExact(TRAP_TILE, LocShape.CentrepieceStraight) + world.locRepo.del(loc!!, Int.MAX_VALUE) + // The tick asserts the controller outlived a single cycle before accepting a missing loc. + world.advance(2) + + world.tick(controller) + + assertNull(world.controllerAt(TRAP_TILE)) + } + + /* Helpers. */ + + /** Puts a sprung trap and the creature it sprang on back the way they were. */ + private fun rearm(controller: Controller, prey: Npc) { + controller.trapCreature = TRAP_CREATURE_NONE + if (!prey.isVisible) { + world.revealNpc(prey) + } + } + + /** + * A box trap with its owner standing clear of it and a chinchompa one tile north. + * + * [hunterLvl] defaults to 99, where the chinchompa's rate exceeds `1.0` and the catch is + * certain. Tests that need a miss pass [CHINCHOMPA_LEVEL] instead - see + * [ScriptedRandom.HIGHEST_DRAW]. + */ + private fun boxTrapWithChinchompaInRange(hunterLvl: Int = 99): Controller { + val owner = world.addPlayer(TRAP_TILE.translate(3, 3), hunterLvl = hunterLvl) + val controller = world.layPortableTrap(TrapFamily.BOX, TRAP_TILE, owner) + world.addNpc("npc.hunting_chinchompa", TRAP_TILE.translate(0, 1)) + return controller + } + + private companion object { + /** The regular chinchompa's Hunter requirement, where its rate is a real fraction (~57%). */ + const val CHINCHOMPA_LEVEL: Int = 53 + + /** The wild kebbit's, i.e. the lowest deadfall creature's (~43%). */ + const val WILD_KEBBIT_LEVEL: Int = 23 + } +} diff --git a/content/skills/hunter/src/test/kotlin/org/rsmod/content/skills/hunter/HunterWiringTest.kt b/content/skills/hunter/src/test/kotlin/org/rsmod/content/skills/hunter/HunterWiringTest.kt new file mode 100644 index 000000000..013e9c0e5 --- /dev/null +++ b/content/skills/hunter/src/test/kotlin/org/rsmod/content/skills/hunter/HunterWiringTest.kt @@ -0,0 +1,331 @@ +package org.rsmod.content.skills.hunter + +import dev.openrune.ServerCacheManager +import dev.openrune.rscm.RSCM.asRSCM +import dev.openrune.rscm.RSCMType +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.parallel.Execution +import org.junit.jupiter.api.parallel.ExecutionMode +import org.junit.jupiter.api.parallel.ResourceLock +import org.rsmod.api.controller.events.ControllerAIEvents +import org.rsmod.api.player.events.interact.HeldObjEvents +import org.rsmod.api.player.events.interact.LocContentEvents +import org.rsmod.api.player.events.interact.LocEvents +import org.rsmod.events.EventBus +import org.rsmod.game.cheat.CheatCommandMap +import org.rsmod.game.interact.HeldOp +import org.rsmod.game.interact.InteractionOp +import org.rsmod.game.queue.EngineQueueCache +import org.rsmod.game.type.hasInvOp +import org.rsmod.game.type.hasOp +import org.rsmod.plugin.scripts.PluginScript +import org.rsmod.plugin.scripts.ScriptContext + +/** + * What the hunter scripts actually put on the event bus. The rest of the suite calls the op bodies + * directly, so a handler registered on the wrong group, the wrong op index, or not at all would + * leave everything green and the feature dead in game. Each script's `startup()` is run against a + * fresh [EventBus] - the same mechanism `PluginScriptLoader` uses at boot - and every lookup + * resolves its key through the same call the matching `on...` helper uses. The second half walks + * the loc states the dispatch branches on and asserts the *packed cache* carries the group and ops + * each handler is registered for. Serialised: `ServerCacheManager` is a singleton and `RSCM` + * memoises into a plain `HashMap`. + */ +@Execution(ExecutionMode.SAME_THREAD) +@ResourceLock(HUNTER_TEST_WORLD_LOCK) +class HunterWiringTest { + private lateinit var scripts: HunterScripts + + @BeforeEach + fun setUp() { + HunterTestCache.load() + scripts = HunterScripts() + } + + /* Per-technique registration. */ + + @Test + fun `bird snare registers its lay op, both loc ops and the shared trap tick`() { + val bus = Wiring().start(scripts.birdSnare) + + assertTrue(bus.hasOpHeld1(SNARE_OBJ), "`Lay` on $SNARE_OBJ") + assertTrue(bus.hasOpContentLoc1(SNARE_GROUP), "op1 (`Dismantle`/`Check`) on $SNARE_GROUP") + assertTrue(bus.hasOpContentLoc2(SNARE_GROUP), "op2 (`Investigate`) on $SNARE_GROUP") + assertTrue(bus.hasAiConTimer(TRAP_CONTROLLER), "the trap tick on $TRAP_CONTROLLER") + + // Its three op1 states share one group, so nothing may leak onto another family's. + assertFalse(bus.hasOpContentLoc1(BOX_GROUP), "the snare must not claim $BOX_GROUP") + } + + @Test + fun `box trap registers its lay op and both loc ops, and not the shared trap tick`() { + val bus = Wiring().start(scripts.boxTrap) + + assertTrue(bus.hasOpHeld1(BOX_OBJ), "`Lay` on $BOX_OBJ") + assertTrue(bus.hasOpContentLoc1(BOX_GROUP), "op1 (`Dismantle`/`Check`) on $BOX_GROUP") + assertTrue(bus.hasOpContentLoc2(BOX_GROUP), "op2 (`Investigate`) on $BOX_GROUP") + + // Deliberate: registering it here as well would run every laid trap's tick twice a cycle. + assertFalse(bus.hasAiConTimer(TRAP_CONTROLLER), "the trap tick belongs to BirdSnareEvents") + assertFalse(bus.hasOpContentLoc1(SNARE_GROUP), "the box trap must not claim $SNARE_GROUP") + } + + /* The once-only invariant. */ + + /** + * `onAiConTimer(TRAP_CONTROLLER)` is registered by exactly one trap script. Counted per script + * on its own bus: `EventBus.subscribeKeyed` throws on a duplicate key, so on a shared bus this + * would assert the engine's guard instead of the invariant. + */ + @Test + fun `the shared trap tick is registered exactly once across the whole trap family`() { + val registrars = + scripts.trapFamily.filter { Wiring().start(it).hasAiConTimer(TRAP_CONTROLLER) } + + assertEquals( + listOf(BirdSnareEvents::class.java), + registrars.map { it.javaClass }, + "exactly one trap script may register $TRAP_CONTROLLER", + ) + } + + /** The same invariant, seen from the boot path: both scripts share one bus at startup. */ + @Test + fun `both scripts start together on one bus without a duplicate key`() { + val bus = Wiring().start(*scripts.all.toTypedArray()) + + assertTrue(bus.hasAiConTimer(TRAP_CONTROLLER)) + for (group in ALL_TRAP_GROUPS) { + assertTrue(bus.hasOpContentLoc1(group), "op1 on $group") + assertTrue(bus.hasOpContentLoc2(group), "op2 on $group") + } + } + + /* Op-index and shadowing guards. */ + + /** + * No hunter handler sits on an op slot the client never draws. + * + * `onOpContentLocN` dispatches on the group and slot, not on the op's label, so a handler on op3 + * would be silently unreachable rather than wrong-looking. + */ + @Test + fun `no loc handler is registered on an op index the hunter locs do not carry`() { + val bus = Wiring().start(*scripts.all.toTypedArray()) + + for (group in ALL_TRAP_GROUPS) { + val id = group.asRSCM(RSCMType.CONTENT) + assertFalse(bus.eventBus.contains(LocContentEvents.Op3::class.java, id), "op3 on $group") + assertFalse(bus.eventBus.contains(LocContentEvents.Op4::class.java, id), "op4 on $group") + assertFalse(bus.eventBus.contains(LocContentEvents.Op5::class.java, id), "op5 on $group") + } + } + + /** + * No hunter loc is claimed by loc **id** as well as by content group. + * + * `LocInteractions.opTrigger` tries the type-level `LocEvents.OpN` first and returns as soon as + * it hits, so a per-id registration on any hunter state would shadow the content handler for + * that state alone - the one shape of wiring bug that presents as "the trap works except when + * it's full". + */ + @Test + fun `no per-loc-id registration shadows a hunter content group`() { + val bus = Wiring().start(*scripts.all.toTypedArray()) + + for ((loc, _) in dispatchedLocStates()) { + val id = loc.asRSCM(RSCMType.LOC) + assertFalse(bus.eventBus.contains(LocEvents.Op1::class.java, id), "op1 shadow on $loc") + assertFalse(bus.eventBus.contains(LocEvents.Op2::class.java, id), "op2 shadow on $loc") + } + } + + /* The packed-cache half: a registration is unreachable without the declaration behind it. */ + + /** + * Every loc state a hunter handler dispatches on carries the group it is registered under, and + * the op slot it is dispatched for. + * + * This is the half a bus assertion cannot see. `content.hunter_box_trap` resolving to an id and + * `hunting_boxtrap_failed` carrying that id are two independent declarations; the second was + * missing once already, and a collapsed box trap was unclearable until it was added. + */ + @Test + fun `every dispatched loc state carries its content group and the op it is dispatched for`() { + for ((loc, expected) in dispatchedLocStates()) { + val type = + ServerCacheManager.getObject(loc.asRSCM(RSCMType.LOC)) + ?: error("No packed loc definition for $loc") + assertTrue( + type.isContentType(expected.group), + "$loc must carry ${expected.group}, has contentGroup=${type.contentGroup}", + ) + for (op in expected.ops) { + assertTrue(type.hasOp(op), "$loc must carry ${op.name} (${type.actions})") + } + } + } + + /** + * The op-less transient frames are under none of the hunter groups, and carry no ops - a group + * would put a state under a handler with no branch for it. The check is "not one of ours" + * rather than `contentGroup == -1` because `-1` never survives the pack: opcode 6 is a USHORT, + * so an unset group reads back as `65535` (measured: 59,717 of 60,000 locs). + */ + @Test + fun `the op-less transient loc states are under none of the hunter content groups`() { + val claimed = + transientLocStates().mapNotNull { loc -> + val type = + ServerCacheManager.getObject(loc.asRSCM(RSCMType.LOC)) + ?: error("No packed loc definition for $loc") + val group = ALL_TRAP_GROUPS.firstOrNull(type::isContentType) + group?.let { loc to it } + } + assertEquals(emptyList>(), claimed, "transient frames must be ungrouped") + + for (loc in transientLocStates()) { + val type = checkNotNull(ServerCacheManager.getObject(loc.asRSCM(RSCMType.LOC))) + assertFalse(type.hasOp(InteractionOp.Op1), "$loc must carry no op1") + assertFalse(type.hasOp(InteractionOp.Op2), "$loc must carry no op2") + } + } + + /** Every obj a lay op is registered on really draws an inventory op1. */ + @Test + fun `every lay obj carries an inventory op1 on the packed obj definition`() { + for (obj in listOf(SNARE_OBJ, BOX_OBJ)) { + val type = + ServerCacheManager.getItem(obj.asRSCM(RSCMType.OBJ)) + ?: error("No packed obj definition for $obj") + assertTrue(type.hasInvOp(HeldOp.Op1), "$obj must carry iop1") + } + } + + /* Fixtures. */ + + /** A loc state the dispatch branches on: which group routes it, and which ops it must draw. */ + private data class LocExpectation(val group: String, val ops: List) + + /** + * Every loc state a hunter handler can be reached through, mapped to the group that routes it. + * + * Built from [HunterTrapStates] and [HunterCreatures] - the same tables the production `when` + * branches read - rather than from a transcribed list, so a creature added to a table joins this + * matrix on its own. + */ + private fun dispatchedLocStates(): Map = buildMap { + fun put(loc: String, group: String, vararg ops: InteractionOp) { + put(loc, LocExpectation(group, ops.toList())) + } + + // Bird snare: `Dismantle` on the armed and broken states, `Check` on each `_full_`, and + // `Investigate` on the armed one alone. + put(checkNotNull(HunterTrapStates.setLoc(TrapFamily.SNARE)), SNARE_GROUP, Op1, Op2) + put(HunterTrapStates.failedLoc(TrapFamily.SNARE), SNARE_GROUP, Op1) + for (creature in creatures(TrapFamily.SNARE)) { + put(HunterTrapStates.fullLoc(creature), SNARE_GROUP, Op1) + } + + // Box trap: the same shape. `hunting_boxtrap_failed` is the state that was missing its group. + put(checkNotNull(HunterTrapStates.setLoc(TrapFamily.BOX)), BOX_GROUP, Op1, Op2) + put(HunterTrapStates.failedLoc(TrapFamily.BOX), BOX_GROUP, Op1) + for (creature in creatures(TrapFamily.BOX)) { + put(HunterTrapStates.fullLoc(creature), BOX_GROUP, Op1) + } + } + + /** + * The frames shown mid-spring, which carry no ops and therefore no content group. + * + * The four compass offsets are there for the box trap alone: it is the only family with one + * `_trapping_` loc per side, and [HunterTrapStates.trappingLoc] is the function that picks one. + * Every other family ignores the offsets, so the set collapses on its own. + */ + private fun transientLocStates(): Set = buildSet { + add(HunterTrapStates.failingLoc(TrapFamily.SNARE)) + add(HunterTrapStates.failingLoc(TrapFamily.BOX)) + + val offsets = listOf(0 to 1, 0 to -1, 1 to 0, -1 to 0) + for (creature in HunterCreatures.all) { + for ((dx, dz) in offsets) { + add(HunterTrapStates.trappingLoc(creature, dx, dz)) + } + } + } + + private fun creatures(family: TrapFamily): List = + HunterCreatures.all.filter { it.family == family } + + /** + * The ten scripts, built over the same worlds the rest of the suite uses. + * + * The collaborators are only there to satisfy the constructors - `startup()` never touches them, + * because every handler body it registers is a lambda that is not run here. + */ + private class HunterScripts { + private val trapWorld = HunterTrapTestWorld() + + val birdSnare = BirdSnareEvents(trapWorld.trap, trapWorld.conRepo) + val boxTrap = BoxTrapEvents(trapWorld.trap, trapWorld.conRepo) + + /** The five families that share [TRAP_CONTROLLER], in declaration order. */ + val trapFamily: List = listOf(birdSnare, boxTrap) + + val all: List = trapFamily + } + + /** + * A fresh [EventBus] and [EngineQueueCache], and the readers for what a script put in them. + * + * Each reader resolves its key through the same `asRSCM` / `composeLongKey` call the matching + * `on…` helper uses, so the test and the game ask the bus the same question. + */ + private class Wiring { + val eventBus = EventBus() + private val engineQueue = EngineQueueCache() + private val context = ScriptContext(eventBus, CheatCommandMap(), engineQueue) + + fun start(vararg scripts: PluginScript): Wiring = apply { + for (script in scripts) { + with(script) { context.startup() } + } + } + + fun hasOpContentLoc1(content: String): Boolean = + eventBus.contains(LocContentEvents.Op1::class.java, content.asRSCM(RSCMType.CONTENT)) + + fun hasOpContentLoc2(content: String): Boolean = + eventBus.contains(LocContentEvents.Op2::class.java, content.asRSCM(RSCMType.CONTENT)) + + fun hasOpHeld1(obj: String): Boolean = + eventBus.contains(HeldObjEvents.Op1::class.java, obj.asRSCM(RSCMType.OBJ)) + + /** + * `onAiConTimer` subscribes a [org.rsmod.events.KeyedEvent], not a suspending one, so + * `EventBus.contains` - which only reads the suspend map - cannot answer this. The keyed + * map's own `get` can, and a non-null handler is the same thing the `AiConTimerProcessor` + * looks up each cycle. + */ + fun hasAiConTimer(controller: String): Boolean = + eventBus.keyed[ + ControllerAIEvents.Timer::class.java, controller.asRSCM(RSCMType.CONTROLLER)] != null + } + + private companion object { + private val Op1 = InteractionOp.Op1 + private val Op2 = InteractionOp.Op2 + + private const val SNARE_GROUP = "content.hunter_bird_snare" + private const val BOX_GROUP = "content.hunter_box_trap" + + private val ALL_TRAP_GROUPS = listOf(SNARE_GROUP, BOX_GROUP) + + private const val SNARE_OBJ = "obj.hunting_ojibway_bird_snare" + private const val BOX_OBJ = "obj.hunting_box_trap" + } +} diff --git a/content/skills/hunter/src/test/kotlin/org/rsmod/content/skills/hunter/HunterXpModTestFakes.kt b/content/skills/hunter/src/test/kotlin/org/rsmod/content/skills/hunter/HunterXpModTestFakes.kt new file mode 100644 index 000000000..fd454b793 --- /dev/null +++ b/content/skills/hunter/src/test/kotlin/org/rsmod/content/skills/hunter/HunterXpModTestFakes.kt @@ -0,0 +1,38 @@ +package org.rsmod.content.skills.hunter + +import org.rsmod.api.stats.xpmod.StatXpMod +import org.rsmod.api.stats.xpmod.XpModifiers +import org.rsmod.game.entity.Player + +/** + * The bonus a test wanting a modified award drives its world with: +100%, because doubling is + * exact for every row where a realistic fractional bonus would round differently per creature. + */ +internal const val DOUBLE_HUNTER_XP: Double = 1.0 + +/** + * An [XpModifiers] that adds [bonus] to `stat.hunter` and nothing to any other stat. An empty set + * is a flat 1.0, so without one test per technique spending a real bonus, the multiply on every + * award site could be deleted with the suite still green. + */ +internal fun hunterXpModifiers(bonus: Double, craftingBonus: Double = 0.0): XpModifiers { + val mods = buildSet { + if (bonus != 0.0) { + add(HunterXpBonus(bonus)) + } + if (craftingBonus != 0.0) { + add(CraftingXpBonus(craftingBonus)) + } + } + return XpModifiers(mods) +} + +/** The shape a Hunter skilling outfit would have, with a bonus a test picks instead of a cape. */ +private class HunterXpBonus(private val bonus: Double) : StatXpMod("stat.hunter") { + override fun Player.modifier(): Double = bonus +} + +/** The same, for `stat.crafting` - bird house crafting's award needs a modifier on *that* stat. */ +private class CraftingXpBonus(private val bonus: Double) : StatXpMod("stat.crafting") { + override fun Player.modifier(): Double = bonus +} diff --git a/content/skills/hunter/src/test/resources/wiki-charts/birdsnare-chance.tsv b/content/skills/hunter/src/test/resources/wiki-charts/birdsnare-chance.tsv new file mode 100644 index 000000000..8f5c7e296 --- /dev/null +++ b/content/skills/hunter/src/test/resources/wiki-charts/birdsnare-chance.tsv @@ -0,0 +1,208 @@ +# creature level chance256 +# Bird-snare catch-chance charts, extracted 2026-08-25 from the offline wiki snapshot +# 20260817 via the `osrs-cache` MCP `get_wiki_section`, each creature's "Hunter info" +# section, {{Skilling success chart}} rendered server-side. The sqlite `chunks` route does +# NOT work for these: chunks truncate at ~1KB and silently drop most of the points. +# Revisions fitted against: +# Crimson swift oldid=15258753 (49 points, levels 1-49) +# Copper longtail oldid=15196354 (48 points, levels 9-56) +# Cerulean twitch oldid=15196328 (48 points, levels 11-58) +# Golden warbler oldid=15196367 (49 points, levels 5-53) +# Every y-fraction is an exact 256th; y*256 below is exact, never rounded. Each curve +# starts at the creature's level requirement and stops where it reaches 256/256, because +# the chart clamps at 1.0 and the wiki emits no points past the cap. +# 194 points across 4 creatures. +crimson_swift 1 101 +crimson_swift 2 104 +crimson_swift 3 108 +crimson_swift 4 111 +crimson_swift 5 114 +crimson_swift 6 117 +crimson_swift 7 121 +crimson_swift 8 124 +crimson_swift 9 127 +crimson_swift 10 130 +crimson_swift 11 134 +crimson_swift 12 137 +crimson_swift 13 140 +crimson_swift 14 143 +crimson_swift 15 147 +crimson_swift 16 150 +crimson_swift 17 153 +crimson_swift 18 157 +crimson_swift 19 160 +crimson_swift 20 163 +crimson_swift 21 166 +crimson_swift 22 170 +crimson_swift 23 173 +crimson_swift 24 176 +crimson_swift 25 179 +crimson_swift 26 183 +crimson_swift 27 186 +crimson_swift 28 189 +crimson_swift 29 192 +crimson_swift 30 196 +crimson_swift 31 199 +crimson_swift 32 202 +crimson_swift 33 205 +crimson_swift 34 209 +crimson_swift 35 212 +crimson_swift 36 215 +crimson_swift 37 219 +crimson_swift 38 222 +crimson_swift 39 225 +crimson_swift 40 228 +crimson_swift 41 232 +crimson_swift 42 235 +crimson_swift 43 238 +crimson_swift 44 241 +crimson_swift 45 245 +crimson_swift 46 248 +crimson_swift 47 251 +crimson_swift 48 254 +crimson_swift 49 256 +copper_longtail 9 111 +copper_longtail 10 114 +copper_longtail 11 117 +copper_longtail 12 120 +copper_longtail 13 123 +copper_longtail 14 126 +copper_longtail 15 130 +copper_longtail 16 133 +copper_longtail 17 136 +copper_longtail 18 139 +copper_longtail 19 142 +copper_longtail 20 145 +copper_longtail 21 148 +copper_longtail 22 151 +copper_longtail 23 154 +copper_longtail 24 158 +copper_longtail 25 161 +copper_longtail 26 164 +copper_longtail 27 167 +copper_longtail 28 170 +copper_longtail 29 173 +copper_longtail 30 176 +copper_longtail 31 179 +copper_longtail 32 182 +copper_longtail 33 186 +copper_longtail 34 189 +copper_longtail 35 192 +copper_longtail 36 195 +copper_longtail 37 198 +copper_longtail 38 201 +copper_longtail 39 204 +copper_longtail 40 207 +copper_longtail 41 210 +copper_longtail 42 214 +copper_longtail 43 217 +copper_longtail 44 220 +copper_longtail 45 223 +copper_longtail 46 226 +copper_longtail 47 229 +copper_longtail 48 232 +copper_longtail 49 235 +copper_longtail 50 239 +copper_longtail 51 242 +copper_longtail 52 245 +copper_longtail 53 248 +copper_longtail 54 251 +copper_longtail 55 254 +copper_longtail 56 256 +cerulean_twitch 11 113 +cerulean_twitch 12 116 +cerulean_twitch 13 119 +cerulean_twitch 14 123 +cerulean_twitch 15 126 +cerulean_twitch 16 129 +cerulean_twitch 17 132 +cerulean_twitch 18 135 +cerulean_twitch 19 138 +cerulean_twitch 20 141 +cerulean_twitch 21 144 +cerulean_twitch 22 147 +cerulean_twitch 23 150 +cerulean_twitch 24 153 +cerulean_twitch 25 156 +cerulean_twitch 26 159 +cerulean_twitch 27 162 +cerulean_twitch 28 165 +cerulean_twitch 29 168 +cerulean_twitch 30 171 +cerulean_twitch 31 174 +cerulean_twitch 32 177 +cerulean_twitch 33 180 +cerulean_twitch 34 183 +cerulean_twitch 35 186 +cerulean_twitch 36 189 +cerulean_twitch 37 192 +cerulean_twitch 38 196 +cerulean_twitch 39 199 +cerulean_twitch 40 202 +cerulean_twitch 41 205 +cerulean_twitch 42 208 +cerulean_twitch 43 211 +cerulean_twitch 44 214 +cerulean_twitch 45 217 +cerulean_twitch 46 220 +cerulean_twitch 47 223 +cerulean_twitch 48 226 +cerulean_twitch 49 229 +cerulean_twitch 50 232 +cerulean_twitch 51 235 +cerulean_twitch 52 238 +cerulean_twitch 53 241 +cerulean_twitch 54 244 +cerulean_twitch 55 247 +cerulean_twitch 56 250 +cerulean_twitch 57 253 +cerulean_twitch 58 256 +golden_warbler 5 106 +golden_warbler 6 109 +golden_warbler 7 112 +golden_warbler 8 115 +golden_warbler 9 118 +golden_warbler 10 121 +golden_warbler 11 124 +golden_warbler 12 128 +golden_warbler 13 131 +golden_warbler 14 134 +golden_warbler 15 137 +golden_warbler 16 140 +golden_warbler 17 143 +golden_warbler 18 146 +golden_warbler 19 150 +golden_warbler 20 153 +golden_warbler 21 156 +golden_warbler 22 159 +golden_warbler 23 162 +golden_warbler 24 165 +golden_warbler 25 168 +golden_warbler 26 172 +golden_warbler 27 175 +golden_warbler 28 178 +golden_warbler 29 181 +golden_warbler 30 184 +golden_warbler 31 187 +golden_warbler 32 190 +golden_warbler 33 194 +golden_warbler 34 197 +golden_warbler 35 200 +golden_warbler 36 203 +golden_warbler 37 206 +golden_warbler 38 209 +golden_warbler 39 212 +golden_warbler 40 216 +golden_warbler 41 219 +golden_warbler 42 222 +golden_warbler 43 225 +golden_warbler 44 228 +golden_warbler 45 231 +golden_warbler 46 234 +golden_warbler 47 238 +golden_warbler 48 241 +golden_warbler 49 244 +golden_warbler 50 247 +golden_warbler 51 250 +golden_warbler 52 253 +golden_warbler 53 256 diff --git a/content/skills/hunter/src/test/resources/wiki-charts/boxtrap-chance.tsv b/content/skills/hunter/src/test/resources/wiki-charts/boxtrap-chance.tsv new file mode 100644 index 000000000..c454cad32 --- /dev/null +++ b/content/skills/hunter/src/test/resources/wiki-charts/boxtrap-chance.tsv @@ -0,0 +1,153 @@ +# creature level chance256 (base curve; the wiki's stated formula carries no bait term) +# Extracted 2026-08-25 from the offline wiki snapshot 20260817 via the osrs-cache MCP +# (get_wiki_sections -> get_wiki_section, section "Hunting technique > Hunting chance"); +# the chunks table was deliberately not used - it truncates the chart JSON. +# Provenance oldids: +# Chinchompa (Hunter) 15199280 section 4 +# Carnivorous chinchompa 15199287 section 5 +# Black chinchompa (Hunter) 15272940 section 5 +# Black chinchompa 15272952 (the ITEM/weapon page - no Hunter info, no chart; +# this is why a marker probe reported the chart +# absent. The creature lives at the "(Hunter)" title.) +# All three creature pages embed the SAME {{Skilling success chart}}, titled "Chinchompa +# catch chance", carrying three series: Grey (req 53), Red (req 63), Black (req 73). Each +# page renders the identical JSON; only the prose above it differs. Every y value is an +# exact 256th, so chance256 = y * 256 with no fitting. +# Rows below are the solid (at-or-above-requirement) segment of each series, matching the +# convention in deadfall-chance.tsv / magicbox-chance.tsv. The dashed below-requirement +# segments are omitted; note the Red and Black dashed segments are display-clamped to 0 +# for levels 1-25, so the underlying low is negative and is NOT recoverable from the +# rendered points below level 26. +# Red and Black are the same curve - verified point-by-point over their level 73-99 +# overlap - and the prose on both pages says so outright. Black is Red with a higher +# requirement, not a separate rate. +# +# Prose endpoints, verbatim: +# Chinchompa (Hunter), "Hunting chance": +# "The chance of success is approximately 145/255 beginning at level 53, and 268/255 +# at level 99. The approximate formula for the chance of a successful catch (P) given +# hunter level (L) is: P(L) = (floor(262 x (L-1) / 98) + 6) / 255" +# Carnivorous chinchompa, "Hunting technique > Hunting chance": +# "The chance of successful catch is approximately 115/255 beginning at level 63, and +# 228/255 at level 99. Carnivorous and Black Chinchompas have the same catch rate. +# The approximate formula for the chance of a successful catch (P) given hunter level +# (L) is: P(L) = (floor(306 x (L-1) / 98) - 78) / 255" +# Black chinchompa (Hunter), "Hunting technique > Hunting chance": +# "The chance of success is approximately 148/255 beginning at level 73, and 228/255 +# at level 99. Carnivorous and Black Chinchompas have the same catch rate. The +# approximate formula for the chance of a successful catch (P) given hunter level (L) +# is: P(L) = (floor(306 x (L-1) / 98) - 78) / 255" +# +# The prose and the chart do not use the same denominator and do not agree exactly. Prose +# is /255 and floors; the chart is /256 and rounds. Grey: prose 145/255 at L53 vs chart +# 146/256; prose 268/255 at L99 vs a chart that has already saturated at 256/256 by L94. +# Red: prose 115/255 at L63 vs chart 117/256. Black: prose 148/255 at L73 vs chart +# 148/256. Both are recorded as found; neither was reconciled toward the other. +# Point counts: chinchompa 42 (L53-94), carnivorous_chinchompa 37 (L63-99), +# black_chinchompa 27 (L73-99). +chinchompa 53 146 +chinchompa 54 149 +chinchompa 55 151 +chinchompa 56 154 +chinchompa 57 157 +chinchompa 58 159 +chinchompa 59 162 +chinchompa 60 165 +chinchompa 61 167 +chinchompa 62 170 +chinchompa 63 173 +chinchompa 64 175 +chinchompa 65 178 +chinchompa 66 181 +chinchompa 67 183 +chinchompa 68 186 +chinchompa 69 189 +chinchompa 70 191 +chinchompa 71 194 +chinchompa 72 197 +chinchompa 73 199 +chinchompa 74 202 +chinchompa 75 205 +chinchompa 76 208 +chinchompa 77 210 +chinchompa 78 213 +chinchompa 79 216 +chinchompa 80 218 +chinchompa 81 221 +chinchompa 82 224 +chinchompa 83 226 +chinchompa 84 229 +chinchompa 85 232 +chinchompa 86 234 +chinchompa 87 237 +chinchompa 88 240 +chinchompa 89 242 +chinchompa 90 245 +chinchompa 91 248 +chinchompa 92 250 +chinchompa 93 253 +chinchompa 94 256 +carnivorous_chinchompa 63 117 +carnivorous_chinchompa 64 120 +carnivorous_chinchompa 65 123 +carnivorous_chinchompa 66 126 +carnivorous_chinchompa 67 129 +carnivorous_chinchompa 68 132 +carnivorous_chinchompa 69 135 +carnivorous_chinchompa 70 138 +carnivorous_chinchompa 71 142 +carnivorous_chinchompa 72 145 +carnivorous_chinchompa 73 148 +carnivorous_chinchompa 74 151 +carnivorous_chinchompa 75 154 +carnivorous_chinchompa 76 157 +carnivorous_chinchompa 77 160 +carnivorous_chinchompa 78 163 +carnivorous_chinchompa 79 167 +carnivorous_chinchompa 80 170 +carnivorous_chinchompa 81 173 +carnivorous_chinchompa 82 176 +carnivorous_chinchompa 83 179 +carnivorous_chinchompa 84 182 +carnivorous_chinchompa 85 185 +carnivorous_chinchompa 86 188 +carnivorous_chinchompa 87 192 +carnivorous_chinchompa 88 195 +carnivorous_chinchompa 89 198 +carnivorous_chinchompa 90 201 +carnivorous_chinchompa 91 204 +carnivorous_chinchompa 92 207 +carnivorous_chinchompa 93 210 +carnivorous_chinchompa 94 213 +carnivorous_chinchompa 95 217 +carnivorous_chinchompa 96 220 +carnivorous_chinchompa 97 223 +carnivorous_chinchompa 98 226 +carnivorous_chinchompa 99 229 +black_chinchompa 73 148 +black_chinchompa 74 151 +black_chinchompa 75 154 +black_chinchompa 76 157 +black_chinchompa 77 160 +black_chinchompa 78 163 +black_chinchompa 79 167 +black_chinchompa 80 170 +black_chinchompa 81 173 +black_chinchompa 82 176 +black_chinchompa 83 179 +black_chinchompa 84 182 +black_chinchompa 85 185 +black_chinchompa 86 188 +black_chinchompa 87 192 +black_chinchompa 88 195 +black_chinchompa 89 198 +black_chinchompa 90 201 +black_chinchompa 91 204 +black_chinchompa 92 207 +black_chinchompa 93 210 +black_chinchompa 94 213 +black_chinchompa 95 217 +black_chinchompa 96 220 +black_chinchompa 97 223 +black_chinchompa 98 226 +black_chinchompa 99 229 diff --git a/content/skills/hunter/src/test/resources/wiki-charts/published-params.tsv b/content/skills/hunter/src/test/resources/wiki-charts/published-params.tsv new file mode 100644 index 000000000..0c653256a --- /dev/null +++ b/content/skills/hunter/src/test/resources/wiki-charts/published-params.tsv @@ -0,0 +1,71 @@ +# page oldid series low high req +# The {{Skilling success chart}} template's OWN (low, high, req) parameters, read from the +# Parsoid transclusion metadata in the offline wiki snapshot 20260817 (`pages.raw_content_zstd`, +# the data-mw attribute) on 2026-08-25. These are the published values themselves, not a fit: +# where a chart has few points many (low, high) pairs reproduce it, and only this pins the pair. +# NOTE: the `chunks` table does NOT contain these - it stores rendered text. Earlier work here +# searched chunks for `low1=` wikitext, found nothing, and wrongly concluded fitting was required. +Crimson swift 15258753 Crimson swift 100 420 1 +Golden warbler 15196367 Golden warbler 92 400 5 +Copper longtail 15196354 Copper longtail 85 390 9 +Cerulean twitch 15196328 Cerulean twitch 82 380 11 +Tropical wagtail 15259195 Tropical wagtail 75 370 19 +Chinchompa (Hunter) 15199280 Grey 6 268 53 +Chinchompa (Hunter) 15199280 Red -78 228 63 +Chinchompa (Hunter) 15199280 Black -78 228 73 +Wild kebbit 15196478 Wild kebbit 29 385 23 +Wild kebbit 15196478 With bait 32 388 23 +Wild kebbit 15196478 With smoke 31 387 23 +Wild kebbit 15196478 Bait and smoke 34 390 23 +Barb-tailed kebbit 15196228 Barb-tailed kebbit -220 1037 33 +Barb-tailed kebbit 15196228 With bait -217 1040 33 +Barb-tailed kebbit 15196228 With smoke -218 1039 33 +Barb-tailed kebbit 15196228 Bait and smoke -215 1042 33 +Prickly kebbit 15196260 Prickly kebbit -70 331 37 +Prickly kebbit 15196260 With bait -67 334 37 +Prickly kebbit 15196260 With smoke -68 333 37 +Prickly kebbit 15196260 Bait and smoke -65 336 37 +Sabre-toothed kebbit 15196422 Sabre-toothed kebbit -434 820 51 +Sabre-toothed kebbit 15196422 With bait -431 823 51 +Sabre-toothed kebbit 15196422 With smoke -432 822 51 +Sabre-toothed kebbit 15196422 Bait and smoke -429 825 51 +Pyre fox 15197087 Pyre fox -475 750 57 +Net trap 15272929 Swamp lizard 52 360 29 +Net trap 15272929 Orange salamander 16 288 47 +Net trap 15272929 Red salamander 0 240 59 +Net trap 15272929 Black salamander 0 212 67 +Net trap 15272929 Tecu salamander 1 212 79 +Imp 15271036 Imp 0 197 71 +Spotted kebbit 15225548 Spotted kebbit 26 310 43 +Dark kebbit 15288973 Dark kebbit 0 253 57 +Dashing kebbit 15225549 Dashing kebbit 0 205 69 +Black warlock 15288148 Butterfly net 20 296 45 +Black warlock 15288148 Barehanded or Magic butterfly net 40 316 45 +Sunlight Moth 15197088 Barehanded or butterfly net 20 296 65 +Sunlight Moth 15197088 Magic butterfly net 40 316 65 +Moonlight moth 15208105 Butterfly net 0 276 75 +Moonlight moth 15208105 Barehanded or magic butterfly net 20 286 75 +Baby impling 15297388 Butterfly net 79 402 17 +Baby impling 15297388 Barehanded or magic butterfly net 99 422 17 +Young impling 15297391 Butterfly net 69 351 22 +Young impling 15297391 Barehanded or magic butterfly net 89 371 22 +Gourmet impling 15297393 Butterfly net 61 325 28 +Gourmet impling 15297393 Barehanded or magic butterfly net 81 345 28 +Earth impling 15297396 Butterfly net 51 302 36 +Earth impling 15297396 Barehanded or magic butterfly net 71 322 36 +Essence impling 15297398 Butterfly net 40 275 42 +Essence impling 15297398 Barehanded or magic butterfly net 60 295 42 +Eclectic impling 15297390 Butterfly net 30 250 50 +Eclectic impling 15297390 Barehanded or magic butterfly net 50 270 50 +Nature impling 15297392 Butterfly net 20 200 58 +Nature impling 15297392 Barehanded or magic butterfly net 40 220 58 +Magpie impling 15297395 Butterfly net 15 177 65 +Magpie impling 15297395 Barehanded or magic butterfly net 35 197 65 +Ninja impling 15297397 Butterfly net 10 151 74 +Ninja impling 15297397 Barehanded or magic butterfly net 30 171 74 +Crystal impling 15250580 Butterfly net 8 141 80 +Crystal impling 15250580 Barehanded or magic butterfly net 28 161 80 +Dragon impling 15297400 Butterfly net 5 125 83 +Dragon impling 15297400 Barehanded or magic butterfly net 25 145 83 +Lucky impling 15297402 Butterfly net 3 100 89 +Lucky impling 15297402 Barehanded or magic butterfly net 23 120 89 diff --git a/docs/hunter.md b/docs/hunter.md index 4fe6884b6..41f4325e9 100644 --- a/docs/hunter.md +++ b/docs/hunter.md @@ -67,3 +67,143 @@ draw at all. This is load-bearing, not an optimisation: the unit tests script the RNG as a fixed sequence of draws, so an unconditional draw for a flat quantity would shift every subsequent roll and change what the next one returns. + +## Bird snare and box trap: the trap engine + +A laid trap is a controller anchored at its tile, the way woodcutting models a +felled tree. The controller, the loc state chain and the trap cap all resolve +from the tile, so there is no separate bookkeeping map to keep in sync. The +per-family scripts (`BirdSnareEvents`, `BoxTrapEvents`) register only the +player-facing ops — every op routed already exists on the cache type; nothing +invents an option the client does not draw. The tick handler is family-agnostic +and registered exactly once, in `BirdSnareEvents`; a second registration would +run every laid trap's tick twice per cycle. + +### What a trap persists + +A trap's whole state is three varcons on its controller — owner uid, family, +creature — plus up to five packed trap coords on the player. + +- `varcon.hunter_trap_family` holds a `TrapFamily` *ordinal* and + `varcon.hunter_trap_creature` an index into `HunterCreatures.all`, so the + enum and the combined creature list are both append-only: inserting into + either re-files every trap standing in the world at the next restart. +- `HunterCreatures.all` is sorted by dbrow id across all trap tables at once, + not per table and concatenated. The two orderings agree only while each + technique arrives as a whole block numbered above the last; sorting globally + makes "give a new row an id above everything" the entire append-only rule, + enforceable by choosing an id rather than a table. +- The trap cap is tracked as coords, not a counter. Controllers and timed locs + are runtime-only, so a counter leaks: a trap that collapses while the player + is away, or a server restart, never runs the decrement and permanently costs + a slot. A coord can be re-checked against the world. +- An unset varcon reads 0, which is a legitimate index, so "armed and empty" + and "sprung and failed" are negative sentinels (`CREATURE_NONE`, + `CREATURE_FAILED`). + +### Where the catch rates come from + +The wiki publishes each bird's per-level success chart as a +`{{Skilling success chart}}` in its "Hunter info" section, on a `P(L) = +(floor(m·(L−1)/98) + c)/255` scale. The engine formula +(`SkillingSuccessRate.successRate`) is `(1 + floor(low·(99−L)/98 + +high·(L−1)/98 + 0.5))/256` — a 1/256 scale with a +1 bias, not the wiki's /255. +Each shipped `(success_low, success_high)` pair is that engine formula's +coefficients, fit to reproduce the creature's full charted curve (all ~48–58 +points) exactly at every non-capped point. The three chinchompas state their +formulas directly, so those pairs are read off rather than fit. + +Reproducing a chart does not pin a pair — a short chart is reproduced by many +pairs — so the wiki's own template parameters, recoverable from its Parsoid +transclusion metadata, are the authoritative source. They are checked in under +`src/test/resources/wiki-charts/published-params.tsv`, and +`HunterRateTablesTest` asserts every shipped pair *is* the published parameter, +as well as re-deriving every charted point. The charts are test resources, not +reads of the gitignored `.data` scratch dir: a chart the test cannot find must +fail loudly, not skip. + +A negative `success_low` (the carnivorous and black chinchompas here) makes +"if the player's Hunter level is too low, the trap will always fail" fall out +of the formula on its own. A creature with a positive `success_low` (regular +chinchompa, +6) does not get that guard implicitly, which is why +`hunterTrapTick` also gates the roll on `owner.hunterLvl >= creature.level` — +without it a level-1 player would catch level-53 chinchompas at a small but +non-zero rate. + +One value discrepancy is known: the cerulean twitch's own infobox states +64.5 xp where the parent *Bird snare* summary table states 64.6; the +creature-page value ships. + +### Tuning numbers + +| constant | value | source | +|---|---|---| +| `TRAP_LIFETIME_CYCLES` | 100 | RuneLite's client-side `HunterTrap.TRAP_TIME` overlay figure (~1 min); not server truth, a starting value to confirm in-game | +| `TRAP_SPRING_CYCLES` | 2 | ours; live's duration for the `_trapping_`/`_failing_` frames is not answerable offline | +| `TRAP_COLLAPSE_LINGER_CYCLES` | 100 | ours; finite so a wreck cleans itself up | +| `BOX_TRAP_TRIGGER_DISTANCE` | 2 | "Any ferret or chinchompa within a 2-tile radius of the box trap (forming a 5x5 square centred on the trap) can be attracted." (*Box trap → Mechanics*) | +| `SNARE_TRIGGER_DISTANCE` | 1 | unsourced; no page states a radius and no cache record carries one. Adjacency is the conservative reading — do not promote it to the box trap's 2 without a source | +| `BOX_TRAP_ATTEMPT_CYCLES` | 3 | "Once a box trap has been set, it will make an attempt every 3 ticks (1.8 seconds) to lure in an animal that is currently in range." (*Box trap → Mechanics*) | +| `SNARE_ATTEMPT_CYCLES` | 1 | unsourced; the wiki gives the cadence for the box trap only | + +### Behaviour rules and their sources + +- **A player standing on the trap suppresses the catch.** "A bird snare will + not catch birds if the user is standing directly on the bird snare." (*Bird + snare*); "Box traps won't trap prey if players are standing on the trap + itself." (*Box trap → Mechanics*). Any player, not just the owner — the box + trap's wording is the plural, general one. Only the roll is blocked; the trap + still ages toward collapse, otherwise standing on one would hold it open + indefinitely. The occupancy test requires `isValidTarget()`, not presence + alone: `PlayerRegistry.findAll` does not filter hidden or mid-logout + players, and an invisible player parked on the tile would otherwise suppress + every catch silently. Known, accepted consequence: a second visible player + can camp someone else's trap and suppress every roll while its lifetime + decays (every trap loc is blockwalk=no, confirmed in cache). The trap item + still comes back via the wreck, so this costs time only, and it matches + live's plural wording — a known griefing vector, not an oversight. +- **A caught creature must not be caught twice.** `NpcRepository.despawn` only + hides a creature — it stays in the zone map until its respawn cycle — and + `NpcRegistry.findAll` does not filter hidden npcs, so the trigger scan + filters on `Npc.isVisible` itself. Without it, two traps in range of one + creature both catch it on the same cycle, and re-despawns rewrite + `lifecycleRespawnCycle` so the missed respawn is never retried. + `isValidTarget()` is deliberately *not* used here: it also requires + `hitpoints > 0`, which no hunter creature declares in the cache. +- **Attempts are phased per trap.** The cadence counts from the trap's own + creation cycle, not the raw map clock, so traps laid on different cycles do + not roll in lockstep. +- **An unattended trap decays.** The tick deliberately never resets an armed + trap's duration; only a spring does, so the owner has the full window to + collect. A controller whose duration expires between ticks is deleted by + `ControllerRepository` silently, which would strand the loc — so the tick + collapses the trap one cycle early instead. + +### The loc-state key + +The bird snare's and box trap's loc states are named by a per-creature suffix +(`loc.hunting_ojibway_trap_full_`, `loc.hunting_boxtrap_full_`). The +key is authored data in the creature row, never derived from the npc symbol: +not every creature's npc and loc names share a derivable stem, and Kotlin's +`substringAfter` returns the whole string when its delimiter is absent, so a +derivation would not even fail loudly — it would build a loc name like +`loc.hunting_ojibway_trap_full_npc.multicoloured_bird` and throw at the first +catch of the one affected creature, not at boot. + +### Deliberately not modelled + +- **The lure walk.** An in-range creature is caught in place; live walks it to + the trap first. Cosmetic; the radius, cadence and rate are modelled. +- **Eagles' Peak.** Live gates box traps on the quest; no quest system entry + for it exists in this repo, so the gate is left unenforced rather than + fabricating a check. The level-27 gate is enforced. +- **`Reset` (op2 on a sprung/collapsed box trap)**, which re-arms in place. A + scope decision, not a cache gap; `BoxTrapEvents.investigate` guards on the + armed loc id so the shared op2 registration cannot run Investigate text + against a Reset click. +- **Letvek** (`npc.hunting_letvek`, level 76 box trap) exists in the cache but + has zero spawns in `.data/raw-cache/map/npcs/`, so a row for it would be + unreachable content. +- **Investigate wording is ours.** The text is server-sent, so it is in + neither the cache nor the wiki; what it reports is the real controller + state.