-- Advanced NPC by Zorman2000 -- Based on original NPC by Tenplus1 local S = mobs.intllib npc = {} -- Constants npc.FEMALE = "female" npc.MALE = "male" npc.age = { adult = "adult", child = "child" } npc.INVENTORY_ITEM_MAX_STACK = 99 npc.ANIMATION_STAND_START = 0 npc.ANIMATION_STAND_END = 79 npc.ANIMATION_SIT_START = 81 npc.ANIMATION_SIT_END = 160 npc.ANIMATION_LAY_START = 162 npc.ANIMATION_LAY_END = 166 npc.ANIMATION_WALK_START = 168 npc.ANIMATION_WALK_END = 187 npc.ANIMATION_MINE_START = 189 npc.ANIMATION_MINE_END =198 npc.direction = { north = 0, east = 1, south = 2, west = 3, north_east = 4, north_west = 5, south_east = 6, south_west = 7 } npc.action_state = { none = 0, executing = 1, interrupted = 2 } npc.log_level = { INFO = true, WARNING = true, ERROR = true, DEBUG = false, DEBUG_ACTION = false, DEBUG_SCHEDULE = false, EXECUTION = false } npc.texture_check = { timer = 0, interval = 2 } --------------------------------------------------------------------------------------- -- General functions --------------------------------------------------------------------------------------- -- Logging function npc.log(level, message) if npc.log_level[level] then minetest.log("info","[advanced_npc] "..level..": "..message) end end -- NPC chat function npc.chat(npc_name, player_name, message) minetest.chat_send_player(player_name, npc_name..": "..message) end -- Simple wrapper over minetest.add_particle() -- Copied from mobs_redo/api.lua function npc.effect(pos, amount, texture, min_size, max_size, radius, gravity, glow) radius = radius or 2 min_size = min_size or 0.5 max_size = max_size or 1 gravity = gravity or -10 glow = glow or 0 minetest.add_particlespawner({ amount = amount, time = 0.25, minpos = pos, maxpos = pos, minvel = {x = -radius, y = -radius, z = -radius}, maxvel = {x = radius, y = radius, z = radius}, minacc = {x = 0, y = gravity, z = 0}, maxacc = {x = 0, y = gravity, z = 0}, minexptime = 0.1, maxexptime = 1, minsize = min_size, maxsize = max_size, texture = texture, glow = glow, }) end -- Gets name of player or NPC function npc.get_entity_name(entity) if entity:is_player() then return entity:get_player_name() else return entity:get_luaentity().name end end -- Returns the item "wielded" by player or NPC -- TODO: Implement NPC function npc.get_entity_wielded_item(entity) if entity:is_player() then return entity:get_wielded_item() end end --------------------------------------------------------------------------------------- -- Spawning functions --------------------------------------------------------------------------------------- -- These functions are used at spawn time to determine several -- random attributes for the NPC in case they are not already -- defined. On a later phase, pre-defining many of the NPC values -- will be allowed. local function get_random_name(gender, tags) local search_tags = {gender} if tags then search_tags = { gender, unpack(tags) } end local names = npc.info.get_names(search_tags, "all_match") if next(names) ~= nil then local i = math.random(#names) return names[i] else -- Return a default name if no name was found return "Anonymous" end end local function initialize_inventory() return { [1] = "", [2] = "", [3] = "", [4] = "", [5] = "", [6] = "", [7] = "", [8] = "", [9] = "", [10] = "", [11] = "", [12] = "", [13] = "", [14] = "", [15] = "", [16] = "", } end -- This function checks for "female" text on the texture name local function is_female_texture(textures) for i = 1, #textures do if string.find(textures[i], "female") ~= nil then return true end end return false end function npc.assign_gender_from_texture(self) if is_female_texture(self.base_texture) then return npc.FEMALE else return npc.MALE end end local function get_random_texture(gender, age) local textures = npc.info.get_textures({gender, age}, "all_match") if next(textures) ~= nil then local i = math.random(#textures) return {textures[i]} else return {"default_"..gender..".png"} end -- local textures = {} -- local filtered_textures = {} -- -- Find textures by gender and age -- if age == npc.age.adult then -- --minetest.log("Registered: "..dump(minetest.registered_entities["advanced_npc:npc"])) -- textures = minetest.registered_entities["advanced_npc:npc"].texture_list -- elseif age == npc.age.child then -- textures = minetest.registered_entities["advanced_npc:npc"].child_texture -- end -- -- for i = 1, #textures do -- local current_texture = textures[i][1] -- if (gender == npc.MALE -- and string.find(current_texture, gender) -- and not string.find(current_texture, npc.FEMALE)) -- or (gender == npc.FEMALE -- and string.find(current_texture, gender)) then -- table.insert(filtered_textures, current_texture) -- end -- end -- -- -- Check if filtered textures is empty -- if filtered_textures == {} then -- return textures[1][1] -- end -- -- return filtered_textures[math.random(1,#filtered_textures)] end --function npc.get_random_texture_from_array(age, gender, textures) -- local filtered_textures = {} -- -- for i = 1, #textures do -- local current_texture = textures[i] -- -- Filter by age -- if (gender == npc.MALE -- and string.find(current_texture, gender) -- and not string.find(current_texture, npc.FEMALE) -- and ((age == npc.age.adult -- and not string.find(current_texture, npc.age.child)) -- or (age == npc.age.child -- and string.find(current_texture, npc.age.child)) -- ) -- ) -- or (gender == npc.FEMALE -- and string.find(current_texture, gender) -- and ((age == npc.age.adult -- and not string.find(current_texture, npc.age.child)) -- or (age == npc.age.child -- and string.find(current_texture, npc.age.child)) -- ) -- ) then -- table.insert(filtered_textures, current_texture) -- end -- end -- -- -- Check if there are no textures -- if #filtered_textures == 0 then -- -- Return whole array for re-evaluation -- npc.log("DEBUG", "No textures found, returning original array") -- return textures -- end -- -- return filtered_textures[math.random(1, #filtered_textures)] --end -- Choose whether NPC can have relationships. Only 30% of NPCs -- cannot have relationships local function can_have_relationships(is_child) -- Children can't have relationships if is_child then return false end local chance = math.random(1,10) return chance > 3 end -- Choose a maximum of two items that the NPC will have at spawn time -- These items are chosen from the favorite items list. local function choose_spawn_items(self) local number_of_items_to_add = math.random(1, 2) -- local number_of_items = #npc.FAVORITE_ITEMS[self.gender].phase1 -- -- for i = 1, number_of_items_to_add do -- npc.add_item_to_inventory( -- self, -- npc.FAVORITE_ITEMS[self.gender].phase1[math.random(1, number_of_items)].item, -- math.random(1,5) -- ) -- end -- Add currency to the items spawned with. Will add 5-10 tier 3 -- currency items local currency_item_count = math.random(5, 10) npc.add_item_to_inventory(self, npc.trade.prices.get_currency_itemstring("tier3"), currency_item_count) -- For test --npc.add_item_to_inventory(self, "default:tree", 10) --npc.add_item_to_inventory(self, "default:cobble", 10) --npc.add_item_to_inventory(self, "default:diamond", 2) --npc.add_item_to_inventory(self, "default:mese_crystal", 2) --npc.add_item_to_inventory(self, "flowers:rose", 2) --npc.add_item_to_inventory(self, "advanced_npc:marriage_ring", 2) --npc.add_item_to_inventory(self, "flowers:geranium", 2) --npc.add_item_to_inventory(self, "mobs:meat", 2) --npc.add_item_to_inventory(self, "mobs:leather", 2) --npc.add_item_to_inventory(self, "default:sword_stone", 2) --npc.add_item_to_inventory(self, "default:shovel_stone", 2) --npc.add_item_to_inventory(self, "default:axe_stone", 2) --minetest.log("Initial inventory: "..dump(self.inventory)) end -- Spawn function. Initializes all variables that the -- NPC will have and choose random, starting values function npc.initialize(entity, pos, is_lua_entity, npc_stats, npc_info) npc.log("INFO", "Initializing NPC at "..minetest.pos_to_string(pos)) -- Get variables local ent = entity if not is_lua_entity then ent = entity:get_luaentity() end local occupation_name if npc_info then occupation_name = npc_info.occupation_name end -- Avoid NPC to be removed by mobs_redo API ent.remove_ok = false -- Flag that enables/disables right-click interaction - good for moments where NPC -- can't be disturbed ent.enable_rightclick_interaction = true -- Determine gender and age -- If there's no previous NPC data, gender and age will be randomly chosen. -- - Sex: Female or male will have each 50% of spawning -- - Age: 90% chance of spawning adults, 10% chance of spawning children. -- If there is previous data then: -- - Sex: The unbalanced gender will get a 75% chance of spawning -- - Example: If there's one male, then female will have 75% spawn chance. -- - If there's male and female, then each have 50% spawn chance. -- - Age: For each two adults, the chance of spawning a child next will be 50% -- If there's a child for two adults, the chance of spawning a child goes to -- 40% and keeps decreasing unless two adults have no child. -- Use NPC stats if provided if npc_stats then -- Default chances local male_s, male_e = 0, 50 local female_s, female_e = 51, 100 local adult_s, adult_e = 0, 85 local child_s, child_e = 86, 100 -- Determine gender probabilities if npc_stats[npc.FEMALE].total > npc_stats[npc.MALE].total then male_e = 75 female_s, female_e = 76, 100 elseif npc_stats[npc.FEMALE].total < npc_stats[npc.MALE].total then male_e = 25 female_s, female_e = 26, 100 end -- Determine age probabilities if npc_stats["adult_total"] >= 2 then if npc_stats["adult_total"] % 2 == 0 and (npc_stats["adult_total"] / 2 > npc_stats["child_total"]) then child_s,child_e = 26, 100 adult_e = 25 else child_s, child_e = 61, 100 adult_e = 60 end end -- Get gender and age based on the probabilities local gender_chance = math.random(1, 100) local age_chance = math.random(1, 100) local selected_gender = "" local selected_age = "" -- Select gender if male_s <= gender_chance and gender_chance <= male_e then selected_gender = npc.MALE elseif female_s <= gender_chance and gender_chance <= female_e then selected_gender = npc.FEMALE end -- Set gender for NPC ent.gender = selected_gender -- Select age if adult_s <= age_chance and age_chance <= adult_e then selected_age = npc.age.adult elseif child_s <= age_chance and age_chance <= child_e then selected_age = npc.age.child ent.visual_size = { x = 0.65, y = 0.65 } ent.collisionbox = {-0.10,-0.50,-0.10, 0.10,0.40,0.10} ent.is_child = true -- For mobs_redo ent.child = true end -- Store the selected age ent.age = selected_age -- Set texture accordingly local selected_texture = get_random_texture(selected_gender, selected_age) --minetest.log("Selected texture: "..dump(selected_texture)) -- Store selected texture due to the need to restore it later ent.selected_texture = selected_texture -- Set texture and base texture ent.textures = {selected_texture} ent.base_texture = {selected_texture} elseif npc_info then -- Attempt to assign gender from npc_info if npc_info.gender then ent.gender = npc_info.gender else local gender_chance = math.random(1,2) ent.gender = npc.FEMALE if gender_chance == 1 then ent.gender = npc.MALE end end -- Attempt to assign age from npc_info if npc_info.age then ent.age = npc_info.age else ent.age = npc.age.adult end else -- Randomly choose gender, and spawn as adult local gender_chance = math.random(1,2) ent.gender = npc.FEMALE if gender_chance == 1 then ent.gender = npc.MALE end ent.age = npc.age.adult end -- Initialize all gift data ent.gift_data = { -- Choose favorite items. Choose phase1 per default favorite_items = npc.relationships.select_random_favorite_items(ent.gender, "phase1"), -- Choose disliked items. Choose phase1 per default disliked_items = npc.relationships.select_random_disliked_items(ent.gender), -- Enable/disable gift item hints dialogue lines enable_gift_items_hints = true } -- Flag that determines if NPC can have a relationship ent.can_have_relationship = can_have_relationships(ent.is_child) --ent.infotext = "Interested in relationships: "..dump(ent.can_have_relationship) -- Flag to determine if NPC can receive gifts ent.can_receive_gifts = ent.can_have_relationship -- Initialize relationships object ent.relationships = {} -- Determines if NPC is married or not ent.is_married_to = nil -- Initialize dialogues ent.dialogues = npc.dialogue.select_random_dialogues_for_npc(ent, "phase1") -- Declare NPC inventory ent.inventory = initialize_inventory() -- Choose items to spawn with choose_spawn_items(ent) -- Flags: generic booleans or functions that help drive functionality ent.flags = {} -- Declare trade data ent.trader_data = { -- Type of trader trader_status = npc.trade.get_random_trade_status(), -- Current buy offers buy_offers = {}, -- Current sell offers sell_offers = {}, -- Items to buy change timer change_offers_timer = 0, -- Items to buy change timer interval change_offers_timer_interval = 60, -- Trading list: a list of item names the trader is expected to trade in. -- It is mostly related to its occupation. -- If empty, the NPC will revert to casual trading -- If not, it will try to sell those that it have, and buy the ones it not. trade_list = {}, -- Custom trade allows to specify more than one payment -- and a custom prompt (instead of the usual buy or sell prompts) custom_trades = {} } -- To model and control behavior of a NPC, advanced_npc follows an OS model -- where it allows developers to create processes. These processes executes -- programs, or a group of instructions that together make the NPC do something, -- e.g. follow a player, use a furnace, etc. The model is: -- - Each process has: -- - An `execution context`, which is memory to store variables -- - An `instruction queue`, which is a queue with the program instructions -- to execute -- - A `state`, whether the process is running or is paused -- - Processes can specify whether they allow interruptions or not. They also -- can opt to handle the interruption with a callback. The possible -- interruptions are: -- - Punch interruption -- - Rightclick interruption -- - Schedule interruption -- - Only one process can run at a time. If another process is executed, -- the currently running process is paused, and restored when the other ends. -- - Processes can be enqueued, so once the executing process finishes, the -- next one in the queue can be started. -- - One process, called the `state` process, will run by default when no -- processes are executing. ent.execution = { process_id = 0, -- Queue of processes process_queue = {}, -- State process state_process = {}, -- Whether state process was changed or not state_process_changed = false, -- Whether to enable process execution or not enable = true, -- Interval to run process queue scheduler scheduler_interval = 1, -- Timer for next scheduler interval scheduler_timer = 0, -- Monitor environment executes timers and registered callbacks monitor = { timer = {}, callback = { to_execute = {} }, enabled = true } } -- NPC permanent storage for data ent.data = {} -- State date ent.npc_state = { -- This table defines the types of interaction the NPC is performing interaction = { dialogues = { is_in_dialogue = false, in_dialogue_with = "", in_dialogue_with_name = "" }, yaw_before_interaction = 0 }, punch = { last_punch_time = 0, }, movement = { is_idle = false, is_sitting = false, is_laying = false, walking = { is_walking = false, path = {}, target_pos = {}, } }, following = { is_following = false, following_obj = "", following_obj_name = "" } } -- This flag is checked on every step. If it is true, the rest of -- Mobs Redo API is not executed ent.freeze = nil -- This map will hold all the places for the NPC -- Map entries should be like: "bed" = {x=1, y=1, z=1} ent.places_map = {} -- Schedule data ent.schedules = { -- Flag to enable or disable the schedules functionality enabled = true, -- Lock for when executing a schedule lock = -1, -- Queue of programs in schedule to be enqueued -- Used to calculate dependencies dependency_queue = {}, -- An array of schedules, meant to be one per day at some point -- when calendars are implemented. Allows for only 7 schedules, -- one for each day of the week generic = {}, -- An array of schedules, meant to be for specific dates in the -- year. Can contain as many as possible. The keys will be strings -- in the format MM:DD date_based = {}, -- The following holds the check parameters provided by the -- current schedule current_check_params = {} } -- If occupation name given, override properties with -- occupation values and initialize schedules if occupation_name and occupation_name ~= "" and ent.age == npc.age.adult then -- Set occupation name ent.occupation_name = occupation_name -- Override relevant values npc.occupations.initialize_occupation_values(ent, occupation_name) end -- Nametag is initialized to blank ent.nametag = "" -- Set name if npc_info and npc_info.name then if npc_info.name.value then ent.npc_name = npc_info.name.value elseif npc_info.name.tags then ent.npc_name = get_random_name(ent.gender, npc_info.name.tags) else ent.npc_name = get_random_name(ent.gender) end else ent.npc_name = get_random_name(ent.gender) end -- Set ID ent.npc_id = tostring(math.random(1000, 9999))..":"..ent.npc_name -- Generate trade offers npc.trade.generate_trade_offers_by_status(ent) -- Set initialized flag on ent.initialized = true --npc.log("WARNING", "Spawned entity: "..dump(ent)) npc.log("INFO", "Successfully initialized NPC with name "..dump(ent.npc_name) ..", gender: "..ent.gender..", is child: "..dump(ent.is_child) ..", texture: "..dump(ent.textures)) -- Refreshes entity ent.object:set_properties(ent) end --------------------------------------------------------------------------------------- -- Trading functions --------------------------------------------------------------------------------------- function npc.generate_trade_list_from_inventory(self) local list = {} for i = 1, #self.inventory do list[npc.get_item_name(self.inventory[i])] = {} end self.trader_data.trade_list = list end function npc.set_trading_status(self, status) -- Stop, if any, the casual offer regeneration timer npc.monitor.timer.stop(self, "advanced_npc:trade:casual_offer_regeneration") --minetest.log("Trader_data: "..dump(self.trader_data)) -- Set status self.trader_data.trader_status = status -- Check if status is casual if status == npc.trade.CASUAL then -- Register timer for changing casual trade offers local timer_reg_success = npc.monitor.timer.register(self, "advanced_npc:trade:casual_offer_regeneration", 60, function(self) -- Re-select casual trade offers npc.trade.generate_trade_offers_by_status(self) end) if timer_reg_success == false then -- Activate timer instead npc.monitor.timer.start(self, "advanced_npc:trade:casual_offer_regeneration") end end -- Re-generate trade offers npc.trade.generate_trade_offers_by_status(self) end --------------------------------------------------------------------------------------- -- Inventory functions --------------------------------------------------------------------------------------- -- NPCs inventories are restrained to 16 slots. -- Each slot can hold one item up to 99 count. -- Utility function to get item name from a string function npc.get_item_name(item_string) return ItemStack(item_string):get_name() end -- Utility function to get item count from a string function npc.get_item_count(item_string) return ItemStack(item_string):get_count() end -- Add an item to inventory. Returns true if add successful -- These function can be used to give items to other NPCs -- given that the "self" variable can be any NPC function npc.add_item_to_inventory(self, item_name, count) -- Check if NPC already has item local existing_item = npc.inventory_contains(self, item_name) if existing_item ~= nil and existing_item.item_string ~= nil then -- NPC already has item. Get count and see local existing_count = npc.get_item_count(existing_item.item_string) if (existing_count + count) < npc.INVENTORY_ITEM_MAX_STACK then -- Set item here self.inventory[existing_item.slot] = npc.get_item_name(existing_item.item_string).." "..tostring(existing_count + count) return true else --Find next free slot for i = 1, #self.inventory do if self.inventory[i] == "" then -- Found slot, set item self.inventory[i] = item_name.." "..tostring((existing_count + count) - npc.INVENTORY_ITEM_MAX_STACK) return true end end -- No free slot found return false end else -- Find a free slot for i = 1, #self.inventory do if self.inventory[i] == "" then -- Found slot, set item self.inventory[i] = item_name.." "..tostring(count) return true end end -- No empty slot found return false end end -- Same add method but with itemstring for convenience function npc.add_item_to_inventory_itemstring(self, item_string) local item_name = npc.get_item_name(item_string) local item_count = npc.get_item_count(item_string) npc.add_item_to_inventory(self, item_name, item_count) end -- Checks if an item is contained in the inventory. Returns -- the item string or nil if not found function npc.inventory_contains(self, item_name) for key,value in pairs(self.inventory) do if value ~= "" and string.find(value, item_name) then return {slot=key, item_string=value} end end -- Item not found return nil end -- Removes the item from an NPC inventory and returns the item -- with its count (as a string, e.g. "default:apple 2"). Returns -- nil if unable to get the item. function npc.take_item_from_inventory(self, item_name, count) local existing_item = npc.inventory_contains(self, item_name) if existing_item ~= nil then -- Found item local existing_count = npc.get_item_count(existing_item.item_string) local new_count = existing_count if existing_count - count < 0 then -- Remove item first self.inventory[existing_item.slot] = "" -- TODO: Support for retrieving from next stack. Too complicated -- and honestly might be unecessary. return item_name.." "..tostring(new_count) else new_count = existing_count - count if new_count == 0 then self.inventory[existing_item.slot] = "" else self.inventory[existing_item.slot] = item_name.." "..new_count end return item_name.." "..tostring(count) end else -- Not able to take item because not found return nil end end -- Same take method but with itemstring for convenience function npc.take_item_from_inventory_itemstring(self, item_string) local item_name = npc.get_item_name(item_string) local item_count = npc.get_item_count(item_string) npc.take_item_from_inventory(self, item_name, item_count) end --------------------------------------------------------------------------------------- -- Flag functionality --------------------------------------------------------------------------------------- -- TODO: Consider removing them as they are pretty simple and straight forward. -- Generic variables or function that help drive some functionality for the NPC. function npc.add_flag(self, flag_name, value) self.flags[flag_name] = value end function npc.update_flag(self, flag_name, value) self.flags[flag_name] = value end function npc.get_flag(self, flag_name) return self.flags[flag_name] end --------------------------------------------------------------------------------------- -- Dialogue functionality --------------------------------------------------------------------------------------- function npc.start_dialogue(self, clicker, show_married_dialogue) -- Call dialogue function as normal npc.dialogue.start_dialogue(self, clicker, show_married_dialogue) -- Check and update relationship if needed npc.relationships.dialogue_relationship_update(self, clicker) end --------------------------------------------------------------------------------------- -- State functionality --------------------------------------------------------------------------------------- -- All the self.npc_state variables are used to track the state of the NPC, and -- if necessary, restore it back in case of changes. The following functions allow -- to set different aspects of the state. function npc.set_movement_state(self, args) self.npc_state.movement.is_idle = args.is_idle or false self.npc_state.movement.is_sitting = args.is_sitting or false self.npc_state.movement.is_laying = args.is_laying or false self.npc_state.movement.walking.is_walking = args.is_walking or false end --------------------------------------------------------------------------------------- -- Execution API --------------------------------------------------------------------------------------- -- Methods for: -- - Enqueue a program -- - Set a program as the `state` process -- - Execute next process in queue -- - Pause/restore current process -- - Process scheduling -- - Get the current process data -- - Create, read, write and update variables in current process -- - Enqueue and execute instructions for the current process -- Global namespace npc.exec = { var = {}, proc = { instr = {} } } -- Private namespace local _exec = { proc = {} } -- Process states npc.exec.proc.state = { INACTIVE = "inactive", RUNNING = "running", EXECUTING = "executing", PAUSED = "paused", WAITING_USER_INPUT = "waiting_user_input", READY = "ready" } npc.exec.proc.instr.state = { INACTIVE = "inactive", EXECUTING = "executing", INTERRUPTED = "interrupted" } -- This function sets the interrupt options as given from the `interrupt_options` -- table. This table can have the following values: -- - allow_punch, boolean -- - allow_rightclick, boolean -- - allow_schedule, boolean function npc.exec.create_interrupt_options(interrupt_options) local interrupt_options = interrupt_options or {} if next(interrupt_options) ~= nil then local allow_punch = interrupt_options.allow_punch local allow_rightclick = interrupt_options.allow_rightclick local allow_schedule = interrupt_options.allow_schedule -- Set defaults if allow_punch == nil then allow_punch = true end if allow_rightclick == nil then allow_rightclick = true end if allow_schedule == nil then allow_schedule = true end return { allow_punch = allow_punch, allow_rightclick = allow_rightclick, allow_schedule = allow_schedule } else return { allow_punch = true, allow_rightclick = true, allow_schedule = true } end end function _exec.get_new_process_id(self) self.execution.process_id = self.execution.process_id + 1 if self.execution.process_id > 10000 then self.execution.process_id = 0 end return self.execution.process_id end function _exec.create_process_entry(program_name, arguments, interrupt_options, is_state_program, process_id) return { id = process_id, program_name = program_name, arguments = arguments, state = npc.exec.proc.state.INACTIVE, execution_context = { data = {}, instr_interval = 1, instr_timer = 0 }, instruction_queue = {}, current_instruction = { entry = {}, state = npc.exec.proc.instr.state.INACTIVE, pos = {} }, interrupt_options = npc.exec.create_interrupt_options(interrupt_options), interrupted_process = {}, is_state_process = is_state_program } end -- This function creates a process for the given program, and -- places it into the process queue. function npc.exec.enqueue_program(self, program_name, arguments, interrupt_options, is_state_program) if is_state_program == nil then is_state_program = false end if is_state_program == true then npc.exec.set_state_program(self, program_name, arguments, interrupt_options) -- Enqueue state process self.execution.process_queue[#self.execution.process_queue + 1] = self.execution.state_process else -- Enqueue process self.execution.process_queue[#self.execution.process_queue + 1] = _exec.create_process_entry(program_name, arguments, interrupt_options, is_state_program, _exec.get_new_process_id(self)) end end -- This function creates a state process. The state process will execute -- everytime there's no other process executing function npc.exec.set_state_program(self, program_name, arguments, interrupt_options) -- Disable monitor - give a chance to this state process to do what it has to do self.execution.monitor.enabled = false -- This flag signals the state process was changed and scheduler needs to consume self.execution.state_process_changed = true self.execution.state_process = { program_name = program_name, arguments = arguments, state = npc.exec.proc.state.INACTIVE, execution_context = { data = {}, instr_interval = 1, instr_timer = 0 }, instruction_queue = {}, current_instruction = { entry = {}, state = npc.exec.proc.instr.state.INACTIVE, pos = {} }, interrupt_options = npc.exec.create_interrupt_options(interrupt_options), is_state_process = true, state_process_id = os.time() } end -- Convenience function that returns first process in the queue function npc.exec.get_current_process(self) local result = self.execution.process_queue[1] if result then if next(result) == 0 then return nil end end return result end -- This function always execute the process at the start of the process -- queue. When a process is stopped (because its instruction queue is empty -- or because the process itself stops), the entry is removed from the -- process queue, and thus the next process to execute will be the first one -- in the queue. function npc.exec.execute_process(self) local current_process = self.execution.process_queue[1] -- Execute current process if current_process then -- Restore scheduler interval self.execution.scheduler_interval = 1 if not current_process.is_state_process then npc.log("EXECUTION", "NPC "..dump(self.npc_name).." is executing: "..dump(current_process.program_name)) end current_process.state = npc.exec.proc.state.EXECUTING npc.programs.execute(self, current_process.program_name, current_process.arguments) current_process.state = npc.exec.proc.state.RUNNING -- Re-enable monitor if current_process.is_state_process then self.execution.monitor.enabled = true end end end --------------------------------------------------------------------------------------- -- Interruption algorithm --------------------------------------------------------------------------------------- -- Interruption of an executing process can come from three sources: -- - NPC is left-clicked (or punch) -- - NPC is right-clicked (or rightclick) -- - Job scheduler has identified it is time to start a process -- When an interrupt happens, and another process needs to be executed, the -- workflow should be the following: -- 1. Enqueue the new process to be scheduled. -- a. If for some reason the process queue *has more than one* process, -- then the process will have to be enqueued with high priority, -- meaning next to the current process. -- 2. Pause the current executing process using `npc.exec.pause_process(self)` -- The new process will be executed by `npc.exec.pause_process()`. -- 3. The process finishes execution successfully, in which the scheduler -- will notice that and restore the interrupted process properly -- -- It is very important that a process is enqueued before pausing the current -- process. The pause will not work itself if that condition is not met -- This function enqueues an array of processes right after the current process -- Each element in `program_entries` is a Lua table with three parameters: -- - program_name -- - arguments -- - interrupt_options function _exec.priority_enqueue(self, program_entries) -- minetest.log("BEGIN PRIORITY ENQUEUE") -- minetest.log("Initial queue: "..dump(self.execution.process_queue)) -- Check if the queue has more than one (current) process if #self.execution.process_queue > 1 then npc.log("EXECUTION", "More than One: "..dump(#self.execution.process_queue)) -- Get current process entry --local current_process = self.execution.process_queue[1] -- Backup the current process queue -- local backup_queue = self.execution.process_queue -- minetest.log("Backup queue size: "..dump(#backup_queue)) -- -- Remove current process from backup_queue -- table.remove(backup_queue, 1) -- minetest.log("Backup queue size after dequeue: "..dump(#backup_queue)) -- -- Recreate queue, re-enqueue first process -- minetest.log("ENqueue") -- self.execution.process_queue[#self.execution.process_queue + 1] = current_process npc.log("EXECUTION", "Queue size after enqueue: "..dump(#self.execution.process_queue)) -- Enqueue the next processes with high priority (next to the current) npc.log("EXECUTION", "Enqueue all new") for i = 1, #program_entries do if program_entries[i].is_state_program == true then npc.exec.set_state_program(self, program_entries[i].program_name, program_entries[i].arguments, program_entries[i].interrupt_options) -- Enqueue state process table.insert(self.execution.process_queue, i + 1, self.execution.state_process) else -- Enqueue normal process table.insert( self.execution.process_queue, i + 1, _exec.create_process_entry( program_entries[i].program_name, program_entries[i].arguments, program_entries[i].interrupt_options, program_entries[i].is_state_program, _exec.get_new_process_id(self))) end end --minetest.log("Backup queue after all new: "..dump(#backup_queue)) else npc.log("EXECUTION", "Only one process in queue") -- There is only one process, therefore just enqueue every process for i = 1, #program_entries do if program_entries[i].is_state_program == true then npc.exec.set_state_program(self, program_entries[i].program_name, program_entries[i].arguments, program_entries[i].interrupt_options) -- Enqueue state process self.execution.process_queue[#self.execution.process_queue + 1] = self.execution.state_process else -- Enqueue normal process self.execution.process_queue[#self.execution.process_queue + 1] = _exec.create_process_entry( program_entries[i].program_name, program_entries[i].arguments, program_entries[i].interrupt_options, program_entries[i].is_state_program, _exec.get_new_process_id(self)) end end end end -- This function handles a new process called by an interrupt. -- Will execute steps 1 and 2 of the above algorithm. The scheduler -- will take care of handling step 3. function npc.exec.interrupt(self, new_program, new_arguments, interrupt_options) -- Enqueue process with priority _exec.priority_enqueue(self, {[1] = {program_name=new_program, arguments=new_arguments, interrupt_options=interrupt_options}}) --minetest.log("Pause") minetest.log("info","Interrupted process: "..dump(self.execution.process_queue[1])) -- Check process - if the instruction queue is empty, do not store -- Pause current process _exec.pause_process(self) local interrupted_process = self.execution.process_queue[1] -- Dequeue process table.remove(self.execution.process_queue, 1) -- Find if interrupted process has more instructions to execute local has_more_instructions = next(interrupted_process.instruction_queue) ~= nil --minetest.log("Process has more instructions: "..dump()) if has_more_instructions then -- Store interrupted process local current_process = self.execution.process_queue[1] current_process.interrupted_process = interrupted_process end -- Restore process scheduler interval self.execution.scheduler_interval = 1 --minetest.log("Execute") -- Execute current process npc.exec.execute_process(self) end -- This function pauses a process and sets its state as waiting for user input. -- The process scheduler and instruction executer will skip any process in this state. -- Once the process is ready to run again, the `npc.exec.set_ready_state()` function -- should be called, and execution will continue. function npc.exec.set_input_wait_state(self) npc.log("EXECUTION", "Setting input wait...") if self.execution.process_queue[1] then -- Call pause to do the instruction interruption _exec.pause_process(self) -- Change process state self.execution.process_queue[1].state = npc.exec.proc.state.WAITING_USER_INPUT end end function npc.exec.set_ready_state(self) if self.execution.process_queue[1] then -- Change process state self.execution.process_queue[1].state = npc.exec.proc.state.READY end end -- If there is another process in the queue, this function pauses a -- currently executing process, then executes the function _exec.pause_process(self, set_instruction_as_interrupted) if #self.execution.process_queue == 1 then npc.log("WARNING", "Unable to pause current process without anoher process in queue.\nCurrent queue: " ..dump(self.execution.process_queue)) return end local current_process = self.execution.process_queue[1] if current_process then -- Check if there are instructions in the instruction queue if next(current_process.instruction_queue) ~= nil then -- If the instruction is interrupt, then dequeue that instruction :) if current_process.instruction_queue[1].name == "advanced_npc:interrupt" then -- Dequeue instruction table.remove(current_process.instruction_queue, 1) -- Check if there are more instructions if next(current_process.instruction_queue) ~= nil then -- Set entry current_process.current_instruction.entry = current_process.instruction_queue[1] -- Set state current_process.current_instruction.state = npc.exec.proc.instr.state.INTERRUPTED else -- Set entry to blank as there is no other instruction current_process.current_instruction.entry = {} -- Set state current_process.current_instruction.state = npc.exec.proc.instr.state.INACTIVE end else -- Check current instruction if current_process.current_instruction.entry and current_process.current_instruction.state == npc.exec.proc.instr.state.EXECUTING then -- This condition shouldn't become true --and set_instruction_as_interrupted == true then -- Change instruction state current_process.current_instruction.state = npc.exec.proc.instr.state.INTERRUPTED -- The following flow has been commented out as it doesn't gets executed. --elseif set_instruction_as_interrupted == nil or set_instruction_as_interrupted == false then -- current_process.current_instruction.state = npc.exec.proc.instr.state.INACTIVE end end end --minetest.log("Process after pausing: "..dump(current_process)) -- Change process state current_process.state = npc.exec.proc.state.PAUSED end end -- This function restores the process that was running before the -- current one (the interrupted process). -- As it can only be runned with the interrupted process being enqueued -- before calling this function, this function is private and only -- used by the scheduler (which will enqueue the interrupted process before -- calling this) function _exec.restore_process(self) local current_process = self.execution.process_queue[1] if current_process then minetest.log("info","Restoring process: "..dump(current_process.program_name)) -- Change process state current_process.state = npc.exec.proc.state.RUNNING -- Check if any instruction was interrupted if current_process.current_instruction.entry and current_process.current_instruction.state == npc.exec.proc.instr.state.INTERRUPTED then -- TODO: Do we really want to restore position? -- Restore position --self.object:setpos(current_process.current_instruction.pos) -- Execute instruction minetest.log("info","Re-executing instruction: "..dump(current_process.current_instruction.entry.name)) _exec.proc.execute(self, current_process.current_instruction.entry) end end end --------------------------------------------------------------------------------------- -- Scheduler algorithm --------------------------------------------------------------------------------------- -- This function will manage how processes are executed. This function needs -- to be called on a one second interval. The function will check: -- - If the process queue is emtpy and there is a state process, enqueue the -- the state process and execute -- - If the current process' instruction queue is empty: -- - If the process is a `state` process, and no other process is in queue, -- re-execute `state` process. -- - If the process is a `state` process and there is a process in queue, -- - Remove current process from queue -- - Store the current process entry into the `interrupted_process` field of -- the next process in queue. -- - Execute next process in queue -- - If the process is *not* a `state` process and there is a process entry in -- the `interrupted_process` field: -- - Remove current process from queue -- - Enqueue the entry in the `interrupted_process` field -- - Execute next process in the queue -- - If the instruction queue is not empty, continue function npc.exec.process_scheduler(self) npc.log("EXECUTION", "Current process queue size: "..dump(#self.execution.process_queue)) -- minetest.log("Queue for "..dump(self.npc_name)) -- for i = 1, #self.execution.process_queue do -- minetest.log("["..dump(self.execution.process_queue[i].program_name).."]") -- end -- Check current process local current_process = self.execution.process_queue[1] if current_process then -- Check current process state if current_process.state == npc.exec.proc.state.EXECUTING then -- Do not interrupt process while the process is enqueuing instructions return elseif current_process.state == npc.exec.proc.state.INACTIVE then -- Execute process npc.exec.execute_process(self) elseif current_process.state == npc.exec.proc.state.READY then -- Change state to running current_process.state = npc.exec.proc.state.RUNNING elseif current_process.state == npc.exec.proc.state.PAUSED then -- Restore process _exec.restore_process(self) end -- Check if instruction queue is empty if current_process.instruction_queue and #current_process.instruction_queue == 0 and current_process.state == npc.exec.proc.state.RUNNING then -- Check if this is a state process if current_process.is_state_process == true then -- Check if the process queue only has this process if #self.execution.process_queue == 1 then -- Check if state process was changed if self.execution.state_process_changed == true then npc.log("EXECUTION", "Switching from state process " ..dump(self.execution.process_queue[1].program_name) .." to " ..dump(self.execution.state_process.program_name)) -- Dequeue this process, enqueue new one self.execution.process_queue[1] = self.execution.state_process -- Change flag back self.execution.state_process_changed = false end -- Since this is a state process, re-execute npc.log("EXECUTION", "Hi, executing state process "..dump(self.execution.process_queue[1].program_name)) npc.exec.execute_process(self) else -- Changed state process check - an old state process could be enqueued, -- but the state process was changed. If this is is true, ignore old -- entry in the process queue. local next_enqueued_process = self.execution.process_queue[2] if self.execution.state_process_changed == true and next_enqueued_process.id ~= current_process.id and next_enqueued_process.is_state_process == true then -- Assume every enqueued state process is old and discard table.remove(self.execution.process_queue, 2) -- Change flag back self.execution.state_process_changed = false else -- Next process is not a state process, interrupt current state process npc.log("EXECUTION", "Current process queue size: "..dump(#self.execution.process_queue)) -- Pause current process current_process.state = npc.exec.proc.state.PAUSED -- Dequeue process table.remove(self.execution.process_queue, 1) -- Get next process in queue local next_process = self.execution.process_queue[1] -- Store the interrupted process in the next process next_process.interrupted_process = current_process end -- Execute next process npc.exec.execute_process(self) end else npc.log("EXECUTION", "Current process name: "..dump(current_process.program_name)) npc.log("EXECUTION", "Process queue size: "..dump(#self.execution.process_queue)) npc.log("EXECUTION", "Current instrcution queue size: "..dump(#current_process.instruction_queue)) npc.log("EXECUTION", "Current process state: "..dump(current_process.state)) -- This is not a state process, check the interrupted process field if next(current_process.interrupted_process) ~= nil then npc.log("EXECUTION", "There is an interrupted process: "..dump(current_process.interrupted_process.program_name)) npc.log("EXECUTION", "------------------------------") npc.log("EXECUTION", "Is state process? "..dump(current_process.interrupted_process.is_state_process)) npc.log("EXECUTION", "State process ID: "..dump(current_process.interrupted_process.state_process_id)) npc.log("EXECUTION", "Valid state process ID: "..dump(self.execution.state_process.state_process_id)) if current_process.interrupted_process.is_state_process == true and current_process.interrupted_process.state_process_id and (current_process.interrupted_process.state_process_id < self.execution.state_process.state_process_id) then -- Do nothing, just dequeue process npc.log("EXECUTION", "Found an old state process that was interrupted.\n" ..dump(current_process.interrupted_process.program_name).." WILL NOT be re-enqueued") npc.log("EXECUTION", "Process "..dump(self.execution.process_queue[1].program_name) .." is finished execution and will be dequeued") -- Dequeue process table.remove(self.execution.process_queue, 1) -- Check if there are more processes if #self.execution.process_queue > 0 then -- Execute new process npc.exec.execute_process(self) end return end -- Dequeue process table.remove(self.execution.process_queue, 1) -- Re-enqueue the interrupted process self.execution.process_queue[#self.execution.process_queue + 1] = current_process.interrupted_process if #self.execution.process_queue > 1 then -- Execute next process in queue npc.exec.execute_process(self) else -- Execute next process in queue which is interrupted _exec.restore_process(self) end else npc.log("EXECUTION", "Process "..dump(self.execution.process_queue[1].program_name).." is finished execution") -- Dequeue process table.remove(self.execution.process_queue, 1) -- Check if there are more processes if #self.execution.process_queue > 0 then -- Execute new process npc.exec.execute_process(self) end end end end else -- Process queue is empty, enqueue state process if it is defined if next(self.execution.state_process) ~= nil then npc.log("EXECUTION", "NPC "..dump(self.npc_name).." is executing: "..dump(self.execution.state_process.program_name)) self.execution.process_queue[#self.execution.process_queue + 1] = self.execution.state_process -- Execute state process npc.exec.execute_process(self) end end end --------------------------------------------------------------------------------------- -- Process instructions functionality - enqueue and execute instructions -- for the currently executing process --------------------------------------------------------------------------------------- -- This function enqueues a given instruction with its arguments -- in the current process' instruction queue. If var_name is given, -- results of this function are stored in the execution context with that -- var_key function npc.exec.proc.enqueue(self, name, args, var_name) local current_process = self.execution.process_queue[1] if current_process then current_process.instruction_queue[#current_process.instruction_queue + 1] = {name=name, args=args, var_name=var_name} end end -- Private function to execute a given instruction entry function _exec.proc.execute(self, entry) if entry ~= nil and next(entry) ~= nil then local current_process = self.execution.process_queue[1] if current_process then -- Set current instruction params current_process.current_instruction.entry = entry current_process.current_instruction.pos = self.object:getpos() current_process.current_instruction.state = npc.exec.proc.instr.state.EXECUTING -- Execute current instruction npc.log("EXECUTION", "Executing instruction: "..dump(entry.name)) local result = npc.programs.instr.execute(self, entry.name, entry.args) if entry.name == "advanced_npc:interrupt" then -- Do not do anything else, the interrupt instruction was already -- dequeued. return end -- Check if var_name was given if entry.var_name then if npc.exec.var.get(self, entry.var_name) then -- Update the value npc.exec.var.set(self, entry.var_name, result) else -- Create new var with value npc.exec.var.put(self, entry.var_name, result, false) end end -- Dequeue from instruction queue table.remove(current_process.instruction_queue, 1) end end -- minetest.log("END PRIVATE PROC EXEC") end -- This function executes the next instruction entry in the current -- process' instruction queue function npc.exec.proc.execute(self) --minetest.log("PROCESS EXECUTE BEGIN") local current_process = self.execution.process_queue[1] if current_process then -- Get next instruction entry in queue local entry = current_process.instruction_queue[1] -- Execute instruction _exec.proc.execute(self, entry) end --minetest.log("PROCESS EXECUTE END") end --------------------------------------------------------------------------------------- -- Execution routine --------------------------------------------------------------------------------------- -- This function is to be executed on each step() of the Lua entity -- Algorithm: -- 1. Increase the timer with dtime -- 2. If the timer has reached the interval, then: -- a. Reset the timer and execute `npc.exec.process_scheduler(self)` -- 3. Increase the current process' instruction timer with dtime -- 4. If the instruction timer has reached the interval, then: -- a. Reset the instruction timer and execute `noc.exec.proc.execute(self)` function npc.exec.execution_routine(self, dtime) local execution = self.execution -- Increase process scheduler timer execution.scheduler_timer = execution.scheduler_timer + dtime -- Check if timer reached interval if execution.scheduler_timer >= execution.scheduler_interval then -- Reset timer execution.scheduler_timer = 0 npc.log("EXECUTION", "Executing scheduler for NPC "..dump(self.npc_name)) -- Execute process scheduler npc.exec.process_scheduler(self) end -- Get current process local current_process = execution.process_queue[1] if current_process ~= nil and current_process.execution_context ~= nil then --minetest.log("STATE: "..dump(self.execution.process_queue[1].state)) --minetest.log("PROCESS: "..dump(self.execution.process_queue[1])) if current_process.state == npc.exec.proc.state.RUNNING then -- Increase timer current_process.execution_context.instr_timer = current_process.execution_context.instr_timer + dtime -- Check if timer reached interval if current_process.execution_context.instr_timer >= current_process.execution_context.instr_interval then -- Reset timer --minetest.log("HI, RESET") current_process.execution_context.instr_timer = 0 -- Check if NPC is walking if self.npc_state.movement.walking.is_walking == true then -- Move NPC to expected position to ensure not getting lost local pos = self.npc_state.movement.walking.target_pos if vector.distance(self.object:getpos(), pos) > 0.2 then npc.log("INFO", "Corrected position for walking NPC "..dump(self.npc_name).." to "..minetest.pos_to_string(pos)) self.object:moveto({x=pos.x, y=pos.y, z=pos.z}) end end -- Execute next instruction in process' queue npc.exec.proc.execute(self) end end end end --------------------------------------------------------------------------------------- -- Variable functionality - create, read, update and delete variables in the -- current process. -- IMPORTANT: These variables are deleted when the process is finished execution. -- For permanent storage, use npc.data.* functions. --------------------------------------------------------------------------------------- -- This function adds a value to the execution context of the -- current process. -- Readonly defaults to false. Returns false if failed due to -- key-name conflict, or returns true if successful function npc.exec.var.put(self, name, value, readonly) -- Retrieve current process execution context local current_process = self.execution.process_queue[1] if current_process then local context = current_process.execution_context -- Check if variable exists if context[name] ~= nil then npc.log("ERROR", "Attempt to create new variable with name "..name.." failed".. "due to variable already existing: "..dump(context[name])) return false end context[name] = {value = value, readonly = readonly} return true end end -- Returns the value of a given key. If not found returns nil. function npc.exec.var.get(self, name) -- Retrieve current process execution context local current_process = self.execution.process_queue[1] if current_process then local context = current_process.execution_context local result = context[name] if result == nil then return nil else return result.value end end end function npc.exec.var.get_or_put_if_nil(self, name, initial_value) local var = npc.exec.var.get(self, name) if var == nil then npc.exec.var.put(self, name, initial_value) return initial_value else return var end end -- This function updates a value in the execution context. -- Returns false if the value is read-only or if key isn't found. -- Returns true if able to update value function npc.exec.var.set(self, name, new_value) -- Retrieve current process execution context local current_process = self.execution.process_queue[1] if current_process then local context = current_process.execution_context local var = context[name] if var == nil then return false else if var.readonly == true then npc.log("ERROR", "Attempt to set value of readonly variable: "..name) return false end var.value = new_value end return true end end -- This function removes a variable from the execution context. -- If the key doesn't exist, returns nil, otherwise, returns -- the value removed. function npc.exec.var.remove(self, name) -- Retrieve current process execution context local current_process = self.execution.process_queue[1] if current_process then local context = current_process.execution_context local result = context[name] if result == nil then return nil else -- Clear variable npc.exec.get_current_process(self).execution_context[name] = nil return result end end end --------------------------------------------------------------------------------------- -- Permanent storage functionality - create, read, update and delete variables -- in the NPC's permnanent storage. -- IMPORTANT: These variables are *NOT* deleted. Be careful what you store on it or -- the NPC object can grow in size very quickly. -- For temporary storage, use npc.exec.var.* functions. --------------------------------------------------------------------------------------- -- Namespace npc.data = {} -- This function adds a value to the execution context of the -- current process. -- Readonly defaults to false. Returns false if failed due to -- key-name conflict, or returns true if successful function npc.data.put(self, name, value, readonly) -- Check if variable exists if self.data[name] ~= nil then npc.log("ERROR", "Attempt to create new variable with name "..name.." failed".. "due to variable already existing: "..dump(self.data[name])) return false end self.data[name] = {value = value, readonly = readonly} return true end -- Returns the value of a given key. If not found returns nil. function npc.data.get(self, name) local result = self.data[name] if result == nil then return nil else return result.value end end -- Convenience function for initializing a variable if nil function npc.data.get_or_put_if_nil(self, name, initial_value) local var = npc.data.get(self, name) if var == nil then npc.data.put(self, name, initial_value, false) return initial_value else return var end end -- This function updates a value in the execution context. -- Returns false if the value is read-only or if key isn't found. -- Returns true if able to update value function npc.data.set(self, name, new_value) local var = self.data[name] if var == nil then return false else if var.readonly == true then npc.log("ERROR", "Attempt to set value of readonly variable: "..name) return false end var.value = new_value end return true end -- This function removes a variable from the execution context. -- If the key doesn't exist, returns nil, otherwise, returns -- the value removed. function npc.data.remove(self, name) local result = self.data[name] if result == nil then return nil else -- Clear variable self.data[name] = nil return result end end --------------------------------------------------------------------------------------- -- Monitor API: API that executes timers and registered callbacks. -- - Timers can be registered by programs or by code in general, and can -- have a callback which is executed when the timer reaches the interval. -- - Callbacks are for programs, instructions and for interrupts (punch, right-click, -- and scheduled entries). The callback is executed after a program, -- instruction or interrupt is executed. -- IMPORTANT: Please, keep *all your callbacks* as light as possible. While useful, -- too many timers or callbacks can deteriorate performance, as all could -- run on NPC steps. --------------------------------------------------------------------------------------- -- Namespace npc.monitor = { timer = { registered = {} }, callback = { registered = {}, -- Constant values type = { program = "program", instruction = "instruction", interaction = "interaction", }, subtype = { on_punch = "on_punch", on_rightclick = "on_rightclick", on_schedule = "on_schedule", } } } -- Register a timer. The timer can have the following arguments: -- - name: unique identifier for timer -- - interval: when timer reaches this value, callback will be executed -- - callback: function to be executed when timer reaches interval -- - initial_value: default is 0. Give this to start with a specific value function npc.monitor.timer.register(name, interval, callback) if npc.monitor.timer.registered[name] ~= nil then npc.log("DEBUG", "Attempt to register an existing timer: "..dump(name)) return false else local timer = { interval = interval, callback = callback } npc.monitor.timer.registered[name] = timer end return true end function npc.monitor.timer.start(self, name, interval, args) if self.execution.monitor.timer[name] then npc.log("DEBUG", "Attempted to start already started timer: "..dump(name)) return end local timer = npc.monitor.timer.registered[name] if timer then -- Activate timer by moving it into the active timer array self.execution.monitor.timer[name] = { value = 0, interval = interval or timer.interval, args = args } else npc.log("DEBUG", "Attempted to start non-existent timer: "..dump(name)) end end function npc.monitor.timer.stop(self, name) if self.execution.monitor.timer[name] == nil then npc.log("DEBUG", "Attempted to stop already stopped timer: "..dump(name)) return end local timer = self.execution.monitor.timer[name] if timer then -- Set timer for removal on next monitor execution routine self.execution.monitor.timer[name].remove = true else npc.log("DEBUG", "Attempted to stop non-existent timer: "..dump(name)) end end -- Name is the name of function for which callback is being registered. -- Use program or instruction name for corresponding programs or instructions, -- and "on_punch", "on_rightclick", "on_activate", "on_schedule" for interrupts function npc.monitor.callback.register(name, type, subtype, callback) -- Initialize type and subtype if they don't exist if npc.monitor.callback.registered[type] == nil then npc.monitor.callback.registered[type] = {} end if npc.monitor.callback.registered[type][subtype] == nil then npc.monitor.callback.registered[type][subtype] = {} end -- Check if callback already exists if npc.monitor.callback.registered[type][subtype][name] ~= nil then npc.log("DEBUG", "Attempt to register an existing callback: "..dump(name)) return else -- Register callback npc.monitor.callback.registered[type][subtype][name] = callback end end function npc.monitor.callback.exists(type, subtype) if npc.monitor.callback.registered[type] ~= nil then if npc.monitor.callback.registered[type][subtype] ~= nil then return next(npc.monitor.callback.registered[type][subtype]) ~= nil end end return false end function npc.monitor.callback.enqueue(self, type, subtype, name) self.execution.monitor.callback.to_execute[#self.execution.monitor.callback.to_execute + 1] = { name = name, type = type, subtype = subtype } end function npc.monitor.callback.enqueue_all(self, type, subtype) for name,_ in pairs(npc.monitor.callback.registered[type][subtype]) do self.execution.monitor.callback.to_execute[#self.execution.monitor.callback.to_execute + 1] = { name = name, type = type, subtype = subtype } end end function npc.monitor.execution_routine(self, dtime) if self.execution.monitor.enabled == false then return end -- Execute timers - traverse the array of active timers and increase -- their respective values for name,timer in pairs(self.execution.monitor.timer) do if timer.remove == true then self.execution.monitor.timer[name] = nil else -- Increase value timer.value = timer.value + dtime -- Check if interval is met if timer.value >= timer.interval then -- Reset value timer.value = 0 -- Execute callback npc.monitor.timer.registered[name].callback(self, timer.args) end end end -- Execute callbacks - traverse array of callbacks to execute for i = #self.execution.monitor.callback.to_execute, 1, -1 do local callback = self.execution.monitor.callback.to_execute[i] -- Execute callback npc.monitor.callback.registered[callback.type][callback.subtype][callback.name](self) -- Remove callback from the execute array self.execution.monitor.callback.to_execute[i] = nil end end --------------------------------------------------------------------------------------- -- Schedule functionality --------------------------------------------------------------------------------------- -- Schedules allow the NPC to do different things depending on the time of the day. -- The time of the day is in 24 hours and is consistent with the Minetest -- /time command. Hours will be written as numbers: 1 for 1:00, 13 for 13:00 or 1:00 PM -- The API is as following: a schedule can be created for a specific date or for a -- day of the week. A date is a string in the format MM:DD npc.schedule = { const = { types = { generic = "generic", date_based = "date_based" } }, entry = {} } npc.schedule_properties = { put_item = "put_item", put_multiple_items = "put_multiple_items", take_item = "take_item", trader_status = "trader_status", can_receive_gifts = "can_receive_gifts", flag = "flag", enable_gift_items_hints = "enable_gift_items_hints", set_trade_list = "set_trade_list" } local function get_time_in_hours() return minetest.get_timeofday() * 24 end -- Create a schedule on a NPC. -- Schedule types: -- - Generic: Returns nil if there are already -- seven schedules, one for each day of the -- week or if the schedule attempting to add -- already exists. The date parameter is the -- day of the week it represents as follows: -- - 1: Monday -- - 2: Tuesday -- - 3: Wednesday -- - 4: Thursday -- - 5: Friday -- - 6: Saturday -- - 7: Sunday -- - Date-based: The date parameter should be a -- string of the format "MM:DD". If it already -- exists, function retuns nil function npc.schedule.create(self, schedule_type, date) if schedule_type == npc.schedule.const.types.generic then -- Check that there are no more than 7 schedules if #self.schedules.generic == 7 then -- Unable to add schedule return nil elseif #self.schedules.generic < 7 then -- Check schedule doesn't exists already if self.schedules.generic[date] == nil then -- Add schedule self.schedules.generic[date] = {} else -- Schedule already present return nil end end elseif schedule_type == npc.schedule.const.types.date then -- Check schedule doesn't exists already if self.schedules.date_based[date] == nil then -- Add schedule self.schedules.date_based[date] = {} else -- Schedule already present return nil end end end function npc.schedule.delete(self, schedule_type, date) -- Delete schedule by setting entry to nil self.schedules[schedule_type][date] = nil end -- Schedule entries API -- Allows to add, get, update and delete entries from each -- schedule. Attempts to be as safe-fail as possible to avoid crashes. -- Actions is an array of actions and tasks that the NPC -- will perform at the scheduled time on the scheduled date function npc.schedule.entry.put(self, schedule_type, date, time, check, actions) -- Check that schedule for date exists if self.schedules[schedule_type][date] ~= nil then -- Add schedule entry if check == nil then self.schedules[schedule_type][date][time] = actions else self.schedules[schedule_type][date][time].check = check end else -- No schedule found, need to be created for date return nil end end function npc.schedule.entry.get(self, schedule_type, date, time) -- Check if schedule for date exists if self.schedules[schedule_type][date] ~= nil then -- Return schedule return self.schedules[schedule_type][date][time] else -- Schedule for date not found return nil end end function npc.schedule.entry.set(self, schedule_type, date, time, check, actions) -- Check schedule for date exists if self.schedules[schedule_type][date] ~= nil then -- Check that a schedule entry for that time exists if self.schedules[schedule_type][date][time] ~= nil then -- Set the new actions if check == nil then self.schedules[schedule_type][date][time] = actions else self.schedules[schedule_type][date][time].check = check end else -- Schedule not found for specified time return nil end else -- Schedule not found for date return nil end end function npc.schedule.entry.remove(self, schedule_type, date, time) -- Check schedule for date exists if self.schedules[schedule_type][date] ~= nil then -- Remove schedule entry by setting to nil self.schedules[schedule_type][date][time] = nil else -- Schedule not found for date return nil end end -- Execution routine for schedules -- For now, only one program per hour should be created by schedule function npc.schedule.execution_routine(self, dtime) if self.schedules.enabled == true then -- Get time of day local time = get_time_in_hours() -- Check if time is an hour if ((time % 1) < dtime) then -- Get integer part of time time = (time) - (time % 1) if not(time > self.schedules.lock or (time == 0 and self.schedules.lock == 23)) then return end npc.log("INFO", "Time: "..dump(time)) -- Activate lock to avoid more than one entry to this code self.schedules.lock = time -- Check if there is a schedule entry for this time -- Note: Currently only one schedule is supported, for day 0 local schedule = self.schedules.generic[0] if schedule ~= nil then -- Check if schedule for this time exists if schedule[time] ~= nil then -- Check if schedules are enabled, and interruptions by scheduler allowed by -- current state/executing script local current_process = self.execution.process_queue[1] --minetest.log("CURRENT PROCESS name: "..dump(current_process.program_name)) if current_process and current_process.interrupt_options.allow_scheduler == false then -- Don't check schedules any further return end -- Hold the programs to be enqueued local programs_to_enqueue = {} local entries_to_enqueue = {} -- Check if a program should be enqueued or not for i = 1, #schedule[time] do -- Check chance local execution_chance = math.random(1, 100) if not schedule[time][i].chance or (schedule[time][i].chance and execution_chance <= schedule[time][i].chance) then -- Check if entry has dependency on other entry local dependencies_met if schedule[time][i].depends then -- TODO: Fix dependency check issue -- minetest.log("Programs to enqueue size: "..dump(programs_to_enqueue)) -- minetest.log("i: "..dump(i)) -- minetest.log("Dependency: "..dump(schedule[time][i].depends[1])) -- minetest.log("programs to enqueue[1]: "..dump(programs_to_enqueue[1])) -- for key,var in pairs(programs_to_enqueue) do -- minetest.log("Key: "..dump(key)) -- end -- minetest.log("entries to enqueue[i]: "..dump(entries_to_enqueue[schedule[time][i].depends[1]])) if entries_to_enqueue[schedule[time][i].depends[1]] ~= nil then dependencies_met = true else dependencies_met = false end end -- minetest.log("Dependencies met for entry with name: "..dump(schedule[time][i].program_name)..": "..dump(dependencies_met)) -- Check for dependencies being met if dependencies_met == nil or dependencies_met == true then programs_to_enqueue[#programs_to_enqueue + 1] = schedule[time][i] entries_to_enqueue[i] = i else npc.log("DEBUG", "Skipping schedule entry for time "..dump(time)..": "..dump(schedule[time][i])) end end end -- Enqueue all programs in programs_to_enqueue if #programs_to_enqueue > 0 then npc.log("INFO", "Enqueueing the following programs into process queue for time: "..dump(time).."\n" ..dump(programs_to_enqueue)) _exec.priority_enqueue(self, programs_to_enqueue) end -- Clear programs_to_enqueue programs_to_enqueue = nil entries_to_enqueue = nil end end -- else -- -- Check if lock can be released -- if (time % 1) > dtime + 0.1 then -- -- Release lock -- self.schedules.lock = false -- end end end end --------------------------------------------------------------------------------------- -- NPC Lua object functions --------------------------------------------------------------------------------------- -- The following functions make up the definitions of on_rightclick(), do_custom() -- and other functions that are assigned to the Lua entity definition -- This function is executed each time the NPC is loaded function npc.after_activate(self) --minetest.log("Self: "..dump(self)) -- Reset animation if self.npc_state then if self.npc_state.movement then if self.npc_state.movement.is_sitting == true then npc.programs.instr.execute(self, npc.programs.instr.default.SIT, {pos=self.object:getpos()}) elseif self.npc_state.movement.is_laying == true then npc.programs.instr.execute(self, npc.programs.instr.default.LAY, {pos=self.object:getpos()}) end -- Reset yaw if available if self.yaw_before_interaction then self.object:setyaw(self.yaw_before_interaction) end end end end -- This function is executed on right-click function npc.rightclick_interaction(self, clicker) -- Disable right click interaction per execution options local current_process = self.execution.process_queue[1] if current_process then if current_process.interrupt_options.allow_rightclick == false then npc.log("WARNING", "Attempted to right-click a NPC with disabled rightlick interaction") return end end -- Enqueue callback if any if npc.monitor.callback.exists(npc.monitor.callback.type.interaction, npc.monitor.callback.subtype.on_rightclick) then -- Enqueue all right-click callbacks for execution npc.monitor.callback.enqueue_all(self, npc.monitor.callback.type.interaction, npc.monitor.callback.subtype.on_rightclick) end -- Store original yaw self.yaw_before_interaction = self.object:getyaw() -- Rotate NPC toward its clicker npc.dialogue.rotate_npc_to_player(self) -- Get information from clicker local item = clicker:get_wielded_item() local name = clicker:get_player_name() npc.log("INFO", "Right-clicked NPC: "..dump(self)) -- Receive gift or start chat. If player has no item in hand -- then it is going to start chat directly --minetest.log("self.can_have_relationship: "..dump(self.can_have_relationship)..", self.can_receive_gifts: "..dump(self.can_receive_gifts)..", table: "..dump(item:to_table())) if self.can_have_relationship and self.can_receive_gifts and item:to_table() ~= nil then -- Get item name local item = minetest.registered_items[item:get_name()] local item_name = item.description -- Show dialogue to confirm that player is giving item as gift npc.dialogue.show_yes_no_dialogue( self, "Do you want to give "..item_name.." to "..self.npc_name.."?", npc.dialogue.POSITIVE_GIFT_ANSWER_PREFIX..item_name, function() npc.relationships.receive_gift(self, clicker) end, npc.dialogue.NEGATIVE_ANSWER_LABEL, function() npc.start_dialogue(self, clicker, true) end, name ) else npc.start_dialogue(self, clicker, true) end end function npc.step(self, dtime) if self.initialized == nil or self.initialized == false then -- Initialize NPC if spawned using the spawn egg built in from -- mobs_redo. This functionality will be removed in the future in -- favor of a better manual spawning method with customization npc.log("WARNING", "Initializing NPC from entity step. This message should only be appearing if an NPC is being spawned from inventory with egg!") npc.initialize(self, self.object:getpos(), true) self.tamed = false self.owner = nil else -- NPC is initialized, check other variables -- Check child texture issues if self.is_child then -- Check texture npc.texture_check.timer = npc.texture_check.timer + dtime if npc.texture_check.timer > npc.texture_check.interval then -- Reset timer npc.texture_check.timer = 0 -- Set hornytimer to zero every 60 seconds so that children -- don't grow automatically self.hornytimer = 0 -- Set correct textures self.texture = {self.selected_texture} self.base_texture = {self.selected_texture} self.object:set_properties(self) npc.log("WARNING", "Corrected textures on NPC child "..dump(self.npc_name)) -- Set interval to large interval so this code isn't called frequently npc.texture_check.interval = 60 end end end -- Timer function for gifts for i = 1, #self.relationships do local relationship = self.relationships[i] -- Gift timer check if relationship.gift_timer_value < relationship.gift_interval then relationship.gift_timer_value = relationship.gift_timer_value + dtime elseif relationship.talk_timer_value < relationship.gift_interval then -- Relationship talk timer - only allows players to increase relationship -- by talking on the same intervals as gifts relationship.talk_timer_value = relationship.talk_timer_value + dtime else -- Relationship decrease timer if relationship.relationship_decrease_timer_value < relationship.relationship_decrease_interval then relationship.relationship_decrease_timer_value = relationship.relationship_decrease_timer_value + dtime else -- Check if married to decrease half if relationship.phase == "phase6" then -- Avoid going below the marriage phase limit if (relationship.points - 0.5) >= npc.relationships.RELATIONSHIP_PHASE["phase5"].limit then relationship.points = relationship.points - 0.5 end else relationship.points = relationship.points - 1 end relationship.relationship_decrease_timer_value = 0 end end end -- Execute monitor npc.monitor.execution_routine(self, dtime) -- Execute process scheduler npc.exec.execution_routine(self, dtime) -- Schedule timer npc.schedule.execution_routine(self, dtime) return false--self.freeze end --------------------------------------------------------------------------------------- -- NPC Definition --------------------------------------------------------------------------------------- --mobs:register_mob("advanced_npc:npc", { -- type = "npc", -- passive = false, -- damage = 3, -- attack_type = "dogfight", -- attacks_monsters = true, -- -- Added group attack -- group_attack = true, -- -- Pathfinder = 2 to make NPCs more smart when attacking -- pathfinding = 2, -- hp_min = 10, -- hp_max = 20, -- armor = 100, -- collisionbox = {-0.20,0,-0.20, 0.20,1.8,0.20}, -- --collisionbox = {-0.20,-1.0,-0.20, 0.20,0.8,0.20}, -- --collisionbox = {-0.35,-1.0,-0.35, 0.35,0.8,0.35}, -- visual = "mesh", -- mesh = "character.b3d", -- drawtype = "front", -- textures = { -- {"npc_male1.png"}, -- {"npc_male2.png"}, -- {"npc_male3.png"}, -- {"npc_male4.png"}, -- {"npc_male5.png"}, -- {"npc_male6.png"}, -- {"npc_male7.png"}, -- {"npc_male8.png"}, -- {"npc_male9.png"}, -- {"npc_male10.png"}, -- {"npc_male11.png"}, -- {"npc_male12.png"}, -- {"npc_male13.png"}, -- {"npc_male14.png"}, -- {"npc_female1.png"}, -- female by nuttmeg20 -- {"npc_female2.png"}, -- {"npc_female3.png"}, -- {"npc_female4.png"}, -- {"npc_female5.png"}, -- {"npc_female6.png"}, -- {"npc_female7.png"}, -- {"npc_female8.png"}, -- {"npc_female9.png"}, -- {"npc_female10.png"}, -- {"npc_female11.png"}, -- }, -- child_texture = { -- {"npc_child_male1.png"}, -- {"npc_child_female1.png"}, -- }, -- makes_footstep_sound = true, -- sounds = {}, -- -- Added walk chance -- walk_chance = 20, -- -- Added stepheight -- stepheight = 0.6, -- walk_velocity = 1, -- run_velocity = 3, -- jump = false, -- drops = { -- {name = "default:wood", chance = 1, min = 1, max = 3}, -- {name = "default:apple", chance = 2, min = 1, max = 2}, -- {name = "default:axe_stone", chance = 5, min = 1, max = 1}, -- }, -- water_damage = 0, -- lava_damage = 2, -- light_damage = 0, -- --follow = {"farming:bread", "mobs:meat", "default:diamond"}, -- view_range = 15, -- owner = "", -- order = "follow", -- --order = "stand", -- fear_height = 3, -- animation = { -- speed_normal = 30, -- speed_run = 30, -- stand_start = 0, -- stand_end = 79, -- walk_start = 168, -- walk_end = 187, -- run_start = 168, -- run_end = 187, -- punch_start = 200, -- punch_end = 219, -- }, -- after_activate = function(self, staticdata, def, dtime) -- npc.after_activate(self) -- end, -- on_rightclick = function(self, clicker) -- -- Check if right-click interaction is enabled -- if self.enable_rightclick_interaction == true then -- npc.rightclick_interaction(self, clicker) -- end -- end, -- do_custom = function(self, dtime) -- return npc.step(self, dtime) -- end --}) ------------------------------------------------------------------------- -- Item definitions ------------------------------------------------------------------------- --mobs:register_egg("advanced_npc:npc", S("NPC"), "default_brick.png", 1) -- compatibility --mobs:alias_mob("mobs:npc", "advanced_npc:npc") -- Marriage ring minetest.register_craftitem("advanced_npc:marriage_ring", { description = S("Marriage Ring"), inventory_image = "marriage_ring.png", }) -- Marriage ring craft recipe minetest.register_craft({ output = "advanced_npc:marriage_ring", recipe = { {"", "", ""}, {"", "default:diamond", ""}, {"", "default:gold_ingot", ""} }, })