diff --git a/server/block/furnace.v b/server/block/furnace.v new file mode 100644 index 0000000..e49e28d --- /dev/null +++ b/server/block/furnace.v @@ -0,0 +1,88 @@ +module block + +import server.world + +// FurnaceVariant is which of the three cooking blocks a furnace is. They +// differ only in how fast they cook and in what they will accept, which the +// session decides from here rather than from the block name. +pub enum FurnaceVariant { + furnace + blast_furnace + smoker +} + +// furnace_cook_ticks is how long this variant takes to cook one item. +pub fn (v FurnaceVariant) cook_ticks() int { + return if v == .furnace { furnace_cook_ticks } else { furnace_cook_ticks / 2 } +} + +pub const furnace_cook_ticks = 200 + +// furnace_slot_input, _fuel and _output are the three slots a furnace shows, +// in the order the client addresses them. +pub const furnace_slot_input = 0 +pub const furnace_slot_fuel = 1 +pub const furnace_slot_output = 2 +pub const furnace_slot_count = 3 + +const furnace_names = { + 'minecraft:furnace': FurnaceVariant.furnace + 'minecraft:blast_furnace': FurnaceVariant.blast_furnace + 'minecraft:smoker': FurnaceVariant.smoker +} + +// furnace_variant returns which cooking block an identifier names, lit or not. +pub fn furnace_variant(identifier string) ?FurnaceVariant { + name := identifier.replace('minecraft:lit_', 'minecraft:') + return furnace_names[name] or { return none } +} + +// furnace_lit_ids and furnace_unlit_ids map a furnace between its two block +// states without the caller having to know which way it faces. They are built +// once from the same names and directions the container blocks are registered +// with, so the two can never drift apart. +pub const furnace_lit_ids = build_furnace_ids(false) +pub const furnace_unlit_ids = build_furnace_ids(true) + +fn build_furnace_ids(from_lit bool) map[int]int { + mut out := map[int]int{} + for name, _ in furnace_names { + lit_name := name.replace('minecraft:', 'minecraft:lit_') + for direction in cardinal_directions { + unlit := furnace_state_id(name, direction) + lit := furnace_state_id(lit_name, direction) + if from_lit { + out[lit] = unlit + } else { + out[unlit] = lit + } + } + } + return out +} + +fn furnace_state_id(name string, direction string) int { + return world.new_block_with_states(name, [ + world.BlockState{ + key: 'minecraft:cardinal_direction' + kind: world.state_kind_string + string_val: direction + }, + ]).network_id +} + +// lit_furnace_id is the burning form of a furnace block, keeping the way it +// faces. It returns none for anything that is not an unlit furnace. +pub fn lit_furnace_id(block_id int) ?int { + return furnace_lit_ids[block_id] or { return none } +} + +// unlit_furnace_id is the reverse. +pub fn unlit_furnace_id(block_id int) ?int { + return furnace_unlit_ids[block_id] or { return none } +} + +// is_lit_furnace reports whether a furnace block is currently burning. +pub fn is_lit_furnace(block_id int) bool { + return block_id in furnace_unlit_ids +} diff --git a/server/block/furnace_test.v b/server/block/furnace_test.v new file mode 100644 index 0000000..e31e6f6 --- /dev/null +++ b/server/block/furnace_test.v @@ -0,0 +1,36 @@ +module block + +fn test_every_cooking_block_is_a_furnace() { + assert furnace_variant('minecraft:furnace')? == .furnace + assert furnace_variant('minecraft:blast_furnace')? == .blast_furnace + assert furnace_variant('minecraft:smoker')? == .smoker + // The burning form is the same block as far as behaviour goes. + assert furnace_variant('minecraft:lit_furnace')? == .furnace + assert furnace_variant('minecraft:lit_smoker')? == .smoker + if _ := furnace_variant('minecraft:chest') { + assert false, 'a chest reported itself as a furnace' + } +} + +fn test_the_specialised_blocks_cook_twice_as_fast() { + assert FurnaceVariant.furnace.cook_ticks() == furnace_cook_ticks + assert FurnaceVariant.blast_furnace.cook_ticks() == furnace_cook_ticks / 2 + assert FurnaceVariant.smoker.cook_ticks() == furnace_cook_ticks / 2 +} + +fn test_lighting_a_furnace_keeps_the_way_it_faces() { + for direction in cardinal_directions { + unlit := furnace_state_id('minecraft:furnace', direction) + lit := furnace_state_id('minecraft:lit_furnace', direction) + assert lit_furnace_id(unlit)? == lit + assert unlit_furnace_id(lit)? == unlit + assert is_lit_furnace(lit) + assert !is_lit_furnace(unlit) + } +} + +fn test_a_block_that_is_not_a_furnace_has_no_lit_form() { + if _ := lit_furnace_id(0) { + assert false, 'block 0 reported a lit form' + } +} diff --git a/server/item/smelting.v b/server/item/smelting.v new file mode 100644 index 0000000..f067a23 --- /dev/null +++ b/server/item/smelting.v @@ -0,0 +1,170 @@ +module item + +// SmeltingRecipe is one input turning into one output, and what cooking it is +// worth once the result is taken out. +// +// food and ores say which of the two faster cookers will take it: a smoker +// only cooks food and a blast furnace only ores while a plain furnace takes +// everything. +pub struct SmeltingRecipe { +pub: + input string + output string + experience f32 + food bool + ores bool +} + +// smelting_recipe returns what an item smelts into, or none when it does not +// smelt at all. +pub fn smelting_recipe(input string) ?SmeltingRecipe { + result, experience, food, ores := smelting_result(input) or { return none } + return SmeltingRecipe{ + input: input + output: result + experience: experience + food: food + ores: ores + } +} + +fn smelting_result(input string) ?(string, f32, bool, bool) { + return match input { + 'minecraft:raw_iron', 'minecraft:iron_ore', 'minecraft:deepslate_iron_ore' { + 'minecraft:iron_ingot', f32(0.7), false, true + } + 'minecraft:raw_gold', 'minecraft:gold_ore', 'minecraft:deepslate_gold_ore' { + 'minecraft:gold_ingot', f32(1.0), false, true + } + 'minecraft:raw_copper', 'minecraft:copper_ore', 'minecraft:deepslate_copper_ore' { + 'minecraft:copper_ingot', f32(0.7), false, true + } + 'minecraft:ancient_debris' { + 'minecraft:netherite_scrap', f32(2.0), false, true + } + 'minecraft:coal_ore', 'minecraft:deepslate_coal_ore' { + 'minecraft:coal', f32(0.1), false, true + } + 'minecraft:diamond_ore', 'minecraft:deepslate_diamond_ore' { + 'minecraft:diamond', f32(1.0), false, true + } + 'minecraft:emerald_ore', 'minecraft:deepslate_emerald_ore' { + 'minecraft:emerald', f32(1.0), false, true + } + 'minecraft:lapis_ore', 'minecraft:deepslate_lapis_ore' { + 'minecraft:lapis_lazuli', f32(0.2), false, true + } + 'minecraft:redstone_ore', 'minecraft:deepslate_redstone_ore' { + 'minecraft:redstone', f32(0.7), false, true + } + 'minecraft:nether_quartz_ore' { + 'minecraft:quartz', f32(0.2), false, true + } + 'minecraft:sand', 'minecraft:red_sand' { + 'minecraft:glass', f32(0.1), false, false + } + 'minecraft:cobblestone' { + 'minecraft:stone', f32(0.1), false, false + } + 'minecraft:stone' { + 'minecraft:smooth_stone', f32(0.1), false, false + } + 'minecraft:clay_ball' { + 'minecraft:brick', f32(0.3), false, false + } + 'minecraft:netherrack' { + 'minecraft:netherbrick', f32(0.1), false, false + } + 'minecraft:cactus' { + 'minecraft:green_dye', f32(1.0), false, false + } + 'minecraft:kelp' { + 'minecraft:dried_kelp', f32(0.1), true, false + } + 'minecraft:porkchop' { + 'minecraft:cooked_porkchop', f32(0.35), true, false + } + 'minecraft:beef' { + 'minecraft:cooked_beef', f32(0.35), true, false + } + 'minecraft:chicken' { + 'minecraft:cooked_chicken', f32(0.35), true, false + } + 'minecraft:mutton' { + 'minecraft:cooked_mutton', f32(0.35), true, false + } + 'minecraft:rabbit' { + 'minecraft:cooked_rabbit', f32(0.35), true, false + } + 'minecraft:cod' { + 'minecraft:cooked_cod', f32(0.35), true, false + } + 'minecraft:salmon' { + 'minecraft:cooked_salmon', f32(0.35), true, false + } + 'minecraft:potato' { + 'minecraft:baked_potato', f32(0.35), true, false + } + else { + none + } + } +} + +// wood_types are the tree materials whose worked forms burn. The suffix alone +// doesn't settle it: a stone, end stone or petrified oak slab is still a slab +// and none of them are fuel. We may change this check later! +const wood_types = ['oak', 'spruce', 'birch', 'jungle', 'acacia', 'dark_oak', 'mangrove', + 'cherry', 'pale_oak', 'bamboo', 'crimson', 'warped'] + +// wooden_burn_ticks is how long any worked piece of wood keeps a furnace lit. +const wooden_burn_ticks = 300 + +// Fuel is how long an item keeps a furnace lit and what it leaves in the fuel +// slot afterwards. +pub struct Fuel { +pub: + burn_ticks int + // residue is the item that replaces the burnt one or empty when the fuel + // is simply consumed. + residue string +} + +// fuel returns how an item burns or none when it doesn't burn at all. +pub fn fuel(name string) ?Fuel { + if is_wooden(name) && (name.ends_with('_planks') || name.ends_with('_log') + || name.ends_with('_wood') || name.ends_with('_slab') || name.ends_with('_sapling')) { + return Fuel{ + burn_ticks: wooden_burn_ticks + } + } + if name == 'minecraft:lava_bucket' { + return Fuel{ + burn_ticks: 20000 + residue: 'minecraft:bucket' + } + } + burn := match name { + 'minecraft:coal_block' { 16000 } + 'minecraft:blaze_rod' { 2400 } + 'minecraft:coal', 'minecraft:charcoal' { 1600 } + 'minecraft:dried_kelp_block' { 4000 } + 'minecraft:stick' { 100 } + else { return none } + } + return Fuel{ + burn_ticks: burn + } +} + +// is_wooden reports whether an identifier names something made of one of the +// tree materials. +fn is_wooden(name string) bool { + base := name.trim_string_left('minecraft:').trim_string_left('stripped_') + for wood in wood_types { + if base.starts_with(wood + '_') { + return true + } + } + return false +} diff --git a/server/item/smelting_test.v b/server/item/smelting_test.v new file mode 100644 index 0000000..b61f70e --- /dev/null +++ b/server/item/smelting_test.v @@ -0,0 +1,82 @@ +module item + +fn test_ores_smelt_into_ingots() { + recipe := smelting_recipe('minecraft:raw_iron') or { + assert false, 'raw iron does not smelt' + return + } + assert recipe.output == 'minecraft:iron_ingot' + assert recipe.experience > 0 +} + +fn test_food_cooks() { + for raw, cooked in { + 'minecraft:porkchop': 'minecraft:cooked_porkchop' + 'minecraft:beef': 'minecraft:cooked_beef' + 'minecraft:potato': 'minecraft:baked_potato' + } { + recipe := smelting_recipe(raw) or { + assert false, '${raw} does not cook' + continue + } + assert recipe.output == cooked + } +} + +fn test_most_things_do_not_smelt() { + if _ := smelting_recipe('minecraft:diamond') { + assert false, 'a diamond smelted into something' + } + if _ := smelting_recipe('minecraft:stick') { + assert false, 'a stick smelted into something' + } +} + +fn test_fuel_burns_for_its_own_time() { + assert fuel('minecraft:coal')?.burn_ticks == 1600 + assert fuel('minecraft:coal_block')?.burn_ticks == 16000 + assert fuel('minecraft:lava_bucket')?.burn_ticks == 20000 + assert fuel('minecraft:stick')?.burn_ticks == 100 + // Anything wooden burns for the same short while. + assert fuel('minecraft:oak_planks')?.burn_ticks == 300 + assert fuel('minecraft:spruce_log')?.burn_ticks == 300 +} + +fn test_things_that_do_not_burn() { + if _ := fuel('minecraft:stone') { + assert false, 'stone burned as fuel' + } + if _ := fuel('minecraft:iron_ingot') { + assert false, 'an iron ingot burned as fuel' + } +} + +// A lava bucket is the one fuel that leaves something behind and burning it +// has to hand the bucket back rather than swallow it. +fn test_a_lava_bucket_leaves_the_bucket() { + assert fuel('minecraft:lava_bucket')?.residue == 'minecraft:bucket' + assert fuel('minecraft:coal')?.residue == '' + assert fuel('minecraft:oak_planks')?.residue == '' +} + +fn test_only_wooden_slabs_burn() { + assert fuel('minecraft:oak_slab')?.burn_ticks == 300 + assert fuel('minecraft:bamboo_mosaic_slab')?.burn_ticks == 300 + for name in ['minecraft:stone_slab', 'minecraft:end_stone_brick_slab', + 'minecraft:petrified_oak_slab', 'minecraft:quartz_slab'] { + if _ := fuel(name) { + assert false, '${name} burned as fuel' + } + } +} + +fn test_recipes_say_which_cooker_takes_them() { + iron := smelting_recipe('minecraft:raw_iron')? + assert iron.ores && !iron.food + beef := smelting_recipe('minecraft:beef')? + assert beef.food && !beef.ores + kelp := smelting_recipe('minecraft:kelp')? + assert kelp.food && !kelp.ores + sand := smelting_recipe('minecraft:sand')? + assert !sand.food && !sand.ores +} diff --git a/server/session/blocks.v b/server/session/blocks.v index e266d98..fba4399 100644 --- a/server/session/blocks.v +++ b/server/session/blocks.v @@ -484,6 +484,10 @@ fn complete_block_break(mut tx worldrt.WorldTx, mut s NetworkSession, pos types. if b is block.ChestBlock { drop_chest_contents(mut tx, mut s, pos.x, pos.y, pos.z) } + if _ := block.furnace_variant(b.identifier()) { + drop_chest_contents(mut tx, mut s, pos.x, pos.y, pos.z) + tx.wr.world.clear_furnace_state(pos.x, pos.y, pos.z) + } if b is block.JukeboxBlock { drop_jukebox_disc(mut tx, pos.x, pos.y, pos.z) } diff --git a/server/session/furnace.v b/server/session/furnace.v new file mode 100644 index 0000000..ce4d928 --- /dev/null +++ b/server/session/furnace.v @@ -0,0 +1,312 @@ +module session + +import bedrock_v.protocol.types +import bedrock_v.protocol.current as proto +import bedrock_v.protocol.version.v662.packets as versioned +import server.block +import server.item +import server.world.db +import server.worldrt + +// Furnace progress ids, as the client's own container data. flame is drawn +// from burn_progress against burn_total, and the arrow from cook_progress +// against cook_total. +const furnace_data_cook_progress = i32(0) +const furnace_data_burn_progress = i32(1) +const furnace_data_burn_total = i32(2) + +// SessionFurnaceTicker advances every burning furnace in a world once per +// simulated step. It holds no state: the progress lives on the world, and the +// contents in the same container storage every other container uses. +struct SessionFurnaceTicker {} + +fn (mut t SessionFurnaceTicker) tick_block_entities(mut tx worldrt.WorldTx) { + for pos in tx.wr.world.burning_furnaces() { + tick_furnace(mut tx, pos.x, pos.y, pos.z) + } +} + +// furnace_at resolves the furnace block at a position, lit or not. +fn furnace_at(tx &worldrt.WorldTx, x int, y int, z int) ?(int, block.FurnaceVariant) { + block_id := block_at(tx, x, y, z) + b := block.get(block_id) or { return none } + variant := block.furnace_variant(b.identifier()) or { return none } + return block_id, variant +} + +// tick_furnace advances one furnace: burn down the fuel, light more when there +// is something to cook, move the cook along and hand over the result. +fn tick_furnace(mut tx worldrt.WorldTx, x int, y int, z int) { + block_id, variant := furnace_at(tx, x, y, z) or { + // The block is gone; drop whatever progress it had with it. + tx.wr.world.clear_furnace_state(x, y, z) + return + } + before := tx.wr.world.furnace_state(x, y, z) + mut state := db.FurnaceState{ + burn_ticks: if before.burn_ticks > 0 { before.burn_ticks - 1 } else { 0 } + burn_total: before.burn_total + cook_ticks: before.cook_ticks + } + smeltable := furnace_can_cook(mut tx, x, y, z, variant) + if state.burn_ticks <= 0 && smeltable { + state = light_furnace(mut tx, x, y, z, state) + } + if state.burn_ticks > 0 && smeltable { + state = db.FurnaceState{ + burn_ticks: state.burn_ticks + burn_total: state.burn_total + cook_ticks: state.cook_ticks + 1 + } + if state.cook_ticks >= variant.cook_ticks() { + finish_cooking(mut tx, x, y, z) + state = db.FurnaceState{ + burn_ticks: state.burn_ticks + burn_total: state.burn_total + } + } + } else { + state = db.FurnaceState{ + burn_ticks: state.burn_ticks + burn_total: state.burn_total + } + } + if state.is_idle() && !smeltable { + tx.wr.world.clear_furnace_state(x, y, z) + } else { + tx.wr.world.set_furnace_state(x, y, z, state) + } + sync_furnace_block(mut tx, x, y, z, block_id, state.burn_ticks > 0) + broadcast_furnace_progress(mut tx, x, y, z, state) +} + +// furnace_slot reads one of a furnace's three slots. +fn furnace_slot(mut tx worldrt.WorldTx, x int, y int, z int, slot int) types.ItemStack { + slots := tx.wr.world.container_slots(x, y, z) + if slot < 0 || slot >= slots.len { + return types.ItemStack{} + } + return slots[slot] +} + +// furnace_can_cook reports whether the input smelts into something this +// variant will cook and the output slot still has room for. +fn furnace_can_cook(mut tx worldrt.WorldTx, x int, y int, z int, variant block.FurnaceVariant) bool { + input := furnace_slot(mut tx, x, y, z, block.furnace_slot_input) + if input.count <= 0 || input.id == 0 { + return false + } + recipe := item.smelting_recipe(tx.wr.services.game_data().item_name(input.id)) or { + return false + } + if !variant_cooks(variant, recipe) { + return false + } + result_id := tx.wr.services.game_data().item_id(recipe.output) + if result_id == 0 { + return false + } + output := furnace_slot(mut tx, x, y, z, block.furnace_slot_output) + if output.count <= 0 || output.id == 0 { + return true + } + if output.id != result_id { + return false + } + return output.count < item.max_stack_size(recipe.output) +} + +// variant_cooks reports whether a cooking block will take a recipe at all. A +// smoker only cooks food and a blast furnace only ores; a plain furnace takes +// whatever smelts. +fn variant_cooks(variant block.FurnaceVariant, recipe item.SmeltingRecipe) bool { + return match variant { + .furnace { true } + .smoker { recipe.food } + .blast_furnace { recipe.ores } + } +} + +// light_furnace consumes one piece of fuel, if there is any. +fn light_furnace(mut tx worldrt.WorldTx, x int, y int, z int, state db.FurnaceState) db.FurnaceState { + fuel := furnace_slot(mut tx, x, y, z, block.furnace_slot_fuel) + if fuel.count <= 0 || fuel.id == 0 { + return state + } + burning := item.fuel(tx.wr.services.game_data().item_name(fuel.id)) or { return state } + tx.wr.world.set_container_slot(x, y, z, block.furnace_slot_fuel, spent_fuel(mut tx, + fuel, burning)) + return db.FurnaceState{ + burn_ticks: burning.burn_ticks + burn_total: burning.burn_ticks + cook_ticks: state.cook_ticks + } +} + +fn spent_fuel(mut tx worldrt.WorldTx, fuel types.ItemStack, burning item.Fuel) types.ItemStack { + if burning.residue == '' { + mut remaining := fuel + remaining.count-- + return remaining + } + residue_id := tx.wr.services.game_data().item_id(burning.residue) + if residue_id == 0 { + mut remaining := fuel + remaining.count-- + return remaining + } + return types.ItemStack{ + id: residue_id + count: 1 + } +} + +// finish_cooking takes one input and puts the result in the output slot. +fn finish_cooking(mut tx worldrt.WorldTx, x int, y int, z int) { + input := furnace_slot(mut tx, x, y, z, block.furnace_slot_input) + if input.count <= 0 || input.id == 0 { + return + } + recipe := item.smelting_recipe(tx.wr.services.game_data().item_name(input.id)) or { return } + result_id := tx.wr.services.game_data().item_id(recipe.output) + if result_id == 0 { + return + } + mut remaining := input + remaining.count-- + tx.wr.world.set_container_slot(x, y, z, block.furnace_slot_input, remaining) + + mut output := furnace_slot(mut tx, x, y, z, block.furnace_slot_output) + if output.count <= 0 || output.id == 0 { + output = types.ItemStack{ + id: result_id + count: 1 + } + } else { + output.count++ + } + tx.wr.world.set_container_slot(x, y, z, block.furnace_slot_output, output) +} + +// sync_furnace_block swaps the block between its lit and unlit forms so the +// furnace looks like what it is doing. +fn sync_furnace_block(mut tx worldrt.WorldTx, x int, y int, z int, block_id int, burning bool) { + if burning { + if lit := block.lit_furnace_id(block_id) { + tx.set_block(x, y, z, lit) + } + return + } + if unlit := block.unlit_furnace_id(block_id) { + tx.set_block(x, y, z, unlit) + } +} + +// broadcast_furnace_progress sends the flame and arrow to whoever has the +// furnace open. The packet has no alias in the protocol's current set, so it +// is reached through the version it was last changed in. +fn broadcast_furnace_progress(mut tx worldrt.WorldTx, x int, y int, z int, state db.FurnaceState) { + pos := types.BlockPosition{x, y, z} + for mut actor in tx.wr.entities.player_actors() { + mut s := as_network_session(mut actor) or { continue } + held := s.open_container_position() or { continue } + if held != pos { + continue + } + s.deliver(furnace_data_packet(furnace_data_cook_progress, i32(state.cook_ticks))) + s.deliver(furnace_data_packet(furnace_data_burn_progress, i32(state.burn_ticks))) + s.deliver(furnace_data_packet(furnace_data_burn_total, i32(state.burn_total))) + } +} + +fn furnace_data_packet(id i32, value i32) &versioned.ContainerSetDataPacket { + return &versioned.ContainerSetDataPacket{ + container_id: proto.ContainerID.first + id: id + value: value + } +} + +// wake_furnace starts the tick visiting a furnace, which is what makes one +// begin after something is put into it. +fn wake_furnace(mut tx worldrt.WorldTx, x int, y int, z int) { + if tx.wr.world.tracks_furnace(x, y, z) { + return + } + _, variant := furnace_at(tx, x, y, z) or { return } + if !furnace_can_cook(mut tx, x, y, z, variant) { + return + } + tx.wr.world.set_furnace_state(x, y, z, db.FurnaceState{}) +} + +// open_furnace shows a furnace's three slots on the client's furnace screen. +// The contents live in the same per-position container storage every other +// container uses; only the screen and the slot count differ. +fn open_furnace(mut tx worldrt.WorldTx, mut s NetworkSession, pos types.BlockPosition, variant block.FurnaceVariant) { + s.close_chest_container(mut tx) + if !tx.wr.world.try_hold_container(pos.x, pos.y, pos.z, s.runtime_id) { + return + } + s.set_open_container_position(pos) + stacks := tx.wr.world.container_slots(pos.x, pos.y, pos.z) + mut descriptors := []proto.NetworkItemStackDescriptorV2{cap: block.furnace_slot_count} + mut slot_net_ids := map[int]int{} + for slot in 0 .. block.furnace_slot_count { + stack := stacks[slot] or { types.ItemStack{} } + if stack.count > 0 && stack.id != 0 { + net_id := s.player.track_stack(stack) + slot_net_ids[slot] = net_id + descriptors << proto.item_descriptor_v2_tracked(stack, net_id) + } else { + descriptors << proto.item_descriptor_v2(stack) + } + } + s.set_open_container_slots(slot_net_ids) + s.deliver(&proto.ContainerOpenPacket{ + container_id: proto.ContainerID.first + container_type: furnace_screen(variant) + position: proto.block_pos(pos) + target_actor_id: proto.actor_unique_id(-1) + }) + s.deliver(&proto.InventoryContentPacket{ + inventory_id: u32(chest_dynamic_container_id()) + slots: descriptors + container_name_data: proto.FullContainerName{ + container: proto.ContainerEnumName.dynamic_container + dynamic_id: i32(chest_dynamic_container_id()) + } + storage_item: proto.item_descriptor_v2(types.ItemStack{}) + }) + state := tx.wr.world.furnace_state(pos.x, pos.y, pos.z) + s.deliver(furnace_data_packet(furnace_data_cook_progress, i32(state.cook_ticks))) + s.deliver(furnace_data_packet(furnace_data_burn_progress, i32(state.burn_ticks))) + s.deliver(furnace_data_packet(furnace_data_burn_total, i32(state.burn_total))) +} + +fn furnace_screen(variant block.FurnaceVariant) proto.ContainerType { + return match variant { + .furnace { proto.ContainerType.furnace } + .blast_furnace { proto.ContainerType.blast_furnace } + .smoker { proto.ContainerType.smoker } + } +} + +// revisit_lit_furnaces queues every furnace a world loaded in its burning form +// for one tick. +fn (mut h Hub) revisit_lit_furnaces(mut wr worldrt.WorldRuntime) { + mut lit_ids := []int{cap: block.furnace_unlit_ids.len} + for id, _ in block.furnace_unlit_ids { + lit_ids << id + } + positions := wr.world.override_positions_of(lit_ids) + if positions.len == 0 { + return + } + worldrt.world_call[bool]('Hub.revisit_lit_furnaces', mut wr, fn [positions] (mut tx worldrt.WorldTx) bool { + for pos in positions { + tx.wr.world.set_furnace_state(pos.x, pos.y, pos.z, db.FurnaceState{}) + } + return true + }) or {} +} diff --git a/server/session/hub.v b/server/session/hub.v index 7dcd768..a92ddcb 100644 --- a/server/session/hub.v +++ b/server/session/hub.v @@ -62,11 +62,11 @@ mut: // session_wg tracks sessions from registration until leave completes. // wait_for_sessions_to_leave blocks until every session has finished // leaving including saving player data and removing itself. - session_wg &sync.WaitGroup = sync.new_waitgroup() - oidc_verifier auth.Verifier - data gamedata.GameData - lang &language.Lang = unsafe { nil } - commands cmd.Registry = cmd.new_registry() + session_wg &sync.WaitGroup = sync.new_waitgroup() + oidc_verifier auth.Verifier + data gamedata.GameData + lang &language.Lang = unsafe { nil } + commands cmd.Registry = cmd.new_registry() // Defaults handed to every player and world this Hub creates. One handler // each, not a list: ordering between several listeners is the caller's to // arrange, in a handler that calls them in the order it wants. @@ -270,14 +270,16 @@ pub fn (h &Hub) uptime_seconds() i64 { // unless one is already set. fn (mut h Hub) add_world(loaded_world &db.World) { mut wr := worldrt.new_world_runtime( - world: loaded_world - services: h - generators: h - handler: h.world_handler - players: SessionPlayerTicker{} - entity_host: new_world_entity_host + world: loaded_world + services: h + generators: h + handler: h.world_handler + players: SessionPlayerTicker{} + block_entities: SessionFurnaceTicker{} + entity_host: new_world_entity_host ) h.restore_world_entities(mut wr) + h.revisit_lit_furnaces(mut wr) h.world_registry.add(wr) h.mutex.lock() if h.default_world_name == '' { diff --git a/server/session/inventory.v b/server/session/inventory.v index 3d5c8f9..134aa58 100644 --- a/server/session/inventory.v +++ b/server/session/inventory.v @@ -138,6 +138,7 @@ fn persist_container_changes(mut tx worldrt.WorldTx, mut target NetworkSession, tx.wr.world.set_container_slot(pos.x, pos.y, pos.z, slot, stack) target.set_open_container_slot_net_id(slot, net_id) } + wake_furnace(mut tx, pos.x, pos.y, pos.z) } fn (s &NetworkSession) cursor_slot_net_id() int { diff --git a/server/session/world_place_ops.v b/server/session/world_place_ops.v index 7df8c67..a2cf947 100644 --- a/server/session/world_place_ops.v +++ b/server/session/world_place_ops.v @@ -90,7 +90,8 @@ fn merged_slab(tx &worldrt.WorldTx, existing_id int, placing_id int, click_face if existing_id == world.air.network_id || isnil(tx.wr.services.block_palette()) { return none } - return tx.wr.services.block_palette().merged_slab(existing_id, placing_id, click_face, click_y, clicked) + return tx.wr.services.block_palette().merged_slab(existing_id, placing_id, click_face, click_y, + clicked) } fn door_placement(mut tx worldrt.WorldTx, runtime_id int, pos types.BlockPosition, click_face int, yaw f32) ?world.DoorPlacement { @@ -198,6 +199,10 @@ fn interact_block(mut tx worldrt.WorldTx, mut s NetworkSession, pos types.BlockP open_chest_container(mut tx, mut s, pos) return true } + if variant := block.furnace_variant(b.identifier()) { + open_furnace(mut tx, mut s, pos, variant) + return true + } if b is block.CraftingTableBlock { open_workbench(mut tx, mut s, pos) return true diff --git a/server/session/world_registry_test.v b/server/session/world_registry_test.v index d6023f2..ec6f739 100644 --- a/server/session/world_registry_test.v +++ b/server/session/world_registry_test.v @@ -12,12 +12,13 @@ fn test_world_registry_add_get_remove() { mut hub := new_hub(gamedata.GameData{}) w := db.new_world('reg-test', none, 'flat', world.overworld) mut wr := worldrt.new_world_runtime( - world: w - services: hub - generators: hub - handler: hub.world_handler - players: SessionPlayerTicker{} - entity_host: new_world_entity_host + world: w + services: hub + generators: hub + handler: hub.world_handler + players: SessionPlayerTicker{} + block_entities: SessionFurnaceTicker{} + entity_host: new_world_entity_host ) defer { wr.shutdown() diff --git a/server/session/world_runtime_test.v b/server/session/world_runtime_test.v index eb1bf67..3599b37 100644 --- a/server/session/world_runtime_test.v +++ b/server/session/world_runtime_test.v @@ -12,12 +12,13 @@ fn new_test_world_runtime() &worldrt.WorldRuntime { mut hub := new_hub(gamedata.GameData{}) w := db.new_world('test', none, 'flat', world.overworld) return worldrt.new_world_runtime( - world: w - services: hub - generators: hub - handler: hub.world_handler - players: SessionPlayerTicker{} - entity_host: new_world_entity_host + world: w + services: hub + generators: hub + handler: hub.world_handler + players: SessionPlayerTicker{} + block_entities: SessionFurnaceTicker{} + entity_host: new_world_entity_host ) } diff --git a/server/world/db/world_instance.v b/server/world/db/world_instance.v index de27881..c63440c 100644 --- a/server/world/db/world_instance.v +++ b/server/world/db/world_instance.v @@ -76,11 +76,15 @@ pub: name string dimension world.Dimension = world.overworld mut: - store ?Provider - overrides map[string]int - tile_data map[string]TileData - container_data map[string][]ContainerSlotItem - open_holders map[string]u64 + store ?Provider + overrides map[string]int + tile_data map[string]TileData + container_data map[string][]ContainerSlotItem + open_holders map[string]u64 + // furnace_states is the burn and cook progress of every furnace that is + // doing something. It is in memory only: a furnace goes out across a + // restart rather than resuming mid-cook. + furnace_states map[string]FurnaceState mutex &sync.Mutex = sync.new_mutex() current_tick i64 scheduled []ScheduledEntry @@ -707,3 +711,106 @@ fn (mut w World) await_persist_barrier() ! { } } } + +// FurnaceState is how far through burning its fuel and cooking its input one +// furnace is. A furnace with nothing to do keeps no state at all. +pub struct FurnaceState { +pub: + // burn_ticks is what is left of the current piece of fuel, and burn_total + // what that piece was worth when it was lit. The client draws the flame + // from the ratio of the two. + burn_ticks int + burn_total int + cook_ticks int +} + +// is_idle reports whether a furnace has neither fuel burning nor a cook in +// progress. +pub fn (f FurnaceState) is_idle() bool { + return f.burn_ticks <= 0 && f.cook_ticks <= 0 +} + +// furnace_state returns a furnace's progress, all zeroes when it is idle. +pub fn (w &World) furnace_state(x int, y int, z int) FurnaceState { + mut m := w.mutex + m.lock() + defer { + m.unlock() + } + return w.furnace_states[override_key(x, y, z)] or { FurnaceState{} } +} + +// set_furnace_state stores a furnace's progress. A furnace stays listed while +// it has state, even all-zero state: that is how one that has just been given +// something to cook gets its first tick. +pub fn (mut w World) set_furnace_state(x int, y int, z int, state FurnaceState) { + w.mutex.lock() + w.furnace_states[override_key(x, y, z)] = state + w.mutex.unlock() +} + +// clear_furnace_state forgets a furnace, so the tick stops visiting it. +pub fn (mut w World) clear_furnace_state(x int, y int, z int) { + w.mutex.lock() + w.furnace_states.delete(override_key(x, y, z)) + w.mutex.unlock() +} + +// tracks_furnace reports whether a furnace is currently being ticked. +pub fn (w &World) tracks_furnace(x int, y int, z int) bool { + mut m := w.mutex + m.lock() + defer { + m.unlock() + } + return override_key(x, y, z) in w.furnace_states +} + +// override_positions_of lists every stored block override holding one of the +// given ids. It exists so a world can be asked where its furnaces are without +// this package having to know what a furnace is. +pub fn (w &World) override_positions_of(ids []int) []TickPosition { + mut m := w.mutex + m.lock() + defer { + m.unlock() + } + mut out := []TickPosition{} + for key, id in w.overrides { + if id !in ids { + continue + } + pos := position_from_key(key) or { continue } + out << pos + } + return out +} + +// position_from_key reads back the coordinates an override key was built from. +fn position_from_key(key string) ?TickPosition { + parts := key.split(':') + if parts.len != 3 { + return none + } + return TickPosition{ + x: parts[0].int() + y: parts[1].int() + z: parts[2].int() + } +} + +// burning_furnaces lists the positions with progress to advance, so the tick +// only visits furnaces that are actually doing something. +pub fn (w &World) burning_furnaces() []TickPosition { + mut m := w.mutex + m.lock() + defer { + m.unlock() + } + mut out := []TickPosition{cap: w.furnace_states.len} + for key, _ in w.furnace_states { + pos := position_from_key(key) or { continue } + out << pos + } + return out +} diff --git a/server/worldrt/player_ticker.v b/server/worldrt/player_ticker.v index 1f2423a..fd318ab 100644 --- a/server/worldrt/player_ticker.v +++ b/server/worldrt/player_ticker.v @@ -9,3 +9,15 @@ pub interface PlayerTicker { mut: tick_players(mut tx WorldTx) } + +// BlockEntityTicker advances the blocks in a world that keep running state of +// their own - a furnace part way through cooking, and whatever else comes to +// need it. +// +// It exists for the same reason PlayerTicker does: the world runtime owns the +// clock, but what a furnace does with a tick is gameplay the runtime cannot +// reach. +pub interface BlockEntityTicker { +mut: + tick_block_entities(mut tx WorldTx) +} diff --git a/server/worldrt/world_runtime.v b/server/worldrt/world_runtime.v index d73c96f..4bbc8ec 100644 --- a/server/worldrt/world_runtime.v +++ b/server/worldrt/world_runtime.v @@ -73,6 +73,9 @@ mut: // players advances the players in this world once per simulated step. The // runtime can't do it itself: a player is a session concept. players PlayerTicker + // block_entities advances the blocks with running state of their own, + // once per simulated step. + block_entities BlockEntityTicker // Guards lifecycle state and in flight submission accounting. It must not // be held during blocking channel operations. mutex &sync.Mutex = sync.new_mutex() @@ -146,11 +149,12 @@ mut: // runtime has no route back to a session. pub struct RuntimeConfig { pub: - world &db.World = unsafe { nil } - services Services - generators GeneratorFactory - handler Handler = NopHandler{} - players PlayerTicker + world &db.World = unsafe { nil } + services Services + generators GeneratorFactory + handler Handler = NopHandler{} + players PlayerTicker + block_entities BlockEntityTicker // entity_host builds the entity manager's host once the runtime exists. // A function rather than a value because the host needs the runtime it // belongs to. @@ -161,11 +165,12 @@ pub: // its actor thread. Callers must shut it down before releasing all references. pub fn new_world_runtime(cfg RuntimeConfig) &WorldRuntime { mut wr := &WorldRuntime{ - services: cfg.services - handler: cfg.handler - generators: cfg.generators - players: cfg.players - world: cfg.world + services: cfg.services + handler: cfg.handler + generators: cfg.generators + players: cfg.players + block_entities: cfg.block_entities + world: cfg.world } wr.liquids = block.new_manager(WorldLiquidHost{ wr: wr }) wr.entities = entity.new_manager(cfg.entity_host(wr)) @@ -761,6 +766,7 @@ fn (mut tx WorldTx) advance_tick(target i64) { } wr.entities.tick() wr.players.tick_players(mut tx) + wr.block_entities.tick_block_entities(mut tx) wr.task_scheduler.heartbeat(mut tx, wr.current_tick) } if debt > max_world_catchup_ticks {