diff --git a/.claude/code-style.md b/.claude/code-style.md index ea9e3eae8e..7082eef7db 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. @@ -18,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`). 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..02256da249 --- /dev/null +++ b/data/skill/construction/construction.anims.toml @@ -0,0 +1,128 @@ +[construction_build] +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 + +# 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.ifaces.toml b/data/skill/construction/construction.ifaces.toml new file mode 100644 index 0000000000..c9af35e5f5 --- /dev/null +++ b/data/skill/construction/construction.ifaces.toml @@ -0,0 +1,235 @@ +[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 = "wide_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 + +[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 new file mode 100644 index 0000000000..f196cafd52 --- /dev/null +++ b/data/skill/construction/construction.objs.toml @@ -0,0 +1,2188 @@ +[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 + +[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_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 + +# 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." + +[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 + +[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 + +[basic_wood_wall] +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 + +[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 + +[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 + +[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 + +[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 + +[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 = 13446 + +[big_sunflower] +id = 13443 + +[marigolds] +id = 13447 + +[big_marigolds] +id = 13444 + +[roses] +id = 13448 + +[big_roses] +id = 13445 + +[rosemary] +id = 13440 + +[big_rosemary] +id = 13437 + +[daffodils] +id = 13441 + +[big_daffodils] +id = 13438 + +[bluebells] +id = 13442 + +[big_bluebells] +id = 13439 + +[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 new file mode 100644 index 0000000000..c1a7ec305a --- /dev/null +++ b/data/skill/construction/construction.tables.toml @@ -0,0 +1,3922 @@ +# 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 +cost = 1000 +template = { x = 1856, y = 5112 } + +[.garden] +level = 1 +cost = 1000 +template = { x = 1856, y = 5064 } +outdoor = true + +[.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 } +upper = { x = 1880, y = 5104 } +stairs = "skill_hall_stair_space" +stairs_down = "skill_hall_stair_space_down" + +[.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 } +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 +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 } +outdoor = true + +[.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 } +stairs = "skill_hall_stair_space" +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 + +# 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" +build = "tile" + +[.land] +template = { x = 1864, y = 5056 } + +[.roof] +template = { x = 1888, y = 5064 } + +# 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 } + +# 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" + +[.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 +# 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] +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_fireplace_space] +furniture = ["clay_fireplace", "stone_fireplace", "marble_fireplace"] + +[.parlour_curtain_space] +furniture = ["torn_curtains", "curtains", "opulent_curtains"] + +[.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"] +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"] + +[.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" +xp = "int" +materials = "list" +amounts = "list" +nails = "int" +objects = "list" +pieces = "list" +anchors = "list" +rotations = "list" +variant = "string" + +[.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"] + +[.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", "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", "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", "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 +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"] + +# 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" + +# 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" + +[.marble_fireplace] +clone = "clay_fireplace" +lit = "marble_fireplace_lit" + +# 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.varbits.toml b/data/skill/construction/construction.varbits.toml new file mode 100644 index 0000000000..6f0405a444 --- /dev/null +++ b/data/skill/construction/construction.varbits.toml @@ -0,0 +1,3 @@ +[house_build_mode] +id = 2176 +format = "boolean" 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.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 new file mode 100644 index 0000000000..8cbdf2d285 --- /dev/null +++ b/data/skill/construction/construction.vars.toml @@ -0,0 +1,48 @@ +[house_location] +format = "string" +persist = true + +[house_room_ids] +persist = true + +[house_room_positions] +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 + +[house_style] +format = "string" +persist = true +default = "basic_wood" + +# Family crest picked by heraldry, shown on built shields +[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/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/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..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) } @@ -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/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/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/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/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/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/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/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/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() 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)), 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) 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, 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..81dc4f3fa4 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,142 @@ 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.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 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 { + 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") + } + 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") + 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") + } + option("No thanks.") { + npc("Well enjoy your player-owned cardboard box or wherever you're going to sleep tonight!") + } + } + } + + 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/FurnitureCreation.kt b/game/src/main/kotlin/content/skill/construction/FurnitureCreation.kt new file mode 100644 index 0000000000..301caa9b56 --- /dev/null +++ b/game/src/main/kotlin/content/skill/construction/FurnitureCreation.kt @@ -0,0 +1,231 @@ +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.freeBuild +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.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 +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 +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 +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.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 + } + 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") + } + } + } + + 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 || freeBuild || (has(Skill.Construction, row.int("level")) && inventory.contains(materials))) + } + + 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 + } + val position = roomPosition(base, target.tile.zone) ?: return + val hotspot = hotspot(target) ?: return + val row = Rows.getOrNull("house_furniture.$furniture") ?: return + if (!canBuild(row)) { + return + } + 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 (!freeBuild && !inventory.remove(materials(row))) { + message("You don't have the right materials.") // TODO proper message + return + } + addHouseFurniture(position, 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 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) + } + anim("construction_build") + // 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 + } + 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 + */ + 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) + } + + 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 new file mode 100644 index 0000000000..cce0414c40 --- /dev/null +++ b/game/src/main/kotlin/content/skill/construction/House.kt @@ -0,0 +1,568 @@ +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.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 +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.Direction +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] and [UPPER_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 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" + + /** + * 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() + + val Player.houseRoomPositions: List + get() = get("house_room_positions") ?: emptyList() + + 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) + set("house_room_rotations", houseRoomRotations + rotation) + } + + /** + * 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")) + } + + /** + * 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 }) + 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], 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] }) + } + + /** + * 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], 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 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") ?: 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 } + 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") + + /** + * 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") + } + + /** + * 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. 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 + 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") + roomLevel(position) == DUNGEON_LEVEL && hasEntrance(above) -> ENTRANCE_STAIRS to Tables.objOrNull("house_rooms.$room.stairs") + 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 + */ + 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 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") + val positions = houseRoomPositions + 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 + } + }, + ) + } + + /** + * Sides of the room at [position] which have doorways, turned the way the room is rotated + */ + 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 + 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 + + 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) + + 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] + */ + 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 + + /** + * 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 + */ + fun Player.houseBase(): Zone? { + if (!contains("house_owner")) { + return null + } + 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 + } + + /** + * 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 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() + val entries = mutableListOf>() + val placed = mutableSetOf() + val ids = houseRoomIds + val rotations = houseRoomRotations + val positions = houseRoomPositions + for ((index, position) in positions.withIndex()) { + val zone = roomZone(base, position) + placed.add(zone) + 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)) + } + } + // 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, 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) { + for (y in 0 until HOUSE_SIZE) { + val zone = base.add(x, y, level) + if (zone !in placed && dynamicZones.dynamicZone(zone) != null) { + dynamicZones.clear(zone) + } + } + } + } + dynamicZones.copy(entries) + for (position in positions) { + furnishRoom(base, position, buildMode) + } + } + + /** + * 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] 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, buildMode: Boolean, wall: String, window: (Direction) -> String, door: (Direction) -> String?) { + for (tile in zone.toCuboid()) { + 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(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.def.name == HABITAT_FLOOR)) { + obj.remove() + } + } + } + } + + /** + * 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 + tele(free) + } + + private const val BLOCKED = CollisionFlag.FLOOR or CollisionFlag.FLOOR_DECORATION or CollisionFlag.OBJECT + + 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 + */ + fun Player.notImplemented() { + message("Not yet implemented.") // TODO + } + + 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/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/HouseFurniture.kt b/game/src/main/kotlin/content/skill/construction/HouseFurniture.kt new file mode 100644 index 0000000000..4b26218a9d --- /dev/null +++ b/game/src/main/kotlin/content/skill/construction/HouseFurniture.kt @@ -0,0 +1,147 @@ +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.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 and lamps 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", 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) + } + } + + objectOperate("Read", CLOCKS) { + message(clockTime(LocalTime.now(ZoneOffset.UTC).minute)) + } + } + + /** + * 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 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 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") + + /** + * 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/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/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..95c9372884 --- /dev/null +++ b/game/src/main/kotlin/content/skill/construction/HousePortal.kt @@ -0,0 +1,171 @@ +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.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 +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.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 +import world.gregs.voidps.engine.entity.character.player.Players +import world.gregs.voidps.engine.entity.character.player.Teleport +import world.gregs.voidps.engine.entity.obj.GameObjects +import world.gregs.voidps.type.Region +import world.gregs.voidps.type.Tile + +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.") + } + } + + 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) + } + + itemOption("Break", "teleport_to_house") { + teleportHome("tablet", it.item.id) + } + + teleportLand("modern") { + if (hasOpen("house_loading")) { + delay(1) + close("house_loading") + } + } + + teleportLand("tablet") { + if (hasOpen("house_loading")) { + 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 + } + closeInterfaces() + Teleport.teleport(this, type, spell, xp = xp, clearInterfaces = false) { + val tile = createHouse(buildMode = 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) + return arrival(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 + } + 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) + tele(arrival(owner, instance)) + houseLoading() + } + + /** + * 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) + 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(base, owner.houseRoomPositions[garden]).tile.add(3, 3) + } +} 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/main/kotlin/content/skill/construction/HouseStairs.kt b/game/src/main/kotlin/content/skill/construction/HouseStairs.kt new file mode 100644 index 0000000000..06c189df3f --- /dev/null +++ b/game/src/main/kotlin/content/skill/construction/HouseStairs.kt @@ -0,0 +1,176 @@ +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.hasEntrance +import content.skill.construction.House.Companion.houseBase +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 +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 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, or to remove the room they lead to. + */ +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) + // 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) -> + 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")) + } + + 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) + } + } + } + + /** + * 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) + // 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") + } + + /** + * 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) { + changeFloor(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) { + changeFloor(destination) + } + } + + 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" + + // 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/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 new file mode 100644 index 0000000000..9963887eba --- /dev/null +++ b/game/src/main/kotlin/content/skill/construction/PortalChamber.kt @@ -0,0 +1,105 @@ +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.notImplemented +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("Scry", "scrying_pool") { + notImplemented() + } + + 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/construction/RoomCreation.kt b/game/src/main/kotlin/content/skill/construction/RoomCreation.kt new file mode 100644 index 0000000000..e4e420aacc --- /dev/null +++ b/game/src/main/kotlin/content/skill/construction/RoomCreation.kt @@ -0,0 +1,238 @@ +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.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 +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.roomSide +import content.skill.construction.House.Companion.roomTemplate +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.Zone + +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 + // Doors lead to the room on the opposite side to the player + 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) { + removeRoom(houseRoomPositions[index], houseRoomIds[index]) + return@objectOperate + } + val range = buildRange() + if (room.x - base.x - 1 !in range) { + message("Your house is already at the maximum width.") + return@objectOperate + } + if (room.y - base.y - 1 !in range) { + message("Your house is already at the maximum height.") + return@objectOperate + } + 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 + } + 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") + } + + interfaceOption("Build", "room_creation:*") { + close("room_creation") + val position: Int = get("house_preview_position") ?: return@interfaceOption + if (!canBuildRoom(it.component, position)) { + return@interfaceOption + } + set("house_preview_room", it.component) + set("house_preview_rotation", 0) + walkTrigger { clearPreview() } + preview() + } + } + + /** + * 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.buildRange(): IntRange { + val size = 3 + sizeLevels.count { buildLevel >= it } + return HOUSE_CENTRE - (size - 1) / 2..HOUSE_CENTRE + size / 2 + } + + 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(roomTemplate(room, position), 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 + } + buildRoom(base, room, position, rotation) + } + + /** + * Remove the room being previewed + */ + private fun Player.clearPreview() { + clearWalkTrigger() + clear("house_preview_room") + clear("house_preview_rotation") + clear("house_preview_position") + val base = houseBase() ?: return + loadHouse(base, buildMode = true) + } + + 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 + */ + fun Player.hasRoomSpace(): Boolean { + 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 + } + 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 (!freeBuild && !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 (row.bool("outdoor") && roomLevel(position) > GROUND_LEVEL) { + message("That room can only be built on the ground floor.") // TODO proper messages + return false + } + if (!freeBuild && 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 (freeBuild || 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) + + // 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/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/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/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/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/FurnitureCreationTest.kt b/game/src/test/kotlin/content/skill/construction/FurnitureCreationTest.kt new file mode 100644 index 0000000000..c51626a8c4 --- /dev/null +++ b/game/src/test/kotlin/content/skill/construction/FurnitureCreationTest.kt @@ -0,0 +1,477 @@ +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.houseRoomIds +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.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 +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 `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() + 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) + } + + @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/HouseFurnitureTest.kt b/game/src/test/kotlin/content/skill/construction/HouseFurnitureTest.kt new file mode 100644 index 0000000000..a859d0e4c2 --- /dev/null +++ b/game/src/test/kotlin/content/skill/construction/HouseFurnitureTest.kt @@ -0,0 +1,299 @@ +package content.skill.construction + +import FakeRandom +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.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 +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 +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 `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(298) + 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) + 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)) + } + + @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.")) + } +} 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")) + } +} 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..f06d5066d8 --- /dev/null +++ b/game/src/test/kotlin/content/skill/construction/HouseStairsTest.kt @@ -0,0 +1,680 @@ +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.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 content.skill.construction.House.Companion.stairsDown +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.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.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 + +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() { + 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") + } + + 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 + 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_stairs", 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) + assertFalse(player.hasOpen("house_loading")) + } + + @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("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) + 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 + 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.hasOpen("house_loading")) + } + + @Test + fun `Climb down to the dungeon stairs and back up`() { + val player = createOwner() + player.addDungeonStairs() + player.enterPortal(1) + + player.use(player.find(hall, "oak_staircase_down"), "Climb-down") + tick(4) + + assertEquals(DUNGEON_LEVEL, player.tile.level) + assertFalse(player.hasOpen("house_loading")) + + player.use(player.find(dungeon, "oak_staircase"), "Climb-up") + + assertEquals(GROUND_LEVEL, player.tile.level) + assertFalse(player.hasOpen("house_loading")) + } + + @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.enterPortal(2) + + 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("line3") + 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() + 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("line4") + + 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 `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() + 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") + } + + @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)))) + } + + @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") }) + } +} 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..f3c40a2e19 --- /dev/null +++ b/game/src/test/kotlin/content/skill/construction/HouseTest.kt @@ -0,0 +1,904 @@ +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.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.leaveHouse +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.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 +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 +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) + player.addHouseFurniture(START_ROOM, "garden_centrepiece_space", "exit_portal") + 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, 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, obj: GameObject = GameObjects.find(portal, "house_portal_rimmington")) { + enterPortal(1, obj) + (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")) + } + + 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) + 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, 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") }) + } + + @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, 2), 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, 2), 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, 2), 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() + 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_preview_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("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 `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() + 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 room with the last exit portal`() { + 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("exit portal")) + 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 + 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")) + assertTrue(player.containsMessage("maximum width")) + + 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)) + } + + private fun Player.exitPortal(): GameObject { + val zone = roomZone(instance()!!.tile.zone, START_ROOM) + 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() + 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() + 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) + } + + @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()) + } + } + + @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() +} 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]) + } +}