diff --git a/.github/workflows/busted.yml b/.github/workflows/busted.yml new file mode 100644 index 0000000..d765392 --- /dev/null +++ b/.github/workflows/busted.yml @@ -0,0 +1,18 @@ +name: busted + +on: [push, pull_request] + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v1 + - name: apt + run: sudo apt-get install -y luarocks + - name: busted install + run: luarocks install --local busted + - name: busted run + working-directory: ./technic + run: $HOME/.luarocks/bin/busted diff --git a/.luacheckrc b/.luacheckrc index 766191e..c58aca8 100644 --- a/.luacheckrc +++ b/.luacheckrc @@ -1,5 +1,10 @@ unused_args = false +-- Exclude regression tests / unit tests +exclude_files = { + "**/spec/**", +} + globals = { "technic", "technic_cnc", "minetest", "wrench" } diff --git a/README.md b/README.md index 0eedd10..b80978f 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,9 @@ Technic A mod for [minetest](http://www.minetest.net) ![integration-test](https://github.com/mt-mods/technic/workflows/integration-test/badge.svg) +![busted](https://github.com/mt-mods/technic/workflows/busted/badge.svg) ![luacheck](https://github.com/mt-mods/technic/workflows/luacheck/badge.svg) + [![License](https://img.shields.io/badge/license-LGPLv2.0%2B-purple.svg)](https://www.gnu.org/licenses/old-licenses/lgpl-2.0.en.html) [![ContentDB](https://content.minetest.net/packages/mt-mods/technic_plus/shields/downloads/)](https://content.minetest.net/packages/mt-mods/technic_plus/) diff --git a/technic/machines/compat/digtron.lua b/technic/machines/compat/digtron.lua index ce8f74e..c5aeddd 100644 --- a/technic/machines/compat/digtron.lua +++ b/technic/machines/compat/digtron.lua @@ -9,13 +9,17 @@ local function power_connector_compat() local digtron_technic_run = minetest.registered_nodes["digtron:power_connector"].technic_run minetest.override_item("digtron:power_connector",{ technic_run = function(pos, node) - local network_id = technic.cables[minetest.hash_node_position(pos)] - local sw_pos = network_id and minetest.get_position_from_hash(network_id) - if sw_pos then sw_pos.y = sw_pos.y + 1 end + local network_id = technic.pos2network(pos) + local sw_pos = network_id and technic.network2sw_pos(network_id) local meta = minetest.get_meta(pos) meta:set_string("HV_network", sw_pos and minetest.pos_to_string(sw_pos) or "") return digtron_technic_run(pos, node) end, + technic_on_disable = function(pos, node) + local meta = minetest.get_meta(pos) + meta:set_string("HV_network", "") + meta:set_string("HV_EU_input", "") + end, }) end diff --git a/technic/machines/init.lua b/technic/machines/init.lua index 84b5c33..ba42a24 100644 --- a/technic/machines/init.lua +++ b/technic/machines/init.lua @@ -14,6 +14,8 @@ technic.digilines = { } } +dofile(path.."/network.lua") + dofile(path.."/register/init.lua") -- Tiers diff --git a/technic/machines/network.lua b/technic/machines/network.lua new file mode 100644 index 0000000..eaaa666 --- /dev/null +++ b/technic/machines/network.lua @@ -0,0 +1,636 @@ +-- +-- Power network specific functions and data should live here +-- +local S = technic.getter + +local switch_max_range = tonumber(minetest.settings:get("technic.switch_max_range") or "256") +local off_delay_seconds = tonumber(minetest.settings:get("technic.switch.off_delay_seconds") or "1800") + +technic.active_networks = {} +local networks = {} +technic.networks = networks +local cables = {} +technic.cables = cables + +local poshash = minetest.hash_node_position +local hashpos = minetest.get_position_from_hash + +function technic.create_network(sw_pos) + local network_id = poshash({x=sw_pos.x,y=sw_pos.y-1,z=sw_pos.z}) + technic.build_network(network_id) + return network_id +end + +function technic.activate_network(network_id, timeout) + -- timeout is optional ttl for network in seconds, if not specified use default + local network = networks[network_id] + if network then + -- timeout is absolute time in microseconds + network.timeout = minetest.get_us_time() + ((timeout or off_delay_seconds) * 1000 * 1000) + technic.active_networks[network_id] = network + end +end + +function technic.sw_pos2tier(pos, use_vm) + -- Get cable tier for switching station or nil if no cable + -- use_vm true to use VoxelManip to load node + local cable_pos = {x=pos.x,y=pos.y-1,z=pos.z} + if use_vm then + technic.get_or_load_node(cable_pos) + end + return technic.get_cable_tier(minetest.get_node(cable_pos).name) +end + +-- Destroy network data +function technic.remove_network(network_id) + for pos_hash,cable_net_id in pairs(cables) do + if cable_net_id == network_id then + cables[pos_hash] = nil + end + end + networks[network_id] = nil + technic.active_networks[network_id] = nil +end + +-- Remove machine or cable from network +local network_node_arrays = {"PR_nodes","BA_nodes","RE_nodes"} +function technic.remove_network_node(network_id, pos) + local network = networks[network_id] + if not network then return end + -- Clear hash tables, cannot use table.remove + local node_id = poshash(pos) + cables[node_id] = nil + network.all_nodes[node_id] = nil + -- TODO: All following things can be skipped if node is not machine + -- check here if it is or is not cable + -- or add separate function to remove cables and move responsibility to caller + -- Clear indexed arrays, do NOT leave holes + local machine_removed = false + for _,tblname in ipairs(network_node_arrays) do + local tbl = network[tblname] + for i=#tbl,1,-1 do + local mpos = tbl[i] + if mpos.x == pos.x and mpos.y == pos.y and mpos.z == pos.z then + table.remove(tbl, i) + machine_removed = true + break + end + end + end + if machine_removed then + -- Machine can still be in world, just not connected to any network. If so then disable it. + local node = minetest.get_node(pos) + technic.disable_machine(pos, node) + end +end + +function technic.sw_pos2network(pos) + return cables[poshash({x=pos.x,y=pos.y-1,z=pos.z})] +end + +function technic.sw_pos2network(pos) + return cables[poshash({x=pos.x,y=pos.y-1,z=pos.z})] +end + +function technic.pos2network(pos) + return cables[poshash(pos)] +end + +function technic.network2pos(network_id) + return hashpos(network_id) +end + +function technic.network2sw_pos(network_id) + -- Return switching station position for network. + -- It is not guaranteed that position actually contains switching station. + local sw_pos = hashpos(network_id) + sw_pos.y = sw_pos.y + 1 + return sw_pos +end + +function technic.network_infotext(network_id, text) + if networks[network_id] == nil then return end + if text then + networks[network_id].infotext = text + else + return networks[network_id].infotext + end +end + +local node_timeout = {} +local default_timeout = 2 + +function technic.set_default_timeout(timeout) + default_timeout = timeout or 2 +end + +function technic.get_timeout(tier, pos) + if node_timeout[tier] == nil then + -- it is normal that some multi tier nodes always drop here when checking all LV, MV and HV tiers + return 0 + end + return node_timeout[tier][poshash(pos)] or 0 +end + +local function touch_node(tier, pos, timeout) + if node_timeout[tier] == nil then + -- this should get built up during registration + node_timeout[tier] = {} + end + node_timeout[tier][poshash(pos)] = timeout or default_timeout +end +technic.touch_node = touch_node + +function technic.disable_machine(pos, node) + local nodedef = minetest.registered_nodes[node.name] + if nodedef and nodedef.technic_disabled_machine_name then + node.name = nodedef.technic_disabled_machine_name + minetest.swap_node(pos, node) + elseif nodedef and nodedef.technic_on_disable then + nodedef.technic_on_disable(pos, node) + end + if nodedef then + local meta = minetest.get_meta(pos) + meta:set_string("infotext", S("%s Has No Network"):format(nodedef.description)) + end + local node_id = poshash(pos) + for _,nodes in pairs(node_timeout) do + nodes[node_id] = nil + end +end + +-- +-- Network overloading (incomplete cheat mitigation) +-- +local overload_reset_time = tonumber(minetest.settings:get("technic.overload_reset_time") or "20") +local overloaded_networks = {} + +local function overload_network(network_id) + local network = networks[network_id] + if network then + network.supply = 0 + network.battery_charge = 0 + end + overloaded_networks[network_id] = minetest.get_us_time() + (overload_reset_time * 1000 * 1000) +end +technic.overload_network = overload_network + +local function reset_overloaded(network_id) + local remaining = math.max(0, overloaded_networks[network_id] - minetest.get_us_time()) + if remaining == 0 then + -- Clear cache, remove overload and restart network + technic.remove_network(network_id) + overloaded_networks[network_id] = nil + end + -- Returns 0 when network reset or remaining time if reset timer has not expired yet + return remaining +end +technic.reset_overloaded = reset_overloaded + +local function is_overloaded(network_id) + return overloaded_networks[network_id] +end +technic.is_overloaded = is_overloaded + +-- +-- Functions to traverse the electrical network +-- + +-- Add a machine node to the LV/MV/HV network +local function add_network_machine(nodes, pos, network_id, all_nodes, multitier) + local node_id = poshash(pos) + local net_id_old = cables[node_id] + if net_id_old == nil or (multitier and net_id_old ~= network_id and all_nodes[node_id] == nil) then + -- Add machine to network only if it is not already added + table.insert(nodes, pos) + -- FIXME: Machines connecting to multiple networks should have way to store multiple network ids + cables[node_id] = network_id + all_nodes[node_id] = pos + return true + elseif not multitier and net_id_old ~= network_id then + -- Do not allow running from multiple networks, trigger overload + overload_network(network_id) + overload_network(net_id_old) + local meta = minetest.get_meta(pos) + meta:set_string("infotext",S("Network Overloaded")) + end +end + +-- Add a wire node to the LV/MV/HV network +local function add_cable_node(nodes, pos, network_id, queue) + local node_id = poshash(pos) + if not cables[node_id] then + cables[node_id] = network_id + nodes[node_id] = pos + table.insert(queue, pos) + end +end + +-- Generic function to add found connected nodes to the right classification array +local function add_network_node(PR_nodes, RE_nodes, BA_nodes, all_nodes, pos, machines, tier, network_id, queue) + technic.get_or_load_node(pos) + local name = minetest.get_node(pos).name + + if technic.is_tier_cable(name, tier) then + add_cable_node(all_nodes, pos, network_id, queue) + elseif machines[name] then + if machines[name] == technic.producer then + add_network_machine(PR_nodes, pos, network_id, all_nodes) + elseif machines[name] == technic.receiver then + add_network_machine(RE_nodes, pos, network_id, all_nodes) + elseif machines[name] == technic.producer_receiver then + if add_network_machine(PR_nodes, pos, network_id, all_nodes, true) then + table.insert(RE_nodes, pos) + end + elseif machines[name] == technic.battery then + add_network_machine(BA_nodes, pos, network_id, all_nodes) + end + end +end + +-- Generic function to add single nodes to the right classification array of existing network +function technic.add_network_node(pos, network) + add_network_node( + network.PR_nodes, + network.RE_nodes, + network.BA_nodes, + network.all_nodes, + pos, + technic.machines[network.tier], + network.tier, + network.id, + {} + ) +end + +-- Traverse a network given a list of machines and a cable type name +local function traverse_network(PR_nodes, RE_nodes, BA_nodes, all_nodes, pos, machines, tier, network_id, queue) + local positions = { + {x=pos.x+1, y=pos.y, z=pos.z}, + {x=pos.x-1, y=pos.y, z=pos.z}, + {x=pos.x, y=pos.y+1, z=pos.z}, + {x=pos.x, y=pos.y-1, z=pos.z}, + {x=pos.x, y=pos.y, z=pos.z+1}, + {x=pos.x, y=pos.y, z=pos.z-1}} + for i, cur_pos in pairs(positions) do + if not all_nodes[poshash(cur_pos)] then + add_network_node(PR_nodes, RE_nodes, BA_nodes, all_nodes, cur_pos, machines, tier, network_id, queue) + end + end +end + +local function touch_nodes(list, tier) + for _, pos in ipairs(list) do + touch_node(tier, pos) -- Touch node + end +end + +local function get_network(network_id, tier) + local cached = networks[network_id] + if cached and cached.tier == tier then + touch_nodes(cached.PR_nodes, tier) + touch_nodes(cached.BA_nodes, tier) + touch_nodes(cached.RE_nodes, tier) + return cached.PR_nodes, cached.BA_nodes, cached.RE_nodes + end + return technic.build_network(network_id) +end + +function technic.add_network_branch(queue, network) + -- Adds whole branch to network, queue positions can be used to bypass sub branches + local PR_nodes = network.PR_nodes -- Indexed array + local BA_nodes = network.BA_nodes -- Indexed array + local RE_nodes = network.RE_nodes -- Indexed array + local all_nodes = network.all_nodes -- Hash table + local network_id = network.id + local tier = network.tier + local machines = technic.machines[tier] + local sw_pos = technic.network2sw_pos(network_id) + --print(string.format("technic.add_network_branch(%s, %s, %.17g)",queue,minetest.pos_to_string(sw_pos),network.id)) + while next(queue) do + local to_visit = {} + for _, pos in ipairs(queue) do + if vector.distance(pos, sw_pos) > switch_max_range then + -- max range exceeded + return + end + traverse_network(PR_nodes, RE_nodes, BA_nodes, all_nodes, pos, + machines, tier, network_id, to_visit) + end + queue = to_visit + end +end + +function technic.build_network(network_id) + technic.remove_network(network_id) + local sw_pos = technic.network2sw_pos(network_id) + local tier = technic.sw_pos2tier(sw_pos) + if not tier then + return + end + local network = { + -- Basic network data and lookup table for attached nodes (no switching stations) + id = network_id, tier = tier, all_nodes = {}, + -- Indexed arrays for iteration by machine type + PR_nodes = {}, RE_nodes = {}, BA_nodes = {}, + -- Power generation, usage and capacity related variables + supply = 0, demand = 0, battery_charge = 0, battery_charge_max = 0, + -- Network activation and excution control + timeout = 0, skip = 0, + } + -- Add first cable (one that is holding network id) and build network + local queue = {} + add_cable_node(network.all_nodes, technic.network2pos(network_id), network_id, queue) + technic.add_network_branch(queue, network) + network.battery_count = #network.BA_nodes + -- Add newly built network to cache array + networks[network_id] = network + -- And return producers, batteries and receivers (should this simply return network?) + return network.PR_nodes, network.BA_nodes, network.RE_nodes +end + +-- +-- Execute technic power network +-- + +local function run_nodes(list, run_stage) + for _, pos in ipairs(list) do + technic.get_or_load_node(pos) + local node = minetest.get_node_or_nil(pos) + if node and node.name then + local nodedef = minetest.registered_nodes[node.name] + if nodedef and nodedef.technic_run then + nodedef.technic_run(pos, node, run_stage) + end + end + end +end + +local mesecons_path = minetest.get_modpath("mesecons") +local digilines_path = minetest.get_modpath("digilines") + +function technic.network_run(network_id) + -- + -- !!! !!! !!! !!! !!! !!! !!! !!! !!! !!! !!! + -- TODO: This function requires a lot of cleanup + -- It is moved here from switching_station.lua and still + -- contain a lot of switching station specific stuff which + -- should be removed and/or refactored. + -- + if not technic.powerctrl_state then return end + + -- Check if network is overloaded / conflicts with another network + if technic.is_overloaded(network_id) then + -- TODO: Overload check should happen before technic.network_run is called + return + end + + local pos = technic.network2sw_pos(network_id) + local t0 = minetest.get_us_time() + + local PR_nodes + local BA_nodes + local RE_nodes + + local tier = technic.sw_pos2tier(pos) + local network + if tier then + PR_nodes, BA_nodes, RE_nodes = get_network(network_id, tier) + if technic.is_overloaded(network_id) then return end + network = networks[network_id] + else + --dprint("Not connected to a network") + technic.network_infotext(network_id, S("%s Has No Network"):format(S("Switching Station"))) + return + end + + run_nodes(PR_nodes, technic.producer) + run_nodes(RE_nodes, technic.receiver) + run_nodes(BA_nodes, technic.battery) + + -- Strings for the meta data + local eu_demand_str = tier.."_EU_demand" + local eu_input_str = tier.."_EU_input" + local eu_supply_str = tier.."_EU_supply" + + -- Distribute charge equally across multiple batteries. + local charge_total = 0 + local battery_count = 0 + + local BA_charge = 0 + local BA_charge_max = 0 + + for n, pos1 in pairs(BA_nodes) do + local meta1 = minetest.get_meta(pos1) + local charge = meta1:get_int("internal_EU_charge") + local charge_max = meta1:get_int("internal_EU_charge_max") + + BA_charge = BA_charge + charge + BA_charge_max = BA_charge_max + charge_max + + if (meta1:get_int(eu_demand_str) ~= 0) then + charge_total = charge_total + charge + battery_count = battery_count + 1 + end + end + + local charge_distributed = math.floor(charge_total / battery_count) + + for n, pos1 in pairs(BA_nodes) do + local meta1 = minetest.get_meta(pos1) + + if (meta1:get_int(eu_demand_str) ~= 0) then + meta1:set_int("internal_EU_charge", charge_distributed) + end + end + + -- Get all the power from the PR nodes + local PR_eu_supply = 0 -- Total power + for _, pos1 in pairs(PR_nodes) do + local meta1 = minetest.get_meta(pos1) + PR_eu_supply = PR_eu_supply + meta1:get_int(eu_supply_str) + end + --dprint("Total PR supply:"..PR_eu_supply) + + -- Get all the demand from the RE nodes + local RE_eu_demand = 0 + for _, pos1 in pairs(RE_nodes) do + local meta1 = minetest.get_meta(pos1) + RE_eu_demand = RE_eu_demand + meta1:get_int(eu_demand_str) + end + --dprint("Total RE demand:"..RE_eu_demand) + + -- Get all the power from the BA nodes + local BA_eu_supply = 0 + for _, pos1 in pairs(BA_nodes) do + local meta1 = minetest.get_meta(pos1) + BA_eu_supply = BA_eu_supply + meta1:get_int(eu_supply_str) + end + --dprint("Total BA supply:"..BA_eu_supply) + + -- Get all the demand from the BA nodes + local BA_eu_demand = 0 + for _, pos1 in pairs(BA_nodes) do + local meta1 = minetest.get_meta(pos1) + BA_eu_demand = BA_eu_demand + meta1:get_int(eu_demand_str) + end + --dprint("Total BA demand:"..BA_eu_demand) + + technic.network_infotext(network_id, S("@1. Supply: @2 Demand: @3", + S("Switching Station"), technic.EU_string(PR_eu_supply), + technic.EU_string(RE_eu_demand))) + + -- If mesecon signal and power supply or demand changed then + -- send them via digilines. + if mesecons_path and digilines_path and mesecon.is_powered(pos) then + if PR_eu_supply ~= network.supply or + RE_eu_demand ~= network.demand then + local meta = minetest.get_meta(pos) + local channel = meta:get_string("channel") + digilines.receptor_send(pos, technic.digilines.rules, channel, { + supply = PR_eu_supply, + demand = RE_eu_demand + }) + end + end + + -- Data that will be used by the power monitor + network.supply = PR_eu_supply + network.demand = RE_eu_demand + network.battery_count = #BA_nodes + network.battery_charge = BA_charge + network.battery_charge_max = BA_charge_max + + -- If the PR supply is enough for the RE demand supply them all + if PR_eu_supply >= RE_eu_demand then + --dprint("PR_eu_supply"..PR_eu_supply.." >= RE_eu_demand"..RE_eu_demand) + for _, pos1 in pairs(RE_nodes) do + local meta1 = minetest.get_meta(pos1) + local eu_demand = meta1:get_int(eu_demand_str) + meta1:set_int(eu_input_str, eu_demand) + end + -- We have a surplus, so distribute the rest equally to the BA nodes + -- Let's calculate the factor of the demand + PR_eu_supply = PR_eu_supply - RE_eu_demand + local charge_factor = 0 -- Assume all batteries fully charged + if BA_eu_demand > 0 then + charge_factor = PR_eu_supply / BA_eu_demand + end + for n, pos1 in pairs(BA_nodes) do + local meta1 = minetest.get_meta(pos1) + local eu_demand = meta1:get_int(eu_demand_str) + meta1:set_int(eu_input_str, math.floor(eu_demand * charge_factor)) + --dprint("Charging battery:"..math.floor(eu_demand*charge_factor)) + end + local t1 = minetest.get_us_time() + local diff = t1 - t0 + if diff > 50000 then + minetest.log("warning", "[technic] [+supply] technic_run took " .. diff .. " us at " .. minetest.pos_to_string(pos)) + end + + return + end + + -- If the PR supply is not enough for the RE demand we will discharge the batteries too + if PR_eu_supply + BA_eu_supply >= RE_eu_demand then + --dprint("PR_eu_supply "..PR_eu_supply.."+BA_eu_supply "..BA_eu_supply.." >= RE_eu_demand"..RE_eu_demand) + for _, pos1 in pairs(RE_nodes) do + local meta1 = minetest.get_meta(pos1) + local eu_demand = meta1:get_int(eu_demand_str) + meta1:set_int(eu_input_str, eu_demand) + end + -- We have a deficit, so distribute to the BA nodes + -- Let's calculate the factor of the supply + local charge_factor = 0 -- Assume all batteries depleted + if BA_eu_supply > 0 then + charge_factor = (PR_eu_supply - RE_eu_demand) / BA_eu_supply + end + for n,pos1 in pairs(BA_nodes) do + local meta1 = minetest.get_meta(pos1) + local eu_supply = meta1:get_int(eu_supply_str) + meta1:set_int(eu_input_str, math.floor(eu_supply * charge_factor)) + --dprint("Discharging battery:"..math.floor(eu_supply*charge_factor)) + end + local t1 = minetest.get_us_time() + local diff = t1 - t0 + if diff > 50000 then + minetest.log("warning", "[technic] [-supply] technic_run took " .. diff .. " us at " .. minetest.pos_to_string(pos)) + end + + return + end + + -- If the PR+BA supply is not enough for the RE demand: Power only the batteries + local charge_factor = 0 -- Assume all batteries fully charged + if BA_eu_demand > 0 then + charge_factor = PR_eu_supply / BA_eu_demand + end + for n, pos1 in pairs(BA_nodes) do + local meta1 = minetest.get_meta(pos1) + local eu_demand = meta1:get_int(eu_demand_str) + meta1:set_int(eu_input_str, math.floor(eu_demand * charge_factor)) + end + for n, pos1 in pairs(RE_nodes) do + local meta1 = minetest.get_meta(pos1) + meta1:set_int(eu_input_str, 0) + end + + local t1 = minetest.get_us_time() + local diff = t1 - t0 + if diff > 50000 then + minetest.log("warning", "[technic] technic_run took " .. diff .. " us at " .. minetest.pos_to_string(pos)) + end + +end + +-- +-- Technic power network administrative functions +-- + +technic.powerctrl_state = true + +minetest.register_chatcommand("powerctrl", { + params = "state", + description = "Enables or disables technic's switching station ABM", + privs = { basic_privs = true }, + func = function(name, state) + if state == "on" then + technic.powerctrl_state = true + else + technic.powerctrl_state = false + end + end +}) + +-- +-- Metadata cleanup LBM, removes old metadata values from nodes +-- +--luacheck: ignore 511 +if false then + minetest.register_lbm({ + name = "technic:metadata-cleanup", + nodenames = { + "group:technic_machine", + "group:technic_all_tiers", + "technic:switching_station", + "technic:power_monitor", + }, + action = function(pos, node) + -- Delete all listed metadata key/value pairs from technic machines + local keys = { + "LV_EU_timeout", "MV_EU_timeout", "HV_EU_timeout", + "LV_network", "MV_network", "HV_network", + "active_pos", "supply", "demand", + "battery_count", "battery_charge", "battery_charge_max", + } + local meta = minetest.get_meta(pos) + for _,key in ipairs(keys) do + -- Value of `""` will delete the key. + meta:set_string(key, "") + end + if node.name == "technic:switching_station" then + meta:set_string("active", "") + end + end, + }) +end diff --git a/technic/machines/power_monitor.lua b/technic/machines/power_monitor.lua index e17a790..7d6ca99 100644 --- a/technic/machines/power_monitor.lua +++ b/technic/machines/power_monitor.lua @@ -13,6 +13,7 @@ local function get_cable(pos) end -- return the position of connected cable or nil +-- TODO: Make it support every possible orientation local function get_connected_cable_network(pos) local param2 = minetest.get_node(pos).param2 -- should probably also work sideways or upside down but for now it wont @@ -26,18 +27,16 @@ local function get_connected_cable_network(pos) -- Behind? checkpos = vector.add(minetest.facedir_to_dir(param2),pos) network_id = get_cable(checkpos) and technic.pos2network(checkpos) - if network_id then - return network_id - end + return network_id end -- return the position of the associated switching station or nil -local function get_swpos(pos) +local function get_network(pos) local network_id = get_connected_cable_network(pos) local network = network_id and technic.networks[network_id] local swpos = network and technic.network2sw_pos(network_id) local is_powermonitor = swpos and minetest.get_node(swpos).name == "technic:switching_station" - return (is_powermonitor and network.all_nodes[network_id]) and swpos + return (is_powermonitor and network.all_nodes[network_id]) and network end minetest.register_craft({ @@ -60,7 +59,7 @@ minetest.register_node("technic:power_monitor",{ "technic_power_monitor_front.png" }, paramtype2 = "facedir", - groups = {snappy=2, choppy=2, oddly_breakable_by_hand=2, technic_all_tiers=1, technic_machine=1}, + groups = {snappy=2, choppy=2, oddly_breakable_by_hand=2, technic_all_tiers=1}, connect_sides = {"bottom", "back"}, sounds = default.node_sound_wood_defaults(), on_construct = function(pos) @@ -97,19 +96,16 @@ minetest.register_node("technic:power_monitor",{ return end - local sw_pos = get_swpos(pos) - if not sw_pos then - return - end + local network = get_network(pos) + if not network then return end - local sw_meta = minetest.get_meta(sw_pos) digilines.receptor_send(pos, technic.digilines.rules, channel, { - supply = sw_meta:get_int("supply"), - demand = sw_meta:get_int("demand"), - lag = sw_meta:get_int("lag"), - battery_count = sw_meta:get_int("battery_count"), - battery_charge = sw_meta:get_int("battery_charge"), - battery_charge_max = sw_meta:get_int("battery_charge_max"), + supply = network.supply, + demand = network.demand, + lag = network.lag, + battery_count = network.battery_count, + battery_charge = network.battery_charge, + battery_charge_max = network.battery_charge_max, }) end }, @@ -123,14 +119,10 @@ minetest.register_abm({ chance = 1, action = function(pos, node, active_object_count, active_object_count_wider) local meta = minetest.get_meta(pos) - local sw_pos = get_swpos(pos) - if sw_pos then - local sw_meta = minetest.get_meta(sw_pos) - local supply = sw_meta:get_int("supply") - local demand = sw_meta:get_int("demand") - meta:set_string("infotext", - S("Power Monitor. Supply: @1 Demand: @2", - technic.EU_string(supply), technic.EU_string(demand))) + local network = get_network(pos) + if network then + meta:set_string("infotext", S("Power Monitor. Supply: @1 Demand: @2", + technic.EU_string(network.supply), technic.EU_string(network.demand))) else meta:set_string("infotext",S("Power Monitor Has No Network")) end diff --git a/technic/machines/register/cables.lua b/technic/machines/register/cables.lua index 7b57f47..05cb68a 100644 --- a/technic/machines/register/cables.lua +++ b/technic/machines/register/cables.lua @@ -11,110 +11,150 @@ function technic.get_cable_tier(name) return cable_tier[name] end -local function check_connections(pos) - -- Build a table of all machines - local machines = {} - for tier,list in pairs(technic.machines) do - for k,v in pairs(list) do - machines[k] = v - end +local function match_cable_tier_filter(name, tiers) + -- Helper to check for set of cable tiers + if tiers then + for _, tier in ipairs(tiers) do if cable_tier[name] == tier then return true end end + return false end - local connections = {} + return cable_tier[name] ~= nil +end + +local function get_neighbors(pos, tiers) + -- TODO: Move this to network.lua + local tier_machines = tiers and technic.machines[tiers[1]] + local is_cable = match_cable_tier_filter(minetest.get_node(pos).name, tiers) + local network = is_cable and technic.networks[technic.pos2network(pos)] + local cables = {} + local machines = {} local positions = { {x=pos.x+1, y=pos.y, z=pos.z}, {x=pos.x-1, y=pos.y, z=pos.z}, {x=pos.x, y=pos.y+1, z=pos.z}, {x=pos.x, y=pos.y-1, z=pos.z}, {x=pos.x, y=pos.y, z=pos.z+1}, - {x=pos.x, y=pos.y, z=pos.z-1}} - for _,connected_pos in pairs(positions) do + {x=pos.x, y=pos.y, z=pos.z-1}, + } + for _,connected_pos in ipairs(positions) do local name = minetest.get_node(connected_pos).name - if machines[name] or technic.get_cable_tier(name) then - table.insert(connections,connected_pos) + if tier_machines and tier_machines[name] then + table.insert(machines, connected_pos) + elseif match_cable_tier_filter(name, tiers) then + local cable_network = technic.networks[technic.pos2network(connected_pos)] + table.insert(cables,{ + pos = connected_pos, + network = cable_network, + }) + if not network then network = cable_network end end end - return connections + return network, cables, machines end -local function clear_networks(pos) - local node = minetest.get_node(pos) - local placed = node.name ~= "air" - local positions = check_connections(pos) - if #positions < 1 then return end - local dead_end = #positions == 1 - for _,connected_pos in pairs(positions) do - local net = technic.cables[minetest.hash_node_position(connected_pos)] - if net and technic.networks[net] then - if dead_end and placed then - -- Dead end placed, add it to the network - -- Get the network - local network_id = technic.cables[minetest.hash_node_position(positions[1])] - if not network_id then - -- We're evidently not on a network, nothing to add ourselves to - return - end - local sw_pos = minetest.get_position_from_hash(network_id) - sw_pos.y = sw_pos.y + 1 - local network = technic.networks[network_id] - local tier = network.tier +local function place_network_node(pos, tiers, name) + -- Get connections and primary network if there's any + local network, cables, machines = get_neighbors(pos, tiers) + if not network then + -- We're evidently not on a network, nothing to add ourselves to + return + end - -- Actually add it to the (cached) network - -- This is similar to check_node_subp - local pos_hash = minetest.hash_node_position(pos) - technic.cables[pos_hash] = network_id - pos.visited = 1 - if technic.is_tier_cable(name, tier) then - network.all_nodes[pos_hash] = pos - elseif technic.machines[tier][node.name] then - if technic.machines[tier][node.name] == technic.producer then - table.insert(network.PR_nodes,pos) - elseif technic.machines[tier][node.name] == technic.receiver then - table.insert(network.RE_nodes,pos) - elseif technic.machines[tier][node.name] == technic.producer_receiver then - table.insert(network.PR_nodes,pos) - table.insert(network.RE_nodes,pos) - elseif technic.machines[tier][node.name] == "SPECIAL" and - (pos.x ~= sw_pos.x or pos.y ~= sw_pos.y or pos.z ~= sw_pos.z) and - from_below then - table.insert(network.SP_nodes,pos) - elseif technic.machines[tier][node.name] == technic.battery then - table.insert(network.BA_nodes,pos) - end + -- Attach to primary network, this must be done before building branches from this position + technic.add_network_node(pos, network) + if not match_cable_tier_filter(name, tiers) then + if technic.machines[tiers[1]][name] == technic.producer_receiver then + -- FIXME: Multi tier machine like supply converter should also attach to other networks around pos. + -- Preferably also with connection rules defined for machine. + -- nodedef.connect_sides could be used to generate these rules. + -- For now, assume that all multi network machines belong to technic.producer_receiver group: + -- Get cables and networks around PR_RE machine + local _, machine_cables, _ = get_neighbors(pos) + for _,connection in ipairs(machine_cables) do + if connection.network and connection.network.id ~= network.id then + -- Attach PR_RE machine to secondary networks (last added is primary until above note is resolved) + technic.add_network_node(pos, connection.network) end - elseif dead_end and not placed then - -- Dead end removed, remove it from the network - -- Get the network - local network_id = technic.cables[minetest.hash_node_position(positions[1])] - if not network_id then - -- We're evidently not on a network, nothing to add ourselves to - return + end + else + -- Check connected cables for foreign networks, overload if machine was connected to multiple networks + for _, connection in ipairs(cables) do + if connection.network and connection.network.id ~= network.id then + technic.overload_network(connection.network.id) + technic.overload_network(network.id) end - local network = technic.networks[network_id] - - -- Search for and remove machine - technic.cables[minetest.hash_node_position(pos)] = nil - for tblname,table in pairs(network) do - if tblname ~= "tier" then - for machinenum,machine in pairs(table) do - if machine.x == pos.x - and machine.y == pos.y - and machine.z == pos.z then - table[machinenum] = nil - end - end - end - end - else - -- Not a dead end, so the whole network needs to be recalculated - for _,v in pairs(technic.networks[net].all_nodes) do - local pos1 = minetest.hash_node_position(v) - technic.cables[pos1] = nil - end - technic.networks[net] = nil end end + -- Machine added, skip all network building + return + end + + -- Attach neighbor machines if cable was added + for _,machine_pos in ipairs(machines) do + technic.add_network_node(machine_pos, network) + end + + -- Attach neighbor cables + for _,connection in ipairs(cables) do + if connection.network then + if connection.network.id ~= network.id then + -- Remove network if position belongs to another network + -- FIXME: Network requires partial rebuild but avoid doing it here if possible. + -- This might cause problems when merging two active networks into one + technic.remove_network(network.id) + technic.remove_network(connection.network.id) + connection.network = nil + end + else + -- There's cable that does not belong to any network, attach whole branch + technic.add_network_node(connection.pos, network) + technic.add_network_branch({connection.pos}, network) + end end end +-- NOTE: Exported for tests but should probably be moved to network.lua +technic.network_node_on_placenode = place_network_node + +local function remove_network_node(pos, tiers, name) + -- Get the network and neighbors + local network, cables, machines = get_neighbors(pos, tiers) + if not network then return end + + if not match_cable_tier_filter(name, tiers) then + -- Machine removed, skip cable checks to prevent unnecessary network cleanups + for _,connection in ipairs(cables) do + if connection.network then + -- Remove machine from all networks around it + technic.remove_network_node(connection.network.id, pos) + end + end + return + end + + if #cables == 1 then + -- Dead end cable removed, remove it from the network + technic.remove_network_node(network.id, pos) + -- Remove neighbor machines from network if cable was removed + if match_cable_tier_filter(name, tiers) then + for _,machine_pos in ipairs(machines) do + local net, _, _ = get_neighbors(machine_pos, tiers) + if not net then + -- Remove machine from network if it does not have other connected cables + technic.remove_network_node(network.id, machine_pos) + end + end + end + else + -- TODO: Check branches around and switching stations for branches: + -- remove branches that do not have switching station. Switching stations not tracked but could be easily tracked. + -- remove branches not connected to another branch. Individual branches not tracked, requires simple AI heuristics. + -- move branches that have switching station to new networks without checking or loading actual nodes in world. + -- To do all this network must be aware of individual branches and switching stations, might not be worth it... + -- For now remove whole network and let ABM rebuild it + technic.remove_network(network.id) + end +end +-- NOTE: Exported for tests but should probably be moved to network.lua +technic.network_node_on_dignode = remove_network_node local function item_place_override_node(itemstack, placer, pointed, node) -- Call the default on_place function with a fake itemstack @@ -142,7 +182,8 @@ function technic.register_cable(tier, size, description, prefix, override_cable, prefix = prefix or "" override_cable_plate = override_cable_plate or override_cable local ltier = string.lower(tier) - cable_tier["technic:"..ltier..prefix.."_cable"] = tier + local node_name = "technic:"..ltier..prefix.."_cable" + cable_tier[node_name] = tier local groups = {snappy=2, choppy=2, oddly_breakable_by_hand=2, ["technic_"..ltier.."_cable"] = 1} @@ -158,22 +199,22 @@ function technic.register_cable(tier, size, description, prefix, override_cable, connect_right = {-size, -size, -size, 0.5, size, size}, -- x+ } - minetest.register_node("technic:"..ltier..prefix.."_cable", override_table({ + minetest.register_node(node_name, override_table({ description = S("%s Cable"):format(tier), tiles = {"technic_"..ltier..prefix.."_cable.png"}, inventory_image = "technic_"..ltier..prefix.."_cable_wield.png", wield_image = "technic_"..ltier..prefix.."_cable_wield.png", groups = groups, sounds = default.node_sound_wood_defaults(), - drop = "technic:"..ltier..prefix.."_cable", + drop = node_name, paramtype = "light", sunlight_propagates = true, drawtype = "nodebox", node_box = node_box, connects_to = {"group:technic_"..ltier.."_cable", "group:technic_"..ltier, "group:technic_all_tiers"}, - on_construct = clear_networks, - on_destruct = clear_networks, + on_construct = function(pos) place_network_node(pos, {tier}, node_name) end, + on_destruct = function(pos) remove_network_node(pos, {tier}, node_name) end, }, override_cable)) local xyz = { @@ -199,21 +240,22 @@ function technic.register_cable(tier, size, description, prefix, override_cable, return "-"..p end end + -- TODO: Does this really need 6 different nodes? Use single node for cable plate if possible. for p, i in pairs(xyz) do local def = { description = S("%s Cable Plate"):format(tier), tiles = {"technic_"..ltier..prefix.."_cable.png"}, groups = table.copy(groups), sounds = default.node_sound_wood_defaults(), - drop = "technic:"..ltier..prefix.."_cable_plate_1", + drop = node_name .. "_plate_1", paramtype = "light", sunlight_propagates = true, drawtype = "nodebox", node_box = table.copy(node_box), connects_to = {"group:technic_"..ltier.."_cable", "group:technic_"..ltier, "group:technic_all_tiers"}, - on_construct = clear_networks, - on_destruct = clear_networks, + on_construct = function(pos) place_network_node(pos, {tier}, node_name.."_plate_"..i) end, + on_destruct = function(pos) remove_network_node(pos, {tier}, node_name.."_plate_"..i) end, } def.node_box.fixed = { {-size, -size, -size, size, size, size}, @@ -254,7 +296,7 @@ function technic.register_cable(tier, size, description, prefix, override_cable, if num == nil then num = 1 end return item_place_override_node( itemstack, placer, pointed_thing, - {name = "technic:"..ltier..prefix.."_cable_plate_"..num} + {name = node_name.."_plate_"..num} ) end else @@ -271,39 +313,43 @@ function technic.register_cable(tier, size, description, prefix, override_cable, num = num + dir num = (num >= 1 and num) or num + 6 num = (num <= 6 and num) or num - 6 - minetest.swap_node(pos, {name = "technic:"..ltier..prefix.."_cable_plate_"..num}) + minetest.swap_node(pos, {name = node_name.."_plate_"..num}) end - minetest.register_node("technic:"..ltier..prefix.."_cable_plate_"..i, override_table(def, override_cable_plate)) - cable_tier["technic:"..ltier..prefix.."_cable_plate_"..i] = tier + minetest.register_node(node_name.."_plate_"..i, override_table(def, override_cable_plate)) + cable_tier[node_name.."_plate_"..i] = tier end - local c = "technic:"..ltier..prefix.."_cable" minetest.register_craft({ - output = "technic:"..ltier..prefix.."_cable_plate_1 5", + output = node_name.."_plate_1 5", recipe = { - {"", "", c}, - {c , c , c}, - {"", "", c}, + {"" , "" , node_name}, + {node_name, node_name, node_name}, + {"" , "" , node_name}, } }) minetest.register_craft({ - output = c, + output = node_name, recipe = { - {"technic:"..ltier..prefix.."_cable_plate_1"}, + {node_name.."_plate_1"}, } }) end - -local function clear_nets_if_machine(pos, node) - for tier, machine_list in pairs(technic.machines) do - if machine_list[node.name] ~= nil then - return clear_networks(pos) - end +minetest.register_on_mods_loaded(function() + -- FIXME: Move this to register.lua or somewhere else where register_on_mods_loaded is not required. + -- Possible better option would be to inject these when machine is registered in register.lua. + for name, tiers in pairs(technic.machine_tiers) do + local nodedef = minetest.registered_nodes[name] + local on_construct = type(nodedef.on_construct) == "function" and nodedef.on_construct + local on_destruct = type(nodedef.on_destruct) == "function" and nodedef.on_destruct + minetest.override_item(name,{ + on_construct = on_construct + and function(pos) on_construct(pos) place_network_node(pos, tiers, name) end + or function(pos) place_network_node(pos, tiers, name) end, + on_destruct = on_destruct + and function(pos) on_destruct(pos) remove_network_node(pos, tiers, name) end + or function(pos) remove_network_node(pos, tiers, name) end, + }) end -end - -minetest.register_on_placenode(clear_nets_if_machine) -minetest.register_on_dignode(clear_nets_if_machine) - +end) diff --git a/technic/machines/supply_converter.lua b/technic/machines/supply_converter.lua index 19afb98..787c912 100644 --- a/technic/machines/supply_converter.lua +++ b/technic/machines/supply_converter.lua @@ -143,6 +143,10 @@ local run = function(pos, node, run_stage) if from and to then local input = meta:get_int(from.."_EU_input") + if (technic.get_timeout(from, pos) <= 0) or (technic.get_timeout(to, pos) <= 0) then + -- Supply converter timed out, either RE or PR network is not running anymore + input = 0 + end meta:set_int(from.."_EU_demand", demand) meta:set_int(from.."_EU_supply", 0) meta:set_int(to.."_EU_demand", 0) diff --git a/technic/machines/switching_station.lua b/technic/machines/switching_station.lua index 83a4690..9e3e246 100644 --- a/technic/machines/switching_station.lua +++ b/technic/machines/switching_station.lua @@ -1,29 +1,6 @@ -- See also technic/doc/api.md -technic.networks = {} -technic.cables = {} -technic.redundant_warn = {} - -local overload_reset_time = tonumber(minetest.settings:get("technic.overload_reset_time") or "20") -local overloaded_networks = {} -local function overload_network(network_id) - overloaded_networks[network_id] = minetest.get_us_time() + (overload_reset_time * 1000 * 1000) -end -local function reset_overloaded(network_id) - local remaining = math.max(0, overloaded_networks[network_id] - minetest.get_us_time()) - if remaining == 0 then - -- Clear cache, remove overload and restart network - technic.remove_network(network_id) - overloaded_networks[network_id] = nil - end - -- Returns 0 when network reset or remaining time if reset timer has not expired yet - return remaining -end - -local switch_max_range = tonumber(minetest.settings:get("technic.switch_max_range") or "256") - local mesecons_path = minetest.get_modpath("mesecons") -local digilines_path = minetest.get_modpath("digilines") local S = technic.getter @@ -38,6 +15,17 @@ minetest.register_craft({ } }) +local function start_network(pos) + local tier = technic.sw_pos2tier(pos) + if not tier then + local meta = minetest.get_meta(pos) + meta:set_string("infotext", S("%s Has No Network"):format(S("Switching Station"))) + return + end + local network_id = technic.sw_pos2network(pos) or technic.create_network(pos) + technic.activate_network(network_id) +end + local mesecon_def if mesecons_path then mesecon_def = {effector = { @@ -60,33 +48,17 @@ minetest.register_node("technic:switching_station",{ on_construct = function(pos) local meta = minetest.get_meta(pos) meta:set_string("infotext", S("Switching Station")) - local network_id = technic.sw_pos2network(pos) - local net_sw_pos = network_id and technic.network2sw_pos(network_id) - local net_sw_node = net_sw_pos and minetest.get_node_or_nil(net_sw_pos) - if net_sw_node then - -- There's already network with same id, check if it already has active switching station - if net_sw_node.name == "technic:switching_station" then - -- Another switch found set active to 0 for this switch if another is already active - local net_sw_meta = minetest.get_meta(net_sw_pos) - meta:set_string("active", net_sw_meta:get_int("active") == 1 and 0 or 1) - else - -- Network switching station disappeared, cleanup caches and start new network - technic.remove_network(network_id) - meta:set_string("active", 1) - end - else - -- Clean start, not previous networks, no other switching stations - meta:set_string("active", 1) - end meta:set_string("channel", "switching_station"..minetest.pos_to_string(pos)) meta:set_string("formspec", "field[channel;Channel;${channel}]") - local poshash = minetest.hash_node_position(pos) - technic.redundant_warn.poshash = nil + start_network(pos) end, - after_dig_node = function(pos) - pos.y = pos.y - 1 - local poshash = minetest.hash_node_position(pos) - technic.redundant_warn.poshash = nil + on_destruct = function(pos) + -- Remove network when switching station is removed, if + -- there's another switching station network will be rebuilt. + local network_id = technic.sw_pos2network(pos) + if technic.networks[network_id] then + technic.remove_network(network_id) + end end, on_receive_fields = function(pos, formname, fields, sender) if not fields.channel then @@ -116,533 +88,83 @@ minetest.register_node("technic:switching_station",{ if channel ~= meta:get_string("channel") then return end - digilines.receptor_send(pos, technic.digilines.rules, channel, { - supply = meta:get_int("supply"), - demand = meta:get_int("demand"), - lag = meta:get_int("lag") - }) + local network_id = technic.sw_pos2network(pos) + local network = network_id and technic.networks[network_id] + if network then + digilines.receptor_send(pos, technic.digilines.rules, channel, { + supply = network.supply, + demand = network.demand, + lag = network.lag + }) + else + digilines.receptor_send(pos, technic.digilines.rules, channel, { + error = "No network", + }) + end end }, }, }) --------------------------------------------------- --- Functions to traverse the electrical network --------------------------------------------------- -local function flatten(map) - local list = {} - for key, value in pairs(map) do - list[#list + 1] = value - end - return list -end - -local function attach_network_machine(network_id, pos) - local pos_hash = minetest.hash_node_position(pos) - local net_id_old = technic.cables[pos_hash] - if net_id_old == nil then - technic.cables[pos_hash] = network_id - elseif net_id_old ~= network_id then - -- do not allow running pos from multiple networks, also disable switch - overload_network(network_id, pos) - overload_network(net_id_old, pos) - technic.cables[pos_hash] = network_id - local meta = minetest.get_meta(pos) - meta:set_string("infotext",S("Network Overloaded")) - end -end - --- Add a wire node to the LV/MV/HV network -local function add_network_node(nodes, pos, network_id) - local node_id = minetest.hash_node_position(pos) - technic.cables[node_id] = network_id - if nodes[node_id] then - return false - end - nodes[node_id] = pos - return true -end - -local function add_cable_node(nodes, pos, network_id, queue) - if add_network_node(nodes, pos, network_id) then - queue[#queue + 1] = pos - end -end - --- Generic function to add found connected nodes to the right classification array -local function check_node_subp(PR_nodes, RE_nodes, BA_nodes, SP_nodes, all_nodes, pos, machines, tier, sw_pos, from_below, network_id, queue) - - local distance_to_switch = vector.distance(pos, sw_pos) - if distance_to_switch > switch_max_range then - -- max range exceeded - return - end - - technic.get_or_load_node(pos) - local name = minetest.get_node(pos).name - - if technic.is_tier_cable(name, tier) then - add_cable_node(all_nodes, pos, network_id, queue) - elseif machines[name] then - --dprint(name.." is a "..machines[name]) - - if machines[name] == technic.producer then - attach_network_machine(network_id, pos) - add_network_node(PR_nodes, pos, network_id) - elseif machines[name] == technic.receiver then - attach_network_machine(network_id, pos) - add_network_node(RE_nodes, pos, network_id) - elseif machines[name] == technic.producer_receiver then - --attach_network_machine(network_id, pos) - add_network_node(PR_nodes, pos, network_id) - add_network_node(RE_nodes, pos, network_id) - elseif machines[name] == "SPECIAL" and - (pos.x ~= sw_pos.x or pos.y ~= sw_pos.y or pos.z ~= sw_pos.z) and - from_below then - -- Another switching station -> disable it - attach_network_machine(network_id, pos) - add_network_node(SP_nodes, pos, network_id) - local meta = minetest.get_meta(pos) - meta:set_int("active", 0) - elseif machines[name] == technic.battery then - attach_network_machine(network_id, pos) - add_network_node(BA_nodes, pos, network_id) - end - - technic.touch_node(tier, pos, 2) -- Touch node - end -end - --- Traverse a network given a list of machines and a cable type name -local function traverse_network(PR_nodes, RE_nodes, BA_nodes, SP_nodes, all_nodes, pos, machines, tier, sw_pos, network_id, queue) - local positions = { - {x=pos.x+1, y=pos.y, z=pos.z}, - {x=pos.x-1, y=pos.y, z=pos.z}, - {x=pos.x, y=pos.y+1, z=pos.z}, - {x=pos.x, y=pos.y-1, z=pos.z}, - {x=pos.x, y=pos.y, z=pos.z+1}, - {x=pos.x, y=pos.y, z=pos.z-1}} - for i, cur_pos in pairs(positions) do - check_node_subp(PR_nodes, RE_nodes, BA_nodes, SP_nodes, all_nodes, cur_pos, machines, tier, sw_pos, i == 3, network_id, queue) - end -end - -function technic.remove_network(network_id) - local cables = technic.cables - for pos_hash,cable_net_id in pairs(cables) do - if cable_net_id == network_id then - cables[pos_hash] = nil - end - end - technic.networks[network_id] = nil -end - -function technic.sw_pos2network(pos) - return pos and technic.cables[minetest.hash_node_position({x=pos.x,y=pos.y-1,z=pos.z})] -end - -function technic.pos2network(pos) - return pos and technic.cables[minetest.hash_node_position(pos)] -end - -function technic.network2pos(network_id) - return network_id and minetest.get_position_from_hash(network_id) -end - -function technic.network2sw_pos(network_id) - -- Return switching station position for network. - -- It is not guaranteed that position actually contains switching station. - local sw_pos = minetest.get_position_from_hash(network_id) - sw_pos.y = sw_pos.y + 1 - return sw_pos -end - -local node_timeout = {} - -function technic.get_timeout(tier, pos) - if node_timeout[tier] == nil then - -- it is normal that some multi tier nodes always drop here when checking all LV, MV and HV tiers - return 0 - end - return node_timeout[tier][minetest.hash_node_position(pos)] or 0 -end - -function technic.touch_node(tier, pos, timeout) - if node_timeout[tier] == nil then - -- this should get built up during registration - node_timeout[tier] = {} - end - node_timeout[tier][minetest.hash_node_position(pos)] = timeout or 2 -end - -local function touch_nodes(list, tier) - local touch_node = technic.touch_node - for _, pos in ipairs(list) do - touch_node(tier, pos, 2) -- Touch node - end -end - -local function get_network(network_id, sw_pos, pos1, tier) - local cached = technic.networks[network_id] - if cached and cached.tier == tier then - touch_nodes(cached.PR_nodes, tier) - touch_nodes(cached.BA_nodes, tier) - touch_nodes(cached.RE_nodes, tier) - for _, pos in ipairs(cached.SP_nodes) do - local meta = minetest.get_meta(pos) - meta:set_int("active", 0) - meta:set_string("active_pos", minetest.serialize(sw_pos)) - technic.touch_node(tier, pos, 2) -- Touch node - end - return cached.PR_nodes, cached.BA_nodes, cached.RE_nodes - end - local PR_nodes = {} - local BA_nodes = {} - local RE_nodes = {} - local SP_nodes = {} - local all_nodes = {} - local queue = {} - add_cable_node(all_nodes, pos1, network_id, queue) - while next(queue) do - local to_visit = {} - for _, pos in ipairs(queue) do - traverse_network(PR_nodes, RE_nodes, BA_nodes, SP_nodes, all_nodes, - pos, technic.machines[tier], tier, sw_pos, network_id, to_visit) - end - queue = to_visit - end - PR_nodes = flatten(PR_nodes) - BA_nodes = flatten(BA_nodes) - RE_nodes = flatten(RE_nodes) - SP_nodes = flatten(SP_nodes) - technic.networks[network_id] = {tier = tier, all_nodes = all_nodes, SP_nodes = SP_nodes, - PR_nodes = PR_nodes, RE_nodes = RE_nodes, BA_nodes = BA_nodes} - return PR_nodes, BA_nodes, RE_nodes -end - ----------------------------------------------- -- The action code for the switching station -- ----------------------------------------------- -technic.powerctrl_state = true - -minetest.register_chatcommand("powerctrl", { - params = "state", - description = "Enables or disables technic's switching station ABM", - privs = { basic_privs = true }, - func = function(name, state) - if state == "on" then - technic.powerctrl_state = true - else - technic.powerctrl_state = false - end - end -}) - --- Run all the nodes -local function run_nodes(list, run_stage) - for _, pos in ipairs(list) do - technic.get_or_load_node(pos) - local node = minetest.get_node_or_nil(pos) - if node and node.name then - local nodedef = minetest.registered_nodes[node.name] - if nodedef and nodedef.technic_run then - nodedef.technic_run(pos, node, run_stage) - end - end - end -end - -function technic.switching_station_run(pos) - if not technic.powerctrl_state then return end - - local t0 = minetest.get_us_time() - local meta = minetest.get_meta(pos) - local meta1 - local pos1 = {} - - local tier = "" - local PR_nodes - local BA_nodes - local RE_nodes - local machine_name = S("Switching Station") - - -- Which kind of network are we on: - pos1 = {x=pos.x, y=pos.y-1, z=pos.z} - - --Disable if necessary - if meta:get_int("active") ~= 1 then - meta:set_string("infotext",S("%s Already Present"):format(machine_name)) - - local poshash = minetest.hash_node_position(pos) - - if not technic.redundant_warn[poshash] then - technic.redundant_warn[poshash] = true - print("[TECHNIC] Warning: redundant switching station found near "..minetest.pos_to_string(pos)) - end - return - end - - local network_id = minetest.hash_node_position(pos1) - -- Check if network is overloaded / conflicts with another network - if overloaded_networks[network_id] then - local remaining = reset_overloaded(network_id) - if remaining > 0 then - meta:set_string("infotext",S("%s Network Overloaded, Restart in %dms"):format(machine_name, remaining / 1000)) - -- Set switching station supply value to zero to clean up power monitor supply info - meta:set_int("supply",0) - return - end - meta:set_string("infotext",S("%s Restarting Network"):format(machine_name)) - return - end - - local name = minetest.get_node(pos1).name - local tier = technic.get_cable_tier(name) - if tier then - PR_nodes, BA_nodes, RE_nodes = get_network(network_id, pos, pos1, tier) - if overloaded_networks[network_id] then return end - else - --dprint("Not connected to a network") - meta:set_string("infotext", S("%s Has No Network"):format(machine_name)) - return - end - - run_nodes(PR_nodes, technic.producer) - run_nodes(RE_nodes, technic.receiver) - run_nodes(BA_nodes, technic.battery) - - -- Strings for the meta data - local eu_demand_str = tier.."_EU_demand" - local eu_input_str = tier.."_EU_input" - local eu_supply_str = tier.."_EU_supply" - - -- Distribute charge equally across multiple batteries. - local charge_total = 0 - local battery_count = 0 - - local BA_charge = 0 - local BA_charge_max = 0 - - for n, pos1 in pairs(BA_nodes) do - meta1 = minetest.get_meta(pos1) - local charge = meta1:get_int("internal_EU_charge") - local charge_max = meta1:get_int("internal_EU_charge_max") - - BA_charge = BA_charge + charge - BA_charge_max = BA_charge_max + charge_max - - if (meta1:get_int(eu_demand_str) ~= 0) then - charge_total = charge_total + charge - battery_count = battery_count + 1 - end - end - - local charge_distributed = math.floor(charge_total / battery_count) - - for n, pos1 in pairs(BA_nodes) do - meta1 = minetest.get_meta(pos1) - - if (meta1:get_int(eu_demand_str) ~= 0) then - meta1:set_int("internal_EU_charge", charge_distributed) - end - end - - -- Get all the power from the PR nodes - local PR_eu_supply = 0 -- Total power - for _, pos1 in pairs(PR_nodes) do - meta1 = minetest.get_meta(pos1) - PR_eu_supply = PR_eu_supply + meta1:get_int(eu_supply_str) - end - --dprint("Total PR supply:"..PR_eu_supply) - - -- Get all the demand from the RE nodes - local RE_eu_demand = 0 - for _, pos1 in pairs(RE_nodes) do - meta1 = minetest.get_meta(pos1) - RE_eu_demand = RE_eu_demand + meta1:get_int(eu_demand_str) - end - --dprint("Total RE demand:"..RE_eu_demand) - - -- Get all the power from the BA nodes - local BA_eu_supply = 0 - for _, pos1 in pairs(BA_nodes) do - meta1 = minetest.get_meta(pos1) - BA_eu_supply = BA_eu_supply + meta1:get_int(eu_supply_str) - end - --dprint("Total BA supply:"..BA_eu_supply) - - -- Get all the demand from the BA nodes - local BA_eu_demand = 0 - for _, pos1 in pairs(BA_nodes) do - meta1 = minetest.get_meta(pos1) - BA_eu_demand = BA_eu_demand + meta1:get_int(eu_demand_str) - end - --dprint("Total BA demand:"..BA_eu_demand) - - meta:set_string("infotext", S("@1. Supply: @2 Demand: @3", - machine_name, technic.EU_string(PR_eu_supply), - technic.EU_string(RE_eu_demand))) - - -- If mesecon signal and power supply or demand changed then - -- send them via digilines. - if mesecons_path and digilines_path and mesecon.is_powered(pos) then - if PR_eu_supply ~= meta:get_int("supply") or - RE_eu_demand ~= meta:get_int("demand") then - local channel = meta:get_string("channel") - digilines.receptor_send(pos, technic.digilines.rules, channel, { - supply = PR_eu_supply, - demand = RE_eu_demand - }) - end - end - - -- Data that will be used by the power monitor - meta:set_int("supply",PR_eu_supply) - meta:set_int("demand",RE_eu_demand) - meta:set_int("battery_count",#BA_nodes) - meta:set_int("battery_charge",BA_charge) - meta:set_int("battery_charge_max",BA_charge_max) - - -- If the PR supply is enough for the RE demand supply them all - if PR_eu_supply >= RE_eu_demand then - --dprint("PR_eu_supply"..PR_eu_supply.." >= RE_eu_demand"..RE_eu_demand) - for _, pos1 in pairs(RE_nodes) do - meta1 = minetest.get_meta(pos1) - local eu_demand = meta1:get_int(eu_demand_str) - meta1:set_int(eu_input_str, eu_demand) - end - -- We have a surplus, so distribute the rest equally to the BA nodes - -- Let's calculate the factor of the demand - PR_eu_supply = PR_eu_supply - RE_eu_demand - local charge_factor = 0 -- Assume all batteries fully charged - if BA_eu_demand > 0 then - charge_factor = PR_eu_supply / BA_eu_demand - end - for n, pos1 in pairs(BA_nodes) do - meta1 = minetest.get_meta(pos1) - local eu_demand = meta1:get_int(eu_demand_str) - meta1:set_int(eu_input_str, math.floor(eu_demand * charge_factor)) - --dprint("Charging battery:"..math.floor(eu_demand*charge_factor)) - end - local t1 = minetest.get_us_time() - local diff = t1 - t0 - if diff > 50000 then - minetest.log("warning", "[technic] [+supply] switching station abm took " .. diff .. " us at " .. minetest.pos_to_string(pos)) - end - - return - end - - -- If the PR supply is not enough for the RE demand we will discharge the batteries too - if PR_eu_supply + BA_eu_supply >= RE_eu_demand then - --dprint("PR_eu_supply "..PR_eu_supply.."+BA_eu_supply "..BA_eu_supply.." >= RE_eu_demand"..RE_eu_demand) - for _, pos1 in pairs(RE_nodes) do - meta1 = minetest.get_meta(pos1) - local eu_demand = meta1:get_int(eu_demand_str) - meta1:set_int(eu_input_str, eu_demand) - end - -- We have a deficit, so distribute to the BA nodes - -- Let's calculate the factor of the supply - local charge_factor = 0 -- Assume all batteries depleted - if BA_eu_supply > 0 then - charge_factor = (PR_eu_supply - RE_eu_demand) / BA_eu_supply - end - for n,pos1 in pairs(BA_nodes) do - meta1 = minetest.get_meta(pos1) - local eu_supply = meta1:get_int(eu_supply_str) - meta1:set_int(eu_input_str, math.floor(eu_supply * charge_factor)) - --dprint("Discharging battery:"..math.floor(eu_supply*charge_factor)) - end - local t1 = minetest.get_us_time() - local diff = t1 - t0 - if diff > 50000 then - minetest.log("warning", "[technic] [-supply] switching station abm took " .. diff .. " us at " .. minetest.pos_to_string(pos)) - end - - return - end - - -- If the PR+BA supply is not enough for the RE demand: Power only the batteries - local charge_factor = 0 -- Assume all batteries fully charged - if BA_eu_demand > 0 then - charge_factor = PR_eu_supply / BA_eu_demand - end - for n, pos1 in pairs(BA_nodes) do - meta1 = minetest.get_meta(pos1) - local eu_demand = meta1:get_int(eu_demand_str) - meta1:set_int(eu_input_str, math.floor(eu_demand * charge_factor)) - end - for n, pos1 in pairs(RE_nodes) do - meta1 = minetest.get_meta(pos1) - meta1:set_int(eu_input_str, 0) - end - - local t1 = minetest.get_us_time() - local diff = t1 - t0 - if diff > 50000 then - minetest.log("warning", "[technic] switching station abm took " .. diff .. " us at " .. minetest.pos_to_string(pos)) - end - - -end - -- Timeout ABM -- Timeout for a node in case it was disconnected from the network -- A node must be touched by the station continuously in order to function -local function switching_station_timeout_count(pos, tier) - local timeout = technic.get_timeout(tier, pos) - if timeout <= 0 then - local meta = minetest.get_meta(pos) - meta:set_int(tier.."_EU_input", 0) -- Not needed anymore <-- actually, it is for supply converter - return true - else - technic.touch_node(tier, pos, timeout - 1) - return false - end -end minetest.register_abm({ label = "Machines: timeout check", nodenames = {"group:technic_machine"}, interval = 1, chance = 1, action = function(pos, node, active_object_count, active_object_count_wider) - for tier, machines in pairs(technic.machines) do - if machines[node.name] and switching_station_timeout_count(pos, tier) then - local nodedef = minetest.registered_nodes[node.name] - if nodedef and nodedef.technic_disabled_machine_name then - node.name = nodedef.technic_disabled_machine_name - minetest.swap_node(pos, node) - elseif nodedef and nodedef.technic_on_disable then - nodedef.technic_on_disable(pos, node) - end - if nodedef then - local meta = minetest.get_meta(pos) - meta:set_string("infotext", S("%s Has No Network"):format(nodedef.description)) - end + -- Check for machine timeouts for all tiers + local tiers = technic.machine_tiers[node.name] + local timed_out = true + for _, tier in ipairs(tiers) do + local timeout = technic.get_timeout(tier, pos) + if timeout > 0 then + technic.touch_node(tier, pos, timeout - 1) + timed_out = false end end + -- If all tiers for machine timed out take action + if timed_out then + technic.disable_machine(pos, node) + end end, }) ---Re-enable disabled switching station if necessary, similar to the timeout above +--Re-enable network of switching station if necessary, similar to the timeout above minetest.register_abm({ label = "Machines: re-enable check", nodenames = {"technic:switching_station"}, interval = 1, chance = 1, action = function(pos, node, active_object_count, active_object_count_wider) - local pos1 = {x=pos.x,y=pos.y-1,z=pos.z} - local tier = technic.get_cable_tier(minetest.get_node(pos1).name) - if not tier then return end - if switching_station_timeout_count(pos, tier) then + local network_id = technic.sw_pos2network(pos) + -- Check if network is overloaded / conflicts with another network + if network_id then + local infotext local meta = minetest.get_meta(pos) - meta:set_int("active",1) + if technic.is_overloaded(network_id) then + local remaining = technic.reset_overloaded(network_id) + if remaining > 0 then + infotext = S("%s Network Overloaded, Restart in %dms"):format(S("Switching Station"), remaining / 1000) + else + infotext = S("%s Restarting Network"):format(S("Switching Station")) + end + technic.network_infotext(network_id, infotext) + else + -- Network exists and is not overloaded, reactivate network + technic.activate_network(network_id) + infotext = technic.network_infotext(network_id) + end + meta:set_string("infotext", infotext) + else + -- Network does not exist yet, attempt to create new network here + start_network(pos) end end, }) - -for tier, machines in pairs(technic.machines) do - -- SPECIAL will not be traversed - technic.register_machine(tier, "technic:switching_station", "SPECIAL") -end diff --git a/technic/machines/switching_station_globalstep.lua b/technic/machines/switching_station_globalstep.lua index bbd5ea5..8932aa0 100644 --- a/technic/machines/switching_station_globalstep.lua +++ b/technic/machines/switching_station_globalstep.lua @@ -1,23 +1,6 @@ local has_monitoring_mod = minetest.get_modpath("monitoring") -local switches = {} -- pos_hash -> { time = time_us } - -local function get_switch_data(pos) - local hash = minetest.hash_node_position(pos) - local switch = switches[hash] - - if not switch then - switch = { - time = 0, - skip = 0 - } - switches[hash] = switch - end - - return switch -end - local active_switching_stations_metric, switching_stations_usage_metric if has_monitoring_mod then @@ -32,21 +15,9 @@ if has_monitoring_mod then ) end --- collect all active switching stations -minetest.register_abm({ - nodenames = {"technic:switching_station"}, - label = "Switching Station", - interval = 1, - chance = 1, - action = function(pos) - local switch = get_switch_data(pos) - switch.time = minetest.get_us_time() - end -}) - - -- the interval between technic_run calls local technic_run_interval = 1.0 +local set_default_timeout = technic.set_default_timeout -- iterate over all collected switching stations and execute the technic_run function local timer = 0 @@ -71,91 +42,78 @@ minetest.register_globalstep(function(dtime) -- normal run_interval technic_run_interval = 1.0 end + set_default_timeout(math.ceil(technic_run_interval) + 1) local now = minetest.get_us_time() - local off_delay_seconds = tonumber(minetest.settings:get("technic.switch.off_delay_seconds") or "1800") - local off_delay_micros = off_delay_seconds*1000*1000 - local active_switches = 0 - for hash, switch in pairs(switches) do - local pos = minetest.get_position_from_hash(hash) - local diff = now - switch.time + for network_id, network in pairs(technic.active_networks) do + local pos = technic.network2sw_pos(network_id) - minetest.get_voxel_manip(pos, pos) - local node = minetest.get_node(pos) + local node = technic.get_or_load_node(pos) or minetest.get_node(pos) if node.name ~= "technic:switching_station" then -- station vanished - switches[hash] = nil + technic.remove_network(network_id) - elseif diff < off_delay_micros then + elseif network.timeout > now then -- station active active_switches = active_switches + 1 - if switch.skip < 1 then + if network.skip > 0 then + network.skip = network.skip - 1 + else local start = minetest.get_us_time() - technic.switching_station_run(pos) + technic.network_run(network_id) local switch_diff = minetest.get_us_time() - start - - local meta = minetest.get_meta(pos) - -- set lag in microseconds into the "lag" meta field - meta:set_int("lag", switch_diff) + network.lag = switch_diff -- overload detection if switch_diff > 250000 then - switch.skip = 30 + network.skip = 30 elseif switch_diff > 150000 then - switch.skip = 20 + network.skip = 20 elseif switch_diff > 75000 then - switch.skip = 10 + network.skip = 10 elseif switch_diff > 50000 then - switch.skip = 2 + network.skip = 2 end - if switch.skip > 0 then + if network.skip > 0 then -- calculate efficiency in percent and display it - local efficiency = math.floor(1/switch.skip*100) - meta:set_string("infotext", "Polyfuse triggered, current efficiency: " .. + local efficiency = math.floor(1/network.skip*100) + technic.network_infotext(network_id, "Polyfuse triggered, current efficiency: " .. efficiency .. "% generated lag : " .. math.floor(switch_diff/1000) .. " ms") - -- remove laggy switching station from active index + -- remove laggy network from active index -- it will be reactivated when a player is near it - -- laggy switching stations won't work well in unloaded areas this way - switches[hash] = nil + technic.active_networks[network_id] = nil end - - else - switch.skip = math.max(switch.skip - 1, 0) end - else -- station timed out - switches[hash] = nil + technic.active_networks[network_id] = nil end end - local time_usage = minetest.get_us_time() - now - if has_monitoring_mod then + local time_usage = minetest.get_us_time() - now active_switching_stations_metric.set(active_switches) switching_stations_usage_metric.inc(time_usage) end - end) - minetest.register_chatcommand("technic_flush_switch_cache", { - description = "removes all loaded switching stations from the cache", + description = "removes all loaded networks from the cache", privs = { server = true }, func = function() - switches = {} + technic.active_networks = {} end }) diff --git a/technic/register.lua b/technic/register.lua index 9b4f9be..cc60cd5 100644 --- a/technic/register.lua +++ b/technic/register.lua @@ -9,17 +9,23 @@ technic.battery = "BA" technic.machines = {} technic.power_tools = {} technic.networks = {} - +technic.machine_tiers = {} function technic.register_tier(tier, description) technic.machines[tier] = {} end function technic.register_machine(tier, nodename, machine_type) + -- Lookup table to get compatible node names and machine type by tier if not technic.machines[tier] then return end technic.machines[tier][nodename] = machine_type + -- Lookup table to get compatible tiers by node name + if not technic.machine_tiers[nodename] then + technic.machine_tiers[nodename] = {} + end + table.insert(technic.machine_tiers[nodename], tier) end function technic.register_power_tool(craftitem, max_charge) diff --git a/technic/spec/building_spec.lua b/technic/spec/building_spec.lua new file mode 100644 index 0000000..48093ff --- /dev/null +++ b/technic/spec/building_spec.lua @@ -0,0 +1,355 @@ +dofile("spec/test_helpers.lua") +--[[ + Technic network unit tests. + Execute busted at technic source directory. +--]] + +-- Load fixtures required by tests +fixture("minetest") +fixture("minetest/player") +fixture("minetest/protection") + +fixture("pipeworks") +fixture("network") + +sourcefile("machines/network") + +sourcefile("machines/register/cables") +sourcefile("machines/LV/cables") +sourcefile("machines/MV/cables") +sourcefile("machines/HV/cables") + +sourcefile("machines/register/generator") +sourcefile("machines/HV/generator") + +function get_network_fixture(sw_pos) + -- Build network + local net_id = technic.create_network(sw_pos) + assert.is_number(net_id) + local net = technic.networks[net_id] + assert.is_table(net) + return net +end + +describe("Power network building", function() + + describe("cable building", function() + + world.layout({ + {{x=100,y=800,z=100}, "technic:hv_cable"}, + {{x=100,y=801,z=100}, "technic:switching_station"}, + {{x=101,y=800,z=100}, "technic:hv_cable"}, + {{x=101,y=801,z=100}, "technic:hv_generator"}, + --{{x=102,y=800,z=100}, "technic:hv_cable"}, -- This cable is built + --{{x=102,y=801,z=100}, "technic:hv_cable"}, -- TODO: Add this cable as test case? + {{x=103,y=800,z=100}, "technic:hv_cable"}, -- This should appear + {{x=103,y=801,z=100}, "technic:hv_generator"}, -- This should appear + }) + -- Build network + local net = get_network_fixture({x=100,y=801,z=100}) + local build_pos = {x=102,y=800,z=100} + + it("does not crash", function() + assert.equals(1, #net.PR_nodes) + assert.equals(3, count(net.all_nodes)) + world.set_node(build_pos, {name="technic:hv_cable", param2=0}) + technic.network_node_on_placenode(build_pos, {"HV"}, "technic:hv_cable") + end) + + it("is added to network", function() + assert.same(build_pos, net.all_nodes[minetest.hash_node_position(build_pos)]) + end) + + it("adds all network nodes", function() + assert.equals(6, count(net.all_nodes)) + end) + + it("adds connected machines to network without duplicates", function() + assert.equals(2, #net.PR_nodes) + --assert.equals({x=103,y=801,z=100}, net.PR_nodes[2]) + end) + + end) + + describe("cable building to machine", function() + + world.layout({ + {{x=100,y=810,z=100}, "technic:hv_cable"}, + {{x=100,y=811,z=100}, "technic:switching_station"}, + {{x=101,y=810,z=100}, "technic:hv_cable"}, + {{x=101,y=811,z=100}, "technic:hv_generator"}, + {{x=102,y=810,z=100}, "technic:hv_cable"}, + --{{x=102,y=811,z=100}, "technic:hv_cable"}, -- This cable is built + --{{x=103,y=810,z=100}, "technic:hv_cable"}, -- This cable is built + {{x=103,y=811,z=100}, "technic:hv_generator"}, -- This should appear + {{x=103,y=812,z=100}, "technic:hv_cable"}, -- Unconnected cable + }) + -- Build network + local net = get_network_fixture({x=100,y=811,z=100}) + local build_pos = {x=103,y=810,z=100} + local build_pos2 = {x=102,y=811,z=100} + + it("does not crash", function() + assert.equals(1, #net.PR_nodes) + assert.equals(4, count(net.all_nodes)) + world.set_node(build_pos, {name="technic:hv_cable", param2=0}) + technic.network_node_on_placenode(build_pos, {"HV"}, "technic:hv_cable") + end) + + it("is added to network", function() + assert.same(build_pos, net.all_nodes[minetest.hash_node_position(build_pos)]) + end) + + it("adds all network nodes", function() + assert.equals(6, count(net.all_nodes)) + end) + + it("adds connected machines to network without duplicates", function() + assert.equals(2, #net.PR_nodes) + --assert.equals({x=103,y=801,z=100}, net.PR_nodes[2]) + end) + + it("does not add unconnected cables to network", function() + assert.is_nil(net.all_nodes[minetest.hash_node_position({x=103,y=812,z=100})]) + end) + + it("does not duplicate already added machine", function() + world.set_node(build_pos2, {name="technic:hv_cable", param2=0}) + technic.network_node_on_placenode(build_pos2, {"HV"}, "technic:hv_cable") + assert.equals(2, #net.PR_nodes) + assert.equals(7, count(net.all_nodes)) + end) + + end) + + describe("machine building", function() + + world.layout({ + {{x=100,y=820,z=100}, "technic:hv_cable"}, + {{x=100,y=821,z=100}, "technic:switching_station"}, + {{x=101,y=820,z=100}, "technic:hv_cable"}, + {{x=101,y=821,z=100}, "technic:hv_generator"}, + {{x=102,y=820,z=100}, "technic:hv_cable"}, + -- {{x=102,y=821,z=100}, "technic:hv_generator"}, -- This machine is built + {{x=102,y=821,z= 99}, "technic:hv_cable"}, -- This should not be added to network + {{x=102,y=821,z=101}, "technic:hv_cable"}, -- This should not be added to network + {{x=103,y=820,z=100}, "technic:hv_cable"}, + {{x=103,y=821,z=100}, "technic:hv_generator"}, + -- Second network for overload test + {{x=100,y=820,z=102}, "technic:hv_cable"}, + {{x=100,y=821,z=102}, "technic:switching_station"}, + -- {{x=100,y=820,z=101}, "technic:hv_generator"}, -- This machine is built, it should overload + }) + -- Build network + local net = get_network_fixture({x=100,y=821,z=100}) + local net2 = get_network_fixture({x=100,y=821,z=102}) + local build_pos = {x=102,y=821,z=100} + local build_pos2 = {x=100,y=820,z=101} + + it("does not crash", function() + assert.equals(2, #net.PR_nodes) + assert.equals(6, count(net.all_nodes)) + world.set_node(build_pos, {name="technic:hv_generator",param2=0}) + technic.network_node_on_placenode(build_pos, {"HV"}, "technic:hv_generator") + end) + + it("is added to network without duplicates", function() + assert.same(build_pos, net.all_nodes[minetest.hash_node_position(build_pos)]) + assert.equals(7, count(net.all_nodes)) + assert.equals(3, #net.PR_nodes) + assert.is_nil(technic.is_overloaded(net.id)) + assert.is_nil(technic.is_overloaded(net2.id)) + end) + + it("does not remove connected machines from network", function() + assert.same({x=101,y=821,z=100},net.all_nodes[minetest.hash_node_position({x=101,y=821,z=100})]) + assert.same({x=103,y=821,z=100},net.all_nodes[minetest.hash_node_position({x=103,y=821,z=100})]) + end) + + it("does not remove network", function() + assert.is_hashed(technic.networks[net.id]) + end) + + it("does not add cables to network", function() + assert.is_nil(net.all_nodes[minetest.hash_node_position({x=102,y=821,z=99})]) + assert.is_nil(net.all_nodes[minetest.hash_node_position({x=102,y=821,z=101})]) + end) + + it("overloads network", function() + world.set_node(build_pos2, {name="technic:hv_generator",param2=0}) + technic.network_node_on_placenode(build_pos2, {"HV"}, "technic:hv_generator") + assert.not_nil(technic.is_overloaded(net.id)) + assert.not_nil(technic.is_overloaded(net2.id)) + end) + + end) + + describe("cable building between networks", function() + + world.layout({ + {{x=100,y=830,z=100}, "technic:hv_cable"}, + {{x=100,y=831,z=100}, "technic:switching_station"}, + --{{x=101,y=830,z=100}, "technic:hv_cable"}, -- This cable is built + --{{x=101,y=831,z=100}, "technic:hv_cable"}, -- TODO: Add this cable as test case? + {{x=102,y=830,z=100}, "technic:hv_cable"}, + {{x=102,y=831,z=100}, "technic:switching_station"}, + }) + -- Build network + local net = get_network_fixture({x=100,y=831,z=100}) + local net2 = get_network_fixture({x=102,y=831,z=100}) + local build_pos = {x=101,y=830,z=100} + + it("does not crash", function() + assert.equals(1, count(net.all_nodes)) + assert.equals(1, count(net2.all_nodes)) + world.set_node(build_pos, {name="technic:hv_cable", param2=0}) + technic.network_node_on_placenode(build_pos, {"HV"}, "technic:hv_cable") + end) + + it("removes network", function() + assert.is_nil(technic.networks[net.id]) + assert.is_nil(technic.networks[net2.id]) + end) + + end) + + describe("cable cutting", function() + + world.layout({ + {{x=100,y=900,z=100}, "technic:hv_cable"}, + {{x=100,y=901,z=100}, "technic:switching_station"}, + {{x=101,y=900,z=100}, "technic:hv_cable"}, + {{x=101,y=901,z=100}, "technic:hv_generator"}, + {{x=102,y=900,z=100}, "technic:hv_cable"}, -- This cable is digged + {{x=103,y=900,z=100}, "technic:hv_cable"}, -- This should disappear + {{x=103,y=901,z=100}, "technic:hv_generator"}, -- This should disappear + }) + -- Build network + local net = get_network_fixture({x=100,y=901,z=100}) + local build_pos = {x=102,y=900,z=100} + + it("does not crash", function() + assert.equals(2, #net.PR_nodes) + assert.equals(6, count(net.all_nodes)) + world.set_node(build_pos, {name="air",param2=0}) + technic.network_node_on_dignode(build_pos, {"HV"}, "technic:hv_cable") + end) + + --[[ NOTE: Whole network is currently removed when cutting cables + + it("is removed from network", function() + assert.is_nil(net.all_nodes[minetest.hash_node_position(build_pos)]) + end) + + it("removes connected cables from network", function() + --assert.is_nil(net.all_nodes[minetest.hash_node_position({x=103,y=900,z=100})]) + assert.equals(3, count(net.all_nodes)) + end) + + it("removes connected machines from network", function() + --assert.is_nil(net.all_nodes[minetest.hash_node_position({x=103,y=901,z=100})]) + assert.equals(1, #net.PR_nodes) + end) + --]] + + it("removes network", function() + assert.is_nil(technic.networks[net.id]) + end) + + end) + + describe("cable digging below machine", function() + + world.layout({ + {{x=100,y=910,z=100}, "technic:hv_cable"}, + {{x=100,y=911,z=100}, "technic:switching_station"}, + {{x=101,y=910,z=100}, "technic:hv_cable"}, + {{x=101,y=911,z=100}, "technic:hv_generator"}, + {{x=102,y=910,z=100}, "technic:hv_cable"}, + {{x=103,y=910,z=100}, "technic:hv_cable"}, -- This cable is digged + {{x=103,y=911,z=100}, "technic:hv_generator"}, -- This should disappear + -- Multiple cable connections to machine at x 101, vertical cable + {{x=101,y=910,z=101}, "technic:hv_cable"}, -- cables for second connection + {{x=101,y=911,z=101}, "technic:hv_cable"}, -- cables for second connection, this cable is digged + }) + -- Build network + local net = get_network_fixture({x=100,y=911,z=100}) + local build_pos = {x=103,y=910,z=100} + local build_pos2 = {x=101,y=911,z=101} + + it("does not crash", function() + assert.equals(2, #net.PR_nodes) + assert.equals(8, count(net.all_nodes)) + world.set_node(build_pos, {name="air",param2=0}) + technic.network_node_on_dignode(build_pos, {"HV"}, "technic:hv_cable") + end) + + it("is removed from network", function() + assert.is_nil(net.all_nodes[minetest.hash_node_position(build_pos)]) + assert.equals(6, count(net.all_nodes)) + end) + + it("removes connected machines from network", function() + assert.is_nil(net.all_nodes[minetest.hash_node_position({x=103,y=911,z=100})]) + assert.equals(1, #net.PR_nodes) + end) + + it("does not remove network", function() + assert.is_hashed(technic.networks[net.id]) + end) + + it("keeps connected machines in network", function() + world.set_node(build_pos2, {name="air",param2=0}) + technic.network_node_on_dignode(build_pos2, {"HV"}, "technic:hv_cable") + assert.same({x=101,y=911,z=100}, net.all_nodes[minetest.hash_node_position({x=101,y=911,z=100})]) + assert.equals(1, #net.PR_nodes) + assert.equals(5, count(net.all_nodes)) + end) + + end) + + describe("machine digging", function() + + world.layout({ + {{x=100,y=920,z=100}, "technic:hv_cable"}, + {{x=100,y=921,z=100}, "technic:switching_station"}, + {{x=101,y=920,z=100}, "technic:hv_cable"}, + {{x=101,y=921,z=100}, "technic:hv_generator"}, + {{x=102,y=920,z=100}, "technic:hv_cable"}, + {{x=102,y=921,z=100}, "technic:hv_generator"}, -- This machine is digged + {{x=103,y=920,z=100}, "technic:hv_cable"}, + {{x=103,y=921,z=100}, "technic:hv_generator"}, + }) + -- Build network + local net = get_network_fixture({x=100,y=921,z=100}) + local build_pos = {x=102,y=921,z=100} + + it("does not crash", function() + assert.equals(3, #net.PR_nodes) + assert.equals(7, count(net.all_nodes)) + world.set_node(build_pos, {name="air",param2=0}) + technic.network_node_on_dignode(build_pos, {"HV"}, "technic:hv_generator") + end) + + it("is removed from network", function() + assert.is_nil(net.all_nodes[minetest.hash_node_position(build_pos)]) + end) + + it("does not remove other nodes from network", function() + assert.equals(2, #net.PR_nodes) + assert.equals(6, count(net.all_nodes)) + end) + + it("does not remove connected machines from network", function() + assert.same({x=101,y=921,z=100},net.all_nodes[minetest.hash_node_position({x=101,y=921,z=100})]) + assert.same({x=103,y=921,z=100},net.all_nodes[minetest.hash_node_position({x=103,y=921,z=100})]) + assert.equals(2, #net.PR_nodes) + end) + + it("does not remove network", function() + assert.is_hashed(technic.networks[net.id]) + end) + + end) + +end) diff --git a/technic/spec/fixtures/minetest.cfg b/technic/spec/fixtures/minetest.cfg new file mode 100644 index 0000000..e69de29 diff --git a/technic/spec/fixtures/minetest.lua b/technic/spec/fixtures/minetest.lua new file mode 100644 index 0000000..ed6a552 --- /dev/null +++ b/technic/spec/fixtures/minetest.lua @@ -0,0 +1,120 @@ +local function noop(...) end +local function dummy_coords(...) return { x = 123, y = 123, z = 123 } end + +_G.world = { nodes = {} } +local world = _G.world +_G.world.set_node = function(pos, node) + local hash = minetest.hash_node_position(pos) + world.nodes[hash] = node +end +_G.world.clear = function() _G.world.nodes = {} end +_G.world.layout = function(layout, offset) + _G.world.clear() + _G.world.add_layout(layout, offset) +end +_G.world.add_layout = function(layout, offset) + for _, node in ipairs(layout) do + local pos = node[1] + if offset then + pos.x = pos.x + offset.x + pos.y = pos.y + offset.y + pos.z = pos.z + offset.z + end + _G.world.set_node(pos, {name=node[2], param2=0}) + end +end + +_G.core = {} +_G.minetest = _G.core + +local configuration_file = fixture_path("minetest.cfg") +_G.Settings = function(fname) + local settings = { + _data = {}, + get = function(self, key) + return self._data[key] + end, + get_bool = function(self, key, default) + return + end, + set = function(...)end, + set_bool = function(...)end, + write = function(...)end, + remove = function(self, key) + self._data[key] = nil + return true + end, + get_names = function(self) + local result = {} + for k,_ in pairs(t) do + table.insert(result, k) + end + return result + end, + to_table = function(self) + local result = {} + for k,v in pairs(self._data) do + result[k] = v + end + return result + end, + } + -- Not even nearly perfect config parser but should be good enough for now + file = assert(io.open(fname, "r")) + for line in file:lines() do + for key, value in string.gmatch(line, "([^= ]+) *= *(.-)$") do + settings._data[key] = value + end + end + return settings +end +_G.core.settings = _G.Settings(configuration_file) + +_G.core.register_on_joinplayer = noop +_G.core.register_on_leaveplayer = noop + +fixture("minetest/game/misc") +fixture("minetest/common/misc_helpers") +fixture("minetest/common/vector") + +_G.minetest.registered_nodes = { + testnode1 = {}, + testnode2 = {}, +} + +_G.minetest.registered_chatcommands = {} + +_G.minetest.register_lbm = noop +_G.minetest.register_abm = noop +_G.minetest.register_chatcommand = noop +_G.minetest.chat_send_player = noop +_G.minetest.register_alias = noop +_G.minetest.register_craftitem = noop +_G.minetest.register_craft = noop +_G.minetest.register_node = noop +_G.minetest.register_on_placenode = noop +_G.minetest.register_on_dignode = noop +_G.minetest.register_on_mods_loaded = noop +_G.minetest.item_drop = noop + +_G.minetest.get_us_time = function() + local socket = require 'socket' + -- FIXME: Returns the time in seconds, relative to the origin of the universe. + return socket.gettime() * 1000 * 1000 +end + +_G.minetest.get_node = function(pos) + local hash = minetest.hash_node_position(pos) + return world.nodes[hash] or {name="IGNORE",param2=0} +end + +_G.minetest.get_modpath = function(...) return "./unit_test_modpath" end + +_G.minetest.get_pointed_thing_position = dummy_coords + +-- +-- Minetest default noop table +-- +local default = { __index = function(...) return function(...)end end } +_G.default = {} +setmetatable(_G.default, default) diff --git a/technic/spec/fixtures/minetest/common/misc_helpers.lua b/technic/spec/fixtures/minetest/common/misc_helpers.lua new file mode 100644 index 0000000..715f89b --- /dev/null +++ b/technic/spec/fixtures/minetest/common/misc_helpers.lua @@ -0,0 +1,702 @@ +-- Minetest: builtin/misc_helpers.lua + +-------------------------------------------------------------------------------- +-- Localize functions to avoid table lookups (better performance). +local string_sub, string_find = string.sub, string.find + +-------------------------------------------------------------------------------- +local function basic_dump(o) + local tp = type(o) + if tp == "number" then + return tostring(o) + elseif tp == "string" then + return string.format("%q", o) + elseif tp == "boolean" then + return tostring(o) + elseif tp == "nil" then + return "nil" + -- Uncomment for full function dumping support. + -- Not currently enabled because bytecode isn't very human-readable and + -- dump's output is intended for humans. + --elseif tp == "function" then + -- return string.format("loadstring(%q)", string.dump(o)) + else + return string.format("<%s>", tp) + end +end + +local keywords = { + ["and"] = true, + ["break"] = true, + ["do"] = true, + ["else"] = true, + ["elseif"] = true, + ["end"] = true, + ["false"] = true, + ["for"] = true, + ["function"] = true, + ["goto"] = true, -- Lua 5.2 + ["if"] = true, + ["in"] = true, + ["local"] = true, + ["nil"] = true, + ["not"] = true, + ["or"] = true, + ["repeat"] = true, + ["return"] = true, + ["then"] = true, + ["true"] = true, + ["until"] = true, + ["while"] = true, +} +local function is_valid_identifier(str) + if not str:find("^[a-zA-Z_][a-zA-Z0-9_]*$") or keywords[str] then + return false + end + return true +end + +-------------------------------------------------------------------------------- +-- Dumps values in a line-per-value format. +-- For example, {test = {"Testing..."}} becomes: +-- _["test"] = {} +-- _["test"][1] = "Testing..." +-- This handles tables as keys and circular references properly. +-- It also handles multiple references well, writing the table only once. +-- The dumped argument is internal-only. + +function dump2(o, name, dumped) + name = name or "_" + -- "dumped" is used to keep track of serialized tables to handle + -- multiple references and circular tables properly. + -- It only contains tables as keys. The value is the name that + -- the table has in the dump, eg: + -- {x = {"y"}} -> dumped[{"y"}] = '_["x"]' + dumped = dumped or {} + if type(o) ~= "table" then + return string.format("%s = %s\n", name, basic_dump(o)) + end + if dumped[o] then + return string.format("%s = %s\n", name, dumped[o]) + end + dumped[o] = name + -- This contains a list of strings to be concatenated later (because + -- Lua is slow at individual concatenation). + local t = {} + for k, v in pairs(o) do + local keyStr + if type(k) == "table" then + if dumped[k] then + keyStr = dumped[k] + else + -- Key tables don't have a name, so use one of + -- the form _G["table: 0xFFFFFFF"] + keyStr = string.format("_G[%q]", tostring(k)) + -- Dump key table + t[#t + 1] = dump2(k, keyStr, dumped) + end + else + keyStr = basic_dump(k) + end + local vname = string.format("%s[%s]", name, keyStr) + t[#t + 1] = dump2(v, vname, dumped) + end + return string.format("%s = {}\n%s", name, table.concat(t)) +end + +-------------------------------------------------------------------------------- +-- This dumps values in a one-statement format. +-- For example, {test = {"Testing..."}} becomes: +-- [[{ +-- test = { +-- "Testing..." +-- } +-- }]] +-- This supports tables as keys, but not circular references. +-- It performs poorly with multiple references as it writes out the full +-- table each time. +-- The indent field specifies a indentation string, it defaults to a tab. +-- Use the empty string to disable indentation. +-- The dumped and level arguments are internal-only. + +function dump(o, indent, nested, level) + local t = type(o) + if not level and t == "userdata" then + -- when userdata (e.g. player) is passed directly, print its metatable: + return "userdata metatable: " .. dump(getmetatable(o)) + end + if t ~= "table" then + return basic_dump(o) + end + + -- Contains table -> true/nil of currently nested tables + nested = nested or {} + if nested[o] then + return "" + end + nested[o] = true + indent = indent or "\t" + level = level or 1 + + local ret = {} + local dumped_indexes = {} + for i, v in ipairs(o) do + ret[#ret + 1] = dump(v, indent, nested, level + 1) + dumped_indexes[i] = true + end + for k, v in pairs(o) do + if not dumped_indexes[k] then + if type(k) ~= "string" or not is_valid_identifier(k) then + k = "["..dump(k, indent, nested, level + 1).."]" + end + v = dump(v, indent, nested, level + 1) + ret[#ret + 1] = k.." = "..v + end + end + nested[o] = nil + if indent ~= "" then + local indent_str = "\n"..string.rep(indent, level) + local end_indent_str = "\n"..string.rep(indent, level - 1) + return string.format("{%s%s%s}", + indent_str, + table.concat(ret, ","..indent_str), + end_indent_str) + end + return "{"..table.concat(ret, ", ").."}" +end + +-------------------------------------------------------------------------------- +function string.split(str, delim, include_empty, max_splits, sep_is_pattern) + delim = delim or "," + max_splits = max_splits or -2 + local items = {} + local pos, len = 1, #str + local plain = not sep_is_pattern + max_splits = max_splits + 1 + repeat + local np, npe = string_find(str, delim, pos, plain) + np, npe = (np or (len+1)), (npe or (len+1)) + if (not np) or (max_splits == 1) then + np = len + 1 + npe = np + end + local s = string_sub(str, pos, np - 1) + if include_empty or (s ~= "") then + max_splits = max_splits - 1 + items[#items + 1] = s + end + pos = npe + 1 + until (max_splits == 0) or (pos > (len + 1)) + return items +end + +-------------------------------------------------------------------------------- +function table.indexof(list, val) + for i, v in ipairs(list) do + if v == val then + return i + end + end + return -1 +end + +-------------------------------------------------------------------------------- +function string:trim() + return (self:gsub("^%s*(.-)%s*$", "%1")) +end + +-------------------------------------------------------------------------------- +function math.hypot(x, y) + local t + x = math.abs(x) + y = math.abs(y) + t = math.min(x, y) + x = math.max(x, y) + if x == 0 then return 0 end + t = t / x + return x * math.sqrt(1 + t * t) +end + +-------------------------------------------------------------------------------- +function math.sign(x, tolerance) + tolerance = tolerance or 0 + if x > tolerance then + return 1 + elseif x < -tolerance then + return -1 + end + return 0 +end + +-------------------------------------------------------------------------------- +function math.factorial(x) + assert(x % 1 == 0 and x >= 0, "factorial expects a non-negative integer") + if x >= 171 then + -- 171! is greater than the biggest double, no need to calculate + return math.huge + end + local v = 1 + for k = 2, x do + v = v * k + end + return v +end + +function core.formspec_escape(text) + if text ~= nil then + text = string.gsub(text,"\\","\\\\") + text = string.gsub(text,"%]","\\]") + text = string.gsub(text,"%[","\\[") + text = string.gsub(text,";","\\;") + text = string.gsub(text,",","\\,") + end + return text +end + + +function core.wrap_text(text, max_length, as_table) + local result = {} + local line = {} + if #text <= max_length then + return as_table and {text} or text + end + + for word in text:gmatch('%S+') do + local cur_length = #table.concat(line, ' ') + if cur_length > 0 and cur_length + #word + 1 >= max_length then + -- word wouldn't fit on current line, move to next line + table.insert(result, table.concat(line, ' ')) + line = {} + end + table.insert(line, word) + end + + table.insert(result, table.concat(line, ' ')) + return as_table and result or table.concat(result, '\n') +end + +-------------------------------------------------------------------------------- + +if INIT == "game" then + local dirs1 = {9, 18, 7, 12} + local dirs2 = {20, 23, 22, 21} + + function core.rotate_and_place(itemstack, placer, pointed_thing, + infinitestacks, orient_flags, prevent_after_place) + orient_flags = orient_flags or {} + + local unode = core.get_node_or_nil(pointed_thing.under) + if not unode then + return + end + local undef = core.registered_nodes[unode.name] + if undef and undef.on_rightclick then + return undef.on_rightclick(pointed_thing.under, unode, placer, + itemstack, pointed_thing) + end + local fdir = placer and core.dir_to_facedir(placer:get_look_dir()) or 0 + + local above = pointed_thing.above + local under = pointed_thing.under + local iswall = (above.y == under.y) + local isceiling = not iswall and (above.y < under.y) + + if undef and undef.buildable_to then + iswall = false + end + + if orient_flags.force_floor then + iswall = false + isceiling = false + elseif orient_flags.force_ceiling then + iswall = false + isceiling = true + elseif orient_flags.force_wall then + iswall = true + isceiling = false + elseif orient_flags.invert_wall then + iswall = not iswall + end + + local param2 = fdir + if iswall then + param2 = dirs1[fdir + 1] + elseif isceiling then + if orient_flags.force_facedir then + param2 = 20 + else + param2 = dirs2[fdir + 1] + end + else -- place right side up + if orient_flags.force_facedir then + param2 = 0 + end + end + + local old_itemstack = ItemStack(itemstack) + local new_itemstack = core.item_place_node(itemstack, placer, + pointed_thing, param2, prevent_after_place) + return infinitestacks and old_itemstack or new_itemstack + end + + +-------------------------------------------------------------------------------- +--Wrapper for rotate_and_place() to check for sneak and assume Creative mode +--implies infinite stacks when performing a 6d rotation. +-------------------------------------------------------------------------------- + local creative_mode_cache = core.settings:get_bool("creative_mode") + local function is_creative(name) + return creative_mode_cache or + core.check_player_privs(name, {creative = true}) + end + + core.rotate_node = function(itemstack, placer, pointed_thing) + local name = placer and placer:get_player_name() or "" + local invert_wall = placer and placer:get_player_control().sneak or false + return core.rotate_and_place(itemstack, placer, pointed_thing, + is_creative(name), + {invert_wall = invert_wall}, true) + end +end + +-------------------------------------------------------------------------------- +function core.explode_table_event(evt) + if evt ~= nil then + local parts = evt:split(":") + if #parts == 3 then + local t = parts[1]:trim() + local r = tonumber(parts[2]:trim()) + local c = tonumber(parts[3]:trim()) + if type(r) == "number" and type(c) == "number" + and t ~= "INV" then + return {type=t, row=r, column=c} + end + end + end + return {type="INV", row=0, column=0} +end + +-------------------------------------------------------------------------------- +function core.explode_textlist_event(evt) + if evt ~= nil then + local parts = evt:split(":") + if #parts == 2 then + local t = parts[1]:trim() + local r = tonumber(parts[2]:trim()) + if type(r) == "number" and t ~= "INV" then + return {type=t, index=r} + end + end + end + return {type="INV", index=0} +end + +-------------------------------------------------------------------------------- +function core.explode_scrollbar_event(evt) + local retval = core.explode_textlist_event(evt) + + retval.value = retval.index + retval.index = nil + + return retval +end + +-------------------------------------------------------------------------------- +function core.rgba(r, g, b, a) + return a and string.format("#%02X%02X%02X%02X", r, g, b, a) or + string.format("#%02X%02X%02X", r, g, b) +end + +-------------------------------------------------------------------------------- +function core.pos_to_string(pos, decimal_places) + local x = pos.x + local y = pos.y + local z = pos.z + if decimal_places ~= nil then + x = string.format("%." .. decimal_places .. "f", x) + y = string.format("%." .. decimal_places .. "f", y) + z = string.format("%." .. decimal_places .. "f", z) + end + return "(" .. x .. "," .. y .. "," .. z .. ")" +end + +-------------------------------------------------------------------------------- +function core.string_to_pos(value) + if value == nil then + return nil + end + + local p = {} + p.x, p.y, p.z = string.match(value, "^([%d.-]+)[, ] *([%d.-]+)[, ] *([%d.-]+)$") + if p.x and p.y and p.z then + p.x = tonumber(p.x) + p.y = tonumber(p.y) + p.z = tonumber(p.z) + return p + end + p = {} + p.x, p.y, p.z = string.match(value, "^%( *([%d.-]+)[, ] *([%d.-]+)[, ] *([%d.-]+) *%)$") + if p.x and p.y and p.z then + p.x = tonumber(p.x) + p.y = tonumber(p.y) + p.z = tonumber(p.z) + return p + end + return nil +end + + +-------------------------------------------------------------------------------- +function core.string_to_area(value) + local p1, p2 = unpack(value:split(") (")) + if p1 == nil or p2 == nil then + return nil + end + + p1 = core.string_to_pos(p1 .. ")") + p2 = core.string_to_pos("(" .. p2) + if p1 == nil or p2 == nil then + return nil + end + + return p1, p2 +end + +local function test_string_to_area() + local p1, p2 = core.string_to_area("(10.0, 5, -2) ( 30.2, 4, -12.53)") + assert(p1.x == 10.0 and p1.y == 5 and p1.z == -2) + assert(p2.x == 30.2 and p2.y == 4 and p2.z == -12.53) + + p1, p2 = core.string_to_area("(10.0, 5, -2 30.2, 4, -12.53") + assert(p1 == nil and p2 == nil) + + p1, p2 = core.string_to_area("(10.0, 5,) -2 fgdf2, 4, -12.53") + assert(p1 == nil and p2 == nil) +end + +test_string_to_area() + +-------------------------------------------------------------------------------- +function table.copy(t, seen) + local n = {} + seen = seen or {} + seen[t] = n + for k, v in pairs(t) do + n[(type(k) == "table" and (seen[k] or table.copy(k, seen))) or k] = + (type(v) == "table" and (seen[v] or table.copy(v, seen))) or v + end + return n +end + + +function table.insert_all(t, other) + for i=1, #other do + t[#t + 1] = other[i] + end + return t +end + + +function table.key_value_swap(t) + local ti = {} + for k,v in pairs(t) do + ti[v] = k + end + return ti +end + + +function table.shuffle(t, from, to, random) + from = from or 1 + to = to or #t + random = random or math.random + local n = to - from + 1 + while n > 1 do + local r = from + n-1 + local l = from + random(0, n-1) + t[l], t[r] = t[r], t[l] + n = n-1 + end +end + + +-------------------------------------------------------------------------------- +-- mainmenu only functions +-------------------------------------------------------------------------------- +if INIT == "mainmenu" then + function core.get_game(index) + local games = core.get_games() + + if index > 0 and index <= #games then + return games[index] + end + + return nil + end +end + +if INIT == "client" or INIT == "mainmenu" then + function fgettext_ne(text, ...) + text = core.gettext(text) + local arg = {n=select('#', ...), ...} + if arg.n >= 1 then + -- Insert positional parameters ($1, $2, ...) + local result = '' + local pos = 1 + while pos <= text:len() do + local newpos = text:find('[$]', pos) + if newpos == nil then + result = result .. text:sub(pos) + pos = text:len() + 1 + else + local paramindex = + tonumber(text:sub(newpos+1, newpos+1)) + result = result .. text:sub(pos, newpos-1) + .. tostring(arg[paramindex]) + pos = newpos + 2 + end + end + text = result + end + return text + end + + function fgettext(text, ...) + return core.formspec_escape(fgettext_ne(text, ...)) + end +end + +local ESCAPE_CHAR = string.char(0x1b) + +function core.get_color_escape_sequence(color) + return ESCAPE_CHAR .. "(c@" .. color .. ")" +end + +function core.get_background_escape_sequence(color) + return ESCAPE_CHAR .. "(b@" .. color .. ")" +end + +function core.colorize(color, message) + local lines = tostring(message):split("\n", true) + local color_code = core.get_color_escape_sequence(color) + + for i, line in ipairs(lines) do + lines[i] = color_code .. line + end + + return table.concat(lines, "\n") .. core.get_color_escape_sequence("#ffffff") +end + + +function core.strip_foreground_colors(str) + return (str:gsub(ESCAPE_CHAR .. "%(c@[^)]+%)", "")) +end + +function core.strip_background_colors(str) + return (str:gsub(ESCAPE_CHAR .. "%(b@[^)]+%)", "")) +end + +function core.strip_colors(str) + return (str:gsub(ESCAPE_CHAR .. "%([bc]@[^)]+%)", "")) +end + +function core.translate(textdomain, str, ...) + local start_seq + if textdomain == "" then + start_seq = ESCAPE_CHAR .. "T" + else + start_seq = ESCAPE_CHAR .. "(T@" .. textdomain .. ")" + end + local arg = {n=select('#', ...), ...} + local end_seq = ESCAPE_CHAR .. "E" + local arg_index = 1 + local translated = str:gsub("@(.)", function(matched) + local c = string.byte(matched) + if string.byte("1") <= c and c <= string.byte("9") then + local a = c - string.byte("0") + if a ~= arg_index then + error("Escape sequences in string given to core.translate " .. + "are not in the correct order: got @" .. matched .. + "but expected @" .. tostring(arg_index)) + end + if a > arg.n then + error("Not enough arguments provided to core.translate") + end + arg_index = arg_index + 1 + return ESCAPE_CHAR .. "F" .. arg[a] .. ESCAPE_CHAR .. "E" + elseif matched == "n" then + return "\n" + else + return matched + end + end) + if arg_index < arg.n + 1 then + error("Too many arguments provided to core.translate") + end + return start_seq .. translated .. end_seq +end + +function core.get_translator(textdomain) + return function(str, ...) return core.translate(textdomain or "", str, ...) end +end + +-------------------------------------------------------------------------------- +-- Returns the exact coordinate of a pointed surface +-------------------------------------------------------------------------------- +function core.pointed_thing_to_face_pos(placer, pointed_thing) + -- Avoid crash in some situations when player is inside a node, causing + -- 'above' to equal 'under'. + if vector.equals(pointed_thing.above, pointed_thing.under) then + return pointed_thing.under + end + + local eye_height = placer:get_properties().eye_height + local eye_offset_first = placer:get_eye_offset() + local node_pos = pointed_thing.under + local camera_pos = placer:get_pos() + local pos_off = vector.multiply( + vector.subtract(pointed_thing.above, node_pos), 0.5) + local look_dir = placer:get_look_dir() + local offset, nc + local oc = {} + + for c, v in pairs(pos_off) do + if nc or v == 0 then + oc[#oc + 1] = c + else + offset = v + nc = c + end + end + + local fine_pos = {[nc] = node_pos[nc] + offset} + camera_pos.y = camera_pos.y + eye_height + eye_offset_first.y / 10 + local f = (node_pos[nc] + offset - camera_pos[nc]) / look_dir[nc] + + for i = 1, #oc do + fine_pos[oc[i]] = camera_pos[oc[i]] + look_dir[oc[i]] * f + end + return fine_pos +end + +function core.string_to_privs(str, delim) + assert(type(str) == "string") + delim = delim or ',' + local privs = {} + for _, priv in pairs(string.split(str, delim)) do + privs[priv:trim()] = true + end + return privs +end + +function core.privs_to_string(privs, delim) + assert(type(privs) == "table") + delim = delim or ',' + local list = {} + for priv, bool in pairs(privs) do + if bool then + list[#list + 1] = priv + end + end + return table.concat(list, delim) +end diff --git a/technic/spec/fixtures/minetest/common/vector.lua b/technic/spec/fixtures/minetest/common/vector.lua new file mode 100644 index 0000000..d6437de --- /dev/null +++ b/technic/spec/fixtures/minetest/common/vector.lua @@ -0,0 +1,242 @@ + +vector = {} + +function vector.new(a, b, c) + if type(a) == "table" then + assert(a.x and a.y and a.z, "Invalid vector passed to vector.new()") + return {x=a.x, y=a.y, z=a.z} + elseif a then + assert(b and c, "Invalid arguments for vector.new()") + return {x=a, y=b, z=c} + end + return {x=0, y=0, z=0} +end + +function vector.equals(a, b) + return a.x == b.x and + a.y == b.y and + a.z == b.z +end + +function vector.length(v) + return math.hypot(v.x, math.hypot(v.y, v.z)) +end + +function vector.normalize(v) + local len = vector.length(v) + if len == 0 then + return {x=0, y=0, z=0} + else + return vector.divide(v, len) + end +end + +function vector.floor(v) + return { + x = math.floor(v.x), + y = math.floor(v.y), + z = math.floor(v.z) + } +end + +function vector.round(v) + return { + x = math.floor(v.x + 0.5), + y = math.floor(v.y + 0.5), + z = math.floor(v.z + 0.5) + } +end + +function vector.apply(v, func) + return { + x = func(v.x), + y = func(v.y), + z = func(v.z) + } +end + +function vector.distance(a, b) + local x = a.x - b.x + local y = a.y - b.y + local z = a.z - b.z + return math.hypot(x, math.hypot(y, z)) +end + +function vector.direction(pos1, pos2) + return vector.normalize({ + x = pos2.x - pos1.x, + y = pos2.y - pos1.y, + z = pos2.z - pos1.z + }) +end + +function vector.angle(a, b) + local dotp = vector.dot(a, b) + local cp = vector.cross(a, b) + local crossplen = vector.length(cp) + return math.atan2(crossplen, dotp) +end + +function vector.dot(a, b) + return a.x * b.x + a.y * b.y + a.z * b.z +end + +function vector.cross(a, b) + return { + x = a.y * b.z - a.z * b.y, + y = a.z * b.x - a.x * b.z, + z = a.x * b.y - a.y * b.x + } +end + +function vector.add(a, b) + if type(b) == "table" then + return {x = a.x + b.x, + y = a.y + b.y, + z = a.z + b.z} + else + return {x = a.x + b, + y = a.y + b, + z = a.z + b} + end +end + +function vector.subtract(a, b) + if type(b) == "table" then + return {x = a.x - b.x, + y = a.y - b.y, + z = a.z - b.z} + else + return {x = a.x - b, + y = a.y - b, + z = a.z - b} + end +end + +function vector.multiply(a, b) + if type(b) == "table" then + return {x = a.x * b.x, + y = a.y * b.y, + z = a.z * b.z} + else + return {x = a.x * b, + y = a.y * b, + z = a.z * b} + end +end + +function vector.divide(a, b) + if type(b) == "table" then + return {x = a.x / b.x, + y = a.y / b.y, + z = a.z / b.z} + else + return {x = a.x / b, + y = a.y / b, + z = a.z / b} + end +end + +function vector.offset(v, x, y, z) + return {x = v.x + x, + y = v.y + y, + z = v.z + z} +end + +function vector.sort(a, b) + return {x = math.min(a.x, b.x), y = math.min(a.y, b.y), z = math.min(a.z, b.z)}, + {x = math.max(a.x, b.x), y = math.max(a.y, b.y), z = math.max(a.z, b.z)} +end + +local function sin(x) + if x % math.pi == 0 then + return 0 + else + return math.sin(x) + end +end + +local function cos(x) + if x % math.pi == math.pi / 2 then + return 0 + else + return math.cos(x) + end +end + +function vector.rotate_around_axis(v, axis, angle) + local cosangle = cos(angle) + local sinangle = sin(angle) + axis = vector.normalize(axis) + -- https://en.wikipedia.org/wiki/Rodrigues%27_rotation_formula + local dot_axis = vector.multiply(axis, vector.dot(axis, v)) + local cross = vector.cross(v, axis) + return vector.new( + cross.x * sinangle + (v.x - dot_axis.x) * cosangle + dot_axis.x, + cross.y * sinangle + (v.y - dot_axis.y) * cosangle + dot_axis.y, + cross.z * sinangle + (v.z - dot_axis.z) * cosangle + dot_axis.z + ) +end + +function vector.rotate(v, rot) + local sinpitch = sin(-rot.x) + local sinyaw = sin(-rot.y) + local sinroll = sin(-rot.z) + local cospitch = cos(rot.x) + local cosyaw = cos(rot.y) + local cosroll = math.cos(rot.z) + -- Rotation matrix that applies yaw, pitch and roll + local matrix = { + { + sinyaw * sinpitch * sinroll + cosyaw * cosroll, + sinyaw * sinpitch * cosroll - cosyaw * sinroll, + sinyaw * cospitch, + }, + { + cospitch * sinroll, + cospitch * cosroll, + -sinpitch, + }, + { + cosyaw * sinpitch * sinroll - sinyaw * cosroll, + cosyaw * sinpitch * cosroll + sinyaw * sinroll, + cosyaw * cospitch, + }, + } + -- Compute matrix multiplication: `matrix` * `v` + return vector.new( + matrix[1][1] * v.x + matrix[1][2] * v.y + matrix[1][3] * v.z, + matrix[2][1] * v.x + matrix[2][2] * v.y + matrix[2][3] * v.z, + matrix[3][1] * v.x + matrix[3][2] * v.y + matrix[3][3] * v.z + ) +end + +function vector.dir_to_rotation(forward, up) + forward = vector.normalize(forward) + local rot = {x = math.asin(forward.y), y = -math.atan2(forward.x, forward.z), z = 0} + if not up then + return rot + end + assert(vector.dot(forward, up) < 0.000001, + "Invalid vectors passed to vector.dir_to_rotation().") + up = vector.normalize(up) + -- Calculate vector pointing up with roll = 0, just based on forward vector. + local forwup = vector.rotate({x = 0, y = 1, z = 0}, rot) + -- 'forwup' and 'up' are now in a plane with 'forward' as normal. + -- The angle between them is the absolute of the roll value we're looking for. + rot.z = vector.angle(forwup, up) + + -- Since vector.angle never returns a negative value or a value greater + -- than math.pi, rot.z has to be inverted sometimes. + -- To determine wether this is the case, we rotate the up vector back around + -- the forward vector and check if it worked out. + local back = vector.rotate_around_axis(up, forward, -rot.z) + + -- We don't use vector.equals for this because of floating point imprecision. + if (back.x - forwup.x) * (back.x - forwup.x) + + (back.y - forwup.y) * (back.y - forwup.y) + + (back.z - forwup.z) * (back.z - forwup.z) > 0.0000001 then + rot.z = -rot.z + end + return rot +end diff --git a/technic/spec/fixtures/minetest/game/misc.lua b/technic/spec/fixtures/minetest/game/misc.lua new file mode 100644 index 0000000..341e613 --- /dev/null +++ b/technic/spec/fixtures/minetest/game/misc.lua @@ -0,0 +1,262 @@ +-- Minetest: builtin/misc.lua + +-- +-- Misc. API functions +-- + +function core.check_player_privs(name, ...) + if core.is_player(name) then + name = name:get_player_name() + elseif type(name) ~= "string" then + error("core.check_player_privs expects a player or playername as " .. + "argument.", 2) + end + + local requested_privs = {...} + local player_privs = core.get_player_privs(name) + local missing_privileges = {} + + if type(requested_privs[1]) == "table" then + -- We were provided with a table like { privA = true, privB = true }. + for priv, value in pairs(requested_privs[1]) do + if value and not player_privs[priv] then + missing_privileges[#missing_privileges + 1] = priv + end + end + else + -- Only a list, we can process it directly. + for key, priv in pairs(requested_privs) do + if not player_privs[priv] then + missing_privileges[#missing_privileges + 1] = priv + end + end + end + + if #missing_privileges > 0 then + return false, missing_privileges + end + + return true, "" +end + + +function core.send_join_message(player_name) + if not core.is_singleplayer() then + core.chat_send_all("*** " .. player_name .. " joined the game.") + end +end + + +function core.send_leave_message(player_name, timed_out) + local announcement = "*** " .. player_name .. " left the game." + if timed_out then + announcement = announcement .. " (timed out)" + end + core.chat_send_all(announcement) +end + + +core.register_on_joinplayer(function(player) + local player_name = player:get_player_name() + if not core.is_singleplayer() then + local status = core.get_server_status(player_name, true) + if status and status ~= "" then + core.chat_send_player(player_name, status) + end + end + core.send_join_message(player_name) +end) + + +core.register_on_leaveplayer(function(player, timed_out) + local player_name = player:get_player_name() + core.send_leave_message(player_name, timed_out) +end) + + +function core.is_player(player) + -- a table being a player is also supported because it quacks sufficiently + -- like a player if it has the is_player function + local t = type(player) + return (t == "userdata" or t == "table") and + type(player.is_player) == "function" and player:is_player() +end + + +function core.player_exists(name) + return core.get_auth_handler().get_auth(name) ~= nil +end + + +-- Returns two position vectors representing a box of `radius` in each +-- direction centered around the player corresponding to `player_name` + +function core.get_player_radius_area(player_name, radius) + local player = core.get_player_by_name(player_name) + if player == nil then + return nil + end + + local p1 = player:get_pos() + local p2 = p1 + + if radius then + p1 = vector.subtract(p1, radius) + p2 = vector.add(p2, radius) + end + + return p1, p2 +end + + +function core.hash_node_position(pos) + return (pos.z + 32768) * 65536 * 65536 + + (pos.y + 32768) * 65536 + + pos.x + 32768 +end + + +function core.get_position_from_hash(hash) + local pos = {} + pos.x = (hash % 65536) - 32768 + hash = math.floor(hash / 65536) + pos.y = (hash % 65536) - 32768 + hash = math.floor(hash / 65536) + pos.z = (hash % 65536) - 32768 + return pos +end + + +function core.get_item_group(name, group) + if not core.registered_items[name] or not + core.registered_items[name].groups[group] then + return 0 + end + return core.registered_items[name].groups[group] +end + + +function core.get_node_group(name, group) + core.log("deprecated", "Deprecated usage of get_node_group, use get_item_group instead") + return core.get_item_group(name, group) +end + + +function core.setting_get_pos(name) + local value = core.settings:get(name) + if not value then + return nil + end + return core.string_to_pos(value) +end + + +-- To be overriden by protection mods + +function core.is_protected(pos, name) + return false +end + + +function core.record_protection_violation(pos, name) + for _, func in pairs(core.registered_on_protection_violation) do + func(pos, name) + end +end + +-- To be overridden by Creative mods + +local creative_mode_cache = core.settings:get_bool("creative_mode") +function core.is_creative_enabled(name) + return creative_mode_cache +end + +-- Checks if specified volume intersects a protected volume + +function core.is_area_protected(minp, maxp, player_name, interval) + -- 'interval' is the largest allowed interval for the 3D lattice of checks. + + -- Compute the optimal float step 'd' for each axis so that all corners and + -- borders are checked. 'd' will be smaller or equal to 'interval'. + -- Subtracting 1e-4 ensures that the max co-ordinate will be reached by the + -- for loop (which might otherwise not be the case due to rounding errors). + + -- Default to 4 + interval = interval or 4 + local d = {} + + for _, c in pairs({"x", "y", "z"}) do + if minp[c] > maxp[c] then + -- Repair positions: 'minp' > 'maxp' + local tmp = maxp[c] + maxp[c] = minp[c] + minp[c] = tmp + end + + if maxp[c] > minp[c] then + d[c] = (maxp[c] - minp[c]) / + math.ceil((maxp[c] - minp[c]) / interval) - 1e-4 + else + d[c] = 1 -- Any value larger than 0 to avoid division by zero + end + end + + for zf = minp.z, maxp.z, d.z do + local z = math.floor(zf + 0.5) + for yf = minp.y, maxp.y, d.y do + local y = math.floor(yf + 0.5) + for xf = minp.x, maxp.x, d.x do + local x = math.floor(xf + 0.5) + local pos = {x = x, y = y, z = z} + if core.is_protected(pos, player_name) then + return pos + end + end + end + end + return false +end + + +local raillike_ids = {} +local raillike_cur_id = 0 +function core.raillike_group(name) + local id = raillike_ids[name] + if not id then + raillike_cur_id = raillike_cur_id + 1 + raillike_ids[name] = raillike_cur_id + id = raillike_cur_id + end + return id +end + + +-- HTTP callback interface + +function core.http_add_fetch(httpenv) + httpenv.fetch = function(req, callback) + local handle = httpenv.fetch_async(req) + + local function update_http_status() + local res = httpenv.fetch_async_get(handle) + if res.completed then + callback(res) + else + core.after(0, update_http_status) + end + end + core.after(0, update_http_status) + end + + return httpenv +end + + +function core.close_formspec(player_name, formname) + return core.show_formspec(player_name, formname, "") +end + + +function core.cancel_shutdown_requests() + core.request_shutdown("", false, -1) +end diff --git a/technic/spec/fixtures/minetest/player.lua b/technic/spec/fixtures/minetest/player.lua new file mode 100644 index 0000000..72a6313 --- /dev/null +++ b/technic/spec/fixtures/minetest/player.lua @@ -0,0 +1,43 @@ + +fixture("minetest") + +local players = {} + +_G.minetest.check_player_privs = function(player_or_name, ...) + local player_privs + if type(player_or_name) == "table" then + player_privs = player_or_name._privs + else + player_privs = players[player_or_name]._privs + end + local missing_privs = {} + local has_priv = false + local arg={...} + for _,priv in ipairs(arg) do + if player_privs[priv] then + has_priv = true + else + table.insert(missing_privs, priv) + end + end + return has_priv, missing_privs +end + +_G.minetest.get_player_by_name = function(name) + return players[name] +end + +_G.Player = function(name, privs) + local player = { + _name = name or "SX", + _privs = privs or { test_priv=1 }, + get_player_control = function(self) + return {} + end, + get_player_name = function(self) + return self._name + end + } + table.insert(players, player) + return player +end diff --git a/technic/spec/fixtures/minetest/protection.lua b/technic/spec/fixtures/minetest/protection.lua new file mode 100644 index 0000000..4246ff9 --- /dev/null +++ b/technic/spec/fixtures/minetest/protection.lua @@ -0,0 +1,18 @@ + +fixture("minetest") + +_G.ProtectedPos = function() + return { x = 123, y = 123, z = 123 } +end + +_G.UnprotectedPos = function() + return { x = -123, y = -123, z = -123 } +end + +minetest.is_protected = function(pos, name) + return pos.x == 123 and pos.y == 123 and pos.z == 123 +end + +minetest.record_protection_violation = function(pos, name) + -- noop +end diff --git a/technic/spec/fixtures/network.lua b/technic/spec/fixtures/network.lua new file mode 100644 index 0000000..bf9815e --- /dev/null +++ b/technic/spec/fixtures/network.lua @@ -0,0 +1,23 @@ + +_G.technic = {} +_G.technic.S = string.format +_G.technic.getter = function(...) return "" end +_G.technic.get_or_load_node = minetest.get_node +_G.technic.digilines = { + rules = { + -- digilines.rules.default + {x= 1,y= 0,z= 0},{x=-1,y= 0,z= 0}, -- along x beside + {x= 0,y= 0,z= 1},{x= 0,y= 0,z=-1}, -- along z beside + {x= 1,y= 1,z= 0},{x=-1,y= 1,z= 0}, -- 1 node above along x diagonal + {x= 0,y= 1,z= 1},{x= 0,y= 1,z=-1}, -- 1 node above along z diagonal + {x= 1,y=-1,z= 0},{x=-1,y=-1,z= 0}, -- 1 node below along x diagonal + {x= 0,y=-1,z= 1},{x= 0,y=-1,z=-1}, -- 1 node below along z diagonal + -- added rules for digi cable + {x = 0, y = -1, z = 0}, -- along y below + } +} + +sourcefile("register") +technic.register_tier("LV", "Busted LV") +technic.register_tier("MV", "Busted MV") +technic.register_tier("HV", "Busted HV") diff --git a/technic/spec/fixtures/pipeworks.lua b/technic/spec/fixtures/pipeworks.lua new file mode 100644 index 0000000..80a4441 --- /dev/null +++ b/technic/spec/fixtures/pipeworks.lua @@ -0,0 +1,2 @@ + +_G.pipeworks = {} diff --git a/technic/spec/network_spec.lua b/technic/spec/network_spec.lua new file mode 100644 index 0000000..0970d51 --- /dev/null +++ b/technic/spec/network_spec.lua @@ -0,0 +1,417 @@ +dofile("spec/test_helpers.lua") +--[[ + Technic network unit tests. + Execute busted at technic source directory. +--]] + +-- Load fixtures required by tests +fixture("minetest") +fixture("minetest/player") +fixture("minetest/protection") + +fixture("pipeworks") +fixture("network") + +sourcefile("machines/network") + +sourcefile("machines/register/cables") +sourcefile("machines/LV/cables") +sourcefile("machines/MV/cables") +sourcefile("machines/HV/cables") + +sourcefile("machines/register/generator") +sourcefile("machines/HV/generator") + +world.layout({ + {{x=100,y=100,z=100}, "technic:lv_cable"}, + {{x=101,y=100,z=100}, "technic:lv_cable"}, + {{x=102,y=100,z=100}, "technic:lv_cable"}, + {{x=103,y=100,z=100}, "technic:lv_cable"}, + {{x=104,y=100,z=100}, "technic:lv_cable"}, + {{x=100,y=101,z=100}, "technic:switching_station"}, + + {{x=100,y=200,z=100}, "technic:mv_cable"}, + {{x=101,y=200,z=100}, "technic:mv_cable"}, + {{x=102,y=200,z=100}, "technic:mv_cable"}, + {{x=103,y=200,z=100}, "technic:mv_cable"}, + {{x=104,y=200,z=100}, "technic:mv_cable"}, + {{x=100,y=201,z=100}, "technic:switching_station"}, + + {{x=100,y=300,z=100}, "technic:hv_cable"}, + {{x=101,y=300,z=100}, "technic:hv_cable"}, + {{x=102,y=300,z=100}, "technic:hv_cable"}, + {{x=103,y=300,z=100}, "technic:hv_cable"}, + {{x=104,y=300,z=100}, "technic:hv_cable"}, + {{x=100,y=301,z=100}, "technic:switching_station"}, + + -- For network lookup function -> returns correct network for position + {{x=100,y=500,z=100}, "technic:hv_cable"}, + {{x=101,y=500,z=100}, "technic:hv_cable"}, + {{x=102,y=500,z=100}, "technic:hv_cable"}, + {{x=103,y=500,z=100}, "technic:hv_cable"}, + {{x=104,y=500,z=100}, "technic:hv_cable"}, + {{x=100,y=501,z=100}, "technic:hv_generator"}, + {{x=101,y=501,z=100}, "technic:hv_cable"}, + {{x=102,y=501,z=100}, "technic:switching_station"}, + {{x=100,y=502,z=100}, "technic:hv_cable"}, + {{x=101,y=502,z=100}, "technic:hv_cable"}, +}) + +describe("Power network helper", function() + + -- Simple network position fixtures + local net_id = 65536 + local pos = { x = -32768, y = -32767, z = -32768 } + local sw_pos = { x = -32768, y = -32766, z = -32768 } + + describe("network lookup functions", function() + + it("does not fail if network missing", function() + assert.is_nil( technic.remove_network(9999) ) + end) + + it("returns correct position for network", function() + assert.same(pos, technic.network2pos(net_id) ) + assert.same(sw_pos, technic.network2sw_pos(net_id) ) + end) + + it("returns correct network for position", function() + local net_id = technic.create_network({x=100,y=501,z=100}) + assert.same(net_id, technic.pos2network({x=100,y=500,z=100}) ) + assert.same(net_id, technic.sw_pos2network({x=100,y=501,z=100}) ) + end) + + it("returns nil tier for empty position", function() + assert.is_nil(technic.sw_pos2tier({x=9999,y=9999,z=9999})) + end) + + it("returns correct tier for switching station position", function() + -- World is defined in fixtures/network.lua + assert.same("LV", technic.sw_pos2tier({x=100,y=101,z=100})) + assert.same("MV", technic.sw_pos2tier({x=100,y=201,z=100})) + assert.same("HV", technic.sw_pos2tier({x=100,y=301,z=100})) + end) + + end) + + describe("network constructors/destructors", function() + + -- Build network + local net_id = technic.create_network({x=100,y=501,z=100}) + assert.is_number(net_id) + + it("creates network", function() + assert.is_hashed(technic.networks[net_id]) + end) + + it("builds network", function() + local net = technic.networks[net_id] + -- Network table is valid + assert.is_indexed(net.PR_nodes) + assert.is_indexed(net.RE_nodes) + assert.is_indexed(net.BA_nodes) + assert.equals(9, count(net.all_nodes)) + assert.is_hashed(net.all_nodes) + end) + + it("does not add duplicates to network", function() + local net = technic.networks[net_id] + -- Local network table is still valid + assert.equals(1, count(net.PR_nodes)) + assert.equals(0, count(net.RE_nodes)) + assert.equals(0, count(net.BA_nodes)) + assert.equals(9, count(net.all_nodes)) + -- FIXME: This might be wrong if technic.cables should contain only cables and not machines + assert.equals(9, count(technic.cables)) + end) + + it("removes network", function() + technic.remove_network(net_id) + assert.is_nil(technic.networks[net_id]) + -- TODO: Verify that there's no lefover positions in technic.cables + end) + + end) + + --[[ TODO: + technic.remove_network_node + --]] + + describe("Power network timeout functions technic.touch_node and technic.get_timeout", function() + + it("returns zero if no data available", function() + assert.equals(0, + technic.get_timeout("LV", {x=9999,y=9999,z=9999}) + ) + assert.equals(0, + technic.get_timeout("HV", {x=9999,y=9999,z=9999}) + ) + end) + + it("returns timeout if data is available", function() + technic.touch_node("LV", {x=123,y=123,z=123}, 42) + assert.equals(42, + technic.get_timeout("LV", {x=123,y=123,z=123}) + ) + technic.touch_node("HV", {x=123,y=123,z=123}, 74) + assert.equals(74, + technic.get_timeout("HV", {x=123,y=123,z=123}) + ) + end) + + end) + +end) + +-- Clean up, left following here just for easy copy pasting stuff from previous proj + +--[[ +describe("Metatool API protection", function() + + it("metatool.is_protected bypass privileges", function() + local value = metatool.is_protected(ProtectedPos(), Player(), "test_priv", true) + assert.equals(false, value) + end) + + it("metatool.is_protected no bypass privileges", function() + local value = metatool.is_protected(ProtectedPos(), Player(), "test_priv2", true) + assert.equals(true, value) + end) + + it("metatool.is_protected bypass privileges, unprotected", function() + local value = metatool.is_protected(UnprotectedPos(), Player(), "test_priv", true) + assert.equals(false, value) + end) + + it("metatool.is_protected no bypass privileges, unprotected", function() + local value = metatool.is_protected(UnprotectedPos(), Player(), "test_priv2", true) + assert.equals(false, value) + end) + +end) + +describe("Metatool API tool namespace", function() + + it("Create invalid namespace", function() + local tool = { ns = metatool.ns, name = 'invalid' } + local value = tool:ns("invalid", { + testkey = "testvalue" + }) + assert.is_nil(metatool:ns("testns")) + end) + + it("Get nonexistent namespace", function() + assert.is_nil(metatool.ns("nonexistent")) + end) + + it("Create tool namespace", function() + -- FIXME: Hack to get fake tool available, replace with real tool + local tool = { ns = metatool.ns, name = 'mytool' } + metatool.tools["metatool:mytool"] = tool + -- Actual tests + local value = tool:ns({ + testkey = "testvalue" + }) + local expected = { + testkey = "testvalue" + } + assert.same(expected, metatool.ns("mytool")) + end) + +end) + +describe("Metatool API tool registration", function() + + it("Register tool default configuration", function() + -- Tool registration + local definition = { + description = 'UnitTestTool Description', + name = 'UnitTestTool', + texture = 'utt.png', + recipe = {{'air'},{'air'},{'air'}}, + on_read_node = function(tooldef, player, pointed_thing, node, pos) + local data, group = tooldef:copy(node, pos, player) + return data, group, "on_read_node description" + end, + on_write_node = function(tooldef, data, group, player, pointed_thing, node, pos) + tooldef:paste(node, pos, player, data, group) + end, + } + local tool = metatool:register_tool('testtool0', definition) + + assert.is_table(tool) + assert.equals("metatool:testtool0", tool.name) + + assert.is_table(tool) + assert.equals(definition.description, tool.description) + assert.equals(definition.name, tool.nice_name) + assert.equals(definition.on_read_node, tool.on_read_node) + assert.equals(definition.on_write_node, tool.on_write_node) + + -- Test configurable tool attributes + assert.is_nil(tool.privs) + assert.same({}, tool.settings) + + -- Namespace creation + local mult = function(a,b) return a * b end + tool:ns({ k1 = "v1", fn = mult }) + + -- Retrieve namespace and and execute tests + local ns = metatool.ns("testtool0") + assert.same({ k1 = "v1", fn = mult }, ns) + assert.equals(8, ns.fn(2,4)) + end) + + it("Register tool with configuration", function() + -- Tool registration + local definition = { + description = 'UnitTestTool Description', + name = 'UnitTestTool', + texture = 'utt.png', + recipe = {{'air'},{'air'},{'air'}}, + on_read_node = function(tooldef, player, pointed_thing, node, pos) + local data, group = tooldef:copy(node, pos, player) + return data, group, "on_read_node description" + end, + on_write_node = function(tooldef, data, group, player, pointed_thing, node, pos) + tooldef:paste(node, pos, player, data, group) + end, + } + local tool = metatool:register_tool('testtool2', definition) + + assert.is_table(tool) + assert.equals("metatool:testtool2", tool.name) + + assert.is_table(tool) + assert.equals(definition.description, tool.description) + assert.equals(definition.name, tool.nice_name) + assert.equals(definition.on_read_node, tool.on_read_node) + assert.equals(definition.on_write_node, tool.on_write_node) + + -- Test configurable tool attributes + assert.equals("test_testtool2_privs", tool.privs) + local expected_settings = { + extra_config_key = "testtool2_extra_config_value", + } + assert.same(expected_settings, tool.settings) + + -- Namespace creation + local sum = function(a,b) return a + b end + tool:ns({ k1 = "v1", fn = sum }) + + -- Retrieve namespace and and execute tests + local ns = metatool.ns("testtool2") + assert.same({ k1 = "v1", fn = sum }, ns) + assert.equals(9, ns.fn(2,7)) + end) + +end) + +describe("Metatool API node registration", function() + + it("Register node default configuration", function() + local tool = metatool.tool("testtool0") + assert.is_table(tool) + assert.equals("metatool:testtool0", tool.name) + assert.is_table(tool) + + local definition = { + name = 'testnode1', + nodes = { + "testnode1", + "nonexistent1", + "testnode2", + "nonexistent2", + }, + tooldef = { + group = 'test node', + protection_bypass_write = "default_bypass_write_priv", + copy = function(node, pos, player) + print("nodedef copy callback executed") + end, + paste = function(node, pos, player, data) + print("nodedef paste callback executed") + end, + } + } + tool:load_node_definition(definition) + + assert.is_table(tool.nodes) + assert.is_table(tool.nodes.testnode1) + assert.is_table(tool.nodes.testnode2) + assert.is_nil(tool.nodes.nonexistent1) + assert.is_nil(tool.nodes.nonexistent2) + + assert.is_function(tool.nodes.testnode1.before_read) + assert.is_function(tool.nodes.testnode2.before_write) + + assert.equals(definition.tooldef.copy, tool.nodes.testnode1.copy) + assert.equals(definition.tooldef.paste, tool.nodes.testnode2.paste) + assert.equals("default_bypass_write_priv", definition.tooldef.protection_bypass_write) + + local expected_settings = { + protection_bypass_write = "default_bypass_write_priv" + } + assert.same(expected_settings, tool.nodes.testnode1.settings) + assert.same(expected_settings, tool.nodes.testnode2.settings) + + end) + + it("Register node with configuration", function() + local tool = metatool.tool("testtool2") + assert.is_table(tool) + assert.equals("metatool:testtool2", tool.name) + assert.is_table(tool) + + local definition = { + name = 'testnode2', + nodes = { + "testnode1", + "nonexistent1", + "testnode2", + "nonexistent2", + }, + tooldef = { + group = 'test node', + protection_bypass_write = "default_bypass_write_priv", + copy = function(node, pos, player) + print("nodedef copy callback executed") + end, + paste = function(node, pos, player, data) + print("nodedef paste callback executed") + end, + } + } + tool:load_node_definition(definition) + + assert.is_table(tool.nodes) + assert.is_table(tool.nodes.testnode1) + assert.is_table(tool.nodes.testnode2) + assert.is_nil(tool.nodes.nonexistent1) + assert.is_nil(tool.nodes.nonexistent2) + + assert.is_function(tool.nodes.testnode1.before_read) + assert.is_function(tool.nodes.testnode2.before_write) + + assert.equals(definition.tooldef.copy, tool.nodes.testnode1.copy) + assert.equals(definition.tooldef.paste, tool.nodes.testnode2.paste) + assert.equals("testtool2_testnode2_bypass_write", tool.nodes.testnode1.protection_bypass_write) + assert.equals("testtool2_testnode2_bypass_write", tool.nodes.testnode2.protection_bypass_write) + assert.equals("testtool2_testnode2_bypass_info", tool.nodes.testnode1.protection_bypass_info) + assert.equals("testtool2_testnode2_bypass_info", tool.nodes.testnode2.protection_bypass_info) + assert.equals("testtool2_testnode2_bypass_read", tool.nodes.testnode1.protection_bypass_read) + assert.equals("testtool2_testnode2_bypass_read", tool.nodes.testnode2.protection_bypass_read) + + local expected_settings = { + protection_bypass_write = "testtool2_testnode2_bypass_write", + protection_bypass_info = "testtool2_testnode2_bypass_info", + protection_bypass_read = "testtool2_testnode2_bypass_read", + } + assert.same(expected_settings, tool.nodes.testnode1.settings) + assert.same(expected_settings, tool.nodes.testnode2.settings) + + end) + +end) + +--]] diff --git a/technic/spec/supply_converter_spec.lua b/technic/spec/supply_converter_spec.lua new file mode 100644 index 0000000..6426da5 --- /dev/null +++ b/technic/spec/supply_converter_spec.lua @@ -0,0 +1,147 @@ +dofile("spec/test_helpers.lua") +--[[ + Technic network unit tests. + Execute busted at technic source directory. +--]] + +-- Load fixtures required by tests +fixture("minetest") +fixture("minetest/player") +fixture("minetest/protection") + +fixture("pipeworks") +fixture("network") + +sourcefile("machines/network") + +sourcefile("machines/register/cables") +sourcefile("machines/LV/cables") +sourcefile("machines/MV/cables") +sourcefile("machines/HV/cables") + +sourcefile("machines/supply_converter") + +function get_network_fixture(sw_pos) + -- Build network + local net_id = technic.create_network(sw_pos) + assert.is_number(net_id) + local net = technic.networks[net_id] + assert.is_table(net) + return net +end + +describe("Supply converter", function() + + describe("building", function() + + world.layout({ + {{x=100,y=820,z=100}, "technic:hv_cable"}, + {{x=100,y=821,z=100}, "technic:switching_station"}, + {{x=101,y=820,z=100}, "technic:hv_cable"}, + {{x=101,y=821,z=100}, "technic:supply_converter"}, + {{x=102,y=820,z=100}, "technic:hv_cable"}, + -- {{x=102,y=821,z=100}, "technic:supply_converter"}, -- This machine is built + {{x=102,y=822,z=100}, "technic:mv_cable"}, -- Supply network for placed SC + {{x=102,y=823,z=100}, "technic:switching_station"}, -- Supply network for placed SC + {{x=102,y=821,z= 99}, "technic:hv_cable"}, -- This should not be added to network + {{x=102,y=821,z=101}, "technic:hv_cable"}, -- This should not be added to network + {{x=103,y=820,z=100}, "technic:hv_cable"}, + -- Second network for overload test + {{x=100,y=820,z=102}, "technic:hv_cable"}, + {{x=100,y=821,z=102}, "technic:switching_station"}, + -- {{x=100,y=820,z=101}, "technic:supply_converter"}, -- This machine is built, it should overload + }) + -- Build network + local net = get_network_fixture({x=100,y=821,z=100}) -- Output network for SC + local net2 = get_network_fixture({x=102,y=823,z=100}) -- Input network for SC + local net3 = get_network_fixture({x=100,y=821,z=102}) -- Overload test network (tests currently disabled) + local build_pos = {x=102,y=821,z=100} + local build_pos2 = {x=100,y=820,z=101} + + it("does not crash", function() + assert.equals(1, #net.PR_nodes) + assert.equals(1, #net.RE_nodes) + assert.equals(5, count(net.all_nodes)) + assert.equals(0, #net2.PR_nodes) + assert.equals(0, #net2.RE_nodes) + assert.equals(1, count(net2.all_nodes)) + world.set_node(build_pos, {name="technic:supply_converter",param2=0}) + technic.network_node_on_placenode(build_pos, {"HV"}, "technic:supply_converter") + end) + + it("is added to network without duplicates", function() + assert.same(build_pos, net.all_nodes[minetest.hash_node_position(build_pos)]) + assert.equals(6, count(net.all_nodes)) + assert.equals(2, #net.PR_nodes) + assert.equals(2, #net.RE_nodes) + assert.equals(2, count(net2.all_nodes)) + assert.equals(1, #net2.PR_nodes) + assert.equals(1, #net2.RE_nodes) + assert.is_nil(technic.is_overloaded(net.id)) + assert.is_nil(technic.is_overloaded(net2.id)) + end) + + it("does not remove connected machines from network", function() + assert.same({x=101,y=821,z=100},net.all_nodes[minetest.hash_node_position({x=101,y=821,z=100})]) + end) + + it("does not remove networks", function() + assert.is_hashed(technic.networks[net.id]) + assert.is_hashed(technic.networks[net2.id]) + end) + + it("does not add cables to network", function() + assert.is_nil(net.all_nodes[minetest.hash_node_position({x=102,y=821,z=99})]) + assert.is_nil(net.all_nodes[minetest.hash_node_position({x=102,y=821,z=101})]) + end) + + it("overloads network", function() + pending("overload does not work with supply converter") + world.set_node(build_pos2, {name="technic:supply_converter",param2=0}) + technic.network_node_on_placenode(build_pos2, {"HV"}, "technic:supply_converter") + assert.not_nil(technic.is_overloaded(net.id)) + assert.is_nil(technic.is_overloaded(net2.id)) + assert.not_nil(technic.is_overloaded(net3.id)) + end) + + end) + + describe("digging", function() + + world.layout({ + {{x=100,y=990,z=100}, "technic:hv_cable"}, + {{x=100,y=991,z=100}, "technic:switching_station"}, + {{x=101,y=990,z=100}, "technic:hv_cable"}, + {{x=102,y=990,z=100}, "technic:hv_cable"}, + {{x=102,y=991,z=100}, "technic:supply_converter"}, -- This machine is digged + {{x=102,y=991,z=101}, "technic:hv_cable"}, + }) + -- Build network + local net = get_network_fixture({x=100,y=991,z=100}) + local build_pos = {x=102,y=991,z=100} + + it("does not crash", function() + assert.equals(1, #net.PR_nodes) + assert.equals(1, #net.RE_nodes) + assert.equals(4, count(net.all_nodes)) + world.set_node(build_pos, {name="air",param2=0}) + technic.network_node_on_dignode(build_pos, {"HV"}, "technic:supply_converter") + end) + + it("is removed from network", function() + assert.is_nil(technic.pos2network(build_pos)) + assert.is_nil(technic.cables[minetest.hash_node_position(build_pos)]) + assert.is_nil(net.all_nodes[minetest.hash_node_position(build_pos)]) + end) + + it("does not remove other nodes from network", function() + assert.equals(3, count(net.all_nodes)) + end) + + it("does not remove network", function() + assert.is_hashed(technic.networks[net.id]) + end) + + end) + +end) diff --git a/technic/spec/test_helpers.lua b/technic/spec/test_helpers.lua new file mode 100644 index 0000000..f9a0075 --- /dev/null +++ b/technic/spec/test_helpers.lua @@ -0,0 +1,79 @@ + +package.path = "../?.lua;./?.lua;machines/?.lua;" .. package.path + +local _fixture_path = "spec/fixtures" + +function fixture_path(name) + return string.format("%s/%s", _fixture_path, name) +end + +local _fixtures = {} +function fixture(name) + if not _fixtures[name] then + dofile(fixture_path(name) .. ".lua") + end + _fixtures[name] = true +end + +local _source_path = "." + +function source_path(name) + return string.format("%s/%s", _source_path, name) +end + +function sourcefile(name) + dofile(source_path(name) .. ".lua") +end + +function timeit(count, func, ...) + local socket = require 'socket' + local t1 = socket.gettime() * 1000 + for i=0,count do + func(...) + end + local diff = (socket.gettime() * 1000) - t1 + local info = debug.getinfo(func,'S') + print(string.format("\nTimeit: %s:%d took %d ticks", info.short_src, info.linedefined, diff)) +end + +function count(t) + if type(t) == "table" or type(t) == "userdata" then + local c = 0 + for a,b in pairs(t) do + c = c + 1 + end + return c + end +end + +local function sequential(t) + local p = 1 + for i,_ in pairs(t) do + if i ~= p then return false end + p = p +1 + end + return true +end + +local function tabletype(t) + if type(t) == "table" or type(t) == "userdata" then + if count(t) == #t and sequential(t) then + return "array" + else + return "hash" + end + end +end + +-- Busted test framework extensions + +local assert = require('luassert.assert') +local say = require("say") + +local function is_array(_,args) return tabletype(args[1]) == "array" end +say:set("assertion.is_indexed.negative", "Expected %s to be indexed array") +assert:register("assertion", "is_indexed", is_array, "assertion.is_indexed.negative") + +local function is_hash(_,args) return tabletype(args[1]) == "hash" end +say:set("assertion.is_hashed.negative", "Expected %s to be hash table") +assert:register("assertion", "is_hashed", is_hash, "assertion.is_hashed.negative")