-- Opt-in, non-lethal training only. No wildlife, drops, node edits or PvP. local C, M = core, assert(hearth.combat, "Install hearth.combat before encounters") local moving = {} -- One ephemeral opponent per consenting player; never serialized. local function now() return C.get_us_time() / 1000000 end local function offset(p, y) return {x = p.x, y = p.y + y, z = p.z} end local function copy(p) return {x = p.x, y = p.y, z = p.z} end local function feedback(player, message, kind) if hearth.feedback then hearth.feedback(player, message, kind) end end local function sound(name, player) if hearth.feedback_sound then hearth.feedback_sound(name, player, 0.25) else C.sound_play(name, {pos = player:get_pos(), gain = 0.25, max_hear_distance = 12}, true) end end local function live(player) if not player or not player:is_player() then return false end local name = player:get_player_name() return C.get_player_by_name(name) == player and hearth.players[name] ~= nil and hearth.sessions[name] ~= nil and not hearth.sessions[name].waiting and player:get_hp() > 0 end local function allowed(pos, name) return pos ~= nil and hearth.claim_allowed(pos, name, "combat") == true and not C.is_protected(pos, name) end local function eye(player) return offset(player:get_pos(), player:get_properties().eye_height or 1.55) end local function stop(self) local v = self.object:get_velocity() self.object:set_velocity({x = 0, y = v.y, z = 0}) end local animations = {idle = {0, 19, 12}, approach = {20, 39, 24}, windup = {70, 79, 12}, strike = {60, 69, 28}, recover = {0, 19, 12}, cooldown = {0, 19, 12}} local function phase(self, value, t, duration) self.phase, self.deadline = value, t + (duration or 0) if value == "approach" then self.approach_until = t + 8 end if self.mobile then local f = animations[value] self.object:set_animation({x = f[1], y = f[2]}, f[3], 0.12, value ~= "strike") end local label = value == "windup" and "COUNTER INCOMING / step aside or face + brace" or (value == "cooldown" and "ROUND COMPLETE / rest 3 seconds" or "PRACTICE / " .. self.health .. "/30") self.object:set_properties({nametag = label}) end local function finish(self, t, player, detached) if self.phase == "cooldown" then return end local s = self.target and hearth.sessions[self.target] if s then s.spar_ready_at = math.max(s.spar_ready_at or 0, t + 3) if s.spar_entity == self then s.spar_entity = nil end end self.target, self.round_complete, self.direction = nil, false, nil if not detached then stop(self); phase(self, "cooldown", t, 3) end if player then feedback(player, "Round complete / rest, then strike to opt in again", "success") end end local function begin(self, player, t) if not live(player) then return false end local name, s = player:get_player_name(), hearth.sessions[player:get_player_name()] if t < (s.spar_ready_at or 0) or (s.spar_entity and s.spar_entity ~= self) then return false end self.target, s.spar_entity = name, self self.stuck, self.perception, self.last_ai_pos, self.direction = 0, 0, nil, nil if self.mobile then phase(self, "approach", t) else phase(self, "windup", t, 0.75) end return true end local function punch(self, player) -- Never trust client damage, elapsed punch time, direction or tool capabilities. if not live(player) or self.phase == "cooldown" then return true end local name, t = player:get_player_name(), now() if (self.mobile and self.owner ~= name) or (self.target and self.target ~= name) then return true end local state, s, pos = hearth.players[name], hearth.sessions[name], self.object:get_pos() if not pos or (s.spar_entity and s.spar_entity ~= self) then return true end local head, center, stack = eye(player), offset(pos, 0.9), player:get_wielded_item() local attack = M.valid_attack({live = true, hp = player:get_hp(), state = state, session = s, now = t, weapon = stack:get_name(), origin = player:get_pos(), eye = head, target = center, look = player:get_look_dir(), allowed = allowed(pos, name) and allowed(player:get_pos(), name), clear = M.distance(head, center) <= 3.4 and C.line_of_sight(head, center, 0.1) == true}) if not attack or self.round_complete then return true end if not self.target and not begin(self, player, t) then return true end state.stamina = state.stamina - attack.stamina s.combat_now, s.attack_ready_at, s.strike_until = t, t + attack.cadence, t + 10 / 28 stack:add_wear(attack.wear); player:set_wielded_item(stack) self.health = math.max(0, self.health - attack.damage) sound(attack.sound, player); sound("hearth_impact", player) feedback(player, "Hit confirmed / target " .. self.health .. "/30", "success") -- Damage NEVER cancels or restarts a committed windup, even the final hit. if self.health == 0 then self.round_complete = true end return true end local function strike(self, player) if not live(player) then return end local pos, p, name = self.object:get_pos(), player:get_pos(), player:get_player_name() if not pos or not allowed(pos, name) or not allowed(p, name) then return end local center, head = offset(pos, 0.9), eye(player) if M.distance(center, head) > 2.2 or not C.line_of_sight(center, head, 0.1) then feedback(player, "Counter evaded", "info"); return end local state, s = hearth.players[name], hearth.sessions[name] local guard = M.valid_guard({live = true, hp = player:get_hp(), state = state, session = s, weapon = player:get_wielded_item():get_name(), controls = player:get_player_control(), eye = head, target = center, look = player:get_look_dir()}) if guard then state.stamina = state.stamina - 8 end if player:get_hp() > 2 then player:set_hp(math.max(2, player:get_hp() - (guard and 1 or 3)), {type = "set_hp", from = "mod"}) end sound(guard and "hearth_block" or "hearth_impact", player) feedback(player, guard and "Braced / reduced impact" or "Counter landed / step back or aside", guard and "success" or "warning") end local function node_ok(pos, ground) local node = C.get_node_or_nil(pos) local def = node and C.registered_nodes[node.name] if not def or node.name == "ignore" or (def.liquidtype and def.liquidtype ~= "none") or ((def.groups or {}).water or 0) > 0 or ((def.groups or {}).liquid or 0) > 0 then return false end return ground and def.walkable == true or (not ground and def.walkable == false) end local function safe_step(self, pos, dx, dz) local nextpos = {x = pos.x + dx * 0.7, y = pos.y, z = pos.z + dz * 0.7} return M.distance(nextpos, self.home) <= 12 and allowed(nextpos, self.target) and node_ok(offset(nextpos, -0.2), true) and node_ok(offset(nextpos, 0.1), false) and node_ok(offset(nextpos, 1.3), false) and C.line_of_sight(offset(pos, 0.9), offset(nextpos, 0.9), 0.1) == true end local function perceive(self, pos, destination, dt) local dx, dz = destination.x - pos.x, destination.z - pos.z local length = math.sqrt(dx * dx + dz * dz) self.direction = nil if length < 0.1 then return end dx, dz = dx / length, dz / length if self.last_ai_pos and M.distance(self.last_ai_pos, pos) < 0.08 then self.stuck = self.stuck + dt else self.stuck = 0 end self.last_ai_pos = copy(pos) if self.stuck > 3 then return end if self.stuck > 1.5 then dx, dz = self.home.x - pos.x, self.home.z - pos.z length = math.sqrt(dx * dx + dz * dz) if length < 0.1 then return end dx, dz = dx / length, dz / length end for _, d in ipairs({{dx, dz}, {-dz, dx}, {dz, -dx}}) do if safe_step(self, pos, d[1], d[2]) then self.direction = d; return end end end local function step(self, dt) dt = math.max(0, math.min(tonumber(dt) or 0, 0.1)) local t, pos = now(), self.object:get_pos() if not pos then return end if self.phase == "cooldown" then stop(self) if t >= self.deadline then self.health = 30; phase(self, "idle", t) end return end if not self.target then stop(self); return end local player = C.get_player_by_name(self.target) if not live(player) or not allowed(pos, self.target) or not allowed(player:get_pos(), self.target) or M.distance(player:get_pos(), self.home) > 12 or M.distance(pos, self.home) > 12 then finish(self, t); return end if self.phase == "windup" then stop(self) if t >= self.deadline then phase(self, "strike", t, 0.12); strike(self, player) end elseif self.phase == "strike" then stop(self); if t >= self.deadline then phase(self, "recover", t, 0.9) end elseif self.phase == "recover" then stop(self) if t >= self.deadline then if self.round_complete then finish(self, t, player) elseif self.mobile then phase(self, "approach", t) else local s = hearth.sessions[self.target]; if s and s.spar_entity == self then s.spar_entity = nil end self.target = nil; phase(self, "idle", t) end end elseif self.phase == "approach" then -- A moving-but-unproductive avoidance loop also has a hard budget. if self.round_complete or t >= self.approach_until then finish(self, t, player); return end self.perception = self.perception + dt if self.perception >= 0.2 then self.perception = 0 local p = player:get_pos() self.object:set_yaw(C.dir_to_yaw({x = p.x - pos.x, y = 0, z = p.z - pos.z})) if M.distance(offset(pos, 0.9), eye(player)) <= 2.0 and C.line_of_sight(offset(pos, 0.9), eye(player), 0.1) then stop(self); phase(self, "windup", t, 0.75) feedback(player, "Counter incoming / step aside or face + brace", "warning"); return end perceive(self, pos, player:get_pos(), 0.2) if self.stuck > 3 then finish(self, t, player); return end end local d, v = self.direction, self.object:get_velocity() self.object:set_velocity({x = d and d[1] * 2.2 or 0, y = v.y, z = d and d[2] * 2.2 or 0}) end end local function definition(mobile) return { initial_properties = {physical = true, collide_with_objects = false, collisionbox = {-0.3, 0, -0.3, 0.3, 1.6, 0.3}, visual = mobile and "mesh" or "cube", mesh = "hearth_person.b3d", textures = mobile and {"hearth_person.png"} or {hearth.texture("#c7aa83"), hearth.texture("#947853"), hearth.texture("#947853"), hearth.texture("#947853"), hearth.texture("#947853"), hearth.texture("#947853")}, visual_size = mobile and {x = 1, y = 1} or {x = 0.6, y = 1.6, z = 0.6}, pointable = true, hp_max = 100, static_save = not mobile, nametag = "PRACTICE / strike to opt in", nametag_color = "#e5c890"}, on_activate = function(self) self.mobile, self.health, self.perception, self.stuck = mobile, 30, 0, 0 self.target, self.owner, self.round_complete, self.direction = nil, nil, false, nil self.last_ai_pos = nil self.home = copy(self.object:get_pos()) self.object:set_armor_groups({immortal = 1}) self.object:set_acceleration({x = 0, y = -9.81, z = 0}) stop(self); phase(self, "idle", now()) -- Ignore old staticdata; keep identity, reset health. end, on_punch = punch, on_step = step, on_deactivate = function(self) if self.owner and moving[self.owner] == self then moving[self.owner] = nil end if self.target then finish(self, now(), nil, true) end end, } end C.register_entity("hearth:practice", definition(false)) C.register_entity("hearth:sparring", definition(true)) -- Independent movement observations, including before the first hit. local sample_elapsed = 0 C.register_globalstep(function(dt) sample_elapsed = sample_elapsed + math.max(0, math.min(dt, 0.1)) if sample_elapsed < 0.1 then return end sample_elapsed = 0 local t = now() for _, player in ipairs(C.get_connected_players()) do if live(player) then local s = hearth.sessions[player:get_player_name()] s.combat_sample = M.sample(s.combat_sample, player:get_pos(), t) end end end) local function deployment_allowed(name, practice) local single = C.is_singleplayer() local deployment = C.settings:get("hearth_environment") or (single and "local" or "production") if deployment == "production" then return false end -- No developer spawning, even for admins. if practice then return single and deployment == "local" end return (single and deployment == "local") or (deployment == "staging" and C.check_player_privs(name, {server = true}) == true) end local function spawn(name, mobile) if not deployment_allowed(name, not mobile) then return false, "Training placement requires local play or privileged staging /spar." end local player, t = C.get_player_by_name(name), now() if not live(player) then return false, "Join and finish arrival first." end local s = hearth.sessions[name] if t < (s.spar_ready_at or 0) or s.spar_entity then return false, "Finish your round and rest first." end if mobile and moving[name] and moving[name].object:get_pos() then return false, "Your sparring opponent is already placed; strike it to opt in again." end local p, look = player:get_pos(), player:get_look_dir() local length = math.sqrt(look.x^2 + look.z^2) if length < 0.01 then return false, "Look towards open ground." end local pos = {x = p.x + look.x / length * 3, y = p.y, z = p.z + look.z / length * 3} if not allowed(p, name) or not allowed(pos, name) or not node_ok(offset(pos, -0.2), true) or not node_ok(offset(pos, 0.1), false) or not node_ok(offset(pos, 1.3), false) or not C.line_of_sight(eye(player), offset(pos, 0.9), 0.1) then return false, "Look at clear, loaded, dry ground outside other claims." end if not mobile then for _, obj in ipairs(C.get_objects_inside_radius(pos, 30)) do local e = obj:get_luaentity() if e and e.name == "hearth:practice" then return false, "A practice target is already nearby." end end end local object = C.add_entity(pos, mobile and "hearth:sparring" or "hearth:practice") if not object then return false, "Could not place training target." end if mobile then local e = object:get_luaentity(); e.owner, moving[name] = name, e begin(e, player, t) end return true, mobile and "Sparring started / step back or aside; face with spear + RMB to brace." or "Practice target placed. Strike it to start." end C.register_chatcommand("practice", {description = "Place a stationary practice target (local play only)", func = function(name) return spawn(name, false) end}) C.register_chatcommand("spar", {description = "Opt in to one moving sparring opponent (local or server-privileged staging only)", func = function(name) return spawn(name, true) end})