From f318cbbcc2df23ae0805da035b8dbf4339005ae0 Mon Sep 17 00:00:00 2001 From: GregHib Date: Thu, 1 Oct 2026 18:13:33 +0100 Subject: [PATCH 01/28] Fix decoding maps to single levels --- .../engine/map/collision/CollisionDecoder.kt | 13 +++--- .../map/obj/MapObjectsRotatedDecoder.kt | 10 +++-- .../map/collision/CollisionDecoderTest.kt | 38 ++++++++++++++++ .../map/obj/MapObjectsRotatedDecoderTest.kt | 45 +++++++++++++++++++ 4 files changed, 97 insertions(+), 9 deletions(-) diff --git a/engine/src/main/kotlin/world/gregs/voidps/engine/map/collision/CollisionDecoder.kt b/engine/src/main/kotlin/world/gregs/voidps/engine/map/collision/CollisionDecoder.kt index 22c4fb0270..7a397124cd 100644 --- a/engine/src/main/kotlin/world/gregs/voidps/engine/map/collision/CollisionDecoder.kt +++ b/engine/src/main/kotlin/world/gregs/voidps/engine/map/collision/CollisionDecoder.kt @@ -55,8 +55,8 @@ class CollisionDecoder { val y = from.tile.y.rem(64) val targetX = to.tile.x val targetY = to.tile.y + Collisions.allocateIfAbsent(targetX, targetY, to.level) for (level in 0 until 4) { - Collisions.allocateIfAbsent(targetX, targetY, level) for (localX in x until x + 8) { for (localY in y until y + 8) { if (!isTile(settings, localX, localY, level, BLOCKED_TILE)) { @@ -64,17 +64,18 @@ class CollisionDecoder { } var height = level if (isTile(settings, localX, localY, 1, BRIDGE_TILE)) { - if (--height < 0) { - continue - } + height-- + } + if (height != from.level) { + continue } val rotX = rotateX(localX, localY, zoneRotation) val rotY = rotateY(localX, localY, zoneRotation) if (isTile(settings, localX, localY, level, ROOF_TILE)) { - Collisions.setUnsafe(targetX + rotX, targetY + rotY, height, CollisionFlag.ROOF) + Collisions.setUnsafe(targetX + rotX, targetY + rotY, to.level, CollisionFlag.ROOF) } - Collisions.setUnsafe(targetX + rotX, targetY + rotY, height, CollisionFlag.FLOOR) + Collisions.setUnsafe(targetX + rotX, targetY + rotY, to.level, CollisionFlag.FLOOR) } } } diff --git a/engine/src/main/kotlin/world/gregs/voidps/engine/map/obj/MapObjectsRotatedDecoder.kt b/engine/src/main/kotlin/world/gregs/voidps/engine/map/obj/MapObjectsRotatedDecoder.kt index 3b2edc9045..9b4f8039c9 100644 --- a/engine/src/main/kotlin/world/gregs/voidps/engine/map/obj/MapObjectsRotatedDecoder.kt +++ b/engine/src/main/kotlin/world/gregs/voidps/engine/map/obj/MapObjectsRotatedDecoder.kt @@ -9,12 +9,14 @@ import world.gregs.voidps.type.Zone import world.gregs.voidps.type.area.Rectangle /** - * Adds all objects except bridges from a single [zone], into a different zone, with [zoneRotation] applied. + * Adds all objects except bridges from a single [zone] and [level], into a different zone and [targetLevel], with [zoneRotation] applied. */ class MapObjectsRotatedDecoder : MapObjectDecoder() { internal var zoneRotation: Int = 0 internal lateinit var zone: Rectangle + internal var level: Int = 0 + internal var targetLevel: Int = 0 fun decode(cache: Cache, settings: ByteArray, from: Zone, to: Zone, rotation: Int, keys: IntArray?) { val objectData = cache.data(Index.MAPS, "l${from.region.x}_${from.region.y}", xtea = keys) ?: return @@ -22,18 +24,20 @@ class MapObjectsRotatedDecoder : MapObjectDecoder() { val y = from.tile.y.rem(64) zone = Rectangle(x, y, x + 7, y + 7) zoneRotation = rotation + level = from.level + targetLevel = to.level super.decode(objectData, settings, to.tile.x, to.tile.y) } override fun add(objectId: Int, localX: Int, localY: Int, level: Int, shape: Int, rotation: Int, regionTileX: Int, regionTileY: Int) { - if (objectId > ObjectDefinitions.definitions.size || !zone.contains(localX, localY)) { + if (objectId > ObjectDefinitions.definitions.size || level != this.level || !zone.contains(localX, localY)) { return } val def = ObjectDefinitions.getValue(objectId) val objRotation = (rotation + zoneRotation) and 0x3 val rotX = rotateX(localX.rem(8), localY.rem(8), def.sizeX, def.sizeY, rotation, zoneRotation) val rotY = rotateY(localX.rem(8), localY.rem(8), def.sizeX, def.sizeY, rotation, zoneRotation) - GameObjects.set(objectId, regionTileX + rotX, regionTileY + rotY, level, shape, objRotation, def) + GameObjects.set(objectId, regionTileX + rotX, regionTileY + rotY, targetLevel, shape, objRotation, def) } internal fun rotateX( diff --git a/engine/src/test/kotlin/world/gregs/voidps/engine/map/collision/CollisionDecoderTest.kt b/engine/src/test/kotlin/world/gregs/voidps/engine/map/collision/CollisionDecoderTest.kt index bc4e65fc4f..33f54caca3 100644 --- a/engine/src/test/kotlin/world/gregs/voidps/engine/map/collision/CollisionDecoderTest.kt +++ b/engine/src/test/kotlin/world/gregs/voidps/engine/map/collision/CollisionDecoderTest.kt @@ -9,6 +9,7 @@ import world.gregs.voidps.engine.map.collision.CollisionDecoder.Companion.BRIDGE import world.gregs.voidps.type.Region import world.gregs.voidps.type.Zone import kotlin.test.assertEquals +import kotlin.test.assertNotEquals import kotlin.test.assertTrue internal class CollisionDecoderTest { @@ -81,4 +82,41 @@ internal class CollisionDecoderTest { // Then assertEquals(Collisions[source.tile.x + 4, source.tile.y + 5, 0], CollisionFlag.FLOOR) } + + @Test + fun `Load blocked onto a different level`() { + // Given + val source = Zone(1, 1, 0) + val target = Zone(2, 2, 1) + settings[MapDefinition.index(10, 12, 0)] = BLOCKED_TILE.toByte() + // When + decoder.decode(settings, source, target, 0) + // Then + assertEquals(CollisionFlag.FLOOR, Collisions[target.tile.x + 2, target.tile.y + 4, 1]) + assertNotEquals(CollisionFlag.FLOOR, Collisions[target.tile.x + 2, target.tile.y + 4, 0]) + } + + @Test + fun `Ignore blocked on other levels`() { + // Given + val source = Zone(1, 1, 0) + settings[MapDefinition.index(10, 12, 2)] = BLOCKED_TILE.toByte() + // When + decoder.decode(settings, source, source, 0) + // Then + for (level in 0 until 4) { + assertNotEquals(CollisionFlag.FLOOR, Collisions[source.tile.x + 2, source.tile.y + 4, level]) + } + } + + @Test + fun `Load bridged blocked onto the level below`() { + // Given + val source = Zone(1, 1, 0) + settings[MapDefinition.index(10, 12, 1)] = (BLOCKED_TILE or BRIDGE_TILE).toByte() + // When + decoder.decode(settings, source, source, 0) + // Then + assertEquals(CollisionFlag.FLOOR, Collisions[source.tile.x + 2, source.tile.y + 4, 0]) + } } diff --git a/engine/src/test/kotlin/world/gregs/voidps/engine/map/obj/MapObjectsRotatedDecoderTest.kt b/engine/src/test/kotlin/world/gregs/voidps/engine/map/obj/MapObjectsRotatedDecoderTest.kt index 27676cdf4a..c285acecff 100644 --- a/engine/src/test/kotlin/world/gregs/voidps/engine/map/obj/MapObjectsRotatedDecoderTest.kt +++ b/engine/src/test/kotlin/world/gregs/voidps/engine/map/obj/MapObjectsRotatedDecoderTest.kt @@ -42,6 +42,8 @@ class MapObjectsRotatedDecoderTest { decoder.zoneRotation = 2 decoder.zone = Rectangle(8, 8, 16, 16) + decoder.level = 1 + decoder.targetLevel = 1 decoder.decode(array, settings, 960, 896) val tile = Tile(965, 900, 1) // local 5, 4 @@ -123,6 +125,49 @@ class MapObjectsRotatedDecoderTest { assertNull(gameObject) } + @Test + fun `Load object onto a different level`() { + val writer = ArrayWriter() + writer.writeSmart(124) + writer.writeSmart(packTile(10, 11, 0)) + val shape = ObjectShape.GROUND_DECOR + writer.writeByte(packInfo(shape, 0)) + writer.writeSmart(0) + writer.writeSmart(0) + val array = writer.toArray() + + decoder.zoneRotation = 0 + decoder.zone = Rectangle(8, 8, 16, 16) + decoder.level = 0 + decoder.targetLevel = 1 + decoder.decode(array, settings, 64, 64) + + assertNull(GameObjects.getShape(Tile(66, 67, 0), shape)) + assertNotNull(GameObjects.getShape(Tile(66, 67, 1), shape)) + } + + @Test + fun `Load ignores objects on other levels`() { + val writer = ArrayWriter() + writer.writeSmart(124) + writer.writeSmart(packTile(12, 13, 2)) + val shape = ObjectShape.GROUND_DECOR + writer.writeByte(packInfo(shape, 0)) + writer.writeSmart(0) + writer.writeSmart(0) + val array = writer.toArray() + + decoder.zoneRotation = 0 + decoder.zone = Rectangle(8, 8, 16, 16) + decoder.level = 0 + decoder.targetLevel = 0 + decoder.decode(array, settings, 64, 64) + + for (level in 0 until 4) { + assertNull(GameObjects.getShape(Tile(68, 69, level), shape)) + } + } + @TestFactory fun `No rotation`() = listOf( Pair(Tile(0, 0), Tile(0, 0)), From fe834c0ade2ba57d2f38f1aaaf9a462ad0044c7b Mon Sep 17 00:00:00 2001 From: GregHib Date: Thu, 1 Oct 2026 18:20:11 +0100 Subject: [PATCH 02/28] Add code style improvement --- .claude/code-style.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.claude/code-style.md b/.claude/code-style.md index ea9e3eae8e..65c9e4fce8 100644 --- a/.claude/code-style.md +++ b/.claude/code-style.md @@ -4,6 +4,7 @@ - **Early exits** — guard disqualifying conditions first; no nested `if` pyramids. No one-line guards or for loops. Braces on every block. - **Thin scripts** — `init {}` only registers handlers; logic goes in `private` helpers or `suspend fun Player.x()` extensions. +- **Small global scope** — top-level functions only if many unrelated script usages. Content-specific helpers go in `companion object`'s. - **Exhaustive `when`** over if/else chains on state. - **Handler order** — guards → `val` setup → mutation → check result → effects (anim/gfx/sound/xp/audit) last. - **Use the receiver** — call `message(...)`, `inventory`, etc. bare; no `player.` re-qualification. From 1554409b4251623bf4398d66150a647f05970521 Mon Sep 17 00:00:00 2001 From: GregHib Date: Thu, 1 Oct 2026 19:05:01 +0100 Subject: [PATCH 03/28] Fix large instance clearing --- .../voidps/engine/map/instance/Instances.kt | 30 ++++++++++++------- .../engine/map/instance/InstancesTest.kt | 18 +++++++++++ 2 files changed, 38 insertions(+), 10 deletions(-) diff --git a/engine/src/main/kotlin/world/gregs/voidps/engine/map/instance/Instances.kt b/engine/src/main/kotlin/world/gregs/voidps/engine/map/instance/Instances.kt index 9bc5b20c17..75ded156f0 100644 --- a/engine/src/main/kotlin/world/gregs/voidps/engine/map/instance/Instances.kt +++ b/engine/src/main/kotlin/world/gregs/voidps/engine/map/instance/Instances.kt @@ -121,18 +121,28 @@ object Instances : Runnable { } } + /** + * Clears every region reserved by [instance], including its padding + */ private fun clear(instance: Region) { - get().clear(instance) - for (level in 0..3) { - NPCs.clear(instance.toLevel(level)) - } - for (zone in instance.toCuboid().toZones()) { - // Floor items too, or they'd linger and resurface when the instance is reused. - for (item in FloorItems.at(zone).flatten()) { - FloorItems.remove(item) + val size = if (instance.y >= MID_POINT) LARGE_SIZE else SMALL_SIZE + val dynamicZones = get() + for (x in instance.x - 1 until instance.x - 1 + size) { + for (y in instance.y - 1 until instance.y - 1 + size) { + val region = Region(x, y) + dynamicZones.clear(region) + for (level in 0..3) { + NPCs.clear(region.toLevel(level)) + } + for (zone in region.toCuboid().toZones()) { + // Floor items too, or they'd linger and resurface when the instance is reused. + for (item in FloorItems.at(zone).flatten()) { + FloorItems.remove(item) + } + GameObjects.clear(zone) + Collisions.clear(zone) + } } - GameObjects.clear(zone) - Collisions.clear(zone) } } diff --git a/engine/src/test/kotlin/world/gregs/voidps/engine/map/instance/InstancesTest.kt b/engine/src/test/kotlin/world/gregs/voidps/engine/map/instance/InstancesTest.kt index e5b239ca7f..030967d57b 100644 --- a/engine/src/test/kotlin/world/gregs/voidps/engine/map/instance/InstancesTest.kt +++ b/engine/src/test/kotlin/world/gregs/voidps/engine/map/instance/InstancesTest.kt @@ -1,6 +1,7 @@ package world.gregs.voidps.engine.map.instance import io.mockk.mockk +import io.mockk.verify import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse @@ -14,6 +15,7 @@ import org.koin.dsl.module import world.gregs.voidps.engine.GameLoop import world.gregs.voidps.engine.entity.character.player.Player import world.gregs.voidps.engine.entity.character.player.Players +import world.gregs.voidps.engine.get import world.gregs.voidps.engine.map.zone.DynamicZones import world.gregs.voidps.engine.script.KoinMock import world.gregs.voidps.type.Region @@ -222,6 +224,22 @@ internal class InstancesTest : KoinMock() { assertNull(Instances.owner(Region(50, 50))) } + @Test + fun `Freeing an instance clears all of its regions`() { + val dynamicZones: DynamicZones = get() + val instance = Instances.small() + // When + GameLoop.tick = Instances.CLEANUP_TICKS + 1 + Instances.cleanup() + // Then + for (x in instance.x - 1..instance.x + 1) { + for (y in instance.y - 1..instance.y + 1) { + verify { dynamicZones.clear(Region(x, y)) } + } + } + verify(exactly = 0) { dynamicZones.clear(Region(instance.x + 2, instance.y)) } + } + @Test fun `Reallocated instance has a different key`() { val instance = Instances.small() From 97cf780f881e99e9c088519f9eb832401be2ab14 Mon Sep 17 00:00:00 2001 From: GregHib Date: Thu, 1 Oct 2026 19:16:56 +0100 Subject: [PATCH 04/28] Add basic house construction --- .../construction/construction.ifaces.toml | 95 +++++ .../skill/construction/construction.objs.toml | 56 +++ .../construction/construction.tables.toml | 161 +++++++ .../construction/construction.varbits.toml | 4 + .../construction/construction.varcs.toml | 3 + .../skill/construction/construction.vars.toml | 12 + .../area/asgarnia/falador/EstateAgent.kt | 51 ++- .../content/skill/construction/House.kt | 191 +++++++++ .../skill/construction/HouseOptions.kt | 74 ++++ .../content/skill/construction/HousePortal.kt | 77 ++++ .../skill/construction/RoomCreation.kt | 167 ++++++++ .../content/skill/construction/HouseTest.kt | 401 ++++++++++++++++++ 12 files changed, 1289 insertions(+), 3 deletions(-) create mode 100644 data/skill/construction/construction.ifaces.toml create mode 100644 data/skill/construction/construction.objs.toml create mode 100644 data/skill/construction/construction.tables.toml create mode 100644 data/skill/construction/construction.varbits.toml create mode 100644 data/skill/construction/construction.varcs.toml create mode 100644 data/skill/construction/construction.vars.toml create mode 100644 game/src/main/kotlin/content/skill/construction/House.kt create mode 100644 game/src/main/kotlin/content/skill/construction/HouseOptions.kt create mode 100644 game/src/main/kotlin/content/skill/construction/HousePortal.kt create mode 100644 game/src/main/kotlin/content/skill/construction/RoomCreation.kt create mode 100644 game/src/test/kotlin/content/skill/construction/HouseTest.kt diff --git a/data/skill/construction/construction.ifaces.toml b/data/skill/construction/construction.ifaces.toml new file mode 100644 index 0000000000..7b3ef76f92 --- /dev/null +++ b/data/skill/construction/construction.ifaces.toml @@ -0,0 +1,95 @@ +[house_options] +id = 398 +type = "options_tab" + +[.building_mode_off] +id = 1 + +[.building_mode_on] +id = 15 + +[.close] +id = 19 + +[.expel_guests] +id = 27 + +[.leave_house] +id = 29 + +[house_loading] +id = 399 +type = "main_screen" + +[room_creation] +id = 402 +type = "main_screen" + +[.parlour] +id = 93 + +[.garden] +id = 94 + +[.kitchen] +id = 95 + +[.dining_room] +id = 96 + +[.workshop] +id = 97 + +[.bedroom] +id = 98 + +[.skill_hall] +id = 99 + +[.games_room] +id = 100 + +[.combat_room] +id = 101 + +[.quest_hall] +id = 102 + +[.menagerie] +id = 103 + +[.study] +id = 104 + +[.costume_room] +id = 105 + +[.chapel] +id = 106 + +[.portal_chamber] +id = 107 + +[.formal_garden] +id = 108 + +[.throne_room] +id = 109 + +[.oubliette] +id = 110 + +[.dungeon_corridor] +id = 111 + +[.dungeon_junction] +id = 112 + +[.dungeon_stairs] +id = 113 + +[.dungeon_pit] +id = 114 + +[.treasure_room] +id = 115 diff --git a/data/skill/construction/construction.objs.toml b/data/skill/construction/construction.objs.toml new file mode 100644 index 0000000000..0419c9546e --- /dev/null +++ b/data/skill/construction/construction.objs.toml @@ -0,0 +1,56 @@ +[house_portal_taverley] +id = 15477 + +[house_portal_rimmington] +id = 15478 + +[house_portal_pollnivneach] +id = 15479 + +[house_portal_rellekka] +id = 15480 + +[house_portal_brimhaven] +id = 15481 + +[house_portal_yanille] +id = 15482 + +[door_hotspot] +id = 15305 + +[door_hotspot_2] +id = 15306 + +[door_hotspot_3] +id = 15307 + +[door_hotspot_4] +id = 15308 + +[door_hotspot_5] +id = 15309 + +[door_hotspot_6] +id = 15310 + +[door_hotspot_7] +id = 15311 + +[door_hotspot_8] +id = 15312 + +[door_hotspot_9] +id = 15313 + +[door_hotspot_10] +id = 15314 + +[door_hotspot_11] +id = 15315 + +[door_hotspot_12] +id = 15316 + +[door_hotspot_13] +id = 15317 diff --git a/data/skill/construction/construction.tables.toml b/data/skill/construction/construction.tables.toml new file mode 100644 index 0000000000..9cdeb06664 --- /dev/null +++ b/data/skill/construction/construction.tables.toml @@ -0,0 +1,161 @@ +# Room templates are the south-west tile of each room in the house style region +[house_rooms] +level = "int" +cost = "int" +template = "tile" +dungeon = "boolean" + +[.parlour] +level = 1 +cost = 1000 +template = { x = 1856, y = 5112 } + +[.garden] +level = 1 +cost = 1000 +template = { x = 1856, y = 5064 } + +[.kitchen] +level = 5 +cost = 5000 +template = { x = 1872, y = 5112 } + +[.dining_room] +level = 10 +cost = 5000 +template = { x = 1888, y = 5112 } + +[.workshop] +level = 15 +cost = 10000 +template = { x = 1856, y = 5096 } + +[.bedroom] +level = 20 +cost = 10000 +template = { x = 1904, y = 5112 } + +[.skill_hall] +level = 25 +cost = 15000 +template = { x = 1864, y = 5104 } + +[.games_room] +level = 30 +cost = 25000 +template = { x = 1896, y = 5088 } + +[.combat_room] +level = 32 +cost = 25000 +template = { x = 1880, y = 5088 } + +[.quest_hall] +level = 35 +cost = 25000 +template = { x = 1896, y = 5104 } + +[.menagerie] +level = 37 +cost = 30000 +template = { x = 1912, y = 5072 } + +[.study] +level = 40 +cost = 50000 +template = { x = 1888, y = 5096 } + +[.costume_room] +level = 42 +cost = 50000 +template = { x = 1904, y = 5064 } + +[.chapel] +level = 45 +cost = 50000 +template = { x = 1872, y = 5096 } + +[.portal_chamber] +level = 50 +cost = 100000 +template = { x = 1864, y = 5088 } + +[.formal_garden] +level = 55 +cost = 75000 +template = { x = 1872, y = 5064 } + +[.throne_room] +level = 60 +cost = 150000 +template = { x = 1904, y = 5096 } + +[.oubliette] +level = 65 +cost = 150000 +template = { x = 1904, y = 5080 } +dungeon = true + +[.dungeon_corridor] +level = 70 +cost = 7500 +template = { x = 1888, y = 5080 } +dungeon = true + +[.dungeon_junction] +level = 70 +cost = 7500 +template = { x = 1856, y = 5080 } +dungeon = true + +[.dungeon_stairs] +level = 70 +cost = 7500 +template = { x = 1872, y = 5080 } +dungeon = true + +[.dungeon_pit] +level = 70 +cost = 10000 +template = { x = 1896, y = 5072 } +dungeon = true + +[.treasure_room] +level = 75 +cost = 250000 +template = { x = 1912, y = 5088 } +dungeon = true + +# Fillers for house grid spaces without a room +[house_spaces] +template = "tile" + +[.land] +template = { x = 1864, y = 5056 } + +[.dungeon] +template = { x = 1880, y = 5056 } + +[.border] +template = { x = 1896, y = 5056 } + +[house_locations] +exit = "tile" + +[.taverley] +exit = { x = 2893, y = 3465 } + +[.rimmington] +exit = { x = 2953, y = 3224 } + +[.pollnivneach] +exit = { x = 3340, y = 3003 } + +[.rellekka] +exit = { x = 2670, y = 3631 } + +[.brimhaven] +exit = { x = 2757, y = 3178 } + +[.yanille] +exit = { x = 2544, y = 3096 } diff --git a/data/skill/construction/construction.varbits.toml b/data/skill/construction/construction.varbits.toml new file mode 100644 index 0000000000..3756415b6f --- /dev/null +++ b/data/skill/construction/construction.varbits.toml @@ -0,0 +1,4 @@ +[house_build_mode] +id = 2176 +format = "boolean" +persist = true diff --git a/data/skill/construction/construction.varcs.toml b/data/skill/construction/construction.varcs.toml new file mode 100644 index 0000000000..dc79302f2e --- /dev/null +++ b/data/skill/construction/construction.varcs.toml @@ -0,0 +1,3 @@ +[house_room_count] +id = 944 +format = "int" diff --git a/data/skill/construction/construction.vars.toml b/data/skill/construction/construction.vars.toml new file mode 100644 index 0000000000..5497545d6b --- /dev/null +++ b/data/skill/construction/construction.vars.toml @@ -0,0 +1,12 @@ +[house_location] +format = "string" +persist = true + +[house_room_ids] +persist = true + +[house_room_positions] +persist = true + +[house_room_rotations] +persist = true diff --git a/game/src/main/kotlin/content/area/asgarnia/falador/EstateAgent.kt b/game/src/main/kotlin/content/area/asgarnia/falador/EstateAgent.kt index 8f676a5292..0585fea675 100644 --- a/game/src/main/kotlin/content/area/asgarnia/falador/EstateAgent.kt +++ b/game/src/main/kotlin/content/area/asgarnia/falador/EstateAgent.kt @@ -1,17 +1,62 @@ package content.area.asgarnia.falador +import content.entity.player.dialogue.Happy +import content.entity.player.dialogue.Neutral import content.entity.player.dialogue.Quiz +import content.entity.player.dialogue.Sad import content.entity.player.dialogue.skillcapeMasterDialogue +import content.entity.player.dialogue.type.choice +import content.entity.player.dialogue.type.npc import content.entity.player.dialogue.type.player +import content.entity.player.inv.item.addOrDrop +import content.skill.construction.House.Companion.START_ROOM +import content.skill.construction.House.Companion.addHouseRoom import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.data.Settings +import world.gregs.voidps.engine.entity.character.player.Player import world.gregs.voidps.engine.entity.character.player.skill.Skill +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.engine.inv.remove class EstateAgent : Script { init { - // TODO house purchase, redecoration and relocation dialogue when construction lands npcOperate("Talk-to", "estate_agent*") { - player("What's that cape you are wearing?") - skillcapeMasterDialogue(Skill.Construction, "a master home builder") + npc("Hello. Welcome to the ${Settings["server.name"]} Housing Agency! What can I do for you?") + choice { + if (!contains("house_location")) { + option("How can I get a house?") { + buyHouse() + } + } else { + // TODO move + redecorate house + } + option("What's that cape you are wearing?") { + skillcapeMasterDialogue(Skill.Construction, "a master home builder") + } + option("Never mind.") + } + } + } + + private suspend fun Player.buyHouse() { + npc("I can sell you a starting house in Rimmington for 1000 coins. As you increase your construction skill you will be able to have your house moved to other areas and redecorated in other styles.") + npc("Do you want to buy a starter house?") + choice { + option("Yes please!") { + if (!inventory.remove("coins", 1000)) { + player("I haven't got 1,000 coins on me.") + npc("Well come back when you have it then.") + return@option + } + set("house_location", "rimmington") + addHouseRoom("garden", START_ROOM) + npc("Thank you. Go through the Rimmington house portal and you will find your house ready for you to start building in it.") + npc("This book will help you to start building your house.") + addOrDrop("construction_guide") + } + option("No thanks.") { + npc("Well enjoy your player-owned cardboard box or wherever you're going to sleep tonight!") + } } } } diff --git a/game/src/main/kotlin/content/skill/construction/House.kt b/game/src/main/kotlin/content/skill/construction/House.kt new file mode 100644 index 0000000000..ca1da87fc9 --- /dev/null +++ b/game/src/main/kotlin/content/skill/construction/House.kt @@ -0,0 +1,191 @@ +package content.skill.construction + +import content.quest.clearInstance +import content.quest.exitInstance +import content.quest.instance +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.client.ui.close +import world.gregs.voidps.engine.client.ui.hasOpen +import world.gregs.voidps.engine.client.ui.open +import world.gregs.voidps.engine.data.definition.Tables +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.entity.character.player.Players +import world.gregs.voidps.engine.entity.obj.GameObjects +import world.gregs.voidps.engine.entity.obj.remove +import world.gregs.voidps.engine.get +import world.gregs.voidps.engine.map.instance.Instances +import world.gregs.voidps.engine.map.zone.DynamicZones +import world.gregs.voidps.type.Zone + +/** + * Houses are a [HOUSE_SIZE] grid of zones, a [ROOM_GRID] area of rooms around the [START_ROOM] surrounded by a border. + * Rooms are on [GROUND_LEVEL] with the dungeon on [DUNGEON_LEVEL] + */ +class House : Script { + init { + moved { + if (!contains("house_owner")) { + return@moved + } + val instance = instance() + if (instance != null && Instances.owner(tile) == instance) { + return@moved + } + leaveHouse(teleport = false) + } + + playerDespawn { + if (inOwnHouse()) { + expelGuests() + } + } + } + + companion object { + const val ROOM_GRID = 7 + const val HOUSE_LEVELS = 4 + const val HOUSE_SIZE = ROOM_GRID + 2 + const val HOUSE_CENTRE = ROOM_GRID / 2 + const val DUNGEON_LEVEL = 0 + const val GROUND_LEVEL = 1 + const val START_ROOM = HOUSE_CENTRE + HOUSE_CENTRE * ROOM_GRID + GROUND_LEVEL * ROOM_GRID * ROOM_GRID + + val Player.houseRoomIds: List + get() = get("house_room_ids") ?: emptyList() + + val Player.houseRoomPositions: List + get() = get("house_room_positions") ?: emptyList() + + val Player.houseRoomRotations: List + get() = get("house_room_rotations") ?: emptyList() + + fun Player.addHouseRoom(id: String, position: Int, rotation: Int = 0) { + set("house_room_ids", houseRoomIds + id) + set("house_room_positions", houseRoomPositions + position) + set("house_room_rotations", houseRoomRotations + rotation) + } + + fun roomPosition(x: Int, y: Int, level: Int) = x + y * ROOM_GRID + level * ROOM_GRID * ROOM_GRID + + fun roomX(position: Int) = position % ROOM_GRID + + fun roomY(position: Int) = position / ROOM_GRID % ROOM_GRID + + fun roomLevel(position: Int) = position / (ROOM_GRID * ROOM_GRID) + + /** + * The zone the room at [position] occupies in the house starting at [base] + */ + fun roomZone(base: Zone, position: Int): Zone = base.add(roomX(position) + 1, roomY(position) + 1, roomLevel(position)) + + /** + * The room position of [zone] in the house starting at [base], or null if it's outside the room grid + */ + fun roomPosition(base: Zone, zone: Zone): Int? { + val x = zone.x - base.x - 1 + val y = zone.y - base.y - 1 + val level = zone.level - base.level + if (x !in 0 until ROOM_GRID || y !in 0 until ROOM_GRID || level !in 0 until HOUSE_LEVELS) { + return null + } + return roomPosition(x, y, level) + } + + fun Player.inOwnHouse() = get("house_owner") == accountName + + /** + * South-west zone of the house the player is currently in + */ + fun Player.houseBase(): Zone? { + if (!contains("house_owner")) { + return null + } + return instance()?.tile?.zone + } + + /** + * Filler for a room [position] without a room + */ + fun emptyRoom(position: Int): Zone = emptySpace(roomX(position) + 1, roomY(position) + 1, roomLevel(position)) + + /** + * Filler for house grid spaces without a room + */ + private fun emptySpace(x: Int, y: Int, level: Int): Zone { + val space = when { + level == DUNGEON_LEVEL -> "dungeon" + x == 0 || y == 0 || x == HOUSE_SIZE - 1 || y == HOUSE_SIZE - 1 -> "border" + else -> "land" + } + return Tables.tile("house_spaces.$space.template").zone + } + + fun Player.loadHouse(base: Zone, buildMode: Boolean) { + val entries = mutableListOf>() + val placed = mutableSetOf() + val ids = houseRoomIds + val rotations = houseRoomRotations + for ((index, position) in houseRoomPositions.withIndex()) { + val zone = roomZone(base, position) + placed.add(zone) + entries.add(Triple(Tables.tile("house_rooms.${ids[index]}.template").zone, zone, rotations[index])) + } + for (level in DUNGEON_LEVEL..GROUND_LEVEL) { + for (x in 0 until HOUSE_SIZE) { + for (y in 0 until HOUSE_SIZE) { + val zone = base.add(x, y, level) + if (zone !in placed) { + entries.add(Triple(emptySpace(x, y, level), zone, 0)) + } + } + } + } + get().copy(entries) + if (buildMode) { + return + } + for (zone in placed) { + removeHotspots(zone) + } + } + + private fun removeHotspots(zone: Zone) { + for (tile in zone.toCuboid()) { + for (obj in GameObjects.at(tile)) { + if (obj.def.containsOption("Build")) { + obj.remove() + } + } + } + } + + suspend fun Player.houseLoading() { + open("house_loading") + delay(3) + close("house_loading") + } + + fun Player.leaveHouse(teleport: Boolean = true) { + val owner: String = remove("house_owner") ?: return + if (hasOpen("house_options")) { + open("options") + } + if (owner == accountName) { + expelGuests() + } + if (teleport) { + exitInstance() + } else { + clearInstance() + } + } + + fun Player.expelGuests() { + for (player in Players) { + if (player != this && player.get("house_owner") == accountName) { + player.leaveHouse() + } + } + } + } +} diff --git a/game/src/main/kotlin/content/skill/construction/HouseOptions.kt b/game/src/main/kotlin/content/skill/construction/HouseOptions.kt new file mode 100644 index 0000000000..5ef9aba0c4 --- /dev/null +++ b/game/src/main/kotlin/content/skill/construction/HouseOptions.kt @@ -0,0 +1,74 @@ +package content.skill.construction + +import content.skill.construction.House.Companion.expelGuests +import content.skill.construction.House.Companion.houseBase +import content.skill.construction.House.Companion.houseLoading +import content.skill.construction.House.Companion.houseRoomIds +import content.skill.construction.House.Companion.inOwnHouse +import content.skill.construction.House.Companion.leaveHouse +import content.skill.construction.House.Companion.loadHouse +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.client.message +import world.gregs.voidps.engine.client.ui.open +import world.gregs.voidps.engine.entity.character.player.Player + +class HouseOptions : Script { + init { + interfaceOption("Open House Options", "options:house") { + if (!contains("house_owner")) { + message("You can only do that in a house.") + return@interfaceOption + } + open("house_options") + } + + interfaceOpened("house_options") { + set("house_room_count", houseRoomIds.size) + } + + interfaceOption("Close", "house_options:close") { + open("options") + } + + interfaceOption("Building mode on", "house_options:building_mode_on") { + buildMode(true) + } + + interfaceOption("Building mode off", "house_options:building_mode_off") { + buildMode(false) + } + + interfaceOption("Expel guests", "house_options:expel_guests") { + if (!inOwnHouse()) { + message("You can only expel guests when you are in your own house.") + return@interfaceOption + } + expelGuests() + } + + interfaceOption("Leave house", "house_options:leave_house") { + if (!contains("house_owner")) { + message("You're not in a house.") + return@interfaceOption + } + leaveHouse() + } + } + + private suspend fun Player.buildMode(enabled: Boolean) { + if (get("house_build_mode", false) == enabled) { + return + } + set("house_build_mode", enabled) + message("Building mode is now ${if (enabled) "on" else "off"}.") + val base = houseBase() + if (base == null || !inOwnHouse()) { + return + } + if (enabled) { + expelGuests() + } + loadHouse(base, enabled) + houseLoading() + } +} diff --git a/game/src/main/kotlin/content/skill/construction/HousePortal.kt b/game/src/main/kotlin/content/skill/construction/HousePortal.kt new file mode 100644 index 0000000000..d50d409013 --- /dev/null +++ b/game/src/main/kotlin/content/skill/construction/HousePortal.kt @@ -0,0 +1,77 @@ +package content.skill.construction + +import content.entity.player.dialogue.type.choice +import content.entity.player.dialogue.type.nameEntry +import content.quest.instance +import content.quest.joinInstance +import content.quest.setInstanceLogout +import content.quest.smallInstance +import content.skill.construction.House.Companion.houseLoading +import content.skill.construction.House.Companion.houseRoomPositions +import content.skill.construction.House.Companion.inOwnHouse +import content.skill.construction.House.Companion.loadHouse +import content.skill.construction.House.Companion.roomZone +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.client.message +import world.gregs.voidps.engine.data.definition.Tables +import world.gregs.voidps.engine.entity.character.move.tele +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.entity.character.player.Players +import world.gregs.voidps.type.Region + +class HousePortal : Script { + init { + objectOperate("Enter", "house_portal_*") { (target) -> + val location = target.id.removePrefix("house_portal_") + val owned = get("house_location", "") == location + choice { + if (owned) { + option("Go to your house.") { + enterOwnHouse(buildMode = false) + } + option("Go to your house (building mode).") { + enterOwnHouse(buildMode = true) + } + } + option("Go to a friend's house.") { + visitFriend(location) + } + option("Never mind.") + } + } + } + + private suspend fun Player.enterOwnHouse(buildMode: Boolean) { + set("house_build_mode", buildMode) + val instance = smallInstance() + loadHouse(instance.tile.zone, buildMode) + arrive(this, instance) + } + + private suspend fun Player.visitFriend(location: String) { + val owner = Players.find(nameEntry("Enter name:")) + if (owner == null || !owner.inOwnHouse()) { + message("That player is offline, or has privacy mode enabled.") // TODO proper messages + return + } + if (owner["house_location", ""] != location) { + message("That player's house isn't here.") // TODO proper messages + return + } + if (owner["house_build_mode", false]) { + message("The owner currently has build mode turned on.") // TODO proper messages + return + } + val instance = owner.instance() ?: return + set("instance_logout", true) + joinInstance(instance) + arrive(owner, instance) + } + + private suspend fun Player.arrive(owner: Player, instance: Region) { + setInstanceLogout(Tables.tile("house_locations.${owner["house_location", ""]}.exit")) + set("house_owner", owner.accountName) + tele(roomZone(instance.tile.zone, owner.houseRoomPositions.first()).tile.add(3, 3)) + houseLoading() + } +} diff --git a/game/src/main/kotlin/content/skill/construction/RoomCreation.kt b/game/src/main/kotlin/content/skill/construction/RoomCreation.kt new file mode 100644 index 0000000000..83d5a8b128 --- /dev/null +++ b/game/src/main/kotlin/content/skill/construction/RoomCreation.kt @@ -0,0 +1,167 @@ +package content.skill.construction + +import content.entity.player.dialogue.type.choice +import content.skill.construction.House.Companion.DUNGEON_LEVEL +import content.skill.construction.House.Companion.HOUSE_CENTRE +import content.skill.construction.House.Companion.addHouseRoom +import content.skill.construction.House.Companion.emptyRoom +import content.skill.construction.House.Companion.houseBase +import content.skill.construction.House.Companion.houseRoomPositions +import content.skill.construction.House.Companion.inOwnHouse +import content.skill.construction.House.Companion.roomLevel +import content.skill.construction.House.Companion.roomPosition +import content.skill.construction.House.Companion.roomX +import content.skill.construction.House.Companion.roomY +import content.skill.construction.House.Companion.roomZone +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.client.message +import world.gregs.voidps.engine.client.ui.close +import world.gregs.voidps.engine.client.ui.open +import world.gregs.voidps.engine.data.definition.Rows +import world.gregs.voidps.engine.data.definition.Tables +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.entity.character.player.skill.Skill +import world.gregs.voidps.engine.entity.character.player.skill.level.Level.has +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.engine.inv.remove +import world.gregs.voidps.engine.map.zone.DynamicZones +import world.gregs.voidps.type.Direction + +class RoomCreation(val dynamicZones: DynamicZones) : Script { + init { + objectOperate("Build", "door_hotspot*") { (target) -> + val base = houseBase() + if (base == null || !inOwnHouse() || !get("house_build_mode", false)) { + return@objectOperate + } + val zone = target.tile.zone + val direction = when { + target.tile.x == zone.tile.x -> Direction.WEST + target.tile.y == zone.tile.y -> Direction.SOUTH + target.tile.x == zone.tile.x + 7 -> Direction.EAST + else -> Direction.NORTH + } + val position = roomPosition(base, zone.add(direction)) + if (position == null || !withinBounds(position)) { + message("You need a higher Construction level to build a room that far out.") // TODO proper messages + return@objectOperate + } + val rooms = houseRoomPositions + if (rooms.size >= maxRooms()) { + message("You need a higher Construction level to build any more rooms.") // TODO proper messages + return@objectOperate + } + if (position in rooms) { + message("There is already a room there.") // TODO proper messages + return@objectOperate + } + set("house_preview_position", position) + open("room_creation") + } + + interfaceOption("Build", "room_creation:*") { + close("room_creation") + val position: Int = get("house_preview_position") ?: return@interfaceOption + val row = Rows.getOrNull("house_rooms.${it.component}") ?: return@interfaceOption + if (!has(Skill.Construction, row.int("level"), message = true)) { + return@interfaceOption + } + val dungeon = row.bool("dungeon") + if (dungeon && roomLevel(position) != DUNGEON_LEVEL) { + message("That room can only be built underground.") // TODO proper messages + return@interfaceOption + } + if (!dungeon && roomLevel(position) == DUNGEON_LEVEL) { + message("That room can't be built underground.") // TODO proper messages + return@interfaceOption + } + if (inventory.count("coins") < row.int("cost")) { + message("You need ${row.int("cost")} coins to build this room.") // TODO proper messages + return@interfaceOption + } + set("house_preview_room", it.component) + set("house_preview_rotation", 0) + walkTrigger { clearPreview() } + preview() + } + } + + /** + * Whether room [position] is inside the area of rooms the players level allows + * Sizes grow from the south-west, so even sizes extend one further north and east of the [HOUSE_CENTRE] + */ + private fun Player.withinBounds(position: Int): Boolean { + val size = 3 + sizeLevels.count { levels.get(Skill.Construction) >= it } + val range = HOUSE_CENTRE - (size - 1) / 2..HOUSE_CENTRE + size / 2 + return roomX(position) in range && roomY(position) in range + } + + private fun Player.maxRooms(): Int = 20 + roomLevels.count { levels.get(Skill.Construction) >= it } + + private suspend fun Player.preview() { + val base = houseBase() + if (base == null || !inOwnHouse()) { + clearPreview() + return + } + val position: Int = get("house_preview_position") ?: return + val room: String = get("house_preview_room") ?: return + dynamicZones.copy(Tables.tile("house_rooms.$room.template").zone, roomZone(base, position), get("house_preview_rotation", 0)) + choice { + option("Rotate clockwise") { + rotate(1) + } + option("Rotate anticlockwise") { + rotate(3) + } + option("Build") { + build() + } + option("Cancel") { + clearPreview() + } + } + } + + private suspend fun Player.rotate(turns: Int) { + set("house_preview_rotation", (get("house_preview_rotation", 0) + turns) % 4) + preview() + } + + private fun Player.build() { + clearWalkTrigger() + val position: Int = remove("house_preview_position") ?: return + val room: String = remove("house_preview_room") ?: return + val rotation: Int = remove("house_preview_rotation") ?: 0 + val base = houseBase() + if (base == null || !inOwnHouse()) { + return + } + if (!inventory.remove("coins", Rows.get("house_rooms.$room").int("cost"))) { + message("You don't have enough coins to build this room.") // TODO proper message + dynamicZones.copy(emptyRoom(position), roomZone(base, position)) + return + } + addHouseRoom(room, position, rotation) + } + + /** + * Remove the room being previewed + */ + private fun Player.clearPreview() { + clearWalkTrigger() + clear("house_preview_room") + clear("house_preview_rotation") + val position: Int = remove("house_preview_position") ?: return + val base = houseBase() ?: return + dynamicZones.copy(emptyRoom(position), roomZone(base, position)) + } + + companion object { + // Levels at which the buildable area grows from 3x3 up to 7x7 rooms + private val sizeLevels = intArrayOf(15, 30, 45, 60) + + // Levels at which the maximum number of rooms increases from 21 up to 33 + private val roomLevels = intArrayOf(1, 38, 44, 50, 56, 62, 68, 74, 80, 86, 92, 96, 99) + } +} diff --git a/game/src/test/kotlin/content/skill/construction/HouseTest.kt b/game/src/test/kotlin/content/skill/construction/HouseTest.kt new file mode 100644 index 0000000000..00edd77b84 --- /dev/null +++ b/game/src/test/kotlin/content/skill/construction/HouseTest.kt @@ -0,0 +1,401 @@ +package content.skill.construction + +import WorldTest +import containsMessage +import content.quest.instance +import content.skill.construction.House.Companion.DUNGEON_LEVEL +import content.skill.construction.House.Companion.GROUND_LEVEL +import content.skill.construction.House.Companion.START_ROOM +import content.skill.construction.House.Companion.addHouseRoom +import content.skill.construction.House.Companion.houseRoomIds +import content.skill.construction.House.Companion.houseRoomPositions +import content.skill.construction.House.Companion.houseRoomRotations +import content.skill.construction.House.Companion.roomPosition +import content.skill.construction.House.Companion.roomZone +import dialogueOption +import interfaceOption +import npcOption +import objectOption +import org.junit.jupiter.api.Test +import skipDialogues +import walk +import world.gregs.voidps.engine.client.ui.dialogue +import world.gregs.voidps.engine.client.ui.hasOpen +import world.gregs.voidps.engine.client.ui.open +import world.gregs.voidps.engine.entity.Despawn +import world.gregs.voidps.engine.entity.character.move.tele +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.entity.character.player.name +import world.gregs.voidps.engine.entity.character.player.skill.Skill +import world.gregs.voidps.engine.entity.obj.GameObjects +import world.gregs.voidps.engine.get +import world.gregs.voidps.engine.inv.add +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.engine.map.instance.Instances +import world.gregs.voidps.engine.map.zone.DynamicZones +import world.gregs.voidps.engine.suspend.Suspension +import world.gregs.voidps.type.Direction +import world.gregs.voidps.type.Tile +import world.gregs.voidps.type.Zone +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class HouseTest : WorldTest() { + + private val portal = Tile(2951, 3222) + private val exit = Tile(2953, 3224) + private val dynamicZones: DynamicZones + get() = get() + + private fun createOwner(name: String = "owner"): Player { + val player = createPlayer(exit, name) + player["house_location"] = "rimmington" + player.addHouseRoom("garden", START_ROOM) + return player + } + + private fun Player.buildDoor(x: Int, y: Int, direction: Direction) { + val base = instance()!!.tile.zone.add(x, y, GROUND_LEVEL).tile + val tile = when (direction) { + Direction.EAST -> base.add(7, 3) + Direction.WEST -> base.add(0, 3) + Direction.NORTH -> base.add(3, 7) + else -> base.add(3, 0) + } + tele(base.add(3, 3)) + objectOption(GameObjects.find(tile) { it.id.startsWith("door_hotspot") }, "Build") + tick(10) + } + + private fun Player.enterPortal(option: Int) { + objectOption(GameObjects.find(portal, "house_portal_rimmington"), "Enter") + tickIf { dialogue == null } + dialogueOption("line$option") + tickIf { hasOpen("house_loading") } + } + + private fun Player.visit(owner: Player) { + enterPortal(1) + (suspension as Suspension.NameEntry).resume(owner.name) + tickIf { hasOpen("house_loading") } + } + + private fun Player.inHouseOf(owner: Player): Boolean { + val instance = owner.instance() ?: return false + return instance() == instance && Instances.owner(tile) == instance + } + + @Test + fun `Buy a house from an estate agent`() { + val player = createPlayer(Tile(2983, 3370)) + player.inventory.add("coins", 1000) + val agent = createNPC("estate_agent", Tile(2983, 3369)) + + player.npcOption(agent, "Talk-to") + tickIf { player.dialogue == null } + player.skipDialogues() + player.dialogueOption("line1") + player.skipDialogues() + player.dialogueOption("line1") + player.skipDialogues() + + assertEquals("rimmington", player["house_location", ""]) + assertEquals(listOf("garden"), player.houseRoomIds) + assertEquals(listOf(START_ROOM), player.houseRoomPositions) + assertEquals(listOf(0), player.houseRoomRotations) + assertEquals(0, player.inventory.count("coins")) + } + + @Test + fun `Room positions cover the 7x7x4 grid`() { + val base = Zone(800, 0) + assertEquals(0, roomPosition(0, 0, 0)) + assertEquals(195, roomPosition(6, 6, 3)) + for (position in 0..195) { + assertEquals(position, roomPosition(base, roomZone(base, position))) + } + assertNull(roomPosition(base, base)) + assertNull(roomPosition(base, base.add(8, 1))) + } + + @Test + fun `Enter own house`() { + val player = createOwner() + + player.enterPortal(1) + + val instance = player.instance() + assertNotNull(instance) + assertEquals(instance, Instances.owner(player.tile)) + assertEquals(GROUND_LEVEL, player.tile.level) + assertEquals(instance.tile.zone.add(4, 4, GROUND_LEVEL).tile.add(3, 3), player.tile) + assertEquals(dynamicZones.dynamicZone(instance.tile.zone.add(4, 4, DUNGEON_LEVEL)), dynamicZones.dynamicZone(instance.tile.zone.add(5, 5, DUNGEON_LEVEL))) + assertNull(GameObjects.findOrNull(instance.tile.zone.add(4, 4, 1).tile.add(7, 3)) { it.id.startsWith("door_hotspot") }) + } + + @Test + fun `Build a room in building mode`() { + val player = createOwner() + player.levels.set(Skill.Construction, 1) + player.inventory.add("coins", 1000) + player.enterPortal(2) + val base = player.instance()!!.tile.zone + + val door = GameObjects.find(base.add(4, 4, 1).tile.add(7, 3)) { it.id.startsWith("door_hotspot") } + player.objectOption(door, "Build") + tickIf { !player.hasOpen("room_creation") } + player.interfaceOption("room_creation", "parlour", "Build") + player.dialogueOption("line1") + player.dialogueOption("line3") + + assertEquals(listOf("garden", "parlour"), player.houseRoomIds) + assertEquals(listOf(START_ROOM, roomPosition(4, 3, GROUND_LEVEL)), player.houseRoomPositions) + assertEquals(listOf(0, 1), player.houseRoomRotations) + assertEquals(0, player.inventory.count("coins")) + } + + @Test + fun `Cancelling a room preview doesn't build`() { + val player = createOwner() + player.inventory.add("coins", 1000) + player.enterPortal(2) + val base = player.instance()!!.tile.zone + + player.objectOption(GameObjects.find(base.add(4, 4, 1).tile.add(7, 3)) { it.id.startsWith("door_hotspot") }, "Build") + tickIf { !player.hasOpen("room_creation") } + player.interfaceOption("room_creation", "parlour", "Build") + player.dialogueOption("line4") + + assertEquals(listOf("garden"), player.houseRoomIds) + assertEquals(1000, player.inventory.count("coins")) + } + + @Test + fun `Can't build a room without the level`() { + val player = createOwner() + player.inventory.add("coins", 10000) + player.enterPortal(2) + val base = player.instance()!!.tile.zone + + player.objectOption(GameObjects.find(base.add(4, 4, 1).tile.add(7, 3)) { it.id.startsWith("door_hotspot") }, "Build") + tickIf { !player.hasOpen("room_creation") } + player.interfaceOption("room_creation", "kitchen", "Build") + + assertTrue(player.containsMessage("Construction level of 5")) + assertEquals(1, player.houseRoomIds.size) + } + + @Test + fun `Walking away from a room preview doesn't build`() { + val player = createOwner() + player.inventory.add("coins", 1000) + player.enterPortal(2) + val base = player.instance()!!.tile.zone + val empty = dynamicZones.dynamicZone(base.add(5, 4, GROUND_LEVEL)) + + player.objectOption(GameObjects.find(base.add(4, 4, 1).tile.add(7, 3)) { it.id.startsWith("door_hotspot") }, "Build") + tickIf { !player.hasOpen("room_creation") } + player.interfaceOption("room_creation", "parlour", "Build") + assertNotEquals(empty, dynamicZones.dynamicZone(base.add(5, 4, GROUND_LEVEL))) + player.walk(player.tile.addX(-1)) + tick() + + assertEquals(listOf("garden"), player.houseRoomIds) + assertEquals(1000, player.inventory.count("coins")) + assertEquals(empty, dynamicZones.dynamicZone(base.add(5, 4, GROUND_LEVEL))) + assertNull(player.get("house_room")) + } + + @Test + fun `Level 1 can only build within a 3x3 area`() { + val player = createOwner() + player.addHouseRoom("parlour", roomPosition(4, 3, GROUND_LEVEL)) + player.enterPortal(2) + + player.buildDoor(5, 4, Direction.EAST) + + assertFalse(player.hasOpen("room_creation")) + assertTrue(player.containsMessage("that far out")) + } + + @Test + fun `Level 15 can build two rooms north and east but only one south and west`() { + val player = createOwner() + player.levels.set(Skill.Construction, 15) + player.addHouseRoom("parlour", roomPosition(4, 3, GROUND_LEVEL)) + player.addHouseRoom("kitchen", roomPosition(2, 3, GROUND_LEVEL)) + player.enterPortal(2) + + player.buildDoor(3, 4, Direction.WEST) + assertFalse(player.hasOpen("room_creation")) + + player.buildDoor(5, 4, Direction.EAST) + assertTrue(player.hasOpen("room_creation")) + } + + @Test + fun `Level 60 can build the full 7x7 area`() { + val player = createOwner() + player.levels.set(Skill.Construction, 60) + player.addHouseRoom("parlour", roomPosition(1, 3, GROUND_LEVEL)) + player.enterPortal(2) + + player.buildDoor(2, 4, Direction.WEST) + + assertTrue(player.hasOpen("room_creation")) + } + + @Test + fun `Can't build more rooms than the level allows`() { + val player = createOwner() + for (x in 0 until 7) { + for (y in 0 until 7) { + if (player.houseRoomIds.size < 21) { + player.addHouseRoom("parlour", roomPosition(x, y, DUNGEON_LEVEL)) + } + } + } + player.enterPortal(2) + + player.buildDoor(4, 4, Direction.EAST) + assertFalse(player.hasOpen("room_creation")) + assertTrue(player.containsMessage("any more rooms")) + + player.levels.set(Skill.Construction, 38) + player.buildDoor(4, 4, Direction.EAST) + assertTrue(player.hasOpen("room_creation")) + } + + @Test + fun `Can't build dungeon rooms above ground`() { + val player = createOwner() + player.levels.set(Skill.Construction, 99) + player.inventory.add("coins", 10000) + player.enterPortal(2) + val base = player.instance()!!.tile.zone + + player.objectOption(GameObjects.find(base.add(4, 4, 1).tile.add(7, 3)) { it.id.startsWith("door_hotspot") }, "Build") + tickIf { !player.hasOpen("room_creation") } + player.interfaceOption("room_creation", "dungeon_corridor", "Build") + + assertTrue(player.containsMessage("can only be built underground")) + assertEquals(1, player.houseRoomIds.size) + } + + @Test + fun `Can't open house options outside of a house`() { + val player = createOwner() + + player.interfaceOption("options", "house", "Open House Options") + + assertFalse(player.hasOpen("house_options")) + } + + @Test + fun `House options close on leaving house`() { + val player = createOwner() + player.enterPortal(1) + + player.interfaceOption("options", "house", "Open House Options") + assertTrue(player.hasOpen("house_options")) + player.tele(3222, 3218) + tick() + + assertFalse(player.hasOpen("house_options")) + assertTrue(player.hasOpen("options")) + } + + @Test + fun `Visit and leave another players house`() { + val owner = createOwner() + owner.enterPortal(1) + val guest = createPlayer(exit, "guest") + + guest.visit(owner) + assertTrue(guest.inHouseOf(owner)) + + guest.open("house_options") + guest.interfaceOption("house_options", "leave_house", "Leave house") + tick() + + assertEquals(exit, guest.tile) + assertNull(guest.instance()) + assertTrue(owner.inHouseOf(owner)) + } + + @Test + fun `Can't visit a house in building mode`() { + val owner = createOwner() + owner.enterPortal(2) + val guest = createPlayer(exit, "guest") + + guest.visit(owner) + + assertFalse(guest.inHouseOf(owner)) + assertTrue(guest.containsMessage("build mode")) + } + + @Test + fun `Expel guests`() { + val owner = createOwner() + owner.enterPortal(1) + val guest = createPlayer(exit, "guest") + guest.visit(owner) + + owner.open("house_options") + owner.interfaceOption("house_options", "expel_guests", "Expel guests") + tick() + + assertEquals(exit, guest.tile) + assertTrue(owner.inHouseOf(owner)) + } + + @Test + fun `Building mode expels guests`() { + val owner = createOwner() + owner.enterPortal(1) + val guest = createPlayer(exit, "guest") + guest.visit(owner) + + owner.open("house_options") + owner.interfaceOption("house_options", "building_mode_on", "Building mode on") + tick() + + assertEquals(exit, guest.tile) + assertTrue(owner["house_build_mode", false]) + assertTrue(owner.inHouseOf(owner)) + } + + @Test + fun `Owner teleporting away kicks guests`() { + val owner = createOwner() + owner.enterPortal(1) + val guest = createPlayer(exit, "guest") + guest.visit(owner) + + owner.tele(3222, 3218) + tick() + + assertNull(owner.instance()) + assertNull(owner.get("house_owner")) + assertEquals(exit, guest.tile) + } + + @Test + fun `Owner logging out kicks guests`() { + val owner = createOwner() + owner.enterPortal(1) + val guest = createPlayer(exit, "guest") + guest.visit(owner) + + Despawn.player(owner) + tick() + + assertEquals(exit, owner.tile) + assertEquals(exit, guest.tile) + } +} From cb413444484fd4b65d2f287e91324d9164584934 Mon Sep 17 00:00:00 2001 From: GregHib Date: Thu, 1 Oct 2026 20:58:31 +0100 Subject: [PATCH 05/28] Add room removal and teleporting to house --- .../construction/construction.tables.toml | 8 +- .../entity/character/player/Teleport.kt | 13 +- .../entity/character/player/TeleportTest.kt | 4 +- .../area/asgarnia/falador/EstateAgent.kt | 5 +- .../content/skill/construction/House.kt | 74 ++++-- .../content/skill/construction/HousePortal.kt | 72 +++++- .../skill/construction/RoomCreation.kt | 86 ++++--- .../free/piratetreasure/PirateTreasureTest.kt | 5 +- .../content/skill/construction/HouseTest.kt | 219 +++++++++++++++++- 9 files changed, 412 insertions(+), 74 deletions(-) diff --git a/data/skill/construction/construction.tables.toml b/data/skill/construction/construction.tables.toml index 9cdeb06664..3630096f2f 100644 --- a/data/skill/construction/construction.tables.toml +++ b/data/skill/construction/construction.tables.toml @@ -126,19 +126,13 @@ cost = 250000 template = { x = 1912, y = 5088 } dungeon = true -# Fillers for house grid spaces without a room +# Filler for house grid spaces without a room [house_spaces] template = "tile" [.land] template = { x = 1864, y = 5056 } -[.dungeon] -template = { x = 1880, y = 5056 } - -[.border] -template = { x = 1896, y = 5056 } - [house_locations] exit = "tile" diff --git a/engine/src/main/kotlin/world/gregs/voidps/engine/entity/character/player/Teleport.kt b/engine/src/main/kotlin/world/gregs/voidps/engine/entity/character/player/Teleport.kt index d2c6353615..360bd8121d 100644 --- a/engine/src/main/kotlin/world/gregs/voidps/engine/entity/character/player/Teleport.kt +++ b/engine/src/main/kotlin/world/gregs/voidps/engine/entity/character/player/Teleport.kt @@ -35,7 +35,7 @@ interface Teleport { } } - fun teleportLand(type: String, block: Player.() -> Unit) { + fun teleportLand(type: String, block: suspend Player.() -> Unit) { Script.checkLoading() land.getOrPut(type) { mutableSetOf() }.add(block) } @@ -62,7 +62,7 @@ interface Teleport { companion object : AutoCloseable { private val takeOff = Object2ObjectOpenHashMap Boolean>>(5) private val items = Object2ObjectOpenHashMap Boolean>>(5) - private val land = Object2ObjectOpenHashMap Unit>>(5) + private val land = Object2ObjectOpenHashMap Unit>>(5) private val objectTakeOff = Object2ObjectOpenHashMap Int>(50) private val objectLand = Object2ObjectOpenHashMap Unit>(20) @@ -94,7 +94,7 @@ interface Teleport { return true } - fun land(player: Player, type: String) { + suspend fun land(player: Player, type: String) { land[type]?.forEach { handler -> handler.invoke(player) } if (type != "*") { land["*"]?.forEach { handler -> handler.invoke(player) } @@ -154,7 +154,9 @@ interface Teleport { teleport(player, tile, type, spell, sound, force, xp) } - fun teleport(player: Player, tile: Tile, type: String, spell: String? = null, sound: Boolean = true, force: Boolean = false, xp: Double = 0.0): Boolean { + fun teleport(player: Player, tile: Tile, type: String, spell: String? = null, sound: Boolean = true, force: Boolean = false, xp: Double = 0.0): Boolean = teleport(player, type, spell, sound, force, xp) { tile } + + fun teleport(player: Player, type: String, spell: String? = null, sound: Boolean = true, force: Boolean = false, xp: Double = 0.0, clearInterfaces: Boolean = true, destination: Player.() -> Tile?): Boolean { if (!force && player.queue.contains(ActionPriority.Strong)) { return false } @@ -173,7 +175,8 @@ interface Teleport { } player.gfx("teleport_$type") player.animDelay("teleport_$type") - player.tele(tile) + val tile = destination.invoke(player) ?: return@strongQueue + player.tele(tile, clearInterfaces = clearInterfaces) if (sound) { player.sound("teleport_land_${type}") } diff --git a/engine/src/test/kotlin/world/gregs/voidps/engine/entity/character/player/TeleportTest.kt b/engine/src/test/kotlin/world/gregs/voidps/engine/entity/character/player/TeleportTest.kt index 5f0448a2ce..6b6f070dc5 100644 --- a/engine/src/test/kotlin/world/gregs/voidps/engine/entity/character/player/TeleportTest.kt +++ b/engine/src/test/kotlin/world/gregs/voidps/engine/entity/character/player/TeleportTest.kt @@ -90,7 +90,9 @@ class TeleportTest { } override fun invoke(args: List) { - Teleport.land(Player(), "tele") + runTest { + Teleport.land(Player(), "tele") + } } override val apis = listOf(Teleport) diff --git a/game/src/main/kotlin/content/area/asgarnia/falador/EstateAgent.kt b/game/src/main/kotlin/content/area/asgarnia/falador/EstateAgent.kt index 0585fea675..aa0d34c054 100644 --- a/game/src/main/kotlin/content/area/asgarnia/falador/EstateAgent.kt +++ b/game/src/main/kotlin/content/area/asgarnia/falador/EstateAgent.kt @@ -9,8 +9,7 @@ import content.entity.player.dialogue.type.choice import content.entity.player.dialogue.type.npc import content.entity.player.dialogue.type.player import content.entity.player.inv.item.addOrDrop -import content.skill.construction.House.Companion.START_ROOM -import content.skill.construction.House.Companion.addHouseRoom +import content.skill.construction.House.Companion.newHouse import world.gregs.voidps.engine.Script import world.gregs.voidps.engine.data.Settings import world.gregs.voidps.engine.entity.character.player.Player @@ -49,7 +48,7 @@ class EstateAgent : Script { return@option } set("house_location", "rimmington") - addHouseRoom("garden", START_ROOM) + newHouse() npc("Thank you. Go through the Rimmington house portal and you will find your house ready for you to start building in it.") npc("This book will help you to start building your house.") addOrDrop("construction_guide") diff --git a/game/src/main/kotlin/content/skill/construction/House.kt b/game/src/main/kotlin/content/skill/construction/House.kt index ca1da87fc9..7c588ad6d9 100644 --- a/game/src/main/kotlin/content/skill/construction/House.kt +++ b/game/src/main/kotlin/content/skill/construction/House.kt @@ -18,7 +18,7 @@ import world.gregs.voidps.engine.map.zone.DynamicZones import world.gregs.voidps.type.Zone /** - * Houses are a [HOUSE_SIZE] grid of zones, a [ROOM_GRID] area of rooms around the [START_ROOM] surrounded by a border. + * Houses are a [HOUSE_SIZE] grid of zones, a [ROOM_GRID] area of rooms around the [START_ROOM] with space for a ring of grass. * Rooms are on [GROUND_LEVEL] with the dungeon on [DUNGEON_LEVEL] */ class House : Script { @@ -65,6 +65,33 @@ class House : Script { set("house_room_rotations", houseRoomRotations + rotation) } + /** + * Replaces any existing rooms with a new house containing just the starting garden + */ + fun Player.newHouse() { + set("house_room_ids", listOf("garden")) + set("house_room_positions", listOf(START_ROOM)) + set("house_room_rotations", listOf(0)) + } + + /** + * Whether the players saved rooms are present and consistent + */ + fun Player.hasHouse(): Boolean { + val size = houseRoomIds.size + return size > 0 && houseRoomPositions.size == size && houseRoomRotations.size == size + } + + fun Player.removeHouseRoom(position: Int) { + val index = houseRoomPositions.indexOf(position) + if (index == -1) { + return + } + set("house_room_ids", houseRoomIds.filterIndexed { i, _ -> i != index }) + set("house_room_positions", houseRoomPositions.filterIndexed { i, _ -> i != index }) + set("house_room_rotations", houseRoomRotations.filterIndexed { i, _ -> i != index }) + } + fun roomPosition(x: Int, y: Int, level: Int) = x + y * ROOM_GRID + level * ROOM_GRID * ROOM_GRID fun roomX(position: Int) = position % ROOM_GRID @@ -104,48 +131,49 @@ class House : Script { } /** - * Filler for a room [position] without a room - */ - fun emptyRoom(position: Int): Zone = emptySpace(roomX(position) + 1, roomY(position) + 1, roomLevel(position)) - - /** - * Filler for house grid spaces without a room + * Builds the players house in the instance starting at [base]. Ground floor rooms are surrounded by a + * ring of grass, every other space is left empty. */ - private fun emptySpace(x: Int, y: Int, level: Int): Zone { - val space = when { - level == DUNGEON_LEVEL -> "dungeon" - x == 0 || y == 0 || x == HOUSE_SIZE - 1 || y == HOUSE_SIZE - 1 -> "border" - else -> "land" - } - return Tables.tile("house_spaces.$space.template").zone - } - fun Player.loadHouse(base: Zone, buildMode: Boolean) { + val dynamicZones = get() val entries = mutableListOf>() val placed = mutableSetOf() val ids = houseRoomIds val rotations = houseRoomRotations - for ((index, position) in houseRoomPositions.withIndex()) { + val positions = houseRoomPositions + for ((index, position) in positions.withIndex()) { val zone = roomZone(base, position) placed.add(zone) entries.add(Triple(Tables.tile("house_rooms.${ids[index]}.template").zone, zone, rotations[index])) } - for (level in DUNGEON_LEVEL..GROUND_LEVEL) { + val ground = positions.filter { roomLevel(it) == GROUND_LEVEL } + if (ground.isNotEmpty()) { + val land = Tables.tile("house_spaces.land.template").zone + for (x in ground.minOf(::roomX) - 1..ground.maxOf(::roomX) + 1) { + for (y in ground.minOf(::roomY) - 1..ground.maxOf(::roomY) + 1) { + val zone = base.add(x + 1, y + 1, GROUND_LEVEL) + if (placed.add(zone)) { + entries.add(Triple(land, zone, 0)) + } + } + } + } + for (level in 0 until HOUSE_LEVELS) { for (x in 0 until HOUSE_SIZE) { for (y in 0 until HOUSE_SIZE) { val zone = base.add(x, y, level) - if (zone !in placed) { - entries.add(Triple(emptySpace(x, y, level), zone, 0)) + if (zone !in placed && dynamicZones.dynamicZone(zone) != null) { + dynamicZones.clear(zone) } } } } - get().copy(entries) + dynamicZones.copy(entries) if (buildMode) { return } - for (zone in placed) { - removeHotspots(zone) + for (position in positions) { + removeHotspots(roomZone(base, position)) } } diff --git a/game/src/main/kotlin/content/skill/construction/HousePortal.kt b/game/src/main/kotlin/content/skill/construction/HousePortal.kt index d50d409013..9d0831b219 100644 --- a/game/src/main/kotlin/content/skill/construction/HousePortal.kt +++ b/game/src/main/kotlin/content/skill/construction/HousePortal.kt @@ -6,18 +6,29 @@ import content.quest.instance import content.quest.joinInstance import content.quest.setInstanceLogout import content.quest.smallInstance +import content.skill.construction.House.Companion.hasHouse import content.skill.construction.House.Companion.houseLoading +import content.skill.construction.House.Companion.houseRoomIds import content.skill.construction.House.Companion.houseRoomPositions import content.skill.construction.House.Companion.inOwnHouse +import content.skill.construction.House.Companion.leaveHouse import content.skill.construction.House.Companion.loadHouse +import content.skill.construction.House.Companion.newHouse import content.skill.construction.House.Companion.roomZone import world.gregs.voidps.engine.Script import world.gregs.voidps.engine.client.message +import world.gregs.voidps.engine.client.ui.close +import world.gregs.voidps.engine.client.ui.closeInterfaces +import world.gregs.voidps.engine.client.ui.open import world.gregs.voidps.engine.data.definition.Tables import world.gregs.voidps.engine.entity.character.move.tele import world.gregs.voidps.engine.entity.character.player.Player import world.gregs.voidps.engine.entity.character.player.Players +import world.gregs.voidps.engine.entity.character.player.Teleport +import world.gregs.voidps.engine.map.instance.Instances +import world.gregs.voidps.engine.queue.longQueue import world.gregs.voidps.type.Region +import world.gregs.voidps.type.Tile class HousePortal : Script { init { @@ -39,13 +50,59 @@ class HousePortal : Script { option("Never mind.") } } + + interfaceOption("Cast", "modern_spellbook:teleport_to_house") { + teleportHome("modern", it.component, xp = Tables.int("spells.teleport_to_house.xp") / 10.0) + } + + itemOption("Break", "teleport_to_house") { + teleportHome("tablet", it.item.id) + } + + teleportLand("modern") { + delay(1) + close("house_loading") + } + + teleportLand("tablet") { + delay(3) + close("house_loading") + } } private suspend fun Player.enterOwnHouse(buildMode: Boolean) { + tele(createHouse(buildMode)) + houseLoading() + } + + /** + * Teleport into the players own house by the portal + */ + private fun Player.teleportHome(type: String, spell: String, xp: Double = 0.0) { + if (!contains("house_location")) { + message("You don't have a house to teleport to.") // TODO proper message + return + } + val tile = createHouse(buildMode = false) + closeInterfaces() + Teleport.teleport(this, type, spell, xp = xp, clearInterfaces = false) { + open("house_loading") + tile + } + } + + /** + * Creates a new instance of the players house, returning the tile to arrive at + */ + private fun Player.createHouse(buildMode: Boolean): Tile { + leaveHouse(teleport = false) set("house_build_mode", buildMode) + if (!hasHouse()) { + newHouse() + } val instance = smallInstance() loadHouse(instance.tile.zone, buildMode) - arrive(this, instance) + return arrival(this, instance) } private suspend fun Player.visitFriend(location: String) { @@ -65,13 +122,18 @@ class HousePortal : Script { val instance = owner.instance() ?: return set("instance_logout", true) joinInstance(instance) - arrive(owner, instance) + tele(arrival(owner, instance)) + houseLoading() } - private suspend fun Player.arrive(owner: Player, instance: Region) { + /** + * Marks the player as inside [owner]'s house [instance], returning the tile to arrive at + */ + private fun Player.arrival(owner: Player, instance: Region): Tile { setInstanceLogout(Tables.tile("house_locations.${owner["house_location", ""]}.exit")) set("house_owner", owner.accountName) - tele(roomZone(instance.tile.zone, owner.houseRoomPositions.first()).tile.add(3, 3)) - houseLoading() + // TODO arrive at the exit portal once furniture is added + val garden = owner.houseRoomIds.indexOf("garden").coerceAtLeast(0) + return roomZone(instance.tile.zone, owner.houseRoomPositions[garden]).tile.add(3, 3) } } diff --git a/game/src/main/kotlin/content/skill/construction/RoomCreation.kt b/game/src/main/kotlin/content/skill/construction/RoomCreation.kt index 83d5a8b128..3482595650 100644 --- a/game/src/main/kotlin/content/skill/construction/RoomCreation.kt +++ b/game/src/main/kotlin/content/skill/construction/RoomCreation.kt @@ -4,14 +4,14 @@ import content.entity.player.dialogue.type.choice import content.skill.construction.House.Companion.DUNGEON_LEVEL import content.skill.construction.House.Companion.HOUSE_CENTRE import content.skill.construction.House.Companion.addHouseRoom -import content.skill.construction.House.Companion.emptyRoom import content.skill.construction.House.Companion.houseBase +import content.skill.construction.House.Companion.houseRoomIds import content.skill.construction.House.Companion.houseRoomPositions import content.skill.construction.House.Companion.inOwnHouse +import content.skill.construction.House.Companion.loadHouse +import content.skill.construction.House.Companion.removeHouseRoom import content.skill.construction.House.Companion.roomLevel import content.skill.construction.House.Companion.roomPosition -import content.skill.construction.House.Companion.roomX -import content.skill.construction.House.Companion.roomY import content.skill.construction.House.Companion.roomZone import world.gregs.voidps.engine.Script import world.gregs.voidps.engine.client.message @@ -26,6 +26,7 @@ import world.gregs.voidps.engine.inv.inventory import world.gregs.voidps.engine.inv.remove import world.gregs.voidps.engine.map.zone.DynamicZones import world.gregs.voidps.type.Direction +import world.gregs.voidps.type.Tile class RoomCreation(val dynamicZones: DynamicZones) : Script { init { @@ -35,27 +36,28 @@ class RoomCreation(val dynamicZones: DynamicZones) : Script { return@objectOperate } val zone = target.tile.zone - val direction = when { - target.tile.x == zone.tile.x -> Direction.WEST - target.tile.y == zone.tile.y -> Direction.SOUTH - target.tile.x == zone.tile.x + 7 -> Direction.EAST - else -> Direction.NORTH + // Doors lead to the room on the opposite side to the player + val room = if (tile.zone == zone) zone.add(direction(target.tile)) else zone + val position = roomPosition(base, room) + val index = houseRoomPositions.indexOf(position ?: -1) + if (index != -1) { + removeRoom(houseRoomPositions[index], houseRoomIds[index]) + return@objectOperate } - val position = roomPosition(base, zone.add(direction)) - if (position == null || !withinBounds(position)) { - message("You need a higher Construction level to build a room that far out.") // TODO proper messages + val range = buildRange() + if (room.x - base.x - 1 !in range) { + message("Your house is already at the maximum width.") return@objectOperate } - val rooms = houseRoomPositions - if (rooms.size >= maxRooms()) { - message("You need a higher Construction level to build any more rooms.") // TODO proper messages + if (room.y - base.y - 1 !in range) { + message("Your house is already at the maximum height.") return@objectOperate } - if (position in rooms) { - message("There is already a room there.") // TODO proper messages + if (houseRoomIds.size >= maxRooms()) { + message("You need a higher Construction level to build any more rooms.") // TODO proper messages return@objectOperate } - set("house_preview_position", position) + set("house_preview_position", position ?: return@objectOperate) open("room_creation") } @@ -87,13 +89,25 @@ class RoomCreation(val dynamicZones: DynamicZones) : Script { } /** - * Whether room [position] is inside the area of rooms the players level allows + * Side of the room [tile] is a door on + */ + private fun direction(tile: Tile): Direction { + val zone = tile.zone.tile + return when { + tile.x == zone.x -> Direction.WEST + tile.y == zone.y -> Direction.SOUTH + tile.x == zone.x + 7 -> Direction.EAST + else -> Direction.NORTH + } + } + + /** + * Range of room grid coordinates the players level allows building in * Sizes grow from the south-west, so even sizes extend one further north and east of the [HOUSE_CENTRE] */ - private fun Player.withinBounds(position: Int): Boolean { + private fun Player.buildRange(): IntRange { val size = 3 + sizeLevels.count { levels.get(Skill.Construction) >= it } - val range = HOUSE_CENTRE - (size - 1) / 2..HOUSE_CENTRE + size / 2 - return roomX(position) in range && roomY(position) in range + return HOUSE_CENTRE - (size - 1) / 2..HOUSE_CENTRE + size / 2 } private fun Player.maxRooms(): Int = 20 + roomLevels.count { levels.get(Skill.Construction) >= it } @@ -137,12 +151,32 @@ class RoomCreation(val dynamicZones: DynamicZones) : Script { if (base == null || !inOwnHouse()) { return } - if (!inventory.remove("coins", Rows.get("house_rooms.$room").int("cost"))) { + if (inventory.remove("coins", Rows.get("house_rooms.$room").int("cost"))) { + addHouseRoom(room, position, rotation) + } else { message("You don't have enough coins to build this room.") // TODO proper message - dynamicZones.copy(emptyRoom(position), roomZone(base, position)) + } + loadHouse(base, buildMode = true) + } + + private suspend fun Player.removeRoom(position: Int, room: String) { + // TODO check for the exit portal rather than any garden once furniture is added + if (room == "garden" && houseRoomIds.count { it == "garden" } <= 1) { + message("You can't remove your last garden with a portal.") // TODO proper message return } - addHouseRoom(room, position, rotation) + choice("Remove the ${room.replace('_', ' ')}?") { + option("Yes") { + val base = houseBase() + if (base == null || !inOwnHouse() || position !in houseRoomPositions) { + return@option + } + removeHouseRoom(position) + loadHouse(base, buildMode = true) + message("Room deleted!") + } + option("No") + } } /** @@ -152,9 +186,9 @@ class RoomCreation(val dynamicZones: DynamicZones) : Script { clearWalkTrigger() clear("house_preview_room") clear("house_preview_rotation") - val position: Int = remove("house_preview_position") ?: return + clear("house_preview_position") val base = houseBase() ?: return - dynamicZones.copy(emptyRoom(position), roomZone(base, position)) + loadHouse(base, buildMode = true) } companion object { diff --git a/game/src/test/kotlin/content/quest/free/piratetreasure/PirateTreasureTest.kt b/game/src/test/kotlin/content/quest/free/piratetreasure/PirateTreasureTest.kt index fe9db68bcd..c559a7b8b0 100644 --- a/game/src/test/kotlin/content/quest/free/piratetreasure/PirateTreasureTest.kt +++ b/game/src/test/kotlin/content/quest/free/piratetreasure/PirateTreasureTest.kt @@ -6,6 +6,7 @@ import dialogueOption import equipItem import itemOnObject import itemOption +import kotlinx.coroutines.test.runTest import npcOption import objectOption import org.junit.jupiter.api.Test @@ -330,7 +331,9 @@ class PirateTreasureTest : WorldTest() { player.inventory.add("karamjan_rum") player.inventory.add("karamjan_rum") - Teleport.land(player, "modern") + runTest { + Teleport.land(player, "modern") + } assertEquals(0, player.inventory.count("karamjan_rum")) assertTrue(player.containsMessage("Your Karamjan rum gets broken and spilled.")) diff --git a/game/src/test/kotlin/content/skill/construction/HouseTest.kt b/game/src/test/kotlin/content/skill/construction/HouseTest.kt index 00edd77b84..068d0ba5c4 100644 --- a/game/src/test/kotlin/content/skill/construction/HouseTest.kt +++ b/game/src/test/kotlin/content/skill/construction/HouseTest.kt @@ -27,6 +27,7 @@ import world.gregs.voidps.engine.entity.character.move.tele import world.gregs.voidps.engine.entity.character.player.Player import world.gregs.voidps.engine.entity.character.player.name import world.gregs.voidps.engine.entity.character.player.skill.Skill +import world.gregs.voidps.engine.entity.item.Item import world.gregs.voidps.engine.entity.obj.GameObjects import world.gregs.voidps.engine.get import world.gregs.voidps.engine.inv.add @@ -133,10 +134,98 @@ class HouseTest : WorldTest() { assertEquals(instance, Instances.owner(player.tile)) assertEquals(GROUND_LEVEL, player.tile.level) assertEquals(instance.tile.zone.add(4, 4, GROUND_LEVEL).tile.add(3, 3), player.tile) - assertEquals(dynamicZones.dynamicZone(instance.tile.zone.add(4, 4, DUNGEON_LEVEL)), dynamicZones.dynamicZone(instance.tile.zone.add(5, 5, DUNGEON_LEVEL))) + assertNull(dynamicZones.dynamicZone(instance.tile.zone.add(4, 4, DUNGEON_LEVEL))) assertNull(GameObjects.findOrNull(instance.tile.zone.add(4, 4, 1).tile.add(7, 3)) { it.id.startsWith("door_hotspot") }) } + @Test + fun `Entering a house with missing rooms starts a new house`() { + val player = createPlayer(exit, "owner") + player["house_location"] = "rimmington" + + player.enterPortal(1) + + assertEquals(listOf("garden"), player.houseRoomIds) + assertEquals(listOf(START_ROOM), player.houseRoomPositions) + assertEquals(listOf(0), player.houseRoomRotations) + assertEquals(player.instance()!!.tile.zone.add(4, 4, GROUND_LEVEL).tile.add(3, 3), player.tile) + } + + @Test + fun `Entering a house with mismatched rooms starts a new house`() { + val player = createOwner() + player["house_room_rotations"] = emptyList() + + player.enterPortal(1) + + assertEquals(listOf("garden"), player.houseRoomIds) + assertEquals(listOf(0), player.houseRoomRotations) + } + + @Test + fun `Teleport to house spell`() { + val player = createOwner() + player["house_build_mode"] = true + player.levels.set(Skill.Magic, 40) + player.inventory.add("law_rune") + player.inventory.add("earth_rune") + player.inventory.add("air_rune") + + player.interfaceOption("modern_spellbook", "teleport_to_house", "Cast") + tickIf { player.instance() == null } + + assertTrue(player.inventory.isEmpty()) + assertFalse(player["house_build_mode", false]) + assertTrue(player.inHouseOf(player)) + assertEquals(player.instance()!!.tile.zone.add(4, 4, GROUND_LEVEL).tile.add(3, 3), player.tile) + } + + @Test + fun `Teleport to house tablet`() { + val player = createOwner() + player["house_build_mode"] = true + player.inventory.add("teleport_to_house") + + player.interfaceOption("inventory", "inventory", "Break", 0, Item("teleport_to_house"), 0) + tickIf { player.instance() == null } + + assertTrue(player.inventory.isEmpty()) + assertFalse(player["house_build_mode", false]) + assertTrue(player.inHouseOf(player)) + assertEquals(player.instance()!!.tile.zone.add(4, 4, GROUND_LEVEL).tile.add(3, 3), player.tile) + } + + @Test + fun `Teleporting home from inside your house expels guests`() { + val owner = createOwner() + owner.enterPortal(2) + val guest = createPlayer(exit, "guest") + owner["house_build_mode"] = false + guest.visit(owner) + val previous = owner.instance() + owner.inventory.add("teleport_to_house") + + owner.interfaceOption("inventory", "inventory", "Break", 0, Item("teleport_to_house"), 0) + tickIf { owner.instance() == previous } + tick() + + assertTrue(owner.inHouseOf(owner)) + assertEquals(exit, guest.tile) + } + + @Test + fun `Can't teleport to house without one`() { + val player = createPlayer(exit) + player.inventory.add("teleport_to_house") + + player.interfaceOption("inventory", "inventory", "Break", 0, Item("teleport_to_house"), 0) + tick(5) + + assertEquals(1, player.inventory.count("teleport_to_house")) + assertEquals(exit, player.tile) + assertTrue(player.containsMessage("don't have a house")) + } + @Test fun `Build a room in building mode`() { val player = createOwner() @@ -207,7 +296,7 @@ class HouseTest : WorldTest() { assertEquals(listOf("garden"), player.houseRoomIds) assertEquals(1000, player.inventory.count("coins")) assertEquals(empty, dynamicZones.dynamicZone(base.add(5, 4, GROUND_LEVEL))) - assertNull(player.get("house_room")) + assertNull(player.get("house_preview_room")) } @Test @@ -219,7 +308,130 @@ class HouseTest : WorldTest() { player.buildDoor(5, 4, Direction.EAST) assertFalse(player.hasOpen("room_creation")) - assertTrue(player.containsMessage("that far out")) + assertTrue(player.containsMessage("Your house is already at the maximum width.")) + } + + @Test + fun `Can't build higher than the level allows`() { + val player = createOwner() + player.addHouseRoom("garden", roomPosition(3, 4, GROUND_LEVEL)) + player.enterPortal(2) + + player.buildDoor(4, 5, Direction.NORTH) + + assertFalse(player.hasOpen("room_creation")) + assertTrue(player.containsMessage("Your house is already at the maximum height.")) + } + + @Test + fun `Grass surrounds the rooms`() { + val player = createOwner() + player.levels.set(Skill.Construction, 1) + player.inventory.add("coins", 1000) + player.enterPortal(2) + val base = player.instance()!!.tile.zone + + for (x in 3..5) { + for (y in 3..5) { + assertNotNull(dynamicZones.dynamicZone(base.add(x, y, GROUND_LEVEL))) + } + } + assertNull(dynamicZones.dynamicZone(base.add(6, 4, GROUND_LEVEL))) + assertNull(dynamicZones.dynamicZone(base.add(2, 4, GROUND_LEVEL))) + + player.buildDoor(4, 4, Direction.EAST) + player.interfaceOption("room_creation", "parlour", "Build") + player.dialogueOption("line3") + + assertNotNull(dynamicZones.dynamicZone(base.add(6, 3, GROUND_LEVEL))) + assertNotNull(dynamicZones.dynamicZone(base.add(6, 5, GROUND_LEVEL))) + assertNull(dynamicZones.dynamicZone(base.add(7, 4, GROUND_LEVEL))) + } + + @Test + fun `Remove a room through its door`() { + val player = createOwner() + player.addHouseRoom("parlour", roomPosition(4, 3, GROUND_LEVEL)) + player.enterPortal(2) + val base = player.instance()!!.tile.zone + + player.buildDoor(4, 4, Direction.EAST) + assertFalse(player.hasOpen("room_creation")) + player.dialogueOption("line1") + + assertEquals(listOf("garden"), player.houseRoomIds) + assertEquals(listOf(START_ROOM), player.houseRoomPositions) + assertEquals(listOf(0), player.houseRoomRotations) + assertTrue(player.containsMessage("Room deleted!")) + assertNull(dynamicZones.dynamicZone(base.add(6, 4, GROUND_LEVEL))) + } + + @Test + fun `Doors from outside a room remove that room`() { + val player = createOwner() + player.addHouseRoom("parlour", roomPosition(4, 3, GROUND_LEVEL)) + player.enterPortal(2) + val parlour = player.instance()!!.tile.zone.add(5, 4, GROUND_LEVEL).tile + + player.tele(parlour.add(8, 3)) + player.objectOption(GameObjects.find(parlour.add(7, 3)) { it.id.startsWith("door_hotspot") }, "Build") + tick(5) + player.dialogueOption("line1") + + assertEquals(listOf("garden"), player.houseRoomIds) + } + + @Test + fun `Can't remove the last garden`() { + val player = createOwner() + player.addHouseRoom("parlour", roomPosition(4, 3, GROUND_LEVEL)) + player.enterPortal(2) + val parlour = player.instance()!!.tile.zone.add(5, 4, GROUND_LEVEL).tile + + player.tele(parlour.add(0, 3)) + player.objectOption(GameObjects.find(parlour.add(-1, 3)) { it.id.startsWith("door_hotspot") }, "Build") + tick(5) + + assertNull(player.dialogue) + assertTrue(player.containsMessage("last garden")) + assertEquals(listOf("garden", "parlour"), player.houseRoomIds) + } + + @Test + fun `Remove a garden when another remains`() { + val player = createOwner() + player.addHouseRoom("garden", roomPosition(4, 3, GROUND_LEVEL)) + player.enterPortal(2) + + player.buildDoor(4, 4, Direction.EAST) + player.dialogueOption("line1") + + assertEquals(listOf("garden"), player.houseRoomIds) + assertEquals(listOf(START_ROOM), player.houseRoomPositions) + } + + @Test + fun `Arrive in the garden even if it isn't the first room`() { + val player = createPlayer(exit, "owner") + player["house_location"] = "rimmington" + player.addHouseRoom("parlour", START_ROOM) + player.addHouseRoom("garden", roomPosition(4, 3, GROUND_LEVEL)) + + player.enterPortal(1) + + assertEquals(player.instance()!!.tile.zone.add(5, 4, GROUND_LEVEL).tile.add(3, 3), player.tile) + } + + @Test + fun `Choosing not to remove a room keeps it`() { + val player = createOwner() + player.addHouseRoom("parlour", roomPosition(4, 3, GROUND_LEVEL)) + player.enterPortal(2) + + player.buildDoor(4, 4, Direction.EAST) + player.dialogueOption("line2") + + assertEquals(listOf("garden", "parlour"), player.houseRoomIds) } @Test @@ -232,6 +444,7 @@ class HouseTest : WorldTest() { player.buildDoor(3, 4, Direction.WEST) assertFalse(player.hasOpen("room_creation")) + assertTrue(player.containsMessage("maximum width")) player.buildDoor(5, 4, Direction.EAST) assertTrue(player.hasOpen("room_creation")) From 5a7a37394a4c2d4563c8e0cff4a215f8f5a97e40 Mon Sep 17 00:00:00 2001 From: GregHib Date: Thu, 1 Oct 2026 22:41:10 +0100 Subject: [PATCH 06/28] Add room furniture hotspots --- .../content/skill/construction/House.kt | 83 ++++++++++++++++++- .../content/skill/construction/HousePortal.kt | 35 +++++++- .../skill/construction/RoomCreation.kt | 6 +- .../content/skill/construction/HouseTest.kt | 82 ++++++++++++++++-- 4 files changed, 192 insertions(+), 14 deletions(-) diff --git a/game/src/main/kotlin/content/skill/construction/House.kt b/game/src/main/kotlin/content/skill/construction/House.kt index 7c588ad6d9..aac52b3729 100644 --- a/game/src/main/kotlin/content/skill/construction/House.kt +++ b/game/src/main/kotlin/content/skill/construction/House.kt @@ -7,11 +7,14 @@ import world.gregs.voidps.engine.Script import world.gregs.voidps.engine.client.ui.close import world.gregs.voidps.engine.client.ui.hasOpen import world.gregs.voidps.engine.client.ui.open +import world.gregs.voidps.engine.data.definition.Rows import world.gregs.voidps.engine.data.definition.Tables import world.gregs.voidps.engine.entity.character.player.Player import world.gregs.voidps.engine.entity.character.player.Players +import world.gregs.voidps.engine.entity.obj.GameObject import world.gregs.voidps.engine.entity.obj.GameObjects import world.gregs.voidps.engine.entity.obj.remove +import world.gregs.voidps.engine.entity.obj.replace import world.gregs.voidps.engine.get import world.gregs.voidps.engine.map.instance.Instances import world.gregs.voidps.engine.map.zone.DynamicZones @@ -59,6 +62,15 @@ class House : Script { val Player.houseRoomRotations: List get() = get("house_room_rotations") ?: emptyList() + val Player.houseFurnitureRooms: List + get() = get("house_furniture_rooms") ?: emptyList() + + val Player.houseFurnitureHotspots: List + get() = get("house_furniture_hotspots") ?: emptyList() + + val Player.houseFurnitureIds: List + get() = get("house_furniture_ids") ?: emptyList() + fun Player.addHouseRoom(id: String, position: Int, rotation: Int = 0) { set("house_room_ids", houseRoomIds + id) set("house_room_positions", houseRoomPositions + position) @@ -66,12 +78,15 @@ class House : Script { } /** - * Replaces any existing rooms with a new house containing just the starting garden + * Replaces any existing rooms with a new house containing just the starting garden and its exit portal */ fun Player.newHouse() { set("house_room_ids", listOf("garden")) set("house_room_positions", listOf(START_ROOM)) set("house_room_rotations", listOf(0)) + set("house_furniture_rooms", listOf(START_ROOM)) + set("house_furniture_hotspots", listOf("garden_centrepiece_space")) + set("house_furniture_ids", listOf("exit_portal")) } /** @@ -90,6 +105,67 @@ class House : Script { set("house_room_ids", houseRoomIds.filterIndexed { i, _ -> i != index }) set("house_room_positions", houseRoomPositions.filterIndexed { i, _ -> i != index }) set("house_room_rotations", houseRoomRotations.filterIndexed { i, _ -> i != index }) + removeHouseFurniture(position) + } + + /** + * The furniture built on [hotspot] in the room at [position] + */ + fun Player.houseFurniture(position: Int, hotspot: String): String? { + val rooms = houseFurnitureRooms + val hotspots = houseFurnitureHotspots + val index = rooms.indices.firstOrNull { rooms[it] == position && hotspots[it] == hotspot } ?: return null + return houseFurnitureIds[index] + } + + /** + * Number of exit portals in the house, or only those in the room at [position] + */ + fun Player.exitPortals(position: Int? = null): Int { + val rooms = houseFurnitureRooms + val ids = houseFurnitureIds + return ids.indices.count { ids[it] == "exit_portal" && (position == null || rooms[it] == position) } + } + + fun Player.addHouseFurniture(position: Int, hotspot: String, id: String) { + set("house_furniture_rooms", houseFurnitureRooms + position) + set("house_furniture_hotspots", houseFurnitureHotspots + hotspot) + set("house_furniture_ids", houseFurnitureIds + id) + } + + /** + * Removes the furniture built in the room at [position], or only that built on [hotspot] + */ + fun Player.removeHouseFurniture(position: Int, hotspot: String? = null) { + val rooms = houseFurnitureRooms + val hotspots = houseFurnitureHotspots + val ids = houseFurnitureIds + val keep = rooms.indices.filter { rooms[it] != position || (hotspot != null && hotspots[it] != hotspot) } + set("house_furniture_rooms", keep.map { rooms[it] }) + set("house_furniture_hotspots", keep.map { hotspots[it] }) + set("house_furniture_ids", keep.map { ids[it] }) + } + + /** + * The hotspot [obj] belongs to, hotspots in a group share the group name + */ + fun hotspot(obj: GameObject): String? { + val row = Rows.getOrNull("house_hotspots.${obj.id}") ?: return null + return row.stringOrNull("group") ?: obj.id + } + + /** + * Replaces each piece of [hotspot] in [zone] with the matching piece of [furniture] + */ + fun placeFurniture(zone: Zone, hotspot: String, furniture: String) { + val objects = Tables.objList("house_furniture.$furniture.objects") + for (tile in zone.toCuboid()) { + for (obj in GameObjects.at(tile)) { + if (hotspot(obj) == hotspot) { + obj.replace(objects[Tables.int("house_hotspots.${obj.id}.piece")]) + } + } + } } fun roomPosition(x: Int, y: Int, level: Int) = x + y * ROOM_GRID + level * ROOM_GRID * ROOM_GRID @@ -169,6 +245,11 @@ class House : Script { } } dynamicZones.copy(entries) + val furniture = houseFurnitureIds + val hotspots = houseFurnitureHotspots + for ((index, position) in houseFurnitureRooms.withIndex()) { + placeFurniture(roomZone(base, position), hotspots[index], furniture[index]) + } if (buildMode) { return } diff --git a/game/src/main/kotlin/content/skill/construction/HousePortal.kt b/game/src/main/kotlin/content/skill/construction/HousePortal.kt index 9d0831b219..55a9594c06 100644 --- a/game/src/main/kotlin/content/skill/construction/HousePortal.kt +++ b/game/src/main/kotlin/content/skill/construction/HousePortal.kt @@ -7,6 +7,8 @@ import content.quest.joinInstance import content.quest.setInstanceLogout import content.quest.smallInstance import content.skill.construction.House.Companion.hasHouse +import content.skill.construction.House.Companion.houseFurnitureIds +import content.skill.construction.House.Companion.houseFurnitureRooms import content.skill.construction.House.Companion.houseLoading import content.skill.construction.House.Companion.houseRoomIds import content.skill.construction.House.Companion.houseRoomPositions @@ -25,8 +27,7 @@ import world.gregs.voidps.engine.entity.character.move.tele import world.gregs.voidps.engine.entity.character.player.Player import world.gregs.voidps.engine.entity.character.player.Players import world.gregs.voidps.engine.entity.character.player.Teleport -import world.gregs.voidps.engine.map.instance.Instances -import world.gregs.voidps.engine.queue.longQueue +import world.gregs.voidps.engine.entity.obj.GameObjects import world.gregs.voidps.type.Region import world.gregs.voidps.type.Tile @@ -51,6 +52,20 @@ class HousePortal : Script { } } + objectOperate("Enter", "exit_portal") { + leaveHouse() + } + + objectOperate("Lock", "exit_portal") { + if (!inOwnHouse()) { + message("You can only lock your own house.") // TODO proper message + return@objectOperate + } + val locked = !get("house_locked", false) + set("house_locked", locked) + message(if (locked) "Your house is now locked to all visitors." else "Visitors can now enter your house.") // TODO proper messages + } + interfaceOption("Cast", "modern_spellbook:teleport_to_house") { teleportHome("modern", it.component, xp = Tables.int("spells.teleport_to_house.xp") / 10.0) } @@ -119,6 +134,10 @@ class HousePortal : Script { message("The owner currently has build mode turned on.") // TODO proper messages return } + if (owner["house_locked", false]) { + message("That player has locked their house.") // TODO proper messages + return + } val instance = owner.instance() ?: return set("instance_logout", true) joinInstance(instance) @@ -132,8 +151,16 @@ class HousePortal : Script { private fun Player.arrival(owner: Player, instance: Region): Tile { setInstanceLogout(Tables.tile("house_locations.${owner["house_location", ""]}.exit")) set("house_owner", owner.accountName) - // TODO arrive at the exit portal once furniture is added + val base = instance.tile.zone + val portal = owner.houseFurnitureIds.indexOf("exit_portal") + if (portal != -1) { + val zone = roomZone(base, owner.houseFurnitureRooms[portal]) + val obj = zone.toCuboid().firstNotNullOfOrNull { GameObjects.findOrNull(it, "exit_portal") } + if (obj != null) { + return obj.tile.add(0, -1) + } + } val garden = owner.houseRoomIds.indexOf("garden").coerceAtLeast(0) - return roomZone(instance.tile.zone, owner.houseRoomPositions[garden]).tile.add(3, 3) + return roomZone(base, owner.houseRoomPositions[garden]).tile.add(3, 3) } } diff --git a/game/src/main/kotlin/content/skill/construction/RoomCreation.kt b/game/src/main/kotlin/content/skill/construction/RoomCreation.kt index 3482595650..0584d158e5 100644 --- a/game/src/main/kotlin/content/skill/construction/RoomCreation.kt +++ b/game/src/main/kotlin/content/skill/construction/RoomCreation.kt @@ -4,6 +4,7 @@ import content.entity.player.dialogue.type.choice import content.skill.construction.House.Companion.DUNGEON_LEVEL import content.skill.construction.House.Companion.HOUSE_CENTRE import content.skill.construction.House.Companion.addHouseRoom +import content.skill.construction.House.Companion.exitPortals import content.skill.construction.House.Companion.houseBase import content.skill.construction.House.Companion.houseRoomIds import content.skill.construction.House.Companion.houseRoomPositions @@ -160,9 +161,8 @@ class RoomCreation(val dynamicZones: DynamicZones) : Script { } private suspend fun Player.removeRoom(position: Int, room: String) { - // TODO check for the exit portal rather than any garden once furniture is added - if (room == "garden" && houseRoomIds.count { it == "garden" } <= 1) { - message("You can't remove your last garden with a portal.") // TODO proper message + if (exitPortals(position) > 0 && exitPortals() <= exitPortals(position)) { + message("Your house must have at least one exit portal.") // TODO proper message return } choice("Remove the ${room.replace('_', ' ')}?") { diff --git a/game/src/test/kotlin/content/skill/construction/HouseTest.kt b/game/src/test/kotlin/content/skill/construction/HouseTest.kt index 068d0ba5c4..7ddecd3ebe 100644 --- a/game/src/test/kotlin/content/skill/construction/HouseTest.kt +++ b/game/src/test/kotlin/content/skill/construction/HouseTest.kt @@ -6,6 +6,7 @@ import content.quest.instance import content.skill.construction.House.Companion.DUNGEON_LEVEL import content.skill.construction.House.Companion.GROUND_LEVEL import content.skill.construction.House.Companion.START_ROOM +import content.skill.construction.House.Companion.addHouseFurniture import content.skill.construction.House.Companion.addHouseRoom import content.skill.construction.House.Companion.houseRoomIds import content.skill.construction.House.Companion.houseRoomPositions @@ -22,12 +23,15 @@ import walk import world.gregs.voidps.engine.client.ui.dialogue import world.gregs.voidps.engine.client.ui.hasOpen import world.gregs.voidps.engine.client.ui.open +import world.gregs.voidps.engine.data.config.VariableDefinition.Companion.persist +import world.gregs.voidps.engine.data.definition.VariableDefinitions import world.gregs.voidps.engine.entity.Despawn import world.gregs.voidps.engine.entity.character.move.tele import world.gregs.voidps.engine.entity.character.player.Player import world.gregs.voidps.engine.entity.character.player.name import world.gregs.voidps.engine.entity.character.player.skill.Skill import world.gregs.voidps.engine.entity.item.Item +import world.gregs.voidps.engine.entity.obj.GameObject import world.gregs.voidps.engine.entity.obj.GameObjects import world.gregs.voidps.engine.get import world.gregs.voidps.engine.inv.add @@ -56,6 +60,7 @@ class HouseTest : WorldTest() { val player = createPlayer(exit, name) player["house_location"] = "rimmington" player.addHouseRoom("garden", START_ROOM) + player.addHouseFurniture(START_ROOM, "garden_centrepiece_space", "exit_portal") return player } @@ -133,7 +138,7 @@ class HouseTest : WorldTest() { assertNotNull(instance) assertEquals(instance, Instances.owner(player.tile)) assertEquals(GROUND_LEVEL, player.tile.level) - assertEquals(instance.tile.zone.add(4, 4, GROUND_LEVEL).tile.add(3, 3), player.tile) + assertEquals(instance.tile.zone.add(4, 4, GROUND_LEVEL).tile.add(3, 2), player.tile) assertNull(dynamicZones.dynamicZone(instance.tile.zone.add(4, 4, DUNGEON_LEVEL))) assertNull(GameObjects.findOrNull(instance.tile.zone.add(4, 4, 1).tile.add(7, 3)) { it.id.startsWith("door_hotspot") }) } @@ -148,7 +153,7 @@ class HouseTest : WorldTest() { assertEquals(listOf("garden"), player.houseRoomIds) assertEquals(listOf(START_ROOM), player.houseRoomPositions) assertEquals(listOf(0), player.houseRoomRotations) - assertEquals(player.instance()!!.tile.zone.add(4, 4, GROUND_LEVEL).tile.add(3, 3), player.tile) + assertEquals(player.instance()!!.tile.zone.add(4, 4, GROUND_LEVEL).tile.add(3, 2), player.tile) } @Test @@ -177,7 +182,7 @@ class HouseTest : WorldTest() { assertTrue(player.inventory.isEmpty()) assertFalse(player["house_build_mode", false]) assertTrue(player.inHouseOf(player)) - assertEquals(player.instance()!!.tile.zone.add(4, 4, GROUND_LEVEL).tile.add(3, 3), player.tile) + assertEquals(player.instance()!!.tile.zone.add(4, 4, GROUND_LEVEL).tile.add(3, 2), player.tile) } @Test @@ -192,7 +197,7 @@ class HouseTest : WorldTest() { assertTrue(player.inventory.isEmpty()) assertFalse(player["house_build_mode", false]) assertTrue(player.inHouseOf(player)) - assertEquals(player.instance()!!.tile.zone.add(4, 4, GROUND_LEVEL).tile.add(3, 3), player.tile) + assertEquals(player.instance()!!.tile.zone.add(4, 4, GROUND_LEVEL).tile.add(3, 2), player.tile) } @Test @@ -382,7 +387,7 @@ class HouseTest : WorldTest() { } @Test - fun `Can't remove the last garden`() { + fun `Can't remove the room with the last exit portal`() { val player = createOwner() player.addHouseRoom("parlour", roomPosition(4, 3, GROUND_LEVEL)) player.enterPortal(2) @@ -393,7 +398,7 @@ class HouseTest : WorldTest() { tick(5) assertNull(player.dialogue) - assertTrue(player.containsMessage("last garden")) + assertTrue(player.containsMessage("exit portal")) assertEquals(listOf("garden", "parlour"), player.houseRoomIds) } @@ -567,6 +572,71 @@ class HouseTest : WorldTest() { assertTrue(owner.inHouseOf(owner)) } + private fun Player.exitPortal(): GameObject { + val zone = roomZone(instance()!!.tile.zone, START_ROOM) + return zone.toCuboid().firstNotNullOf { GameObjects.findOrNull(it, "exit_portal") } + } + + @Test + fun `Leave a house through the exit portal`() { + val owner = createOwner() + owner.enterPortal(1) + val guest = createPlayer(exit, "guest") + guest.visit(owner) + + guest.objectOption(guest.exitPortal(), "Enter") + tick(5) + + assertEquals(exit, guest.tile) + assertNull(guest.instance()) + assertTrue(owner.inHouseOf(owner)) + } + + @Test + fun `Locked houses can't be visited`() { + val owner = createOwner() + owner.enterPortal(1) + + owner.objectOption(owner.exitPortal(), "Lock") + tick(5) + val guest = createPlayer(exit, "guest") + guest.visit(owner) + + assertTrue(owner["house_locked", false]) + assertTrue(VariableDefinitions.get("house_locked").persist) + assertEquals(exit, guest.tile) + assertTrue(guest.containsMessage("locked")) + } + + @Test + fun `Unlock a house`() { + val owner = createOwner() + owner["house_locked"] = true + owner.enterPortal(1) + + owner.objectOption(owner.exitPortal(), "Lock") + tick(5) + val guest = createPlayer(exit, "guest") + guest.visit(owner) + + assertFalse(owner["house_locked", false]) + assertTrue(guest.inHouseOf(owner)) + } + + @Test + fun `Guests can't lock a house`() { + val owner = createOwner() + owner.enterPortal(1) + val guest = createPlayer(exit, "guest") + guest.visit(owner) + + guest.objectOption(guest.exitPortal(), "Lock") + tick(5) + + assertFalse(owner["house_locked", false]) + assertFalse(guest.contains("house_locked")) + } + @Test fun `Building mode expels guests`() { val owner = createOwner() From bbf114ba9729e9c4944860aa04a62624bf76e0f7 Mon Sep 17 00:00:00 2001 From: GregHib Date: Thu, 1 Oct 2026 23:05:31 +0100 Subject: [PATCH 07/28] Clear instead of reset dynamic zones --- .../voidps/engine/entity/obj/GameObjects.kt | 11 ++++++++ .../voidps/engine/map/zone/DynamicZones.kt | 4 +-- .../engine/entity/obj/GameObjectsTest.kt | 21 ++++++++++++++ .../engine/map/zone/DynamicZonesTest.kt | 28 +++++++++++++++++++ 4 files changed, 62 insertions(+), 2 deletions(-) diff --git a/engine/src/main/kotlin/world/gregs/voidps/engine/entity/obj/GameObjects.kt b/engine/src/main/kotlin/world/gregs/voidps/engine/entity/obj/GameObjects.kt index 791d0b1b1e..1bc9fc0fb8 100644 --- a/engine/src/main/kotlin/world/gregs/voidps/engine/entity/obj/GameObjects.kt +++ b/engine/src/main/kotlin/world/gregs/voidps/engine/entity/obj/GameObjects.kt @@ -313,6 +313,17 @@ object GameObjects : ZoneBatchUpdates.Sender { return GameObject(id(value), tile.x, tile.y, tile.level, shape(value), rotation(value)) } + /** + * Get the original object which [obj] is replacing, if any + */ + fun original(obj: GameObject): GameObject? { + val value = map[obj] + if (!replaced(value) || value == REPLACED || replacements[obj.index] != obj.value(replaced = true)) { + return null + } + return GameObject(id(value), obj.x, obj.y, obj.level, shape(value), rotation(value)) + } + /** * Checks if an object exists */ diff --git a/engine/src/main/kotlin/world/gregs/voidps/engine/map/zone/DynamicZones.kt b/engine/src/main/kotlin/world/gregs/voidps/engine/map/zone/DynamicZones.kt index f3397474ac..21092b2db5 100644 --- a/engine/src/main/kotlin/world/gregs/voidps/engine/map/zone/DynamicZones.kt +++ b/engine/src/main/kotlin/world/gregs/voidps/engine/map/zone/DynamicZones.kt @@ -46,7 +46,7 @@ class DynamicZones( */ fun copy(entries: List>) { for ((_, to) in entries) { - GameObjects.reset(to) + GameObjects.clear(to) Collisions.clear(to) } for ((from, to, rotation) in entries) { @@ -91,7 +91,7 @@ class DynamicZones( */ fun clear(zone: Zone) { zones.remove(zone.id) - GameObjects.reset(zone) + GameObjects.clear(zone) Collisions.clear(zone) definitions.loadZone(zone, zone, 0) for (region in zone.toCuboid(radius = 3).toRegions()) { diff --git a/engine/src/test/kotlin/world/gregs/voidps/engine/entity/obj/GameObjectsTest.kt b/engine/src/test/kotlin/world/gregs/voidps/engine/entity/obj/GameObjectsTest.kt index 9d4e77d2cf..c1c2b5109b 100644 --- a/engine/src/test/kotlin/world/gregs/voidps/engine/entity/obj/GameObjectsTest.kt +++ b/engine/src/test/kotlin/world/gregs/voidps/engine/entity/obj/GameObjectsTest.kt @@ -253,6 +253,27 @@ class GameObjectsTest : KoinMock() { } } + @Test + fun `Get the original object under a replacement`() { + val original = GameObject(id = 5678, x = 100, y = 100, level = 0, shape = ObjectShape.CENTRE_PIECE_STRAIGHT, rotation = 2) + GameObjects.set(original.intId, original.x, original.y, original.level, original.shape, original.rotation, ObjectDefinition.EMPTY) + assertNull(GameObjects.original(original)) + + val replacement = GameObjects.replace(original, "test", collision = false) + assertEquals(original, GameObjects.original(replacement)) + + GameObjects.remove(replacement, collision = false) + assertNull(GameObjects.original(original)) + } + + @Test + fun `No original object under an added object`() { + val obj = GameObject(id = 1234, x = 10, y = 10, level = 0, shape = ObjectShape.CENTRE_PIECE_STRAIGHT, rotation = 1) + GameObjects.add(obj) + + assertNull(GameObjects.original(obj)) + } + @Test fun `Replacing a replacement is undone back to the original`() { val original = GameObject(id = 5678, x = 100, y = 100, level = 0, shape = ObjectShape.CENTRE_PIECE_STRAIGHT, rotation = 2) diff --git a/engine/src/test/kotlin/world/gregs/voidps/engine/map/zone/DynamicZonesTest.kt b/engine/src/test/kotlin/world/gregs/voidps/engine/map/zone/DynamicZonesTest.kt index 805236b398..af1bc6abf5 100644 --- a/engine/src/test/kotlin/world/gregs/voidps/engine/map/zone/DynamicZonesTest.kt +++ b/engine/src/test/kotlin/world/gregs/voidps/engine/map/zone/DynamicZonesTest.kt @@ -6,7 +6,11 @@ import org.junit.jupiter.api.Assertions.* import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.rsmod.game.pathfinder.flag.CollisionFlag +import world.gregs.voidps.cache.definition.data.ObjectDefinition import world.gregs.voidps.engine.data.definition.MapDefinitions +import world.gregs.voidps.engine.entity.obj.GameObjects +import world.gregs.voidps.engine.entity.obj.ObjectLayer +import world.gregs.voidps.engine.entity.obj.ObjectShape import world.gregs.voidps.engine.map.collision.Collisions import world.gregs.voidps.engine.map.collision.check import world.gregs.voidps.type.Region @@ -21,6 +25,7 @@ internal class DynamicZonesTest { fun setup() { definitions = mockk(relaxed = true) zones = DynamicZones(definitions) + GameObjects.clear() } @Test @@ -87,6 +92,29 @@ internal class DynamicZonesTest { ) } + @Test + fun `Copying over a zone removes its previous objects`() { + val zone = Zone(8, 8) + GameObjects.storeUnused = true + GameObjects.set(123, zone.tile.x, zone.tile.y, zone.level, ObjectShape.CENTRE_PIECE_STRAIGHT, 0, ObjectDefinition.EMPTY) + + zones.copy(Zone(4, 4), zone, rotation = 1) + + assertNull(GameObjects.getLayer(zone.tile, ObjectLayer.GROUND)) + } + + @Test + fun `Clearing a zone removes its copied objects`() { + val zone = Zone(8, 8) + zones.copy(Zone(4, 4), zone) + GameObjects.storeUnused = true + GameObjects.set(123, zone.tile.x, zone.tile.y, zone.level, ObjectShape.CENTRE_PIECE_STRAIGHT, 0, ObjectDefinition.EMPTY) + + zones.clear(zone) + + assertNull(GameObjects.getLayer(zone.tile, ObjectLayer.GROUND)) + } + @Test fun `Copy one region to another`() { val from = Region(8, 8) From faf898d461b880c60bfec227d0626613ebcae5e1 Mon Sep 17 00:00:00 2001 From: GregHib Date: Thu, 1 Oct 2026 23:07:22 +0100 Subject: [PATCH 08/28] Add furniture creation --- data/entity/obj/chairs.objs.toml | 28 -- .../construction/construction.anims.toml | 5 + .../construction/construction.ifaces.toml | 140 ++++++++ .../skill/construction/construction.objs.toml | 130 ++++++++ .../construction/construction.tables.toml | 227 +++++++++++++ .../construction/construction.varbits.toml | 1 - .../construction/construction.varps.toml | 27 ++ .../skill/construction/construction.vars.toml | 13 + .../skill/construction/FurnitureCreation.kt | 173 ++++++++++ .../construction/FurnitureCreationTest.kt | 314 ++++++++++++++++++ 10 files changed, 1029 insertions(+), 29 deletions(-) create mode 100644 data/skill/construction/construction.anims.toml create mode 100644 data/skill/construction/construction.varps.toml create mode 100644 game/src/main/kotlin/content/skill/construction/FurnitureCreation.kt create mode 100644 game/src/test/kotlin/content/skill/construction/FurnitureCreationTest.kt diff --git a/data/entity/obj/chairs.objs.toml b/data/entity/obj/chairs.objs.toml index d5140eca29..aac8008ef8 100644 --- a/data/entity/obj/chairs.objs.toml +++ b/data/entity/obj/chairs.objs.toml @@ -234,34 +234,6 @@ examine = "The ideal thing to sit on." id = 12888 examine = "This needs dusting before I'll sit on it." -[chair_53] -id = 13581 -examine = "It's not the best chair but you think it would take your weight." - -[chair_54] -id = 13582 -examine = "The ideal thing to sit on." - -[chair_55] -id = 13583 -examine = "A comfortable seat." - -[chair_56] -id = 13584 -examine = "A comfortable seat." - -[chair_57] -id = 13585 -examine = "A comfortable seat." - -[chair_58] -id = 13586 -examine = "A comfortable seat." - -[chair_59] -id = 13587 -examine = "A comfortable seat." - [fishing_contest_chair] id = 14050 examine = "For sitting." diff --git a/data/skill/construction/construction.anims.toml b/data/skill/construction/construction.anims.toml new file mode 100644 index 0000000000..fa282e66a0 --- /dev/null +++ b/data/skill/construction/construction.anims.toml @@ -0,0 +1,5 @@ +[construction_build] +id = 3683 + +[construction_remove] +id = 3685 diff --git a/data/skill/construction/construction.ifaces.toml b/data/skill/construction/construction.ifaces.toml index 7b3ef76f92..d47a4dad77 100644 --- a/data/skill/construction/construction.ifaces.toml +++ b/data/skill/construction/construction.ifaces.toml @@ -93,3 +93,143 @@ id = 114 [.treasure_room] id = 115 + +[furniture_creation] +id = 396 +type = "main_screen" + +[.items] +id = 11 +inventory = "poh_furniture_menu_inv" +width = 2 +height = 4 +options = { Build = 0 } + +[.name_1] +id = 14 + +[.material_1_1] +id = 15 + +[.material_1_2] +id = 16 + +[.material_1_3] +id = 17 + +[.material_1_4] +id = 18 + +[.name_2] +id = 19 + +[.material_2_1] +id = 20 + +[.material_2_2] +id = 21 + +[.material_2_3] +id = 22 + +[.material_2_4] +id = 23 + +[.name_3] +id = 24 + +[.material_3_1] +id = 25 + +[.material_3_2] +id = 26 + +[.material_3_3] +id = 27 + +[.material_3_4] +id = 28 + +[.name_4] +id = 29 + +[.material_4_1] +id = 30 + +[.material_4_2] +id = 31 + +[.material_4_3] +id = 32 + +[.material_4_4] +id = 33 + +[.name_5] +id = 34 + +[.material_5_1] +id = 35 + +[.material_5_2] +id = 36 + +[.material_5_3] +id = 37 + +[.material_5_4] +id = 38 + +[.name_6] +id = 39 + +[.material_6_1] +id = 40 + +[.material_6_2] +id = 41 + +[.material_6_3] +id = 42 + +[.material_6_4] +id = 43 + +[.name_7] +id = 44 + +[.material_7_1] +id = 45 + +[.material_7_2] +id = 46 + +[.material_7_3] +id = 47 + +[.material_7_4] +id = 48 + +[.level_1] +id = 56 + +[.level_2] +id = 57 + +[.level_3] +id = 58 + +[.level_4] +id = 59 + +[.level_5] +id = 60 + +[.level_6] +id = 61 + +[.level_7] +id = 62 + +[.close] +id = 63 diff --git a/data/skill/construction/construction.objs.toml b/data/skill/construction/construction.objs.toml index 0419c9546e..f7dc77953e 100644 --- a/data/skill/construction/construction.objs.toml +++ b/data/skill/construction/construction.objs.toml @@ -54,3 +54,133 @@ id = 15316 [door_hotspot_13] id = 15317 + +[garden_centrepiece_space] +id = 15361 + +[parlour_chair_space] +id = 15410 + +[parlour_chair_space_2] +id = 15411 + +[parlour_chair_space_3] +id = 15412 + +[parlour_rug_space_corner] +id = 15415 + +[parlour_rug_space_side] +id = 15414 + +[parlour_rug_space_middle] +id = 15413 + +[parlour_bookcase_space] +id = 15416 + +[parlour_bookcase_space_2] +id = 15417 + +[parlour_fireplace_space] +id = 15418 + +[parlour_curtain_space] +id = 15419 + +[exit_portal] +id = 13405 + +[decorative_rock] +id = 13406 + +[pond] +id = 13407 + +[imp_statue] +id = 13408 + +[dungeon_entrance] +id = 13409 + +[crude_wooden_chair] +id = 13581 +examine = "It's not the best chair but you think it would take your weight." + +[wooden_chair] +id = 13582 +examine = "The ideal thing to sit on." + +[rocking_chair] +id = 13583 +examine = "A comfortable seat." + +[oak_chair] +id = 13584 +examine = "A comfortable seat." + +[oak_armchair] +id = 13585 +examine = "A comfortable seat." + +[teak_armchair] +id = 13586 +examine = "A comfortable seat." + +[mahogany_armchair] +id = 13587 +examine = "A comfortable seat." + +[brown_rug_corner] +id = 13588 + +[brown_rug_side] +id = 13589 + +[brown_rug_middle] +id = 13590 + +[rug_corner] +id = 13591 + +[rug_side] +id = 13592 + +[rug_middle] +id = 13593 + +[opulent_rug_corner] +id = 13594 + +[opulent_rug_side] +id = 13595 + +[opulent_rug_middle] +id = 13596 + +[wooden_bookcase] +id = 13597 + +[oak_bookcase] +id = 13598 + +[mahogany_bookcase] +id = 13599 + +[torn_curtains] +id = 13603 + +[curtains] +id = 13604 + +[opulent_curtains] +id = 13605 + +[clay_fireplace] +id = 13609 + +[stone_fireplace] +id = 13611 + +[marble_fireplace] +id = 13613 diff --git a/data/skill/construction/construction.tables.toml b/data/skill/construction/construction.tables.toml index 3630096f2f..5afb9f4f89 100644 --- a/data/skill/construction/construction.tables.toml +++ b/data/skill/construction/construction.tables.toml @@ -153,3 +153,230 @@ exit = { x = 2757, y = 3178 } [.yanille] exit = { x = 2544, y = 3096 } + +# Furniture which can be built on each hotspot, hotspots with the same group are built and removed together +[house_hotspots] +row_id = "obj" +furniture = "list" +group = "string" +piece = "int" +clone = "clone" + +[.garden_centrepiece_space] +furniture = ["exit_portal", "decorative_rock", "pond", "imp_statue", "dungeon_entrance"] + +[.parlour_chair_space] +furniture = ["crude_wooden_chair", "wooden_chair", "rocking_chair", "oak_chair", "oak_armchair", "teak_armchair", "mahogany_armchair"] + +[.parlour_chair_space_2] +clone = "parlour_chair_space" + +[.parlour_chair_space_3] +clone = "parlour_chair_space" + +[.parlour_rug_space_corner] +furniture = ["brown_rug", "rug", "opulent_rug"] +group = "parlour_rug" + +[.parlour_rug_space_side] +clone = "parlour_rug_space_corner" +piece = 1 + +[.parlour_rug_space_middle] +clone = "parlour_rug_space_corner" +piece = 2 + +[.parlour_bookcase_space] +furniture = ["wooden_bookcase", "oak_bookcase", "mahogany_bkcase"] + +[.parlour_bookcase_space_2] +clone = "parlour_bookcase_space" + +[.parlour_fireplace_space] +furniture = ["clay_fireplace", "stone_fireplace", "marble_fireplace"] + +[.parlour_curtain_space] +furniture = ["torn_curtains", "curtains", "opulent_curtains"] + +# Furniture keyed by build menu item, objects are placed on the hotspot of the same piece +[house_furniture] +row_id = "item" +level = "int" +xp = "int" +materials = "list" +amounts = "list" +nails = "int" +objects = "list" + +[.exit_portal] +level = 1 +xp = 1000 +materials = ["iron_bar"] +amounts = [10] +objects = ["exit_portal"] + +[.decorative_rock] +level = 5 +xp = 1000 +materials = ["limestone_brick"] +amounts = [5] +objects = ["decorative_rock"] + +[.pond] +level = 10 +xp = 1000 +materials = ["soft_clay"] +amounts = [10] +objects = ["pond"] + +[.imp_statue] +level = 15 +xp = 1500 +materials = ["limestone_brick", "soft_clay"] +amounts = [5, 5] +objects = ["imp_statue"] + +[.dungeon_entrance] +level = 70 +xp = 5000 +materials = ["marble_block"] +amounts = [1] +objects = ["dungeon_entrance"] + +[.crude_wooden_chair] +level = 1 +xp = 580 +materials = ["plank"] +amounts = [2] +nails = 2 +objects = ["crude_wooden_chair"] + +[.wooden_chair] +level = 8 +xp = 870 +materials = ["plank"] +amounts = [3] +nails = 3 +objects = ["wooden_chair"] + +[.rocking_chair] +level = 14 +xp = 870 +materials = ["plank"] +amounts = [3] +nails = 3 +objects = ["rocking_chair"] + +[.oak_chair] +level = 19 +xp = 1200 +materials = ["oak_plank"] +amounts = [2] +objects = ["oak_chair"] + +[.oak_armchair] +level = 26 +xp = 1800 +materials = ["oak_plank"] +amounts = [3] +objects = ["oak_armchair"] + +[.teak_armchair] +level = 35 +xp = 1800 +materials = ["teak_plank"] +amounts = [2] +objects = ["teak_armchair"] + +[.mahogany_armchair] +level = 50 +xp = 2800 +materials = ["mahogany_plank"] +amounts = [2] +objects = ["mahogany_armchair"] + +[.brown_rug] +level = 2 +xp = 300 +materials = ["bolt_of_cloth"] +amounts = [2] +objects = ["brown_rug_corner", "brown_rug_side", "brown_rug_middle"] + +[.rug] +level = 13 +xp = 600 +materials = ["bolt_of_cloth"] +amounts = [4] +objects = ["rug_corner", "rug_side", "rug_middle"] + +[.opulent_rug] +level = 65 +xp = 3600 +materials = ["bolt_of_cloth", "gold_leaf"] +amounts = [4, 1] +objects = ["opulent_rug_corner", "opulent_rug_side", "opulent_rug_middle"] + +[.wooden_bookcase] +level = 4 +xp = 1150 +materials = ["plank"] +amounts = [4] +nails = 4 +objects = ["wooden_bookcase"] + +[.oak_bookcase] +level = 29 +xp = 1800 +materials = ["oak_plank"] +amounts = [3] +objects = ["oak_bookcase"] + +[.mahogany_bkcase] +level = 40 +xp = 4200 +materials = ["mahogany_plank"] +amounts = [3] +objects = ["mahogany_bookcase"] + +[.clay_fireplace] +level = 3 +xp = 300 +materials = ["soft_clay"] +amounts = [3] +objects = ["clay_fireplace"] + +[.stone_fireplace] +level = 33 +xp = 400 +materials = ["limestone_brick"] +amounts = [2] +objects = ["stone_fireplace"] + +[.marble_fireplace] +level = 63 +xp = 5000 +materials = ["marble_block"] +amounts = [1] +objects = ["marble_fireplace"] + +[.torn_curtains] +level = 2 +xp = 1320 +materials = ["plank", "bolt_of_cloth"] +amounts = [3, 3] +nails = 3 +objects = ["torn_curtains"] + +[.curtains] +level = 18 +xp = 2250 +materials = ["oak_plank", "bolt_of_cloth"] +amounts = [3, 3] +objects = ["curtains"] + +[.opulent_curtains] +level = 40 +xp = 3150 +materials = ["teak_plank", "bolt_of_cloth"] +amounts = [3, 3] +objects = ["opulent_curtains"] diff --git a/data/skill/construction/construction.varbits.toml b/data/skill/construction/construction.varbits.toml index 3756415b6f..6f0405a444 100644 --- a/data/skill/construction/construction.varbits.toml +++ b/data/skill/construction/construction.varbits.toml @@ -1,4 +1,3 @@ [house_build_mode] id = 2176 format = "boolean" -persist = true diff --git a/data/skill/construction/construction.varps.toml b/data/skill/construction/construction.varps.toml new file mode 100644 index 0000000000..8993e9ea54 --- /dev/null +++ b/data/skill/construction/construction.varps.toml @@ -0,0 +1,27 @@ +[furniture_creation_hide_cross_1] +id = 1485 +format = "boolean" + +[furniture_creation_hide_cross_2] +id = 1486 +format = "boolean" + +[furniture_creation_hide_cross_3] +id = 1487 +format = "boolean" + +[furniture_creation_hide_cross_4] +id = 1488 +format = "boolean" + +[furniture_creation_hide_cross_5] +id = 1489 +format = "boolean" + +[furniture_creation_hide_cross_6] +id = 1490 +format = "boolean" + +[furniture_creation_hide_cross_7] +id = 1491 +format = "boolean" diff --git a/data/skill/construction/construction.vars.toml b/data/skill/construction/construction.vars.toml index 5497545d6b..509b5078d7 100644 --- a/data/skill/construction/construction.vars.toml +++ b/data/skill/construction/construction.vars.toml @@ -10,3 +10,16 @@ persist = true [house_room_rotations] persist = true + +[house_furniture_rooms] +persist = true + +[house_furniture_hotspots] +persist = true + +[house_furniture_ids] +persist = true + +[house_locked] +format = "boolean" +persist = true diff --git a/game/src/main/kotlin/content/skill/construction/FurnitureCreation.kt b/game/src/main/kotlin/content/skill/construction/FurnitureCreation.kt new file mode 100644 index 0000000000..f929957821 --- /dev/null +++ b/game/src/main/kotlin/content/skill/construction/FurnitureCreation.kt @@ -0,0 +1,173 @@ +package content.skill.construction + +import content.entity.player.dialogue.type.choice +import content.skill.construction.House.Companion.addHouseFurniture +import content.skill.construction.House.Companion.exitPortals +import content.skill.construction.House.Companion.hotspot +import content.skill.construction.House.Companion.houseBase +import content.skill.construction.House.Companion.houseFurniture +import content.skill.construction.House.Companion.inOwnHouse +import content.skill.construction.House.Companion.placeFurniture +import content.skill.construction.House.Companion.removeHouseFurniture +import content.skill.construction.House.Companion.roomPosition +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.client.message +import world.gregs.voidps.engine.client.ui.close +import world.gregs.voidps.engine.client.ui.open +import world.gregs.voidps.engine.data.config.RowDefinition +import world.gregs.voidps.engine.data.definition.ItemDefinitions +import world.gregs.voidps.engine.data.definition.Rows +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.entity.character.player.skill.Skill +import world.gregs.voidps.engine.entity.character.player.skill.exp.exp +import world.gregs.voidps.engine.entity.character.player.skill.level.Level.has +import world.gregs.voidps.engine.entity.item.Item +import world.gregs.voidps.engine.entity.obj.GameObject +import world.gregs.voidps.engine.entity.obj.GameObjects +import world.gregs.voidps.engine.entity.obj.remove +import world.gregs.voidps.engine.inv.contains +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.engine.inv.remove +import world.gregs.voidps.engine.inv.transact.operation.ClearItem.clear +import world.gregs.voidps.type.Zone + +class FurnitureCreation : Script { + init { + objectOperate("Build", "*_space*") { (target) -> + val hotspot = Rows.getOrNull("house_hotspots.${target.id}") ?: return@objectOperate + if (!inOwnHouse() || !get("house_build_mode", false)) { + return@objectOperate + } + val furniture = hotspot.itemList("furniture") + inventories.inventory("poh_furniture_menu_inv").transaction { + clear() + for ((index, id) in furniture.withIndex()) { + // Menu items are listed down each column + set(index % 4 * 2 + index / 4, Item(id)) + } + } + set("house_hotspot", target) + open("furniture_creation") + for (slot in 1..7) { + sendMenuSlot(slot, furniture.getOrNull(slot - 1)) + } + } + + interfaceOpened("furniture_creation") { + interfaceOptions.send("furniture_creation", "items") + interfaceOptions.unlockAll("furniture_creation", "items", 0 until 8) + } + + interfaceClosed("furniture_creation") { + inventories.clear("poh_furniture_menu_inv") + clear("house_hotspot") + } + + interfaceOption("Build", "furniture_creation:items") { (item) -> + val target: GameObject = get("house_hotspot") ?: return@interfaceOption + close("furniture_creation") + build(target, item.id) + } + + interfaceOption("Close", "furniture_creation:close") { + close("furniture_creation") + } + + objectOperate("Remove") { (target) -> + val base = houseBase() + if (base == null || !inOwnHouse()) { + return@objectOperate + } + val original = GameObjects.original(target) ?: return@objectOperate + val hotspot = hotspot(original) ?: return@objectOperate + if (!get("house_build_mode", false)) { + message("You can only do that in building mode.") // TODO proper message + return@objectOperate + } + val position = roomPosition(base, target.tile.zone) ?: return@objectOperate + if (houseFurniture(position, hotspot) == "exit_portal" && exitPortals() <= 1) { + message("Your house must have at least one exit portal.") // TODO proper message + return@objectOperate + } + choice("Really remove it?") { + option("Yes") { + if (!GameObjects.contains(target) || houseFurniture(position, hotspot) == null) { + return@option + } + removeHouseFurniture(position, hotspot) + removeFurniture(target.tile.zone, hotspot) + anim("construction_remove") + } + option("No") + } + } + } + + private fun Player.sendMenuSlot(slot: Int, furniture: String?) { + val row = if (furniture == null) null else Rows.get("house_furniture.$furniture") + val materials = if (row == null) emptyList() else materials(row) + interfaces.sendText("furniture_creation", "name_$slot", if (furniture == null) "" else ItemDefinitions.get(furniture).name) + for (line in 1..4) { + val item = materials.getOrNull(line - 1) + interfaces.sendText("furniture_creation", "material_${slot}_$line", if (item == null) "" else "${item.amount} ${item.def.name}") + } + interfaces.sendText("furniture_creation", "level_$slot", if (row == null) "" else "Level ${row.int("level")}") + set("furniture_creation_hide_cross_$slot", row == null || (has(Skill.Construction, row.int("level")) && inventory.contains(materials))) + } + + private fun Player.build(target: GameObject, furniture: String) { + val base = houseBase() + if (base == null || !inOwnHouse() || !get("house_build_mode", false) || !GameObjects.contains(target)) { + return + } + val position = roomPosition(base, target.tile.zone) ?: return + val hotspot = hotspot(target) ?: return + val row = Rows.getOrNull("house_furniture.$furniture") ?: return + if (!has(Skill.Construction, row.int("level"), message = true)) { + return + } + if (!inventory.contains("hammer", "saw")) { + message("You need a hammer and saw to build furniture.") // TODO proper message + return + } + if (!inventory.remove(materials(row))) { + message("You don't have the right materials.") // TODO proper message + return + } + addHouseFurniture(position, hotspot, furniture) + placeFurniture(target.tile.zone, hotspot, furniture) + anim("construction_build") + exp(Skill.Construction, row.int("xp") / 10.0) + } + + /** + * Materials needed to build [row], using the first type of nails the player has enough of + */ + private fun Player.materials(row: RowDefinition): List { + val materials = row.itemList("materials").zip(row.intList("amounts")) { id, amount -> Item(id, amount) } + val nails = row.int("nails") + if (nails == 0) { + return materials + } + val type = nailTypes.firstOrNull { inventory.contains(it, nails) } ?: nailTypes.first() + return materials + Item(type, nails) + } + + /** + * Removes all furniture built on [hotspot] in [zone], revealing the original hotspots + */ + private fun removeFurniture(zone: Zone, hotspot: String) { + for (tile in zone.toCuboid()) { + for (obj in GameObjects.at(tile)) { + val original = GameObjects.original(obj) ?: continue + if (hotspot(original) == hotspot) { + obj.remove() + } + } + } + } + + companion object { + private val nailTypes = listOf("bronze_nails", "iron_nails", "steel_nails", "black_nails", "mithril_nails", "adamant_nails", "rune_nails") + } +} diff --git a/game/src/test/kotlin/content/skill/construction/FurnitureCreationTest.kt b/game/src/test/kotlin/content/skill/construction/FurnitureCreationTest.kt new file mode 100644 index 0000000000..f5669d52f1 --- /dev/null +++ b/game/src/test/kotlin/content/skill/construction/FurnitureCreationTest.kt @@ -0,0 +1,314 @@ +package content.skill.construction + +import WorldTest +import containsMessage +import content.quest.instance +import content.skill.construction.House.Companion.GROUND_LEVEL +import content.skill.construction.House.Companion.START_ROOM +import content.skill.construction.House.Companion.addHouseFurniture +import content.skill.construction.House.Companion.addHouseRoom +import content.skill.construction.House.Companion.houseFurnitureHotspots +import content.skill.construction.House.Companion.houseFurnitureIds +import content.skill.construction.House.Companion.houseFurnitureRooms +import content.skill.construction.House.Companion.houseRoomPositions +import content.skill.construction.House.Companion.removeHouseRoom +import content.skill.construction.House.Companion.roomZone +import dialogueOption +import interfaceOption +import objectOption +import org.junit.jupiter.api.Test +import world.gregs.voidps.engine.client.ui.dialogue +import world.gregs.voidps.engine.client.ui.hasOpen +import world.gregs.voidps.engine.entity.character.move.tele +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.entity.character.player.skill.Skill +import world.gregs.voidps.engine.entity.item.Item +import world.gregs.voidps.engine.entity.obj.GameObject +import world.gregs.voidps.engine.entity.obj.GameObjects +import world.gregs.voidps.engine.inv.add +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.engine.inv.remove +import world.gregs.voidps.type.Tile +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class FurnitureCreationTest : WorldTest() { + + private val portal = Tile(2951, 3222) + private val exit = Tile(2953, 3224) + + private fun createBuilder(room: String = "parlour"): Player { + val player = createPlayer(exit, "builder") + player["house_location"] = "rimmington" + player.addHouseRoom(room, START_ROOM) + player.inventory.add("hammer") + player.inventory.add("saw") + return player + } + + private fun Player.enterPortal(option: Int) { + objectOption(GameObjects.find(portal, "house_portal_rimmington"), "Enter") + tickIf { dialogue == null } + dialogueOption("line$option") + tickIf { hasOpen("house_loading") } + } + + private fun Player.objects(id: String): List { + val zone = roomZone(instance()!!.tile.zone, START_ROOM) + return zone.toCuboid().mapNotNull { GameObjects.findOrNull(it, id) } + } + + private fun Player.build(hotspot: String, furniture: String) { + objectOption(objects(hotspot).first(), "Build") + tickIf { !hasOpen("furniture_creation") } + val slot = inventories.inventory("poh_furniture_menu_inv").indexOf(furniture) + interfaceOption("furniture_creation", "items", "Build", item = Item(furniture), slot = slot) + } + + @Test + fun `Build furniture on a hotspot`() { + val player = createBuilder() + player.inventory.add("plank", 2) + player.inventory.add("bronze_nails", 2) + player.enterPortal(2) + val hotspot = player.objects("parlour_chair_space").single() + + player.build("parlour_chair_space", "crude_wooden_chair") + + assertFalse(player.hasOpen("furniture_creation")) + assertEquals(hotspot.tile, player.objects("crude_wooden_chair").single().tile) + assertTrue(player.objects("parlour_chair_space").isEmpty()) + assertEquals(0, player.inventory.count("plank")) + assertEquals(0, player.inventory.count("bronze_nails")) + assertEquals(58.0, player.experience.get(Skill.Construction)) + assertEquals(listOf(START_ROOM), player.houseFurnitureRooms) + assertEquals(listOf("parlour_chair_space"), player.houseFurnitureHotspots) + assertEquals(listOf("crude_wooden_chair"), player.houseFurnitureIds) + } + + @Test + fun `Build furniture in a rotated room`() { + val player = createBuilder("garden") + player.inventory.add("coins", 1000) + player.inventory.add("plank", 2) + player.inventory.add("bronze_nails", 2) + player.enterPortal(2) + val base = player.instance()!!.tile.zone + player.objectOption(GameObjects.find(base.add(4, 4, GROUND_LEVEL).tile.add(7, 3)) { it.id.startsWith("door_hotspot") }, "Build") + tickIf { !player.hasOpen("room_creation") } + player.interfaceOption("room_creation", "parlour", "Build") + player.dialogueOption("line1") + player.dialogueOption("line3") + val parlour = player.houseRoomPositions.last() + val zone = roomZone(base, parlour) + val hotspot = zone.toCuboid().mapNotNull { GameObjects.findOrNull(it, "parlour_chair_space") }.single() + player.tele(hotspot.tile.add(1, 0)) + + player.objectOption(hotspot, "Build") + tickIf { !player.hasOpen("furniture_creation") } + player.interfaceOption("furniture_creation", "items", "Build", item = Item("crude_wooden_chair"), slot = 0) + + assertEquals(listOf(hotspot.tile), zone.toCuboid().mapNotNull { GameObjects.findOrNull(it, "crude_wooden_chair")?.tile }) + } + + @Test + fun `Furniture menu lists a hotspots furniture down each column`() { + val player = createBuilder() + player.inventory.add("plank", 2) + player.inventory.add("bronze_nails", 2) + player.enterPortal(2) + + player.objectOption(player.objects("parlour_chair_space").single(), "Build") + tickIf { !player.hasOpen("furniture_creation") } + + val menu = player.inventories.inventory("poh_furniture_menu_inv") + assertEquals("crude_wooden_chair", menu[0].id) + assertEquals("wooden_chair", menu[2].id) + assertEquals("oak_armchair", menu[1].id) + assertEquals("", menu[7].id) + assertTrue(player["furniture_creation_hide_cross_1", false]) + assertFalse(player["furniture_creation_hide_cross_2", false]) + assertFalse(player["furniture_creation_hide_cross_7", false]) + } + + @Test + fun `Empty menu slots have no cross`() { + val player = createBuilder() + player.enterPortal(2) + player.objectOption(player.objects("parlour_chair_space").single(), "Build") + tickIf { !player.hasOpen("furniture_creation") } + player.interfaceOption("furniture_creation", "close", "Close") + + player.objectOption(player.objects("parlour_fireplace_space").single(), "Build") + tickIf { !player.hasOpen("furniture_creation") } + + assertFalse(player["furniture_creation_hide_cross_3", false]) + for (slot in 4..7) { + assertTrue(player["furniture_creation_hide_cross_$slot", false]) + } + } + + @Test + fun `Can't build furniture without the level`() { + val player = createBuilder() + player.inventory.add("plank", 3) + player.inventory.add("bronze_nails", 3) + player.enterPortal(2) + + player.build("parlour_chair_space", "wooden_chair") + + assertTrue(player.objects("wooden_chair").isEmpty()) + assertEquals(3, player.inventory.count("plank")) + assertTrue(player.houseFurnitureIds.isEmpty()) + } + + @Test + fun `Can't build furniture without materials`() { + val player = createBuilder() + player.inventory.add("plank", 2) + player.enterPortal(2) + + player.build("parlour_chair_space", "crude_wooden_chair") + + assertTrue(player.objects("crude_wooden_chair").isEmpty()) + assertEquals(2, player.inventory.count("plank")) + assertTrue(player.containsMessage("right materials")) + } + + @Test + fun `Can't build furniture without a hammer and saw`() { + val player = createBuilder() + player.inventory.remove("saw") + player.inventory.add("plank", 2) + player.inventory.add("bronze_nails", 2) + player.enterPortal(2) + + player.build("parlour_chair_space", "crude_wooden_chair") + + assertTrue(player.objects("crude_wooden_chair").isEmpty()) + assertEquals(2, player.inventory.count("plank")) + assertTrue(player.containsMessage("hammer and saw")) + } + + @Test + fun `Build every piece of grouped hotspots together`() { + val player = createBuilder() + player.levels.set(Skill.Construction, 2) + player.inventory.add("bolt_of_cloth", 2) + player.enterPortal(2) + val corners = player.objects("parlour_rug_space_corner").map { it.tile } + val sides = player.objects("parlour_rug_space_side").map { it.tile } + val middles = player.objects("parlour_rug_space_middle").map { it.tile } + + player.build("parlour_rug_space_side", "brown_rug") + + assertEquals(4, corners.size) + assertEquals(corners, player.objects("brown_rug_corner").map { it.tile }) + assertEquals(sides, player.objects("brown_rug_side").map { it.tile }) + assertEquals(middles, player.objects("brown_rug_middle").map { it.tile }) + assertTrue(player.objects("parlour_rug_space_middle").isEmpty()) + assertEquals(listOf("parlour_rug"), player.houseFurnitureHotspots) + } + + @Test + fun `Remove furniture`() { + val player = createBuilder() + player.addHouseFurniture(START_ROOM, "parlour_chair_space", "crude_wooden_chair") + player.addHouseFurniture(START_ROOM, "parlour_chair_space_2", "crude_wooden_chair") + player.enterPortal(2) + val chair = player.objects("crude_wooden_chair").first { GameObjects.original(it)?.id == "parlour_chair_space" } + + player.objectOption(chair, "Remove") + tickIf { player.dialogue == null } + player.dialogueOption("line1") + + assertEquals(chair.tile, player.objects("parlour_chair_space").single().tile) + assertEquals(1, player.objects("crude_wooden_chair").size) + assertTrue(player.objects("parlour_chair_space_2").isEmpty()) + assertEquals(listOf("parlour_chair_space_2"), player.houseFurnitureHotspots) + } + + @Test + fun `Remove every piece of grouped furniture`() { + val player = createBuilder() + player.addHouseFurniture(START_ROOM, "parlour_rug", "rug") + player.enterPortal(2) + + player.objectOption(player.objects("rug_middle").first(), "Remove") + tickIf { player.dialogue == null } + player.dialogueOption("line1") + + assertTrue(player.objects("rug_corner").isEmpty()) + assertTrue(player.objects("rug_side").isEmpty()) + assertTrue(player.objects("rug_middle").isEmpty()) + assertEquals(4, player.objects("parlour_rug_space_corner").size) + assertTrue(player.houseFurnitureIds.isEmpty()) + } + + @Test + fun `New houses start with an exit portal`() { + val player = createPlayer(exit, "builder") + player["house_location"] = "rimmington" + + player.enterPortal(1) + + assertEquals(listOf("exit_portal"), player.houseFurnitureIds) + assertNotNull(player.objects("exit_portal").singleOrNull()) + } + + @Test + fun `Can't remove the last exit portal`() { + val player = createBuilder("garden") + player.addHouseFurniture(START_ROOM, "garden_centrepiece_space", "exit_portal") + player.enterPortal(2) + + player.objectOption(player.objects("exit_portal").single(), "Remove") + tick(5) + + assertNull(player.dialogue) + assertEquals(1, player.objects("exit_portal").size) + assertEquals(listOf("exit_portal"), player.houseFurnitureIds) + assertTrue(player.containsMessage("exit portal")) + } + + @Test + fun `Can't remove furniture outside of building mode`() { + val player = createBuilder() + player.addHouseFurniture(START_ROOM, "parlour_chair_space", "oak_chair") + player.enterPortal(1) + + player.objectOption(player.objects("oak_chair").single(), "Remove") + tick(5) + + assertNull(player.dialogue) + assertEquals(1, player.objects("oak_chair").size) + assertTrue(player.containsMessage("building mode")) + } + + @Test + fun `Furniture is placed when entering a house`() { + val player = createBuilder("garden") + player.addHouseFurniture(START_ROOM, "garden_centrepiece_space", "pond") + + player.enterPortal(1) + + assertNotNull(player.objects("pond").singleOrNull()) + assertTrue(player.objects("garden_centrepiece_space").isEmpty()) + } + + @Test + fun `Removing a room removes its furniture`() { + val player = createBuilder() + player.addHouseRoom("garden", START_ROOM + 1) + player.addHouseFurniture(START_ROOM, "parlour_chair_space", "oak_chair") + player.addHouseFurniture(START_ROOM + 1, "garden_centrepiece_space", "pond") + + player.removeHouseRoom(START_ROOM) + + assertEquals(listOf(START_ROOM + 1), player.houseFurnitureRooms) + assertEquals(listOf("pond"), player.houseFurnitureIds) + } +} From 4e4886c43abe736a5ae2b2c788e12af37ce7be6c Mon Sep 17 00:00:00 2001 From: GregHib Date: Thu, 1 Oct 2026 23:34:00 +0100 Subject: [PATCH 09/28] Add house windows --- data/skill/construction/construction.objs.toml | 12 +++++++++--- data/skill/construction/construction.tables.toml | 12 +++++++++--- .../kotlin/content/skill/construction/House.kt | 16 ++++++++++------ .../content/skill/construction/HouseTest.kt | 14 ++++++++++++++ 4 files changed, 42 insertions(+), 12 deletions(-) diff --git a/data/skill/construction/construction.objs.toml b/data/skill/construction/construction.objs.toml index f7dc77953e..5e31a08abc 100644 --- a/data/skill/construction/construction.objs.toml +++ b/data/skill/construction/construction.objs.toml @@ -79,9 +79,6 @@ id = 15413 [parlour_bookcase_space] id = 15416 -[parlour_bookcase_space_2] -id = 15417 - [parlour_fireplace_space] id = 15418 @@ -184,3 +181,12 @@ id = 13611 [marble_fireplace] id = 13613 + +[house_window_space] +id = 13830 + +[basic_wood_wall] +id = 13098 + +[basic_wood_window] +id = 13099 diff --git a/data/skill/construction/construction.tables.toml b/data/skill/construction/construction.tables.toml index 5afb9f4f89..e0ac420aa1 100644 --- a/data/skill/construction/construction.tables.toml +++ b/data/skill/construction/construction.tables.toml @@ -133,6 +133,15 @@ template = "tile" [.land] template = { x = 1864, y = 5056 } +# Room templates for each style are on their own level, window spaces are replaced with the styles window, or wall underground +[house_styles] +wall = "obj" +window = "obj" + +[.basic_wood] +wall = "basic_wood_wall" +window = "basic_wood_window" + [house_locations] exit = "tile" @@ -189,9 +198,6 @@ piece = 2 [.parlour_bookcase_space] furniture = ["wooden_bookcase", "oak_bookcase", "mahogany_bkcase"] -[.parlour_bookcase_space_2] -clone = "parlour_bookcase_space" - [.parlour_fireplace_space] furniture = ["clay_fireplace", "stone_fireplace", "marble_fireplace"] diff --git a/game/src/main/kotlin/content/skill/construction/House.kt b/game/src/main/kotlin/content/skill/construction/House.kt index aac52b3729..98f761ee5a 100644 --- a/game/src/main/kotlin/content/skill/construction/House.kt +++ b/game/src/main/kotlin/content/skill/construction/House.kt @@ -250,18 +250,22 @@ class House : Script { for ((index, position) in houseFurnitureRooms.withIndex()) { placeFurniture(roomZone(base, position), hotspots[index], furniture[index]) } - if (buildMode) { - return - } + val style = "basic_wood" // TODO house styles for (position in positions) { - removeHotspots(roomZone(base, position)) + val window = Tables.obj("house_styles.$style.${if (roomLevel(position) == DUNGEON_LEVEL) "wall" else "window"}") + decorate(roomZone(base, position), window, buildMode) } } - private fun removeHotspots(zone: Zone) { + /** + * Fills the window spaces in [zone] and removes any hotspots outside of [buildMode] + */ + private fun decorate(zone: Zone, window: String, buildMode: Boolean) { for (tile in zone.toCuboid()) { for (obj in GameObjects.at(tile)) { - if (obj.def.containsOption("Build")) { + if (obj.id == "house_window_space") { + obj.replace(window) + } else if (!buildMode && obj.def.containsOption("Build")) { obj.remove() } } diff --git a/game/src/test/kotlin/content/skill/construction/HouseTest.kt b/game/src/test/kotlin/content/skill/construction/HouseTest.kt index 7ddecd3ebe..03ac84de60 100644 --- a/game/src/test/kotlin/content/skill/construction/HouseTest.kt +++ b/game/src/test/kotlin/content/skill/construction/HouseTest.kt @@ -577,6 +577,20 @@ class HouseTest : WorldTest() { return zone.toCuboid().firstNotNullOf { GameObjects.findOrNull(it, "exit_portal") } } + @Test + fun `Window spaces are filled with windows`() { + for (option in 1..2) { + val player = createOwner("owner$option") + player.addHouseRoom("parlour", roomPosition(4, 3, GROUND_LEVEL)) + + player.enterPortal(option) + + val parlour = roomZone(player.instance()!!.tile.zone, roomPosition(4, 3, GROUND_LEVEL)).toCuboid() + assertTrue(parlour.none { GameObjects.findOrNull(it, "house_window_space") != null }) + assertEquals(8, parlour.count { GameObjects.findOrNull(it, "basic_wood_window") != null }) + } + } + @Test fun `Leave a house through the exit portal`() { val owner = createOwner() From 08bd3ce3380a43fd2283c6018eba71e470341303 Mon Sep 17 00:00:00 2001 From: GregHib Date: Thu, 1 Oct 2026 23:57:28 +0100 Subject: [PATCH 10/28] Add house styles and location --- .../skill/construction/construction.objs.toml | 30 ++++++ .../construction/construction.tables.toml | 69 +++++++++++- .../skill/construction/construction.vars.toml | 5 + .../area/asgarnia/falador/EstateAgent.kt | 83 ++++++++++++++- .../content/skill/construction/House.kt | 15 ++- .../skill/construction/RoomCreation.kt | 4 +- .../content/skill/construction/HouseTest.kt | 100 +++++++++++++++++- 7 files changed, 291 insertions(+), 15 deletions(-) diff --git a/data/skill/construction/construction.objs.toml b/data/skill/construction/construction.objs.toml index 5e31a08abc..d47eea4f04 100644 --- a/data/skill/construction/construction.objs.toml +++ b/data/skill/construction/construction.objs.toml @@ -190,3 +190,33 @@ id = 13098 [basic_wood_window] id = 13099 + +[basic_stone_wall] +id = 13090 + +[basic_stone_window] +id = 13091 + +[whitewashed_stone_wall] +id = 1415 + +[whitewashed_stone_window] +id = 13005 + +[fremennik_wood_wall] +id = 13111 + +[fremennik_wood_window] +id = 13112 + +[tropical_wood_wall] +id = 13011 + +[tropical_wood_window] +id = 10816 + +[fancy_stone_wall] +id = 13116 + +[fancy_stone_window] +id = 13117 diff --git a/data/skill/construction/construction.tables.toml b/data/skill/construction/construction.tables.toml index e0ac420aa1..abc06b9e2b 100644 --- a/data/skill/construction/construction.tables.toml +++ b/data/skill/construction/construction.tables.toml @@ -133,34 +133,95 @@ template = "tile" [.land] template = { x = 1864, y = 5056 } -# Room templates for each style are on their own level, window spaces are replaced with the styles window, or wall underground +# Templates for each style are offset from the basic wood templates, window spaces are filled with the styles window or wall underground [house_styles] +name = "string" +level = "int" +cost = "int" +offset = "tile" wall = "obj" window = "obj" [.basic_wood] +name = "Basic wood" +level = 1 +cost = 5000 wall = "basic_wood_wall" window = "basic_wood_window" +[.basic_stone] +name = "Basic stone" +level = 10 +cost = 5000 +offset = { level = 1 } +wall = "basic_stone_wall" +window = "basic_stone_window" + +[.whitewashed_stone] +name = "Whitewashed stone" +level = 20 +cost = 7500 +offset = { level = 2 } +wall = "whitewashed_stone_wall" +window = "whitewashed_stone_window" + +[.fremennik_wood] +name = "Fremennik-style wood" +level = 30 +cost = 10000 +offset = { level = 3 } +wall = "fremennik_wood_wall" +window = "fremennik_wood_window" + +[.tropical_wood] +name = "Tropical wood" +level = 40 +cost = 15000 +offset = { x = 64 } +wall = "tropical_wood_wall" +window = "tropical_wood_window" + +[.fancy_stone] +name = "Fancy stone" +level = 50 +cost = 25000 +offset = { x = 64, level = 1 } +wall = "fancy_stone_wall" +window = "fancy_stone_window" + [house_locations] +level = "int" +cost = "int" exit = "tile" -[.taverley] -exit = { x = 2893, y = 3465 } - [.rimmington] +level = 1 +cost = 5000 exit = { x = 2953, y = 3224 } +[.taverley] +level = 10 +cost = 5000 +exit = { x = 2893, y = 3465 } + [.pollnivneach] +level = 20 +cost = 7500 exit = { x = 3340, y = 3003 } [.rellekka] +level = 30 +cost = 10000 exit = { x = 2670, y = 3631 } [.brimhaven] +level = 40 +cost = 15000 exit = { x = 2757, y = 3178 } [.yanille] +level = 50 +cost = 25000 exit = { x = 2544, y = 3096 } # Furniture which can be built on each hotspot, hotspots with the same group are built and removed together diff --git a/data/skill/construction/construction.vars.toml b/data/skill/construction/construction.vars.toml index 509b5078d7..9a8a944daa 100644 --- a/data/skill/construction/construction.vars.toml +++ b/data/skill/construction/construction.vars.toml @@ -23,3 +23,8 @@ persist = true [house_locked] format = "boolean" persist = true + +[house_style] +format = "string" +persist = true +default = "basic_wood" diff --git a/game/src/main/kotlin/content/area/asgarnia/falador/EstateAgent.kt b/game/src/main/kotlin/content/area/asgarnia/falador/EstateAgent.kt index aa0d34c054..81dc4f3fa4 100644 --- a/game/src/main/kotlin/content/area/asgarnia/falador/EstateAgent.kt +++ b/game/src/main/kotlin/content/area/asgarnia/falador/EstateAgent.kt @@ -10,10 +10,15 @@ import content.entity.player.dialogue.type.npc import content.entity.player.dialogue.type.player import content.entity.player.inv.item.addOrDrop import content.skill.construction.House.Companion.newHouse +import net.pearx.kasechange.toSentenceCase import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.client.ui.chat.toDigitGroupString import world.gregs.voidps.engine.data.Settings +import world.gregs.voidps.engine.data.config.RowDefinition +import world.gregs.voidps.engine.data.definition.Tables import world.gregs.voidps.engine.entity.character.player.Player import world.gregs.voidps.engine.entity.character.player.skill.Skill +import world.gregs.voidps.engine.entity.character.player.skill.level.Level.has import world.gregs.voidps.engine.inv.inventory import world.gregs.voidps.engine.inv.remove @@ -27,7 +32,12 @@ class EstateAgent : Script { buyHouse() } } else { - // TODO move + redecorate house + option("Can you move my house please?") { + moveHouse() + } + option("Can you redecorate my house please?") { + redecorateHouse() + } } option("What's that cape you are wearing?") { skillcapeMasterDialogue(Skill.Construction, "a master home builder") @@ -58,4 +68,75 @@ class EstateAgent : Script { } } } + + private suspend fun Player.moveHouse() { + npc("Certainly. Where would you like it moved to?") + chooseRow("house_locations") { row -> + val name = row.rowId.toSentenceCase() + if (get("house_location", "") == row.rowId) { + npc("Your house is already in $name!") // TODO proper messages + return@chooseRow + } + if (!pay(row)) { + return@chooseRow + } + set("house_location", row.rowId) + npc("Your house has been moved to $name.") // TODO proper messages + } + } + + private suspend fun Player.redecorateHouse() { + npc("Certainly. My magic can rebuild the house in a completely new style! What style would you like?") + chooseRow("house_styles") { row -> + if (get("house_style", "basic_wood") == row.rowId) { + npc("Your house is already built in that style!") // TODO proper messages + return@chooseRow + } + if (!pay(row)) { + return@chooseRow + } + set("house_style", row.rowId) + npc("Your house has been redecorated.") // TODO proper messages + } + } + + /** + * Choose a row from [table], listed four at a time with their costs + */ + private suspend fun Player.chooseRow(table: String, page: Int = 0, block: suspend Player.(RowDefinition) -> Unit) { + val rows = Tables.get(table).rows() + choice { + for (row in rows.subList(page * 4, minOf(rows.size, page * 4 + 4))) { + val name = row.stringOrNull("name") ?: row.rowId.toSentenceCase() + option("$name (${row.int("cost").toDigitGroupString()})") { + block(row) + } + } + if (rows.size > page * 4 + 4) { + option("More...") { + chooseRow(table, page + 1, block) + } + } else { + option("Never mind.") + } + } + } + + /** + * Checks the construction level of [row] and takes its cost + */ + private suspend fun Player.pay(row: RowDefinition): Boolean { + val level = row.int("level") + if (!has(Skill.Construction, level)) { + npc("I'm afraid you need a Construction level of $level for that.") // TODO proper messages + return false + } + val cost = row.int("cost") + if (!inventory.remove("coins", cost)) { + player("I haven't got ${cost.toDigitGroupString()} coins on me.") + npc("Well come back when you have it then.") + return false + } + return true + } } diff --git a/game/src/main/kotlin/content/skill/construction/House.kt b/game/src/main/kotlin/content/skill/construction/House.kt index 98f761ee5a..a575322c65 100644 --- a/game/src/main/kotlin/content/skill/construction/House.kt +++ b/game/src/main/kotlin/content/skill/construction/House.kt @@ -206,6 +206,14 @@ class House : Script { return instance()?.tile?.zone } + /** + * The zone of the template at [path] in the players house style + */ + fun Player.template(path: String): Zone { + val offset = Tables.tile("house_styles.${get("house_style", "basic_wood")}.offset") + return Tables.tile(path).add(offset.x, offset.y, offset.level).zone + } + /** * Builds the players house in the instance starting at [base]. Ground floor rooms are surrounded by a * ring of grass, every other space is left empty. @@ -220,11 +228,11 @@ class House : Script { for ((index, position) in positions.withIndex()) { val zone = roomZone(base, position) placed.add(zone) - entries.add(Triple(Tables.tile("house_rooms.${ids[index]}.template").zone, zone, rotations[index])) + entries.add(Triple(template("house_rooms.${ids[index]}.template"), zone, rotations[index])) } val ground = positions.filter { roomLevel(it) == GROUND_LEVEL } if (ground.isNotEmpty()) { - val land = Tables.tile("house_spaces.land.template").zone + val land = template("house_spaces.land.template") for (x in ground.minOf(::roomX) - 1..ground.maxOf(::roomX) + 1) { for (y in ground.minOf(::roomY) - 1..ground.maxOf(::roomY) + 1) { val zone = base.add(x + 1, y + 1, GROUND_LEVEL) @@ -250,9 +258,8 @@ class House : Script { for ((index, position) in houseFurnitureRooms.withIndex()) { placeFurniture(roomZone(base, position), hotspots[index], furniture[index]) } - val style = "basic_wood" // TODO house styles for (position in positions) { - val window = Tables.obj("house_styles.$style.${if (roomLevel(position) == DUNGEON_LEVEL) "wall" else "window"}") + val window = Tables.obj("house_styles.${get("house_style", "basic_wood")}.${if (roomLevel(position) == DUNGEON_LEVEL) "wall" else "window"}") decorate(roomZone(base, position), window, buildMode) } } diff --git a/game/src/main/kotlin/content/skill/construction/RoomCreation.kt b/game/src/main/kotlin/content/skill/construction/RoomCreation.kt index 0584d158e5..ea7066238e 100644 --- a/game/src/main/kotlin/content/skill/construction/RoomCreation.kt +++ b/game/src/main/kotlin/content/skill/construction/RoomCreation.kt @@ -14,12 +14,12 @@ import content.skill.construction.House.Companion.removeHouseRoom import content.skill.construction.House.Companion.roomLevel import content.skill.construction.House.Companion.roomPosition import content.skill.construction.House.Companion.roomZone +import content.skill.construction.House.Companion.template import world.gregs.voidps.engine.Script import world.gregs.voidps.engine.client.message import world.gregs.voidps.engine.client.ui.close import world.gregs.voidps.engine.client.ui.open import world.gregs.voidps.engine.data.definition.Rows -import world.gregs.voidps.engine.data.definition.Tables import world.gregs.voidps.engine.entity.character.player.Player import world.gregs.voidps.engine.entity.character.player.skill.Skill import world.gregs.voidps.engine.entity.character.player.skill.level.Level.has @@ -121,7 +121,7 @@ class RoomCreation(val dynamicZones: DynamicZones) : Script { } val position: Int = get("house_preview_position") ?: return val room: String = get("house_preview_room") ?: return - dynamicZones.copy(Tables.tile("house_rooms.$room.template").zone, roomZone(base, position), get("house_preview_rotation", 0)) + dynamicZones.copy(template("house_rooms.$room.template"), roomZone(base, position), get("house_preview_rotation", 0)) choice { option("Rotate clockwise") { rotate(1) diff --git a/game/src/test/kotlin/content/skill/construction/HouseTest.kt b/game/src/test/kotlin/content/skill/construction/HouseTest.kt index 03ac84de60..cd20737376 100644 --- a/game/src/test/kotlin/content/skill/construction/HouseTest.kt +++ b/game/src/test/kotlin/content/skill/construction/HouseTest.kt @@ -11,6 +11,7 @@ import content.skill.construction.House.Companion.addHouseRoom import content.skill.construction.House.Companion.houseRoomIds import content.skill.construction.House.Companion.houseRoomPositions import content.skill.construction.House.Companion.houseRoomRotations +import content.skill.construction.House.Companion.leaveHouse import content.skill.construction.House.Companion.roomPosition import content.skill.construction.House.Companion.roomZone import dialogueOption @@ -24,6 +25,7 @@ import world.gregs.voidps.engine.client.ui.dialogue import world.gregs.voidps.engine.client.ui.hasOpen import world.gregs.voidps.engine.client.ui.open import world.gregs.voidps.engine.data.config.VariableDefinition.Companion.persist +import world.gregs.voidps.engine.data.definition.Tables import world.gregs.voidps.engine.data.definition.VariableDefinitions import world.gregs.voidps.engine.entity.Despawn import world.gregs.voidps.engine.entity.character.move.tele @@ -77,15 +79,15 @@ class HouseTest : WorldTest() { tick(10) } - private fun Player.enterPortal(option: Int) { - objectOption(GameObjects.find(portal, "house_portal_rimmington"), "Enter") + private fun Player.enterPortal(option: Int, obj: GameObject = GameObjects.find(portal, "house_portal_rimmington")) { + objectOption(obj, "Enter") tickIf { dialogue == null } dialogueOption("line$option") tickIf { hasOpen("house_loading") } } - private fun Player.visit(owner: Player) { - enterPortal(1) + private fun Player.visit(owner: Player, obj: GameObject = GameObjects.find(portal, "house_portal_rimmington")) { + enterPortal(1, obj) (suspension as Suspension.NameEntry).resume(owner.name) tickIf { hasOpen("house_loading") } } @@ -116,6 +118,96 @@ class HouseTest : WorldTest() { assertEquals(0, player.inventory.count("coins")) } + private fun Player.talkToAgent(vararg options: Int) { + val agent = createNPC("estate_agent", tile.addY(-1)) + npcOption(agent, "Talk-to") + tickIf { dialogue == null } + for (option in options) { + skipDialogues() + dialogueOption("line$option") + } + skipDialogues() + } + + @Test + fun `Move house to another location`() { + val player = createOwner() + player.tele(2983, 3370) + player.levels.set(Skill.Construction, 10) + player.inventory.add("coins", 5000) + + player.talkToAgent(1, 2) + + assertEquals("taverley", player["house_location", ""]) + assertEquals(0, player.inventory.count("coins")) + } + + @Test + fun `Can't move house without the level`() { + val player = createOwner() + player.tele(2983, 3370) + player.inventory.add("coins", 25000) + + player.talkToAgent(1, 5, 2) + + assertEquals("rimmington", player["house_location", ""]) + assertEquals(25000, player.inventory.count("coins")) + } + + @Test + fun `Redecorate house`() { + val player = createOwner() + player.tele(2983, 3370) + player.levels.set(Skill.Construction, 50) + player.inventory.add("coins", 25000) + + player.talkToAgent(2, 5, 2) + + assertEquals("fancy_stone", player["house_style", ""]) + assertEquals(0, player.inventory.count("coins")) + } + + @Test + fun `Houses load in every style`() { + for (style in Tables.get("house_styles").rows()) { + val player = createOwner(style.rowId) + player["house_style"] = style.rowId + player.addHouseRoom("parlour", roomPosition(4, 3, GROUND_LEVEL)) + + player.enterPortal(1) + + val parlour = roomZone(player.instance()!!.tile.zone, roomPosition(4, 3, GROUND_LEVEL)).toCuboid() + assertEquals(8, parlour.count { GameObjects.findOrNull(it, style.obj("window")) != null }, style.rowId) + assertTrue(parlour.none { GameObjects.findOrNull(it, "house_window_space") != null }, style.rowId) + assertTrue(player.inHouseOf(player), style.rowId) + player.leaveHouse() + } + } + + @Test + fun `Enter and leave houses through every location's portal`() { + for (location in Tables.get("house_locations").rows()) { + val exit = location.tile("exit") + val owner = createOwner("owner${location.id}") + owner["house_location"] = location.rowId + owner.tele(exit) + val portal = exit.toCuboid(radius = 8).firstNotNullOf { GameObjects.findOrNull(it, "house_portal_${location.rowId}") } + + owner.enterPortal(1, portal) + val guest = createPlayer(exit, "guest${location.id}") + guest.visit(owner, portal) + + assertTrue(owner.inHouseOf(owner), location.rowId) + assertTrue(guest.inHouseOf(owner), location.rowId) + guest.objectOption(guest.exitPortal(), "Enter") + tick(5) + assertEquals(exit, guest.tile, location.rowId) + owner.objectOption(owner.exitPortal(), "Enter") + tick(5) + assertEquals(exit, owner.tile, location.rowId) + } + } + @Test fun `Room positions cover the 7x7x4 grid`() { val base = Zone(800, 0) From e79f575aecaa46d51a6fc706b0d65aea987811da Mon Sep 17 00:00:00 2001 From: GregHib Date: Fri, 2 Oct 2026 00:00:34 +0100 Subject: [PATCH 11/28] Add note about citations --- .claude/code-style.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.claude/code-style.md b/.claude/code-style.md index 65c9e4fce8..7082eef7db 100644 --- a/.claude/code-style.md +++ b/.claude/code-style.md @@ -19,3 +19,4 @@ - Fields as `row.int("level")`, `row.string("type")`, etc. - Persistent variables must be declared in `.vars.toml`, `.varbits.toml`, or `varps.toml` configs. - Format with `gradle spotlessApply` +- **Citations** — comment data with its origin: wiki/web data gets the URL, a made-up `message()` gets `// TODO proper message`, a guessed stat is commented as guessed (also in `.toml`). From 70e7ed6789c1779337b90b77969c3dff578b448c Mon Sep 17 00:00:00 2001 From: GregHib Date: Fri, 2 Oct 2026 12:03:06 +0100 Subject: [PATCH 12/28] Add all buildable objects --- .../skill/construction/construction.objs.toml | 1869 ++++++++++ .../construction/construction.tables.toml | 3160 ++++++++++++++++- .../skill/construction/construction.vars.toml | 5 + .../skill/construction/FurnitureCreation.kt | 20 +- .../content/skill/construction/House.kt | 50 +- .../construction/FurnitureCreationTest.kt | 131 + 6 files changed, 5205 insertions(+), 30 deletions(-) diff --git a/data/skill/construction/construction.objs.toml b/data/skill/construction/construction.objs.toml index d47eea4f04..8e338b5b6b 100644 --- a/data/skill/construction/construction.objs.toml +++ b/data/skill/construction/construction.objs.toml @@ -220,3 +220,1872 @@ id = 13116 [fancy_stone_window] id = 13117 + +[garden_big_tree_space] +id = 15362 + +[garden_tree_space] +id = 15363 + +[garden_big_plant_space] +id = 15364 + +[garden_big_plant_space_2] +id = 15365 + +[garden_small_plant_space] +id = 15366 + +[garden_small_plant_space_2] +id = 15367 + +[kitchen_stove_space] +id = 15398 + +[kitchen_shelf_space] +id = 15400 + +[kitchen_shelf_space_2] +id = 15399 + +[kitchen_barrel_space] +id = 15401 + +[kitchen_cat_basket_space] +id = 15402 + +[kitchen_larder_space] +id = 15403 + +[kitchen_sink_space] +id = 15404 + +[kitchen_table_space] +id = 15405 + +[dining_room_table_space] +id = 15298 + +[dining_room_bench_space] +id = 15299 + +[dining_room_bench_space_2] +id = 15300 + +[dining_room_fireplace_space] +id = 15301 + +[dining_room_curtain_space] +id = 15302 + +[dining_room_decoration_space] +id = 15303 + +[dining_room_bell_pull_space] +id = 15304 + +[workshop_workbench_space] +id = 15439 + +[workshop_crafting_space] +id = 15441 + +[workshop_tool_space] +id = 15443 + +[workshop_tool_space_2] +id = 15444 + +[workshop_tool_space_3] +id = 15445 + +[workshop_tool_space_4] +id = 15446 + +[workshop_tool_space_5] +id = 15447 + +[workshop_repair_space] +id = 15448 + +[workshop_heraldry_space] +id = 15450 + +[bedroom_bed_space] +id = 15260 + +[bedroom_wardrobe_space] +id = 15261 + +[bedroom_dresser_space] +id = 15262 + +[bedroom_curtain_space] +id = 15263 + +[bedroom_rug_space_corner] +id = 15266 + +[bedroom_rug_space_side] +id = 15265 + +[bedroom_rug_space_middle] +id = 15264 + +[bedroom_fireplace_space] +id = 15267 + +[bedroom_clock_space] +id = 15268 + +[skill_hall_rug_space_corner] +id = 15379 + +[skill_hall_rug_space_side] +id = 15378 + +[skill_hall_rug_space_middle] +id = 15377 + +[skill_hall_stair_space] +id = 15380 + +[skill_hall_stair_space_down] +id = 15381 + +[skill_hall_head_trophy_space] +id = 15382 + +[skill_hall_fishing_trophy_space] +id = 15383 + +[skill_hall_decorative_armour_space] +id = 15384 + +[skill_hall_armour_space] +id = 34255 + +[skill_hall_rune_case_space] +id = 15386 + +[games_room_game_space] +id = 15342 + +[games_room_prize_chest_space] +id = 15343 + +[games_room_stone_space] +id = 15344 + +[games_room_elemental_balance_space] +id = 15345 + +[games_room_ranging_game_space] +id = 15346 + +[combat_room_ring_rope_space] +id = 15277 + +[combat_room_ring_rope_space_2] +id = 15286 + +[combat_room_ring_corner_space] +id = 15278 + +[combat_room_ring_corner_space_2] +id = 15279 + +[combat_room_ring_corner_space_3] +id = 15281 + +[combat_room_ring_corner_space_4] +id = 15282 + +[combat_room_ring_pedestal_rope_space] +id = 15280 + +[combat_room_ring_pedestal_rope_space_2] +id = 15287 + +[combat_room_ring_mat_corner_space] +id = 15289 + +[combat_room_ring_mat_corner_space_2] +id = 15294 + +[combat_room_ring_pedestal_space] +id = 15290 + +[combat_room_ring_pedestal_space_2] +id = 15295 + +[combat_room_ring_mat_side_space] +id = 15288 + +[combat_room_ring_mat_side_space_2] +id = 15291 + +[combat_room_ring_mat_side_space_3] +id = 15293 + +[combat_room_ring_mat_middle_space] +id = 15292 + +[combat_room_ring_barrier_space] +id = 15283 + +[combat_room_ring_barrier_space_2] +id = 15284 + +[combat_room_ring_beam_space] +id = 15285 + +[combat_room_storage_space] +id = 15296 + +[combat_room_decoration_space] +id = 15297 + +[quest_hall_rug_space_corner] +id = 15389 + +[quest_hall_rug_space_side] +id = 15388 + +[quest_hall_rug_space_middle] +id = 15387 + +[quest_hall_stair_space] +id = 15390 + +[quest_hall_stair_space_down] +id = 15391 + +[quest_hall_portrait_space] +id = 15392 + +[quest_hall_landscape_space] +id = 15393 + +[quest_hall_guild_trophy_space] +id = 15394 + +[quest_hall_sword_space] +id = 15395 + +[quest_hall_map_space] +id = 15396 + +[quest_hall_bookcase_space] +id = 15397 + +[menagerie_habitat_space] +id = 44907 + +[menagerie_habitat_space_2] +id = 44908 + +[menagerie_pet_house_space] +id = 44909 + +[menagerie_pet_feeder_space] +id = 44910 + +[menagerie_obelisk_space] +id = 44911 + +[study_lectern_space] +id = 15420 + +[study_globe_space] +id = 15421 + +[study_crystal_ball_space] +id = 15422 + +[study_wall_chart_space] +id = 15423 + +[study_telescope_space] +id = 15424 + +[study_bookcase_space] +id = 15425 + +[study_statue_space] +id = 48662 + +[costume_room_cape_rack_space] +id = 18810 + +[costume_room_magic_wardrobe_space] +id = 18811 + +[costume_room_toy_box_space] +id = 18812 + +[costume_room_treasure_chest_space] +id = 18813 + +[costume_room_fancy_dress_box_space] +id = 18814 + +[costume_room_armour_case_space] +id = 18815 + +[chapel_altar_space] +id = 15270 + +[chapel_lamp_space] +id = 15271 + +[chapel_icon_space] +id = 15269 + +[chapel_musical_space] +id = 15276 + +[chapel_statue_space] +id = 15275 + +[chapel_rug_space_corner] +id = 15274 + +[chapel_rug_space_side] +id = 15273 + +[chapel_window_space] +id = 13730 + +[chapel_window_space_2] +id = 13728 + +[chapel_window_space_3] +id = 13732 + +[chapel_window_space_4] +id = 13729 + +[chapel_window_space_5] +id = 13733 + +[chapel_window_space_6] +id = 13731 + +[portal_chamber_portal_space] +id = 15406 + +[portal_chamber_portal_space_2] +id = 15407 + +[portal_chamber_portal_space_3] +id = 15408 + +[portal_chamber_centrepiece_space] +id = 15409 + +[formal_garden_centrepiece_space] +id = 15368 + +[formal_garden_fencing_space] +id = 15369 + +[formal_garden_hedging_space] +id = 15370 + +[formal_garden_hedging_space_2] +id = 15371 + +[formal_garden_hedging_space_3] +id = 15372 + +[formal_garden_small_plant_space] +id = 15375 + +[formal_garden_big_plant_space] +id = 15373 + +[formal_garden_small_plant_space_2] +id = 15376 + +[formal_garden_big_plant_space_2] +id = 15374 + +[throne_room_throne_space] +id = 15426 + +[throne_room_lever_space] +id = 15435 + +[throne_room_trapdoor_space] +id = 15438 + +[throne_room_seating_space] +id = 15436 + +[throne_room_seating_space_2] +id = 15437 + +[throne_room_decoration_space] +id = 15434 + +[throne_room_floor_space] +id = 15427 + +[throne_room_floor_space_2] +id = 15428 + +[throne_room_floor_space_3] +id = 15429 + +[throne_room_floor_space_4] +id = 15430 + +[throne_room_floor_space_5] +id = 15431 + +[throne_room_floor_space_6] +id = 15432 + +[oubliette_guard_space] +id = 15354 + +[oubliette_ladder_space] +id = 15356 + +[oubliette_lighting_space] +id = 15355 + +[dungeon_guard_space] +id = 15336 + +[dungeon_guard_space_2] +id = 15337 + +[dungeon_guard_space_3] +id = 15323 + +[dungeon_trap_space] +id = 15325 + +[dungeon_trap_space_2] +id = 15324 + +[dungeon_door_space_left] +id = 15328 + +[dungeon_door_space_right] +id = 15329 + +[dungeon_door_space_left_2] +id = 15326 + +[dungeon_door_space_right_2] +id = 15327 + +[dungeon_lighting_space] +id = 15330 + +[dungeon_lighting_space_2] +id = 34138 + +[dungeon_decoration_space] +id = 15331 + +[dungeon_pit_guardian_space] +id = 36676 + +[dungeon_pit_trap_space] +id = 36692 + +[dungeon_pit_trap_space_2] +id = 39229 + +[dungeon_pit_trap_space_3] +id = 39230 + +[dungeon_pit_trap_space_4] +id = 39231 + +[treasure_room_monster_space] +id = 15257 + +[treasure_room_treasure_space] +id = 15256 + +[treasure_room_decoration_space] +id = 15259 + +[big_nice_tree] +id = 13412 + +[nice_tree] +id = 13419 + +[big_oak_tree] +id = 13413 + +[oak_tree] +id = 13420 + +[big_willow_tree] +id = 13414 + +[willow_tree] +id = 13421 + +[big_maple_tree] +id = 13415 + +[house_maple_tree] +id = 13423 + +[big_yew_tree] +id = 13416 + +[yew_tree] +id = 13422 + +[big_magic_tree] +id = 13417 + +[house_magic_tree] +id = 13424 + +[house_bush] +id = 13426 + +[tall_plant] +id = 13427 + +[short_plant] +id = 13428 + +[large_leaf_plant] +id = 13429 + +[huge_plant] +id = 13430 + +[plant] +id = 13431 + +[dock_leaf] +id = 13434 + +[house_thistle] +id = 13435 + +[reeds] +id = 13436 + +[firepit] +id = 13528 + +[firepit_with_hook] +id = 13529 + +[firepit_with_pot] +id = 13531 + +[small_oven] +id = 13533 + +[large_oven] +id = 13536 + +[steel_range] +id = 13539 + +[fancy_range] +id = 13542 + +[wooden_shelves_1] +id = 13545 + +[wooden_shelves_2] +id = 13546 + +[wooden_shelves_3] +id = 13547 + +[oak_shelves_1] +id = 13548 + +[oak_shelves_2] +id = 13549 + +[teak_shelves_1] +id = 13550 + +[teak_shelves_2] +id = 13551 + +[beer_barrel] +id = 13568 + +[cider_barrel] +id = 13569 + +[asgarnian_ale_barrel] +id = 13570 + +[greenmans_ale_barrel] +id = 13571 + +[dragon_bitter_barrel] +id = 13572 + +[chefs_delight_barrel] +id = 13573 + +[cat_blanket] +id = 13574 + +[cat_basket] +id = 13575 + +[cushioned_basket] +id = 13576 + +[wooden_larder] +id = 13565 + +[oak_larder] +id = 13566 + +[teak_larder] +id = 13567 + +[pump_and_tub] +id = 13561 + +[wood_kitchen_table] +id = 13577 + +[oak_kitchen_table] +id = 13578 + +[teak_kitchen_table] +id = 13579 + +[wood_dining_table] +id = 13293 + +[oak_dining_table] +id = 13294 + +[carved_oak_table] +id = 13295 + +[teak_table] +id = 13296 + +[carved_teak_table] +id = 13297 + +[mahogany_table] +id = 13298 + +[opulent_table] +id = 13299 + +[wooden_bench] +id = 13300 + +[oak_bench] +id = 13301 + +[carved_oak_bench] +id = 13302 + +[teak_dining_bench] +id = 13303 + +[carved_teak_bench] +id = 13304 + +[carved_teak_bench_throne_room] +id = 13694 + +[mahogany_bench] +id = 13305 + +[mahogany_bench_throne_room] +id = 13695 + +[gilded_bench] +id = 13306 + +[gilded_bench_throne_room] +id = 13696 + +[oak_decoration] +id = 13606 + +[teak_decoration] +id = 13607 + +[gilded_decoration] +id = 13608 + +[rope_bell_pull] +id = 13307 + +[bell_pull] +id = 13308 + +[posh_bell_pull] +id = 13309 + +[wooden_workbench] +id = 13704 + +[oak_workbench] +id = 13705 + +[steel_framed_bench] +id = 13706 + +[bench_with_vice] +id = 13707 + +[bench_with_lathe] +id = 13708 + +[crafting_table_1] +id = 13709 + +[crafting_table_2] +id = 13710 + +[crafting_table_3] +id = 13711 + +[crafting_table_4] +id = 13712 + +[tool_store_1] +id = 13699 + +[tool_store_2] +id = 13700 + +[tool_store_3] +id = 13701 + +[tool_store_4] +id = 13702 + +[tool_store_5] +id = 13703 + +[repair_bench] +id = 13713 + +[whetstone] +id = 13714 + +[armour_stand] +id = 13715 + +[pluming_stand] +id = 13716 + +[shield_easel] +id = 13717 + +[banner_easel] +id = 13718 + +[wooden_bed] +id = 13148 + +[oak_bed] +id = 13149 + +[large_oak_bed] +id = 13150 + +[teak_bed] +id = 13151 + +[large_teak_bed] +id = 13152 + +[four_poster_bed] +id = 13153 + +[gilded_four_poster_bed] +id = 13154 + +[shoe_box] +id = 13155 + +[oak_drawers] +id = 13156 + +[oak_wardrobe] +id = 13157 + +[teak_drawers] +id = 13158 + +[teak_wardrobe] +id = 13159 + +[mahogany_wardrobe] +id = 13160 + +[gilded_wardrobe] +id = 13161 + +[shaving_stand] +id = 13162 + +[oak_shaving_stand] +id = 13163 + +[oak_dresser] +id = 13164 + +[teak_dresser] +id = 13165 + +[fancy_teak_dresser] +id = 13166 + +[mahogany_dresser] +id = 13167 + +[gilded_dresser] +id = 13168 + +[oak_clock] +id = 13169 + +[teak_clock] +id = 13170 + +[gilded_clock] +id = 13171 + +[oak_staircase] +id = 13497 + +[oak_staircase_down] +id = 13498 + +[teak_staircase] +id = 13499 + +[teak_staircase_down] +id = 13500 + +[spiral_staircase] +id = 13503 + +[spiral_staircase_down] +id = 13504 + +[marble_staircase] +id = 13501 + +[marble_staircase_down] +id = 13502 + +[marble_spiral] +id = 13505 + +[marble_spiral_down] +id = 13506 + +[crawling_hand_trophy] +id = 13481 + +[cockatrice_head_trophy] +id = 13482 + +[basilisk_head_trophy] +id = 13483 + +[kurask_head_trophy] +id = 13484 + +[abyssal_head_trophy] +id = 13485 + +[kbd_heads_trophy] +id = 13486 + +[kq_head_trophy] +id = 13487 + +[mounted_bass] +id = 13488 + +[mounted_swordfish] +id = 13489 + +[mounted_shark] +id = 13490 + +[basic_decorative_armour] +id = 34256 + +[detailed_decorative_armour] +id = 34263 + +[intricate_decorative_armour] +id = 34280 + +[profound_decorative_armour] +id = 34281 + +[mithril_armour] +id = 13491 + +[adamant_armour] +id = 13492 + +[rune_armour] +id = 13493 + +[rune_case_1] +id = 13507 + +[rune_case_2] +id = 13508 + +[jester] +id = 13390 + +[treasure_hunt] +id = 13379 + +[hangman_game] +id = 13404 + +[oak_prize_chest] +id = 13385 + +[teak_prize_chest] +id = 13387 + +[mahogany_prize_chest] +id = 13389 + +[clay_attack_stone] +id = 13392 + +[limestone_attack_stone] +id = 13393 + +[marble_attack_stone] +id = 13394 + +[elemental_balance_1] +id = 13395 + +[elemental_balance_2] +id = 13396 + +[elemental_balance_3] +id = 13397 + +[hoop_and_stick] +id = 13399 + +[dartboard] +id = 13400 + +[house_archery_target] +id = 13402 + +[boxing_ring] +id = 13129 + +[boxing_mat_corner] +id = 13126 + +[boxing_mat_side] +id = 13128 + +[boxing_mat_middle] +id = 13127 + +[fencing_ring] +id = 13133 + +[fencing_ring_corner] +id = 13135 + +[fencing_ring_side] +id = 13134 + +[fencing_ring_middle] +id = 13136 + +[combat_ring] +id = 13137 + +[combat_ring_corner] +id = 13138 + +[combat_ring_side] +id = 13139 + +[combat_ring_middle] +id = 13140 + +[magic_barrier] +id = 13145 + +[ranging_spot] +id = 13147 + +[balance_beam_end] +id = 13144 + +[balance_beam] +id = 13142 + +[glove_rack] +id = 13381 + +[weapons_rack] +id = 13382 + +[extra_weapons_rack] +id = 13383 + +[king_arthur_portrait] +id = 13510 + +[elena_portrait] +id = 13511 + +[giant_dwarf_portrait] +id = 13512 + +[miscellanians_portrait] +id = 13513 + +[lumbridge_painting] +id = 13517 + +[desert_painting] +id = 13514 + +[morytania_painting] +id = 13518 + +[karamja_painting] +id = 13516 + +[isafdar_painting] +id = 13515 + +[anti_dragon_shield_mounted] +id = 13522 + +[amulet_of_glory_mounted] +id = 13523 + +[cape_of_legends_mounted] +id = 13524 + +[silverlight_mounted] +id = 13519 + +[excalibur_mounted] +id = 13521 + +[darklight_mounted] +id = 13520 + +[small_map] +id = 13525 + +[medium_map] +id = 13526 + +[large_map] +id = 13527 + +[garden_habitat] +id = 44562 + +[garden_habitat_2] +id = 44563 + +[jungle_habitat] +id = 44628 + +[jungle_habitat_2] +id = 44629 + +[desert_habitat] +id = 44694 + +[desert_habitat_2] +id = 44695 + +[polar_habitat] +id = 44760 + +[polar_habitat_2] +id = 44761 + +[volcanic_habitat] +id = 44826 + +[volcanic_habitat_2] +id = 44827 + +[oak_pet_house] +id = 44828 + +[teak_pet_house] +id = 44829 + +[mahogany_pet_house] +id = 44830 + +[consecrated_pet_house] +id = 44831 + +[desecrated_pet_house] +id = 44832 + +[natural_pet_house] +id = 44833 + +[oak_pet_feeder] +id = 44834 + +[teak_pet_feeder] +id = 44835 + +[mahogany_pet_feeder] +id = 44836 + +[house_small_obelisk] +id = 44837 + +[oak_lectern] +id = 13642 + +[eagle_lectern] +id = 13643 + +[demon_lectern] +id = 13644 + +[teak_eagle_lectern] +id = 13645 + +[teak_demon_lectern] +id = 13646 + +[mahogany_eagle_lectern] +id = 13647 + +[mahogany_demon_lectern] +id = 13648 + +[globe] +id = 13649 + +[ornamental_globe] +id = 13650 + +[lunar_globe] +id = 13651 + +[celestial_globe] +id = 13652 + +[armillary_sphere] +id = 13653 + +[small_orrery] +id = 13654 + +[large_orrery] +id = 13655 + +[crystal_ball] +id = 13659 + +[elemental_sphere] +id = 13660 + +[crystal_of_power] +id = 13661 + +[alchemical_chart] +id = 13662 + +[astronomical_chart] +id = 13663 + +[wooden_telescope] +id = 13656 + +[teak_telescope] +id = 13657 + +[mahogany_telescope] +id = 13658 + +[dahmaroc_statue_plinth] +id = 48644 + +[oak_cape_rack] +id = 18766 + +[teak_cape_rack] +id = 18767 + +[mahogany_cape_rack] +id = 18768 + +[gilded_cape_rack] +id = 18769 + +[marble_cape_rack] +id = 18770 + +[magic_cape_rack] +id = 18771 + +[oak_magic_wardrobe] +id = 18784 + +[carved_oak_magic_wardrobe] +id = 18786 + +[teak_magic_wardrobe] +id = 18788 + +[carved_teak_magic_wardrobe] +id = 18790 + +[mahogany_magic_wardrobe] +id = 18792 + +[gilded_magic_wardrobe] +id = 18794 + +[marble_magic_wardrobe] +id = 18796 + +[oak_toy_box] +id = 18798 + +[teak_toy_box] +id = 18800 + +[mahogany_toy_box] +id = 18802 + +[oak_treasure_chest] +id = 18804 + +[teak_treasure_chest] +id = 18806 + +[mahogany_treasure_chest] +id = 18808 + +[oak_fancy_dress_box] +id = 18772 + +[teak_fancy_dress_box] +id = 18774 + +[mahogany_fancy_dress_box] +id = 18776 + +[oak_armour_case] +id = 18778 + +[teak_armour_case] +id = 18780 + +[mahogany_armour_case] +id = 18782 + +[steel_torches] +id = 13202 + +[wooden_torches] +id = 13200 + +[steel_candlesticks] +id = 13204 + +[gold_candlesticks] +id = 13206 + +[incense_burners] +id = 13208 + +[mahogany_burners] +id = 13210 + +[marble_burners] +id = 13212 + +[guthix_symbol] +id = 13174 + +[saradomin_symbol] +id = 13172 + +[zamorak_symbol] +id = 13173 + +[guthix_icon] +id = 13177 + +[saradomin_icon] +id = 13175 + +[zamorak_icon] +id = 13176 + +[icon_of_bob] +id = 13178 + +[windchimes] +id = 13214 + +[bells] +id = 13215 + +[organ] +id = 13216 + +[small_statue] +id = 13271 + +[medium_statue] +id = 13272 + +[large_statue] +id = 13282 + +[shuttered_window_basic_wood] +id = 13253 + +[shuttered_window_basic_stone] +id = 13226 + +[shuttered_window_whitewashed_stone] +id = 13235 + +[shuttered_window_fremennik_wood] +id = 13244 + +[shuttered_window_tropical_wood] +id = 13217 + +[shuttered_window_fancy_stone] +id = 13262 + +[decorative_window_basic_wood] +id = 13254 + +[decorative_window_basic_stone] +id = 13227 + +[decorative_window_whitewashed_stone] +id = 13236 + +[decorative_window_fremennik_wood] +id = 13245 + +[decorative_window_tropical_wood] +id = 13218 + +[decorative_window_fancy_stone] +id = 13263 + +[stained_glass_basic_wood] +id = 13255 + +[stained_glass_basic_stone] +id = 13228 + +[stained_glass_whitewashed_stone] +id = 13237 + +[stained_glass_fremennik_wood] +id = 13246 + +[stained_glass_tropical_wood] +id = 13219 + +[stained_glass_fancy_stone] +id = 13264 + +[teak_portal] +id = 13636 + +[mahogany_portal] +id = 13637 + +[marble_portal] +id = 13638 + +[teleport_focus] +id = 13640 + +[greater_teleport_focus] +id = 13641 + +[scrying_pool] +id = 13639 + +[gazebo] +id = 13477 + +[boundary_stones] +id = 13449 + +[wooden_fence] +id = 13450 + +[stone_wall] +id = 13451 + +[iron_railings] +id = 13452 + +[picket_fence] +id = 13453 + +[garden_fence] +id = 13454 + +[marble_wall] +id = 13455 + +[thorny_hedge] +id = 13456 + +[thorny_hedge_2] +id = 13458 + +[thorny_hedge_3] +id = 13457 + +[nice_hedge] +id = 13459 + +[nice_hedge_2] +id = 13461 + +[nice_hedge_3] +id = 13460 + +[small_box_hedge] +id = 13462 + +[small_box_hedge_2] +id = 13464 + +[small_box_hedge_3] +id = 13463 + +[topiary_hedge] +id = 13465 + +[topiary_hedge_2] +id = 13467 + +[topiary_hedge_3] +id = 13466 + +[fancy_hedge] +id = 13468 + +[fancy_hedge_2] +id = 13470 + +[fancy_hedge_3] +id = 13469 + +[tall_fancy_hedge] +id = 13471 + +[tall_fancy_hedge_2] +id = 13473 + +[tall_fancy_hedge_3] +id = 13472 + +[tall_box_hedge] +id = 13474 + +[tall_box_hedge_2] +id = 13476 + +[tall_box_hedge_3] +id = 13475 + +[sunflower] +id = 13443 + +[big_sunflower] +id = 13446 + +[marigolds] +id = 13444 + +[big_marigolds] +id = 13447 + +[roses] +id = 13445 + +[big_roses] +id = 13448 + +[rosemary] +id = 13437 + +[big_rosemary] +id = 13440 + +[daffodils] +id = 13438 + +[big_daffodils] +id = 13441 + +[bluebells] +id = 13439 + +[big_bluebells] +id = 13442 + +[oak_throne] +id = 13665 + +[teak_throne] +id = 13666 + +[mahogany_throne] +id = 13667 + +[gilded_throne] +id = 13668 + +[skeleton_throne] +id = 13669 + +[crystal_throne] +id = 13670 + +[demonic_throne] +id = 13671 + +[oak_lever] +id = 13672 + +[teak_lever] +id = 13673 + +[mahogany_lever] +id = 13674 + +[oak_trapdoor] +id = 13675 + +[teak_trapdoor] +id = 13676 + +[mahogany_trapdoor] +id = 13677 + +[round_shield_0] +id = 13734 + +[round_shield_1] +id = 13735 + +[round_shield_2] +id = 13736 + +[round_shield_3] +id = 13737 + +[round_shield_4] +id = 13738 + +[round_shield_5] +id = 13739 + +[round_shield_6] +id = 13740 + +[round_shield_7] +id = 13741 + +[round_shield_8] +id = 13742 + +[round_shield_9] +id = 13743 + +[round_shield_10] +id = 13744 + +[round_shield_11] +id = 13745 + +[round_shield_12] +id = 13746 + +[round_shield_13] +id = 13747 + +[round_shield_14] +id = 13748 + +[round_shield_15] +id = 13749 + +[square_shield_0] +id = 13766 + +[square_shield_1] +id = 13767 + +[square_shield_2] +id = 13768 + +[square_shield_3] +id = 13769 + +[square_shield_4] +id = 13770 + +[square_shield_5] +id = 13771 + +[square_shield_6] +id = 13772 + +[square_shield_7] +id = 13773 + +[square_shield_8] +id = 13774 + +[square_shield_9] +id = 13775 + +[square_shield_10] +id = 13776 + +[square_shield_11] +id = 13777 + +[square_shield_12] +id = 13778 + +[square_shield_13] +id = 13779 + +[square_shield_14] +id = 13780 + +[square_shield_15] +id = 13781 + +[kite_shield_0] +id = 13750 + +[kite_shield_1] +id = 13751 + +[kite_shield_2] +id = 13752 + +[kite_shield_3] +id = 13753 + +[kite_shield_4] +id = 13754 + +[kite_shield_5] +id = 13755 + +[kite_shield_6] +id = 13756 + +[kite_shield_7] +id = 13757 + +[kite_shield_8] +id = 13758 + +[kite_shield_9] +id = 13759 + +[kite_shield_10] +id = 13760 + +[kite_shield_11] +id = 13761 + +[kite_shield_12] +id = 13762 + +[kite_shield_13] +id = 13763 + +[kite_shield_14] +id = 13764 + +[kite_shield_15] +id = 13765 + +[floor_decoration_basic_wood] +id = 13689 + +[floor_decoration_basic_stone] +id = 13686 + +[floor_decoration_whitewashed_stone] +id = 13687 + +[floor_decoration_fremennik_wood] +id = 13688 + +[floor_decoration_tropical_wood] +id = 13684 + +[floor_decoration_fancy_stone] +id = 13685 + +[skeleton_guard] +id = 13366 + +[guard_dog] +id = 13367 + +[hobgoblin_guard] +id = 13368 + +[baby_red_dragon] +id = 13372 + +[huge_spider] +id = 13370 + +[troll_guard] +id = 13369 + +[hellhound] +id = 2715 + +[oak_ladder] +id = 13328 + +[teak_ladder] +id = 13329 + +[mahogany_ladder] +id = 13330 + +[torches] +id = 13341 + +[skull_torches] +id = 13343 + +[spike_trap] +id = 13356 + +[man_trap] +id = 13357 + +[tangle_vine] +id = 13358 + +[marble_trap] +id = 13359 + +[teleport_trap] +id = 13360 + +[decorative_blood] +id = 13312 + +[decorative_pipe] +id = 13311 + +[hanging_skeleton] +id = 13310 + +[pit_dog] +id = 39260 + +[pit_ogre] +id = 39261 + +[pit_rock_protector] +id = 39262 + +[pit_scabarite] +id = 39263 + +[pit_black_demon] +id = 39264 + +[pit_iron_dragon] +id = 39265 + +[minor_pit_trap] +id = 39266 + +[major_pit_trap] +id = 39268 + +[superior_pit_trap] +id = 39270 + +[demon_guardian] +id = 13378 + +[kalphite_soldier_guardian] +id = 13374 + +[tok_xil_guardian] +id = 13377 + +[dagannoth_guardian] +id = 13376 + +[steel_dragon_guardian] +id = 13375 + +[wooden_treasure_crate] +id = 13283 + +[oak_treasure_room_chest] +id = 13285 + +[teak_treasure_room_chest] +id = 13287 + +[mahogany_treasure_room_chest] +id = 13289 + +[magic_treasure_room_chest] +id = 13291 diff --git a/data/skill/construction/construction.tables.toml b/data/skill/construction/construction.tables.toml index abc06b9e2b..3ad8ba0423 100644 --- a/data/skill/construction/construction.tables.toml +++ b/data/skill/construction/construction.tables.toml @@ -265,7 +265,561 @@ furniture = ["clay_fireplace", "stone_fireplace", "marble_fireplace"] [.parlour_curtain_space] furniture = ["torn_curtains", "curtains", "opulent_curtains"] -# Furniture keyed by build menu item, objects are placed on the hotspot of the same piece +[.garden_big_tree_space] +furniture = ["tree", "nice_tree", "oak_tree", "willow_tree", "maple_tree", "yew_tree", "magic_tree"] + +[.garden_tree_space] +clone = "garden_big_tree_space" +piece = 1 + +[.garden_big_plant_space] +furniture = ["fern_2", "bush", "tall_plant"] + +[.garden_big_plant_space_2] +furniture = ["short_plant", "large_leaf_plant", "huge_plant"] + +[.garden_small_plant_space] +furniture = ["plant", "small_fern", "fern"] + +[.garden_small_plant_space_2] +furniture = ["dock_leaf", "thistle", "reeds"] + +[.kitchen_stove_space] +furniture = ["firepit", "firepit_with_hook", "firepit_with_pot", "small_oven", "large_oven", "steel_range", "fancy_range"] + +[.kitchen_shelf_space] +furniture = ["wooden_shelves_1", "wooden_shelves_2", "wooden_shelves_3", "oak_shelves_1", "oak_shelves_2", "teak_shelves_1", "teak_shelves_2"] + +[.kitchen_shelf_space_2] +clone = "kitchen_shelf_space" + +[.kitchen_barrel_space] +furniture = ["beer_barrel", "cider_barrel", "asgarnian_ale_barrel", "greenmans_ale_barrel", "dragon_bitter_barrel", "chefs_delight_barrel"] + +[.kitchen_cat_basket_space] +furniture = ["cat_blanket", "cat_basket", "cushioned_basket"] + +[.kitchen_larder_space] +furniture = ["wooden_larder", "oak_larder", "teak_larder"] + +[.kitchen_sink_space] +furniture = ["pump_and_drain", "pump_and_tub", "sink"] + +[.kitchen_table_space] +furniture = ["wood_kitchen_table", "oak_kitchen_table", "teak_kitchen_table"] + +[.dining_room_table_space] +furniture = ["wood_dining_table", "oak_dining_table", "carved_oak_table", "teak_table", "carved_teak_table", "mahogany_table", "opulent_table"] + +[.dining_room_bench_space] +furniture = ["wooden_bench", "oak_bench", "carved_oak_bench", "teak_dining_bench", "carved_teak_bench", "mahogany_bench", "gilded_bench"] + +[.dining_room_bench_space_2] +clone = "dining_room_bench_space" + +[.dining_room_fireplace_space] +furniture = ["clay_fireplace", "stone_fireplace", "marble_fireplace"] + +[.dining_room_curtain_space] +furniture = ["torn_curtains", "curtains", "opulent_curtains"] + +[.dining_room_decoration_space] +furniture = ["oak_decoration", "teak_decoration", "gilded_decoration"] + +[.dining_room_bell_pull_space] +furniture = ["rope_bell_pull", "bell_pull", "posh_bell_pull"] + +[.workshop_workbench_space] +furniture = ["wooden_workbench", "oak_workbench", "steel_framed_bench", "bench_with_vice", "bench_with_lathe"] + +[.workshop_crafting_space] +furniture = ["crafting_table_1", "crafting_table_2", "crafting_table_3", "crafting_table_4"] + +[.workshop_tool_space] +furniture = ["tool_store_1", "tool_store_2", "tool_store_3", "tool_store_4", "tool_store_5"] +group = "workshop_tool" + +[.workshop_tool_space_2] +clone = "workshop_tool_space" + +[.workshop_tool_space_3] +clone = "workshop_tool_space" + +[.workshop_tool_space_4] +clone = "workshop_tool_space" + +[.workshop_tool_space_5] +clone = "workshop_tool_space" + +[.workshop_repair_space] +furniture = ["repair_bench", "whetstone", "armour_stand"] + +[.workshop_heraldry_space] +furniture = ["pluming_stand", "shield_easel", "banner_easel"] + +[.bedroom_bed_space] +furniture = ["wooden_bed", "oak_bed", "large_oak_bed", "teak_bed", "large_teak_bed", "four_poster", "gilded_4_poster"] + +[.bedroom_wardrobe_space] +furniture = ["shoe_box", "oak_drawers", "oak_wardrobe", "teak_drawers", "teak_wardrobe", "mahogany_drobe", "gilded_wardrobe"] + +[.bedroom_dresser_space] +furniture = ["shaving_stand", "oak_shaving_stand", "oak_dresser", "teak_dresser", "fancy_teak_dresser", "mahogany_dresser", "gilded_dresser"] + +[.bedroom_curtain_space] +furniture = ["torn_curtains", "curtains", "opulent_curtains"] + +[.bedroom_rug_space_corner] +furniture = ["brown_rug", "rug", "opulent_rug"] +group = "bedroom_rug" + +[.bedroom_rug_space_side] +clone = "bedroom_rug_space_corner" +piece = 1 + +[.bedroom_rug_space_middle] +clone = "bedroom_rug_space_corner" +piece = 2 + +[.bedroom_fireplace_space] +furniture = ["clay_fireplace", "stone_fireplace", "marble_fireplace"] + +[.bedroom_clock_space] +furniture = ["oak_clock", "teak_clock", "gilded_clock"] + +[.skill_hall_rug_space_corner] +furniture = ["brown_rug", "rug", "opulent_rug"] +group = "skill_hall_rug" + +[.skill_hall_rug_space_side] +clone = "skill_hall_rug_space_corner" +piece = 1 + +[.skill_hall_rug_space_middle] +clone = "skill_hall_rug_space_corner" +piece = 2 + +[.skill_hall_stair_space] +furniture = ["oak_staircase", "teak_staircase", "spiral_staircase", "marble_staircase", "marble_spiral"] + +[.skill_hall_stair_space_down] +clone = "skill_hall_stair_space" +piece = 1 + +[.skill_hall_head_trophy_space] +furniture = ["crawling_hand_3", "cockatrice_head_2", "basilisk_head_2", "kurask_head_2", "abyssal_head_2", "kbd_heads", "kq_head"] + +[.skill_hall_fishing_trophy_space] +furniture = ["mounted_bass", "mounted_swordfish", "mounted_shark"] + +[.skill_hall_decorative_armour_space] +furniture = ["basic_decorative_armour", "detailed_decorative_armour", "intricate_decorative_armour", "profound_decorative_armour"] + +[.skill_hall_armour_space] +furniture = ["mithril_armour", "adamant_armour", "rune_armour"] + +[.skill_hall_rune_case_space] +furniture = ["rune_case_1", "rune_case_2"] + +[.games_room_game_space] +furniture = ["jester", "treasure_hunt", "hangman_game"] + +[.games_room_prize_chest_space] +furniture = ["oak_prize_chest", "teak_prize_chest", "mahogany_chest_2"] + +[.games_room_stone_space] +furniture = ["clay_attack_stone", "attack_stone", "marble_att_stone"] + +[.games_room_elemental_balance_space] +furniture = ["magical_balance_1", "magical_balance_2", "magical_balance_3"] + +[.games_room_ranging_game_space] +furniture = ["hoop_and_stick", "dartboard", "archery_target"] + +[.combat_room_ring_rope_space] +furniture = ["boxing_ring", "fencing_ring", "combat_ring", "ranging_pedestals", "balance_beam"] +group = "combat_ring" + +[.combat_room_ring_rope_space_2] +clone = "combat_room_ring_rope_space" + +[.combat_room_ring_corner_space] +clone = "combat_room_ring_rope_space" +piece = 1 + +[.combat_room_ring_corner_space_2] +clone = "combat_room_ring_rope_space" +piece = 1 + +[.combat_room_ring_corner_space_3] +clone = "combat_room_ring_rope_space" +piece = 1 + +[.combat_room_ring_corner_space_4] +clone = "combat_room_ring_rope_space" +piece = 1 + +[.combat_room_ring_pedestal_rope_space] +clone = "combat_room_ring_rope_space" +piece = 2 + +[.combat_room_ring_pedestal_rope_space_2] +clone = "combat_room_ring_rope_space" +piece = 2 + +[.combat_room_ring_mat_corner_space] +clone = "combat_room_ring_rope_space" +piece = 3 + +[.combat_room_ring_mat_corner_space_2] +clone = "combat_room_ring_rope_space" +piece = 3 + +[.combat_room_ring_pedestal_space] +clone = "combat_room_ring_rope_space" +piece = 4 + +[.combat_room_ring_pedestal_space_2] +clone = "combat_room_ring_rope_space" +piece = 4 + +[.combat_room_ring_mat_side_space] +clone = "combat_room_ring_rope_space" +piece = 5 + +[.combat_room_ring_mat_side_space_2] +clone = "combat_room_ring_rope_space" +piece = 5 + +[.combat_room_ring_mat_side_space_3] +clone = "combat_room_ring_rope_space" +piece = 5 + +[.combat_room_ring_mat_middle_space] +clone = "combat_room_ring_rope_space" +piece = 6 + +[.combat_room_ring_barrier_space] +clone = "combat_room_ring_rope_space" +piece = 7 + +[.combat_room_ring_barrier_space_2] +clone = "combat_room_ring_rope_space" +piece = 8 + +[.combat_room_ring_beam_space] +clone = "combat_room_ring_rope_space" +piece = 9 + +[.combat_room_storage_space] +furniture = ["glove_rack", "weapons_rack", "extra_weapons_rack"] + +[.combat_room_decoration_space] +furniture = ["oak_decoration", "teak_decoration", "gilded_decoration"] + +[.quest_hall_rug_space_corner] +furniture = ["brown_rug", "rug", "opulent_rug"] +group = "quest_hall_rug" + +[.quest_hall_rug_space_side] +clone = "quest_hall_rug_space_corner" +piece = 1 + +[.quest_hall_rug_space_middle] +clone = "quest_hall_rug_space_corner" +piece = 2 + +[.quest_hall_stair_space] +furniture = ["oak_staircase", "teak_staircase", "spiral_staircase", "marble_staircase", "marble_spiral"] + +[.quest_hall_stair_space_down] +clone = "quest_hall_stair_space" +piece = 1 + +[.quest_hall_portrait_space] +furniture = ["king_arthur", "elena", "giant_dwarf", "miscellanians"] + +[.quest_hall_landscape_space] +furniture = ["lumbridge", "the_desert", "morytania", "karamja", "isafdar"] + +[.quest_hall_guild_trophy_space] +furniture = ["anti_dragon_shield_mounted", "amulet_of_glory_mounted", "cape_of_legends_2"] + +[.quest_hall_sword_space] +furniture = ["silverlight_2", "excalibur_2", "darklight_2"] + +[.quest_hall_map_space] +furniture = ["small_map_2", "medium_map_2", "large_map_2"] + +[.quest_hall_bookcase_space] +furniture = ["wooden_bookcase", "oak_bookcase", "mahogany_bkcase"] + +[.menagerie_habitat_space] +furniture = ["garden_habitat", "jungle_habitat", "desert_habitat", "polar_habitat", "volcanic_habitat"] +group = "menagerie_habitat" + +[.menagerie_habitat_space_2] +clone = "menagerie_habitat_space" +piece = 1 + +[.menagerie_pet_house_space] +furniture = ["oak_pet_house", "teak_pet_house", "mahogany_pet_house", "consecrated_pet_house", "desecrated_pet_house", "natural_pet_house"] + +[.menagerie_pet_feeder_space] +furniture = ["oak_pet_feeder", "teak_pet_feeder", "mahogany_pet_feeder"] + +[.menagerie_obelisk_space] +furniture = ["mini_obelisk"] + +[.study_lectern_space] +furniture = ["oak_lectern", "eagle_lectern", "demon_lectern", "teak_eagle_lectern", "teak_demon_lectern", "mahogany_eagle", "mahogany_demon"] + +[.study_globe_space] +furniture = ["globe", "ornamental_globe", "lunar_globe", "celestial_globe", "armillary_sphere", "small_orrery", "large_orrery"] + +[.study_crystal_ball_space] +furniture = ["crystal_ball", "elemental_sphere", "crystal_of_power"] + +[.study_wall_chart_space] +furniture = ["alchemical_chart", "astronomical_chart", "infernal_chart"] + +[.study_telescope_space] +furniture = ["wooden_telescope", "teak_telescope", "mahogany_scope"] + +[.study_bookcase_space] +furniture = ["wooden_bookcase", "oak_bookcase", "mahogany_bkcase"] + +[.study_statue_space] +furniture = ["statue_2"] + +[.costume_room_cape_rack_space] +furniture = ["oak_cape_rack", "teak_cape_rack", "mgany_cape_rack", "gilded_cape_rack", "marble_cape_rack", "magical_cape_rack"] + +[.costume_room_magic_wardrobe_space] +furniture = ["oak_magic_wardrobe", "carved_oak_magic_wardrobe", "teak_magic_wardrobe", "carved_teak_magic_wardrobe", "mahogany_magic_wardrobe", "gilded_magic_wardrobe", "marble_magic_wardrobe"] + +[.costume_room_toy_box_space] +furniture = ["oak_toy_box", "teak_toy_box", "mahogany_toy_box"] + +[.costume_room_treasure_chest_space] +furniture = ["oak_treasure_chest", "teak_treas_chest", "mgany_treas_chest"] + +[.costume_room_fancy_dress_box_space] +furniture = ["oak_costume_box", "teak_costume_box", "mahogany_cos_box"] + +[.costume_room_armour_case_space] +furniture = ["oak_armour_case", "teak_armour_case", "mgany_armr_case"] + +[.chapel_altar_space] +furniture = ["oak_altar", "teak_altar", "cloth_coverd_altar", "mahogany_altar", "limestone_altar", "marble_altar", "gilded_altar"] + +[.chapel_lamp_space] +furniture = ["steel_torches", "wooden_torches", "steel_candlesticks", "gold_candlesticks", "incense_burners", "mahogany_burners", "marble_burners"] + +[.chapel_icon_space] +furniture = ["guthix_symbol", "saradomin_symbol", "zamorak_symbol", "guthix_icon", "saradomin_icon", "zamorak_icon", "icon_of_bob"] + +[.chapel_musical_space] +furniture = ["windchimes", "bells", "organ"] + +[.chapel_statue_space] +furniture = ["small_statues", "medium_statues", "large_statues"] + +[.chapel_rug_space_corner] +furniture = ["brown_rug", "rug", "opulent_rug"] +group = "chapel_rug" + +[.chapel_rug_space_side] +clone = "chapel_rug_space_corner" +piece = 1 + +[.chapel_window_space] +furniture = ["shuttered_window", "decorative_window", "stained_glass"] + +[.chapel_window_space_2] +clone = "chapel_window_space" +piece = 1 + +[.chapel_window_space_3] +clone = "chapel_window_space" +piece = 2 + +[.chapel_window_space_4] +clone = "chapel_window_space" +piece = 3 + +[.chapel_window_space_5] +clone = "chapel_window_space" +piece = 4 + +[.chapel_window_space_6] +clone = "chapel_window_space" +piece = 5 + +[.portal_chamber_portal_space] +furniture = ["teak_portal", "mahogany_portal", "marble_portal"] + +[.portal_chamber_portal_space_2] +clone = "portal_chamber_portal_space" + +[.portal_chamber_portal_space_3] +clone = "portal_chamber_portal_space" + +[.portal_chamber_centrepiece_space] +furniture = ["teleport_focus", "greater_focus", "scrying_pool"] + +[.formal_garden_centrepiece_space] +furniture = ["exit_portal", "gazebo", "dungeon_entrance", "small_fountain", "large_fountain", "posh_fountain"] + +[.formal_garden_fencing_space] +furniture = ["boundary_stones", "wooden_fence", "stone_wall", "iron_railings", "picket_fence", "garden_fence", "marble_wall"] + +[.formal_garden_hedging_space] +furniture = ["thorny_hedge", "nice_hedge", "small_box_hedge", "topiary_hedge", "fancy_hedge", "tall_fancy_hedge", "tall_box_hedge"] +group = "formal_garden_hedging" + +[.formal_garden_hedging_space_2] +clone = "formal_garden_hedging_space" +piece = 1 + +[.formal_garden_hedging_space_3] +clone = "formal_garden_hedging_space" +piece = 2 + +[.formal_garden_small_plant_space] +furniture = ["sunflower", "marigolds_2", "roses"] + +[.formal_garden_big_plant_space] +clone = "formal_garden_small_plant_space" +piece = 1 + +[.formal_garden_small_plant_space_2] +furniture = ["rosemary_2", "daffodils", "bluebells"] + +[.formal_garden_big_plant_space_2] +clone = "formal_garden_small_plant_space_2" +piece = 1 + +[.throne_room_throne_space] +furniture = ["oak_throne", "teak_throne", "mahogany_throne", "gilded_throne", "skeleton_throne", "crystal_throne", "demonic_throne"] + +[.throne_room_lever_space] +furniture = ["oak_lever", "teak_lever", "mahogany_lever"] + +[.throne_room_trapdoor_space] +furniture = ["trapdoor", "trapdoor_2", "trapdoor_3"] + +[.throne_room_seating_space] +furniture = ["carved_teak_bench", "mahogany_bench", "gilded_bench"] +piece = 1 + +[.throne_room_seating_space_2] +clone = "throne_room_seating_space" +piece = 1 + +[.throne_room_decoration_space] +furniture = ["oak_decoration", "teak_decoration", "gilded_decoration", "round_shield", "square_shield", "kite_shield"] + +[.throne_room_floor_space] +furniture = ["floor_decoration", "steel_cage_2", "trapdoor_4", "lesser_magic_cage", "greater_magic_cage"] + +[.throne_room_floor_space_2] +clone = "throne_room_floor_space" +piece = 1 + +[.throne_room_floor_space_3] +clone = "throne_room_floor_space" +piece = 2 + +[.throne_room_floor_space_4] +clone = "throne_room_floor_space" +piece = 3 + +[.throne_room_floor_space_5] +clone = "throne_room_floor_space" +piece = 4 + +[.throne_room_floor_space_6] +clone = "throne_room_floor_space" +piece = 5 + +[.oubliette_guard_space] +furniture = ["skeleton_guard", "guard_dog", "hobgoblin_guard", "baby_red_dragon", "huge_spider", "troll_guard", "hellhound"] + +[.oubliette_ladder_space] +furniture = ["oak_ladder", "teak_ladder", "mahogany_ladder"] + +[.oubliette_lighting_space] +furniture = ["candles", "torches", "skull_torches"] + +[.dungeon_guard_space] +furniture = ["skeleton_guard", "guard_dog", "hobgoblin_guard", "baby_red_dragon", "huge_spider", "troll_guard", "hellhound"] + +[.dungeon_guard_space_2] +clone = "dungeon_guard_space" + +[.dungeon_guard_space_3] +clone = "dungeon_guard_space" + +[.dungeon_trap_space] +furniture = ["spike_trap", "man_trap", "tangle_vine", "marble_trap", "teleport_trap"] + +[.dungeon_trap_space_2] +clone = "dungeon_trap_space" + +[.dungeon_door_space_left] +furniture = ["oak_door", "steel_plated_door", "marble_door"] +group = "dungeon_door" + +[.dungeon_door_space_right] +clone = "dungeon_door_space_left" +piece = 1 + +[.dungeon_door_space_left_2] +clone = "dungeon_door_space_left" + +[.dungeon_door_space_right_2] +clone = "dungeon_door_space_left" +piece = 1 + +[.dungeon_lighting_space] +furniture = ["candles", "torches", "skull_torches"] + +[.dungeon_lighting_space_2] +clone = "dungeon_lighting_space" + +[.dungeon_decoration_space] +furniture = ["decorative_blood", "decorative_pipe", "hanging_skeleton"] + +[.dungeon_pit_guardian_space] +furniture = ["pit_dog", "pit_ogre", "pit_rock_protector", "pit_scabarite", "pit_black_demon", "pit_iron_dragon"] + +[.dungeon_pit_trap_space] +furniture = ["minor_pit_trap", "major_pit_trap", "superior_pit_trap"] + +[.dungeon_pit_trap_space_2] +clone = "dungeon_pit_trap_space" + +[.dungeon_pit_trap_space_3] +clone = "dungeon_pit_trap_space" + +[.dungeon_pit_trap_space_4] +clone = "dungeon_pit_trap_space" + +[.treasure_room_monster_space] +furniture = ["demon", "kalphite_soldier", "tok_xil", "dagannoth", "steel_dragon"] + +[.treasure_room_treasure_space] +furniture = ["wooden_crate", "oak_chest", "teak_chest", "mahogany_chest", "magic_chest"] + +[.treasure_room_decoration_space] +furniture = ["round_shield", "square_shield", "kite_shield"] + +# Furniture keyed by build menu item, objects are placed on the hotspot of the same piece, or the listed pieces +# Pieces without an object are left empty, as are tiles without one of the anchor pieces when listed +# Rotations are turns added to each objects hotspot rotation +# Variant is a player variable which picks the object instead of the piece +# Levels and xp from https://runescape.wiki/w/Construction historical room pages [house_furniture] row_id = "item" level = "int" @@ -274,6 +828,10 @@ materials = "list" amounts = "list" nails = "int" objects = "list" +pieces = "list" +anchors = "list" +rotations = "list" +variant = "string" [.exit_portal] level = 1 @@ -447,3 +1005,2603 @@ xp = 3150 materials = ["teak_plank", "bolt_of_cloth"] amounts = [3, 3] objects = ["opulent_curtains"] + +[.tree] +level = 5 +xp = 310 +materials = ["bagged_dead_tree"] +amounts = [1] +objects = ["dead_tree_20", "dead_tree_21"] + +[.nice_tree] +level = 10 +xp = 440 +materials = ["bagged_nice_tree"] +amounts = [1] +objects = ["big_nice_tree", "nice_tree"] + +[.oak_tree] +level = 15 +xp = 700 +materials = ["bagged_oak_tree"] +amounts = [1] +objects = ["big_oak_tree", "oak_tree"] + +[.willow_tree] +level = 30 +xp = 1000 +materials = ["bagged_willow_tree"] +amounts = [1] +objects = ["big_willow_tree", "willow_tree"] + +[.maple_tree] +level = 45 +xp = 1220 +materials = ["bagged_maple_tree"] +amounts = [1] +objects = ["big_maple_tree", "house_maple_tree"] + +[.yew_tree] +level = 60 +xp = 1410 +materials = ["bagged_yew_tree"] +amounts = [1] +objects = ["big_yew_tree", "yew_tree"] + +[.magic_tree] +level = 75 +xp = 2230 +materials = ["bagged_magic_tree"] +amounts = [1] +objects = ["big_magic_tree", "house_magic_tree"] + +[.fern_2] +level = 1 +xp = 310 +materials = ["bagged_plant_1"] +amounts = [1] +objects = ["fern_4"] + +[.bush] +level = 6 +xp = 700 +materials = ["bagged_plant_2"] +amounts = [1] +objects = ["house_bush"] + +[.tall_plant] +level = 12 +xp = 1000 +materials = ["bagged_plant_3"] +amounts = [1] +objects = ["tall_plant"] + +[.short_plant] +level = 1 +xp = 310 +materials = ["bagged_plant_1"] +amounts = [1] +objects = ["short_plant"] + +[.large_leaf_plant] +level = 6 +xp = 700 +materials = ["bagged_plant_2"] +amounts = [1] +objects = ["large_leaf_plant"] + +[.huge_plant] +level = 12 +xp = 1000 +materials = ["bagged_plant_3"] +amounts = [1] +objects = ["huge_plant"] + +[.plant] +level = 1 +xp = 310 +materials = ["bagged_plant_1"] +amounts = [1] +objects = ["plant"] + +[.small_fern] +level = 6 +xp = 700 +materials = ["bagged_plant_2"] +amounts = [1] +objects = ["small_fern_2"] + +[.fern] +level = 12 +xp = 1000 +materials = ["bagged_plant_3"] +amounts = [1] +objects = ["fern_5"] + +[.dock_leaf] +level = 1 +xp = 310 +materials = ["bagged_plant_1"] +amounts = [1] +objects = ["dock_leaf"] + +[.thistle] +level = 6 +xp = 700 +materials = ["bagged_plant_2"] +amounts = [1] +objects = ["house_thistle"] + +[.reeds] +level = 12 +xp = 1000 +materials = ["bagged_plant_3"] +amounts = [1] +objects = ["reeds"] + +[.firepit] +level = 5 +xp = 400 +materials = ["steel_bar", "soft_clay"] +amounts = [1, 2] +objects = ["firepit"] + +[.firepit_with_hook] +level = 11 +xp = 600 +materials = ["steel_bar", "soft_clay"] +amounts = [2, 2] +objects = ["firepit_with_hook"] + +[.firepit_with_pot] +level = 17 +xp = 800 +materials = ["steel_bar", "soft_clay"] +amounts = [3, 2] +objects = ["firepit_with_pot"] + +[.small_oven] +level = 24 +xp = 800 +materials = ["steel_bar"] +amounts = [4] +objects = ["small_oven"] + +[.large_oven] +level = 29 +xp = 1000 +materials = ["steel_bar"] +amounts = [5] +objects = ["large_oven"] + +[.steel_range] +level = 34 +xp = 1200 +materials = ["steel_bar"] +amounts = [6] +objects = ["steel_range"] + +[.fancy_range] +level = 42 +xp = 1600 +materials = ["steel_bar"] +amounts = [8] +objects = ["fancy_range"] + +[.wooden_shelves_1] +level = 6 +xp = 870 +materials = ["plank"] +amounts = [3] +nails = 3 +objects = ["wooden_shelves_1"] + +[.wooden_shelves_2] +level = 12 +xp = 1470 +materials = ["plank", "soft_clay"] +amounts = [3, 6] +nails = 3 +objects = ["wooden_shelves_2"] + +[.wooden_shelves_3] +level = 23 +xp = 1470 +materials = ["plank", "soft_clay"] +amounts = [3, 6] +nails = 3 +objects = ["wooden_shelves_3"] + +[.oak_shelves_1] +level = 34 +xp = 2400 +materials = ["oak_plank", "soft_clay"] +amounts = [3, 6] +objects = ["oak_shelves_1"] + +[.oak_shelves_2] +level = 45 +xp = 2400 +materials = ["oak_plank", "soft_clay"] +amounts = [3, 6] +objects = ["oak_shelves_2"] + +[.teak_shelves_1] +level = 56 +xp = 3300 +materials = ["teak_plank", "soft_clay"] +amounts = [3, 6] +objects = ["teak_shelves_1"] + +[.teak_shelves_2] +level = 67 +xp = 9300 +materials = ["teak_plank", "soft_clay", "gold_leaf"] +amounts = [3, 6, 2] +objects = ["teak_shelves_2"] + +[.beer_barrel] +level = 7 +xp = 870 +materials = ["plank"] +amounts = [3] +nails = 3 +objects = ["beer_barrel"] + +[.cider_barrel] +level = 12 +xp = 910 +materials = ["plank", "cider"] +amounts = [3, 8] +nails = 3 +objects = ["cider_barrel"] + +[.asgarnian_ale_barrel] +level = 18 +xp = 1840 +materials = ["oak_plank", "asgarnian_ale"] +amounts = [3, 8] +objects = ["asgarnian_ale_barrel"] + +[.greenmans_ale_barrel] +level = 26 +xp = 1840 +materials = ["oak_plank", "greenmans_ale"] +amounts = [3, 8] +objects = ["greenmans_ale_barrel"] + +[.dragon_bitter_barrel] +level = 36 +xp = 2240 +materials = ["oak_plank", "steel_bar", "dragon_bitter"] +amounts = [3, 2, 8] +objects = ["dragon_bitter_barrel"] + +[.chefs_delight_barrel] +level = 48 +xp = 2240 +materials = ["oak_plank", "steel_bar", "chefs_delight"] +amounts = [3, 2, 8] +objects = ["chefs_delight_barrel"] + +[.cat_blanket] +level = 5 +xp = 150 +materials = ["bolt_of_cloth"] +amounts = [1] +objects = ["cat_blanket"] + +[.cat_basket] +level = 19 +xp = 580 +materials = ["plank", "bolt_of_cloth"] +amounts = [2, 1] +objects = ["cat_basket"] + +[.cushioned_basket] +level = 33 +xp = 580 +materials = ["plank", "bolt_of_cloth", "wool"] +amounts = [2, 2, 2] +objects = ["cushioned_basket"] + +[.wooden_larder] +level = 9 +xp = 2280 +materials = ["plank"] +amounts = [8] +nails = 8 +objects = ["wooden_larder"] + +[.oak_larder] +level = 33 +xp = 4800 +materials = ["oak_plank"] +amounts = [8] +objects = ["oak_larder"] + +[.teak_larder] +level = 43 +xp = 7500 +materials = ["teak_plank", "bolt_of_cloth"] +amounts = [8, 2] +objects = ["teak_larder"] + +[.pump_and_drain] +level = 7 +xp = 1000 +materials = ["steel_bar"] +amounts = [5] +objects = ["pump_and_drain"] + +[.pump_and_tub] +level = 27 +xp = 2000 +materials = ["steel_bar"] +amounts = [10] +objects = ["pump_and_tub"] + +[.sink] +level = 47 +xp = 3000 +materials = ["steel_bar"] +amounts = [15] +objects = ["sink_9"] + +[.wood_kitchen_table] +level = 12 +xp = 870 +materials = ["plank"] +amounts = [3] +nails = 3 +objects = ["wood_kitchen_table"] + +[.oak_kitchen_table] +level = 32 +xp = 1800 +materials = ["oak_plank"] +amounts = [3] +objects = ["oak_kitchen_table"] + +[.teak_kitchen_table] +level = 52 +xp = 2700 +materials = ["teak_plank"] +amounts = [3] +objects = ["teak_kitchen_table"] + +[.wood_dining_table] +level = 10 +xp = 1150 +materials = ["plank"] +amounts = [4] +nails = 4 +objects = ["wood_dining_table"] + +[.oak_dining_table] +level = 22 +xp = 2400 +materials = ["oak_plank"] +amounts = [4] +objects = ["oak_dining_table"] + +[.carved_oak_table] +level = 31 +xp = 3600 +materials = ["oak_plank"] +amounts = [6] +objects = ["carved_oak_table"] + +[.teak_table] +level = 38 +xp = 3600 +materials = ["teak_plank"] +amounts = [4] +objects = ["teak_table"] + +[.carved_teak_table] +level = 45 +xp = 6000 +materials = ["teak_plank", "bolt_of_cloth"] +amounts = [6, 4] +objects = ["carved_teak_table"] + +[.mahogany_table] +level = 52 +xp = 8400 +materials = ["mahogany_plank"] +amounts = [6] +objects = ["mahogany_table"] + +[.opulent_table] +level = 72 +xp = 31000 +materials = ["mahogany_plank", "bolt_of_cloth", "gold_leaf", "marble_block"] +amounts = [6, 4, 4, 2] +objects = ["opulent_table"] + +[.wooden_bench] +level = 10 +xp = 1150 +materials = ["plank"] +amounts = [4] +nails = 4 +objects = ["wooden_bench"] + +[.oak_bench] +level = 22 +xp = 2400 +materials = ["oak_plank"] +amounts = [4] +objects = ["oak_bench"] + +[.carved_oak_bench] +level = 31 +xp = 2400 +materials = ["oak_plank"] +amounts = [4] +objects = ["carved_oak_bench"] + +[.teak_dining_bench] +level = 38 +xp = 3600 +materials = ["teak_plank"] +amounts = [4] +objects = ["teak_dining_bench"] + +[.carved_teak_bench] +level = 44 +xp = 3600 +materials = ["teak_plank"] +amounts = [4] +objects = ["carved_teak_bench", "carved_teak_bench_throne_room"] + +[.mahogany_bench] +level = 52 +xp = 5600 +materials = ["mahogany_plank"] +amounts = [4] +objects = ["mahogany_bench", "mahogany_bench_throne_room"] + +[.gilded_bench] +level = 61 +xp = 17600 +materials = ["mahogany_plank", "gold_leaf"] +amounts = [4, 4] +objects = ["gilded_bench", "gilded_bench_throne_room"] + +[.oak_decoration] +level = 16 +xp = 1200 +materials = ["oak_plank"] +amounts = [2] +objects = ["oak_decoration"] + +[.teak_decoration] +level = 36 +xp = 1800 +materials = ["teak_plank"] +amounts = [2] +objects = ["teak_decoration"] + +[.gilded_decoration] +level = 56 +xp = 10200 +materials = ["mahogany_plank", "gold_leaf"] +amounts = [3, 2] +objects = ["gilded_decoration"] + +[.rope_bell_pull] +level = 26 +xp = 640 +materials = ["oak_plank", "rope"] +amounts = [1, 1] +objects = ["rope_bell_pull"] + +[.bell_pull] +level = 37 +xp = 1200 +materials = ["teak_plank", "bolt_of_cloth"] +amounts = [1, 2] +objects = ["bell_pull"] + +[.posh_bell_pull] +level = 60 +xp = 4200 +materials = ["teak_plank", "bolt_of_cloth", "gold_leaf"] +amounts = [1, 2, 1] +objects = ["posh_bell_pull"] + +[.wooden_workbench] +level = 17 +xp = 1450 +materials = ["plank"] +amounts = [5] +nails = 5 +objects = ["wooden_workbench"] + +[.oak_workbench] +level = 32 +xp = 3000 +materials = ["oak_plank"] +amounts = [5] +objects = ["oak_workbench"] + +[.steel_framed_bench] +level = 46 +xp = 4400 +materials = ["oak_plank", "steel_bar"] +amounts = [6, 4] +objects = ["steel_framed_bench"] + +[.bench_with_vice] +level = 62 +xp = 7500 +materials = ["oak_plank", "steel_bar"] +amounts = [2, 1] +objects = ["bench_with_vice"] + +[.bench_with_lathe] +level = 77 +xp = 10000 +materials = ["oak_plank", "steel_bar"] +amounts = [2, 1] +objects = ["bench_with_lathe"] + +[.crafting_table_1] +level = 16 +xp = 2400 +materials = ["oak_plank"] +amounts = [4] +objects = ["crafting_table_1"] + +[.crafting_table_2] +level = 25 +xp = 1000 +materials = ["molten_glass"] +amounts = [1] +objects = ["crafting_table_2"] + +[.crafting_table_3] +level = 34 +xp = 1750 +materials = ["molten_glass"] +amounts = [2] +objects = ["crafting_table_3"] + +[.crafting_table_4] +level = 42 +xp = 2400 +materials = ["oak_plank"] +amounts = [2] +objects = ["crafting_table_4"] + +[.tool_store_1] +level = 15 +xp = 1200 +materials = ["oak_plank"] +amounts = [2] +objects = ["tool_store_1"] + +[.tool_store_2] +level = 25 +xp = 1200 +materials = ["oak_plank"] +amounts = [2] +objects = ["tool_store_2"] + +[.tool_store_3] +level = 35 +xp = 1200 +materials = ["oak_plank"] +amounts = [2] +objects = ["tool_store_3"] + +[.tool_store_4] +level = 44 +xp = 1200 +materials = ["oak_plank"] +amounts = [2] +objects = ["tool_store_4"] + +[.tool_store_5] +level = 55 +xp = 1200 +materials = ["oak_plank"] +amounts = [2] +objects = ["tool_store_5"] + +[.repair_bench] +level = 15 +xp = 1200 +materials = ["oak_plank"] +amounts = [2] +objects = ["repair_bench"] + +[.whetstone] +level = 35 +xp = 2600 +materials = ["oak_plank", "limestone_brick"] +amounts = [4, 1] +objects = ["whetstone"] + +[.armour_stand] +level = 55 +xp = 5000 +materials = ["oak_plank", "limestone_brick"] +amounts = [8, 1] +objects = ["armour_stand"] + +[.pluming_stand] +level = 16 +xp = 1200 +materials = ["oak_plank"] +amounts = [2] +objects = ["pluming_stand"] + +[.shield_easel] +level = 41 +xp = 2400 +materials = ["oak_plank"] +amounts = [4] +objects = ["shield_easel"] + +[.banner_easel] +level = 66 +xp = 5100 +materials = ["oak_plank", "bolt_of_cloth"] +amounts = [8, 2] +objects = ["banner_easel"] + +[.wooden_bed] +level = 20 +xp = 1170 +materials = ["plank", "bolt_of_cloth"] +amounts = [3, 2] +nails = 3 +objects = ["wooden_bed"] + +[.oak_bed] +level = 30 +xp = 2100 +materials = ["oak_plank", "bolt_of_cloth"] +amounts = [3, 2] +objects = ["oak_bed"] + +[.large_oak_bed] +level = 34 +xp = 3300 +materials = ["oak_plank", "bolt_of_cloth"] +amounts = [5, 2] +objects = ["large_oak_bed"] + +[.teak_bed] +level = 40 +xp = 3000 +materials = ["teak_plank", "bolt_of_cloth"] +amounts = [3, 2] +objects = ["teak_bed"] + +[.large_teak_bed] +level = 45 +xp = 4800 +materials = ["teak_plank", "bolt_of_cloth"] +amounts = [5, 2] +objects = ["large_teak_bed"] + +[.four_poster] +level = 53 +xp = 4500 +materials = ["mahogany_plank", "bolt_of_cloth"] +amounts = [3, 2] +objects = ["four_poster_bed"] + +[.gilded_4_poster] +level = 60 +xp = 13300 +materials = ["mahogany_plank", "bolt_of_cloth", "gold_leaf"] +amounts = [5, 2, 2] +objects = ["gilded_four_poster_bed"] + +[.shoe_box] +level = 20 +xp = 580 +materials = ["plank"] +amounts = [2] +nails = 2 +objects = ["shoe_box"] + +[.oak_drawers] +level = 27 +xp = 1200 +materials = ["oak_plank"] +amounts = [2] +objects = ["oak_drawers"] + +[.oak_wardrobe] +level = 39 +xp = 1800 +materials = ["oak_plank"] +amounts = [3] +objects = ["oak_wardrobe"] + +[.teak_drawers] +level = 51 +xp = 1800 +materials = ["teak_plank"] +amounts = [2] +objects = ["teak_drawers"] + +[.teak_wardrobe] +level = 63 +xp = 2700 +materials = ["teak_plank"] +amounts = [3] +objects = ["teak_wardrobe"] + +[.mahogany_drobe] +level = 75 +xp = 4200 +materials = ["mahogany_plank"] +amounts = [3] +objects = ["mahogany_wardrobe"] + +[.gilded_wardrobe] +level = 87 +xp = 7200 +materials = ["mahogany_plank", "gold_leaf"] +amounts = [3, 1] +objects = ["gilded_wardrobe"] + +[.shaving_stand] +level = 21 +xp = 300 +materials = ["plank", "molten_glass"] +amounts = [1, 1] +nails = 1 +objects = ["shaving_stand"] + +[.oak_shaving_stand] +level = 29 +xp = 610 +materials = ["oak_plank", "molten_glass"] +amounts = [1, 1] +objects = ["oak_shaving_stand"] + +[.oak_dresser] +level = 37 +xp = 1210 +materials = ["oak_plank", "molten_glass"] +amounts = [2, 1] +objects = ["oak_dresser"] + +[.teak_dresser] +level = 46 +xp = 1810 +materials = ["teak_plank", "molten_glass"] +amounts = [2, 1] +objects = ["teak_dresser"] + +[.fancy_teak_dresser] +level = 56 +xp = 1820 +materials = ["teak_plank", "molten_glass"] +amounts = [2, 2] +objects = ["fancy_teak_dresser"] + +[.mahogany_dresser] +level = 64 +xp = 2810 +materials = ["mahogany_plank", "molten_glass"] +amounts = [2, 1] +objects = ["mahogany_dresser"] + +[.gilded_dresser] +level = 74 +xp = 5820 +materials = ["mahogany_plank", "molten_glass", "gold_leaf"] +amounts = [2, 2, 1] +objects = ["gilded_dresser"] + +[.oak_clock] +level = 25 +xp = 1420 +materials = ["oak_plank", "clockwork"] +amounts = [2, 1] +objects = ["oak_clock"] + +[.teak_clock] +level = 55 +xp = 2020 +materials = ["teak_plank", "clockwork"] +amounts = [2, 1] +objects = ["teak_clock"] + +[.gilded_clock] +level = 85 +xp = 6020 +materials = ["mahogany_plank", "clockwork", "gold_leaf"] +amounts = [2, 1, 1] +objects = ["gilded_clock"] + +[.oak_staircase] +level = 27 +xp = 6800 +materials = ["oak_plank", "steel_bar"] +amounts = [10, 4] +objects = ["oak_staircase", "oak_staircase_down"] + +[.teak_staircase] +level = 48 +xp = 9800 +materials = ["teak_plank", "steel_bar"] +amounts = [10, 4] +objects = ["teak_staircase", "teak_staircase_down"] + +[.spiral_staircase] +level = 67 +xp = 10400 +materials = ["teak_plank", "limestone_brick"] +amounts = [10, 7] +objects = ["spiral_staircase", "spiral_staircase_down"] + +[.marble_staircase] +level = 82 +xp = 32000 +materials = ["mahogany_plank", "marble_block"] +amounts = [5, 5] +objects = ["marble_staircase", "marble_staircase_down"] + +[.marble_spiral] +level = 97 +xp = 44000 +materials = ["teak_plank", "marble_block"] +amounts = [10, 7] +objects = ["marble_spiral", "marble_spiral_down"] + +[.crawling_hand_3] +level = 38 +xp = 2110 +materials = ["teak_plank", "crawling_hand_stuffed"] +amounts = [2, 1] +objects = ["crawling_hand_trophy"] + +[.cockatrice_head_2] +level = 38 +xp = 2240 +materials = ["teak_plank", "cockatrice_head_stuffed"] +amounts = [2, 1] +objects = ["cockatrice_head_trophy"] + +[.basilisk_head_2] +level = 38 +xp = 2430 +materials = ["teak_plank", "basilisk_head_stuffed"] +amounts = [2, 1] +objects = ["basilisk_head_trophy"] + +[.kurask_head_2] +level = 58 +xp = 3570 +materials = ["mahogany_plank", "kurask_head_stuffed"] +amounts = [2, 1] +objects = ["kurask_head_trophy"] + +[.abyssal_head_2] +level = 58 +xp = 3890 +materials = ["mahogany_plank", "abyssal_head_stuffed"] +amounts = [2, 1] +objects = ["abyssal_head_trophy"] + +[.kbd_heads] +level = 78 +xp = 11030 +materials = ["mahogany_plank", "gold_leaf", "king_black_dragon_head_stuffed"] +amounts = [2, 2, 1] +objects = ["kbd_heads_trophy"] + +[.kq_head] +level = 78 +xp = 11030 +materials = ["mahogany_plank", "gold_leaf", "kalphite_queen_head_stuffed"] +amounts = [2, 2, 1] +objects = ["kq_head_trophy"] + +[.mounted_bass] +level = 36 +xp = 1510 +materials = ["oak_plank", "big_bass_stuffed"] +amounts = [2, 1] +objects = ["mounted_bass"] + +[.mounted_swordfish] +level = 56 +xp = 2300 +materials = ["teak_plank", "big_swordfish_stuffed"] +amounts = [2, 1] +objects = ["mounted_swordfish"] + +[.mounted_shark] +level = 76 +xp = 3500 +materials = ["mahogany_plank", "big_shark_stuffed"] +amounts = [2, 1] +objects = ["mounted_shark"] + +[.basic_decorative_armour] +level = 28 +xp = 1350 +materials = ["oak_plank", "basic_decorative_platebody", "basic_decorative_shield", "basic_decorative_helm"] +amounts = [2, 1, 1, 1] +objects = ["basic_decorative_armour"] + +[.detailed_decorative_armour] +level = 28 +xp = 1500 +materials = ["oak_plank", "detailed_decorative_platebody", "detailed_decorative_shield", "detailed_decorative_helm"] +amounts = [2, 1, 1, 1] +objects = ["detailed_decorative_armour"] + +[.intricate_decorative_armour] +level = 28 +xp = 1650 +materials = ["oak_plank", "intricate_decorative_platebody", "intricate_decorative_shield", "intricate_decorative_helm"] +amounts = [2, 1, 1, 1] +objects = ["intricate_decorative_armour"] + +[.profound_decorative_armour] +level = 28 +xp = 1800 +materials = ["oak_plank", "profound_decorative_platebody", "profound_decorative_shield", "profound_decorative_helm"] +amounts = [2, 1, 1, 1] +objects = ["profound_decorative_armour"] + +[.mithril_armour] +level = 28 +xp = 1350 +materials = ["oak_plank", "mithril_platebody", "mithril_plateskirt", "mithril_full_helm"] +amounts = [2, 1, 1, 1] +objects = ["mithril_armour"] + +[.adamant_armour] +level = 28 +xp = 1500 +materials = ["oak_plank", "adamant_platebody", "adamant_plateskirt", "adamant_full_helm"] +amounts = [2, 1, 1, 1] +objects = ["adamant_armour"] + +[.rune_armour] +level = 28 +xp = 1650 +materials = ["oak_plank", "rune_platebody", "rune_plateskirt", "rune_full_helm"] +amounts = [2, 1, 1, 1] +objects = ["rune_armour"] + +[.rune_case_1] +level = 41 +xp = 1900 +materials = ["teak_plank", "molten_glass", "air_rune", "water_rune", "earth_rune", "fire_rune"] +amounts = [2, 2, 1, 1, 1, 1] +objects = ["rune_case_1"] + +[.rune_case_2] +level = 41 +xp = 2120 +materials = ["teak_plank", "molten_glass", "body_rune", "cosmic_rune", "chaos_rune", "nature_rune"] +amounts = [2, 2, 1, 1, 1, 1] +objects = ["rune_case_2"] + +[.jester] +level = 39 +xp = 3600 +materials = ["teak_plank"] +amounts = [4] +objects = ["jester"] + +[.treasure_hunt] +level = 49 +xp = 8000 +materials = ["teak_plank", "steel_bar"] +amounts = [8, 4] +objects = ["treasure_hunt"] + +[.hangman_game] +level = 59 +xp = 12000 +materials = ["teak_plank", "steel_bar"] +amounts = [12, 6] +objects = ["hangman_game"] + +[.oak_prize_chest] +level = 34 +xp = 2400 +materials = ["oak_plank"] +amounts = [4] +objects = ["oak_prize_chest"] + +[.teak_prize_chest] +level = 44 +xp = 6600 +materials = ["teak_plank", "gold_leaf"] +amounts = [4, 1] +objects = ["teak_prize_chest"] + +[.mahogany_chest_2] +level = 54 +xp = 8600 +materials = ["mahogany_plank", "gold_leaf"] +amounts = [4, 1] +objects = ["mahogany_prize_chest"] + +[.clay_attack_stone] +level = 39 +xp = 1000 +materials = ["soft_clay"] +amounts = [10] +objects = ["clay_attack_stone"] + +[.attack_stone] +level = 59 +xp = 2000 +materials = ["limestone_brick"] +amounts = [10] +objects = ["limestone_attack_stone"] + +[.marble_att_stone] +level = 79 +xp = 20000 +materials = ["marble_block"] +amounts = [4] +objects = ["marble_attack_stone"] + +[.magical_balance_1] +level = 37 +xp = 1760 +materials = ["air_rune", "water_rune", "earth_rune", "fire_rune"] +amounts = [500, 500, 500, 500] +objects = ["elemental_balance_1"] + +[.magical_balance_2] +level = 57 +xp = 2520 +materials = ["air_rune", "water_rune", "earth_rune", "fire_rune"] +amounts = [1000, 1000, 1000, 1000] +objects = ["elemental_balance_2"] + +[.magical_balance_3] +level = 77 +xp = 3560 +materials = ["air_rune", "water_rune", "earth_rune", "fire_rune"] +amounts = [2000, 2000, 2000, 2000] +objects = ["elemental_balance_3"] + +[.hoop_and_stick] +level = 30 +xp = 1200 +materials = ["oak_plank"] +amounts = [2] +objects = ["hoop_and_stick"] + +[.dartboard] +level = 54 +xp = 2900 +materials = ["teak_plank", "steel_bar"] +amounts = [3, 1] +objects = ["dartboard"] + +[.archery_target] +level = 81 +xp = 6000 +materials = ["teak_plank", "steel_bar"] +amounts = [6, 3] +objects = ["house_archery_target"] + +[.boxing_ring] +level = 32 +xp = 5700 +materials = ["oak_plank", "bolt_of_cloth"] +amounts = [6, 4] +objects = ["boxing_ring", "boxing_ring", "boxing_ring", "boxing_mat_corner", "boxing_mat_corner", "boxing_mat_side", "boxing_mat_middle"] + +[.fencing_ring] +level = 41 +xp = 5700 +materials = ["oak_plank", "bolt_of_cloth"] +amounts = [8, 6] +objects = ["fencing_ring", "fencing_ring", "fencing_ring", "fencing_ring_corner", "fencing_ring_corner", "fencing_ring_side", "fencing_ring_middle"] + +[.combat_ring] +level = 51 +xp = 6300 +materials = ["teak_plank", "bolt_of_cloth"] +amounts = [6, 6] +objects = ["combat_ring", "combat_ring", "combat_ring", "combat_ring_corner", "combat_ring_corner", "combat_ring_side", "combat_ring_middle"] + +[.ranging_pedestals] +level = 71 +xp = 7200 +materials = ["teak_plank"] +amounts = [8] +objects = ["magic_barrier", "ranging_spot", "magic_barrier", "magic_barrier"] # guessed +pieces = [2, 4, 7, 8] + +[.balance_beam] +level = 81 +xp = 10000 +materials = ["teak_plank", "steel_bar"] +amounts = [10, 5] +objects = ["balance_beam_end", "balance_beam"] # guessed +pieces = [5, 6] +anchors = [8, 9] +rotations = [0, 1] + +[.glove_rack] +level = 34 +xp = 1200 +materials = ["oak_plank"] +amounts = [2] +objects = ["glove_rack"] + +[.weapons_rack] +level = 44 +xp = 1800 +materials = ["teak_plank"] +amounts = [2] +objects = ["weapons_rack"] + +[.extra_weapons_rack] +level = 54 +xp = 4400 +materials = ["teak_plank", "steel_bar"] +amounts = [4, 4] +objects = ["extra_weapons_rack"] + +[.king_arthur] +level = 35 +xp = 2110 +materials = ["teak_plank", "arthur_portrait"] +amounts = [2, 1] +objects = ["king_arthur_portrait"] + +[.elena] +level = 35 +xp = 2110 +materials = ["teak_plank", "elena_portrait"] +amounts = [2, 1] +objects = ["elena_portrait"] + +[.giant_dwarf] +level = 35 +xp = 2110 +materials = ["teak_plank", "keldagrim_portrait"] +amounts = [2, 1] +objects = ["giant_dwarf_portrait"] + +[.miscellanians] +level = 55 +xp = 3110 +materials = ["mahogany_plank", "misc_portrait"] +amounts = [2, 1] +objects = ["miscellanians_portrait"] + +[.lumbridge] +level = 44 +xp = 3140 +materials = ["teak_plank", "lumbridge_painting"] +amounts = [3, 1] +objects = ["lumbridge_painting"] + +[.the_desert] +level = 44 +xp = 3140 +materials = ["teak_plank", "desert_painting"] +amounts = [3, 1] +objects = ["desert_painting"] + +[.morytania] +level = 44 +xp = 3140 +materials = ["teak_plank", "morytania_painting"] +amounts = [3, 1] +objects = ["morytania_painting"] + +[.karamja] +level = 65 +xp = 4640 +materials = ["mahogany_plank", "karamja_painting"] +amounts = [3, 1] +objects = ["karamja_painting"] + +[.isafdar] +level = 65 +xp = 4640 +materials = ["mahogany_plank", "isafdar_painting"] +amounts = [3, 1] +objects = ["isafdar_painting"] + +[.anti_dragon_shield_mounted] +level = 47 +xp = 2800 +materials = ["teak_plank", "anti_dragon_shield"] +amounts = [3, 1] +objects = ["anti_dragon_shield_mounted"] + +[.amulet_of_glory_mounted] +level = 47 +xp = 2900 +materials = ["teak_plank", "amulet_of_glory"] +amounts = [3, 1] +objects = ["amulet_of_glory_mounted"] + +[.cape_of_legends_2] +level = 47 +xp = 3000 +materials = ["teak_plank", "cape_of_legends"] +amounts = [3, 1] +objects = ["cape_of_legends_mounted"] + +[.silverlight_2] +level = 42 +xp = 1870 +materials = ["teak_plank", "silverlight"] +amounts = [2, 1] +objects = ["silverlight_mounted"] + +[.excalibur_2] +level = 42 +xp = 1940 +materials = ["teak_plank", "excalibur"] +amounts = [2, 1] +objects = ["excalibur_mounted"] + +[.darklight_2] +level = 42 +xp = 2020 +materials = ["teak_plank", "darklight"] +amounts = [2, 1] +objects = ["darklight_mounted"] + +[.small_map_2] +level = 38 +xp = 2110 +materials = ["teak_plank", "small_map"] +amounts = [2, 1] +objects = ["small_map"] + +[.medium_map_2] +level = 58 +xp = 4510 +materials = ["mahogany_plank", "medium_map"] +amounts = [3, 1] +objects = ["medium_map"] + +[.large_map_2] +level = 78 +xp = 5910 +materials = ["mahogany_plank", "large_map"] +amounts = [4, 1] +objects = ["large_map"] + +[.garden_habitat] +level = 37 +xp = 2010 +materials = ["bagged_plant_1", "bagged_plant_2", "bagged_plant_3"] +amounts = [1, 1, 1] +objects = ["garden_habitat", "garden_habitat_2"] + +[.jungle_habitat] +level = 47 +xp = 2780 +materials = ["bagged_willow_tree", "bucket_of_water", "bagged_plant_3"] +amounts = [1, 5, 3] +objects = ["jungle_habitat", "jungle_habitat_2"] + +[.desert_habitat] +level = 57 +xp = 2380 +materials = ["bucket_of_sand", "limestone_brick", "bagged_cactus"] +amounts = [10, 5, 1] +objects = ["desert_habitat", "desert_habitat_2"] + +[.polar_habitat] +level = 67 +xp = 3730 +materials = ["air_rune", "water_rune", "bagged_evergreen"] +amounts = [1000, 1000, 1] +objects = ["polar_habitat", "polar_habitat_2"] + +[.volcanic_habitat] +level = 77 +xp = 770 +materials = ["bagged_dead_tree", "lava_rune", "stone_slab"] +amounts = [1, 100, 5] +objects = ["volcanic_habitat", "volcanic_habitat_2"] + +[.oak_pet_house] +level = 37 +xp = 2400 +materials = ["oak_plank"] +amounts = [4] +objects = ["oak_pet_house"] + +[.teak_pet_house] +level = 52 +xp = 3800 +materials = ["teak_plank"] +amounts = [4] +objects = ["teak_pet_house"] + +[.mahogany_pet_house] +level = 67 +xp = 5800 +materials = ["mahogany_plank"] +amounts = [4] +objects = ["mahogany_pet_house"] + +[.consecrated_pet_house] +level = 92 +xp = 15800 +materials = ["mahogany_plank", "magic_stone"] +amounts = [4, 1] +objects = ["consecrated_pet_house"] + +[.desecrated_pet_house] +level = 92 +xp = 15800 +materials = ["mahogany_plank", "magic_stone"] +amounts = [4, 1] +objects = ["desecrated_pet_house"] + +[.natural_pet_house] +level = 92 +xp = 15800 +materials = ["mahogany_plank", "magic_stone"] +amounts = [4, 1] +objects = ["natural_pet_house"] + +[.oak_pet_feeder] +level = 37 +xp = 2400 +materials = ["oak_plank"] +amounts = [4] +objects = ["oak_pet_feeder"] + +[.teak_pet_feeder] +level = 52 +xp = 3800 +materials = ["teak_plank"] +amounts = [4] +objects = ["teak_pet_feeder"] + +[.mahogany_pet_feeder] +level = 67 +xp = 8800 +materials = ["mahogany_plank", "gold_leaf"] +amounts = [4, 1] +objects = ["mahogany_pet_feeder"] + +[.mini_obelisk] +level = 41 +xp = 6760 +materials = ["marble_block", "spirit_shards", "gold_charm", "green_charm", "crimson_charm", "blue_charm"] +amounts = [1, 1000, 10, 10, 10, 10] +objects = ["house_small_obelisk"] + +[.oak_lectern] +level = 40 +xp = 600 +materials = ["oak_plank"] +amounts = [1] +objects = ["oak_lectern"] + +[.eagle_lectern] +level = 47 +xp = 1200 +materials = ["oak_plank"] +amounts = [2] +objects = ["eagle_lectern"] + +[.demon_lectern] +level = 47 +xp = 1200 +materials = ["oak_plank"] +amounts = [2] +objects = ["demon_lectern"] + +[.teak_eagle_lectern] +level = 57 +xp = 1800 +materials = ["teak_plank"] +amounts = [2] +objects = ["teak_eagle_lectern"] + +[.teak_demon_lectern] +level = 57 +xp = 1800 +materials = ["teak_plank"] +amounts = [2] +objects = ["teak_demon_lectern"] + +[.mahogany_eagle] +level = 67 +xp = 5800 +materials = ["mahogany_plank", "gold_leaf"] +amounts = [2, 1] +objects = ["mahogany_eagle_lectern"] + +[.mahogany_demon] +level = 67 +xp = 5800 +materials = ["mahogany_plank", "gold_leaf"] +amounts = [2, 1] +objects = ["mahogany_demon_lectern"] + +[.globe] +level = 41 +xp = 1800 +materials = ["oak_plank"] +amounts = [3] +objects = ["globe"] + +[.ornamental_globe] +level = 50 +xp = 2700 +materials = ["teak_plank"] +amounts = [3] +objects = ["ornamental_globe"] + +[.lunar_globe] +level = 59 +xp = 5700 +materials = ["teak_plank", "gold_leaf"] +amounts = [3, 1] +objects = ["lunar_globe"] + +[.celestial_globe] +level = 68 +xp = 5700 +materials = ["teak_plank", "gold_leaf"] +amounts = [3, 1] +objects = ["celestial_globe"] + +[.armillary_sphere] +level = 77 +xp = 9600 +materials = ["mahogany_plank", "gold_leaf", "steel_bar"] +amounts = [2, 2, 4] +objects = ["armillary_sphere"] + +[.small_orrery] +level = 86 +xp = 13200 +materials = ["mahogany_plank", "gold_leaf"] +amounts = [3, 3] +objects = ["small_orrery"] + +[.large_orrery] +level = 95 +xp = 14200 +materials = ["mahogany_plank", "marble_block"] +amounts = [3, 2] +objects = ["large_orrery"] + +[.crystal_ball] +level = 42 +xp = 2800 +materials = ["teak_plank", "unpowered_orb"] +amounts = [3, 1] +objects = ["crystal_ball"] + +[.elemental_sphere] +level = 54 +xp = 5800 +materials = ["teak_plank", "unpowered_orb", "gold_leaf"] +amounts = [3, 1, 1] +objects = ["elemental_sphere"] + +[.crystal_of_power] +level = 66 +xp = 8900 +materials = ["mahogany_plank", "unpowered_orb", "gold_leaf"] +amounts = [2, 1, 2] +objects = ["crystal_of_power"] + +[.alchemical_chart] +level = 43 +xp = 300 +materials = ["bolt_of_cloth"] +amounts = [2] +objects = ["alchemical_chart"] + +[.astronomical_chart] +level = 63 +xp = 450 +materials = ["bolt_of_cloth"] +amounts = [3] +objects = ["astronomical_chart"] + +[.infernal_chart] +level = 83 +xp = 600 +materials = ["bolt_of_cloth"] +amounts = [4] +objects = ["infernal_chart"] + +[.wooden_telescope] +level = 44 +xp = 1210 +materials = ["oak_plank", "molten_glass"] +amounts = [2, 1] +objects = ["wooden_telescope"] + +[.teak_telescope] +level = 64 +xp = 1810 +materials = ["teak_plank", "molten_glass"] +amounts = [2, 1] +objects = ["teak_telescope"] + +[.mahogany_scope] +level = 84 +xp = 2810 +materials = ["mahogany_plank", "gold_leaf"] +amounts = [2, 1] +objects = ["mahogany_telescope"] + +[.statue_2] +level = 1 +xp = 15 +materials = ["statue_plinth"] +amounts = [1] +objects = ["dahmaroc_statue_plinth"] + +[.oak_cape_rack] +level = 54 +xp = 2400 +materials = ["oak_plank"] +amounts = [4] +objects = ["oak_cape_rack"] + +[.teak_cape_rack] +level = 63 +xp = 3600 +materials = ["teak_plank"] +amounts = [4] +objects = ["teak_cape_rack"] + +[.mgany_cape_rack] +level = 72 +xp = 5600 +materials = ["mahogany_plank"] +amounts = [4] +objects = ["mahogany_cape_rack"] + +[.gilded_cape_rack] +level = 81 +xp = 8600 +materials = ["mahogany_plank", "gold_leaf"] +amounts = [4, 1] +objects = ["gilded_cape_rack"] + +[.marble_cape_rack] +level = 90 +xp = 5000 +materials = ["marble_block"] +amounts = [1] +objects = ["marble_cape_rack"] + +[.magical_cape_rack] +level = 99 +xp = 10000 +materials = ["magic_stone"] +amounts = [1] +objects = ["magic_cape_rack"] + +[.oak_magic_wardrobe] +level = 42 +xp = 2400 +materials = ["oak_plank"] +amounts = [4] +objects = ["oak_magic_wardrobe"] + +[.carved_oak_magic_wardrobe] +level = 51 +xp = 3600 +materials = ["oak_plank"] +amounts = [6] +objects = ["carved_oak_magic_wardrobe"] + +[.teak_magic_wardrobe] +level = 60 +xp = 3600 +materials = ["teak_plank"] +amounts = [4] +objects = ["teak_magic_wardrobe"] + +[.carved_teak_magic_wardrobe] +level = 69 +xp = 5400 +materials = ["teak_plank"] +amounts = [6] +objects = ["carved_teak_magic_wardrobe"] + +[.mahogany_magic_wardrobe] +level = 78 +xp = 5400 +materials = ["mahogany_plank"] +amounts = [4] +objects = ["mahogany_magic_wardrobe"] + +[.gilded_magic_wardrobe] +level = 87 +xp = 8600 +materials = ["mahogany_plank", "gold_leaf"] +amounts = [4, 1] +objects = ["gilded_magic_wardrobe"] + +[.marble_magic_wardrobe] +level = 96 +xp = 5000 +materials = ["marble_block"] +amounts = [1] +objects = ["marble_magic_wardrobe"] + +[.oak_toy_box] +level = 50 +xp = 1200 +materials = ["oak_plank"] +amounts = [2] +objects = ["oak_toy_box"] + +[.teak_toy_box] +level = 68 +xp = 1800 +materials = ["teak_plank"] +amounts = [2] +objects = ["teak_toy_box"] + +[.mahogany_toy_box] +level = 86 +xp = 2800 +materials = ["mahogany_plank"] +amounts = [2] +objects = ["mahogany_toy_box"] + +[.oak_treasure_chest] +level = 48 +xp = 1200 +materials = ["oak_plank"] +amounts = [2] +objects = ["oak_treasure_chest"] + +[.teak_treas_chest] +level = 66 +xp = 1800 +materials = ["teak_plank"] +amounts = [2] +objects = ["teak_treasure_chest"] + +[.mgany_treas_chest] +level = 84 +xp = 2800 +materials = ["mahogany_plank"] +amounts = [2] +objects = ["mahogany_treasure_chest"] + +[.oak_costume_box] +level = 44 +xp = 1200 +materials = ["oak_plank"] +amounts = [2] +objects = ["oak_fancy_dress_box"] + +[.teak_costume_box] +level = 62 +xp = 1800 +materials = ["teak_plank"] +amounts = [2] +objects = ["teak_fancy_dress_box"] + +[.mahogany_cos_box] +level = 80 +xp = 2800 +materials = ["mahogany_plank"] +amounts = [2] +objects = ["mahogany_fancy_dress_box"] + +[.oak_armour_case] +level = 46 +xp = 1800 +materials = ["oak_plank"] +amounts = [3] +objects = ["oak_armour_case"] + +[.teak_armour_case] +level = 64 +xp = 2700 +materials = ["teak_plank"] +amounts = [3] +objects = ["teak_armour_case"] + +[.mgany_armr_case] +level = 82 +xp = 4200 +materials = ["mahogany_plank"] +amounts = [3] +objects = ["mahogany_armour_case"] + +[.oak_altar] +level = 45 +xp = 2400 +materials = ["oak_plank"] +amounts = [4] +objects = ["prayer_altar_oak_saradomin"] + +[.teak_altar] +level = 50 +xp = 3600 +materials = ["teak_plank"] +amounts = [4] +objects = ["prayer_altar_teak_saradomin"] + +[.cloth_coverd_altar] +level = 56 +xp = 3900 +materials = ["teak_plank", "bolt_of_cloth"] +amounts = [4, 2] +objects = ["prayer_altar_cloth_saradomin"] + +[.mahogany_altar] +level = 60 +xp = 5900 +materials = ["mahogany_plank", "bolt_of_cloth"] +amounts = [4, 2] +objects = ["prayer_altar_mahogany_saradomin"] + +[.limestone_altar] +level = 64 +xp = 9100 +materials = ["mahogany_plank", "limestone_brick", "bolt_of_cloth"] +amounts = [6, 2, 2] +objects = ["prayer_altar_limestone_saradomin"] + +[.marble_altar] +level = 70 +xp = 10300 +materials = ["marble_block", "bolt_of_cloth"] +amounts = [2, 2] +objects = ["prayer_altar_marble_saradomin"] + +[.gilded_altar] +level = 75 +xp = 22300 +materials = ["marble_block", "gold_leaf", "bolt_of_cloth"] +amounts = [2, 4, 2] +objects = ["prayer_altar_gilded_saradomin"] + +[.steel_torches] +level = 45 +xp = 800 +materials = ["steel_bar"] +amounts = [2] +objects = ["steel_torches"] + +[.wooden_torches] +level = 49 +xp = 580 +materials = ["plank"] +amounts = [2] +nails = 2 +objects = ["wooden_torches"] + +[.steel_candlesticks] +level = 53 +xp = 1240 +materials = ["steel_bar", "white_candle"] +amounts = [6, 6] +objects = ["steel_candlesticks"] + +[.gold_candlesticks] +level = 57 +xp = 460 +materials = ["gold_bar", "white_candle"] +amounts = [6, 6] +objects = ["gold_candlesticks"] + +[.incense_burners] +level = 61 +xp = 2800 +materials = ["oak_plank", "steel_bar"] +amounts = [4, 2] +objects = ["incense_burners"] + +[.mahogany_burners] +level = 65 +xp = 6000 +materials = ["mahogany_plank", "steel_bar"] +amounts = [4, 2] +objects = ["mahogany_burners"] + +[.marble_burners] +level = 69 +xp = 10400 +materials = ["marble_block", "steel_bar"] +amounts = [2, 2] +objects = ["marble_burners"] + +[.guthix_symbol] +level = 48 +xp = 1200 +materials = ["oak_plank"] +amounts = [2] +objects = ["guthix_symbol"] + +[.saradomin_symbol] +level = 48 +xp = 1200 +materials = ["oak_plank"] +amounts = [2] +objects = ["saradomin_symbol"] + +[.zamorak_symbol] +level = 48 +xp = 1200 +materials = ["oak_plank"] +amounts = [2] +objects = ["zamorak_symbol"] + +[.guthix_icon] +level = 59 +xp = 9600 +materials = ["teak_plank", "gold_leaf"] +amounts = [4, 2] +objects = ["guthix_icon"] + +[.saradomin_icon] +level = 59 +xp = 9600 +materials = ["teak_plank", "gold_leaf"] +amounts = [4, 2] +objects = ["saradomin_icon"] + +[.zamorak_icon] +level = 59 +xp = 9600 +materials = ["teak_plank", "gold_leaf"] +amounts = [4, 2] +objects = ["zamorak_icon"] + +[.icon_of_bob] +level = 71 +xp = 11600 +materials = ["mahogany_plank", "gold_leaf"] +amounts = [4, 2] +objects = ["icon_of_bob"] + +[.windchimes] +level = 49 +xp = 3230 +materials = ["oak_plank", "steel_bar"] +amounts = [4, 4] +nails = 4 +objects = ["windchimes"] + +[.bells] +level = 58 +xp = 4800 +materials = ["teak_plank", "steel_bar"] +amounts = [4, 6] +objects = ["bells"] + +[.organ] +level = 69 +xp = 6800 +materials = ["mahogany_plank", "steel_bar"] +amounts = [4, 6] +objects = ["organ"] + +[.small_statues] +level = 49 +xp = 400 +materials = ["limestone_brick"] +amounts = [2] +objects = ["small_statue"] # guessed + +[.medium_statues] +level = 69 +xp = 5000 +materials = ["marble_block"] +amounts = [1] +objects = ["medium_statue"] # guessed + +[.large_statues] +level = 89 +xp = 15000 +materials = ["marble_block"] +amounts = [3] +objects = ["large_statue"] # guessed + +[.shuttered_window] +level = 49 +xp = 2280 +materials = ["plank"] +amounts = [8] +nails = 8 +objects = ["shuttered_window_basic_wood", "shuttered_window_basic_stone", "shuttered_window_whitewashed_stone", "shuttered_window_fremennik_wood", "shuttered_window_tropical_wood", "shuttered_window_fancy_stone"] + +[.decorative_window] +level = 69 +xp = 40 +materials = ["molten_glass"] +amounts = [8] +objects = ["decorative_window_basic_wood", "decorative_window_basic_stone", "decorative_window_whitewashed_stone", "decorative_window_fremennik_wood", "decorative_window_tropical_wood", "decorative_window_fancy_stone"] + +[.stained_glass] +level = 89 +xp = 50 +materials = ["molten_glass"] +amounts = [16] +objects = ["stained_glass_basic_wood", "stained_glass_basic_stone", "stained_glass_whitewashed_stone", "stained_glass_fremennik_wood", "stained_glass_tropical_wood", "stained_glass_fancy_stone"] + +[.teak_portal] +level = 50 +xp = 2700 +materials = ["teak_plank"] +amounts = [3] +objects = ["teak_portal"] + +[.mahogany_portal] +level = 65 +xp = 4200 +materials = ["mahogany_plank"] +amounts = [3] +objects = ["mahogany_portal"] + +[.marble_portal] +level = 80 +xp = 15000 +materials = ["marble_block"] +amounts = [3] +objects = ["marble_portal"] + +[.teleport_focus] +level = 50 +xp = 400 +materials = ["limestone_brick"] +amounts = [2] +objects = ["teleport_focus"] + +[.greater_focus] +level = 65 +xp = 5000 +materials = ["marble_block"] +amounts = [1] +objects = ["greater_teleport_focus"] + +[.scrying_pool] +level = 80 +xp = 20000 +materials = ["marble_block"] +amounts = [4] +objects = ["scrying_pool"] + +[.gazebo] +level = 65 +xp = 12000 +materials = ["mahogany_plank", "steel_bar"] +amounts = [8, 4] +objects = ["gazebo"] + +[.small_fountain] +level = 71 +xp = 5000 +materials = ["marble_block"] +amounts = [1] +objects = ["fountain_small"] + +[.large_fountain] +level = 75 +xp = 10000 +materials = ["marble_block"] +amounts = [2] +objects = ["fountain_large"] + +[.posh_fountain] +level = 81 +xp = 15000 +materials = ["marble_block"] +amounts = [3] +objects = ["fountain_ornamental"] + +[.boundary_stones] +level = 55 +xp = 1000 +materials = ["soft_clay"] +amounts = [10] +objects = ["boundary_stones"] + +[.wooden_fence] +level = 59 +xp = 2800 +materials = ["plank"] +amounts = [10] +objects = ["wooden_fence"] + +[.stone_wall] +level = 63 +xp = 2000 +materials = ["limestone_brick"] +amounts = [10] +objects = ["stone_wall"] + +[.iron_railings] +level = 67 +xp = 2200 +materials = ["iron_bar", "limestone_brick"] +amounts = [10, 6] +objects = ["iron_railings"] + +[.picket_fence] +level = 71 +xp = 6400 +materials = ["oak_plank", "steel_bar"] +amounts = [10, 2] +objects = ["picket_fence"] + +[.garden_fence] +level = 75 +xp = 9400 +materials = ["teak_plank", "steel_bar"] +amounts = [10, 2] +objects = ["garden_fence"] + +[.marble_wall] +level = 79 +xp = 40000 +materials = ["marble_block"] +amounts = [8] +objects = ["marble_wall"] + +[.thorny_hedge] +level = 56 +xp = 700 +materials = ["thorny_hedge_bagged"] +amounts = [1] +objects = ["thorny_hedge", "thorny_hedge_2", "thorny_hedge_3"] + +[.nice_hedge] +level = 60 +xp = 1000 +materials = ["nice_hedge_bagged"] +amounts = [1] +objects = ["nice_hedge", "nice_hedge_2", "nice_hedge_3"] + +[.small_box_hedge] +level = 64 +xp = 1220 +materials = ["small_box_hedge_bagged"] +amounts = [1] +objects = ["small_box_hedge", "small_box_hedge_2", "small_box_hedge_3"] + +[.topiary_hedge] +level = 68 +xp = 1410 +materials = ["topiary_hedge_bagged"] +amounts = [1] +objects = ["topiary_hedge", "topiary_hedge_2", "topiary_hedge_3"] + +[.fancy_hedge] +level = 72 +xp = 1580 +materials = ["fancy_hedge_bagged"] +amounts = [1] +objects = ["fancy_hedge", "fancy_hedge_2", "fancy_hedge_3"] + +[.tall_fancy_hedge] +level = 76 +xp = 2230 +materials = ["tall_fancy_hedge_bagged"] +amounts = [1] +objects = ["tall_fancy_hedge", "tall_fancy_hedge_2", "tall_fancy_hedge_3"] + +[.tall_box_hedge] +level = 80 +xp = 3160 +materials = ["tall_box_hedge_bagged"] +amounts = [1] +objects = ["tall_box_hedge", "tall_box_hedge_2", "tall_box_hedge_3"] + +[.sunflower] +level = 66 +xp = 700 +materials = ["bagged_sunflower"] +amounts = [1] +objects = ["sunflower", "big_sunflower"] + +[.marigolds_2] +level = 71 +xp = 1000 +materials = ["bagged_marigolds"] +amounts = [1] +objects = ["marigolds", "big_marigolds"] + +[.roses] +level = 76 +xp = 1220 +materials = ["bagged_roses"] +amounts = [1] +objects = ["roses", "big_roses"] + +[.rosemary_2] +level = 66 +xp = 700 +materials = ["bagged_rosemary"] +amounts = [1] +objects = ["rosemary", "big_rosemary"] + +[.daffodils] +level = 71 +xp = 1000 +materials = ["bagged_daffodils"] +amounts = [1] +objects = ["daffodils", "big_daffodils"] + +[.bluebells] +level = 76 +xp = 1220 +materials = ["bagged_bluebells"] +amounts = [1] +objects = ["bluebells", "big_bluebells"] + +[.oak_throne] +level = 60 +xp = 8000 +materials = ["oak_plank", "marble_block"] +amounts = [5, 1] +objects = ["oak_throne"] + +[.teak_throne] +level = 67 +xp = 14500 +materials = ["teak_plank", "marble_block"] +amounts = [5, 2] +objects = ["teak_throne"] + +[.mahogany_throne] +level = 74 +xp = 22000 +materials = ["mahogany_plank", "marble_block"] +amounts = [5, 3] +objects = ["mahogany_throne"] + +[.gilded_throne] +level = 81 +xp = 26000 +materials = ["mahogany_plank", "marble_block", "gold_leaf"] +amounts = [5, 2, 3] +objects = ["gilded_throne"] + +[.skeleton_throne] +level = 88 +xp = 70030 +materials = ["magic_stone", "marble_block", "bones", "skull"] +amounts = [5, 4, 5, 2] +objects = ["skeleton_throne"] + +[.crystal_throne] +level = 95 +xp = 150000 +materials = ["magic_stone"] +amounts = [15] +objects = ["crystal_throne"] + +[.demonic_throne] +level = 99 +xp = 250000 +materials = ["magic_stone"] +amounts = [25] +objects = ["demonic_throne"] + +[.oak_lever] +level = 68 +xp = 3000 +materials = ["oak_plank"] +amounts = [5] +objects = ["oak_lever"] + +[.teak_lever] +level = 78 +xp = 4500 +materials = ["teak_plank"] +amounts = [5] +objects = ["teak_lever"] + +[.mahogany_lever] +level = 88 +xp = 7000 +materials = ["mahogany_plank"] +amounts = [5] +objects = ["mahogany_lever"] + +[.trapdoor] +level = 68 +xp = 3000 +materials = ["oak_plank"] +amounts = [5] +objects = ["oak_trapdoor"] # guessed + +[.trapdoor_2] +level = 78 +xp = 4500 +materials = ["teak_plank"] +amounts = [5] +objects = ["teak_trapdoor"] # guessed + +[.trapdoor_3] +level = 88 +xp = 7000 +materials = ["mahogany_plank"] +amounts = [5] +objects = ["mahogany_trapdoor"] # guessed + +[.round_shield] +level = 66 +xp = 1200 +materials = ["oak_plank"] +amounts = [2] +objects = ["round_shield_0", "round_shield_1", "round_shield_2", "round_shield_3", "round_shield_4", "round_shield_5", "round_shield_6", "round_shield_7", "round_shield_8", "round_shield_9", "round_shield_10", "round_shield_11", "round_shield_12", "round_shield_13", "round_shield_14", "round_shield_15"] # guessed +variant = "heraldry_crest" + +[.square_shield] +level = 76 +xp = 3600 +materials = ["teak_plank"] +amounts = [4] +objects = ["square_shield_0", "square_shield_1", "square_shield_2", "square_shield_3", "square_shield_4", "square_shield_5", "square_shield_6", "square_shield_7", "square_shield_8", "square_shield_9", "square_shield_10", "square_shield_11", "square_shield_12", "square_shield_13", "square_shield_14", "square_shield_15"] # guessed +variant = "heraldry_crest" + +[.kite_shield] +level = 86 +xp = 4200 +materials = ["mahogany_plank"] +amounts = [3] +objects = ["kite_shield_0", "kite_shield_1", "kite_shield_2", "kite_shield_3", "kite_shield_4", "kite_shield_5", "kite_shield_6", "kite_shield_7", "kite_shield_8", "kite_shield_9", "kite_shield_10", "kite_shield_11", "kite_shield_12", "kite_shield_13", "kite_shield_14", "kite_shield_15"] # guessed +variant = "heraldry_crest" + +[.floor_decoration] +level = 61 +xp = 7000 +materials = ["mahogany_plank"] +amounts = [5] +objects = ["floor_decoration_basic_wood", "floor_decoration_basic_stone", "floor_decoration_whitewashed_stone", "floor_decoration_fremennik_wood", "floor_decoration_tropical_wood", "floor_decoration_fancy_stone"] + +[.steel_cage_2] +level = 68 +xp = 11000 +materials = ["mahogany_plank", "steel_bar"] +amounts = [5, 20] +objects = ["floor_decoration_basic_wood", "floor_decoration_basic_stone", "floor_decoration_whitewashed_stone", "floor_decoration_fremennik_wood", "floor_decoration_tropical_wood", "floor_decoration_fancy_stone"] + +[.trapdoor_4] +level = 74 +xp = 7700 +materials = ["mahogany_plank", "clockwork"] +amounts = [5, 10] +objects = ["floor_decoration_basic_wood", "floor_decoration_basic_stone", "floor_decoration_whitewashed_stone", "floor_decoration_fremennik_wood", "floor_decoration_tropical_wood", "floor_decoration_fancy_stone"] + +[.lesser_magic_cage] +level = 82 +xp = 27000 +materials = ["mahogany_plank", "magic_stone"] +amounts = [5, 2] +objects = ["floor_decoration_basic_wood", "floor_decoration_basic_stone", "floor_decoration_whitewashed_stone", "floor_decoration_fremennik_wood", "floor_decoration_tropical_wood", "floor_decoration_fancy_stone"] + +[.greater_magic_cage] +level = 89 +xp = 47000 +materials = ["mahogany_plank", "magic_stone"] +amounts = [5, 4] +objects = ["floor_decoration_basic_wood", "floor_decoration_basic_stone", "floor_decoration_whitewashed_stone", "floor_decoration_fremennik_wood", "floor_decoration_tropical_wood", "floor_decoration_fancy_stone"] + +[.skeleton_guard] +level = 70 +xp = 2230 +materials = ["coins"] +amounts = [50000] +objects = ["skeleton_guard"] + +[.guard_dog] +level = 74 +xp = 2730 +materials = ["coins"] +amounts = [75000] +objects = ["guard_dog"] + +[.hobgoblin_guard] +level = 78 +xp = 3160 +materials = ["coins"] +amounts = [100000] +objects = ["hobgoblin_guard"] + +[.baby_red_dragon] +level = 82 +xp = 3870 +materials = ["coins"] +amounts = [150000] +objects = ["baby_red_dragon"] + +[.huge_spider] +level = 86 +xp = 4470 +materials = ["coins"] +amounts = [200000] +objects = ["huge_spider"] + +[.troll_guard] +level = 90 +xp = 10000 +materials = ["coins"] +amounts = [1000000] +objects = ["troll_guard"] + +[.hellhound] +level = 94 +xp = 22360 +materials = ["coins"] +amounts = [5000000] +objects = ["hellhound"] + +[.oak_ladder] +level = 68 +xp = 3000 +materials = ["oak_plank"] +amounts = [5] +objects = ["oak_ladder"] + +[.teak_ladder] +level = 78 +xp = 4500 +materials = ["teak_plank"] +amounts = [5] +objects = ["teak_ladder"] + +[.mahogany_ladder] +level = 88 +xp = 7000 +materials = ["mahogany_plank"] +amounts = [5] +objects = ["mahogany_ladder"] + +[.candles] +level = 72 +xp = 2430 +materials = ["oak_plank", "white_candle_lit"] +amounts = [4, 4] +objects = ["candle"] + +[.torches] +level = 84 +xp = 2440 +materials = ["oak_plank", "lit_torch"] +amounts = [4, 4] +objects = ["torches"] + +[.skull_torches] +level = 94 +xp = 2460 +materials = ["oak_plank", "lit_torch", "skull"] +amounts = [4, 4, 4] +objects = ["skull_torches"] + +[.spike_trap] +level = 72 +xp = 2230 +materials = ["coins"] +amounts = [50000] +objects = ["spike_trap"] + +[.man_trap] +level = 76 +xp = 2730 +materials = ["coins"] +amounts = [75000] +objects = ["man_trap"] + +[.tangle_vine] +level = 80 +xp = 3160 +materials = ["coins"] +amounts = [100000] +objects = ["tangle_vine"] + +[.marble_trap] +level = 84 +xp = 3870 +materials = ["coins"] +amounts = [150000] +objects = ["marble_trap"] + +[.teleport_trap] +level = 88 +xp = 4470 +materials = ["coins"] +amounts = [200000] +objects = ["teleport_trap"] + +[.oak_door] +level = 74 +xp = 6000 +materials = ["oak_plank"] +amounts = [10] +objects = ["door_309_closed", "door_310_closed"] + +[.steel_plated_door] +level = 84 +xp = 8000 +materials = ["oak_plank", "steel_bar"] +amounts = [10, 10] +objects = ["door_311_closed", "door_312_closed"] + +[.marble_door] +level = 94 +xp = 20000 +materials = ["marble_block"] +amounts = [4] +objects = ["door_313_closed", "door_314_closed"] + +[.decorative_blood] +level = 72 +xp = 40 +materials = ["red_dye"] +amounts = [4] +objects = ["decorative_blood"] + +[.decorative_pipe] +level = 83 +xp = 1200 +materials = ["steel_bar"] +amounts = [6] +objects = ["decorative_pipe"] + +[.hanging_skeleton] +level = 94 +xp = 30 +materials = ["skull", "bones"] +amounts = [2, 6] +objects = ["hanging_skeleton"] + +[.pit_dog] +level = 70 +xp = 2000 +materials = ["coins"] +amounts = [40000] +objects = ["pit_dog"] + +[.pit_ogre] +level = 73 +xp = 2340 +materials = ["coins"] +amounts = [55000] +objects = ["pit_ogre"] + +[.pit_rock_protector] +level = 79 +xp = 3000 +materials = ["coins"] +amounts = [90000] +objects = ["pit_rock_protector"] + +[.pit_scabarite] +level = 84 +xp = 3870 +materials = ["coins"] +amounts = [150000] +objects = ["pit_scabarite"] + +[.pit_black_demon] +level = 89 +xp = 5470 +materials = ["coins"] +amounts = [300000] +objects = ["pit_black_demon"] + +[.pit_iron_dragon] +level = 97 +xp = 27380 +materials = ["coins"] +amounts = [7500000] +objects = ["pit_iron_dragon"] + +[.minor_pit_trap] +level = 71 +xp = 3040 +materials = ["coins", "fire_rune"] +amounts = [45000, 500] +objects = ["minor_pit_trap"] + +[.major_pit_trap] +level = 83 +xp = 10000 +materials = ["coins", "fire_rune"] +amounts = [125000, 2500] +objects = ["major_pit_trap"] + +[.superior_pit_trap] +level = 96 +xp = 11000 +materials = ["coins", "fire_rune"] +amounts = [850000, 4500] +objects = ["superior_pit_trap"] + +[.demon] +level = 75 +xp = 7070 +materials = ["coins"] +amounts = [500000] +objects = ["demon_guardian"] + +[.kalphite_soldier] +level = 80 +xp = 8660 +materials = ["coins"] +amounts = [750000] +objects = ["kalphite_soldier_guardian"] + +[.tok_xil] +level = 85 +xp = 22360 +materials = ["coins"] +amounts = [5000000] +objects = ["tok_xil_guardian"] + +[.dagannoth] +level = 90 +xp = 27380 +materials = ["coins"] +amounts = [7500000] +objects = ["dagannoth_guardian"] + +[.steel_dragon] +level = 95 +xp = 31620 +materials = ["coins"] +amounts = [10000000] +objects = ["steel_dragon_guardian"] + +[.wooden_crate] +level = 75 +xp = 1430 +materials = ["plank"] +amounts = [5] +nails = 5 +objects = ["wooden_treasure_crate"] + +[.oak_chest] +level = 79 +xp = 3400 +materials = ["oak_plank", "steel_bar"] +amounts = [5, 2] +objects = ["oak_treasure_room_chest"] + +[.teak_chest] +level = 83 +xp = 5300 +materials = ["teak_plank", "steel_bar"] +amounts = [5, 4] +objects = ["teak_treasure_room_chest"] + +[.mahogany_chest] +level = 87 +xp = 10000 +materials = ["mahogany_plank", "gold_leaf"] +amounts = [5, 1] +objects = ["mahogany_treasure_room_chest"] + +[.magic_chest] +level = 91 +xp = 10000 +materials = ["magic_stone"] +amounts = [1] +objects = ["magic_treasure_room_chest"] diff --git a/data/skill/construction/construction.vars.toml b/data/skill/construction/construction.vars.toml index 9a8a944daa..4c56969f07 100644 --- a/data/skill/construction/construction.vars.toml +++ b/data/skill/construction/construction.vars.toml @@ -28,3 +28,8 @@ persist = true format = "string" persist = true default = "basic_wood" + +# Family crest picked by heraldry, shown on built shields +[heraldry_crest] +format = "int" +persist = true diff --git a/game/src/main/kotlin/content/skill/construction/FurnitureCreation.kt b/game/src/main/kotlin/content/skill/construction/FurnitureCreation.kt index f929957821..e773a41d48 100644 --- a/game/src/main/kotlin/content/skill/construction/FurnitureCreation.kt +++ b/game/src/main/kotlin/content/skill/construction/FurnitureCreation.kt @@ -3,6 +3,7 @@ package content.skill.construction import content.entity.player.dialogue.type.choice import content.skill.construction.House.Companion.addHouseFurniture import content.skill.construction.House.Companion.exitPortals +import content.skill.construction.House.Companion.furnishRoom import content.skill.construction.House.Companion.hotspot import content.skill.construction.House.Companion.houseBase import content.skill.construction.House.Companion.houseFurniture @@ -24,12 +25,10 @@ import world.gregs.voidps.engine.entity.character.player.skill.level.Level.has import world.gregs.voidps.engine.entity.item.Item import world.gregs.voidps.engine.entity.obj.GameObject import world.gregs.voidps.engine.entity.obj.GameObjects -import world.gregs.voidps.engine.entity.obj.remove import world.gregs.voidps.engine.inv.contains import world.gregs.voidps.engine.inv.inventory import world.gregs.voidps.engine.inv.remove import world.gregs.voidps.engine.inv.transact.operation.ClearItem.clear -import world.gregs.voidps.type.Zone class FurnitureCreation : Script { init { @@ -95,7 +94,8 @@ class FurnitureCreation : Script { return@option } removeHouseFurniture(position, hotspot) - removeFurniture(target.tile.zone, hotspot) + GameObjects.reset(target.tile.zone) + furnishRoom(base, position, buildMode = true) anim("construction_remove") } option("No") @@ -153,20 +153,6 @@ class FurnitureCreation : Script { return materials + Item(type, nails) } - /** - * Removes all furniture built on [hotspot] in [zone], revealing the original hotspots - */ - private fun removeFurniture(zone: Zone, hotspot: String) { - for (tile in zone.toCuboid()) { - for (obj in GameObjects.at(tile)) { - val original = GameObjects.original(obj) ?: continue - if (hotspot(original) == hotspot) { - obj.remove() - } - } - } - } - companion object { private val nailTypes = listOf("bronze_nails", "iron_nails", "steel_nails", "black_nails", "mithril_nails", "adamant_nails", "rune_nails") } diff --git a/game/src/main/kotlin/content/skill/construction/House.kt b/game/src/main/kotlin/content/skill/construction/House.kt index a575322c65..535d88ba04 100644 --- a/game/src/main/kotlin/content/skill/construction/House.kt +++ b/game/src/main/kotlin/content/skill/construction/House.kt @@ -155,19 +155,51 @@ class House : Script { } /** - * Replaces each piece of [hotspot] in [zone] with the matching piece of [furniture] + * Replaces each piece of [hotspot] in [zone] with the matching piece of [furniture], pieces without one are removed. + * Furniture objects are listed by piece unless [furniture] lists which pieces they're placed on, + * only placed on tiles with one of its anchor pieces when it has any, and turned by its rotations. + * Furniture with a variant uses the object picked by that player variable instead. */ - fun placeFurniture(zone: Zone, hotspot: String, furniture: String) { + fun Player.placeFurniture(zone: Zone, hotspot: String, furniture: String) { val objects = Tables.objList("house_furniture.$furniture.objects") + val pieces = Tables.intListOrNull("house_furniture.$furniture.pieces") + val anchors = Tables.intListOrNull("house_furniture.$furniture.anchors") + val rotations = Tables.intListOrNull("house_furniture.$furniture.rotations") + val variant = Tables.stringOrNull("house_furniture.$furniture.variant") for (tile in zone.toCuboid()) { - for (obj in GameObjects.at(tile)) { - if (hotspot(obj) == hotspot) { - obj.replace(objects[Tables.int("house_hotspots.${obj.id}.piece")]) + val spaces = GameObjects.at(tile).filter { hotspot(it) == hotspot } + val anchored = anchors == null || spaces.any { piece(it) in anchors } + for (obj in spaces) { + val piece = piece(obj) + val index = if (variant != null) get(variant, 0) else pieces?.indexOf(piece) ?: piece + val id = if (anchored) objects.getOrNull(index) else null + if (id == null) { + obj.remove() + } else { + obj.replace(id, rotation = (obj.rotation + (rotations?.get(index) ?: 0)) and 0x3) } } } } + private fun piece(obj: GameObject) = Tables.int("house_hotspots.${obj.id}.piece") + + /** + * Places the furniture built in the room at [position] and decorates it + */ + fun Player.furnishRoom(base: Zone, position: Int, buildMode: Boolean) { + val zone = roomZone(base, position) + val furniture = houseFurnitureIds + val hotspots = houseFurnitureHotspots + for ((index, room) in houseFurnitureRooms.withIndex()) { + if (room == position) { + placeFurniture(zone, hotspots[index], furniture[index]) + } + } + val window = Tables.obj("house_styles.${get("house_style", "basic_wood")}.${if (roomLevel(position) == DUNGEON_LEVEL) "wall" else "window"}") + decorate(zone, window, buildMode) + } + fun roomPosition(x: Int, y: Int, level: Int) = x + y * ROOM_GRID + level * ROOM_GRID * ROOM_GRID fun roomX(position: Int) = position % ROOM_GRID @@ -253,14 +285,8 @@ class House : Script { } } dynamicZones.copy(entries) - val furniture = houseFurnitureIds - val hotspots = houseFurnitureHotspots - for ((index, position) in houseFurnitureRooms.withIndex()) { - placeFurniture(roomZone(base, position), hotspots[index], furniture[index]) - } for (position in positions) { - val window = Tables.obj("house_styles.${get("house_style", "basic_wood")}.${if (roomLevel(position) == DUNGEON_LEVEL) "wall" else "window"}") - decorate(roomZone(base, position), window, buildMode) + furnishRoom(base, position, buildMode) } } diff --git a/game/src/test/kotlin/content/skill/construction/FurnitureCreationTest.kt b/game/src/test/kotlin/content/skill/construction/FurnitureCreationTest.kt index f5669d52f1..dc327b919e 100644 --- a/game/src/test/kotlin/content/skill/construction/FurnitureCreationTest.kt +++ b/game/src/test/kotlin/content/skill/construction/FurnitureCreationTest.kt @@ -19,6 +19,7 @@ import objectOption import org.junit.jupiter.api.Test import world.gregs.voidps.engine.client.ui.dialogue import world.gregs.voidps.engine.client.ui.hasOpen +import world.gregs.voidps.engine.data.definition.Tables import world.gregs.voidps.engine.entity.character.move.tele import world.gregs.voidps.engine.entity.character.player.Player import world.gregs.voidps.engine.entity.character.player.skill.Skill @@ -213,6 +214,136 @@ class FurnitureCreationTest : WorldTest() { assertEquals(listOf("parlour_rug"), player.houseFurnitureHotspots) } + @Test + fun `Build the piece of furniture matching the hotspot`() { + val player = createBuilder("garden") + player.levels.set(Skill.Construction, 15) + player.inventory.add("bagged_oak_tree", 2) + player.enterPortal(2) + val tree = player.objects("garden_tree_space").single() + val bigTree = player.objects("garden_big_tree_space").single() + + player.build("garden_tree_space", "oak_tree") + player.build("garden_big_tree_space", "oak_tree") + + assertEquals(tree.tile, player.objects("oak_tree").single().tile) + assertEquals(bigTree.tile, player.objects("big_oak_tree").single().tile) + assertEquals(0, player.inventory.count("bagged_oak_tree")) + } + + @Test + fun `Every hotspot outside of a group has furniture for its piece`() { + for (hotspot in Tables.get("house_hotspots").rows()) { + val furniture = hotspot.itemList("furniture") + assertTrue(furniture.size in 1..7, hotspot.stringId) + if (hotspot.stringOrNull("group") != null) { + continue + } + for (id in furniture) { + val objects = Tables.objList("house_furniture.$id.objects") + val piece = hotspot.int("piece") + val index = Tables.intListOrNull("house_furniture.$id.pieces")?.indexOf(piece) ?: piece + assertNotNull(objects.getOrNull(index), "${hotspot.stringId} $id") + } + } + } + + @Test + fun `Build furniture which leaves some pieces empty`() { + val player = createBuilder("combat_room") + player.levels.set(Skill.Construction, 71) + player.inventory.add("teak_plank", 8) + player.enterPortal(2) + + player.build("combat_room_ring_rope_space", "ranging_pedestals") + + assertEquals(2, player.objects("ranging_spot").size) + assertEquals(8, player.objects("magic_barrier").size) + assertTrue(player.objects("combat_room_ring_rope_space").isEmpty()) + assertTrue(player.objects("combat_room_ring_mat_middle_space").isEmpty()) + assertEquals(listOf("combat_ring"), player.houseFurnitureHotspots) + } + + @Test + fun `Build furniture only on tiles with its anchors`() { + val player = createBuilder("combat_room") + player.levels.set(Skill.Construction, 81) + player.inventory.add("teak_plank", 10) + player.inventory.add("steel_bar", 5) + player.enterPortal(2) + val tiles = (player.objects("combat_room_ring_barrier_space_2") + player.objects("combat_room_ring_beam_space")).map { it.tile } + val mats = player.objects("combat_room_ring_mat_middle_space").associate { it.tile to it.rotation } + + player.build("combat_room_ring_rope_space", "balance_beam") + + assertEquals(listOf(tiles.first(), tiles.last()).toSet(), player.objects("balance_beam_end").map { it.tile }.toSet()) + val middles = player.objects("balance_beam") + assertEquals(tiles.drop(1).dropLast(1).toSet(), middles.map { it.tile }.toSet()) + assertTrue(middles.all { it.rotation == (mats.getValue(it.tile) + 1) and 0x3 }) + assertTrue(player.objects("combat_room_ring_beam_space").isEmpty()) + assertTrue(player.objects("combat_room_ring_mat_middle_space").isEmpty()) + } + + @Test + fun `Removing anchored furniture restores its hotspots`() { + val player = createBuilder("combat_room") + player.addHouseFurniture(START_ROOM, "combat_ring", "balance_beam") + player.enterPortal(2) + + player.objectOption(player.objects("balance_beam").first(), "Remove") + tickIf { player.dialogue == null } + player.dialogueOption("line1") + + assertTrue(player.objects("balance_beam").isEmpty()) + assertEquals(3, player.objects("combat_room_ring_beam_space").size) + assertEquals(4, player.objects("combat_room_ring_mat_middle_space").size) + } + + @Test + fun `Removing furniture restores empty pieces`() { + val player = createBuilder("combat_room") + player.addHouseFurniture(START_ROOM, "combat_ring", "boxing_ring") + player.enterPortal(2) + assertTrue(player.objects("combat_room_ring_barrier_space").isEmpty()) + + player.objectOption(player.objects("boxing_mat_middle").first(), "Remove") + tickIf { player.dialogue == null } + player.dialogueOption("line1") + + assertTrue(player.objects("boxing_ring").isEmpty()) + assertEquals(3, player.objects("combat_room_ring_barrier_space").size) + assertEquals(4, player.objects("combat_room_ring_mat_middle_space").size) + assertTrue(player.houseFurnitureIds.isEmpty()) + } + + @Test + fun `Build furniture showing the players family crest`() { + val player = createBuilder("throne_room") + player["heraldry_crest"] = 3 + player.levels.set(Skill.Construction, 66) + player.inventory.add("oak_plank", 2) + player.enterPortal(2) + + player.build("throne_room_decoration_space", "round_shield") + + assertEquals(2, player.objects("round_shield_3").size) + assertTrue(player.objects("throne_room_decoration_space").isEmpty()) + } + + @Test + fun `Build furniture matching the house style`() { + val player = createBuilder("throne_room") + player["house_style"] = "basic_stone" + player.levels.set(Skill.Construction, 61) + player.inventory.add("mahogany_plank", 5) + player.enterPortal(2) + + player.build("throne_room_floor_space_2", "floor_decoration") + + assertEquals(4, player.objects("floor_decoration_basic_stone").size) + assertTrue(player.objects("throne_room_floor_space_2").isEmpty()) + } + @Test fun `Remove furniture`() { val player = createBuilder() From 8769e722cd87f19b06a1ec49c10a7e596a2876b5 Mon Sep 17 00:00:00 2001 From: GregHib Date: Fri, 2 Oct 2026 13:00:55 +0100 Subject: [PATCH 13/28] Add poh floors, stairs and dungeons --- .../construction/construction.tables.toml | 20 ++ .../skill/construction/FurnitureCreation.kt | 78 +++- .../content/skill/construction/House.kt | 124 ++++++- .../content/skill/construction/HouseStairs.kt | 144 ++++++++ .../skill/construction/RoomCreation.kt | 91 +++-- .../skill/construction/HouseStairsTest.kt | 334 ++++++++++++++++++ .../content/skill/construction/HouseTest.kt | 17 + 7 files changed, 768 insertions(+), 40 deletions(-) create mode 100644 game/src/main/kotlin/content/skill/construction/HouseStairs.kt create mode 100644 game/src/test/kotlin/content/skill/construction/HouseStairsTest.kt diff --git a/data/skill/construction/construction.tables.toml b/data/skill/construction/construction.tables.toml index 3ad8ba0423..9363deeba6 100644 --- a/data/skill/construction/construction.tables.toml +++ b/data/skill/construction/construction.tables.toml @@ -1,9 +1,15 @@ # Room templates are the south-west tile of each room in the house style region +# Upper is the template with stairs leading down, used on the upper floor or when the stairs are built down +# Stairs and stairs down are the hotspots for stairs leading up and down, outdoor rooms have no roof [house_rooms] level = "int" cost = "int" template = "tile" +upper = "tile" +stairs = "obj" +stairs_down = "obj" dungeon = "boolean" +outdoor = "boolean" [.parlour] level = 1 @@ -14,6 +20,7 @@ template = { x = 1856, y = 5112 } level = 1 cost = 1000 template = { x = 1856, y = 5064 } +outdoor = true [.kitchen] level = 5 @@ -39,6 +46,9 @@ template = { x = 1904, y = 5112 } level = 25 cost = 15000 template = { x = 1864, y = 5104 } +upper = { x = 1880, y = 5104 } +stairs = "skill_hall_stair_space" +stairs_down = "skill_hall_stair_space_down" [.games_room] level = 30 @@ -54,11 +64,15 @@ template = { x = 1880, y = 5088 } level = 35 cost = 25000 template = { x = 1896, y = 5104 } +upper = { x = 1912, y = 5104 } +stairs = "quest_hall_stair_space" +stairs_down = "quest_hall_stair_space_down" [.menagerie] level = 37 cost = 30000 template = { x = 1912, y = 5072 } +outdoor = true [.study] level = 40 @@ -84,6 +98,7 @@ template = { x = 1864, y = 5088 } level = 55 cost = 75000 template = { x = 1872, y = 5064 } +outdoor = true [.throne_room] level = 60 @@ -112,6 +127,7 @@ dungeon = true level = 70 cost = 7500 template = { x = 1872, y = 5080 } +stairs = "skill_hall_stair_space" dungeon = true [.dungeon_pit] @@ -127,12 +143,16 @@ template = { x = 1912, y = 5088 } dungeon = true # Filler for house grid spaces without a room +# Roof is the hipped roof for a single room, the style region also has ridged roofs at { x = 1864, y = 5072 } and { x = 1880, y = 5072 } [house_spaces] template = "tile" [.land] template = { x = 1864, y = 5056 } +[.roof] +template = { x = 1888, y = 5064 } + # Templates for each style are offset from the basic wood templates, window spaces are filled with the styles window or wall underground [house_styles] name = "string" diff --git a/game/src/main/kotlin/content/skill/construction/FurnitureCreation.kt b/game/src/main/kotlin/content/skill/construction/FurnitureCreation.kt index e773a41d48..8ffdcdcf4a 100644 --- a/game/src/main/kotlin/content/skill/construction/FurnitureCreation.kt +++ b/game/src/main/kotlin/content/skill/construction/FurnitureCreation.kt @@ -1,16 +1,26 @@ package content.skill.construction import content.entity.player.dialogue.type.choice +import content.skill.construction.House.Companion.GROUND_LEVEL import content.skill.construction.House.Companion.addHouseFurniture +import content.skill.construction.House.Companion.connectStairs import content.skill.construction.House.Companion.exitPortals import content.skill.construction.House.Companion.furnishRoom import content.skill.construction.House.Companion.hotspot import content.skill.construction.House.Companion.houseBase import content.skill.construction.House.Companion.houseFurniture +import content.skill.construction.House.Companion.houseRoom +import content.skill.construction.House.Companion.houseStairs +import content.skill.construction.House.Companion.loadHouse import content.skill.construction.House.Companion.inOwnHouse import content.skill.construction.House.Companion.placeFurniture import content.skill.construction.House.Companion.removeHouseFurniture +import content.skill.construction.House.Companion.removeHouseStairs +import content.skill.construction.House.Companion.roomAbove +import content.skill.construction.House.Companion.roomBelow +import content.skill.construction.House.Companion.roomLevel import content.skill.construction.House.Companion.roomPosition +import content.skill.construction.House.Companion.stairsDown import world.gregs.voidps.engine.Script import world.gregs.voidps.engine.client.message import world.gregs.voidps.engine.client.ui.close @@ -18,6 +28,7 @@ import world.gregs.voidps.engine.client.ui.open import world.gregs.voidps.engine.data.config.RowDefinition import world.gregs.voidps.engine.data.definition.ItemDefinitions import world.gregs.voidps.engine.data.definition.Rows +import world.gregs.voidps.engine.data.definition.Tables import world.gregs.voidps.engine.entity.character.player.Player import world.gregs.voidps.engine.entity.character.player.skill.Skill import world.gregs.voidps.engine.entity.character.player.skill.exp.exp @@ -29,6 +40,7 @@ import world.gregs.voidps.engine.inv.contains import world.gregs.voidps.engine.inv.inventory import world.gregs.voidps.engine.inv.remove import world.gregs.voidps.engine.inv.transact.operation.ClearItem.clear +import world.gregs.voidps.type.Zone class FurnitureCreation : Script { init { @@ -93,9 +105,16 @@ class FurnitureCreation : Script { if (!GameObjects.contains(target) || houseFurniture(position, hotspot) == null) { return@option } - removeHouseFurniture(position, hotspot) - GameObjects.reset(target.tile.zone) - furnishRoom(base, position, buildMode = true) + if (houseFurniture(position, hotspot) == houseStairs(position)) { + // Staircases are removed from both floors + removeHouseStairs(if (stairsDown(position)) roomBelow(position) else roomAbove(position)) + removeHouseFurniture(position, hotspot) + loadHouse(base, buildMode = true) + } else { + removeHouseFurniture(position, hotspot) + GameObjects.reset(target.tile.zone) + furnishRoom(base, position, buildMode = true) + } anim("construction_remove") } option("No") @@ -115,7 +134,7 @@ class FurnitureCreation : Script { set("furniture_creation_hide_cross_$slot", row == null || (has(Skill.Construction, row.int("level")) && inventory.contains(materials))) } - private fun Player.build(target: GameObject, furniture: String) { + private suspend fun Player.build(target: GameObject, furniture: String) { val base = houseBase() if (base == null || !inOwnHouse() || !get("house_build_mode", false) || !GameObjects.contains(target)) { return @@ -123,11 +142,33 @@ class FurnitureCreation : Script { val position = roomPosition(base, target.tile.zone) ?: return val hotspot = hotspot(target) ?: return val row = Rows.getOrNull("house_furniture.$furniture") ?: return - if (!has(Skill.Construction, row.int("level"), message = true)) { + if (!canBuild(row)) { return } - if (!inventory.contains("hammer", "saw")) { - message("You need a hammer and saw to build furniture.") // TODO proper message + val down = Tables.objOrNull("house_rooms.${houseRoom(position)}.stairs_down") + if (roomLevel(position) == GROUND_LEVEL && down != null && hotspot == Tables.objOrNull("house_rooms.${houseRoom(position)}.stairs")) { + choice("Build stairs in which direction") { + option("Up") { + build(base, target, position, hotspot, furniture) + } + option("Down") { + build(base, target, position, down, furniture) + } + } + return + } + build(base, target, position, hotspot, furniture) + } + + /** + * Builds [furniture] on [hotspot] in the room at [position], staircases are built on both floors + */ + private fun Player.build(base: Zone, target: GameObject, position: Int, hotspot: String, furniture: String) { + if (houseBase() != base || !GameObjects.contains(target) || houseFurniture(position, hotspot) != null) { + return + } + val row = Rows.getOrNull("house_furniture.$furniture") ?: return + if (!canBuild(row)) { return } if (!inventory.remove(materials(row))) { @@ -135,11 +176,32 @@ class FurnitureCreation : Script { return } addHouseFurniture(position, hotspot, furniture) - placeFurniture(target.tile.zone, hotspot, furniture) + if (furniture == houseStairs(position)) { + connectStairs(if (stairsDown(position)) roomBelow(position) else roomAbove(position)) + // Stairs leading down change the rooms template + loadHouse(base, buildMode = true) + } else { + placeFurniture(target.tile.zone, hotspot, furniture) + } anim("construction_build") exp(Skill.Construction, row.int("xp") / 10.0) } + private fun Player.canBuild(row: RowDefinition): Boolean { + if (!has(Skill.Construction, row.int("level"), message = true)) { + return false + } + if (!inventory.contains("hammer", "saw")) { + message("You need a hammer and saw to build furniture.") // TODO proper message + return false + } + if (!inventory.contains(materials(row))) { + message("You don't have the right materials.") // TODO proper message + return false + } + return true + } + /** * Materials needed to build [row], using the first type of nails the player has enough of */ diff --git a/game/src/main/kotlin/content/skill/construction/House.kt b/game/src/main/kotlin/content/skill/construction/House.kt index 535d88ba04..3c17c604e1 100644 --- a/game/src/main/kotlin/content/skill/construction/House.kt +++ b/game/src/main/kotlin/content/skill/construction/House.kt @@ -3,12 +3,14 @@ package content.skill.construction import content.quest.clearInstance import content.quest.exitInstance import content.quest.instance +import org.rsmod.game.pathfinder.flag.CollisionFlag import world.gregs.voidps.engine.Script import world.gregs.voidps.engine.client.ui.close import world.gregs.voidps.engine.client.ui.hasOpen import world.gregs.voidps.engine.client.ui.open import world.gregs.voidps.engine.data.definition.Rows import world.gregs.voidps.engine.data.definition.Tables +import world.gregs.voidps.engine.entity.character.move.tele import world.gregs.voidps.engine.entity.character.player.Player import world.gregs.voidps.engine.entity.character.player.Players import world.gregs.voidps.engine.entity.obj.GameObject @@ -16,13 +18,16 @@ import world.gregs.voidps.engine.entity.obj.GameObjects import world.gregs.voidps.engine.entity.obj.remove import world.gregs.voidps.engine.entity.obj.replace import world.gregs.voidps.engine.get +import world.gregs.voidps.engine.map.collision.Collisions +import world.gregs.voidps.engine.map.collision.check import world.gregs.voidps.engine.map.instance.Instances import world.gregs.voidps.engine.map.zone.DynamicZones +import world.gregs.voidps.type.Tile import world.gregs.voidps.type.Zone /** * Houses are a [HOUSE_SIZE] grid of zones, a [ROOM_GRID] area of rooms around the [START_ROOM] with space for a ring of grass. - * Rooms are on [GROUND_LEVEL] with the dungeon on [DUNGEON_LEVEL] + * Rooms are on [GROUND_LEVEL] and [UPPER_LEVEL] with the dungeon on [DUNGEON_LEVEL] */ class House : Script { init { @@ -51,6 +56,7 @@ class House : Script { const val HOUSE_CENTRE = ROOM_GRID / 2 const val DUNGEON_LEVEL = 0 const val GROUND_LEVEL = 1 + const val UPPER_LEVEL = 2 const val START_ROOM = HOUSE_CENTRE + HOUSE_CENTRE * ROOM_GRID + GROUND_LEVEL * ROOM_GRID * ROOM_GRID val Player.houseRoomIds: List @@ -184,6 +190,65 @@ class House : Script { private fun piece(obj: GameObject) = Tables.int("house_hotspots.${obj.id}.piece") + /** + * The staircase built in the room at [position] + */ + fun Player.houseStairs(position: Int): String? = stairsIndex(position)?.let { houseFurnitureIds[it] } + + private fun Player.stairsIndex(position: Int): Int? { + val staircases = Tables.itemList("house_hotspots.skill_hall_stair_space.furniture") + val rooms = houseFurnitureRooms + val ids = houseFurnitureIds + return rooms.indices.firstOrNull { rooms[it] == position && ids[it] in staircases } + } + + /** + * Whether the staircase built in the room at [position] leads down + */ + fun Player.stairsDown(position: Int): Boolean { + val index = stairsIndex(position) ?: return false + return houseFurnitureHotspots[index] == Tables.objOrNull("house_rooms.${houseRoom(position)}.stairs_down") + } + + /** + * Adds the other end of the stairs leading down from the room above, or up from the room below, + * to the room at [position] if it has space for them. Returns whether stairs were added. + */ + fun Player.connectStairs(position: Int): Boolean { + val room = houseRoom(position) ?: return false + if (houseStairs(position) != null) { + return false + } + val above = roomAbove(position) + val below = roomBelow(position) + val (furniture, hotspot) = when { + houseStairs(above) != null && stairsDown(above) -> houseStairs(above) to Tables.objOrNull("house_rooms.$room.stairs") + houseStairs(below) != null && !stairsDown(below) -> houseStairs(below) to Tables.objOrNull("house_rooms.$room.stairs_down") + else -> return false + } + if (furniture == null || hotspot == null) { + return false + } + addHouseFurniture(position, hotspot, furniture) + return true + } + + /** + * The room built at [position] + */ + fun Player.houseRoom(position: Int): String? { + val index = houseRoomPositions.indexOf(position) + return if (index == -1) null else houseRoomIds.getOrNull(index) + } + + /** + * Removes the staircase from the room at [position] + */ + fun Player.removeHouseStairs(position: Int) { + val index = stairsIndex(position) ?: return + removeHouseFurniture(position, houseFurnitureHotspots[index]) + } + /** * Places the furniture built in the room at [position] and decorates it */ @@ -208,6 +273,10 @@ class House : Script { fun roomLevel(position: Int) = position / (ROOM_GRID * ROOM_GRID) + fun roomAbove(position: Int) = position + ROOM_GRID * ROOM_GRID + + fun roomBelow(position: Int) = position - ROOM_GRID * ROOM_GRID + /** * The zone the room at [position] occupies in the house starting at [base] */ @@ -228,6 +297,14 @@ class House : Script { fun Player.inOwnHouse() = get("house_owner") == accountName + /** + * The owner of the house the player is currently in + */ + fun Player.houseOwner(): Player? { + val owner: String = get("house_owner") ?: return null + return if (owner == accountName) this else Players.findByAccount(owner) + } + /** * South-west zone of the house the player is currently in */ @@ -247,8 +324,19 @@ class House : Script { } /** - * Builds the players house in the instance starting at [base]. Ground floor rooms are surrounded by a - * ring of grass, every other space is left empty. + * The zone of the template for [room] at [position], rooms with stairs have a separate template + * for stairs leading down used on the upper floor, or when the stairs are built leading down. + */ + fun Player.roomTemplate(room: String, position: Int): Zone { + val down = Tables.objOrNull("house_rooms.$room.stairs_down") + val upper = Tables.tileOrNull("house_rooms.$room.upper") != null && + (roomLevel(position) > GROUND_LEVEL || (down != null && houseFurniture(position, down) != null)) + return template("house_rooms.$room.${if (upper) "upper" else "template"}") + } + + /** + * Builds the players house in the instance starting at [base]. Ground floor rooms are surrounded by a ring of grass, + * indoor rooms without a room above are roofed and every other space is left empty. */ fun Player.loadHouse(base: Zone, buildMode: Boolean) { val dynamicZones = get() @@ -260,7 +348,18 @@ class House : Script { for ((index, position) in positions.withIndex()) { val zone = roomZone(base, position) placed.add(zone) - entries.add(Triple(template("house_rooms.${ids[index]}.template"), zone, rotations[index])) + entries.add(Triple(roomTemplate(ids[index], position), zone, rotations[index])) + } + val roof = template("house_spaces.roof.template") + for ((index, position) in positions.withIndex()) { + val level = roomLevel(position) + if (level == DUNGEON_LEVEL || level + 1 >= HOUSE_LEVELS || Tables.bool("house_rooms.${ids[index]}.outdoor")) { + continue + } + val zone = roomZone(base, roomAbove(position)) + if (placed.add(zone)) { + entries.add(Triple(roof, zone, 0)) + } } val ground = positions.filter { roomLevel(it) == GROUND_LEVEL } if (ground.isNotEmpty()) { @@ -305,6 +404,22 @@ class House : Script { } } + /** + * Moves to the nearest free tile to [tile] in its room, levels above the dungeon are hidden so they don't block the view + */ + fun Player.changeFloor(tile: Tile) { + val free = tile.zone.toCuboid().filter { !Collisions.check(it, BLOCKED) }.minByOrNull { it.distanceTo(tile) } ?: tile + val base = houseBase() + if (base != null && free.level - base.level == DUNGEON_LEVEL) { + set("hide_upper_levels", true) + } else { + clear("hide_upper_levels") + } + tele(free) + } + + private const val BLOCKED = CollisionFlag.FLOOR or CollisionFlag.FLOOR_DECORATION or CollisionFlag.OBJECT + suspend fun Player.houseLoading() { open("house_loading") delay(3) @@ -313,6 +428,7 @@ class House : Script { fun Player.leaveHouse(teleport: Boolean = true) { val owner: String = remove("house_owner") ?: return + clear("hide_upper_levels") if (hasOpen("house_options")) { open("options") } diff --git a/game/src/main/kotlin/content/skill/construction/HouseStairs.kt b/game/src/main/kotlin/content/skill/construction/HouseStairs.kt new file mode 100644 index 0000000000..85c1040c74 --- /dev/null +++ b/game/src/main/kotlin/content/skill/construction/HouseStairs.kt @@ -0,0 +1,144 @@ +package content.skill.construction + +import content.entity.player.dialogue.type.choice +import content.entity.player.dialogue.type.statement +import content.skill.construction.House.Companion.DUNGEON_LEVEL +import content.skill.construction.House.Companion.UPPER_LEVEL +import content.skill.construction.House.Companion.changeFloor +import content.skill.construction.House.Companion.houseBase +import content.skill.construction.House.Companion.houseLoading +import content.skill.construction.House.Companion.houseOwner +import content.skill.construction.House.Companion.houseRoomPositions +import content.skill.construction.House.Companion.houseRoomRotations +import content.skill.construction.House.Companion.houseStairs +import content.skill.construction.House.Companion.inOwnHouse +import content.skill.construction.House.Companion.roomAbove +import content.skill.construction.House.Companion.roomBelow +import content.skill.construction.House.Companion.roomLevel +import content.skill.construction.House.Companion.roomPosition +import content.skill.construction.House.Companion.stairsDown +import content.skill.construction.RoomCreation.Companion.buildRoom +import content.skill.construction.RoomCreation.Companion.canBuildRoom +import content.skill.construction.RoomCreation.Companion.hasRoomSpace +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.type.Tile +import world.gregs.voidps.type.Zone + +/** + * Staircases connect rooms with stairs on the floors above and below, the dungeon entrance leads to the dungeon room below it. + * In building mode the owner is offered to build the room if there isn't one. + */ +class HouseStairs : Script { + init { + objectOperate("Enter", "dungeon_entrance") { (target) -> + val base = houseBase() ?: return@objectOperate + val owner = houseOwner() ?: return@objectOperate + val position = roomPosition(base, target.tile.zone) ?: return@objectOperate + val below = roomBelow(position) + travel(base, position, below, below in owner.houseRoomPositions, entranceRooms, "This entrance does not lead anywhere.", "below") // TODO proper message + } + + objectOperate("Climb-up", STAIRCASES) { (target) -> + val base = houseBase() ?: return@objectOperate + val position = roomPosition(base, target.tile.zone) ?: return@objectOperate + climb(base, position, up = true) + } + + objectOperate("Climb-down", STAIRCASES) { (target) -> + val base = houseBase() ?: return@objectOperate + val position = roomPosition(base, target.tile.zone) ?: return@objectOperate + climb(base, position, up = false) + } + + objectOperate("Climb", STAIRCASES) { (target) -> + val base = houseBase() ?: return@objectOperate + val position = roomPosition(base, target.tile.zone) ?: return@objectOperate + climb(base, position, up = !target.id.endsWith("_down")) + } + } + + /** + * Climb the stairs in the room at [position] to the stairs leading the other way in the room above or below + */ + private suspend fun Player.climb(base: Zone, position: Int, up: Boolean) { + val owner = houseOwner() ?: return + val level = roomLevel(position) + if (owner.stairsDown(position) == up || (if (up) level >= UPPER_LEVEL else level <= DUNGEON_LEVEL)) { + statement(NOWHERE) + return + } + val next = if (up) roomAbove(position) else roomBelow(position) + val connected = owner.houseStairs(next) != null && owner.stairsDown(next) == up + // Stairs lead between halls, or down from a hall to the dungeon stairs + val rooms = if (up || level == UPPER_LEVEL) hallRooms else stairRooms + travel(base, position, next, connected, rooms, NOWHERE, if (up) "at the top" else "at the bottom") + } + + /** + * Moves from the room at [from] to the one at [to] if they're [connected], + * otherwise offers the owner in building mode to build one of [rooms] [place]. + */ + private suspend fun Player.travel(base: Zone, from: Int, to: Int, connected: Boolean, rooms: List>, nowhere: String, place: String) { + val destination = tile.addLevel(roomLevel(to) - roomLevel(from)) + if (connected) { + arrive(destination) + return + } + if (!inOwnHouse() || to in houseRoomPositions) { + statement(nowhere) + return + } + if (!get("house_build_mode", false)) { + statement("$nowhere
Enter building mode to build a room $place.") + return + } + if (!hasRoomSpace()) { + return + } + statement("$nowhere
Do you want to build a room $place?") + choice { + for ((room, name) in rooms) { + option(name) { + build(base, room, from, to, destination) + } + } + option("Cancel") + } + } + + /** + * Builds [room] at [to] facing the same way as the room at [from] so the stairs line up, then moves into it + */ + private suspend fun Player.build(base: Zone, room: String, from: Int, to: Int, destination: Tile) { + if (houseBase() != base || !inOwnHouse() || to in houseRoomPositions || !canBuildRoom(room, to)) { + return + } + val index = houseRoomPositions.indexOf(from) + buildRoom(base, room, to, if (index == -1) 0 else houseRoomRotations[index]) + if (to in houseRoomPositions) { + arrive(destination) + } + } + + /** + * Moves to [tile] showing the loading screen when entering or leaving the dungeon as the map is re-rendered + */ + private suspend fun Player.arrive(tile: Tile) { + val hidden = get("hide_upper_levels", false) + changeFloor(tile) + if (hidden != get("hide_upper_levels", false)) { + houseLoading() + } + } + + companion object { + private const val NOWHERE = "These stairs do no lead anywhere." + private const val STAIRCASES = "oak_staircase,oak_staircase_down,teak_staircase,teak_staircase_down,spiral_staircase,spiral_staircase_down,marble_staircase,marble_staircase_down,marble_spiral,marble_spiral_down" + + // Rooms which can be built at the other end of stairs or below the dungeon entrance + private val hallRooms = listOf("skill_hall" to "Skill hall", "quest_hall" to "Quest hall") + private val stairRooms = listOf("dungeon_stairs" to "Dungeon stairs room") + private val entranceRooms = listOf("dungeon_stairs" to "Dungeon stairs room", "dungeon_corridor" to "Dungeon corridor", "dungeon_junction" to "Dungeon junction") + } +} diff --git a/game/src/main/kotlin/content/skill/construction/RoomCreation.kt b/game/src/main/kotlin/content/skill/construction/RoomCreation.kt index ea7066238e..10191b5999 100644 --- a/game/src/main/kotlin/content/skill/construction/RoomCreation.kt +++ b/game/src/main/kotlin/content/skill/construction/RoomCreation.kt @@ -1,9 +1,12 @@ package content.skill.construction import content.entity.player.dialogue.type.choice +import content.entity.player.dialogue.type.statement import content.skill.construction.House.Companion.DUNGEON_LEVEL +import content.skill.construction.House.Companion.GROUND_LEVEL import content.skill.construction.House.Companion.HOUSE_CENTRE import content.skill.construction.House.Companion.addHouseRoom +import content.skill.construction.House.Companion.connectStairs import content.skill.construction.House.Companion.exitPortals import content.skill.construction.House.Companion.houseBase import content.skill.construction.House.Companion.houseRoomIds @@ -11,10 +14,11 @@ import content.skill.construction.House.Companion.houseRoomPositions import content.skill.construction.House.Companion.inOwnHouse import content.skill.construction.House.Companion.loadHouse import content.skill.construction.House.Companion.removeHouseRoom +import content.skill.construction.House.Companion.roomBelow import content.skill.construction.House.Companion.roomLevel import content.skill.construction.House.Companion.roomPosition +import content.skill.construction.House.Companion.roomTemplate import content.skill.construction.House.Companion.roomZone -import content.skill.construction.House.Companion.template import world.gregs.voidps.engine.Script import world.gregs.voidps.engine.client.message import world.gregs.voidps.engine.client.ui.close @@ -28,6 +32,7 @@ import world.gregs.voidps.engine.inv.remove import world.gregs.voidps.engine.map.zone.DynamicZones import world.gregs.voidps.type.Direction import world.gregs.voidps.type.Tile +import world.gregs.voidps.type.Zone class RoomCreation(val dynamicZones: DynamicZones) : Script { init { @@ -54,32 +59,21 @@ class RoomCreation(val dynamicZones: DynamicZones) : Script { message("Your house is already at the maximum height.") return@objectOperate } - if (houseRoomIds.size >= maxRooms()) { - message("You need a higher Construction level to build any more rooms.") // TODO proper messages + if (!hasRoomSpace()) { + return@objectOperate + } + if (roomLevel(position ?: return@objectOperate) > GROUND_LEVEL && roomBelow(position) !in houseRoomPositions) { + statement("You can't build a room that would have no room to support it.") return@objectOperate } - set("house_preview_position", position ?: return@objectOperate) + set("house_preview_position", position) open("room_creation") } interfaceOption("Build", "room_creation:*") { close("room_creation") val position: Int = get("house_preview_position") ?: return@interfaceOption - val row = Rows.getOrNull("house_rooms.${it.component}") ?: return@interfaceOption - if (!has(Skill.Construction, row.int("level"), message = true)) { - return@interfaceOption - } - val dungeon = row.bool("dungeon") - if (dungeon && roomLevel(position) != DUNGEON_LEVEL) { - message("That room can only be built underground.") // TODO proper messages - return@interfaceOption - } - if (!dungeon && roomLevel(position) == DUNGEON_LEVEL) { - message("That room can't be built underground.") // TODO proper messages - return@interfaceOption - } - if (inventory.count("coins") < row.int("cost")) { - message("You need ${row.int("cost")} coins to build this room.") // TODO proper messages + if (!canBuildRoom(it.component, position)) { return@interfaceOption } set("house_preview_room", it.component) @@ -111,8 +105,6 @@ class RoomCreation(val dynamicZones: DynamicZones) : Script { return HOUSE_CENTRE - (size - 1) / 2..HOUSE_CENTRE + size / 2 } - private fun Player.maxRooms(): Int = 20 + roomLevels.count { levels.get(Skill.Construction) >= it } - private suspend fun Player.preview() { val base = houseBase() if (base == null || !inOwnHouse()) { @@ -121,7 +113,7 @@ class RoomCreation(val dynamicZones: DynamicZones) : Script { } val position: Int = get("house_preview_position") ?: return val room: String = get("house_preview_room") ?: return - dynamicZones.copy(template("house_rooms.$room.template"), roomZone(base, position), get("house_preview_rotation", 0)) + dynamicZones.copy(roomTemplate(room, position), roomZone(base, position), get("house_preview_rotation", 0)) choice { option("Rotate clockwise") { rotate(1) @@ -152,12 +144,7 @@ class RoomCreation(val dynamicZones: DynamicZones) : Script { if (base == null || !inOwnHouse()) { return } - if (inventory.remove("coins", Rows.get("house_rooms.$room").int("cost"))) { - addHouseRoom(room, position, rotation) - } else { - message("You don't have enough coins to build this room.") // TODO proper message - } - loadHouse(base, buildMode = true) + buildRoom(base, room, position, rotation) } private suspend fun Player.removeRoom(position: Int, room: String) { @@ -192,6 +179,54 @@ class RoomCreation(val dynamicZones: DynamicZones) : Script { } companion object { + /** + * Whether the players level allows building another room + */ + fun Player.hasRoomSpace(): Boolean { + if (houseRoomIds.size >= 20 + roomLevels.count { levels.get(Skill.Construction) >= it }) { + message("You need a higher Construction level to build any more rooms.") // TODO proper messages + return false + } + return true + } + + /** + * Whether the player can build [room] at [position], with the level, coins and on the right floor + */ + fun Player.canBuildRoom(room: String, position: Int): Boolean { + val row = Rows.getOrNull("house_rooms.$room") ?: return false + if (!has(Skill.Construction, row.int("level"), message = true)) { + return false + } + val dungeon = row.bool("dungeon") + if (dungeon && roomLevel(position) != DUNGEON_LEVEL) { + message("That room can only be built underground.") // TODO proper messages + return false + } + if (!dungeon && roomLevel(position) == DUNGEON_LEVEL) { + message("That room can't be built underground.") // TODO proper messages + return false + } + if (inventory.count("coins") < row.int("cost")) { + message("You need ${row.int("cost")} coins to build this room.") // TODO proper messages + return false + } + return true + } + + /** + * Pays for and builds [room] at [position] connecting any stairs above or below, then reloads the house + */ + fun Player.buildRoom(base: Zone, room: String, position: Int, rotation: Int) { + if (inventory.remove("coins", Rows.get("house_rooms.$room").int("cost"))) { + addHouseRoom(room, position, rotation) + connectStairs(position) + } else { + message("You don't have enough coins to build this room.") // TODO proper message + } + loadHouse(base, buildMode = true) + } + // Levels at which the buildable area grows from 3x3 up to 7x7 rooms private val sizeLevels = intArrayOf(15, 30, 45, 60) diff --git a/game/src/test/kotlin/content/skill/construction/HouseStairsTest.kt b/game/src/test/kotlin/content/skill/construction/HouseStairsTest.kt new file mode 100644 index 0000000000..02e92baf2c --- /dev/null +++ b/game/src/test/kotlin/content/skill/construction/HouseStairsTest.kt @@ -0,0 +1,334 @@ +package content.skill.construction + +import WorldTest +import content.quest.instance +import content.skill.construction.House.Companion.DUNGEON_LEVEL +import content.skill.construction.House.Companion.GROUND_LEVEL +import content.skill.construction.House.Companion.START_ROOM +import content.skill.construction.House.Companion.UPPER_LEVEL +import content.skill.construction.House.Companion.addHouseFurniture +import content.skill.construction.House.Companion.addHouseRoom +import content.skill.construction.House.Companion.houseRoomIds +import content.skill.construction.House.Companion.houseRoomPositions +import content.skill.construction.House.Companion.houseRoomRotations +import content.skill.construction.House.Companion.houseStairs +import content.skill.construction.House.Companion.roomPosition +import content.skill.construction.House.Companion.roomZone +import dialogueOption +import interfaceOption +import objectOption +import org.junit.jupiter.api.Test +import skipDialogues +import world.gregs.voidps.engine.client.ui.dialogue +import world.gregs.voidps.engine.client.ui.hasOpen +import world.gregs.voidps.engine.entity.character.move.tele +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.entity.character.player.skill.Skill +import world.gregs.voidps.engine.entity.item.Item +import world.gregs.voidps.engine.entity.obj.GameObject +import world.gregs.voidps.engine.entity.obj.GameObjects +import world.gregs.voidps.engine.inv.add +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.type.Tile +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class HouseStairsTest : WorldTest() { + + private val portal = Tile(2951, 3222) + private val exit = Tile(2953, 3224) + private val hall = roomPosition(4, 3, GROUND_LEVEL) + private val upperHall = roomPosition(4, 3, UPPER_LEVEL) + private val dungeon = roomPosition(4, 3, DUNGEON_LEVEL) + + private fun createOwner(): Player { + val player = createPlayer(exit, "owner") + player["house_location"] = "rimmington" + player.levels.set(Skill.Construction, 99) + player.addHouseRoom("garden", START_ROOM) + player.addHouseFurniture(START_ROOM, "garden_centrepiece_space", "exit_portal") + return player + } + + private fun Player.enterPortal(option: Int) { + objectOption(GameObjects.find(portal, "house_portal_rimmington"), "Enter") + tickIf { dialogue == null } + dialogueOption("line$option") + tickIf { hasOpen("house_loading") } + } + + private fun Player.find(position: Int, id: String): GameObject { + val zone = roomZone(instance()!!.tile.zone, position) + return zone.toCuboid().firstNotNullOf { GameObjects.findOrNull(it, id) } + } + + private fun Player.findOrNull(position: Int, id: String): GameObject? { + val zone = roomZone(instance()!!.tile.zone, position) + return zone.toCuboid().firstNotNullOfOrNull { GameObjects.findOrNull(it, id) } + } + + /** + * Uses [option] on [obj], waiting long enough to walk around it + */ + private fun Player.use(obj: GameObject, option: String) { + tele(obj.tile.addY(-1)) + objectOption(obj, option) + tick(8) + } + + private fun Player.addEntrance() { + addHouseRoom("garden", hall) + addHouseFurniture(hall, "garden_centrepiece_space", "dungeon_entrance") + } + + private fun Player.addHalls(dungeonStairs: Boolean = false) { + addHouseRoom("skill_hall", hall) + addHouseFurniture(hall, "skill_hall_stair_space", "oak_staircase") + addHouseRoom("skill_hall", upperHall) + addHouseFurniture(upperHall, "skill_hall_stair_space_down", "oak_staircase") + if (dungeonStairs) { + addHouseRoom("dungeon_stairs", dungeon) + addHouseFurniture(dungeon, "skill_hall_stair_space", "oak_staircase") + } + } + + @Test + fun `Dungeon entrance without a room below doesn't lead anywhere`() { + val player = createOwner() + player.addEntrance() + player.enterPortal(1) + val entrance = player.find(hall, "dungeon_entrance") + + player.use(entrance, "Enter") + + assertEquals(GROUND_LEVEL, player.tile.level) + assertEquals("dialogue_message2", player.dialogue) + } + + @Test + fun `Enter the dungeon below the entrance`() { + val player = createOwner() + player.addEntrance() + player.addHouseRoom("dungeon_corridor", dungeon) + player.enterPortal(1) + val entrance = player.find(hall, "dungeon_entrance") + + player.use(entrance, "Enter") + + assertEquals(DUNGEON_LEVEL, player.tile.level) + assertEquals(roomZone(player.instance()!!.tile.zone, dungeon), player.tile.zone) + assertTrue(player["hide_upper_levels", false]) + } + + @Test + fun `Build a dungeon room through the entrance in building mode`() { + val player = createOwner() + player.addEntrance() + player.inventory.add("coins", 7500) + player.enterPortal(2) + val entrance = player.find(hall, "dungeon_entrance") + + player.use(entrance, "Enter") + player.skipDialogues() + player.dialogueOption("line1") + tick(4) + + assertEquals("dungeon_stairs", player.houseRoomIds.last()) + assertEquals(dungeon, player.houseRoomPositions.last()) + assertEquals(0, player.inventory.count("coins")) + assertEquals(DUNGEON_LEVEL, player.tile.level) + assertTrue(player["hide_upper_levels", false]) + } + + @Test + fun `Declining to build below the entrance stays put`() { + val player = createOwner() + player.addEntrance() + player.inventory.add("coins", 7500) + player.enterPortal(2) + val entrance = player.find(hall, "dungeon_entrance") + + player.use(entrance, "Enter") + player.skipDialogues() + player.dialogueOption("line4") + + assertEquals(listOf(START_ROOM, hall), player.houseRoomPositions) + assertEquals(7500, player.inventory.count("coins")) + assertEquals(GROUND_LEVEL, player.tile.level) + } + + @Test + fun `Climb up and down stairs between halls`() { + val player = createOwner() + player.addHalls() + player.enterPortal(1) + + player.use(player.find(hall, "oak_staircase"), "Climb-up") + + assertEquals(UPPER_LEVEL, player.tile.level) + assertEquals(roomZone(player.instance()!!.tile.zone, upperHall), player.tile.zone) + + player.use(player.find(upperHall, "oak_staircase_down"), "Climb-down") + + assertEquals(GROUND_LEVEL, player.tile.level) + assertFalse(player["hide_upper_levels", false]) + } + + @Test + fun `Ground floor stairs choose between up and down when there are dungeon stairs`() { + val player = createOwner() + player.addHalls(dungeonStairs = true) + player.enterPortal(1) + + player.use(player.find(hall, "oak_staircase"), "Climb-up") + player.dialogueOption("line2") + tick(4) + + assertEquals(DUNGEON_LEVEL, player.tile.level) + assertTrue(player["hide_upper_levels", false]) + + player.use(player.find(dungeon, "oak_staircase"), "Climb-up") + tick(4) + + assertEquals(GROUND_LEVEL, player.tile.level) + assertFalse(player["hide_upper_levels", false]) + } + + @Test + fun `Stairs without a room above don't lead anywhere`() { + val player = createOwner() + player.addHouseRoom("skill_hall", hall) + player.addHouseFurniture(hall, "skill_hall_stair_space", "oak_staircase") + player.enterPortal(1) + + player.use(player.find(hall, "oak_staircase"), "Climb-up") + + assertEquals(GROUND_LEVEL, player.tile.level) + assertEquals("dialogue_message2", player.dialogue) + player.skipDialogues() + assertNull(player.dialogue) + } + + @Test + fun `Build a hall at the top of the stairs in building mode`() { + val player = createOwner() + player.addHouseRoom("skill_hall", hall, rotation = 2) + player.addHouseFurniture(hall, "skill_hall_stair_space", "oak_staircase") + player.inventory.add("coins", 25000) + player.enterPortal(2) + + player.use(player.find(hall, "oak_staircase"), "Climb-up") + player.skipDialogues() + player.dialogueOption("line2") + + assertEquals("quest_hall", player.houseRoomIds.last()) + assertEquals(upperHall, player.houseRoomPositions.last()) + assertEquals(2, player.houseRoomRotations.last()) + assertEquals("oak_staircase", player.houseStairs(upperHall)) + assertEquals(UPPER_LEVEL, player.tile.level) + player.find(upperHall, "oak_staircase_down") + } + + @Test + fun `Building stairs builds them on the floor above`() { + val player = createOwner() + player.addHouseRoom("skill_hall", hall) + player.addHouseRoom("skill_hall", upperHall) + player.inventory.add("hammer") + player.inventory.add("saw") + player.inventory.add("oak_plank", 10) + player.inventory.add("steel_bar", 4) + player.enterPortal(2) + + player.use(player.find(hall, "skill_hall_stair_space"), "Build") + tickIf { !player.hasOpen("furniture_creation") } + player.interfaceOption("furniture_creation", "items", "Build", item = Item("oak_staircase"), slot = 0) + + assertEquals("oak_staircase", player.houseStairs(hall)) + assertEquals("oak_staircase", player.houseStairs(upperHall)) + player.find(upperHall, "oak_staircase_down") + } + + @Test + fun `Removing stairs removes them from both floors`() { + val player = createOwner() + player.addHalls() + player.enterPortal(2) + + player.use(player.find(upperHall, "oak_staircase_down"), "Remove") + player.dialogueOption("line1") + + assertNull(player.houseStairs(hall)) + assertNull(player.houseStairs(upperHall)) + assertNull(player.findOrNull(hall, "oak_staircase")) + player.find(hall, "skill_hall_stair_space") + } + + @Test + fun `Cancel building a room at the top of the stairs`() { + val player = createOwner() + player.addHouseRoom("skill_hall", hall) + player.addHouseFurniture(hall, "skill_hall_stair_space", "oak_staircase") + player.inventory.add("coins", 25000) + player.enterPortal(2) + + player.use(player.find(hall, "oak_staircase"), "Climb-up") + player.skipDialogues() + player.dialogueOption("line3") + + assertEquals(listOf(START_ROOM, hall), player.houseRoomPositions) + assertEquals(25000, player.inventory.count("coins")) + assertEquals(GROUND_LEVEL, player.tile.level) + } + + @Test + fun `Can't build an upstairs room without a room below to support it`() { + val player = createOwner() + player.addHalls() + player.enterPortal(2) + player.use(player.find(hall, "oak_staircase"), "Climb-up") + val upper = roomZone(player.instance()!!.tile.zone, upperHall).tile + player.tele(upper.add(6, 3)) + + player.objectOption(GameObjects.find(upper.add(7, 3)) { it.id.startsWith("door_hotspot") }, "Build") + tick(5) + + assertFalse(player.hasOpen("room_creation")) + assertEquals("dialogue_message1", player.dialogue) + assertNull(player["house_preview_position"]) + } + + @Test + fun `Build an upstairs room with a room below to support it`() { + val player = createOwner() + player.addHalls() + player.addHouseRoom("parlour", roomPosition(5, 3, GROUND_LEVEL)) + player.enterPortal(2) + player.use(player.find(hall, "oak_staircase"), "Climb-up") + val upper = roomZone(player.instance()!!.tile.zone, upperHall).tile + player.tele(upper.add(6, 3)) + + player.objectOption(GameObjects.find(upper.add(7, 3)) { it.id.startsWith("door_hotspot") }, "Build") + tick(5) + + assertTrue(player.hasOpen("room_creation")) + assertEquals(roomPosition(5, 3, UPPER_LEVEL), player["house_preview_position"]) + } + + @Test + fun `Removing other furniture keeps the stairs`() { + val player = createOwner() + player.addHalls() + player.addHouseFurniture(upperHall, "skill_hall_fishing_trophy_space", "mounted_bass") + player.enterPortal(2) + + player.use(player.find(upperHall, "mounted_bass"), "Remove") + player.dialogueOption("line1") + + assertEquals("oak_staircase", player.houseStairs(hall)) + assertEquals("oak_staircase", player.houseStairs(upperHall)) + player.find(hall, "oak_staircase") + } +} diff --git a/game/src/test/kotlin/content/skill/construction/HouseTest.kt b/game/src/test/kotlin/content/skill/construction/HouseTest.kt index cd20737376..e8bc5f3d8d 100644 --- a/game/src/test/kotlin/content/skill/construction/HouseTest.kt +++ b/game/src/test/kotlin/content/skill/construction/HouseTest.kt @@ -6,6 +6,7 @@ import content.quest.instance import content.skill.construction.House.Companion.DUNGEON_LEVEL import content.skill.construction.House.Companion.GROUND_LEVEL import content.skill.construction.House.Companion.START_ROOM +import content.skill.construction.House.Companion.UPPER_LEVEL import content.skill.construction.House.Companion.addHouseFurniture import content.skill.construction.House.Companion.addHouseRoom import content.skill.construction.House.Companion.houseRoomIds @@ -445,6 +446,22 @@ class HouseTest : WorldTest() { assertNull(dynamicZones.dynamicZone(base.add(7, 4, GROUND_LEVEL))) } + @Test + fun `Indoor rooms without a room above are roofed`() { + val player = createOwner() + player.addHouseRoom("parlour", roomPosition(4, 3, GROUND_LEVEL)) + player.addHouseRoom("kitchen", roomPosition(3, 2, GROUND_LEVEL)) + player.addHouseRoom("parlour", roomPosition(3, 2, UPPER_LEVEL)) + player.enterPortal(1) + val base = player.instance()!!.tile.zone + val roof = dynamicZones.dynamicZone(base.add(5, 4, UPPER_LEVEL)) + + assertNotNull(roof) + assertNull(dynamicZones.dynamicZone(base.add(4, 4, UPPER_LEVEL))) + assertEquals(dynamicZones.dynamicZone(base.add(5, 4, GROUND_LEVEL)), dynamicZones.dynamicZone(base.add(4, 3, UPPER_LEVEL))) + assertEquals(roof, dynamicZones.dynamicZone(base.add(4, 3, UPPER_LEVEL + 1))) + } + @Test fun `Remove a room through its door`() { val player = createOwner() From 9a168cc61af256e51726d590b7d81cc63929410c Mon Sep 17 00:00:00 2001 From: GregHib Date: Fri, 2 Oct 2026 13:01:08 +0100 Subject: [PATCH 14/28] Region level changes --- .../voidps/engine/client/update/view/Viewport.kt | 3 +++ .../kotlin/content/entity/world/RegionLoading.kt | 12 ++++++++++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/engine/src/main/kotlin/world/gregs/voidps/engine/client/update/view/Viewport.kt b/engine/src/main/kotlin/world/gregs/voidps/engine/client/update/view/Viewport.kt index 71b140c281..6e5dbbc57d 100644 --- a/engine/src/main/kotlin/world/gregs/voidps/engine/client/update/view/Viewport.kt +++ b/engine/src/main/kotlin/world/gregs/voidps/engine/client/update/view/Viewport.kt @@ -27,6 +27,9 @@ class Viewport { // The DynamicZones.version the last region update was sent with var dynamicVersion: Int = -1 + + // The highest level the last dynamic region update was sent with + var maxLevel: Int = 3 var size: Int = 0 val tileSize: Int get() = VIEWPORT_SIZES[size] diff --git a/game/src/main/kotlin/content/entity/world/RegionLoading.kt b/game/src/main/kotlin/content/entity/world/RegionLoading.kt index 3d2c3ce76d..9b6625daf8 100644 --- a/game/src/main/kotlin/content/entity/world/RegionLoading.kt +++ b/game/src/main/kotlin/content/entity/world/RegionLoading.kt @@ -101,7 +101,13 @@ class RegionLoading(val dynamicZones: DynamicZones) : Script { } fun crossedDynamicBoarder(player: Player) = player.viewport!!.dynamic != inDynamicView(player) || - (dynamicZones.dynamicUpdate(player.tile.region) && player.viewport!!.dynamicVersion != dynamicZones.version) + (dynamicZones.dynamicUpdate(player.tile.region) && player.viewport!!.dynamicVersion != dynamicZones.version) || + (player.viewport!!.dynamic && player.viewport!!.maxLevel != maxLevel(player)) + + /** + * Highest level of a dynamic region to send, levels above the player can be hidden so they don't block the view e.g. underground + */ + fun maxLevel(player: Player): Int = if (player["hide_upper_levels", false]) player.tile.level else 3 fun inDynamicView(player: Player): Boolean = dynamicZones.dynamic(player.tile.region) @@ -163,11 +169,12 @@ class RegionLoading(val dynamicZones: DynamicZones) : Script { val view = player.tile.zone.minus(viewport.zoneRadius, viewport.zoneRadius) val zoneSize = viewport.zoneArea val xtea = blankXtea + val maxLevel = maxLevel(player) for (lvl in 0..3) { for (x in 0 until zoneSize) { for (y in 0 until zoneSize) { val zone = Zone(view.x + x, view.y + y, lvl) - val target = dynamicZones.dynamicZone(zone) + val target = if (lvl > maxLevel) null else dynamicZones.dynamicZone(zone) if (target == null) { zones.add(null) continue @@ -181,6 +188,7 @@ class RegionLoading(val dynamicZones: DynamicZones) : Script { } } viewport.dynamic = true + viewport.maxLevel = maxLevel player.client?.dynamicMapRegion( zoneX = player.tile.zone.x, zoneY = player.tile.zone.y, From 023034cd4c052f6220e0b9e6b15a0ca1cbb5a44a Mon Sep 17 00:00:00 2001 From: GregHib Date: Fri, 2 Oct 2026 13:56:19 +0100 Subject: [PATCH 15/28] Stairs tests --- .../skill/construction/HouseStairsTest.kt | 94 +++++++++++++++---- 1 file changed, 77 insertions(+), 17 deletions(-) diff --git a/game/src/test/kotlin/content/skill/construction/HouseStairsTest.kt b/game/src/test/kotlin/content/skill/construction/HouseStairsTest.kt index 02e92baf2c..779d068c6e 100644 --- a/game/src/test/kotlin/content/skill/construction/HouseStairsTest.kt +++ b/game/src/test/kotlin/content/skill/construction/HouseStairsTest.kt @@ -14,6 +14,7 @@ import content.skill.construction.House.Companion.houseRoomRotations import content.skill.construction.House.Companion.houseStairs import content.skill.construction.House.Companion.roomPosition import content.skill.construction.House.Companion.roomZone +import content.skill.construction.House.Companion.stairsDown import dialogueOption import interfaceOption import objectOption @@ -83,15 +84,28 @@ class HouseStairsTest : WorldTest() { addHouseFurniture(hall, "garden_centrepiece_space", "dungeon_entrance") } - private fun Player.addHalls(dungeonStairs: Boolean = false) { + private fun Player.addHalls() { addHouseRoom("skill_hall", hall) addHouseFurniture(hall, "skill_hall_stair_space", "oak_staircase") addHouseRoom("skill_hall", upperHall) addHouseFurniture(upperHall, "skill_hall_stair_space_down", "oak_staircase") - if (dungeonStairs) { - addHouseRoom("dungeon_stairs", dungeon) - addHouseFurniture(dungeon, "skill_hall_stair_space", "oak_staircase") - } + } + + private fun Player.addDungeonStairs() { + addHouseRoom("skill_hall", hall) + addHouseFurniture(hall, "skill_hall_stair_space_down", "oak_staircase") + addHouseRoom("dungeon_stairs", dungeon) + addHouseFurniture(dungeon, "skill_hall_stair_space", "oak_staircase") + } + + private fun Player.buildStairs() { + inventory.add("hammer") + inventory.add("saw") + inventory.add("oak_plank", 10) + inventory.add("steel_bar", 4) + use(find(hall, "skill_hall_stair_space"), "Build") + tickIf { !hasOpen("furniture_creation") } + interfaceOption("furniture_creation", "items", "Build", item = Item("oak_staircase"), slot = 0) } @Test @@ -177,20 +191,18 @@ class HouseStairsTest : WorldTest() { } @Test - fun `Ground floor stairs choose between up and down when there are dungeon stairs`() { + fun `Climb down to the dungeon stairs and back up`() { val player = createOwner() - player.addHalls(dungeonStairs = true) + player.addDungeonStairs() player.enterPortal(1) - player.use(player.find(hall, "oak_staircase"), "Climb-up") - player.dialogueOption("line2") + player.use(player.find(hall, "oak_staircase_down"), "Climb-down") tick(4) assertEquals(DUNGEON_LEVEL, player.tile.level) assertTrue(player["hide_upper_levels", false]) player.use(player.find(dungeon, "oak_staircase"), "Climb-up") - tick(4) assertEquals(GROUND_LEVEL, player.tile.level) assertFalse(player["hide_upper_levels", false]) @@ -236,21 +248,69 @@ class HouseStairsTest : WorldTest() { val player = createOwner() player.addHouseRoom("skill_hall", hall) player.addHouseRoom("skill_hall", upperHall) - player.inventory.add("hammer") - player.inventory.add("saw") - player.inventory.add("oak_plank", 10) - player.inventory.add("steel_bar", 4) player.enterPortal(2) - player.use(player.find(hall, "skill_hall_stair_space"), "Build") - tickIf { !player.hasOpen("furniture_creation") } - player.interfaceOption("furniture_creation", "items", "Build", item = Item("oak_staircase"), slot = 0) + player.buildStairs() + player.dialogueOption("line1") assertEquals("oak_staircase", player.houseStairs(hall)) + assertFalse(player.stairsDown(hall)) assertEquals("oak_staircase", player.houseStairs(upperHall)) + assertEquals(0, player.inventory.count("oak_plank")) + player.find(hall, "oak_staircase") player.find(upperHall, "oak_staircase_down") } + @Test + fun `Build stairs leading down to the dungeon stairs`() { + val player = createOwner() + player.addHouseRoom("skill_hall", hall) + player.addHouseRoom("dungeon_stairs", dungeon) + player.enterPortal(2) + + player.buildStairs() + player.dialogueOption("line2") + + assertTrue(player.stairsDown(hall)) + assertEquals("oak_staircase", player.houseStairs(dungeon)) + assertEquals(0, player.inventory.count("oak_plank")) + player.find(hall, "oak_staircase_down") + player.find(dungeon, "oak_staircase") + } + + @Test + fun `Build a dungeon stairs room at the bottom of the stairs in building mode`() { + val player = createOwner() + player.addHouseRoom("skill_hall", hall) + player.addHouseFurniture(hall, "skill_hall_stair_space_down", "oak_staircase") + player.inventory.add("coins", 7500) + player.enterPortal(2) + + player.use(player.find(hall, "oak_staircase_down"), "Climb-down") + player.skipDialogues() + player.dialogueOption("line1") + tick(4) + + assertEquals("dungeon_stairs", player.houseRoomIds.last()) + assertEquals("oak_staircase", player.houseStairs(dungeon)) + assertEquals(DUNGEON_LEVEL, player.tile.level) + } + + @Test + fun `Removing stairs leading down restores the stair space`() { + val player = createOwner() + player.addDungeonStairs() + player.enterPortal(2) + + player.use(player.find(hall, "oak_staircase_down"), "Remove") + player.dialogueOption("line1") + + assertNull(player.houseStairs(hall)) + assertNull(player.houseStairs(dungeon)) + player.find(hall, "skill_hall_stair_space") + player.find(dungeon, "skill_hall_stair_space") + } + @Test fun `Removing stairs removes them from both floors`() { val player = createOwner() From 92b3eebc4d21fc3151bf3dcb51529fdc35d2b5a9 Mon Sep 17 00:00:00 2001 From: GregHib Date: Fri, 2 Oct 2026 13:56:57 +0100 Subject: [PATCH 16/28] Start adding obj interactions --- .../construction/construction.anims.toml | 22 +++ .../skill/construction/construction.objs.toml | 9 ++ .../construction/construction.tables.toml | 124 +++++++++++++++ .../skill/construction/FurnitureCreation.kt | 2 +- .../skill/construction/HouseFurniture.kt | 109 +++++++++++++ .../kotlin/content/skill/cooking/Filling.kt | 2 +- .../skill/construction/HouseFurnitureTest.kt | 147 ++++++++++++++++++ 7 files changed, 413 insertions(+), 2 deletions(-) create mode 100644 game/src/main/kotlin/content/skill/construction/HouseFurniture.kt create mode 100644 game/src/test/kotlin/content/skill/construction/HouseFurnitureTest.kt diff --git a/data/skill/construction/construction.anims.toml b/data/skill/construction/construction.anims.toml index fa282e66a0..6200637483 100644 --- a/data/skill/construction/construction.anims.toml +++ b/data/skill/construction/construction.anims.toml @@ -3,3 +3,25 @@ id = 3683 [construction_remove] id = 3685 + +# Ids from the Fury emulator, guessed +[light_fireplace] +id = 3658 + +[fill_beer_barrel] +id = 3661 + +[fill_cider_barrel] +id = 3662 + +[fill_asgarnian_ale_barrel] +id = 3663 + +[fill_greenmans_ale_barrel] +id = 3664 + +[fill_dragon_bitter_barrel] +id = 3665 + +[fill_chefs_delight_barrel] +id = 3666 diff --git a/data/skill/construction/construction.objs.toml b/data/skill/construction/construction.objs.toml index 8e338b5b6b..938a4f66fb 100644 --- a/data/skill/construction/construction.objs.toml +++ b/data/skill/construction/construction.objs.toml @@ -176,12 +176,21 @@ id = 13605 [clay_fireplace] id = 13609 +[clay_fireplace_lit] +id = 13610 + [stone_fireplace] id = 13611 +[stone_fireplace_lit] +id = 13612 + [marble_fireplace] id = 13613 +[marble_fireplace_lit] +id = 13614 + [house_window_space] id = 13830 diff --git a/data/skill/construction/construction.tables.toml b/data/skill/construction/construction.tables.toml index 9363deeba6..89d57d1f69 100644 --- a/data/skill/construction/construction.tables.toml +++ b/data/skill/construction/construction.tables.toml @@ -3625,3 +3625,127 @@ xp = 10000 materials = ["magic_stone"] amounts = [1] objects = ["magic_treasure_room_chest"] + +# Items which can be taken from storage furniture +[house_storage] +row_id = "obj" +items = "list" + +# https://oldschool.runescape.wiki/w/Larder +[.wooden_larder] +items = ["tea_leaves", "bucket_of_milk"] + +[.oak_larder] +items = ["tea_leaves", "bucket_of_milk", "egg", "pot_of_flour"] + +[.teak_larder] +items = ["tea_leaves", "bucket_of_milk", "egg", "pot_of_flour", "raw_potato", "garlic", "onion", "cheese"] + +# https://oldschool.runescape.wiki/w/Wooden_shelves_1 +[.wooden_shelves_1] +items = ["kettle", "teapot_clay", "empty_cup_player_owned_house"] + +# https://oldschool.runescape.wiki/w/Wooden_shelves_2 +[.wooden_shelves_2] +items = ["kettle", "teapot_clay", "empty_cup_player_owned_house", "beer_glass"] + +# https://oldschool.runescape.wiki/w/Wooden_shelves_3 +[.wooden_shelves_3] +items = ["kettle", "teapot_white", "porcelain_cup_white", "beer_glass", "cake_tin"] + +# https://oldschool.runescape.wiki/w/Oak_shelves_1 +[.oak_shelves_1] +items = ["kettle", "teapot_clay", "empty_cup_player_owned_house", "beer_glass", "cake_tin", "bowl"] + +# https://oldschool.runescape.wiki/w/Oak_shelves_2 +[.oak_shelves_2] +items = ["kettle", "teapot_white", "porcelain_cup_white", "beer_glass", "cake_tin", "bowl", "pie_dish"] + +# https://oldschool.runescape.wiki/w/Teak_shelves_1 +[.teak_shelves_1] +items = ["kettle", "teapot_white", "porcelain_cup_white", "beer_glass", "cake_tin", "bowl", "pie_dish", "empty_pot"] + +# https://oldschool.runescape.wiki/w/Teak_shelves_2 +[.teak_shelves_2] +items = ["kettle", "teapot_gold", "porcelain_cup_gold", "beer_glass", "cake_tin", "bowl", "pie_dish", "empty_pot", "chefs_hat"] + +# https://oldschool.runescape.wiki/w/Tool_store_1 +[.tool_store_1] +items = ["saw", "hammer", "chisel", "shears"] + +# https://oldschool.runescape.wiki/w/Tool_store_2 +[.tool_store_2] +items = ["bucket", "knife", "spade", "tinderbox"] + +# https://oldschool.runescape.wiki/w/Tool_store_3 +[.tool_store_3] +items = ["brown_apron", "glassblowing_pipe", "needle"] + +# https://oldschool.runescape.wiki/w/Tool_store_4 +[.tool_store_4] +items = ["amulet_unstrung_mould", "necklace_mould", "ring_mould", "holy_mould", "bracelet_mould", "tiara_mould"] + +# https://oldschool.runescape.wiki/w/Tool_store_5 +[.tool_store_5] +items = ["rake", "spade", "gardening_trowel", "seed_dibber", "watering_can", "secateurs"] + +# https://oldschool.runescape.wiki/w/Weapons_rack +[.glove_rack] +items = ["boxing_gloves_red", "boxing_gloves_blue"] + +[.weapons_rack] +items = ["boxing_gloves_red", "boxing_gloves_blue", "wooden_sword", "wooden_shield_weapons_rack"] + +# Extra rack's pugel stick is from the Fury emulator +[.extra_weapons_rack] +items = ["boxing_gloves_red", "boxing_gloves_blue", "wooden_sword", "wooden_shield_weapons_rack", "pugel"] + +# Drinks poured into an empty beer glass from kitchen barrels +# https://oldschool.runescape.wiki/w/Beer_barrel +[house_barrels] +row_id = "obj" +drink = "item" +anim = "string" + +[.beer_barrel] +drink = "beer" +anim = "fill_beer_barrel" + +[.cider_barrel] +drink = "cider" +anim = "fill_cider_barrel" + +[.asgarnian_ale_barrel] +drink = "asgarnian_ale" +anim = "fill_asgarnian_ale_barrel" + +[.greenmans_ale_barrel] +drink = "greenmans_ale" +anim = "fill_greenmans_ale_barrel" + +[.dragon_bitter_barrel] +drink = "dragon_bitter" +anim = "fill_dragon_bitter_barrel" + +[.chefs_delight_barrel] +drink = "chefs_delight" +anim = "fill_chefs_delight_barrel" + +# Fireplaces lit with logs and a tinderbox +# https://oldschool.runescape.wiki/w/Clay_fireplace +[house_fireplaces] +row_id = "obj" +lit = "obj" +xp = "int" + +[.clay_fireplace] +lit = "clay_fireplace_lit" +xp = 800 + +[.stone_fireplace] +lit = "stone_fireplace_lit" +xp = 800 + +[.marble_fireplace] +lit = "marble_fireplace_lit" +xp = 800 diff --git a/game/src/main/kotlin/content/skill/construction/FurnitureCreation.kt b/game/src/main/kotlin/content/skill/construction/FurnitureCreation.kt index 8ffdcdcf4a..230adb9f3c 100644 --- a/game/src/main/kotlin/content/skill/construction/FurnitureCreation.kt +++ b/game/src/main/kotlin/content/skill/construction/FurnitureCreation.kt @@ -11,8 +11,8 @@ import content.skill.construction.House.Companion.houseBase import content.skill.construction.House.Companion.houseFurniture import content.skill.construction.House.Companion.houseRoom import content.skill.construction.House.Companion.houseStairs -import content.skill.construction.House.Companion.loadHouse import content.skill.construction.House.Companion.inOwnHouse +import content.skill.construction.House.Companion.loadHouse import content.skill.construction.House.Companion.placeFurniture import content.skill.construction.House.Companion.removeHouseFurniture import content.skill.construction.House.Companion.removeHouseStairs diff --git a/game/src/main/kotlin/content/skill/construction/HouseFurniture.kt b/game/src/main/kotlin/content/skill/construction/HouseFurniture.kt new file mode 100644 index 0000000000..1bb1ff20e0 --- /dev/null +++ b/game/src/main/kotlin/content/skill/construction/HouseFurniture.kt @@ -0,0 +1,109 @@ +package content.skill.construction + +import content.entity.player.dialogue.type.choice +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.client.message +import world.gregs.voidps.engine.data.definition.ItemDefinitions +import world.gregs.voidps.engine.data.definition.Rows +import world.gregs.voidps.engine.data.definition.Tables +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.entity.character.player.chat.inventoryFull +import world.gregs.voidps.engine.entity.character.player.skill.Skill +import world.gregs.voidps.engine.entity.character.player.skill.exp.exp +import world.gregs.voidps.engine.entity.obj.replace +import world.gregs.voidps.engine.inv.add +import world.gregs.voidps.engine.inv.contains +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.engine.inv.remove +import world.gregs.voidps.engine.inv.replace +import java.time.LocalTime +import java.time.ZoneOffset + +/** + * Furniture which can be used once built: storage to take items from, barrels to pour drinks, fireplaces to light and clocks to read. + */ +class HouseFurniture : Script { + init { + objectOperate("Search", STORAGE) { (target) -> + val items = Tables.itemListOrNull("house_storage.${target.id}.items") ?: return@objectOperate + take(items) + } + + itemOnObjectOperate("beer_glass", BARRELS) { (target) -> + val row = Rows.getOrNull("house_barrels.${target.id}") ?: return@itemOnObjectOperate + val drink = row.item("drink") + if (!inventory.replace("beer_glass", drink)) { + return@itemOnObjectOperate + } + anim(row.string("anim")) + message("You fill the glass with ${ItemDefinitions.get(drink).name.lowercase()}.") // TODO proper message + } + + objectOperate("Light", FIREPLACES) { (target) -> + val row = Rows.getOrNull("house_fireplaces.${target.id}") ?: return@objectOperate + // Lit fireplaces can't be removed + if (get("house_build_mode", false)) { + message("You can't light the fireplace in building mode.") // TODO proper message + return@objectOperate + } + if (!inventory.contains("tinderbox")) { + message("You need a tinderbox to light the fireplace.") // TODO proper message + return@objectOperate + } + if (!inventory.remove("logs")) { + message("You need some logs to light the fireplace.") // TODO proper message + return@objectOperate + } + target.replace(row.obj("lit")) + anim("light_fireplace") + exp(Skill.Firemaking, row.int("xp") / 10.0) + } + + objectOperate("Read", CLOCKS) { + message(clockTime(LocalTime.now(ZoneOffset.UTC).minute)) + } + } + + /** + * Pick one of [items] to take, four at a time when there are too many for one choice + */ + private suspend fun Player.take(items: List) { + val page = if (items.size > 5) items.take(4) else items + choice { + for (item in page) { + option(ItemDefinitions.get(item).name) { + if (!inventory.add(item)) { + inventoryFull() + } + } + } + if (page.size < items.size) { + option("More...") { + take(items.drop(page.size)) + } + } + } + } + + companion object { + private const val STORAGE = "wooden_larder,oak_larder,teak_larder,wooden_shelves_*,oak_shelves_*,teak_shelves_*,tool_store_*,glove_rack,weapons_rack,extra_weapons_rack" + private const val BARRELS = "beer_barrel,cider_barrel,asgarnian_ale_barrel,greenmans_ale_barrel,dragon_bitter_barrel,chefs_delight_barrel" + private const val FIREPLACES = "clay_fireplace,stone_fireplace,marble_fireplace" + private const val CLOCKS = "oak_clock,teak_clock,gilded_clock" + + private val numbers = listOf("", "five", "ten", "a quarter", "twenty", "twenty-five", "half") + + /** + * The time to the nearest five minutes, it's always Rune o'clock + * https://oldschool.runescape.wiki/w/Oak_clock + */ + fun clockTime(minute: Int): String { + val rounded = (minute + 2) / 5 % 12 + return when { + rounded == 0 -> "It's Rune o'clock." + rounded <= 6 -> "It's ${numbers[rounded]} past Rune." + else -> "It's ${numbers[12 - rounded]} to Rune." + } + } + } +} diff --git a/game/src/main/kotlin/content/skill/cooking/Filling.kt b/game/src/main/kotlin/content/skill/cooking/Filling.kt index 246739c563..93c5cabedb 100644 --- a/game/src/main/kotlin/content/skill/cooking/Filling.kt +++ b/game/src/main/kotlin/content/skill/cooking/Filling.kt @@ -9,7 +9,7 @@ import world.gregs.voidps.engine.inv.replace class Filling : Script { init { - itemOnObjectOperate(obj = "sink*,fountain*,well*,water_trough*,pump_and_drain*,water_barrel_*,waterpump_*") { (target, item) -> + itemOnObjectOperate(obj = "sink*,fountain*,well*,water_trough*,pump_and_drain*,pump_and_tub*,water_barrel_*,waterpump_*") { (target, item) -> if (!item.def.contains("full")) { return@itemOnObjectOperate } diff --git a/game/src/test/kotlin/content/skill/construction/HouseFurnitureTest.kt b/game/src/test/kotlin/content/skill/construction/HouseFurnitureTest.kt new file mode 100644 index 0000000000..d68adbc5c4 --- /dev/null +++ b/game/src/test/kotlin/content/skill/construction/HouseFurnitureTest.kt @@ -0,0 +1,147 @@ +package content.skill.construction + +import WorldTest +import containsMessage +import content.skill.construction.HouseFurniture.Companion.clockTime +import dialogueOption +import itemOnObject +import objectOption +import org.junit.jupiter.api.Test +import world.gregs.voidps.engine.entity.character.player.skill.Skill +import world.gregs.voidps.engine.entity.obj.GameObjects +import world.gregs.voidps.engine.inv.add +import world.gregs.voidps.engine.inv.inventory +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class HouseFurnitureTest : WorldTest() { + + @Test + fun `Take an item from a larder`() { + val player = createPlayer(emptyTile) + val larder = createObject("oak_larder", emptyTile.addY(1)) + + player.objectOption(larder, "Search") + tick() + player.dialogueOption("line3") + + assertEquals(1, player.inventory.count("egg")) + } + + @Test + fun `Take an item from the second page of shelves`() { + val player = createPlayer(emptyTile) + val shelves = createObject("teak_shelves_2", emptyTile.addY(1)) + + player.objectOption(shelves, "Search") + tick() + player.dialogueOption("line5") + player.dialogueOption("line5") + + assertEquals(1, player.inventory.count("chefs_hat")) + } + + @Test + fun `Can't take items with a full inventory`() { + val player = createPlayer(emptyTile) + player.inventory.add("coins") + player.inventory.add("shark", 27) + val store = createObject("tool_store_1", emptyTile.addY(1)) + + player.objectOption(store, "Search") + tick() + player.dialogueOption("line2") + + assertEquals(0, player.inventory.count("hammer")) + assertTrue(player.containsMessage("inventory space")) + } + + @Test + fun `Fill a beer glass from a barrel`() { + val player = createPlayer(emptyTile) + player.inventory.add("beer_glass") + val barrel = createObject("dragon_bitter_barrel", emptyTile.addY(1)) + + player.itemOnObject(barrel, 0) + tick() + + assertEquals(0, player.inventory.count("beer_glass")) + assertEquals(1, player.inventory.count("dragon_bitter")) + } + + @Test + fun `Light a fireplace`() { + val player = createPlayer(emptyTile) + player.inventory.add("tinderbox", "logs") + val fireplace = createObject("clay_fireplace", emptyTile.addX(-1)) + + player.objectOption(fireplace, "Light") + tick() + + assertEquals(0, player.inventory.count("logs")) + assertNotNull(GameObjects.findOrNull(fireplace.tile, "clay_fireplace_lit")) + assertEquals(80.0, player.experience.get(Skill.Firemaking)) + } + + @Test + fun `Can't light a fireplace in building mode`() { + val player = createPlayer(emptyTile) + player["house_build_mode"] = true + player.inventory.add("tinderbox", "logs") + val fireplace = createObject("clay_fireplace", emptyTile.addX(-1)) + + player.objectOption(fireplace, "Light") + tick() + + assertEquals(1, player.inventory.count("logs")) + assertNull(GameObjects.findOrNull(fireplace.tile, "clay_fireplace_lit")) + } + + @Test + fun `Can't light a fireplace without logs`() { + val player = createPlayer(emptyTile) + player.inventory.add("tinderbox") + val fireplace = createObject("marble_fireplace", emptyTile.addX(-1)) + + player.objectOption(fireplace, "Light") + tick() + + assertNull(GameObjects.findOrNull(fireplace.tile, "marble_fireplace_lit")) + assertEquals(0.0, player.experience.get(Skill.Firemaking)) + } + + @Test + fun `Fill a bucket from a pump and tub`() { + val player = createPlayer(emptyTile) + player.inventory.add("bucket") + val pump = createObject("pump_and_tub", emptyTile.addX(-1)) + + player.itemOnObject(pump, 0) + tick(2) + + assertEquals(1, player.inventory.count("bucket_of_water")) + } + + @Test + fun `Read a clock`() { + val player = createPlayer(emptyTile) + val clock = createObject("oak_clock", emptyTile.addY(1)) + + player.objectOption(clock, "Read") + tick() + + assertTrue(player.containsMessage("Rune")) + } + + @Test + fun `Clock time rounds to the nearest five minutes`() { + assertEquals("It's Rune o'clock.", clockTime(1)) + assertEquals("It's a quarter past Rune.", clockTime(15)) + assertEquals("It's half past Rune.", clockTime(32)) + assertEquals("It's twenty to Rune.", clockTime(39)) + assertEquals("It's five to Rune.", clockTime(55)) + assertEquals("It's Rune o'clock.", clockTime(58)) + } +} From 37d2ba3d4b7997605446b3eb139ddd9fe40c53a1 Mon Sep 17 00:00:00 2001 From: GregHib Date: Fri, 2 Oct 2026 15:40:18 +0100 Subject: [PATCH 17/28] Add portal chamber and chapel functionality --- .../construction/construction.anims.toml | 4 + .../skill/construction/construction.objs.toml | 84 ++++++++ .../construction/construction.tables.toml | 181 +++++++++++++++++- .../skill/construction/construction.vars.toml | 13 ++ .../content/skill/construction/House.kt | 10 +- .../skill/construction/HouseFurniture.kt | 106 ++++++---- .../skill/construction/PortalChamber.kt | 100 ++++++++++ .../content/skill/prayer/bone/BoneOffering.kt | 10 +- .../skill/construction/HouseFurnitureTest.kt | 119 ++++++++++++ .../skill/construction/PortalChamberTest.kt | 106 ++++++++++ 10 files changed, 684 insertions(+), 49 deletions(-) create mode 100644 game/src/main/kotlin/content/skill/construction/PortalChamber.kt create mode 100644 game/src/test/kotlin/content/skill/construction/PortalChamberTest.kt diff --git a/data/skill/construction/construction.anims.toml b/data/skill/construction/construction.anims.toml index 6200637483..a3ac1bd28f 100644 --- a/data/skill/construction/construction.anims.toml +++ b/data/skill/construction/construction.anims.toml @@ -25,3 +25,7 @@ id = 3665 [fill_chefs_delight_barrel] id = 3666 + +# Placeholder, Fury used 3687 for torches and burners +[light_torch] +id = 3687 diff --git a/data/skill/construction/construction.objs.toml b/data/skill/construction/construction.objs.toml index 938a4f66fb..1693c066fc 100644 --- a/data/skill/construction/construction.objs.toml +++ b/data/skill/construction/construction.objs.toml @@ -1529,24 +1529,45 @@ id = 18782 [steel_torches] id = 13202 +[steel_torches_lit] +id = 13203 + [wooden_torches] id = 13200 +[wooden_torches_lit] +id = 13201 + [steel_candlesticks] id = 13204 +[steel_candlesticks_lit] +id = 13205 + [gold_candlesticks] id = 13206 +[gold_candlesticks_lit] +id = 13207 + [incense_burners] id = 13208 +[incense_burners_lit] +id = 13209 + [mahogany_burners] id = 13210 +[mahogany_burners_lit] +id = 13211 + [marble_burners] id = 13212 +[marble_burners_lit] +id = 13213 + [guthix_symbol] id = 13174 @@ -1643,12 +1664,75 @@ id = 13264 [teak_portal] id = 13636 +[teak_portal_varrock] +id = 13615 + +[teak_portal_lumbridge] +id = 13616 + +[teak_portal_falador] +id = 13617 + +[teak_portal_camelot] +id = 13618 + +[teak_portal_ardougne] +id = 13619 + +[teak_portal_yanille] +id = 13620 + +[teak_portal_kharyrll] +id = 13621 + [mahogany_portal] id = 13637 +[mahogany_portal_varrock] +id = 13622 + +[mahogany_portal_lumbridge] +id = 13623 + +[mahogany_portal_falador] +id = 13624 + +[mahogany_portal_camelot] +id = 13625 + +[mahogany_portal_ardougne] +id = 13626 + +[mahogany_portal_yanille] +id = 13627 + +[mahogany_portal_kharyrll] +id = 13628 + [marble_portal] id = 13638 +[marble_portal_varrock] +id = 13629 + +[marble_portal_lumbridge] +id = 13630 + +[marble_portal_falador] +id = 13631 + +[marble_portal_camelot] +id = 13632 + +[marble_portal_ardougne] +id = 13633 + +[marble_portal_yanille] +id = 13634 + +[marble_portal_kharyrll] +id = 13635 + [teleport_focus] id = 13640 diff --git a/data/skill/construction/construction.tables.toml b/data/skill/construction/construction.tables.toml index 89d57d1f69..e49b7d9581 100644 --- a/data/skill/construction/construction.tables.toml +++ b/data/skill/construction/construction.tables.toml @@ -245,11 +245,13 @@ cost = 25000 exit = { x = 2544, y = 3096 } # Furniture which can be built on each hotspot, hotspots with the same group are built and removed together +# Variant is the player variable picking which of the furniture's objects is placed on the hotspot [house_hotspots] row_id = "obj" furniture = "list" group = "string" piece = "int" +variant = "string" clone = "clone" [.garden_centrepiece_space] @@ -678,12 +680,15 @@ piece = 5 [.portal_chamber_portal_space] furniture = ["teak_portal", "mahogany_portal", "marble_portal"] +variant = "house_portal" [.portal_chamber_portal_space_2] clone = "portal_chamber_portal_space" +variant = "house_portal_2" [.portal_chamber_portal_space_3] clone = "portal_chamber_portal_space" +variant = "house_portal_3" [.portal_chamber_centrepiece_space] furniture = ["teleport_focus", "greater_focus", "scrying_pool"] @@ -2969,21 +2974,21 @@ level = 50 xp = 2700 materials = ["teak_plank"] amounts = [3] -objects = ["teak_portal"] +objects = ["teak_portal", "teak_portal_varrock", "teak_portal_lumbridge", "teak_portal_falador", "teak_portal_camelot", "teak_portal_ardougne", "teak_portal_yanille", "teak_portal_kharyrll"] [.mahogany_portal] level = 65 xp = 4200 materials = ["mahogany_plank"] amounts = [3] -objects = ["mahogany_portal"] +objects = ["mahogany_portal", "mahogany_portal_varrock", "mahogany_portal_lumbridge", "mahogany_portal_falador", "mahogany_portal_camelot", "mahogany_portal_ardougne", "mahogany_portal_yanille", "mahogany_portal_kharyrll"] [.marble_portal] level = 80 xp = 15000 materials = ["marble_block"] amounts = [3] -objects = ["marble_portal"] +objects = ["marble_portal", "marble_portal_varrock", "marble_portal_lumbridge", "marble_portal_falador", "marble_portal_camelot", "marble_portal_ardougne", "marble_portal_yanille", "marble_portal_kharyrll"] [.teleport_focus] level = 50 @@ -3731,21 +3736,181 @@ anim = "fill_dragon_bitter_barrel" drink = "chefs_delight" anim = "fill_chefs_delight_barrel" -# Fireplaces lit with logs and a tinderbox -# https://oldschool.runescape.wiki/w/Clay_fireplace -[house_fireplaces] +# Furniture lit with a tinderbox at a Firemaking level, using up an item if needed +# Lit until the room is reloaded, or for ticks plus the players Firemaking level plus a random amount up to their Firemaking level +# Lit objects have no remove option so can't be lit in building mode +[house_lights] row_id = "obj" lit = "obj" +level = "int" +item = "item" xp = "int" +ticks = "int" +anim = "string" +clone = "clone" +# https://oldschool.runescape.wiki/w/Clay_fireplace [.clay_fireplace] lit = "clay_fireplace_lit" +item = "logs" xp = 800 +anim = "light_fireplace" [.stone_fireplace] +clone = "clay_fireplace" lit = "stone_fireplace_lit" -xp = 800 [.marble_fireplace] +clone = "clay_fireplace" lit = "marble_fireplace_lit" -xp = 800 + +# https://oldschool.runescape.wiki/w/Wooden_torches +# All lamps need a herb to light, not only burners +[.wooden_torches] +lit = "wooden_torches_lit" +level = 30 +item = "clean_marrentill" +anim = "light_torch" + +# https://oldschool.runescape.wiki/w/Steel_torches +[.steel_torches] +clone = "wooden_torches" +lit = "steel_torches_lit" + +# https://oldschool.runescape.wiki/w/Steel_candlesticks +[.steel_candlesticks] +clone = "wooden_torches" +lit = "steel_candlesticks_lit" + +# Level guessed, unlisted on https://oldschool.runescape.wiki/w/Gold_candlesticks +[.gold_candlesticks] +clone = "wooden_torches" +lit = "gold_candlesticks_lit" + +# https://oldschool.runescape.wiki/w/Incense_burner_(Oak) +# https://oldschool.runescape.wiki/w/Construction/Lamp_duration +[.incense_burners] +lit = "incense_burners_lit" +level = 30 +item = "clean_marrentill" +ticks = 200 +anim = "light_torch" + +[.mahogany_burners] +clone = "incense_burners" +lit = "mahogany_burners_lit" + +[.marble_burners] +clone = "incense_burners" +lit = "marble_burners_lit" + +# Percentage of the bones experience given when offered on an altar, before the bonus from lit incense burners +# https://oldschool.runescape.wiki/w/Chapel +[house_altars] +row_id = "obj" +bonus = "int" +clone = "clone" + +[.prayer_altar_oak_saradomin] +bonus = 100 + +[.prayer_altar_oak_zamorak] +clone = "prayer_altar_oak_saradomin" + +[.prayer_altar_oak_guthix] +clone = "prayer_altar_oak_saradomin" + +[.prayer_altar_teak_saradomin] +bonus = 110 + +[.prayer_altar_teak_zamorak] +clone = "prayer_altar_teak_saradomin" + +[.prayer_altar_teak_guthix] +clone = "prayer_altar_teak_saradomin" + +[.prayer_altar_cloth_saradomin] +bonus = 125 + +[.prayer_altar_cloth_zamorak] +clone = "prayer_altar_cloth_saradomin" + +[.prayer_altar_cloth_guthix] +clone = "prayer_altar_cloth_saradomin" + +[.prayer_altar_mahogany_saradomin] +bonus = 150 + +[.prayer_altar_mahogany_zamorak] +clone = "prayer_altar_mahogany_saradomin" + +[.prayer_altar_mahogany_guthix] +clone = "prayer_altar_mahogany_saradomin" + +[.prayer_altar_limestone_saradomin] +bonus = 175 + +[.prayer_altar_limestone_zamorak] +clone = "prayer_altar_limestone_saradomin" + +[.prayer_altar_limestone_guthix] +clone = "prayer_altar_limestone_saradomin" + +[.prayer_altar_marble_saradomin] +bonus = 200 + +[.prayer_altar_marble_zamorak] +clone = "prayer_altar_marble_saradomin" + +[.prayer_altar_marble_guthix] +clone = "prayer_altar_marble_saradomin" + +[.prayer_altar_gilded_saradomin] +bonus = 250 + +[.prayer_altar_gilded_zamorak] +clone = "prayer_altar_gilded_saradomin" + +[.prayer_altar_gilded_guthix] +clone = "prayer_altar_gilded_saradomin" + +# Destinations portals can be directed to, in the order of each portal frame's objects +[house_portals] +name = "string" +spell = "string" +spellbook = "string" + +[.varrock] +name = "Varrock" +spell = "varrock_teleport" +spellbook = "modern_spellbook" + +[.lumbridge] +name = "Lumbridge" +spell = "lumbridge_teleport" +spellbook = "modern_spellbook" + +[.falador] +name = "Falador" +spell = "falador_teleport" +spellbook = "modern_spellbook" + +[.camelot] +name = "Camelot" +spell = "camelot_teleport" +spellbook = "modern_spellbook" + +[.ardougne] +name = "Ardougne" +spell = "ardougne_teleport" +spellbook = "modern_spellbook" + +[.yanille] +name = "Yanille" +spell = "watchtower_teleport" +spellbook = "modern_spellbook" + +[.kharyrll] +name = "Kharyrll" +spell = "kharyrll_teleport" +spellbook = "ancient_spellbook" diff --git a/data/skill/construction/construction.vars.toml b/data/skill/construction/construction.vars.toml index 4c56969f07..8cbdf2d285 100644 --- a/data/skill/construction/construction.vars.toml +++ b/data/skill/construction/construction.vars.toml @@ -33,3 +33,16 @@ default = "basic_wood" [heraldry_crest] format = "int" persist = true + +# Index of the house_portals destination + 1 each portal chamber portal is directed to, 0 when undirected +[house_portal] +format = "int" +persist = true + +[house_portal_2] +format = "int" +persist = true + +[house_portal_3] +format = "int" +persist = true diff --git a/game/src/main/kotlin/content/skill/construction/House.kt b/game/src/main/kotlin/content/skill/construction/House.kt index 3c17c604e1..ad9f23b89a 100644 --- a/game/src/main/kotlin/content/skill/construction/House.kt +++ b/game/src/main/kotlin/content/skill/construction/House.kt @@ -140,13 +140,17 @@ class House : Script { } /** - * Removes the furniture built in the room at [position], or only that built on [hotspot] + * Removes the furniture built in the room at [position], or only that built on [hotspot], resetting their hotspots variants */ fun Player.removeHouseFurniture(position: Int, hotspot: String? = null) { val rooms = houseFurnitureRooms val hotspots = houseFurnitureHotspots val ids = houseFurnitureIds val keep = rooms.indices.filter { rooms[it] != position || (hotspot != null && hotspots[it] != hotspot) } + for (index in rooms.indices - keep.toSet()) { + val variant = Tables.stringOrNull("house_hotspots.${hotspots[index]}.variant") ?: continue + clear(variant) + } set("house_furniture_rooms", keep.map { rooms[it] }) set("house_furniture_hotspots", keep.map { hotspots[it] }) set("house_furniture_ids", keep.map { ids[it] }) @@ -164,14 +168,14 @@ class House : Script { * Replaces each piece of [hotspot] in [zone] with the matching piece of [furniture], pieces without one are removed. * Furniture objects are listed by piece unless [furniture] lists which pieces they're placed on, * only placed on tiles with one of its anchor pieces when it has any, and turned by its rotations. - * Furniture with a variant uses the object picked by that player variable instead. + * Furniture or hotspots with a variant use the object picked by that player variable instead. */ fun Player.placeFurniture(zone: Zone, hotspot: String, furniture: String) { val objects = Tables.objList("house_furniture.$furniture.objects") val pieces = Tables.intListOrNull("house_furniture.$furniture.pieces") val anchors = Tables.intListOrNull("house_furniture.$furniture.anchors") val rotations = Tables.intListOrNull("house_furniture.$furniture.rotations") - val variant = Tables.stringOrNull("house_furniture.$furniture.variant") + val variant = Tables.stringOrNull("house_furniture.$furniture.variant") ?: Tables.stringOrNull("house_hotspots.$hotspot.variant") for (tile in zone.toCuboid()) { val spaces = GameObjects.at(tile).filter { hotspot(it) == hotspot } val anchored = anchors == null || spaces.any { piece(it) in anchors } diff --git a/game/src/main/kotlin/content/skill/construction/HouseFurniture.kt b/game/src/main/kotlin/content/skill/construction/HouseFurniture.kt index 1bb1ff20e0..4b26218a9d 100644 --- a/game/src/main/kotlin/content/skill/construction/HouseFurniture.kt +++ b/game/src/main/kotlin/content/skill/construction/HouseFurniture.kt @@ -10,17 +10,21 @@ import world.gregs.voidps.engine.entity.character.player.Player import world.gregs.voidps.engine.entity.character.player.chat.inventoryFull import world.gregs.voidps.engine.entity.character.player.skill.Skill import world.gregs.voidps.engine.entity.character.player.skill.exp.exp +import world.gregs.voidps.engine.entity.character.player.skill.level.Level.has +import world.gregs.voidps.engine.entity.obj.GameObject +import world.gregs.voidps.engine.entity.obj.GameObjects import world.gregs.voidps.engine.entity.obj.replace import world.gregs.voidps.engine.inv.add import world.gregs.voidps.engine.inv.contains import world.gregs.voidps.engine.inv.inventory import world.gregs.voidps.engine.inv.remove import world.gregs.voidps.engine.inv.replace +import world.gregs.voidps.type.random import java.time.LocalTime import java.time.ZoneOffset /** - * Furniture which can be used once built: storage to take items from, barrels to pour drinks, fireplaces to light and clocks to read. + * Furniture which can be used once built: storage to take items from, barrels to pour drinks, fireplaces and lamps to light and clocks to read. */ class HouseFurniture : Script { init { @@ -39,24 +43,14 @@ class HouseFurniture : Script { message("You fill the glass with ${ItemDefinitions.get(drink).name.lowercase()}.") // TODO proper message } - objectOperate("Light", FIREPLACES) { (target) -> - val row = Rows.getOrNull("house_fireplaces.${target.id}") ?: return@objectOperate - // Lit fireplaces can't be removed - if (get("house_build_mode", false)) { - message("You can't light the fireplace in building mode.") // TODO proper message - return@objectOperate - } - if (!inventory.contains("tinderbox")) { - message("You need a tinderbox to light the fireplace.") // TODO proper message - return@objectOperate - } - if (!inventory.remove("logs")) { - message("You need some logs to light the fireplace.") // TODO proper message - return@objectOperate + objectOperate("Light", LIGHTS) { (target) -> + light(target) + } + + itemOnObjectOperate("tinderbox,logs,clean_marrentill", LIGHTS) { (target, item) -> + if (item.id == "tinderbox" || item.id == Tables.itemOrNull("house_lights.${target.id}.item")) { + light(target) } - target.replace(row.obj("lit")) - anim("light_fireplace") - exp(Skill.Firemaking, row.int("xp") / 10.0) } objectOperate("Read", CLOCKS) { @@ -65,32 +59,76 @@ class HouseFurniture : Script { } /** - * Pick one of [items] to take, four at a time when there are too many for one choice + * Lights [target] with a tinderbox and its item, burning out after a random time depending on Firemaking level if it has ticks */ + private fun Player.light(target: GameObject) { + val row = Rows.getOrNull("house_lights.${target.id}") ?: return + if (get("house_build_mode", false)) { + message("You can't do that in building mode.") // TODO proper message + return + } + if (!has(Skill.Firemaking, row.int("level"), message = true)) { + return + } + if (!inventory.contains("tinderbox")) { + message("You need a tinderbox to light that.") // TODO proper message + return + } + val item = row.itemOrNull("item") + if (item != null && !inventory.remove(item)) { + message("You need ${ItemDefinitions.get(item).name.lowercase()} to light that.") // TODO proper message + return + } + val ticks = row.int("ticks") + val level = levels.get(Skill.Firemaking) + target.replace(row.obj("lit"), ticks = if (ticks == 0) -1 else ticks + level + random.nextInt(level)) + anim(row.string("anim")) + exp(Skill.Firemaking, row.int("xp") / 10.0) + } + private suspend fun Player.take(items: List) { - val page = if (items.size > 5) items.take(4) else items - choice { - for (item in page) { - option(ItemDefinitions.get(item).name) { - if (!inventory.add(item)) { - inventoryFull() - } - } - } - if (page.size < items.size) { - option("More...") { - take(items.drop(page.size)) - } - } + val index = pick(items.map { ItemDefinitions.get(it).name }) + if (index == -1) { + return + } + if (!inventory.add(items[index])) { + inventoryFull() } } companion object { private const val STORAGE = "wooden_larder,oak_larder,teak_larder,wooden_shelves_*,oak_shelves_*,teak_shelves_*,tool_store_*,glove_rack,weapons_rack,extra_weapons_rack" private const val BARRELS = "beer_barrel,cider_barrel,asgarnian_ale_barrel,greenmans_ale_barrel,dragon_bitter_barrel,chefs_delight_barrel" - private const val FIREPLACES = "clay_fireplace,stone_fireplace,marble_fireplace" + private const val LIGHTS = "clay_fireplace,stone_fireplace,marble_fireplace,wooden_torches,steel_torches,steel_candlesticks,gold_candlesticks,incense_burners,mahogany_burners,marble_burners" + private val burners = setOf("incense_burners_lit", "mahogany_burners_lit", "marble_burners_lit") private const val CLOCKS = "oak_clock,teak_clock,gilded_clock" + /** + * Pick one of [options], four at a time with a "More..." option when there are too many for one choice. + * Returns the index picked or -1 if none were. + */ + suspend fun Player.pick(options: List, offset: Int = 0): Int { + val remaining = options.size - offset + val count = if (remaining > 5) 4 else remaining + val lines = options.subList(offset, offset + count) + val choice = choice(if (count < remaining) lines + "More..." else lines) + return when { + choice == -1 -> -1 + choice > count -> pick(options, offset + count) + else -> offset + choice - 1 + } + } + + /** + * Multiplier of bones experience offered on a house [altar], half again for each lit incense burner in its room up to two + * https://oldschool.runescape.wiki/w/Chapel + */ + fun altarBonus(altar: GameObject): Double? { + val bonus = Tables.intOrNull("house_altars.${altar.id}.bonus") ?: return null + val lit = altar.tile.zone.toCuboid().sumOf { tile -> GameObjects.at(tile).count { it.id in burners } } + return (bonus + lit.coerceAtMost(2) * 50) / 100.0 + } + private val numbers = listOf("", "five", "ten", "a quarter", "twenty", "twenty-five", "half") /** diff --git a/game/src/main/kotlin/content/skill/construction/PortalChamber.kt b/game/src/main/kotlin/content/skill/construction/PortalChamber.kt new file mode 100644 index 0000000000..9be90aa085 --- /dev/null +++ b/game/src/main/kotlin/content/skill/construction/PortalChamber.kt @@ -0,0 +1,100 @@ +package content.skill.construction + +import content.entity.player.dialogue.type.choice +import content.skill.construction.House.Companion.furnishRoom +import content.skill.construction.House.Companion.houseBase +import content.skill.construction.House.Companion.houseFurniture +import content.skill.construction.House.Companion.inOwnHouse +import content.skill.construction.House.Companion.roomPosition +import content.skill.construction.House.Companion.roomZone +import content.skill.construction.HouseFurniture.Companion.pick +import content.skill.magic.spell.SpellRunes +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.client.message +import world.gregs.voidps.engine.data.config.RowDefinition +import world.gregs.voidps.engine.data.definition.Tables +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.entity.character.player.Teleport +import world.gregs.voidps.engine.entity.character.player.skill.Skill +import world.gregs.voidps.engine.entity.character.player.skill.level.Level.has +import world.gregs.voidps.engine.entity.item.Item +import world.gregs.voidps.engine.entity.obj.GameObjects +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.engine.inv.remove +import world.gregs.voidps.type.Zone + +/** + * Portal chamber portals are directed to a teleport destination by the centrepiece, costing a hundred casts worth of runes. + * Each portal hotspot's variant stores the destination so the room is furnished with the directed portal. + * https://oldschool.runescape.wiki/w/Portal_chamber + */ +class PortalChamber : Script { + init { + objectOperate("Enter", PORTALS) { (target) -> + val spell = Tables.stringOrNull("house_portals.${target.id.substringAfter("_portal_")}.spell") ?: return@objectOperate + Teleport.teleport(this, spell, "modern") + } + + objectOperate("Direct-portal", FOCUSES) { (target) -> + val base = houseBase() ?: return@objectOperate + if (!inOwnHouse()) { + message("You can only do that in your own house.") // TODO proper message + return@objectOperate + } + val position = roomPosition(base, target.tile.zone) ?: return@objectOperate + val portals = PORTAL_SPACES.filter { houseFurniture(position, it) != null } + if (portals.isEmpty()) { + message("You need to build a portal frame first.") // TODO proper message + return@objectOperate + } + // TODO proper portal names + val portal = if (portals.size == 1) 1 else choice(portals.indices.map { "Portal ${it + 1}" }, "Direct which portal?") + if (portal == -1) { + return@objectOperate + } + val destinations = Tables.get("house_portals").rows() + val index = pick(destinations.map { it.string("name") }) + if (index == -1) { + return@objectOperate + } + direct(base, position, portals[portal - 1], destinations[index], index + 1) + } + } + + /** + * Directs the portal on [hotspot] in the room at [position] to [destination] + * TODO quest requirements for Ardougne (Plague City), Yanille (Watchtower) and Kharyrll (Desert Treasure) + */ + private fun Player.direct(base: Zone, position: Int, hotspot: String, destination: RowDefinition, direction: Int) { + if (houseBase() != base || houseFurniture(position, hotspot) == null) { + return + } + val variant = Tables.string("house_hotspots.$hotspot.variant") + if (get(variant, 0) == direction) { + message("That portal is already directed there.") // TODO proper message + return + } + val book = destination.string("spellbook") + val spell = destination.string("spell") + val level = SpellRunes.magicLevel(book, spell) ?: return + if (!has(Skill.Magic, level, message = true)) { + return + } + val runes = SpellRunes.requiredItems(book, spell)?.map { (id, amount) -> Item(id, amount * RUNE_MULTIPLIER) } ?: return + if (!inventory.remove(runes)) { + message("You need ${runes.joinToString { "${it.amount} ${it.def.name.lowercase()}s" }} to direct the portal there.") // TODO proper message + return + } + set(variant, direction) + GameObjects.reset(roomZone(base, position)) + furnishRoom(base, position, get("house_build_mode", false)) + message("Your portal is now directed to ${destination.string("name")}.") // TODO proper message + } + + companion object { + private const val PORTALS = "teak_portal_*,mahogany_portal_*,marble_portal_*" + private const val FOCUSES = "teleport_focus,greater_teleport_focus,scrying_pool" + private const val RUNE_MULTIPLIER = 100 + private val PORTAL_SPACES = listOf("portal_chamber_portal_space", "portal_chamber_portal_space_2", "portal_chamber_portal_space_3") + } +} diff --git a/game/src/main/kotlin/content/skill/prayer/bone/BoneOffering.kt b/game/src/main/kotlin/content/skill/prayer/bone/BoneOffering.kt index 44b789e884..41e1bedb8a 100644 --- a/game/src/main/kotlin/content/skill/prayer/bone/BoneOffering.kt +++ b/game/src/main/kotlin/content/skill/prayer/bone/BoneOffering.kt @@ -2,6 +2,7 @@ package content.skill.prayer.bone import content.entity.gfx.areaGfx import content.entity.player.dialogue.type.makeAmount +import content.skill.construction.HouseFurniture.Companion.altarBonus import world.gregs.voidps.engine.Script import world.gregs.voidps.engine.client.message import world.gregs.voidps.engine.data.definition.Tables @@ -21,21 +22,22 @@ class BoneOffering : Script { itemOnObjectOperate(obj = "prayer_altar*") { (target, item) -> Tables.intOrNull("bones.${item.id}.xp") ?: return@itemOnObjectOperate val tile = target.nearestTo(tile) + val bonus = altarBonus(target) ?: BONUS val count = inventory.count(item.id) if (count > 1) { val (_, amount) = makeAmount(listOf(item.id), "", count) - offer(item, amount, tile) + offer(item, amount, tile, bonus) } else { - offer(item, 1, tile) + offer(item, 1, tile, bonus) } } } - suspend fun Player.offer(item: Item, amount: Int, tile: Tile) { + suspend fun Player.offer(item: Item, amount: Int, tile: Tile, bonus: Double) { val xp = Tables.intOrNull("bones.${item.id}.xp") ?: return repeat(amount) { if (inventory.remove(item.id)) { - exp(Skill.Prayer, (xp / 10.0) * BONUS) + exp(Skill.Prayer, (xp / 10.0) * bonus) anim("offer_bones") areaGfx("bone_offering", tile) message( diff --git a/game/src/test/kotlin/content/skill/construction/HouseFurnitureTest.kt b/game/src/test/kotlin/content/skill/construction/HouseFurnitureTest.kt index d68adbc5c4..903f4d68a3 100644 --- a/game/src/test/kotlin/content/skill/construction/HouseFurnitureTest.kt +++ b/game/src/test/kotlin/content/skill/construction/HouseFurnitureTest.kt @@ -1,5 +1,6 @@ package content.skill.construction +import FakeRandom import WorldTest import containsMessage import content.skill.construction.HouseFurniture.Companion.clockTime @@ -11,6 +12,7 @@ import world.gregs.voidps.engine.entity.character.player.skill.Skill import world.gregs.voidps.engine.entity.obj.GameObjects import world.gregs.voidps.engine.inv.add import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.type.setRandom import kotlin.test.assertEquals import kotlin.test.assertNotNull import kotlin.test.assertNull @@ -112,6 +114,123 @@ class HouseFurnitureTest : WorldTest() { assertEquals(0.0, player.experience.get(Skill.Firemaking)) } + @Test + fun `Light torches with marrentill`() { + val player = createPlayer(emptyTile) + player.levels.set(Skill.Firemaking, 30) + player.inventory.add("tinderbox", "clean_marrentill") + val torches = createObject("steel_torches", emptyTile.addY(1)) + + player.objectOption(torches, "Light") + tick() + + assertEquals(0, player.inventory.count("clean_marrentill")) + assertNotNull(GameObjects.findOrNull(torches.tile, "steel_torches_lit")) + } + + @Test + fun `Can't light torches without marrentill`() { + val player = createPlayer(emptyTile) + player.levels.set(Skill.Firemaking, 30) + player.inventory.add("tinderbox") + val torches = createObject("steel_candlesticks", emptyTile.addY(1)) + + player.objectOption(torches, "Light") + tick() + + assertNull(GameObjects.findOrNull(torches.tile, "steel_candlesticks_lit")) + } + + @Test + fun `Can't light torches without the firemaking level`() { + val player = createPlayer(emptyTile) + player.levels.set(Skill.Firemaking, 29) + player.inventory.add("tinderbox") + val torches = createObject("gold_candlesticks", emptyTile.addY(1)) + + player.objectOption(torches, "Light") + tick() + + assertNull(GameObjects.findOrNull(torches.tile, "gold_candlesticks_lit")) + assertTrue(player.containsMessage("Firemaking level of 30")) + } + + @Test + fun `Light incense burners with marrentill`() { + val player = createPlayer(emptyTile) + player.levels.set(Skill.Firemaking, 30) + player.inventory.add("tinderbox", "clean_marrentill") + val burners = createObject("incense_burners", emptyTile.addY(1)) + + player.itemOnObject(burners, 1) + tick() + + assertEquals(0, player.inventory.count("clean_marrentill")) + assertNotNull(GameObjects.findOrNull(burners.tile, "incense_burners_lit")) + } + + @Test + fun `Can't light incense burners without marrentill`() { + val player = createPlayer(emptyTile) + player.levels.set(Skill.Firemaking, 30) + player.inventory.add("tinderbox") + val burners = createObject("mahogany_burners", emptyTile.addY(1)) + + player.objectOption(burners, "Light") + tick() + + assertNull(GameObjects.findOrNull(burners.tile, "mahogany_burners_lit")) + } + + @Test + fun `Incense burners burn out depending on firemaking level`() { + setRandom(object : FakeRandom() { + override fun nextInt(until: Int) = until - 1 + }) + val player = createPlayer(emptyTile) + player.levels.set(Skill.Firemaking, 50) + player.inventory.add("tinderbox", "clean_marrentill") + val burners = createObject("marble_burners", emptyTile.addY(1)) + + player.objectOption(burners, "Light") + tick() + // 200 + level 50 + random 49 + tick(297) + assertNotNull(GameObjects.findOrNull(burners.tile, "marble_burners_lit")) + tick() + + assertNull(GameObjects.findOrNull(burners.tile, "marble_burners_lit")) + assertNotNull(GameObjects.findOrNull(burners.tile, "marble_burners")) + } + + @Test + fun `Offer bones on a house altar`() { + val player = createPlayer(emptyTile) + player.inventory.add("bones") + val altar = createObject("prayer_altar_teak_saradomin", emptyTile.addY(1)) + + player.itemOnObject(altar, 0) + tick(2) + + assertEquals(0, player.inventory.count("bones")) + assertEquals(4.9, player.experience.get(Skill.Prayer)) + } + + @Test + fun `Lit incense burners increase house altar experience`() { + val player = createPlayer(emptyTile) + player.inventory.add("bones") + val altar = createObject("prayer_altar_marble_zamorak", emptyTile.addY(1)) + val zone = altar.tile.zone.tile + createObject("marble_burners_lit", zone.add(7, 7)) + createObject("marble_burners_lit", zone.add(6, 7)) + + player.itemOnObject(altar, 0) + tick(2) + + assertEquals(13.5, player.experience.get(Skill.Prayer)) + } + @Test fun `Fill a bucket from a pump and tub`() { val player = createPlayer(emptyTile) diff --git a/game/src/test/kotlin/content/skill/construction/PortalChamberTest.kt b/game/src/test/kotlin/content/skill/construction/PortalChamberTest.kt new file mode 100644 index 0000000000..ffbdff426e --- /dev/null +++ b/game/src/test/kotlin/content/skill/construction/PortalChamberTest.kt @@ -0,0 +1,106 @@ +package content.skill.construction + +import WorldTest +import content.quest.instance +import content.skill.construction.House.Companion.START_ROOM +import content.skill.construction.House.Companion.addHouseFurniture +import content.skill.construction.House.Companion.addHouseRoom +import content.skill.construction.House.Companion.removeHouseFurniture +import content.skill.construction.House.Companion.roomZone +import dialogueOption +import objectOption +import org.junit.jupiter.api.Test +import world.gregs.voidps.engine.client.ui.dialogue +import world.gregs.voidps.engine.client.ui.hasOpen +import world.gregs.voidps.engine.data.definition.Areas +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.entity.character.player.skill.Skill +import world.gregs.voidps.engine.entity.obj.GameObject +import world.gregs.voidps.engine.entity.obj.GameObjects +import world.gregs.voidps.engine.inv.add +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.type.Tile +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class PortalChamberTest : WorldTest() { + + private val portal = Tile(2951, 3222) + private val exit = Tile(2953, 3224) + + private fun createOwner(direction: Int = 0): Player { + val player = createPlayer(exit, "owner") + player["house_location"] = "rimmington" + player["house_portal"] = direction + player.levels.set(Skill.Magic, 99) + player.addHouseRoom("portal_chamber", START_ROOM) + player.addHouseFurniture(START_ROOM, "portal_chamber_portal_space", "teak_portal") + player.addHouseFurniture(START_ROOM, "portal_chamber_centrepiece_space", "teleport_focus") + enterHouse(player) + return player + } + + private fun enterHouse(player: Player) { + player.objectOption(GameObjects.find(portal, "house_portal_rimmington"), "Enter") + tickIf { player.dialogue == null } + player.dialogueOption("line1") + tickIf { player.hasOpen("house_loading") } + } + + private fun Player.objects(id: String): List { + val zone = roomZone(instance()!!.tile.zone, START_ROOM) + return zone.toCuboid().mapNotNull { GameObjects.findOrNull(it, id) } + } + + @Test + fun `Direct a portal`() { + val player = createOwner() + player.inventory.add("fire_rune", 100) + player.inventory.add("air_rune", 300) + player.inventory.add("law_rune", 100) + + player.objectOption(player.objects("teleport_focus").single(), "Direct-portal") + tickIf { player.dialogue == null } + player.dialogueOption("line1") + + assertEquals(1, player["house_portal", 0]) + assertTrue(player.inventory.isEmpty()) + assertEquals(1, player.objects("teak_portal_varrock").size) + assertTrue(player.objects("teak_portal").isEmpty()) + } + + @Test + fun `Can't direct a portal without enough runes`() { + val player = createOwner() + player.inventory.add("fire_rune", 99) + player.inventory.add("air_rune", 300) + player.inventory.add("law_rune", 100) + + player.objectOption(player.objects("teleport_focus").single(), "Direct-portal") + tickIf { player.dialogue == null } + player.dialogueOption("line1") + + assertEquals(0, player["house_portal", 0]) + assertEquals(300, player.inventory.count("air_rune")) + assertEquals(1, player.objects("teak_portal").size) + } + + @Test + fun `Enter a directed portal`() { + val player = createOwner(direction = 2) + + player.objectOption(player.objects("teak_portal_lumbridge").single(), "Enter") + tick(6) + + assertTrue(player.tile in Areas["lumbridge_teleport"]) + } + + @Test + fun `Removing a portal resets its direction`() { + val player = createOwner(direction = 3) + + player.removeHouseFurniture(START_ROOM, "portal_chamber_portal_space") + + assertEquals(0, player["house_portal", 0]) + } +} From 32f6c36c100327ad76e6eef9ef8c8a4dbdb30105 Mon Sep 17 00:00:00 2001 From: GregHib Date: Fri, 2 Oct 2026 17:09:50 +0100 Subject: [PATCH 18/28] Fix not displaying temp 1 tick objects --- game/src/main/kotlin/GameTick.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/game/src/main/kotlin/GameTick.kt b/game/src/main/kotlin/GameTick.kt index b57aa9950b..35120e14b3 100644 --- a/game/src/main/kotlin/GameTick.kt +++ b/game/src/main/kotlin/GameTick.kt @@ -57,13 +57,13 @@ fun getTickStages( // Connections/Tick Input queue, NPCs, + GameObjects.timers, // Tick InstructionTask(handlers), World, Instances, NPCTask(sequentialNpc), PlayerTask(sequentialPlayer), - GameObjects.timers, // Update dynamicZones, ZoneBatchUpdates, From c3ec6b27f33cc8eb792e1c7668a4d46797f9774f Mon Sep 17 00:00:00 2001 From: GregHib Date: Fri, 2 Oct 2026 17:32:39 +0100 Subject: [PATCH 19/28] Fix game object collision --- .../world/gregs/voidps/engine/entity/obj/GameObjects.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/engine/src/main/kotlin/world/gregs/voidps/engine/entity/obj/GameObjects.kt b/engine/src/main/kotlin/world/gregs/voidps/engine/entity/obj/GameObjects.kt index 1bc9fc0fb8..e42d7f9eb6 100644 --- a/engine/src/main/kotlin/world/gregs/voidps/engine/entity/obj/GameObjects.kt +++ b/engine/src/main/kotlin/world/gregs/voidps/engine/entity/obj/GameObjects.kt @@ -54,7 +54,7 @@ object GameObjects : ZoneBatchUpdates.Sender { */ fun add(id: String, tile: Tile, shape: Int = ObjectShape.CENTRE_PIECE_STRAIGHT, rotation: Int = 0, ticks: Int = NEVER, collision: Boolean = true): GameObject { val obj = GameObject(ObjectDefinitions.get(id).id, tile, shape, rotation) - add(obj) + add(obj, collision) timers.add(obj, ticks) { remove(obj, collision) } @@ -136,7 +136,7 @@ object GameObjects : ZoneBatchUpdates.Sender { * Removes an object, optionally reverting after [ticks] */ fun remove(obj: GameObject, ticks: Int = NEVER, collision: Boolean = true) { - remove(obj) + remove(obj, collision) timers.add(obj, ticks) { add(obj, collision) } From fd5961d7f4c5ab77cb29038339e56d0b7ba8686d Mon Sep 17 00:00:00 2001 From: GregHib Date: Fri, 2 Oct 2026 18:25:22 +0100 Subject: [PATCH 20/28] Add accessing stairs --- .../construction/construction.tables.toml | 6 + .../client/update/batch/ZoneBatchUpdates.kt | 13 +- .../engine/map/zone/ZoneBatchUpdatesTest.kt | 27 +++ .../content/skill/construction/House.kt | 45 ++-- .../content/skill/construction/HousePortal.kt | 15 +- .../content/skill/construction/HouseStairs.kt | 87 ++++++-- .../skill/construction/RoomCreation.kt | 42 ++-- .../skill/construction/HouseFurnitureTest.kt | 2 +- .../skill/construction/HouseStairsTest.kt | 206 +++++++++++++++++- 9 files changed, 363 insertions(+), 80 deletions(-) diff --git a/data/skill/construction/construction.tables.toml b/data/skill/construction/construction.tables.toml index e49b7d9581..8c3db172c0 100644 --- a/data/skill/construction/construction.tables.toml +++ b/data/skill/construction/construction.tables.toml @@ -146,6 +146,7 @@ dungeon = true # Roof is the hipped roof for a single room, the style region also has ridged roofs at { x = 1864, y = 5072 } and { x = 1880, y = 5072 } [house_spaces] template = "tile" +build = "tile" [.land] template = { x = 1864, y = 5056 } @@ -153,6 +154,11 @@ template = { x = 1864, y = 5056 } [.roof] template = { x = 1888, y = 5064 } +# Fills the empty dungeon spaces around dungeon rooms, the same for every style +[.dungeon] +template = { x = 1880, y = 5056 } +build = { x = 1944, y = 5056 } + # Templates for each style are offset from the basic wood templates, window spaces are filled with the styles window or wall underground [house_styles] name = "string" diff --git a/engine/src/main/kotlin/world/gregs/voidps/engine/client/update/batch/ZoneBatchUpdates.kt b/engine/src/main/kotlin/world/gregs/voidps/engine/client/update/batch/ZoneBatchUpdates.kt index 4d57437f54..f19f9e2f2b 100644 --- a/engine/src/main/kotlin/world/gregs/voidps/engine/client/update/batch/ZoneBatchUpdates.kt +++ b/engine/src/main/kotlin/world/gregs/voidps/engine/client/update/batch/ZoneBatchUpdates.kt @@ -81,7 +81,8 @@ object ZoneBatchUpdates : Runnable { */ fun refresh(player: Player) { val viewport = player.viewport ?: return - for (zone in player.tile.zone.toRectangle(radius = viewport.localRadius).toZonesReversed(player.tile.level)) { + val area = player.tile.zone.toRectangle(radius = viewport.localRadius) + for (zone in area.toZonesReversed(player.tile.level)) { if (!inViewport(viewport, zone)) { continue } @@ -90,6 +91,16 @@ object ZoneBatchUpdates : Runnable { sender.send(player, zone) } } + if (viewport.dynamic) { + // The client keeps objects spawned on levels hidden by the new map, without terrain they'd show at ground height + for (level in viewport.maxLevel + 1 until 4) { + for (zone in area.toZonesReversed(level)) { + if (inViewport(viewport, zone)) { + player.clearZone(zone) + } + } + } + } viewport.lastBatchZone = player.tile.zone } diff --git a/engine/src/test/kotlin/world/gregs/voidps/engine/map/zone/ZoneBatchUpdatesTest.kt b/engine/src/test/kotlin/world/gregs/voidps/engine/map/zone/ZoneBatchUpdatesTest.kt index 47fbd6073d..35ae7b2ddd 100644 --- a/engine/src/test/kotlin/world/gregs/voidps/engine/map/zone/ZoneBatchUpdatesTest.kt +++ b/engine/src/test/kotlin/world/gregs/voidps/engine/map/zone/ZoneBatchUpdatesTest.kt @@ -74,6 +74,33 @@ internal class ZoneBatchUpdatesTest : KoinMock() { } } + @Test + fun `Refreshing a dynamic region clears levels hidden above the player`() { + player.tile = Tile(20, 20) + player.viewport!!.lastLoadZone = player.tile.zone + player.viewport!!.dynamic = true + player.viewport!!.maxLevel = 0 + + ZoneBatchUpdates.refresh(player) + + verify { + client.clearZone(2, 2, 0) + client.clearZone(2, 2, 1) + client.clearZone(2, 2, 3) + } + } + + @Test + fun `Refreshing a static region only clears the players level`() { + player.tile = Tile(20, 20) + player.viewport!!.lastLoadZone = player.tile.zone + + ZoneBatchUpdates.refresh(player) + + verify { client.clearZone(2, 2, 0) } + verify(exactly = 0) { client.clearZone(any(), any(), 1) } + } + @Test fun `Staying in zone sends batched updates`() { // Given diff --git a/game/src/main/kotlin/content/skill/construction/House.kt b/game/src/main/kotlin/content/skill/construction/House.kt index ad9f23b89a..72adff4fdc 100644 --- a/game/src/main/kotlin/content/skill/construction/House.kt +++ b/game/src/main/kotlin/content/skill/construction/House.kt @@ -339,8 +339,8 @@ class House : Script { } /** - * Builds the players house in the instance starting at [base]. Ground floor rooms are surrounded by a ring of grass, - * indoor rooms without a room above are roofed and every other space is left empty. + * Builds the players house in the instance starting at [base]. Ground floor and dungeon rooms are surrounded by grass, + * and by darkness in the dungeon when there is one, indoor rooms without a room above are roofed and every other space is left empty. */ fun Player.loadHouse(base: Zone, buildMode: Boolean) { val dynamicZones = get() @@ -365,17 +365,13 @@ class House : Script { entries.add(Triple(roof, zone, 0)) } } - val ground = positions.filter { roomLevel(it) == GROUND_LEVEL } - if (ground.isNotEmpty()) { - val land = template("house_spaces.land.template") - for (x in ground.minOf(::roomX) - 1..ground.maxOf(::roomX) + 1) { - for (y in ground.minOf(::roomY) - 1..ground.maxOf(::roomY) + 1) { - val zone = base.add(x + 1, y + 1, GROUND_LEVEL) - if (placed.add(zone)) { - entries.add(Triple(land, zone, 0)) - } - } - } + // Grass and the dungeon share one area, around the rooms on both floors + val rooms = positions.filter { roomLevel(it) == GROUND_LEVEL || roomLevel(it) == DUNGEON_LEVEL } + if (rooms.isNotEmpty()) { + surround(base, rooms, GROUND_LEVEL, template("house_spaces.land.template"), placed, entries) + } + if (positions.any { roomLevel(it) == DUNGEON_LEVEL }) { + surround(base, rooms, DUNGEON_LEVEL, Tables.tile("house_spaces.dungeon.${if (buildMode) "build" else "template"}").zone, placed, entries) } for (level in 0 until HOUSE_LEVELS) { for (x in 0 until HOUSE_SIZE) { @@ -393,6 +389,20 @@ class House : Script { } } + /** + * Fills the empty spaces on [level] within one space of all [rooms] with [template] + */ + private fun surround(base: Zone, rooms: List, level: Int, template: Zone, placed: MutableSet, entries: MutableList>) { + for (x in rooms.minOf(::roomX) - 1..rooms.maxOf(::roomX) + 1) { + for (y in rooms.minOf(::roomY) - 1..rooms.maxOf(::roomY) + 1) { + val zone = base.add(x + 1, y + 1, level) + if (placed.add(zone)) { + entries.add(Triple(template, zone, 0)) + } + } + } + } + /** * Fills the window spaces in [zone] and removes any hotspots outside of [buildMode] */ @@ -409,16 +419,10 @@ class House : Script { } /** - * Moves to the nearest free tile to [tile] in its room, levels above the dungeon are hidden so they don't block the view + * Moves to the nearest free tile to [tile] in its room */ fun Player.changeFloor(tile: Tile) { val free = tile.zone.toCuboid().filter { !Collisions.check(it, BLOCKED) }.minByOrNull { it.distanceTo(tile) } ?: tile - val base = houseBase() - if (base != null && free.level - base.level == DUNGEON_LEVEL) { - set("hide_upper_levels", true) - } else { - clear("hide_upper_levels") - } tele(free) } @@ -432,7 +436,6 @@ class House : Script { fun Player.leaveHouse(teleport: Boolean = true) { val owner: String = remove("house_owner") ?: return - clear("hide_upper_levels") if (hasOpen("house_options")) { open("options") } diff --git a/game/src/main/kotlin/content/skill/construction/HousePortal.kt b/game/src/main/kotlin/content/skill/construction/HousePortal.kt index 55a9594c06..95c9372884 100644 --- a/game/src/main/kotlin/content/skill/construction/HousePortal.kt +++ b/game/src/main/kotlin/content/skill/construction/HousePortal.kt @@ -21,6 +21,7 @@ import world.gregs.voidps.engine.Script import world.gregs.voidps.engine.client.message import world.gregs.voidps.engine.client.ui.close import world.gregs.voidps.engine.client.ui.closeInterfaces +import world.gregs.voidps.engine.client.ui.hasOpen import world.gregs.voidps.engine.client.ui.open import world.gregs.voidps.engine.data.definition.Tables import world.gregs.voidps.engine.entity.character.move.tele @@ -75,13 +76,17 @@ class HousePortal : Script { } teleportLand("modern") { - delay(1) - close("house_loading") + if (hasOpen("house_loading")) { + delay(1) + close("house_loading") + } } teleportLand("tablet") { - delay(3) - close("house_loading") + if (hasOpen("house_loading")) { + delay(3) + close("house_loading") + } } } @@ -98,9 +103,9 @@ class HousePortal : Script { message("You don't have a house to teleport to.") // TODO proper message return } - val tile = createHouse(buildMode = false) closeInterfaces() Teleport.teleport(this, type, spell, xp = xp, clearInterfaces = false) { + val tile = createHouse(buildMode = false) open("house_loading") tile } diff --git a/game/src/main/kotlin/content/skill/construction/HouseStairs.kt b/game/src/main/kotlin/content/skill/construction/HouseStairs.kt index 85c1040c74..f62dfb437a 100644 --- a/game/src/main/kotlin/content/skill/construction/HouseStairs.kt +++ b/game/src/main/kotlin/content/skill/construction/HouseStairs.kt @@ -6,12 +6,15 @@ import content.skill.construction.House.Companion.DUNGEON_LEVEL import content.skill.construction.House.Companion.UPPER_LEVEL import content.skill.construction.House.Companion.changeFloor import content.skill.construction.House.Companion.houseBase -import content.skill.construction.House.Companion.houseLoading +import content.skill.construction.House.Companion.houseFurnitureIds +import content.skill.construction.House.Companion.houseFurnitureRooms import content.skill.construction.House.Companion.houseOwner +import content.skill.construction.House.Companion.houseRoom import content.skill.construction.House.Companion.houseRoomPositions import content.skill.construction.House.Companion.houseRoomRotations import content.skill.construction.House.Companion.houseStairs import content.skill.construction.House.Companion.inOwnHouse +import content.skill.construction.House.Companion.removeHouseStairs import content.skill.construction.House.Companion.roomAbove import content.skill.construction.House.Companion.roomBelow import content.skill.construction.House.Companion.roomLevel @@ -20,14 +23,16 @@ import content.skill.construction.House.Companion.stairsDown import content.skill.construction.RoomCreation.Companion.buildRoom import content.skill.construction.RoomCreation.Companion.canBuildRoom import content.skill.construction.RoomCreation.Companion.hasRoomSpace +import content.skill.construction.RoomCreation.Companion.removeRoom import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.data.definition.Tables import world.gregs.voidps.engine.entity.character.player.Player import world.gregs.voidps.type.Tile import world.gregs.voidps.type.Zone /** * Staircases connect rooms with stairs on the floors above and below, the dungeon entrance leads to the dungeon room below it. - * In building mode the owner is offered to build the room if there isn't one. + * In building mode the owner is offered to build the room if there isn't one, or to remove the room they lead to. */ class HouseStairs : Script { init { @@ -36,7 +41,9 @@ class HouseStairs : Script { val owner = houseOwner() ?: return@objectOperate val position = roomPosition(base, target.tile.zone) ?: return@objectOperate val below = roomBelow(position) - travel(base, position, below, below in owner.houseRoomPositions, entranceRooms, "This entrance does not lead anywhere.", "below") // TODO proper message + // The entrance leads down to the stairs in the room below + val connected = owner.houseRoom(below)?.let { Tables.objOrNull("house_rooms.$it.stairs") } != null + travel(base, position, below, connected, stairRooms, "This entrance does not lead anywhere.", "below") // TODO proper message } objectOperate("Climb-up", STAIRCASES) { (target) -> @@ -56,6 +63,42 @@ class HouseStairs : Script { val position = roomPosition(base, target.tile.zone) ?: return@objectOperate climb(base, position, up = !target.id.endsWith("_down")) } + + objectOperate("Remove-room", "$STAIRCASES,$ENTRANCES") { (target) -> + val base = houseBase() + if (base == null || !inOwnHouse()) { + return@objectOperate + } + if (!get("house_build_mode", false)) { + statement("You can only do that in building mode.") + return@objectOperate + } + val position = roomPosition(base, target.tile.zone) ?: return@objectOperate + removeConnected(position, target.id) + } + } + + /** + * Removes the room above or below [position] that the stairs, entrance, trapdoor or ladder [id] leads to, + * staircases are also removed from this room as they no longer lead anywhere. + */ + private suspend fun Player.removeConnected(position: Int, id: String) { + val staircase = STAIRCASES.split(',').contains(id) + val up = id.endsWith("_ladder") || (staircase && !id.endsWith("_down")) + val connected = if (up) roomAbove(position) else roomBelow(position) + val room = houseRoom(connected) + if (room == null) { + statement("There is no room ${if (up) "above" else "below"} to remove.") + return + } + if (staircase && roomLevel(position) != DUNGEON_LEVEL && roomLevel(connected) != DUNGEON_LEVEL) { + statement("Warning: If you remove this room you may be unable to access the upper floor from the ground.") + } + removeRoom(connected, room) { + if (staircase) { + removeHouseStairs(position) + } + } } /** @@ -69,10 +112,16 @@ class HouseStairs : Script { return } val next = if (up) roomAbove(position) else roomBelow(position) - val connected = owner.houseStairs(next) != null && owner.stairsDown(next) == up - // Stairs lead between halls, or down from a hall to the dungeon stairs - val rooms = if (up || level == UPPER_LEVEL) hallRooms else stairRooms - travel(base, position, next, connected, rooms, NOWHERE, if (up) "at the top" else "at the bottom") + // Stairs in the dungeon can also lead up to a dungeon entrance + val connected = (owner.houseStairs(next) != null && owner.stairsDown(next) == up) || + (up && level == DUNGEON_LEVEL && owner.hasEntrance(next)) + travel(base, position, next, connected, stairRooms, NOWHERE, if (up) "at the top" else "at the bottom") + } + + private fun Player.hasEntrance(position: Int): Boolean { + val rooms = houseFurnitureRooms + val ids = houseFurnitureIds + return rooms.indices.any { rooms[it] == position && ids[it] == "dungeon_entrance" } } /** @@ -82,7 +131,7 @@ class HouseStairs : Script { private suspend fun Player.travel(base: Zone, from: Int, to: Int, connected: Boolean, rooms: List>, nowhere: String, place: String) { val destination = tile.addLevel(roomLevel(to) - roomLevel(from)) if (connected) { - arrive(destination) + changeFloor(destination) return } if (!inOwnHouse() || to in houseRoomPositions) { @@ -117,18 +166,7 @@ class HouseStairs : Script { val index = houseRoomPositions.indexOf(from) buildRoom(base, room, to, if (index == -1) 0 else houseRoomRotations[index]) if (to in houseRoomPositions) { - arrive(destination) - } - } - - /** - * Moves to [tile] showing the loading screen when entering or leaving the dungeon as the map is re-rendered - */ - private suspend fun Player.arrive(tile: Tile) { - val hidden = get("hide_upper_levels", false) - changeFloor(tile) - if (hidden != get("hide_upper_levels", false)) { - houseLoading() + changeFloor(destination) } } @@ -136,9 +174,10 @@ class HouseStairs : Script { private const val NOWHERE = "These stairs do no lead anywhere." private const val STAIRCASES = "oak_staircase,oak_staircase_down,teak_staircase,teak_staircase_down,spiral_staircase,spiral_staircase_down,marble_staircase,marble_staircase_down,marble_spiral,marble_spiral_down" - // Rooms which can be built at the other end of stairs or below the dungeon entrance - private val hallRooms = listOf("skill_hall" to "Skill hall", "quest_hall" to "Quest hall") - private val stairRooms = listOf("dungeon_stairs" to "Dungeon stairs room") - private val entranceRooms = listOf("dungeon_stairs" to "Dungeon stairs room", "dungeon_corridor" to "Dungeon corridor", "dungeon_junction" to "Dungeon junction") + // Entrances to the room below and ladders up to the room above + private const val ENTRANCES = "dungeon_entrance,oak_trapdoor,teak_trapdoor,mahogany_trapdoor,oak_ladder,teak_ladder,mahogany_ladder" + + // Rooms with stairs which can be built at the other end of stairs or below the dungeon entrance + private val stairRooms = listOf("skill_hall" to "Skill hall", "quest_hall" to "Quest hall", "dungeon_stairs" to "Dungeon stairs room") } } diff --git a/game/src/main/kotlin/content/skill/construction/RoomCreation.kt b/game/src/main/kotlin/content/skill/construction/RoomCreation.kt index 10191b5999..1feece2bee 100644 --- a/game/src/main/kotlin/content/skill/construction/RoomCreation.kt +++ b/game/src/main/kotlin/content/skill/construction/RoomCreation.kt @@ -147,25 +147,6 @@ class RoomCreation(val dynamicZones: DynamicZones) : Script { buildRoom(base, room, position, rotation) } - private suspend fun Player.removeRoom(position: Int, room: String) { - if (exitPortals(position) > 0 && exitPortals() <= exitPortals(position)) { - message("Your house must have at least one exit portal.") // TODO proper message - return - } - choice("Remove the ${room.replace('_', ' ')}?") { - option("Yes") { - val base = houseBase() - if (base == null || !inOwnHouse() || position !in houseRoomPositions) { - return@option - } - removeHouseRoom(position) - loadHouse(base, buildMode = true) - message("Room deleted!") - } - option("No") - } - } - /** * Remove the room being previewed */ @@ -179,6 +160,29 @@ class RoomCreation(val dynamicZones: DynamicZones) : Script { } companion object { + /** + * Asks to remove [room] at [position] and its furniture, calling [onRemove] before the house is reloaded + */ + suspend fun Player.removeRoom(position: Int, room: String, onRemove: Player.() -> Unit = {}) { + if (exitPortals(position) > 0 && exitPortals() <= exitPortals(position)) { + message("Your house must have at least one exit portal.") // TODO proper message + return + } + choice("Remove the ${room.replace('_', ' ')}?") { + option("Yes") { + val base = houseBase() + if (base == null || !inOwnHouse() || position !in houseRoomPositions) { + return@option + } + removeHouseRoom(position) + onRemove() + loadHouse(base, buildMode = true) + message("Room deleted!") + } + option("No") + } + } + /** * Whether the players level allows building another room */ diff --git a/game/src/test/kotlin/content/skill/construction/HouseFurnitureTest.kt b/game/src/test/kotlin/content/skill/construction/HouseFurnitureTest.kt index 903f4d68a3..fd65deee5d 100644 --- a/game/src/test/kotlin/content/skill/construction/HouseFurnitureTest.kt +++ b/game/src/test/kotlin/content/skill/construction/HouseFurnitureTest.kt @@ -195,7 +195,7 @@ class HouseFurnitureTest : WorldTest() { player.objectOption(burners, "Light") tick() // 200 + level 50 + random 49 - tick(297) + tick(298) assertNotNull(GameObjects.findOrNull(burners.tile, "marble_burners_lit")) tick() diff --git a/game/src/test/kotlin/content/skill/construction/HouseStairsTest.kt b/game/src/test/kotlin/content/skill/construction/HouseStairsTest.kt index 779d068c6e..24bc339a4c 100644 --- a/game/src/test/kotlin/content/skill/construction/HouseStairsTest.kt +++ b/game/src/test/kotlin/content/skill/construction/HouseStairsTest.kt @@ -1,6 +1,7 @@ package content.skill.construction import WorldTest +import containsMessage import content.quest.instance import content.skill.construction.House.Companion.DUNGEON_LEVEL import content.skill.construction.House.Companion.GROUND_LEVEL @@ -22,17 +23,22 @@ import org.junit.jupiter.api.Test import skipDialogues import world.gregs.voidps.engine.client.ui.dialogue import world.gregs.voidps.engine.client.ui.hasOpen +import world.gregs.voidps.engine.data.definition.Tables import world.gregs.voidps.engine.entity.character.move.tele import world.gregs.voidps.engine.entity.character.player.Player import world.gregs.voidps.engine.entity.character.player.skill.Skill import world.gregs.voidps.engine.entity.item.Item import world.gregs.voidps.engine.entity.obj.GameObject import world.gregs.voidps.engine.entity.obj.GameObjects +import world.gregs.voidps.engine.get import world.gregs.voidps.engine.inv.add import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.engine.map.zone.DynamicZones +import world.gregs.voidps.engine.map.zone.DynamicZones.Companion.rotatedId import world.gregs.voidps.type.Tile import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue @@ -125,7 +131,7 @@ class HouseStairsTest : WorldTest() { fun `Enter the dungeon below the entrance`() { val player = createOwner() player.addEntrance() - player.addHouseRoom("dungeon_corridor", dungeon) + player.addHouseRoom("dungeon_stairs", dungeon) player.enterPortal(1) val entrance = player.find(hall, "dungeon_entrance") @@ -133,7 +139,7 @@ class HouseStairsTest : WorldTest() { assertEquals(DUNGEON_LEVEL, player.tile.level) assertEquals(roomZone(player.instance()!!.tile.zone, dungeon), player.tile.zone) - assertTrue(player["hide_upper_levels", false]) + assertFalse(player.hasOpen("house_loading")) } @Test @@ -146,14 +152,14 @@ class HouseStairsTest : WorldTest() { player.use(entrance, "Enter") player.skipDialogues() - player.dialogueOption("line1") + player.dialogueOption("line3") tick(4) assertEquals("dungeon_stairs", player.houseRoomIds.last()) assertEquals(dungeon, player.houseRoomPositions.last()) assertEquals(0, player.inventory.count("coins")) assertEquals(DUNGEON_LEVEL, player.tile.level) - assertTrue(player["hide_upper_levels", false]) + assertFalse(player.hasOpen("house_loading")) } @Test @@ -187,7 +193,7 @@ class HouseStairsTest : WorldTest() { player.use(player.find(upperHall, "oak_staircase_down"), "Climb-down") assertEquals(GROUND_LEVEL, player.tile.level) - assertFalse(player["hide_upper_levels", false]) + assertFalse(player.hasOpen("house_loading")) } @Test @@ -200,12 +206,12 @@ class HouseStairsTest : WorldTest() { tick(4) assertEquals(DUNGEON_LEVEL, player.tile.level) - assertTrue(player["hide_upper_levels", false]) + assertFalse(player.hasOpen("house_loading")) player.use(player.find(dungeon, "oak_staircase"), "Climb-up") assertEquals(GROUND_LEVEL, player.tile.level) - assertFalse(player["hide_upper_levels", false]) + assertFalse(player.hasOpen("house_loading")) } @Test @@ -288,7 +294,7 @@ class HouseStairsTest : WorldTest() { player.use(player.find(hall, "oak_staircase_down"), "Climb-down") player.skipDialogues() - player.dialogueOption("line1") + player.dialogueOption("line3") tick(4) assertEquals("dungeon_stairs", player.houseRoomIds.last()) @@ -336,7 +342,7 @@ class HouseStairsTest : WorldTest() { player.use(player.find(hall, "oak_staircase"), "Climb-up") player.skipDialogues() - player.dialogueOption("line3") + player.dialogueOption("line4") assertEquals(listOf(START_ROOM, hall), player.houseRoomPositions) assertEquals(25000, player.inventory.count("coins")) @@ -391,4 +397,186 @@ class HouseStairsTest : WorldTest() { assertEquals("oak_staircase", player.houseStairs(upperHall)) player.find(hall, "oak_staircase") } + + @Test + fun `Can only remove rooms through stairs in building mode`() { + val player = createOwner() + player.addHalls() + player.enterPortal(1) + + player.use(player.find(hall, "oak_staircase"), "Remove-room") + + assertEquals("dialogue_message1", player.dialogue) + assertEquals(listOf(START_ROOM, hall, upperHall), player.houseRoomPositions) + } + + @Test + fun `Remove the room above through the stairs`() { + val player = createOwner() + player.addHalls() + player.enterPortal(2) + + player.use(player.find(hall, "oak_staircase"), "Remove-room") + player.skipDialogues() + player.dialogueOption("line1") + + assertEquals(listOf(START_ROOM, hall), player.houseRoomPositions) + assertNull(player.houseStairs(hall)) + assertNull(player.houseStairs(upperHall)) + assertNull(player.findOrNull(hall, "oak_staircase")) + } + + @Test + fun `Declining to remove the room through the stairs keeps it`() { + val player = createOwner() + player.addHalls() + player.enterPortal(2) + + player.use(player.find(upperHall, "oak_staircase_down"), "Remove-room") + player.skipDialogues() + player.dialogueOption("line2") + + assertEquals(listOf(START_ROOM, hall, upperHall), player.houseRoomPositions) + assertEquals("oak_staircase", player.houseStairs(hall)) + } + + @Test + fun `Remove the dungeon room below the entrance`() { + val player = createOwner() + player.addEntrance() + player.addHouseRoom("dungeon_corridor", dungeon) + player.enterPortal(2) + + player.use(player.find(hall, "dungeon_entrance"), "Remove-room") + player.dialogueOption("line1") + + assertEquals(listOf(START_ROOM, hall), player.houseRoomPositions) + player.find(hall, "dungeon_entrance") + } + + @Test + fun `No room below the entrance to remove`() { + val player = createOwner() + player.addEntrance() + player.enterPortal(2) + + player.use(player.find(hall, "dungeon_entrance"), "Remove-room") + + assertEquals("dialogue_message1", player.dialogue) + assertEquals(listOf(START_ROOM, hall), player.houseRoomPositions) + } + + @Test + fun `Entrance doesn't lead to a dungeon room without stairs`() { + val player = createOwner() + player.addEntrance() + player.addHouseRoom("dungeon_corridor", dungeon) + player.enterPortal(1) + + player.use(player.find(hall, "dungeon_entrance"), "Enter") + + assertEquals(GROUND_LEVEL, player.tile.level) + assertEquals("dialogue_message1", player.dialogue) + } + + @Test + fun `Rooms with stairs are offered below the entrance`() { + val player = createOwner() + player.addEntrance() + player.inventory.add("coins", 7500) + player.enterPortal(2) + + player.use(player.find(hall, "dungeon_entrance"), "Enter") + player.skipDialogues() + + assertEquals("dialogue_multi4", player.dialogue) + } + + @Test + fun `Climb up the dungeon stairs to the entrance`() { + val player = createOwner() + player.addEntrance() + player.addHouseRoom("dungeon_stairs", dungeon) + player.addHouseFurniture(dungeon, "skill_hall_stair_space", "oak_staircase") + player.enterPortal(1) + player.use(player.find(hall, "dungeon_entrance"), "Enter") + tick(4) + + player.use(player.find(dungeon, "oak_staircase"), "Climb-up") + tick(4) + + assertEquals(GROUND_LEVEL, player.tile.level) + assertEquals(roomZone(player.instance()!!.tile.zone, hall), player.tile.zone) + } + + @Test + fun `Can't build a hall underground below the stairs`() { + val player = createOwner() + player.addHouseRoom("skill_hall", hall) + player.addHouseFurniture(hall, "skill_hall_stair_space_down", "oak_staircase") + player.inventory.add("coins", 25000) + player.enterPortal(2) + + player.use(player.find(hall, "oak_staircase_down"), "Climb-down") + player.skipDialogues() + player.dialogueOption("line1") + + assertEquals(listOf(START_ROOM, hall), player.houseRoomPositions) + assertEquals(25000, player.inventory.count("coins")) + assertTrue(player.containsMessage("That room can't be built underground.")) + } + + @Test + fun `Empty dungeon covers the same area as the grass`() { + val player = createOwner() + player.addDungeonStairs() + player.enterPortal(1) + val base = player.instance()!!.tile.zone + val dynamicZones: DynamicZones = get() + val empty = roomZone(base, roomPosition(3, 3, DUNGEON_LEVEL)) + + assertEquals(Tables.tile("house_spaces.dungeon.template").zone.rotatedId(0), dynamicZones.dynamicZone(empty)) + // Same area as the grass, one space around the ground floor rooms at x 3..4 + assertNotNull(dynamicZones.dynamicZone(roomZone(base, roomPosition(2, 3, DUNGEON_LEVEL)))) + assertNull(dynamicZones.dynamicZone(roomZone(base, roomPosition(1, 3, DUNGEON_LEVEL)))) + } + + @Test + fun `Grass surrounds dungeon rooms beyond the ground floor`() { + val player = createOwner() + player.addHouseRoom("dungeon_corridor", roomPosition(5, 3, DUNGEON_LEVEL)) + player.enterPortal(1) + val base = player.instance()!!.tile.zone + val dynamicZones: DynamicZones = get() + val dungeon = Tables.tile("house_spaces.dungeon.template").zone.rotatedId(0) + + // Garden at x 3 and dungeon at x 5 cover x 2..6 on both floors + assertNotNull(dynamicZones.dynamicZone(roomZone(base, roomPosition(6, 3, GROUND_LEVEL)))) + assertNull(dynamicZones.dynamicZone(roomZone(base, roomPosition(1, 3, GROUND_LEVEL)))) + assertEquals(dungeon, dynamicZones.dynamicZone(roomZone(base, roomPosition(2, 3, DUNGEON_LEVEL)))) + assertEquals(dungeon, dynamicZones.dynamicZone(roomZone(base, roomPosition(6, 3, DUNGEON_LEVEL)))) + } + + @Test + fun `Empty dungeon spaces show build mode template in building mode`() { + val player = createOwner() + player.addDungeonStairs() + player.enterPortal(2) + val base = player.instance()!!.tile.zone + val dynamicZones: DynamicZones = get() + val empty = roomZone(base, roomPosition(3, 3, DUNGEON_LEVEL)) + + assertEquals(Tables.tile("house_spaces.dungeon.build").zone.rotatedId(0), dynamicZones.dynamicZone(empty)) + } + + @Test + fun `Dungeon spaces are left empty without a dungeon`() { + val player = createOwner() + player.addHalls() + player.enterPortal(1) + val base = player.instance()!!.tile.zone + val dynamicZones: DynamicZones = get() + + assertNull(dynamicZones.dynamicZone(roomZone(base, roomPosition(3, 3, DUNGEON_LEVEL)))) + } } From a2fbb3d69cff212ceab4e9408e0e52cdc31dde07 Mon Sep 17 00:00:00 2001 From: GregHib Date: Fri, 2 Oct 2026 18:25:39 +0100 Subject: [PATCH 21/28] Add working seats --- .../construction/construction.anims.toml | 97 +++++++++++ .../skill/construction/construction.objs.toml | 4 + .../content/skill/construction/HouseSeats.kt | 137 +++++++++++++++ .../skill/construction/HouseSeatsTest.kt | 161 ++++++++++++++++++ 4 files changed, 399 insertions(+) create mode 100644 game/src/main/kotlin/content/skill/construction/HouseSeats.kt create mode 100644 game/src/test/kotlin/content/skill/construction/HouseSeatsTest.kt diff --git a/data/skill/construction/construction.anims.toml b/data/skill/construction/construction.anims.toml index a3ac1bd28f..02256da249 100644 --- a/data/skill/construction/construction.anims.toml +++ b/data/skill/construction/construction.anims.toml @@ -29,3 +29,100 @@ id = 3666 # Placeholder, Fury used 3687 for torches and burners [light_torch] id = 3687 + +[chair_sit] +id = 4103 + +[chair_sit_diagonal] +id = 4104 + +[chair_get_up] +id = 4105 + +[chair_get_up_diagonal] +id = 4106 + +# Seated anims hold the seat model in the right hand while the seat object is hidden +[sit_crude_wooden_chair] +id = 4073 + +[sit_crude_wooden_chair_diagonal] +id = 4074 + +[sit_wooden_chair] +id = 4075 + +[sit_wooden_chair_diagonal] +id = 4076 + +[sit_rocking_chair] +id = 4079 + +[sit_rocking_chair_diagonal] +id = 4080 + +[sit_oak_chair] +id = 4081 + +[sit_oak_chair_diagonal] +id = 4082 + +[sit_oak_armchair] +id = 4083 + +[sit_oak_armchair_diagonal] +id = 4084 + +[sit_teak_armchair] +id = 4085 + +[sit_teak_armchair_diagonal] +id = 4086 + +[sit_mahogany_armchair] +id = 4087 + +[sit_mahogany_armchair_diagonal] +id = 4088 + +[sit_wooden_bench] +id = 4089 + +[sit_oak_bench] +id = 4091 + +[sit_carved_oak_bench] +id = 4093 + +[sit_teak_dining_bench] +id = 4095 + +[sit_carved_teak_bench] +id = 4097 + +[sit_mahogany_bench] +id = 4099 + +[sit_gilded_bench] +id = 4101 + +[sit_oak_throne] +id = 4111 + +[sit_teak_throne] +id = 4112 + +[sit_mahogany_throne] +id = 4113 + +[sit_gilded_throne] +id = 4114 + +[sit_skeleton_throne] +id = 4115 + +[sit_crystal_throne] +id = 4116 + +[sit_demonic_throne] +id = 4117 diff --git a/data/skill/construction/construction.objs.toml b/data/skill/construction/construction.objs.toml index 1693c066fc..ba18ea678b 100644 --- a/data/skill/construction/construction.objs.toml +++ b/data/skill/construction/construction.objs.toml @@ -100,6 +100,10 @@ id = 13408 [dungeon_entrance] id = 13409 +# Hides seats while sat on, the seated animations hold the seat model +[invisible_seat] +id = 13723 + [crude_wooden_chair] id = 13581 examine = "It's not the best chair but you think it would take your weight." diff --git a/game/src/main/kotlin/content/skill/construction/HouseSeats.kt b/game/src/main/kotlin/content/skill/construction/HouseSeats.kt new file mode 100644 index 0000000000..8102d8085b --- /dev/null +++ b/game/src/main/kotlin/content/skill/construction/HouseSeats.kt @@ -0,0 +1,137 @@ +package content.skill.construction + +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.client.variable.start +import world.gregs.voidps.engine.entity.World +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.entity.obj.GameObject +import world.gregs.voidps.engine.entity.obj.GameObjects +import world.gregs.voidps.engine.entity.obj.ObjectLayer +import world.gregs.voidps.engine.entity.obj.ObjectShape +import world.gregs.voidps.engine.entity.obj.replace +import world.gregs.voidps.engine.timer.Timer +import world.gregs.voidps.type.Direction +import world.gregs.voidps.type.Tile + +/** + * Chairs, benches and thrones can be sat on. The seated animations hold the seat's model + * so the seat is swapped for an invisible one, keeping its collision, until the player gets up by walking away. + */ +class HouseSeats : Script { + init { + objectOperate("Sit-on", SEATS) { (target) -> + if (contains("house_seat") || !GameObjects.contains(target)) { + return@objectOperate + } + val diagonal = target.shape == ObjectShape.CENTRE_PIECE_DIAGONAL + val bench = diningBench(target) + val from = tile + anim(if (diagonal) "chair_sit_diagonal" else "chair_sit", delay = 17) + if (bench) { + // Dining benches are pushed back from the table as they're sat on + target.replace("invisible_seat", collision = false) + pushBack(target) + } + exactMoveDelay(target.tile, startDelay = 17, delay = 30, direction = facing(target)) + if (!bench) { + if (!GameObjects.contains(target)) { + return@objectOperate + } + target.replace("invisible_seat", collision = false) + } + anim(seated(target)) + set("house_seat", target) + set("house_seat_from", from) + softTimers.start("house_seat") + walkTrigger { stand(animate = true) } + } + + // Replay the seated animation so it doesn't run out and is seen by players arriving later + timerStart("house_seat") { if (contains("house_seat")) 50 else Timer.CANCEL } + + timerTick("house_seat") { + val seat: GameObject = get("house_seat") ?: return@timerTick Timer.CANCEL + anim(seated(seat)) + Timer.CONTINUE + } + + timerStop("house_seat") { stand(animate = false) } + + moved { + val seat: GameObject = get("house_seat") ?: return@moved + if (tile != seat.tile) { + stand(animate = false) + } + } + } + + /** + * Gets up from the seat back onto the tile sat down from, putting the seat back if it hasn't been already. + */ + private fun Player.stand(animate: Boolean) { + val seat: GameObject = remove("house_seat") ?: return + val from: Tile = remove("house_seat_from") ?: seat.tile + clearWalkTrigger() + softTimers.stop("house_seat") + if (!animate) { + restore(seat) + return + } + if (diningBench(seat)) { + // Dining benches are pulled back out before returning to their place + pushBack(seat) + // Put back next tick along with the pushed back bench being removed + World.queue("house_seat_${seat.tile.id}") { restore(seat) } + } else { + restore(seat) + } + start("movement_delay", 1) + anim(if (seat.shape == ObjectShape.CENTRE_PIECE_DIAGONAL) "chair_get_up_diagonal" else "chair_get_up") + exactMove(from, delay = 25, direction = facing(seat)) + } + + /** + * Benches around the dining table, throne room benches are against the wall so aren't moved + */ + private fun diningBench(seat: GameObject) = seat.id.contains("bench") && !seat.id.endsWith("_throne_room") + + /** + * Puts [seat] back in place of its invisible placeholder, unless the house was reloaded since sitting down + */ + private fun restore(seat: GameObject) { + val placeholder = GameObjects.getLayer(seat.tile, ObjectLayer.layer(seat.shape)) + if (placeholder?.id == "invisible_seat") { + placeholder.replace(seat.id, collision = false) + } + } + + /** + * Shows [seat] on the tile behind it for a tick if there's nothing already there + */ + private fun pushBack(seat: GameObject) { + val behind = seat.tile.add(facing(seat).inverse()) + if (GameObjects.getLayer(behind, ObjectLayer.layer(seat.shape)) == null) { + GameObjects.add(seat.id, behind, seat.shape, seat.rotation, ticks = 1, collision = false) + } + } + + /** + * Seats face away from their rotation + */ + private fun facing(seat: GameObject): Direction = directions[seat.rotation and 0x3] + + /** + * The seated animation for [seat], throne room benches use the same as the dining room + */ + private fun seated(seat: GameObject): String { + val id = "sit_${seat.id.removeSuffix("_throne_room")}" + return if (seat.shape == ObjectShape.CENTRE_PIECE_DIAGONAL && id.endsWith("chair")) "${id}_diagonal" else id + } + + companion object { + private const val SEATS = "crude_wooden_chair,wooden_chair,rocking_chair,oak_chair,oak_armchair,teak_armchair,mahogany_armchair," + + "wooden_bench,oak_bench,carved_oak_bench,teak_dining_bench,carved_teak_bench,carved_teak_bench_throne_room,mahogany_bench,mahogany_bench_throne_room,gilded_bench,gilded_bench_throne_room," + + "oak_throne,teak_throne,mahogany_throne,gilded_throne,skeleton_throne,crystal_throne,demonic_throne" + private val directions = arrayOf(Direction.SOUTH, Direction.WEST, Direction.NORTH, Direction.EAST) + } +} diff --git a/game/src/test/kotlin/content/skill/construction/HouseSeatsTest.kt b/game/src/test/kotlin/content/skill/construction/HouseSeatsTest.kt new file mode 100644 index 0000000000..27352c65f4 --- /dev/null +++ b/game/src/test/kotlin/content/skill/construction/HouseSeatsTest.kt @@ -0,0 +1,161 @@ +package content.skill.construction + +import WorldTest +import content.quest.instance +import content.skill.construction.House.Companion.GROUND_LEVEL +import content.skill.construction.House.Companion.START_ROOM +import content.skill.construction.House.Companion.addHouseFurniture +import content.skill.construction.House.Companion.addHouseRoom +import content.skill.construction.House.Companion.roomPosition +import content.skill.construction.House.Companion.roomZone +import dialogueOption +import objectOption +import org.junit.jupiter.api.Test +import walk +import world.gregs.voidps.engine.client.ui.dialogue +import world.gregs.voidps.engine.client.ui.hasOpen +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.entity.character.player.skill.Skill +import world.gregs.voidps.engine.entity.obj.GameObject +import world.gregs.voidps.engine.entity.obj.GameObjects +import world.gregs.voidps.engine.entity.obj.ObjectLayer +import world.gregs.voidps.type.Tile +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class HouseSeatsTest : WorldTest() { + + private val portal = Tile(2951, 3222) + private val exit = Tile(2953, 3224) + private val parlour = roomPosition(4, 3, GROUND_LEVEL) + + private fun createOwner(): Player { + val player = createPlayer(exit, "owner") + player["house_location"] = "rimmington" + player.levels.set(Skill.Construction, 99) + player.addHouseRoom("garden", START_ROOM) + player.addHouseFurniture(START_ROOM, "garden_centrepiece_space", "exit_portal") + player.addHouseRoom("parlour", parlour) + player.addHouseFurniture(parlour, "parlour_chair_space", "oak_chair") + return player + } + + private fun Player.enterHouse() { + objectOption(GameObjects.find(portal, "house_portal_rimmington"), "Enter") + tickIf { dialogue == null } + dialogueOption("line1") + tickIf { hasOpen("house_loading") } + } + + private fun Player.findOrNull(id: String): GameObject? { + val zone = roomZone(instance()!!.tile.zone, parlour) + return zone.toCuboid().firstNotNullOfOrNull { GameObjects.findOrNull(it, id) } + } + + @Test + fun `Sit on a chair hides it until walking away`() { + val player = createOwner() + player.enterHouse() + val chair = player.findOrNull("oak_chair")!! + + player.objectOption(chair, "Sit-on") + tick(10) + + assertEquals(chair.tile, player.tile) + assertNull(player.findOrNull("oak_chair")) + assertTrue(player.contains("house_seat")) + + player.walk(chair.tile.addX(2)) + tick(5) + + assertNotEquals(chair.tile, player.tile) + assertFalse(player.contains("house_seat")) + assertEquals(chair, player.findOrNull("oak_chair")) + } + + @Test + fun `Stopping sitting puts the chair back`() { + val player = createOwner() + player.enterHouse() + val chair = player.findOrNull("oak_chair")!! + player.objectOption(chair, "Sit-on") + tick(10) + + player.softTimers.stop("house_seat") + + assertFalse(player.contains("house_seat")) + assertEquals(chair, player.findOrNull("oak_chair")) + } + + @Test + fun `Sit on a dining bench and get up again`() { + val player = createOwner() + val dining = roomPosition(3, 3, GROUND_LEVEL) + player.addHouseRoom("dining_room", dining) + player.addHouseFurniture(dining, "dining_room_bench_space", "wooden_bench") + player.enterHouse() + val zone = roomZone(player.instance()!!.tile.zone, dining) + val bench = zone.toCuboid().firstNotNullOf { GameObjects.findOrNull(it, "wooden_bench") } + + player.objectOption(bench, "Sit-on") + tick(10) + + assertEquals(bench.tile, player.tile) + assertEquals("invisible_seat", GameObjects.getLayer(bench.tile, ObjectLayer.layer(bench.shape))?.id) + + player.walk(bench.tile.addY(-3)) + tick(5) + + assertFalse(player.contains("house_seat")) + assertEquals(bench, GameObjects.findOrNull(bench.tile, "wooden_bench")) + } + + @Test + fun `Sit on a dining bench a second time`() { + val player = createOwner() + val dining = roomPosition(3, 3, GROUND_LEVEL) + player.addHouseRoom("dining_room", dining) + player.addHouseFurniture(dining, "dining_room_bench_space", "wooden_bench") + player.enterHouse() + val zone = roomZone(player.instance()!!.tile.zone, dining) + val bench = zone.toCuboid().firstNotNullOf { GameObjects.findOrNull(it, "wooden_bench") } + player.objectOption(bench, "Sit-on") + tick(10) + player.walk(bench.tile.addY(-3)) + tick(5) + + player.objectOption(bench, "Sit-on") + tick(10) + + assertEquals(bench.tile, player.tile) + assertTrue(player.contains("house_seat")) + } + + @Test + fun `Throne room benches aren't pulled out`() { + val player = createOwner() + val throneRoom = roomPosition(3, 3, GROUND_LEVEL) + player.addHouseRoom("throne_room", throneRoom) + player.addHouseFurniture(throneRoom, "throne_room_seating_space", "carved_teak_bench") + player.enterHouse() + val zone = roomZone(player.instance()!!.tile.zone, throneRoom) + val bench = zone.toCuboid().firstNotNullOf { GameObjects.findOrNull(it, "carved_teak_bench_throne_room") } + val benches = zone.toCuboid().count { GameObjects.findOrNull(it, "carved_teak_bench_throne_room") != null } + + player.objectOption(bench, "Sit-on") + tickIf { !player.contains("house_seat") } + + assertEquals(bench.tile, player.tile) + // Only the sat on bench is hidden without a pushed back copy appearing + assertEquals(benches - 1, zone.toCuboid().count { GameObjects.findOrNull(it, "carved_teak_bench_throne_room") != null }) + + player.walk(bench.tile.addY(-3)) + tick() + + assertFalse(player.contains("house_seat")) + assertEquals(bench, GameObjects.findOrNull(bench.tile, "carved_teak_bench_throne_room")) + } +} From f9648ebeafe59b7bb170bf70800b82f28e507be8 Mon Sep 17 00:00:00 2001 From: GregHib Date: Fri, 2 Oct 2026 18:31:00 +0100 Subject: [PATCH 22/28] Revert unnecessary dynamic zone level changes --- .../client/update/batch/ZoneBatchUpdates.kt | 13 +-------- .../engine/client/update/view/Viewport.kt | 3 --- .../engine/map/zone/ZoneBatchUpdatesTest.kt | 27 ------------------- .../content/entity/world/RegionLoading.kt | 12 ++------- 4 files changed, 3 insertions(+), 52 deletions(-) diff --git a/engine/src/main/kotlin/world/gregs/voidps/engine/client/update/batch/ZoneBatchUpdates.kt b/engine/src/main/kotlin/world/gregs/voidps/engine/client/update/batch/ZoneBatchUpdates.kt index f19f9e2f2b..4d57437f54 100644 --- a/engine/src/main/kotlin/world/gregs/voidps/engine/client/update/batch/ZoneBatchUpdates.kt +++ b/engine/src/main/kotlin/world/gregs/voidps/engine/client/update/batch/ZoneBatchUpdates.kt @@ -81,8 +81,7 @@ object ZoneBatchUpdates : Runnable { */ fun refresh(player: Player) { val viewport = player.viewport ?: return - val area = player.tile.zone.toRectangle(radius = viewport.localRadius) - for (zone in area.toZonesReversed(player.tile.level)) { + for (zone in player.tile.zone.toRectangle(radius = viewport.localRadius).toZonesReversed(player.tile.level)) { if (!inViewport(viewport, zone)) { continue } @@ -91,16 +90,6 @@ object ZoneBatchUpdates : Runnable { sender.send(player, zone) } } - if (viewport.dynamic) { - // The client keeps objects spawned on levels hidden by the new map, without terrain they'd show at ground height - for (level in viewport.maxLevel + 1 until 4) { - for (zone in area.toZonesReversed(level)) { - if (inViewport(viewport, zone)) { - player.clearZone(zone) - } - } - } - } viewport.lastBatchZone = player.tile.zone } diff --git a/engine/src/main/kotlin/world/gregs/voidps/engine/client/update/view/Viewport.kt b/engine/src/main/kotlin/world/gregs/voidps/engine/client/update/view/Viewport.kt index 6e5dbbc57d..71b140c281 100644 --- a/engine/src/main/kotlin/world/gregs/voidps/engine/client/update/view/Viewport.kt +++ b/engine/src/main/kotlin/world/gregs/voidps/engine/client/update/view/Viewport.kt @@ -27,9 +27,6 @@ class Viewport { // The DynamicZones.version the last region update was sent with var dynamicVersion: Int = -1 - - // The highest level the last dynamic region update was sent with - var maxLevel: Int = 3 var size: Int = 0 val tileSize: Int get() = VIEWPORT_SIZES[size] diff --git a/engine/src/test/kotlin/world/gregs/voidps/engine/map/zone/ZoneBatchUpdatesTest.kt b/engine/src/test/kotlin/world/gregs/voidps/engine/map/zone/ZoneBatchUpdatesTest.kt index 35ae7b2ddd..47fbd6073d 100644 --- a/engine/src/test/kotlin/world/gregs/voidps/engine/map/zone/ZoneBatchUpdatesTest.kt +++ b/engine/src/test/kotlin/world/gregs/voidps/engine/map/zone/ZoneBatchUpdatesTest.kt @@ -74,33 +74,6 @@ internal class ZoneBatchUpdatesTest : KoinMock() { } } - @Test - fun `Refreshing a dynamic region clears levels hidden above the player`() { - player.tile = Tile(20, 20) - player.viewport!!.lastLoadZone = player.tile.zone - player.viewport!!.dynamic = true - player.viewport!!.maxLevel = 0 - - ZoneBatchUpdates.refresh(player) - - verify { - client.clearZone(2, 2, 0) - client.clearZone(2, 2, 1) - client.clearZone(2, 2, 3) - } - } - - @Test - fun `Refreshing a static region only clears the players level`() { - player.tile = Tile(20, 20) - player.viewport!!.lastLoadZone = player.tile.zone - - ZoneBatchUpdates.refresh(player) - - verify { client.clearZone(2, 2, 0) } - verify(exactly = 0) { client.clearZone(any(), any(), 1) } - } - @Test fun `Staying in zone sends batched updates`() { // Given diff --git a/game/src/main/kotlin/content/entity/world/RegionLoading.kt b/game/src/main/kotlin/content/entity/world/RegionLoading.kt index 9b6625daf8..3d2c3ce76d 100644 --- a/game/src/main/kotlin/content/entity/world/RegionLoading.kt +++ b/game/src/main/kotlin/content/entity/world/RegionLoading.kt @@ -101,13 +101,7 @@ class RegionLoading(val dynamicZones: DynamicZones) : Script { } fun crossedDynamicBoarder(player: Player) = player.viewport!!.dynamic != inDynamicView(player) || - (dynamicZones.dynamicUpdate(player.tile.region) && player.viewport!!.dynamicVersion != dynamicZones.version) || - (player.viewport!!.dynamic && player.viewport!!.maxLevel != maxLevel(player)) - - /** - * Highest level of a dynamic region to send, levels above the player can be hidden so they don't block the view e.g. underground - */ - fun maxLevel(player: Player): Int = if (player["hide_upper_levels", false]) player.tile.level else 3 + (dynamicZones.dynamicUpdate(player.tile.region) && player.viewport!!.dynamicVersion != dynamicZones.version) fun inDynamicView(player: Player): Boolean = dynamicZones.dynamic(player.tile.region) @@ -169,12 +163,11 @@ class RegionLoading(val dynamicZones: DynamicZones) : Script { val view = player.tile.zone.minus(viewport.zoneRadius, viewport.zoneRadius) val zoneSize = viewport.zoneArea val xtea = blankXtea - val maxLevel = maxLevel(player) for (lvl in 0..3) { for (x in 0 until zoneSize) { for (y in 0 until zoneSize) { val zone = Zone(view.x + x, view.y + y, lvl) - val target = if (lvl > maxLevel) null else dynamicZones.dynamicZone(zone) + val target = dynamicZones.dynamicZone(zone) if (target == null) { zones.add(null) continue @@ -188,7 +181,6 @@ class RegionLoading(val dynamicZones: DynamicZones) : Script { } } viewport.dynamic = true - viewport.maxLevel = maxLevel player.client?.dynamicMapRegion( zoneX = player.tile.zone.x, zoneY = player.tile.zone.y, From 0a5a51a7389eae88d15d1520f290b1bc83d613f2 Mon Sep 17 00:00:00 2001 From: GregHib Date: Fri, 2 Oct 2026 18:35:01 +0100 Subject: [PATCH 23/28] Apply dungeon styles --- data/skill/construction/construction.tables.toml | 2 +- game/src/main/kotlin/content/skill/construction/House.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/data/skill/construction/construction.tables.toml b/data/skill/construction/construction.tables.toml index 8c3db172c0..c1a7ec305a 100644 --- a/data/skill/construction/construction.tables.toml +++ b/data/skill/construction/construction.tables.toml @@ -154,7 +154,7 @@ template = { x = 1864, y = 5056 } [.roof] template = { x = 1888, y = 5064 } -# Fills the empty dungeon spaces around dungeon rooms, the same for every style +# Fills the empty dungeon spaces around the rooms, in building mode with spaces to build rooms in [.dungeon] template = { x = 1880, y = 5056 } build = { x = 1944, y = 5056 } diff --git a/game/src/main/kotlin/content/skill/construction/House.kt b/game/src/main/kotlin/content/skill/construction/House.kt index 72adff4fdc..251714a4b4 100644 --- a/game/src/main/kotlin/content/skill/construction/House.kt +++ b/game/src/main/kotlin/content/skill/construction/House.kt @@ -371,7 +371,7 @@ class House : Script { surround(base, rooms, GROUND_LEVEL, template("house_spaces.land.template"), placed, entries) } if (positions.any { roomLevel(it) == DUNGEON_LEVEL }) { - surround(base, rooms, DUNGEON_LEVEL, Tables.tile("house_spaces.dungeon.${if (buildMode) "build" else "template"}").zone, placed, entries) + surround(base, rooms, DUNGEON_LEVEL, template("house_spaces.dungeon.${if (buildMode) "build" else "template"}"), placed, entries) } for (level in 0 until HOUSE_LEVELS) { for (x in 0 until HOUSE_SIZE) { From 4b2da83717e8bbf3bbbaaf7fd99a5b4951a084fa Mon Sep 17 00:00:00 2001 From: GregHib Date: Fri, 2 Oct 2026 19:30:02 +0100 Subject: [PATCH 24/28] Prevent penthouse gardens --- .../construction/construction.ifaces.toml | 2 +- .../skill/construction/FurnitureCreation.kt | 3 + .../content/skill/construction/House.kt | 47 ++++++++- .../content/skill/construction/HouseStairs.kt | 9 +- .../skill/construction/RoomCreation.kt | 28 +++--- .../skill/construction/HouseStairsTest.kt | 98 +++++++++++++++++++ 6 files changed, 157 insertions(+), 30 deletions(-) diff --git a/data/skill/construction/construction.ifaces.toml b/data/skill/construction/construction.ifaces.toml index d47a4dad77..c9af35e5f5 100644 --- a/data/skill/construction/construction.ifaces.toml +++ b/data/skill/construction/construction.ifaces.toml @@ -19,7 +19,7 @@ id = 29 [house_loading] id = 399 -type = "main_screen" +type = "wide_screen" [room_creation] id = 402 diff --git a/game/src/main/kotlin/content/skill/construction/FurnitureCreation.kt b/game/src/main/kotlin/content/skill/construction/FurnitureCreation.kt index 230adb9f3c..8816159d25 100644 --- a/game/src/main/kotlin/content/skill/construction/FurnitureCreation.kt +++ b/game/src/main/kotlin/content/skill/construction/FurnitureCreation.kt @@ -180,6 +180,9 @@ class FurnitureCreation : Script { connectStairs(if (stairsDown(position)) roomBelow(position) else roomAbove(position)) // Stairs leading down change the rooms template loadHouse(base, buildMode = true) + } else if (furniture == "dungeon_entrance" && connectStairs(roomBelow(position))) { + // The entrance leads down to stairs in the dungeon room below + loadHouse(base, buildMode = true) } else { placeFurniture(target.tile.zone, hotspot, furniture) } diff --git a/game/src/main/kotlin/content/skill/construction/House.kt b/game/src/main/kotlin/content/skill/construction/House.kt index 251714a4b4..c2cc4f27e5 100644 --- a/game/src/main/kotlin/content/skill/construction/House.kt +++ b/game/src/main/kotlin/content/skill/construction/House.kt @@ -22,6 +22,7 @@ import world.gregs.voidps.engine.map.collision.Collisions import world.gregs.voidps.engine.map.collision.check import world.gregs.voidps.engine.map.instance.Instances import world.gregs.voidps.engine.map.zone.DynamicZones +import world.gregs.voidps.type.Direction import world.gregs.voidps.type.Tile import world.gregs.voidps.type.Zone @@ -59,6 +60,9 @@ class House : Script { const val UPPER_LEVEL = 2 const val START_ROOM = HOUSE_CENTRE + HOUSE_CENTRE * ROOM_GRID + GROUND_LEVEL * ROOM_GRID * ROOM_GRID + // Staircase placed in the dungeon room below a dungeon entrance so it leads back up + private const val ENTRANCE_STAIRS = "oak_staircase" + val Player.houseRoomIds: List get() = get("house_room_ids") ?: emptyList() @@ -214,9 +218,19 @@ class House : Script { return houseFurnitureHotspots[index] == Tables.objOrNull("house_rooms.${houseRoom(position)}.stairs_down") } + /** + * Whether the room at [position] has a dungeon entrance built in it + */ + fun Player.hasEntrance(position: Int): Boolean { + val rooms = houseFurnitureRooms + val ids = houseFurnitureIds + return rooms.indices.any { rooms[it] == position && ids[it] == "dungeon_entrance" } + } + /** * Adds the other end of the stairs leading down from the room above, or up from the room below, - * to the room at [position] if it has space for them. Returns whether stairs were added. + * to the room at [position] if it has space for them. Dungeon rooms below a dungeon entrance get [ENTRANCE_STAIRS]. + * Returns whether stairs were added. */ fun Player.connectStairs(position: Int): Boolean { val room = houseRoom(position) ?: return false @@ -228,6 +242,7 @@ class House : Script { val (furniture, hotspot) = when { houseStairs(above) != null && stairsDown(above) -> houseStairs(above) to Tables.objOrNull("house_rooms.$room.stairs") houseStairs(below) != null && !stairsDown(below) -> houseStairs(below) to Tables.objOrNull("house_rooms.$room.stairs_down") + roomLevel(position) == DUNGEON_LEVEL && hasEntrance(above) -> ENTRANCE_STAIRS to Tables.objOrNull("house_rooms.$room.stairs") else -> return false } if (furniture == null || hotspot == null) { @@ -265,8 +280,27 @@ class House : Script { placeFurniture(zone, hotspots[index], furniture[index]) } } - val window = Tables.obj("house_styles.${get("house_style", "basic_wood")}.${if (roomLevel(position) == DUNGEON_LEVEL) "wall" else "window"}") - decorate(zone, window, buildMode) + val style = get("house_style", "basic_wood") + val level = roomLevel(position) + val wall = Tables.obj("house_styles.$style.wall") + val window = if (level == DUNGEON_LEVEL) wall else Tables.obj("house_styles.$style.window") + // Upstairs doorways without a room on the other side would lead out onto the roofs + val positions = houseRoomPositions + val doorWall = if (level > GROUND_LEVEL) wall else null + decorate(zone, window, doorWall, buildMode) { side -> roomPosition(base, zone.add(side)) in positions } + } + + /** + * Side of the room [tile] is a door on + */ + fun doorSide(tile: Tile): Direction { + val zone = tile.zone.tile + return when { + tile.x == zone.x -> Direction.WEST + tile.y == zone.y -> Direction.SOUTH + tile.x == zone.x + 7 -> Direction.EAST + else -> Direction.NORTH + } } fun roomPosition(x: Int, y: Int, level: Int) = x + y * ROOM_GRID + level * ROOM_GRID * ROOM_GRID @@ -404,13 +438,16 @@ class House : Script { } /** - * Fills the window spaces in [zone] and removes any hotspots outside of [buildMode] + * Fills the window spaces in [zone] and removes any hotspots outside of [buildMode]. + * Doorways on a side without a [connected] room are filled with [doorWall] when there is one. */ - private fun decorate(zone: Zone, window: String, buildMode: Boolean) { + private fun decorate(zone: Zone, window: String, doorWall: String?, buildMode: Boolean, connected: (Direction) -> Boolean) { for (tile in zone.toCuboid()) { for (obj in GameObjects.at(tile)) { if (obj.id == "house_window_space") { obj.replace(window) + } else if (!buildMode && doorWall != null && obj.id.startsWith("door_hotspot") && !connected(doorSide(obj.tile))) { + obj.replace(doorWall) } else if (!buildMode && obj.def.containsOption("Build")) { obj.remove() } diff --git a/game/src/main/kotlin/content/skill/construction/HouseStairs.kt b/game/src/main/kotlin/content/skill/construction/HouseStairs.kt index f62dfb437a..06c189df3f 100644 --- a/game/src/main/kotlin/content/skill/construction/HouseStairs.kt +++ b/game/src/main/kotlin/content/skill/construction/HouseStairs.kt @@ -5,9 +5,8 @@ import content.entity.player.dialogue.type.statement import content.skill.construction.House.Companion.DUNGEON_LEVEL import content.skill.construction.House.Companion.UPPER_LEVEL import content.skill.construction.House.Companion.changeFloor +import content.skill.construction.House.Companion.hasEntrance import content.skill.construction.House.Companion.houseBase -import content.skill.construction.House.Companion.houseFurnitureIds -import content.skill.construction.House.Companion.houseFurnitureRooms import content.skill.construction.House.Companion.houseOwner import content.skill.construction.House.Companion.houseRoom import content.skill.construction.House.Companion.houseRoomPositions @@ -118,12 +117,6 @@ class HouseStairs : Script { travel(base, position, next, connected, stairRooms, NOWHERE, if (up) "at the top" else "at the bottom") } - private fun Player.hasEntrance(position: Int): Boolean { - val rooms = houseFurnitureRooms - val ids = houseFurnitureIds - return rooms.indices.any { rooms[it] == position && ids[it] == "dungeon_entrance" } - } - /** * Moves from the room at [from] to the one at [to] if they're [connected], * otherwise offers the owner in building mode to build one of [rooms] [place]. diff --git a/game/src/main/kotlin/content/skill/construction/RoomCreation.kt b/game/src/main/kotlin/content/skill/construction/RoomCreation.kt index 1feece2bee..d2e1b71b33 100644 --- a/game/src/main/kotlin/content/skill/construction/RoomCreation.kt +++ b/game/src/main/kotlin/content/skill/construction/RoomCreation.kt @@ -7,8 +7,10 @@ import content.skill.construction.House.Companion.GROUND_LEVEL import content.skill.construction.House.Companion.HOUSE_CENTRE import content.skill.construction.House.Companion.addHouseRoom import content.skill.construction.House.Companion.connectStairs +import content.skill.construction.House.Companion.doorSide import content.skill.construction.House.Companion.exitPortals import content.skill.construction.House.Companion.houseBase +import content.skill.construction.House.Companion.houseRoom import content.skill.construction.House.Companion.houseRoomIds import content.skill.construction.House.Companion.houseRoomPositions import content.skill.construction.House.Companion.inOwnHouse @@ -24,14 +26,13 @@ import world.gregs.voidps.engine.client.message import world.gregs.voidps.engine.client.ui.close import world.gregs.voidps.engine.client.ui.open import world.gregs.voidps.engine.data.definition.Rows +import world.gregs.voidps.engine.data.definition.Tables import world.gregs.voidps.engine.entity.character.player.Player import world.gregs.voidps.engine.entity.character.player.skill.Skill import world.gregs.voidps.engine.entity.character.player.skill.level.Level.has import world.gregs.voidps.engine.inv.inventory import world.gregs.voidps.engine.inv.remove import world.gregs.voidps.engine.map.zone.DynamicZones -import world.gregs.voidps.type.Direction -import world.gregs.voidps.type.Tile import world.gregs.voidps.type.Zone class RoomCreation(val dynamicZones: DynamicZones) : Script { @@ -43,7 +44,7 @@ class RoomCreation(val dynamicZones: DynamicZones) : Script { } val zone = target.tile.zone // Doors lead to the room on the opposite side to the player - val room = if (tile.zone == zone) zone.add(direction(target.tile)) else zone + val room = if (tile.zone == zone) zone.add(doorSide(target.tile)) else zone val position = roomPosition(base, room) val index = houseRoomPositions.indexOf(position ?: -1) if (index != -1) { @@ -66,6 +67,10 @@ class RoomCreation(val dynamicZones: DynamicZones) : Script { statement("You can't build a room that would have no room to support it.") return@objectOperate } + if (roomLevel(position) > GROUND_LEVEL && Tables.bool("house_rooms.${houseRoom(roomBelow(position))}.outdoor")) { + statement("You can't build a room above a garden.") // TODO proper message + return@objectOperate + } set("house_preview_position", position) open("room_creation") } @@ -83,19 +88,6 @@ class RoomCreation(val dynamicZones: DynamicZones) : Script { } } - /** - * Side of the room [tile] is a door on - */ - private fun direction(tile: Tile): Direction { - val zone = tile.zone.tile - return when { - tile.x == zone.x -> Direction.WEST - tile.y == zone.y -> Direction.SOUTH - tile.x == zone.x + 7 -> Direction.EAST - else -> Direction.NORTH - } - } - /** * Range of room grid coordinates the players level allows building in * Sizes grow from the south-west, so even sizes extend one further north and east of the [HOUSE_CENTRE] @@ -211,6 +203,10 @@ class RoomCreation(val dynamicZones: DynamicZones) : Script { message("That room can't be built underground.") // TODO proper messages return false } + if (row.bool("outdoor") && roomLevel(position) > GROUND_LEVEL) { + message("That room can only be built on the ground floor.") // TODO proper messages + return false + } if (inventory.count("coins") < row.int("cost")) { message("You need ${row.int("cost")} coins to build this room.") // TODO proper messages return false diff --git a/game/src/test/kotlin/content/skill/construction/HouseStairsTest.kt b/game/src/test/kotlin/content/skill/construction/HouseStairsTest.kt index 24bc339a4c..f06d5066d8 100644 --- a/game/src/test/kotlin/content/skill/construction/HouseStairsTest.kt +++ b/game/src/test/kotlin/content/skill/construction/HouseStairsTest.kt @@ -23,6 +23,7 @@ import org.junit.jupiter.api.Test import skipDialogues import world.gregs.voidps.engine.client.ui.dialogue import world.gregs.voidps.engine.client.ui.hasOpen +import world.gregs.voidps.engine.client.ui.open import world.gregs.voidps.engine.data.definition.Tables import world.gregs.voidps.engine.entity.character.move.tele import world.gregs.voidps.engine.entity.character.player.Player @@ -160,6 +161,28 @@ class HouseStairsTest : WorldTest() { assertEquals(0, player.inventory.count("coins")) assertEquals(DUNGEON_LEVEL, player.tile.level) assertFalse(player.hasOpen("house_loading")) + assertEquals("oak_staircase", player.houseStairs(dungeon)) + assertFalse(player.stairsDown(dungeon)) + player.find(dungeon, "oak_staircase") + } + + @Test + fun `Building a dungeon entrance adds stairs to the dungeon room below`() { + val player = createOwner() + player.addHouseRoom("garden", hall) + player.addHouseRoom("dungeon_stairs", dungeon) + player.inventory.add("hammer") + player.inventory.add("saw") + player.inventory.add("marble_block") + player.enterPortal(2) + + player.use(player.find(hall, "garden_centrepiece_space"), "Build") + tickIf { !player.hasOpen("furniture_creation") } + player.interfaceOption("furniture_creation", "items", "Build", item = Item("dungeon_entrance"), slot = 1) + + assertEquals("oak_staircase", player.houseStairs(dungeon)) + player.find(hall, "dungeon_entrance") + player.find(dungeon, "oak_staircase") } @Test @@ -383,6 +406,47 @@ class HouseStairsTest : WorldTest() { assertEquals(roomPosition(5, 3, UPPER_LEVEL), player["house_preview_position"]) } + @Test + fun `Can't build an upstairs room above a garden`() { + val player = createOwner() + player.addHalls() + player.addHouseRoom("garden", roomPosition(5, 3, GROUND_LEVEL)) + player.enterPortal(2) + player.use(player.find(hall, "oak_staircase"), "Climb-up") + val upper = roomZone(player.instance()!!.tile.zone, upperHall).tile + player.tele(upper.add(6, 3)) + + player.objectOption(GameObjects.find(upper.add(7, 3)) { it.id.startsWith("door_hotspot") }, "Build") + tick(5) + + assertFalse(player.hasOpen("room_creation")) + assertEquals("dialogue_message1", player.dialogue) + assertNull(player["house_preview_position"]) + } + + @Test + fun `Can't build gardens upstairs`() { + val player = createOwner() + player.addHalls() + player.addHouseRoom("parlour", roomPosition(5, 3, GROUND_LEVEL)) + player.inventory.add("coins", 100000) + player.enterPortal(2) + player.use(player.find(hall, "oak_staircase"), "Climb-up") + val upper = roomZone(player.instance()!!.tile.zone, upperHall).tile + player.tele(upper.add(6, 3)) + player.objectOption(GameObjects.find(upper.add(7, 3)) { it.id.startsWith("door_hotspot") }, "Build") + tickIf { !player.hasOpen("room_creation") } + + for (garden in listOf("garden", "menagerie", "formal_garden")) { + player.interfaceOption("room_creation", garden, "Build") + player.open("room_creation") + } + + assertNull(player["house_preview_room"]) + assertEquals(100000, player.inventory.count("coins")) + assertTrue(player.containsMessage("That room can only be built on the ground floor.")) + } + @Test fun `Removing other furniture keeps the stairs`() { val player = createOwner() @@ -579,4 +643,38 @@ class HouseStairsTest : WorldTest() { assertNull(dynamicZones.dynamicZone(roomZone(base, roomPosition(3, 3, DUNGEON_LEVEL)))) } + + @Test + fun `Upstairs doorways without a room on the other side are walled`() { + val player = createOwner() + player.addHalls() + player.addHouseRoom("parlour", roomPosition(5, 3, GROUND_LEVEL)) + player.addHouseRoom("parlour", roomPosition(5, 3, UPPER_LEVEL)) + player.enterPortal(1) + val base = player.instance()!!.tile.zone + val upper = roomZone(base, upperHall).tile + val ground = roomZone(base, hall).tile + + // Connected to the parlour to the east + assertNull(GameObjects.findOrNull(upper.add(7, 3), "basic_wood_wall")) + assertNull(GameObjects.findOrNull(upper.add(7, 4), "basic_wood_wall")) + // Open to the roofs on the other sides + for (tile in listOf(upper.add(0, 3), upper.add(0, 4), upper.add(3, 0), upper.add(4, 0), upper.add(3, 7), upper.add(4, 7))) { + assertNotNull(GameObjects.findOrNull(tile, "basic_wood_wall"), tile.toString()) + } + assertTrue(roomZone(base, upperHall).toCuboid().none { tile -> GameObjects.at(tile).any { it.id.startsWith("door_hotspot") } }) + // Ground floor doorways still lead outside + assertNull(GameObjects.findOrNull(ground.add(0, 3), "basic_wood_wall")) + } + + @Test + fun `Upstairs doorways keep their hotspots in building mode`() { + val player = createOwner() + player.addHalls() + player.enterPortal(2) + val upper = roomZone(player.instance()!!.tile.zone, upperHall).tile + + assertNull(GameObjects.findOrNull(upper.add(0, 3), "basic_wood_wall")) + assertNotNull(GameObjects.findOrNull(upper.add(0, 3)) { it.id.startsWith("door_hotspot") }) + } } From a4bab651fce1a1443a016d4a4cd6f94df778c276 Mon Sep 17 00:00:00 2001 From: GregHib Date: Fri, 2 Oct 2026 19:41:07 +0100 Subject: [PATCH 25/28] Add all interactions with unimplemented warnings --- .../content/skill/construction/House.kt | 8 +++++ .../skill/construction/HouseBedroom.kt | 24 +++++++++++++ .../content/skill/construction/HouseChapel.kt | 15 ++++++++ .../skill/construction/HouseCombatRoom.kt | 31 ++++++++++++++++ .../skill/construction/HouseCostumeRoom.kt | 26 ++++++++++++++ .../skill/construction/HouseDiningRoom.kt | 15 ++++++++ .../skill/construction/HouseDungeon.kt | 31 ++++++++++++++++ .../skill/construction/HouseGamesRoom.kt | 35 ++++++++++++++++++ .../content/skill/construction/HouseHalls.kt | 23 ++++++++++++ .../skill/construction/HouseMenagerie.kt | 32 +++++++++++++++++ .../content/skill/construction/HouseStudy.kt | 32 +++++++++++++++++ .../skill/construction/HouseThroneRoom.kt | 27 ++++++++++++++ .../skill/construction/HouseWorkshop.kt | 36 +++++++++++++++++++ .../skill/construction/PortalChamber.kt | 5 +++ .../skill/construction/HouseFurnitureTest.kt | 33 +++++++++++++++++ 15 files changed, 373 insertions(+) create mode 100644 game/src/main/kotlin/content/skill/construction/HouseBedroom.kt create mode 100644 game/src/main/kotlin/content/skill/construction/HouseChapel.kt create mode 100644 game/src/main/kotlin/content/skill/construction/HouseCombatRoom.kt create mode 100644 game/src/main/kotlin/content/skill/construction/HouseCostumeRoom.kt create mode 100644 game/src/main/kotlin/content/skill/construction/HouseDiningRoom.kt create mode 100644 game/src/main/kotlin/content/skill/construction/HouseDungeon.kt create mode 100644 game/src/main/kotlin/content/skill/construction/HouseGamesRoom.kt create mode 100644 game/src/main/kotlin/content/skill/construction/HouseHalls.kt create mode 100644 game/src/main/kotlin/content/skill/construction/HouseMenagerie.kt create mode 100644 game/src/main/kotlin/content/skill/construction/HouseStudy.kt create mode 100644 game/src/main/kotlin/content/skill/construction/HouseThroneRoom.kt create mode 100644 game/src/main/kotlin/content/skill/construction/HouseWorkshop.kt diff --git a/game/src/main/kotlin/content/skill/construction/House.kt b/game/src/main/kotlin/content/skill/construction/House.kt index c2cc4f27e5..55e1bac282 100644 --- a/game/src/main/kotlin/content/skill/construction/House.kt +++ b/game/src/main/kotlin/content/skill/construction/House.kt @@ -5,6 +5,7 @@ import content.quest.exitInstance import content.quest.instance import org.rsmod.game.pathfinder.flag.CollisionFlag import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.client.message import world.gregs.voidps.engine.client.ui.close import world.gregs.voidps.engine.client.ui.hasOpen import world.gregs.voidps.engine.client.ui.open @@ -465,6 +466,13 @@ class House : Script { private const val BLOCKED = CollisionFlag.FLOOR or CollisionFlag.FLOOR_DECORATION or CollisionFlag.OBJECT + /** + * Placeholder for furniture interactions which haven't been added yet + */ + fun Player.notImplemented() { + message("Not yet implemented.") // TODO + } + suspend fun Player.houseLoading() { open("house_loading") delay(3) diff --git a/game/src/main/kotlin/content/skill/construction/HouseBedroom.kt b/game/src/main/kotlin/content/skill/construction/HouseBedroom.kt new file mode 100644 index 0000000000..2dc2f4e56f --- /dev/null +++ b/game/src/main/kotlin/content/skill/construction/HouseBedroom.kt @@ -0,0 +1,24 @@ +package content.skill.construction + +import content.skill.construction.House.Companion.notImplemented +import world.gregs.voidps.engine.Script + +/** + * Bedroom furniture: wardrobes and drawers to change clothes and dressers to change hairstyles + */ +class HouseBedroom : Script { + init { + objectOperate("Change-clothes", WARDROBES) { + notImplemented() + } + + objectOperate("Preen", DRESSERS) { + notImplemented() + } + } + + companion object { + private const val WARDROBES = "shoe_box,oak_drawers,oak_wardrobe,teak_drawers,teak_wardrobe,mahogany_wardrobe,gilded_wardrobe" + private const val DRESSERS = "shaving_stand,oak_shaving_stand,oak_dresser,teak_dresser,fancy_teak_dresser,mahogany_dresser,gilded_dresser" + } +} diff --git a/game/src/main/kotlin/content/skill/construction/HouseChapel.kt b/game/src/main/kotlin/content/skill/construction/HouseChapel.kt new file mode 100644 index 0000000000..a903d66531 --- /dev/null +++ b/game/src/main/kotlin/content/skill/construction/HouseChapel.kt @@ -0,0 +1,15 @@ +package content.skill.construction + +import content.skill.construction.House.Companion.notImplemented +import world.gregs.voidps.engine.Script + +/** + * Chapel musical instruments + */ +class HouseChapel : Script { + init { + objectOperate("Play", "windchimes,bells,organ") { + notImplemented() + } + } +} diff --git a/game/src/main/kotlin/content/skill/construction/HouseCombatRoom.kt b/game/src/main/kotlin/content/skill/construction/HouseCombatRoom.kt new file mode 100644 index 0000000000..1e4c01a317 --- /dev/null +++ b/game/src/main/kotlin/content/skill/construction/HouseCombatRoom.kt @@ -0,0 +1,31 @@ +package content.skill.construction + +import content.skill.construction.House.Companion.notImplemented +import world.gregs.voidps.engine.Script + +/** + * Combat room rings and the barriers and balance beams built inside them + */ +class HouseCombatRoom : Script { + init { + objectOperate("Climb-over", "boxing_ring,fencing_ring,combat_ring") { + notImplemented() + } + + objectOperate("Walk-through", "magic_barrier") { + notImplemented() + } + + objectOperate("Stand-on", BALANCE_BEAMS) { + notImplemented() + } + + objectOperate("Get-down", BALANCE_BEAMS) { + notImplemented() + } + } + + companion object { + private const val BALANCE_BEAMS = "balance_beam,balance_beam_end" + } +} diff --git a/game/src/main/kotlin/content/skill/construction/HouseCostumeRoom.kt b/game/src/main/kotlin/content/skill/construction/HouseCostumeRoom.kt new file mode 100644 index 0000000000..eca2b09a20 --- /dev/null +++ b/game/src/main/kotlin/content/skill/construction/HouseCostumeRoom.kt @@ -0,0 +1,26 @@ +package content.skill.construction + +import content.skill.construction.House.Companion.notImplemented +import world.gregs.voidps.engine.Script + +/** + * Costume room storage: cape racks, magic wardrobes, toy boxes, treasure chests, fancy dress boxes and armour cases + */ +class HouseCostumeRoom : Script { + init { + objectOperate("Search", CAPE_RACKS) { + notImplemented() + } + + objectOperate("Open", "$MAGIC_WARDROBES,$CHESTS") { + notImplemented() + } + } + + companion object { + private const val CAPE_RACKS = "oak_cape_rack,teak_cape_rack,mahogany_cape_rack,gilded_cape_rack,marble_cape_rack,magic_cape_rack" + private const val MAGIC_WARDROBES = "oak_magic_wardrobe,carved_oak_magic_wardrobe,teak_magic_wardrobe,carved_teak_magic_wardrobe,mahogany_magic_wardrobe,gilded_magic_wardrobe,marble_magic_wardrobe" + private const val CHESTS = "oak_toy_box,teak_toy_box,mahogany_toy_box,oak_treasure_chest,teak_treasure_chest,mahogany_treasure_chest," + + "oak_fancy_dress_box,teak_fancy_dress_box,mahogany_fancy_dress_box,oak_armour_case,teak_armour_case,mahogany_armour_case" + } +} diff --git a/game/src/main/kotlin/content/skill/construction/HouseDiningRoom.kt b/game/src/main/kotlin/content/skill/construction/HouseDiningRoom.kt new file mode 100644 index 0000000000..8dd4116217 --- /dev/null +++ b/game/src/main/kotlin/content/skill/construction/HouseDiningRoom.kt @@ -0,0 +1,15 @@ +package content.skill.construction + +import content.skill.construction.House.Companion.notImplemented +import world.gregs.voidps.engine.Script + +/** + * Dining room bell-pulls for summoning the players servant + */ +class HouseDiningRoom : Script { + init { + objectOperate("Ring", "rope_bell_pull,bell_pull,posh_bell_pull") { + notImplemented() + } + } +} diff --git a/game/src/main/kotlin/content/skill/construction/HouseDungeon.kt b/game/src/main/kotlin/content/skill/construction/HouseDungeon.kt new file mode 100644 index 0000000000..c1d2160bcb --- /dev/null +++ b/game/src/main/kotlin/content/skill/construction/HouseDungeon.kt @@ -0,0 +1,31 @@ +package content.skill.construction + +import content.skill.construction.House.Companion.notImplemented +import world.gregs.voidps.engine.Script + +/** + * Dungeon furniture: locked doors between rooms, oubliette ladders and treasure room chests + */ +class HouseDungeon : Script { + init { + objectOperate("Pick-lock", DOORS) { + notImplemented() + } + + objectOperate("Force", DOORS) { + notImplemented() + } + + objectOperate("Climb", "oak_ladder,teak_ladder,mahogany_ladder") { + notImplemented() + } + + objectOperate("Open", "wooden_treasure_crate,oak_treasure_room_chest,teak_treasure_room_chest,mahogany_treasure_room_chest,magic_treasure_room_chest") { + notImplemented() + } + } + + companion object { + private const val DOORS = "door_309_closed,door_310_closed,door_311_closed,door_312_closed,door_313_closed,door_314_closed" + } +} diff --git a/game/src/main/kotlin/content/skill/construction/HouseGamesRoom.kt b/game/src/main/kotlin/content/skill/construction/HouseGamesRoom.kt new file mode 100644 index 0000000000..398aa8562c --- /dev/null +++ b/game/src/main/kotlin/content/skill/construction/HouseGamesRoom.kt @@ -0,0 +1,35 @@ +package content.skill.construction + +import content.skill.construction.House.Companion.notImplemented +import world.gregs.voidps.engine.Script + +/** + * Games room furniture: game spaces, prize chests, attack stones, elemental balances and ranging games + */ +class HouseGamesRoom : Script { + init { + objectOperate("Activate", "jester,treasure_hunt,hangman_game,elemental_balance_*") { + notImplemented() + } + + objectOperate("Open", "oak_prize_chest,teak_prize_chest,mahogany_prize_chest") { + notImplemented() + } + + objectOperate("Set-up", "clay_attack_stone,limestone_attack_stone,marble_attack_stone") { + notImplemented() + } + + objectOperate("Hoop", "hoop_and_stick") { + notImplemented() + } + + objectOperate("Throw-at", "dartboard") { + notImplemented() + } + + objectOperate("Shoot-at", "house_archery_target") { + notImplemented() + } + } +} diff --git a/game/src/main/kotlin/content/skill/construction/HouseHalls.kt b/game/src/main/kotlin/content/skill/construction/HouseHalls.kt new file mode 100644 index 0000000000..807234378b --- /dev/null +++ b/game/src/main/kotlin/content/skill/construction/HouseHalls.kt @@ -0,0 +1,23 @@ +package content.skill.construction + +import content.skill.construction.House.Companion.notImplemented +import world.gregs.voidps.engine.Script + +/** + * Skill hall head trophies and the quest hall's mounted amulet of glory + */ +class HouseHalls : Script { + init { + objectOperate("Talk-to", TROPHIES) { + notImplemented() + } + + objectOperate("Rub", "amulet_of_glory_mounted") { + notImplemented() + } + } + + companion object { + private const val TROPHIES = "crawling_hand_trophy,cockatrice_head_trophy,basilisk_head_trophy,kurask_head_trophy,abyssal_head_trophy,kbd_heads_trophy,kq_head_trophy" + } +} diff --git a/game/src/main/kotlin/content/skill/construction/HouseMenagerie.kt b/game/src/main/kotlin/content/skill/construction/HouseMenagerie.kt new file mode 100644 index 0000000000..6352ac7a67 --- /dev/null +++ b/game/src/main/kotlin/content/skill/construction/HouseMenagerie.kt @@ -0,0 +1,32 @@ +package content.skill.construction + +import content.skill.construction.House.Companion.notImplemented +import world.gregs.voidps.engine.Script + +/** + * Menagerie furniture: pet houses to store pets, feeders and the small obelisk for renewing summoning points + */ +class HouseMenagerie : Script { + init { + objectOperate("Store", PET_HOUSES) { + notImplemented() + } + + objectOperate("Feed-all", PET_FEEDERS) { + notImplemented() + } + + objectOperate("Upgrade", "$PET_HOUSES,oak_pet_feeder,teak_pet_feeder") { + notImplemented() + } + + objectOperate("Renew-points", "house_small_obelisk") { + notImplemented() + } + } + + companion object { + private const val PET_HOUSES = "oak_pet_house,teak_pet_house,mahogany_pet_house,consecrated_pet_house,desecrated_pet_house,natural_pet_house" + private const val PET_FEEDERS = "oak_pet_feeder,teak_pet_feeder,mahogany_pet_feeder" + } +} diff --git a/game/src/main/kotlin/content/skill/construction/HouseStudy.kt b/game/src/main/kotlin/content/skill/construction/HouseStudy.kt new file mode 100644 index 0000000000..11d49b598a --- /dev/null +++ b/game/src/main/kotlin/content/skill/construction/HouseStudy.kt @@ -0,0 +1,32 @@ +package content.skill.construction + +import content.skill.construction.House.Companion.notImplemented +import world.gregs.voidps.engine.Script + +/** + * Study furniture: lecterns for making tablets, charts, telescopes and the statue plinth, + * along with bookcases which are also built in parlours and quest halls. + */ +class HouseStudy : Script { + init { + objectOperate("Study", "$LECTERNS,alchemical_chart,astronomical_chart,infernal_chart") { + notImplemented() + } + + objectOperate("Observe", "wooden_telescope,teak_telescope,mahogany_telescope") { + notImplemented() + } + + objectOperate("Check", "dahmaroc_statue_plinth") { + notImplemented() + } + + objectOperate("Search", "wooden_bookcase,oak_bookcase,mahogany_bookcase") { + notImplemented() + } + } + + companion object { + private const val LECTERNS = "oak_lectern,eagle_lectern,demon_lectern,teak_eagle_lectern,teak_demon_lectern,mahogany_eagle_lectern,mahogany_demon_lectern" + } +} diff --git a/game/src/main/kotlin/content/skill/construction/HouseThroneRoom.kt b/game/src/main/kotlin/content/skill/construction/HouseThroneRoom.kt new file mode 100644 index 0000000000..67f252b9a9 --- /dev/null +++ b/game/src/main/kotlin/content/skill/construction/HouseThroneRoom.kt @@ -0,0 +1,27 @@ +package content.skill.construction + +import content.skill.construction.House.Companion.notImplemented +import world.gregs.voidps.engine.Script + +/** + * Throne room levers for triggering traps and the trapdoors leading down to the oubliette + */ +class HouseThroneRoom : Script { + init { + objectOperate("Pull", LEVERS) { + notImplemented() + } + + objectOperate("Challenge-mode", LEVERS) { + notImplemented() + } + + objectOperate("Open", "oak_trapdoor,teak_trapdoor,mahogany_trapdoor") { + notImplemented() + } + } + + companion object { + private const val LEVERS = "oak_lever,teak_lever,mahogany_lever" + } +} diff --git a/game/src/main/kotlin/content/skill/construction/HouseWorkshop.kt b/game/src/main/kotlin/content/skill/construction/HouseWorkshop.kt new file mode 100644 index 0000000000..75c6371530 --- /dev/null +++ b/game/src/main/kotlin/content/skill/construction/HouseWorkshop.kt @@ -0,0 +1,36 @@ +package content.skill.construction + +import content.skill.construction.House.Companion.notImplemented +import world.gregs.voidps.engine.Script + +/** + * Workshop furniture: workbenches for making flatpacks, clockmaker's benches, the helmet pluming stand and heraldry easels + */ +class HouseWorkshop : Script { + init { + objectOperate("Work-at", WORKBENCHES) { + notImplemented() + } + + objectOperate("Upgrade", UPGRADABLE) { + notImplemented() + } + + objectOperate("Craft", "crafting_table_*") { + notImplemented() + } + + objectOperate("Make-helmet", "pluming_stand") { + notImplemented() + } + + objectOperate("Use", "shield_easel,banner_easel") { + notImplemented() + } + } + + companion object { + private const val WORKBENCHES = "wooden_workbench,oak_workbench,steel_framed_bench,bench_with_vice,bench_with_lathe" + private const val UPGRADABLE = "steel_framed_bench,bench_with_vice,crafting_table_1,crafting_table_2,crafting_table_3,tool_store_1,tool_store_2,tool_store_3,tool_store_4" + } +} diff --git a/game/src/main/kotlin/content/skill/construction/PortalChamber.kt b/game/src/main/kotlin/content/skill/construction/PortalChamber.kt index 9be90aa085..9963887eba 100644 --- a/game/src/main/kotlin/content/skill/construction/PortalChamber.kt +++ b/game/src/main/kotlin/content/skill/construction/PortalChamber.kt @@ -5,6 +5,7 @@ import content.skill.construction.House.Companion.furnishRoom import content.skill.construction.House.Companion.houseBase import content.skill.construction.House.Companion.houseFurniture import content.skill.construction.House.Companion.inOwnHouse +import content.skill.construction.House.Companion.notImplemented import content.skill.construction.House.Companion.roomPosition import content.skill.construction.House.Companion.roomZone import content.skill.construction.HouseFurniture.Companion.pick @@ -35,6 +36,10 @@ class PortalChamber : Script { Teleport.teleport(this, spell, "modern") } + objectOperate("Scry", "scrying_pool") { + notImplemented() + } + objectOperate("Direct-portal", FOCUSES) { (target) -> val base = houseBase() ?: return@objectOperate if (!inOwnHouse()) { diff --git a/game/src/test/kotlin/content/skill/construction/HouseFurnitureTest.kt b/game/src/test/kotlin/content/skill/construction/HouseFurnitureTest.kt index fd65deee5d..a859d0e4c2 100644 --- a/game/src/test/kotlin/content/skill/construction/HouseFurnitureTest.kt +++ b/game/src/test/kotlin/content/skill/construction/HouseFurnitureTest.kt @@ -8,6 +8,9 @@ import dialogueOption import itemOnObject import objectOption import org.junit.jupiter.api.Test +import world.gregs.voidps.engine.data.definition.ObjectDefinitions +import world.gregs.voidps.engine.data.definition.Tables +import world.gregs.voidps.engine.entity.Operation import world.gregs.voidps.engine.entity.character.player.skill.Skill import world.gregs.voidps.engine.entity.obj.GameObjects import world.gregs.voidps.engine.inv.add @@ -263,4 +266,34 @@ class HouseFurnitureTest : WorldTest() { assertEquals("It's five to Rune.", clockTime(55)) assertEquals("It's Rune o'clock.", clockTime(58)) } + + @Test + fun `Every furniture option has an interaction`() { + val missing = mutableListOf() + for (row in Tables.get("house_furniture").rows()) { + for (id in Tables.objList("house_furniture.${row.rowId}.objects")) { + val options = ObjectDefinitions.get(id).options ?: continue + for (option in options.filterNotNull()) { + if (option == "Examine" || option == "Remove") { + continue + } + if (!Operation.playerObject.containsKey("$option:$id") && !Operation.playerObject.containsKey("$option:*")) { + missing.add("$option:$id") + } + } + } + } + assertTrue(missing.isEmpty(), missing.toString()) + } + + @Test + fun `Unimplemented furniture sends a message`() { + val player = createPlayer(emptyTile) + val organ = createObject("organ", emptyTile.addY(1)) + + player.objectOption(organ, "Play") + tick() + + assertTrue(player.containsMessage("Not yet implemented.")) + } } From 931c834d726c010b51c69e0b7595a71d82acc839 Mon Sep 17 00:00:00 2001 From: GregHib Date: Fri, 2 Oct 2026 19:58:15 +0100 Subject: [PATCH 26/28] Fix flower locations --- .../skill/construction/construction.objs.toml | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/data/skill/construction/construction.objs.toml b/data/skill/construction/construction.objs.toml index ba18ea678b..f196cafd52 100644 --- a/data/skill/construction/construction.objs.toml +++ b/data/skill/construction/construction.objs.toml @@ -1834,40 +1834,40 @@ id = 13476 id = 13475 [sunflower] -id = 13443 +id = 13446 [big_sunflower] -id = 13446 +id = 13443 [marigolds] -id = 13444 +id = 13447 [big_marigolds] -id = 13447 +id = 13444 [roses] -id = 13445 +id = 13448 [big_roses] -id = 13448 +id = 13445 [rosemary] -id = 13437 +id = 13440 [big_rosemary] -id = 13440 +id = 13437 [daffodils] -id = 13438 +id = 13441 [big_daffodils] -id = 13441 +id = 13438 [bluebells] -id = 13439 +id = 13442 [big_bluebells] -id = 13442 +id = 13439 [oak_throne] id = 13665 From dce3a99dcb05fccbbe8421e84c56873951bf1eca Mon Sep 17 00:00:00 2001 From: GregHib Date: Fri, 2 Oct 2026 19:58:28 +0100 Subject: [PATCH 27/28] Add walls instead of windows --- .../content/skill/construction/House.kt | 70 ++++++++++++++--- .../skill/construction/RoomCreation.kt | 4 +- .../content/skill/construction/HouseTest.kt | 78 +++++++++++++++++++ 3 files changed, 138 insertions(+), 14 deletions(-) diff --git a/game/src/main/kotlin/content/skill/construction/House.kt b/game/src/main/kotlin/content/skill/construction/House.kt index 55e1bac282..29866c3488 100644 --- a/game/src/main/kotlin/content/skill/construction/House.kt +++ b/game/src/main/kotlin/content/skill/construction/House.kt @@ -285,16 +285,54 @@ class House : Script { val level = roomLevel(position) val wall = Tables.obj("house_styles.$style.wall") val window = if (level == DUNGEON_LEVEL) wall else Tables.obj("house_styles.$style.window") - // Upstairs doorways without a room on the other side would lead out onto the roofs val positions = houseRoomPositions - val doorWall = if (level > GROUND_LEVEL) wall else null - decorate(zone, window, doorWall, buildMode) { side -> roomPosition(base, zone.add(side)) in positions } + val neighbours = Direction.cardinal.associateWith { side -> roomPosition(base, zone.add(side))?.takeIf { it in positions } } + decorate( + zone, + buildMode, + wall, + window = { side -> + // Windows only look out onto gardens, roofs and empty spaces + val neighbour = neighbours[side] + if (neighbour == null || Tables.bool("house_rooms.${houseRoom(neighbour)}.outdoor")) window else wall + }, + door = { side -> + val neighbour = neighbours[side] + when { + neighbour != null -> if (side.inverse() in doorways(neighbour)) null else wall + // Upstairs doorways without a room on the other side would lead out onto the roofs + level > GROUND_LEVEL -> wall + else -> null + } + }, + ) } /** - * Side of the room [tile] is a door on + * Sides of the room at [position] which have doorways, turned the way the room is rotated */ - fun doorSide(tile: Tile): Direction { + private fun Player.doorways(position: Int): Set { + val index = houseRoomPositions.indexOf(position) + if (index == -1) { + return emptySet() + } + val template = roomTemplate(houseRoomIds[index], position) + val turns = houseRoomRotations[index] * 2 + val sides = mutableSetOf() + for (tile in template.toCuboid()) { + for (obj in GameObjects.at(tile)) { + if (obj.id.startsWith("door_hotspot")) { + sides.add(roomSide(obj.tile).rotate(turns)) + } + } + } + return sides + } + + /** + * Side of the room the wall, door or window on [tile] is on + */ + fun roomSide(tile: Tile): Direction { val zone = tile.zone.tile return when { tile.x == zone.x -> Direction.WEST @@ -439,16 +477,22 @@ class House : Script { } /** - * Fills the window spaces in [zone] and removes any hotspots outside of [buildMode]. - * Doorways on a side without a [connected] room are filled with [doorWall] when there is one. + * Fills the window spaces in [zone] with the [window] for their side and removes any hotspots outside of [buildMode], + * doorways are filled with the [door] wall for their side, or left open when there isn't one. + * Window spaces filled with [wall] lose any curtains, or curtain hotspots, hung on them. */ - private fun decorate(zone: Zone, window: String, doorWall: String?, buildMode: Boolean, connected: (Direction) -> Boolean) { + private fun decorate(zone: Zone, buildMode: Boolean, wall: String, window: (Direction) -> String, door: (Direction) -> String?) { for (tile in zone.toCuboid()) { - for (obj in GameObjects.at(tile)) { + val objects = GameObjects.at(tile) + val walled = objects.any { it.id == "house_window_space" && window(roomSide(it.tile)) == wall } + for (obj in objects) { if (obj.id == "house_window_space") { - obj.replace(window) - } else if (!buildMode && doorWall != null && obj.id.startsWith("door_hotspot") && !connected(doorSide(obj.tile))) { - obj.replace(doorWall) + obj.replace(window(roomSide(obj.tile))) + } else if (walled && (obj.id in curtains || obj.id.endsWith("_curtain_space"))) { + obj.remove() + } else if (!buildMode && obj.id.startsWith("door_hotspot")) { + val wall = door(roomSide(obj.tile)) + if (wall == null) obj.remove() else obj.replace(wall) } else if (!buildMode && obj.def.containsOption("Build")) { obj.remove() } @@ -466,6 +510,8 @@ class House : Script { private const val BLOCKED = CollisionFlag.FLOOR or CollisionFlag.FLOOR_DECORATION or CollisionFlag.OBJECT + private val curtains = setOf("torn_curtains", "curtains", "opulent_curtains") + /** * Placeholder for furniture interactions which haven't been added yet */ diff --git a/game/src/main/kotlin/content/skill/construction/RoomCreation.kt b/game/src/main/kotlin/content/skill/construction/RoomCreation.kt index d2e1b71b33..7075f1b7da 100644 --- a/game/src/main/kotlin/content/skill/construction/RoomCreation.kt +++ b/game/src/main/kotlin/content/skill/construction/RoomCreation.kt @@ -7,7 +7,6 @@ import content.skill.construction.House.Companion.GROUND_LEVEL import content.skill.construction.House.Companion.HOUSE_CENTRE import content.skill.construction.House.Companion.addHouseRoom import content.skill.construction.House.Companion.connectStairs -import content.skill.construction.House.Companion.doorSide import content.skill.construction.House.Companion.exitPortals import content.skill.construction.House.Companion.houseBase import content.skill.construction.House.Companion.houseRoom @@ -19,6 +18,7 @@ import content.skill.construction.House.Companion.removeHouseRoom import content.skill.construction.House.Companion.roomBelow import content.skill.construction.House.Companion.roomLevel import content.skill.construction.House.Companion.roomPosition +import content.skill.construction.House.Companion.roomSide import content.skill.construction.House.Companion.roomTemplate import content.skill.construction.House.Companion.roomZone import world.gregs.voidps.engine.Script @@ -44,7 +44,7 @@ class RoomCreation(val dynamicZones: DynamicZones) : Script { } val zone = target.tile.zone // Doors lead to the room on the opposite side to the player - val room = if (tile.zone == zone) zone.add(doorSide(target.tile)) else zone + val room = if (tile.zone == zone) zone.add(roomSide(target.tile)) else zone val position = roomPosition(base, room) val index = houseRoomPositions.indexOf(position ?: -1) if (index != -1) { diff --git a/game/src/test/kotlin/content/skill/construction/HouseTest.kt b/game/src/test/kotlin/content/skill/construction/HouseTest.kt index e8bc5f3d8d..c2f5bdd97d 100644 --- a/game/src/test/kotlin/content/skill/construction/HouseTest.kt +++ b/game/src/test/kotlin/content/skill/construction/HouseTest.kt @@ -804,4 +804,82 @@ class HouseTest : WorldTest() { assertEquals(exit, owner.tile) assertEquals(exit, guest.tile) } + + @Test + fun `Windows joining another room are walls`() { + val player = createOwner() + player.addHouseRoom("parlour", roomPosition(4, 3, GROUND_LEVEL)) + player.addHouseRoom("kitchen", roomPosition(5, 3, GROUND_LEVEL)) + + player.enterPortal(1) + + val parlour = roomZone(player.instance()!!.tile.zone, roomPosition(4, 3, GROUND_LEVEL)).tile + // Kitchen to the east + assertNotNull(GameObjects.findOrNull(parlour.add(7, 2), "basic_wood_wall")) + assertNotNull(GameObjects.findOrNull(parlour.add(7, 5), "basic_wood_wall")) + // Garden to the west + assertNotNull(GameObjects.findOrNull(parlour.add(0, 2), "basic_wood_window")) + // Empty to the north + assertNotNull(GameObjects.findOrNull(parlour.add(2, 7), "basic_wood_window")) + } + + @Test + fun `Curtains are only hung on windows`() { + val player = createOwner() + player.addHouseRoom("parlour", roomPosition(4, 3, GROUND_LEVEL)) + player.addHouseFurniture(roomPosition(4, 3, GROUND_LEVEL), "parlour_curtain_space", "curtains") + player.addHouseRoom("kitchen", roomPosition(5, 3, GROUND_LEVEL)) + + player.enterPortal(1) + + val parlour = roomZone(player.instance()!!.tile.zone, roomPosition(4, 3, GROUND_LEVEL)) + val curtains = parlour.toCuboid().filter { GameObjects.at(it).any { obj -> obj.id == "curtains" } } + assertEquals(6, curtains.size) + assertTrue(curtains.none { it.x == parlour.tile.x + 7 }) + } + + @Test + fun `Curtain spaces are only on windows in building mode`() { + val player = createOwner() + player.addHouseRoom("parlour", roomPosition(4, 3, GROUND_LEVEL)) + player.addHouseRoom("kitchen", roomPosition(5, 3, GROUND_LEVEL)) + + player.enterPortal(2) + + val parlour = roomZone(player.instance()!!.tile.zone, roomPosition(4, 3, GROUND_LEVEL)) + val spaces = parlour.toCuboid().filter { GameObjects.at(it).any { obj -> obj.id == "parlour_curtain_space" } } + assertEquals(6, spaces.size) + assertTrue(spaces.none { it.x == parlour.tile.x + 7 }) + } + + @Test + fun `Doorways joining a room without a doorway are walls`() { + val player = createOwner() + player.addHouseRoom("parlour", roomPosition(4, 3, GROUND_LEVEL)) + // Kitchen turned so its doorways face north and east + player.addHouseRoom("kitchen", roomPosition(5, 3, GROUND_LEVEL), rotation = 2) + + player.enterPortal(1) + + val parlour = roomZone(player.instance()!!.tile.zone, roomPosition(4, 3, GROUND_LEVEL)).tile + assertNotNull(GameObjects.findOrNull(parlour.add(7, 3), "basic_wood_wall")) + assertNotNull(GameObjects.findOrNull(parlour.add(7, 4), "basic_wood_wall")) + // Garden doorways join on every side + assertNull(GameObjects.findOrNull(parlour.add(0, 3)) { it.id == "basic_wood_wall" || it.id.startsWith("door_hotspot") }) + } + + @Test + fun `Doorways joining a room with a doorway are open`() { + val player = createOwner() + player.addHouseRoom("parlour", roomPosition(4, 3, GROUND_LEVEL)) + player.addHouseRoom("kitchen", roomPosition(5, 3, GROUND_LEVEL)) + + player.enterPortal(1) + + val parlour = roomZone(player.instance()!!.tile.zone, roomPosition(4, 3, GROUND_LEVEL)).tile + val kitchen = roomZone(player.instance()!!.tile.zone, roomPosition(5, 3, GROUND_LEVEL)).tile + for (tile in listOf(parlour.add(7, 3), parlour.add(7, 4), kitchen.add(0, 3), kitchen.add(0, 4))) { + assertNull(GameObjects.findOrNull(tile) { it.id == "basic_wood_wall" || it.id.startsWith("door_hotspot") }, tile.toString()) + } + } } From 72bd5c0761fe5a44246c9620a59e8a26a6012b45 Mon Sep 17 00:00:00 2001 From: GregHib Date: Fri, 2 Oct 2026 20:05:46 +0100 Subject: [PATCH 28/28] Add toggle for disabling building resource requirements --- .../skill/construction/FurnitureCreation.kt | 13 ++++++-- .../content/skill/construction/House.kt | 19 ++++++++++- .../skill/construction/RoomCreation.kt | 12 ++++--- game/src/main/resources/game.properties | 5 +++ .../construction/FurnitureCreationTest.kt | 32 +++++++++++++++++++ .../content/skill/construction/HouseTest.kt | 19 +++++++++++ 6 files changed, 91 insertions(+), 9 deletions(-) diff --git a/game/src/main/kotlin/content/skill/construction/FurnitureCreation.kt b/game/src/main/kotlin/content/skill/construction/FurnitureCreation.kt index 8816159d25..301caa9b56 100644 --- a/game/src/main/kotlin/content/skill/construction/FurnitureCreation.kt +++ b/game/src/main/kotlin/content/skill/construction/FurnitureCreation.kt @@ -5,6 +5,7 @@ import content.skill.construction.House.Companion.GROUND_LEVEL import content.skill.construction.House.Companion.addHouseFurniture import content.skill.construction.House.Companion.connectStairs import content.skill.construction.House.Companion.exitPortals +import content.skill.construction.House.Companion.freeBuild import content.skill.construction.House.Companion.furnishRoom import content.skill.construction.House.Companion.hotspot import content.skill.construction.House.Companion.houseBase @@ -131,7 +132,7 @@ class FurnitureCreation : Script { interfaces.sendText("furniture_creation", "material_${slot}_$line", if (item == null) "" else "${item.amount} ${item.def.name}") } interfaces.sendText("furniture_creation", "level_$slot", if (row == null) "" else "Level ${row.int("level")}") - set("furniture_creation_hide_cross_$slot", row == null || (has(Skill.Construction, row.int("level")) && inventory.contains(materials))) + set("furniture_creation_hide_cross_$slot", row == null || freeBuild || (has(Skill.Construction, row.int("level")) && inventory.contains(materials))) } private suspend fun Player.build(target: GameObject, furniture: String) { @@ -171,7 +172,7 @@ class FurnitureCreation : Script { if (!canBuild(row)) { return } - if (!inventory.remove(materials(row))) { + if (!freeBuild && !inventory.remove(materials(row))) { message("You don't have the right materials.") // TODO proper message return } @@ -187,10 +188,16 @@ class FurnitureCreation : Script { placeFurniture(target.tile.zone, hotspot, furniture) } anim("construction_build") - exp(Skill.Construction, row.int("xp") / 10.0) + // Free building doesn't give experience so it can't be used for training + if (!freeBuild) { + exp(Skill.Construction, row.int("xp") / 10.0) + } } private fun Player.canBuild(row: RowDefinition): Boolean { + if (freeBuild) { + return true + } if (!has(Skill.Construction, row.int("level"), message = true)) { return false } diff --git a/game/src/main/kotlin/content/skill/construction/House.kt b/game/src/main/kotlin/content/skill/construction/House.kt index 29866c3488..cce0414c40 100644 --- a/game/src/main/kotlin/content/skill/construction/House.kt +++ b/game/src/main/kotlin/content/skill/construction/House.kt @@ -9,11 +9,13 @@ import world.gregs.voidps.engine.client.message import world.gregs.voidps.engine.client.ui.close import world.gregs.voidps.engine.client.ui.hasOpen import world.gregs.voidps.engine.client.ui.open +import world.gregs.voidps.engine.data.Settings import world.gregs.voidps.engine.data.definition.Rows import world.gregs.voidps.engine.data.definition.Tables import world.gregs.voidps.engine.entity.character.move.tele import world.gregs.voidps.engine.entity.character.player.Player import world.gregs.voidps.engine.entity.character.player.Players +import world.gregs.voidps.engine.entity.character.player.skill.Skill import world.gregs.voidps.engine.entity.obj.GameObject import world.gregs.voidps.engine.entity.obj.GameObjects import world.gregs.voidps.engine.entity.obj.remove @@ -64,6 +66,18 @@ class House : Script { // Staircase placed in the dungeon room below a dungeon entrance so it leads back up private const val ENTRANCE_STAIRS = "oak_staircase" + /** + * Whether furniture and rooms can be built without levels, tools, materials or coins + */ + val freeBuild: Boolean + get() = Settings["construction.freeBuild", false] + + /** + * Construction level used for building requirements + */ + val Player.buildLevel: Int + get() = if (freeBuild) 99 else levels.get(Skill.Construction) + val Player.houseRoomIds: List get() = get("house_room_ids") ?: emptyList() @@ -493,7 +507,7 @@ class House : Script { } else if (!buildMode && obj.id.startsWith("door_hotspot")) { val wall = door(roomSide(obj.tile)) if (wall == null) obj.remove() else obj.replace(wall) - } else if (!buildMode && obj.def.containsOption("Build")) { + } else if (!buildMode && (obj.def.containsOption("Build") || obj.def.name == HABITAT_FLOOR)) { obj.remove() } } @@ -512,6 +526,9 @@ class House : Script { private val curtains = setOf("torn_curtains", "curtains", "opulent_curtains") + // Name of the menagerie's floor placeholders, one per tile without any options so aren't caught as hotspots + private const val HABITAT_FLOOR = "Habitat space" + /** * Placeholder for furniture interactions which haven't been added yet */ diff --git a/game/src/main/kotlin/content/skill/construction/RoomCreation.kt b/game/src/main/kotlin/content/skill/construction/RoomCreation.kt index 7075f1b7da..e4e420aacc 100644 --- a/game/src/main/kotlin/content/skill/construction/RoomCreation.kt +++ b/game/src/main/kotlin/content/skill/construction/RoomCreation.kt @@ -6,8 +6,10 @@ import content.skill.construction.House.Companion.DUNGEON_LEVEL import content.skill.construction.House.Companion.GROUND_LEVEL import content.skill.construction.House.Companion.HOUSE_CENTRE import content.skill.construction.House.Companion.addHouseRoom +import content.skill.construction.House.Companion.buildLevel import content.skill.construction.House.Companion.connectStairs import content.skill.construction.House.Companion.exitPortals +import content.skill.construction.House.Companion.freeBuild import content.skill.construction.House.Companion.houseBase import content.skill.construction.House.Companion.houseRoom import content.skill.construction.House.Companion.houseRoomIds @@ -93,7 +95,7 @@ class RoomCreation(val dynamicZones: DynamicZones) : Script { * Sizes grow from the south-west, so even sizes extend one further north and east of the [HOUSE_CENTRE] */ private fun Player.buildRange(): IntRange { - val size = 3 + sizeLevels.count { levels.get(Skill.Construction) >= it } + val size = 3 + sizeLevels.count { buildLevel >= it } return HOUSE_CENTRE - (size - 1) / 2..HOUSE_CENTRE + size / 2 } @@ -179,7 +181,7 @@ class RoomCreation(val dynamicZones: DynamicZones) : Script { * Whether the players level allows building another room */ fun Player.hasRoomSpace(): Boolean { - if (houseRoomIds.size >= 20 + roomLevels.count { levels.get(Skill.Construction) >= it }) { + if (houseRoomIds.size >= 20 + roomLevels.count { buildLevel >= it }) { message("You need a higher Construction level to build any more rooms.") // TODO proper messages return false } @@ -191,7 +193,7 @@ class RoomCreation(val dynamicZones: DynamicZones) : Script { */ fun Player.canBuildRoom(room: String, position: Int): Boolean { val row = Rows.getOrNull("house_rooms.$room") ?: return false - if (!has(Skill.Construction, row.int("level"), message = true)) { + if (!freeBuild && !has(Skill.Construction, row.int("level"), message = true)) { return false } val dungeon = row.bool("dungeon") @@ -207,7 +209,7 @@ class RoomCreation(val dynamicZones: DynamicZones) : Script { message("That room can only be built on the ground floor.") // TODO proper messages return false } - if (inventory.count("coins") < row.int("cost")) { + if (!freeBuild && inventory.count("coins") < row.int("cost")) { message("You need ${row.int("cost")} coins to build this room.") // TODO proper messages return false } @@ -218,7 +220,7 @@ class RoomCreation(val dynamicZones: DynamicZones) : Script { * Pays for and builds [room] at [position] connecting any stairs above or below, then reloads the house */ fun Player.buildRoom(base: Zone, room: String, position: Int, rotation: Int) { - if (inventory.remove("coins", Rows.get("house_rooms.$room").int("cost"))) { + if (freeBuild || inventory.remove("coins", Rows.get("house_rooms.$room").int("cost"))) { addHouseRoom(room, position, rotation) connectStairs(position) } else { diff --git a/game/src/main/resources/game.properties b/game/src/main/resources/game.properties index 06f250cb95..e2128156eb 100644 --- a/game/src/main/resources/game.properties +++ b/game/src/main/resources/game.properties @@ -255,6 +255,11 @@ fletching.moreArrowShafts=false # 1 in X chance of pickpocketing double, triple or quadruple loot level depending thieving.pickpocket.multiChance=10 +#------- Construction ------- + +# Build furniture and rooms without needing levels, tools, materials or coins (gives no experience) +construction.freeBuild=false + #------- Dungeoneering ------- # Countdown time before party starts new dungeon diff --git a/game/src/test/kotlin/content/skill/construction/FurnitureCreationTest.kt b/game/src/test/kotlin/content/skill/construction/FurnitureCreationTest.kt index dc327b919e..c51626a8c4 100644 --- a/game/src/test/kotlin/content/skill/construction/FurnitureCreationTest.kt +++ b/game/src/test/kotlin/content/skill/construction/FurnitureCreationTest.kt @@ -10,6 +10,7 @@ import content.skill.construction.House.Companion.addHouseRoom import content.skill.construction.House.Companion.houseFurnitureHotspots import content.skill.construction.House.Companion.houseFurnitureIds import content.skill.construction.House.Companion.houseFurnitureRooms +import content.skill.construction.House.Companion.houseRoomIds import content.skill.construction.House.Companion.houseRoomPositions import content.skill.construction.House.Companion.removeHouseRoom import content.skill.construction.House.Companion.roomZone @@ -19,6 +20,7 @@ import objectOption import org.junit.jupiter.api.Test import world.gregs.voidps.engine.client.ui.dialogue import world.gregs.voidps.engine.client.ui.hasOpen +import world.gregs.voidps.engine.data.Settings import world.gregs.voidps.engine.data.definition.Tables import world.gregs.voidps.engine.entity.character.move.tele import world.gregs.voidps.engine.entity.character.player.Player @@ -442,4 +444,34 @@ class FurnitureCreationTest : WorldTest() { assertEquals(listOf(START_ROOM + 1), player.houseFurnitureRooms) assertEquals(listOf("pond"), player.houseFurnitureIds) } + + @Test + fun `Free building doesn't need levels, tools or materials`() { + Settings.load(mapOf("construction.freeBuild" to "true")) + val player = createBuilder() + player.inventory.remove("hammer") + player.inventory.remove("saw") + player.enterPortal(2) + + player.build("parlour_chair_space", "mahogany_armchair") + + assertEquals(1, player.objects("mahogany_armchair").size) + assertEquals(listOf("mahogany_armchair"), player.houseFurnitureIds) + assertEquals(0.0, player.experience.get(Skill.Construction)) + } + + @Test + fun `Free building builds rooms without levels or coins`() { + Settings.load(mapOf("construction.freeBuild" to "true")) + val player = createBuilder("garden") + player.enterPortal(2) + val base = player.instance()!!.tile.zone + + player.objectOption(GameObjects.find(base.add(4, 4, GROUND_LEVEL).tile.add(7, 3)) { it.id.startsWith("door_hotspot") }, "Build") + tickIf { !player.hasOpen("room_creation") } + player.interfaceOption("room_creation", "throne_room", "Build") + player.dialogueOption("line3") + + assertEquals("throne_room", player.houseRoomIds.last()) + } } diff --git a/game/src/test/kotlin/content/skill/construction/HouseTest.kt b/game/src/test/kotlin/content/skill/construction/HouseTest.kt index c2f5bdd97d..f3c40a2e19 100644 --- a/game/src/test/kotlin/content/skill/construction/HouseTest.kt +++ b/game/src/test/kotlin/content/skill/construction/HouseTest.kt @@ -882,4 +882,23 @@ class HouseTest : WorldTest() { assertNull(GameObjects.findOrNull(tile) { it.id == "basic_wood_wall" || it.id.startsWith("door_hotspot") }, tile.toString()) } } + + @Test + fun `Menagerie habitat floor spaces only show in building mode`() { + val building = createOwner("builder") + building.addHouseRoom("menagerie", roomPosition(4, 3, GROUND_LEVEL)) + building.enterPortal(2) + val visiting = createOwner("visitor") + visiting.addHouseRoom("menagerie", roomPosition(4, 3, GROUND_LEVEL)) + visiting.enterPortal(1) + + assertEquals(64, building.habitatFloor().size) + assertTrue(visiting.habitatFloor().isEmpty()) + } + + private fun Player.habitatFloor() = roomZone(instance()!!.tile.zone, roomPosition(4, 3, GROUND_LEVEL)).toCuboid() + .flatMap { GameObjects.at(it) } + .filter { it.def.name == "Habitat space" } + .map { it.tile } + .distinct() }