show_travel_trail = true bindkey = [^D] CMD_LUA_CONSOLE macros += M \{F1} ===reload macros += M y ===show_sneak_path macros += M h ===sneak_attack macros += M j ===zap_a macros += M k ===zap_b macros += M l ===zap_c macros += M ; ===zap_d macros += M J X<\{13} macros += M K ===travel_to_downstairs macros += M U \{24}!a macros += M Y \{6}armour&&!!dropped&&!!carried&&!!useless&&!!orb\{13} macros += M H ===go_safest_explore default_manual_training = true explore_auto_rest = true rest_wait_percent = 100 show_more = false travel_one_unsafe_move = true spell_slot += fireball:f spell_slot += lee's rapid deconstruction:r spell_slot += flame wave:w spell_slot += fire storm:s spell_slot += ensorcelled hibernation:e spell_slot += blink:x spell_slot += passwall:p { -- Global utility functions function pk(x, y) return 40000 * (100 + x) + (100 + y) end function cheb(x1, y1, x2, y2) if x2 then return math.max(math.abs(x2 - x1), math.abs(y2 - y1)) end -- Two-arg form: cheb(x, y) = distance from origin return math.max(math.abs(x1), math.abs(y1)) end -- compat shim: 0.33 used "fully_identified", 0.34 renamed to "is_identified" function item_identified(it) if it.is_identified ~= nil then return it.is_identified end if it.fully_identified ~= nil then return it.fully_identified end return false end -- state for pending autoequip (waiting for equip command to complete) pending_autoequip = nil -- map armor base types to equipment slots local armor_to_slot = {} armor_to_slot["robe"] = "armour" armor_to_slot["leather armour"] = "armour" armor_to_slot["ring mail"] = "armour" armor_to_slot["scale mail"] = "armour" armor_to_slot["chain mail"] = "armour" armor_to_slot["plate armour"] = "armour" armor_to_slot["crystal plate armour"] = "armour" armor_to_slot["troll leather armour"] = "armour" armor_to_slot["steam dragon scales"] = "armour" armor_to_slot["acid dragon scales"] = "armour" armor_to_slot["quicksilver dragon scales"] = "armour" armor_to_slot["swamp dragon scales"] = "armour" armor_to_slot["fire dragon scales"] = "armour" armor_to_slot["ice dragon scales"] = "armour" armor_to_slot["pearl dragon scales"] = "armour" armor_to_slot["shadow dragon scales"] = "armour" armor_to_slot["storm dragon scales"] = "armour" armor_to_slot["gold dragon scales"] = "armour" armor_to_slot["helmet"] = "helmet" armor_to_slot["hat"] = "helmet" armor_to_slot["cloak"] = "cloak" armor_to_slot["scarf"] = "cloak" armor_to_slot["gloves"] = "gloves" armor_to_slot["boots"] = "boots" armor_to_slot["buckler"] = "shield" armor_to_slot["kite shield"] = "shield" armor_to_slot["tower shield"] = "shield" -- orbs (subtype "offhand") are excluded from auto-pickup entirely function is_dangerous(it) if it.is_useless then return true end if it.ego() == "distortion" then return true end if it.name_coloured and it.name_coloured():find("^") then return true end return false end function is_better_weapon(it) -- 1. useless or dangerous: always false if is_dangerous(it) then return false end local current = items.equipped_at("weapon") -- 2. anything vs nothing: always true if not current then return true end -- 3. if type is different, always false (don't suggest switching weapon types) if it.subtype() ~= current.subtype() then return false end -- 4. artefact of same type is "better" (so user can judge) if it.artefact then return true end -- 4b. only artefact can be better than current artefact if current.artefact then return false end local current_brand = current.ego() local new_brand = it.ego() local current_plus = current.plus or 0 local new_plus = it.plus or 0 -- 5. if branded and brand is different than current: true (so user can judge) if new_brand and new_brand ~= current_brand then return true end -- 6. plus is higher and brands are same (or both unbranded): true if new_plus > current_plus and new_brand == current_brand then return true end return false end function should_ask_about_dagger() -- don't ask if we already have a short blade (dagger, quick blade, short sword, rapier) for _, it in ipairs(items.inventory()) do if it.class(true) == "weapon" then local subtype = it.subtype and it.subtype():lower() or "" if short_blades[subtype] then return false end end end return true end function handle_pending_weapon() -- Scan visible items for better weapons local want_dagger = should_ask_about_dagger() local current_weapon = items.equipped_at("weapon") local current_weapon_desc = current_weapon and current_weapon.name() or "unarmed" for _, entry in ipairs(scan_visible_floor_items()) do local it = entry.item local ix, iy = entry.x, entry.y if it.class(true) == "weapon" and not it.dropped then local is_dagger = it.name():lower():find("dagger") local should_ask = is_better_weapon(it) or (is_dagger and want_dagger) if should_ask then local weapon_name = it.name() if crawl.yesno("Found " .. weapon_name .. " (current: " .. current_weapon_desc .. "). Pick it up?", true, "n") then -- Build command sequence: move to item + pickup local cmds = moves_to(ix, iy) table.insert(cmds, "CMD_PICKUP") crawl.do_commands(cmds) return true end end end end return false end function get_armor_slot(msg) for armor, slot in pairs(armor_to_slot) do if msg:lower():find(armor) then return slot, armor end end return nil, nil end function is_better_armor(new_armor, old_armor) -- 1. useless or dangerous: always false if is_dangerous(new_armor) then return false end -- 1b. ignore orbs (they go in offhand slot but shouldn't be auto-picked) local subtype = new_armor.subtype and new_armor.subtype() or nil if subtype and subtype:lower() == "offhand" then return false end -- 2. anything vs nothing: always true if not old_armor then return true end -- 3. artefact is "better" than anything (so user can judge) if new_armor.artefact then return true end -- 3b. only artefact can be better than current artefact if old_armor.artefact then return false end -- (no rule 4 for armor - type doesn't matter) local old_brand = old_armor.ego() local new_brand = new_armor.ego() local old_plus = old_armor.plus or 0 local new_plus = new_armor.plus or 0 -- 5. if branded and brand is different than current: true (so user can judge) if new_brand and new_brand ~= old_brand then return true end -- 6. plus is higher and brands are same (or both unbranded): true if new_plus > old_plus and new_brand == old_brand then return true end return false end function get_equipped_aux_armor() -- returns list of all equipped non-body/shield armor -- Works for all species including poltergeists with flexible slots local equipped = {} for _, it in ipairs(items.inventory()) do if it.equipped and it.class(true) == "armour" then local subtype = it.subtype and it.subtype() or nil -- Exclude body armour and shields/offhand items if subtype and subtype ~= "body" and subtype ~= "shield" and subtype ~= "buckler" and subtype ~= "offhand" then table.insert(equipped, it) end end end return equipped end function has_open_armor_slot(slot) -- For poltergeists with flexible slots, use the game's slot_is_available API if you.race() == "Poltergeist" and items.slot_is_available then return items.slot_is_available(slot) end -- For normal species, just check if slot is empty return not items.equipped_at(slot) end function should_get_armor(it, slot) -- returns true if we should ask about this armor if is_dangerous(it) then return false end -- First check if slot is available (works for all species) if has_open_armor_slot(slot) then return true end -- Slot is full. For species with flexible slots (like poltergeists), -- check if new armor is better than any currently equipped piece if you.race() == "Poltergeist" and slot ~= "armour" and slot ~= "shield" then local equipped = get_equipped_aux_armor() for _, old_armor in ipairs(equipped) do if is_better_armor(it, old_armor) then return true end end end return false end -- cached floor items for the current turn cached_floor_items = nil cached_floor_items_turn = -1 cached_map = nil cached_map_turn = -1 function get_map() local t = you.turns() if cached_map and cached_map_turn == t then return cached_map end cached_map = view.get_map() cached_map_turn = t return cached_map end cached_monsters = nil cached_monsters_turn = -1 -- Returns list of map cells that have monsters, cached per turn. function get_monsters() local t = you.turns() if cached_monsters and cached_monsters_turn == t then return cached_monsters end local map = get_map() cached_monsters = {} for _, cell in pairs(map) do if cell.monster then cached_monsters[#cached_monsters + 1] = cell end end cached_monsters_turn = t return cached_monsters end function scan_visible_floor_items() -- cache results per turn to avoid repeated scanning local current_turn = you.turns() if cached_floor_items and cached_floor_items_turn == current_turn then return cached_floor_items end local found_items = {} for x = -8, 8 do for y = -8, 8 do if you.see_cell(x, y) then local floor_items = items.get_items_at(x, y) if floor_items then for _, it in ipairs(floor_items) do -- Store item with its position table.insert(found_items, {item = it, x = x, y = y}) end floor_items = nil -- release reference end end end end cached_floor_items = found_items cached_floor_items_turn = current_turn return found_items end -- Convert relative (x, y) to a list of CMD_MOVE_* commands -- Direction lookup: [dx+2][dy+2] -> suffix (UP_LEFT, UP, etc.) local dir_suffix = { [1] = { [1] = "UP_LEFT", [2] = "LEFT", [3] = "DOWN_LEFT" }, [2] = { [1] = "UP", [3] = "DOWN" }, [3] = { [1] = "UP_RIGHT", [2] = "RIGHT", [3] = "DOWN_RIGHT" } } -- Generate a list of commands to step from origin toward (x, y) function steps_to(x, y, prefix) local cmds = {} while x ~= 0 or y ~= 0 do local dx = x > 0 and 1 or (x < 0 and -1 or 0) local dy = y > 0 and 1 or (y < 0 and -1 or 0) local suffix = dir_suffix[dx + 2] and dir_suffix[dx + 2][dy + 2] if not suffix then break end cmds[#cmds + 1] = prefix .. suffix x = x - dx; y = y - dy end return cmds end function moves_to(x, y) return steps_to(x, y, "CMD_MOVE_") end function map_cursor_moves_to(x, y) return steps_to(x, y, "CMD_MAP_MOVE_") end -- Travel to player-relative coordinates (x, y) using the map interface function map_travel_to(x, y) local cmds = { "CMD_WAIT", "CMD_DISPLAY_MAP" } local cursor_moves = map_cursor_moves_to(x, y) for _, cmd in ipairs(cursor_moves) do cmds[#cmds + 1] = cmd end cmds[#cmds + 1] = "CMD_MAP_GOTO_TARGET" crawl.do_commands(cmds) end function travel_to(x, y) map_travel_to(x, y) end function check_floor_armor() -- Scan visible items for armor we want for _, entry in ipairs(scan_visible_floor_items()) do local it = entry.item local ix, iy = entry.x, entry.y if it.class(true) == "armour" and not it.dropped then -- skip orbs - subtype is "offhand", shouldn't be auto-picked local subtype = it.subtype and it.subtype() or nil if subtype ~= "offhand" then local slot, armor_type = get_armor_slot(it.name()) if slot and should_get_armor(it, slot) then if crawl.yesno("Found " .. it.name() .. ". Pick it up?", true, "n") then -- Build command sequence: move to item + pickup local cmds = moves_to(ix, iy) table.insert(cmds, "CMD_PICKUP") crawl.do_commands(cmds) return true end end end end end return false end function know_identify() for _, name in ipairs(you.known_items()) do if name:lower():find("identify") then return true end end return false end function maybe_read_identify() -- check if there's anything unidentified to identify local has_unidentified = false for _, it in ipairs(items.inventory()) do if not item_identified(it) and not it.is_useless then has_unidentified = true break end end if not has_unidentified then return false end for _, it in ipairs(items.inventory()) do if it.class(true) == "scroll" and it.name():lower():find("identify") and not it.is_useless then if crawl.yesno("Read a scroll of identify?", true, "n") then crawl.sendkeys("r" .. items.index_to_letter(it.slot)) return true end return false end end return false end function maybe_find_identify() if know_identify() then return end -- count unidentified scrolls by name local scroll_counts = {} for _, it in ipairs(items.inventory()) do if it.class(true) == "scroll" and not item_identified(it) then local name = it.name() scroll_counts[name] = (scroll_counts[name] or 0) + it.quantity end end -- find any stack with 4 or more for name, count in pairs(scroll_counts) do if count >= 4 then if crawl.yesno("You have " .. count .. " " .. name .. ". Read one to check for identify?", true, "n") then -- find the item again and read it for _, it in ipairs(items.inventory()) do if it.name() == name then crawl.sendkeys("r" .. items.index_to_letter(it.slot)) return true end end end return false end end return false end -- garbage collection counter gc_counter = 0 -- turn tracking: ready() is called repeatedly until a game turn passes; -- prompt-based functions must only run once per turn to prevent infinite loop if not c_persist.last_ready_turn then c_persist.last_ready_turn = -1 end -- Lists of items that benefit from various skills -- Short blades (subtypes) short_blades = { ["dagger"] = true, ["quick blade"] = true, ["short sword"] = true, ["rapier"] = true } -- Throwable items (class = missile) -- Detected by class(true) == "missile" -- Evocable miscellaneous items evocable_misc = { ["phial of floods"] = true, ["horn of geryon"] = true, ["lightning rod"] = true, ["box of beasts"] = true, ["sack of spiders"] = true, ["tin of tremorstones"] = true, ["phantom mirror"] = true, ["condenser vane"] = true, ["gravitambourine"] = true } -- Artefacts with evocable abilities (name fragments to match) evocable_artefacts = { ["staff of olgreb"] = true, ["dispater"] = true, -- Orb of Dispater ["elemental staff"] = true } -- Items with evocable invisibility evocable_invis = { ["scarf of invisibility"] = true, ["amulet of invisibility"] = true } -- Shield subtypes shield_types = { ["buckler"] = true, ["kite shield"] = true, ["tower shield"] = true } function has_evocable_item() for _, it in ipairs(items.inventory()) do local class = it.class(true) local name = it.name():lower() local subtype = it.subtype and it.subtype() or nil -- Wands always benefit from evocations if class == "wand" then return true end -- Miscellaneous evocable items if class == "miscellany" then for evo_name, _ in pairs(evocable_misc) do if name:find(evo_name) then return true end end end -- Artefacts with evocable abilities if it.artefact then for art_name, _ in pairs(evocable_artefacts) do if name:find(art_name) then return true end end end -- Evocable invisibility items for invis_name, _ in pairs(evocable_invis) do if name:find(invis_name) then return true end end end return false end function wielding_short_blade() local weapon = items.equipped_at("weapon") if not weapon then return false end local subtype = weapon.subtype and weapon.subtype() or nil if subtype and short_blades[subtype:lower()] then return true end return false end function has_throwables() for _, it in ipairs(items.inventory()) do if it.class(true) == "missile" then return true end end return false end function has_shield() for _, it in ipairs(items.inventory()) do if it.is_shield() then return true end end return false end -- Check floor for shields when not carrying one function check_floor_shield() -- Don't prompt if already carrying a shield if has_shield() then return false end -- Scan visible items for shields for _, entry in ipairs(scan_visible_floor_items()) do local it = entry.item local ix, iy = entry.x, entry.y if it.is_shield() and not it.dropped then if not is_dangerous(it) then if crawl.yesno("Found " .. it.name() .. ". Pick it up?", true, "n") then local cmds = moves_to(ix, iy) table.insert(cmds, "CMD_PICKUP") crawl.do_commands(cmds) return true end end end end return false end function spell_has_school(spell_name, school_name) local desc = spells.describe(spell_name) local schools = desc:match("Schools?:%s*([^\n]+)") if schools then return schools:find(school_name) ~= nil end return false end function needs_school_training(school_name) -- Train if a memorised spell of this school is below max power for _, spell_name in ipairs(you.spells()) do if spell_has_school(spell_name, school_name) and spells.power_perc(spell_name) < 100 then return true end end -- Also train if a wanted unmemorised spell belongs to this school for spell_name, _ in pairs(wanted_spells) do if not spells.memorised(spell_name) and is_wanted_spell(spell_name) and spell_has_school(spell_name, school_name) then return true end end return false end function needs_transloc_training() for _, spell_name in ipairs(you.spells()) do if spell_has_school(spell_name, "Transloc") and (spells.power_perc(spell_name) < 50 or spells.fail(spell_name) >= 5) then return true end end return false end function autotrain() -- Always train fighting unless target is set and reached local fighting_target = you.get_training_target("Fighting") if fighting_target == 0 or you.skill("Fighting") < fighting_target then local current = you.train_skill("Fighting") if current == 0 then you.train_skill("Fighting", 1) crawl.mpr("Autotrain: enabled Fighting training") end end -- Train evocations if carrying any evocable item if has_evocable_item() then local current = you.train_skill("Evocations") if current == 0 then you.train_skill("Evocations", 1) crawl.mpr("Autotrain: enabled Evocations training (carrying evocables)") end end -- Train short blades to 5 if wielding a short blade if wielding_short_blade() then local skill = you.skill("Short Blades") if skill < 5 then local current = you.train_skill("Short Blades") if current == 0 then you.train_skill("Short Blades", 1) crawl.mpr("Autotrain: enabled Short Blades training (wielding short blade)") end -- Set target to 5 if not already set higher local target = you.get_training_target("Short Blades") if target < 5 then you.set_training_target("Short Blades", 5) end end end -- Train throwing to 5 if carrying throwables if has_throwables() then local skill = you.skill("Throwing") if skill < 5 then local current = you.train_skill("Throwing") if current == 0 then you.train_skill("Throwing", 1) crawl.mpr("Autotrain: enabled Throwing training (carrying throwables)") end -- Set target to 5 if not already set higher local target = you.get_training_target("Throwing") if target < 5 then you.set_training_target("Throwing", 5) end end end -- Train shields to 5 if carrying a shield if has_shield() then local skill = you.skill("Shields") if skill < 5 then local current = you.train_skill("Shields") if current == 0 then you.train_skill("Shields", 1) crawl.mpr("Autotrain: enabled Shields training (carrying shield)") end -- Set target to 5 if not already set higher local target = you.get_training_target("Shields") if target < 5 then you.set_training_target("Shields", 5) end end end -- Train Fire Magic if casting any fire spell below 100% power if needs_school_training("Fire") then local target = you.get_training_target("Fire Magic") if target == 0 or you.skill("Fire Magic") < target then local current = you.train_skill("Fire Magic") if current == 0 then you.train_skill("Fire Magic", 1) crawl.mpr("Autotrain: enabled Fire Magic training") end end end -- Train Earth Magic if casting any earth spell below 100% power if needs_school_training("Earth") then local target = you.get_training_target("Earth Magic") if target == 0 or you.skill("Earth Magic") < target then local current = you.train_skill("Earth Magic") if current == 0 then you.train_skill("Earth Magic", 1) crawl.mpr("Autotrain: enabled Earth Magic training") end end end -- Train Translocations if any memorised translocation spell has -- power below 50% or failure rate >= 5% if needs_transloc_training() then local target = you.get_training_target("Translocations") if target == 0 or you.skill("Translocations") < target then local current = you.train_skill("Translocations") if current == 0 then you.train_skill("Translocations", 1) crawl.mpr("Autotrain: enabled Translocations training") end end end end function ready() -- Debug for testing if DEBUG_ASSIST then crawl.mpr("ready() called, feel_safe=" .. tostring(you.feel_safe())) end -- ready() is called repeatedly by the game loop until a turn passes. -- Functions that use crawl.yesno() must only run once per turn, -- otherwise declining a prompt causes an infinite loop (the game -- re-calls ready() immediately since no turn was consumed). local current_turn = you.turns() local new_turn = (c_persist.last_ready_turn ~= current_turn) if new_turn then c_persist.last_ready_turn = current_turn else return end -- Too-deep warning fires even when unsafe (e.g. shafted into monsters) warn_if_too_deep() suggest_lake() if not you.feel_safe() then return end if check_floor_armor() then return end if handle_pending_weapon() then return end if check_floor_shield() then return end if DEBUG_ASSIST then crawl.mpr("calling autojoin()") end autojoin() autocheckshop() maybe_find_identify() maybe_read_identify() automemorise() autoequip() autodrop() autotrain() -- clear cached data at end of turn cached_floor_items = nil cached_map = nil cached_monsters = nil -- periodic garbage collection (every 10 turns) gc_counter = gc_counter + 1 if gc_counter >= 10 then gc_counter = 0 collectgarbage("collect") end end function control(c) return string.char(string.byte(c) - string.byte("a") + 1) end -- Find the safest cell from which to observe unexplored territory. -- "Safest" = sees an unseen cell from the furthest distance. -- Tiebreak: more frontier cells visible at that max distance. -- Then tiebreak: closest to player (fewest steps). -- Returns: {x, y, best_dist, count, path_cost, path} or nil. -- Algorithm: -- 1. From frontier cells (known cells adjacent to unknown), derive actual -- unseen cells: neighbors of frontiers that are NOT in the known map. -- 2. BFS from player through "interior" cells (cells that see NO unseen cell). -- The player's own cell is always expanded regardless. -- 3. When BFS reaches a cell that DOES see an unseen cell, it becomes a -- candidate destination (not expanded further). -- 4. Score candidates: maximize the max Chebyshev distance to any visible -- unseen cell, tiebreak by count at that distance, then closest path cost. -- Result: the path stays in fully-explored territory; only the final -- destination reveals unseen cells, and from maximum distance. function safest_explore() local map = get_map() local cell_see = view.cell_see_cell -- Collect traversable cells and build set of known cell keys local known = {} -- pk -> true for all known cells local trav = {} -- pk -> true for traversable, safe cells local frontier_list = {} for key, cell in pairs(map) do known[key] = true if cell.frontier then frontier_list[#frontier_list + 1] = cell end if cell.traversable and not cell.unsafe and not cell.excluded then trav[key] = true end end if #frontier_list == 0 then return nil end -- Derive actual unseen cells: neighbors of frontiers not in the known map. -- Deduplicate by key. local unseen = {} -- pk -> {x, y} local unseen_list = {} for _, f in ipairs(frontier_list) do for dx = -1, 1 do for dy = -1, 1 do if dx ~= 0 or dy ~= 0 then local ux, uy = f.x + dx, f.y + dy local uk = pk(ux, uy) if not known[uk] and not unseen[uk] then local u = { x = ux, y = uy } unseen[uk] = u unseen_list[#unseen_list + 1] = u end end end end end if #unseen_list == 0 then return nil end -- Check whether cell (cx,cy) sees any unseen cell. Returns list or false. local sees_cache = {} local function sees_unseen(cx, cy) local k = pk(cx, cy) if sees_cache[k] ~= nil then return sees_cache[k] end local vis = {} for _, u in ipairs(unseen_list) do if math.abs(u.x - cx) <= 7 and math.abs(u.y - cy) <= 7 and cell_see(cx, cy, u.x, u.y) then vis[#vis + 1] = u end end sees_cache[k] = #vis > 0 and vis or false return sees_cache[k] end -- BFS from player through interior cells only (cells seeing no unseen). -- Cells that DO see unseen become candidate destinations, scored inline. -- Early termination: the better the best candidate, the sooner we stop. -- best_d=7 (max LOS): stop immediately. best_d=6: search 2*LOS more, etc. local los_range = 7 local max_bfs = 50 local start_pk = pk(0, 0) local dist = {} local par = {} dist[start_pk] = 0 local s = { x = 0, y = 0, k = start_pk } local queue = { s } local qi = 1 local best = nil local best_d = 0 local best_count = 0 local best_cost = math.huge local best_found_at = max_bfs -- BFS depth when best was found local function score_candidate(n, vis, cost) local max_d, cnt = 0, 0 for _, u in ipairs(vis) do local d = cheb(u.x, u.y, n.x, n.y) if d > max_d then max_d = d; cnt = 1 elseif d == max_d then cnt = cnt + 1 end end if max_d > best_d or (max_d == best_d and cnt > best_count) or (max_d == best_d and cnt == best_count and cost < best_cost) then best = n best_d = max_d best_count = cnt best_cost = cost best_found_at = cost end end while qi <= #queue do local c = queue[qi] qi = qi + 1 local cd = dist[c.k] -- Early termination: budget = (LOS+1 - best_d) * LOS past where best was found if best and cd > best_found_at + (los_range + 1 - best_d) * los_range then break end if cd < max_bfs then for dx = -1, 1 do for dy = -1, 1 do if dx ~= 0 or dy ~= 0 then local nx, ny = c.x + dx, c.y + dy local nk = pk(nx, ny) if trav[nk] and dist[nk] == nil then dist[nk] = cd + 1 local n = { x = nx, y = ny, k = nk } par[nk] = c local vis = sees_unseen(nx, ny) if vis then score_candidate(n, vis, cd + 1) else queue[#queue + 1] = n end end end end end end end if not best then return nil end -- Reconstruct path local path = {} local cur = best while cur.k ~= start_pk do table.insert(path, 1, { x = cur.x, y = cur.y }) cur = par[cur.k] if not cur then return nil end end return { x = best.x, y = best.y, best_dist = best_d, count = best_count, path_cost = best_cost, path = path } end function go_safest_explore() local result = safest_explore() if not result or #result.path == 0 then return false end crawl.mpr("Safest explore: unseen cell " .. result.best_dist .. " tiles away.") map_travel_to(result.x, result.y) return true end function safe_moves_to(x, y) return steps_to(x, y, "CMD_SAFE_MOVE_") end -- A* pathfinder for sneaking: finds path from player to a cell adjacent -- to the target that minimizes visibility to the target. Supports Passwall. -- The final bump-attack step onto the target is appended separately. -- Returns list of steps: {x, y} for normal moves, {x, y, passwall=true} -- for passwall moves, with the last step always being {tx, ty} (the attack). -- Returns nil if no path found. function sneak_path(map, tx, ty) local cell_see = view.cell_see_cell -- Check if Passwall is available local pw_range = 0 if spells.memorised("Passwall") and spells.fail("Passwall") <= 5 then pw_range = spells.range("Passwall") end -- Collect frontier cells into an array for distance checks. -- BFS flood-fill from frontier cells to mark minimum distance on each cell. -- Each traversable cell gets cell.fd = steps to nearest frontier. -- Cells near frontier get higher sneak penalty (more likely to reveal unseen). local los_range = 7 local todo, ti = {}, 0 for key, c in pairs(map) do if c.frontier and c.traversable then c.fd = 0 todo[#todo + 1] = c end end while ti < #todo do ti = ti + 1 local c = todo[ti] local d = c.fd + 1 if d <= los_range then for ddx = -1, 1 do for ddy = -1, 1 do if ddx ~= 0 or ddy ~= 0 then local nk = pk(c.x + ddx, c.y + ddy) local nc = map[nk] if nc and nc.traversable and not nc.fd then nc.fd = d todo[#todo + 1] = nc end end end end end end -- Goal: any traversable cell adjacent to the target local goal_set = {} local target_k = pk(tx, ty) for dx = -1, 1 do for dy = -1, 1 do if dx ~= 0 or dy ~= 0 then local ax, ay = tx + dx, ty + dy local ak = pk(ax, ay) local c = map[ak] -- Adjacent cell must be traversable (or be the player's cell) if ak == pk(0, 0) or (c and c.traversable and not c.unsafe and not c.door and not (c.feature and c.feature:find("^trap_")) and not (c.monster and c.monster:is_stationary())) then goal_set[ak] = true end end end end -- A* state local start_k = pk(0, 0) -- Already adjacent to target if goal_set[start_k] then return { { x = tx, y = ty } } end local g = { [start_k] = 0 } local f = {} local came_from = {} -- key -> {from_key, x, y, passwall} local closed = {} local open = { { k = start_k, x = 0, y = 0 } } local open_set = { [start_k] = true } local function heuristic(x, y) -- Chebyshev distance to nearest cell adjacent to target, -- plus small Euclidean tiebreaker for more natural paths local dx, dy = math.abs(x - tx), math.abs(y - ty) return math.max(dx, dy) - 1 + math.sqrt(dx * dx + dy * dy) * 0.001 end f[start_k] = heuristic(0, 0) local function pop_best() local best_i, best_f = 1, math.huge for i, node in ipairs(open) do if f[node.k] < best_f then best_i = i best_f = f[node.k] end end local node = open[best_i] table.remove(open, best_i) open_set[node.k] = nil return node end local function try_neighbor(cur, nx, ny, cost, is_passwall) local nk = pk(nx, ny) if closed[nk] then return end local tentative_g = g[cur.k] + cost if g[nk] == nil or tentative_g < g[nk] then g[nk] = tentative_g f[nk] = tentative_g + heuristic(nx, ny) came_from[nk] = { from = cur.k, x = nx, y = ny, passwall = is_passwall or false } if not open_set[nk] then open[#open + 1] = { k = nk, x = nx, y = ny } open_set[nk] = true end end end local expansions = 0 while #open > 0 do expansions = expansions + 1 if expansions > 1000 then return nil end -- avoid Lua timeout local cur = pop_best() if goal_set[cur.k] then -- Reconstruct path to this adjacent cell, then append attack step local path = {} local ck = cur.k while ck ~= start_k do local step = came_from[ck] if not step then return nil end table.insert(path, 1, { x = step.x, y = step.y, passwall = step.passwall }) ck = step.from end -- Append the final bump-attack onto the target path[#path + 1] = { x = tx, y = ty } return path end closed[cur.k] = true -- Normal neighbors (8 adjacent cells) -- Limit search to cells within reasonable range local max_range = cheb(tx, ty) + 8 for dx = -1, 1 do for dy = -1, 1 do if dx ~= 0 or dy ~= 0 then local nx, ny = cur.x + dx, cur.y + dy local nk = pk(nx, ny) -- Don't path through the target cell or go too far if nk ~= target_k and math.abs(nx) <= max_range and math.abs(ny) <= max_range then local c = map[nk] if c and c.traversable and not c.unsafe and not c.door and not (c.feature and c.feature:find("^trap_")) and not (c.monster and c.monster:is_stationary()) then local cost = 1 if cell_see(nx, ny, tx, ty) then cost = cost + 10 end if c.fd then cost = cost + (los_range + 1 - c.fd) * 2 end try_neighbor(cur, nx, ny, cost, false) -- Passwall through rock walls elseif c and pw_range > 0 and c.solid and c.feature and c.feature:find("rock") and not c.undiggable then for dist = 2, pw_range + 1 do local px, py = cur.x + dx * dist, cur.y + dy * dist local ppk = pk(px, py) -- Can't passwall onto the target cell if ppk == target_k then break end local pc = map[ppk] if not pc then break end if pc.traversable and not pc.door and not (pc.monster and pc.monster:is_stationary()) then local cost = dist * 2 if cell_see(px, py, tx, ty) then cost = cost + 10 end if pc.fd then cost = cost + (los_range + 1 - pc.fd) * 2 end try_neighbor(cur, px, py, cost, true) break end if not (pc.solid and pc.feature and pc.feature:find("rock") and not pc.undiggable) then break end end end end end end end end return nil -- no path found end -- Convert sneak_path result to commands. Returns a list of command strings -- for do_commands, or nil if path contains passwall steps (uses sendkeys instead). -- For passwall paths, returns sendkeys string. function sneak_path_keys(path) if not path or #path == 0 then return nil end -- Check if any step is passwall local has_passwall = false for _, step in ipairs(path) do if step.passwall then has_passwall = true; break end end if not has_passwall then -- Pure move commands local cmds = {} local prev_x, prev_y = 0, 0 for _, step in ipairs(path) do local dx, dy = step.x - prev_x, step.y - prev_y local dirs = { [-1] = { [-1] = "UP_LEFT", [0] = "LEFT", [1] = "DOWN_LEFT" }, [0] = { [-1] = "UP", [1] = "DOWN" }, [1] = { [-1] = "UP_RIGHT", [0] = "RIGHT", [1] = "DOWN_RIGHT" } } local d = dirs[dx] and dirs[dx][dy] if not d then return nil end -- First move unsafe, rest safe local prefix = #cmds == 0 and "CMD_MOVE_" or "CMD_SAFE_MOVE_" cmds[#cmds + 1] = prefix .. d prev_x, prev_y = step.x, step.y end return { type = "commands", cmds = cmds } else -- Mixed path with passwall: build sendkeys string local keys = "" local prev_x, prev_y = 0, 0 local pw_letter = spells.letter("Passwall") for _, step in ipairs(path) do local dx, dy = step.x - prev_x, step.y - prev_y if step.passwall then -- Normalize direction for passwall targeting local ndx = dx > 0 and 1 or (dx < 0 and -1 or 0) local ndy = dy > 0 and 1 or (dy < 0 and -1 or 0) local dir_keys = walk_keys_to(ndx, ndy) keys = keys .. "z" .. pw_letter .. "r" .. dir_keys .. "f" else keys = keys .. walk_keys_to(dx, dy) end prev_x, prev_y = step.x, step.y end return { type = "keys", keys = keys } end end -- Display a sneak path as a travel trail overlay function draw_sneak_trail(path) if not path then return end travel.clear_travel_trail() for i = #path, 1, -1 do pcall(travel.set_travel_trail, path[i].x, path[i].y) end pcall(travel.set_travel_trail, path[1].x, path[1].y) crawl.redraw_screen() end -- Execute one step of a sneak path action function sneak_step(path) if not path or #path == 0 then return end local step = path[1] local dx, dy = step.x, step.y -- relative to player (0,0) if step.passwall then local ndx = dx > 0 and 1 or (dx < 0 and -1 or 0) local ndy = dy > 0 and 1 or (dy < 0 and -1 or 0) local pw_letter = spells.letter("Passwall") local dir_keys = walk_keys_to(ndx, ndy) crawl.sendkeys("z" .. pw_letter .. "r" .. dir_keys .. "f") else crawl.do_commands({ "CMD_MOVE_" .. dir_key(dx, dy) }) end end -- Convert dx,dy to direction name function dir_key(dx, dy) local dirs = { [-1] = { [-1] = "UP_LEFT", [0] = "LEFT", [1] = "DOWN_LEFT" }, [0] = { [-1] = "UP", [1] = "DOWN" }, [1] = { [-1] = "UP_RIGHT", [0] = "RIGHT", [1] = "DOWN_RIGHT" } } return dirs[dx] and dirs[dx][dy] end -- Find the best sneak target. Returns map, target cell, path, or nil. function find_sneak_target() local map = get_map() local targets = {} local visible_count = 0 for _, cell in ipairs(get_monsters()) do if is_hostile(cell.monster) then targets[#targets + 1] = cell if cell.visible then visible_count = visible_count + 1 end end end if #targets == 0 then crawl.mpr("No known monsters to sneak attack.") return nil end -- Priority: adjacent > visible stabbable > closest remembered stabbable local target = nil local best_visible = nil local best_remembered = nil local best_remembered_dist = math.huge for _, cell in ipairs(targets) do local stab = cell.monster:stabbability() local is_stabbable = stab > 0 and (cell.monster:is_stationary() or stab >= 1.0) if is_stabbable then local adj = math.abs(cell.x) <= 1 and math.abs(cell.y) <= 1 if adj then target = cell break elseif cell.visible then if not best_visible then best_visible = cell end else local dist = cheb(cell.x, cell.y) if dist < best_remembered_dist then best_remembered = cell best_remembered_dist = dist end end end end -- Prefer visible over remembered if not target then target = best_visible or best_remembered end if not target then crawl.mpr("No stabbable target found.") return nil end local tx, ty = target.x, target.y local adjacent = math.abs(tx) <= 1 and math.abs(ty) <= 1 if not adjacent and visible_count > 1 then crawl.mpr("Multiple monsters visible; sneak attack requires at most one.") return nil end local path = adjacent and {{ x = tx, y = ty }} or sneak_path(map, tx, ty) if not path then crawl.mpr("No sneak path to " .. target.monster:name() .. ".") return nil end return map, target, path end -- reload() only refreshes options (show_travel_trail, macros, etc). -- Lua function definitions require a game restart to update. function reload() crawl.read_options("C:/dev/simple/assist.rc") crawl.mpr("options reloaded (Lua code needs game restart)") end function show_sneak_path() local map, target, path = find_sneak_target() if not path then return end crawl.mpr("Sneak path to " .. target.monster:name() .. " (" .. #path .. " steps)") draw_sneak_trail(path) end -- Sneak attack algorithm: -- 1. Must have a short blade (any) to attempt a sneak attack -- 2. Find best stab weapon in inventory (dagger > quick blade > others) -- 3. If not wielding the best stab weapon: -- a. Find a retreat cell (adjacent, out of target LOS, won't reveal unseen) -- b. Step to retreat cell and wield best stab weapon -- c. If no retreat cell, wield in place (risks alerting target) -- 4. If already wielding best stab weapon: take one step along sneak path -- Stab weapon priority: lower = better (dagger has highest stab damage) local stab_priority = { ["dagger"] = 1, ["quick blade"] = 2, ["short sword"] = 3, ["rapier"] = 4 } function find_best_stab_weapon() local best_slot, best_pri = nil, math.huge for _, it in ipairs(items.inventory()) do if it.class(true) == "weapon" then local subtype = it.subtype and it.subtype():lower() or "" local pri = stab_priority[subtype] if pri and pri < best_pri then best_slot = it.slot best_pri = pri end end end return best_slot, best_pri end function sneak_attack() local map, target, path = find_sneak_target() if not path then return end local tx, ty = target.x, target.y local mon = target.monster -- Check if player has any short blade local best_slot, best_pri = find_best_stab_weapon() if not best_slot then crawl.mpr("No short blade in inventory for sneak attack.") return end -- Check if already wielding the best stab weapon local weapon = items.equipped_at("weapon") local need_swap = true if weapon then local subtype = weapon.subtype and weapon.subtype():lower() or "" local cur_pri = stab_priority[subtype] if cur_pri and cur_pri <= best_pri then need_swap = false end end crawl.mpr("Sneak attacking " .. mon:name() .. ".") draw_sneak_trail(path) if need_swap then -- Find a retreat cell: out of target's LOS, not revealing unseen local cell_see = view.cell_see_cell local known = {} local unseen_near = {} for key, cell in pairs(map) do known[key] = true if cell.frontier then for fdx = -1, 1 do for fdy = -1, 1 do if fdx ~= 0 or fdy ~= 0 then local uk = pk(cell.x + fdx, cell.y + fdy) if not known[uk] then unseen_near[uk] = { x = cell.x + fdx, y = cell.y + fdy } end end end end end end local function sees_any_unseen(cx, cy) for _, u in pairs(unseen_near) do if math.abs(u.x - cx) <= 7 and math.abs(u.y - cy) <= 7 and cell_see(cx, cy, u.x, u.y) then return true end end return false end local retreat = nil for dx = -1, 1 do for dy = -1, 1 do if dx ~= 0 or dy ~= 0 then local nk = pk(dx, dy) local c = map[nk] if c and c.traversable and not c.unsafe and not (c.feature and c.feature:find("^trap_")) and not cell_see(dx, dy, tx, ty) and not sees_any_unseen(dx, dy) then retreat = { x = dx, y = dy } break end end end if retreat then break end end local slot_letter = string.char(string.byte("a") + best_slot) if retreat then crawl.sendkeys(walk_keys_to(retreat.x, retreat.y) .. "w" .. slot_letter) else -- No safe retreat; wield in place crawl.sendkeys("w" .. slot_letter) end else sneak_step(path) end end -- Cycle order for downstairs: i -> ii -> iii -> i c_persist.last_downstair_suffix = c_persist.last_downstair_suffix or nil function travel_to_downstairs() local map = get_map() -- Collect traversable cells and stone stairs down (exclude escape hatches) local trav = {} local stairs_by_suffix = {} -- "i"/"ii"/"iii" -> cell for key, cell in pairs(map) do if cell.traversable and not cell.unsafe and not cell.excluded then trav[key] = true end if cell.feature then local suffix = cell.feature:match("^stone_stairs_down_(.+)$") if suffix then stairs_by_suffix[suffix] = cell end end end -- Build ordered list of available stairs local cycle = { "i", "ii", "iii" } local available = {} for _, s in ipairs(cycle) do if stairs_by_suffix[s] then available[#available + 1] = s end end if #available == 0 then crawl.mpr("No downstairs found on this level.") return end -- Pick the next suffix in the cycle after c_persist.last_downstair_suffix local pick = available[1] if c_persist.last_downstair_suffix then for i, s in ipairs(available) do if s == c_persist.last_downstair_suffix then pick = available[i % #available + 1] break end end end c_persist.last_downstair_suffix = pick local target = stairs_by_suffix[pick] -- BFS from player to find path to target stairs local start_pk = pk(0, 0) local target_pk = pk(target.x, target.y) -- Already standing on the target stairs — cycle to the next one if start_pk == target_pk then if #available <= 1 then crawl.mpr("Already on the only stairs down (" .. pick .. ").") return end -- Find current pick in available and advance for i, s in ipairs(available) do if s == pick then pick = available[i % #available + 1] break end end c_persist.last_downstair_suffix = pick target = stairs_by_suffix[pick] target_pk = pk(target.x, target.y) end local dist = {} local par = {} dist[start_pk] = 0 local s = { x = 0, y = 0, k = start_pk } local queue = { s } local qi = 1 local goal = nil local stairs_set = { [target_pk] = true } while qi <= #queue do local c = queue[qi] qi = qi + 1 local cd = dist[c.k] for dx = -1, 1 do for dy = -1, 1 do if dx ~= 0 or dy ~= 0 then local nx, ny = c.x + dx, c.y + dy local nk = pk(nx, ny) if trav[nk] and dist[nk] == nil then dist[nk] = cd + 1 local n = { x = nx, y = ny, k = nk } par[nk] = c if stairs_set[nk] then goal = n break end queue[#queue + 1] = n end end end if goal then break end end if goal then break end end if not goal then crawl.mpr("No reachable downstairs found.") return end map_travel_to(goal.x, goal.y) end level = level or {} -- Get map using view.get_map() which is much faster than iterating -- over coordinates. Returns table keyed by packed coords with rich cell info: -- x, y, feature, visible, mapped, frontier, traversable, solid, door, -- undiggable, excluded, unvisited, item, monster, invisible_monster, -- cloud, unsafe, detected_item, detected_monster function level.get_map() return get_map() end function count_stairs(map) -- Use view.get_map() for efficient iteration over known cells map = map or get_map() local upstairs = 0 local downstairs = 0 for _, cell in pairs(map) do local feat = cell.feature if feat then if feat:find("stone_stairs_up") or feat == "exit_dungeon" then upstairs = upstairs + 1 elseif feat:find("stone_stairs_down") then downstairs = downstairs + 1 end end end return upstairs, downstairs end -- Check if current branch is a single-level "vault" (portals, etc.) function is_single_level_branch() return you.depth() == 1 and you.depth_fraction() == 1 end -- Check if at max depth of current branch function is_at_max_depth() return you.depth_fraction() == 1 end function check_stairs(map) local upstairs, downstairs = count_stairs(map) local depth = you.depth() local upstairs_ok = (depth == 1) or (upstairs >= 3) local downstairs_ok = is_at_max_depth() or (downstairs >= 3) if upstairs_ok and downstairs_ok then crawl.mpr("Has all expected stairs.") else if not upstairs_ok then crawl.mpr("WARNING: Only " .. upstairs .. " upstairs found!", "warning") end if not downstairs_ok then crawl.mpr("WARNING: Only " .. downstairs .. " downstairs found!", "warning") end end end function c_message(msg, channel) -- DEBUG: Log all messages to see what we receive -- crawl.stderr("c_message: [" .. msg .. "] channel=" .. tostring(channel)) if msg:lower():find("^found") then -- intercepted a "found" message end -- Mark level as explored when autoexplore finishes -- Use case-insensitive match (game may add title casing after hook) -- Use substring match, not anchored, in case of formatting if msg:lower():find("done exploring") then -- Fetch map once for all checks local map = get_map() mark_level_explored() check_stairs(map) check_remembered_monsters(map) end end -- Predict where a monster will be next turn given player moves to (px, py). -- All coords are player-relative (0,0 = current player position). -- cell: a cell from view.get_map() with cell.x, cell.y, cell.monster -- px, py: where the player will be next turn (player-relative) -- map: optional, from view.get_map(), used to check for blocking monsters -- Returns nx, ny, certainty ("certain", "likely", or "unknown") function monster_move(cell, px, py, map) local m = cell.monster local mx, my = cell.x, cell.y -- Stationary monsters never move if m:is_stationary() then return mx, my, "certain" end -- Incapacitated: can't move (or confused = unpredictable) if is_incapacitated(m) then return mx, my, m:is("confused") and "unknown" or "certain" end -- Non-hostile (or firewood like plants): can't predict behavior if not is_hostile(m) then return mx, my, "unknown" end -- Adjacent to player: monster will attack, not move if cheb(mx, my, px, py) <= 1 then return mx, my, "certain" end -- Wandering hostile: moving randomly, unpredictable if m:is("wandering") then return mx, my, "unknown" end -- Helper: check if monster can move to (x, y) local function passable(x, y) -- Can't move onto player's new position if x == px and y == py then return false end -- Check terrain if not m:can_traverse(x, y) then return false end -- Check for other monsters blocking if map then local c = map[pk(x, y)] if c and c.monster and not (c.x == mx and c.y == my) then return false end end return true end -- Determine target: fleeing moves away, seeking moves toward player local dx, dy if m:is("fleeing") then dx = mx - px dy = my - py else -- Default hostile behavior: seek player dx = px - mx dy = py - my end -- Already at target (shouldn't happen for seeking, but handle it) if dx == 0 and dy == 0 then return mx, my, "certain" end -- Step direction: sgn of each axis local sx = dx > 0 and 1 or (dx < 0 and -1 or 0) local sy = dy > 0 and 1 or (dy < 0 and -1 or 0) -- Try direct diagonal/cardinal step if passable(mx + sx, my + sy) then return mx + sx, my + sy, "likely" end -- If diagonal was blocked, try each cardinal component if sx ~= 0 and sy ~= 0 then if passable(mx + sx, my) then return mx + sx, my, "likely" end if passable(mx, my + sy) then return mx, my + sy, "likely" end end -- Try perpendicular moves (alternate step finding) if sx == 0 then -- Moving purely vertical, try diagonal slides if passable(mx + 1, my + sy) then return mx + 1, my + sy, "likely" end if passable(mx - 1, my + sy) then return mx - 1, my + sy, "likely" end elseif sy == 0 then -- Moving purely horizontal, try diagonal slides if passable(mx + sx, my + 1) then return mx + sx, my + 1, "likely" end if passable(mx + sx, my - 1) then return mx + sx, my - 1, "likely" end end -- Fully blocked: stays in place return mx, my, "likely" end -- Produce a predicted next-turn map given player moves to (px, py). -- Returns a shallow copy of map with monsters moved (fastest first, -- matching DCSS turn order). Coordinates stay in current player-relative -- frame: player will be at (px, py), old player cell (0,0) is vacated. -- Monsters are moved sequentially: each sees the results of prior moves, -- so a monster won't step into an already-occupied cell, and a vacated -- cell becomes available for the next monster. function next_map(map, px, py) -- Shallow copy only the LOS area (7 cells around player) for speed local los = 7 local nmap = {} for dy = -los, los do for dx = -los, los do local key = pk(dx, dy) local cell = map[key] if cell then nmap[key] = { x = cell.x, y = cell.y, feature = cell.feature, solid = cell.solid, traversable = cell.traversable, door = cell.door, monster = cell.monster, visible = cell.visible, frontier = cell.frontier, excluded = cell.excluded, unsafe = cell.unsafe, cloud = cell.cloud } end end end -- Collect hostile monsters with speed for ordering local speed_rank = { ["extremely fast"] = 6, ["very fast"] = 5, ["fast"] = 4, ["normal"] = 3, ["slow"] = 2, ["very slow"] = 1 } local monsters = {} for key, cell in pairs(nmap) do if cell.monster and is_hostile(cell.monster) then local desc = cell.monster:speed_description() or "normal" monsters[#monsters + 1] = { key = key, x = cell.x, y = cell.y, monster = cell.monster, speed = speed_rank[desc] or 3 } end end -- Sort by speed descending (fastest first, matching DCSS turn order) table.sort(monsters, function(a, b) return a.speed > b.speed end) -- Move each monster in speed order for _, mon in ipairs(monsters) do local mon_cell = { x = mon.x, y = mon.y, monster = mon.monster } local nx, ny = monster_move(mon_cell, px, py, nmap) if nx ~= mon.x or ny ~= mon.y then local dest_key = pk(nx, ny) local dest = nmap[dest_key] if dest and not dest.monster and not (nx == px and ny == py) then nmap[mon.key].monster = nil dest.monster = mon.monster end end end return nmap end -- BFS within LOS to find the nearest cell the player can reach but no -- visible monster can reach. Unseen cells are treated as open. -- Returns { x, y, path } or nil. path is a list from player toward target. function find_lake() local map = get_map() local los = 7 local function in_los(x, y) return math.abs(x) <= los and math.abs(y) <= los end local function player_passable(x, y) local cell = map[pk(x, y)] if not cell then return true end return cell.traversable and not cell.unsafe and not cell.excluded and not cell.door and not (cell.monster and cell.monster:is_stationary()) end -- BFS from player with parent tracking local player_reach = {} local player_order = {} local parent = {} local origin = pk(0, 0) local pq = { { x = 0, y = 0 } } player_reach[origin] = true local qi = 1 while qi <= #pq do local c = pq[qi]; qi = qi + 1 player_order[#player_order + 1] = c for dx = -1, 1 do for dy = -1, 1 do if dx ~= 0 or dy ~= 0 then local nx, ny = c.x + dx, c.y + dy local nk = pk(nx, ny) if in_los(nx, ny) and not player_reach[nk] and player_passable(nx, ny) then player_reach[nk] = true parent[nk] = c pq[#pq + 1] = { x = nx, y = ny } end end end end end -- Collect visible hostile monsters local monsters = {} for _, cell in ipairs(get_monsters()) do if cell.visible and is_hostile(cell.monster) then monsters[#monsters + 1] = cell end end if #monsters == 0 then return nil end -- BFS from each monster: union of monster-reachable cells local monster_reach = {} for _, mcell in ipairs(monsters) do local m = mcell.monster local mx, my = mcell.x, mcell.y local mq = { { x = mx, y = my } } local visited = { [pk(mx, my)] = true } local mi = 1 while mi <= #mq do local c = mq[mi]; mi = mi + 1 monster_reach[pk(c.x, c.y)] = true for dx = -1, 1 do for dy = -1, 1 do if dx ~= 0 or dy ~= 0 then local nx, ny = c.x + dx, c.y + dy local nk = pk(nx, ny) if in_los(nx, ny) and not visited[nk] then local cell = map[nk] local passable if not cell then passable = true else passable = m:can_traverse(nx, ny) end if passable then visited[nk] = true mq[#mq + 1] = { x = nx, y = ny } end end end end end end end -- Find closest player-reachable cell no monster can reach for _, c in ipairs(player_order) do if (c.x ~= 0 or c.y ~= 0) and not monster_reach[pk(c.x, c.y)] then -- Reconstruct path from player to target local path = {} local cur = c while cur do path[#path + 1] = { x = cur.x, y = cur.y } cur = parent[pk(cur.x, cur.y)] end -- Reverse so path goes from player toward target local rev = {} for i = #path, 1, -1 do rev[#rev + 1] = path[i] end return { x = c.x, y = c.y, path = rev } end end return nil end -- If visible melee-only monsters exist, show trail to nearest "lake" cell -- (a cell reachable by player but not by any visible monster). function suggest_lake() -- Check for visible hostile monsters; also check if any have ranged attacks local has_hostile = false local has_ranged = false for _, cell in ipairs(get_monsters()) do local m = cell.monster if cell.visible and is_hostile(m) then has_hostile = true local range = m:range() or 0 if range > 1 then has_ranged = true break end end end if not has_hostile or has_ranged then return end local lake = find_lake() if not lake then return end draw_sneak_trail(lake.path) crawl.mpr("Move to lake?") end -- Check if a monster is hostile -- Hostile = attitude 0 and not flagged as safe or firewood function is_hostile(m) if not m then return false end -- flags() returns a table of flag name strings, not a single string local flags = m:flags() or {} for _, flag in ipairs(flags) do if flag == "safe" or flag == "firewood" then return false end end -- attitude 0 = hostile return m:attitude() == 0 end -- Check if a monster can't act (sleeping, paralysed, confused, etc.) function is_incapacitated(m) return m:is("sleeping") or m:is("dormant") or m:is("unaware") or m:is("paralysed") or m:is("petrified") or m:is("confused") or m:is("caught") or m:is("webbed") end -- Return list of hostile monsters that can attack the player this turn. -- A monster can attack if it's hostile, not incapacitated, and either: -- melee: adjacent (Chebyshev dist <= 1) or within reach range -- ranged: within range and has LOS to player function attackers() local cell_see = view.cell_see_cell local result = {} for _, cell in ipairs(get_monsters()) do local m = cell.monster if is_hostile(m) and not is_incapacitated(m) then local dist = cheb(cell.x, cell.y) local can_attack = false -- Melee: adjacent or reach weapon local reach = m:reach_range() or 1 if dist <= reach then can_attack = true end -- Ranged: within range and has LOS if not can_attack then local range = m:range() or 0 if range > 1 and dist <= range and cell_see(cell.x, cell.y, 0, 0) then can_attack = true end end if can_attack then result[#result + 1] = cell end end end return result end -- Find an adjacent traversable cell to the given coordinates -- Used for traveling near stationary monsters function find_adjacent_traversable(x, y, map) map = map or get_map() -- Check 8 adjacent cells local deltas = { {-1, -1}, {0, -1}, {1, -1}, {-1, 0}, {1, 0}, {-1, 1}, {0, 1}, {1, 1} } local best = nil local best_dist = 999 for _, d in ipairs(deltas) do local ax, ay = x + d[1], y + d[2] -- Look for this cell in the map for _, cell in pairs(map) do if cell.x == ax and cell.y == ay then -- Traversable if no monster and not a wall/impassable feature local dominated_by_wall = cell.feature and ( cell.feature:find("wall") or cell.feature:find("stone") or cell.feature:find("tree") or cell.feature:find("statue") or cell.feature == "lava" or cell.feature == "deep_water") if not cell.monster and not dominated_by_wall then local dist = math.abs(ax) + math.abs(ay) if dist < best_dist then best = {x = ax, y = ay} best_dist = dist end end break end end end if best then return best.x, best.y end return nil, nil -- no traversable adjacent cell found end -- Find remembered (not currently visible) hostile monsters on the map function find_remembered_monsters() local monsters = {} for _, cell in ipairs(get_monsters()) do if not cell.visible and is_hostile(cell.monster) then local mon_name = cell.monster:name() or "monster" table.insert(monsters, { x = cell.x, y = cell.y, name = mon_name, monster = cell.monster, distance = math.abs(cell.x) + math.abs(cell.y) }) end end -- Sort by distance (closest first) table.sort(monsters, function(a, b) return a.distance < b.distance end) return monsters end -- Check for remembered monsters and offer to travel to closest one function check_remembered_monsters(map) local monsters = find_remembered_monsters() if #monsters == 0 then return end local closest = monsters[1] local msg = "Found " .. #monsters .. " remembered monster(s). " msg = msg .. "Travel to " .. closest.name .. " (" .. closest.x .. ',' .. closest.y .. ")?" if crawl.yesno(msg, true, "n") then local dest_x, dest_y = closest.x, closest.y -- If monster is stationary, travel to adjacent cell instead if closest.monster:is_stationary() then local adj_x, adj_y = find_adjacent_traversable(closest.x, closest.y, map) if adj_x and adj_y then dest_x, dest_y = adj_x, adj_y end end map_travel_to(dest_x, dest_y) end end function get_level_name() return you.branch() .. ":" .. you.depth() end function mark_level_explored() c_persist.explored = c_persist.explored or {} local level = get_level_name() if not c_persist.explored[level] then c_persist.explored[level] = true crawl.mpr("Marked " .. level .. " as explored.") end end function too_deep() local depth = you.depth() if depth <= 1 then return false end local branch = you.branch() local first_level = branch .. ":1" -- Initialize explored table if needed c_persist.explored = c_persist.explored or {} -- If we have no record of the first level of this branch, assume -- all levels above current depth have been explored (mid-game adoption) if not c_persist.explored[first_level] then for i = 1, depth - 1 do c_persist.explored[branch .. ":" .. i] = true end if depth > 1 then crawl.mpr("Initialized exploration tracking; assuming " .. branch .. ":1-" .. (depth - 1) .. " explored.") end end -- Check if all levels above current depth are explored for i = 1, depth - 1 do local level = branch .. ":" .. i if not c_persist.explored[level] then return level -- Return the unexplored level name end end return false end -- Track which level we last warned about (to avoid spamming) too_deep_warned_level = nil function warn_if_too_deep() local unexplored = too_deep() local current = get_level_name() -- Only warn once per level if unexplored and too_deep_warned_level ~= current then too_deep_warned_level = current crawl.mpr("WARNING: Too deep! " .. unexplored .. " is not fully explored.", "warning") end end function autodrop() -- don't drop in dangerous terrain local feat = view.feature_at(0, 0) if feat and (feat:lower():find("lava") or feat:lower():find("deep_water")) then return end -- don't drop when transformed (items may be temporarily useless) if you.transform() ~= "" then return end -- don't drop in portal vault levels (single-level branches) if is_single_level_branch() then return end for _, it in ipairs(items.inventory()) do if not it.equipped and should_drop(it) then crawl.mpr("Dropping " .. it.name() .. " (unwanted)") it.drop() return end end end function should_drop(it) -- is_useless checks for PERMANENT uselessness only (temp=false by default). -- This correctly ignores temporary conditions like: -- - Transformations (can't wear armor while in bat form) -- - Full mana (potion of magic not useless, just not needed right now) -- - Temporary inability to read/drink (berserk, etc.) -- It also returns false for unidentified items. if it.is_useless then return true end -- Also drop specific dangerous items that aren't technically "useless" -- but are generally harmful/risky to use. -- Note: These must be identified to check by name. local name = it.name():lower() local class = it.class(true) if class == "potion" then -- attraction pulls enemies toward you - dangerous if name:find("attraction") then return true end end if class == "scroll" then -- noise alerts enemies - dangerous if name:find("noise") then return true end end -- Drop unenchanted armor if carrying (not wearing) and slot is filled -- with something as good or better. "Unenchanted" = no ego and not artefact. if class == "armour" and not it.equipped then if it.artefact or it.ego() then return false end local subtype = it.subtype and it.subtype() or nil if not subtype then return false end -- Map subtype() names to equipped_at() slot names local slot = subtype if subtype == "body" then slot = "armour" elseif subtype == "offhand" then slot = "shield" end if you.race() == "Poltergeist" and slot ~= "armour" and slot ~= "shield" then -- Poltergeist shares aux slots: only drop if EVERY equipped aux -- piece is as good or better (i.e., no slot would want this item) local dominated = true local equipped = get_equipped_aux_armor() if #equipped == 0 then dominated = false else for _, eq in ipairs(equipped) do if is_better_armor(it, eq) then dominated = false break end end end -- Also check if there's still an open aux slot if items.slot_is_available and items.slot_is_available(slot) then dominated = false end if dominated then return true end else -- Normal species: check if the specific slot is filled with -- something as good or better local current = items.equipped_at(slot) if current and not is_better_armor(it, current) then return true end end end -- Drop weapons dominated by a strictly better weapon of the same type. -- "Better" = higher enchantment, or has ego/artefact when this one doesn't. if class == "weapon" and not it.equipped then local subtype = it.subtype and it.subtype() or nil if subtype then local my_plus = it.plus or 0 local my_ego = it.ego() or "" for _, other in ipairs(items.inventory()) do if other ~= it and other.class(true) == "weapon" and other.subtype and other.subtype() == subtype then local dominated = false if other.artefact then dominated = true elseif (other.plus or 0) > my_plus then dominated = true elseif (other.plus or 0) == my_plus and (other.ego() or "") ~= "" and my_ego == "" then dominated = true end if dominated then return true end end end end end return false end -- list of wanted spells wanted_spells = {} wanted_spells["Sandblast"] = true wanted_spells["Stone Arrow"] = true wanted_spells["Mephitic Cloud"] = true wanted_spells["Lee's Rapid Deconstruction"] = true wanted_spells["Iron Shot"] = true wanted_spells["Iskenderun's Mystic Blast"] = true wanted_spells["Lehudib's Crystal Spear"] = true wanted_spells["Shatter"] = true wanted_spells["Foxfire"] = true wanted_spells["Scorch"] = true wanted_spells["Shock"] = true wanted_spells["Flame Wave"] = true wanted_spells["Passwall"] = true wanted_spells["Blink"] = true wanted_spells["Fireball"] = true wanted_spells["Ignition"] = true wanted_spells["Fire Storm"] = true -- Transmutation forms require the ability to mutate; skip for undead races local no_mutate = { Mummy = true, Ghoul = true, Poltergeist = true } if not no_mutate[you.race()] then wanted_spells["Statue Form"] = true wanted_spells["Necromutation"] = true end -- Spells that become unwanted once an upgrade is memorised. -- Key = old spell, value = upgrade spell. spell_upgrades = {} spell_upgrades["Sandblast"] = "Stone Arrow" -- Track whether we've already asked about forgetting each spell asked_forget = {} function is_wanted_spell(spell) if not wanted_spells[spell] then return false end -- Unwanted if the upgrade has been memorised local upgrade = spell_upgrades[spell] if upgrade and spells.memorised(upgrade) then return false end return true end -- Bolt spells that travel through multiple cells and can hit multiple monsters. -- Maps spell name to the monster resistance method to check (false = no resistance). bolt_spells = { ["Shock"] = "res_shock", ["Lightning Bolt"] = "res_shock", ["Thunderbolt"] = "res_shock", ["Corrosive Bolt"] = "res_corr", ["Searing Ray"] = false, ["Quicksilver Bolt"] = false, ["Bolt of Light"] = false } -- Given a bolt spell and visible monsters, find the target cell whose bolt path -- hits the most (weighted) hostile monsters. Returns x,y of best target or nil. function best_bolt_target(spell_name, monsters_by_key) local range = spells.range(spell_name) local res_method = bolt_spells[spell_name] -- false means no resistance local best_x, best_y, best_score = nil, nil, 0 -- Test all cells within range for tx = -range, range do for ty = -range, range do if (tx ~= 0 or ty ~= 0) and cheb(tx, ty) <= range then local path = spells.path(spell_name, tx, ty) if path and #path > 0 then local score = 0 for i = 1, #path do local px, py = path[i][1], path[i][2] local key = 40000 * (100 + px) + (100 + py) local mon = monsters_by_key[key] if mon then local weight = 1 if res_method then local res = mon[res_method](mon) if res > 0 then weight = 1 / (2 ^ res) elseif res < 0 then weight = 1.5 end end score = score + weight end end if score > best_score then best_score = score best_x, best_y = tx, ty end end end end end if best_score > 0 then return best_x, best_y end end -- Blast spell properties: radius, smite (true = ignores path, targets any visible cell) blast_spells = { ["Fireball"] = { radius = 1, smite = false }, ["Iceblast"] = { radius = 1, smite = false }, ["Fire Storm"] = { radius = 2, smite = true } } -- For a blast spell, find the target cell that hits the most hostile monsters -- while avoiding the player and allies. Returns x, y of best target or nil. function zap_blast(spell) local props = blast_spells[spell] if not props then return nil end local range = spells.range(spell) local radius = props.radius local cell_see = view.cell_see_cell local map = get_map() -- Build per-cell scores: hostile +1, friendly -1 (weighted below) -- Use is_hostile() to exclude plants/firewood/safe monsters local hostile_at = {} -- pk -> true local friendly_at = {} -- pk -> true local has_hostile = false for _, cell in ipairs(get_monsters()) do local key = pk(cell.x, cell.y) if is_hostile(cell.monster) then hostile_at[key] = true has_hostile = true elseif cell.monster:attitude() == 4 and cell.monster:name() ~= "foxfire" then friendly_at[key] = true end end if not has_hostile then return nil end local is_smite = props.smite local best_x, best_y, best_score = nil, nil, 0 -- Iterate outward from player: closer targets preferred as tiebreaker for d = 1, range do for tx = -d, d do for ty = -d, d do if cheb(tx, ty) == d and cell_see(0, 0, tx, ty) then -- For non-smite spells, check projectile path. -- If blocked before (tx,ty), skip: that closer cell -- was already evaluated in an earlier ring. local reachable = true if not is_smite then local path = spells.path(spell, tx, ty) if not path or #path == 0 or path[#path][1] ~= tx or path[#path][2] ~= ty then reachable = false end end if reachable then -- Score the blast at (tx, ty) local score = 0 local hits_player = false for bx = -radius, radius do for by = -radius, radius do local hx, hy = tx + bx, ty + by if hx == 0 and hy == 0 then hits_player = true break end local hk = pk(hx, hy) if hostile_at[hk] then score = score + 1 elseif friendly_at[hk] then score = score - 100 end if spell == "Fireball" then local cell = map[hk] if cell and cell.feature then if cell.feature:find("tree") then score = score + 0.001 end if cell.feature:find("water") and cheb(hx, hy) <= cheb(tx, ty) then score = score + 0.002 end end end end if hits_player then break end end if not hits_player then if spell == "Fire Storm" then score = score + (7 - math.sqrt(tx*tx + ty*ty)) * 0.01 end if score > best_score then best_score = score best_x, best_y = tx, ty end end end -- reachable end -- if cheb==d end -- for ty end -- for tx end -- for tx if best_score > 0 then crawl.mpr("zap_blast: " .. spell .. " -> (" .. best_x .. "," .. best_y .. ") score=" .. best_score) return best_x, best_y end crawl.mpr("zap_blast: " .. spell .. " -> nil (range=" .. range .. ", hostiles=" .. tostring(has_hostile) .. ")") end -- Check if player can cast a spell right now function can_zap(spell) if not spells.memorised(spell) then return false end if you.silenced() or you.confused() or you.berserk() then return false end local cur_mp = you.mp() if cur_mp < spells.mana_cost(spell) then return false end return true end -- Check if a foxfire can reach target (tx, ty) starting from (sx, sy). -- Foxfires fly (traverse water/lava) but not solid cells or the player. -- px, py = player position (to avoid). All coords player-relative. -- Uses bounded BFS (not restricted to decreasing distance) since -- DCSS foxfires use real pathfinding and can detour around obstacles. function foxfire_can_reach(sx, sy, tx, ty, px, py, map) local dist = cheb(sx, sy, tx, ty) -- Adjacent and not blocked by player: can reach if dist <= 1 then return not (tx == px and ty == py) end -- Bounded BFS: limit search to a reasonable area around source and target local max_dist = dist + 4 -- allow some detour local visited = { [pk(sx, sy)] = true } local queue = {{ x = sx, y = sy }} local qi = 1 while qi <= #queue do local c = queue[qi]; qi = qi + 1 for ddx = -1, 1 do for ddy = -1, 1 do if ddx ~= 0 or ddy ~= 0 then local nx, ny = c.x + ddx, c.y + ddy local nk = pk(nx, ny) if not visited[nk] and not (nx == px and ny == py) and cheb(nx, ny, tx, ty) <= max_dist then local cell = map[nk] if cell and not cell.solid then -- Non-hostile non-foxfire monsters block local blocked = false if cell.monster then local att = cell.monster:attitude() if att ~= 0 and cell.monster:name() ~= "foxfire" then blocked = true end end if not blocked then if cheb(nx, ny, tx, ty) <= 1 then return true end visited[nk] = true queue[#queue + 1] = { x = nx, y = ny } end end end end end end end return false end -- Count how many adjacent cells to (cx, cy) are good foxfire spawn cells: -- not solid, no monster, and foxfire can reach at least one hostile target. -- Foxfires don't need LOS to their target — they pathfind around obstacles. -- Player is at (px, py). Monsters in targets list. -- spawn_map: used for cell availability (is cell empty NOW for spawning). -- path_map: used for foxfire reachability (can foxfire fly there on future -- turns, after monsters have moved). Defaults to spawn_map if omitted. function count_foxfire_cells(cx, cy, px, py, targets, spawn_map, path_map) path_map = path_map or spawn_map local count = 0 for ddx = -1, 1 do for ddy = -1, 1 do if ddx ~= 0 or ddy ~= 0 then local ax, ay = cx + ddx, cy + ddy -- Can't be the player's cell if not (ax == px and ay == py) then local ak = pk(ax, ay) local ac = spawn_map[ak] if ac and not ac.solid and not ac.monster then -- Check if foxfire can reach any target for _, t in ipairs(targets) do if foxfire_can_reach(ax, ay, t.x, t.y, px, py, path_map) then count = count + 1 break end end end end end end end return count end -- Count total open (non-solid, no monster) adjacent cells to (cx, cy). function count_open_adjacent(cx, cy, map) local count = 0 for ddx = -1, 1 do for ddy = -1, 1 do if ddx ~= 0 or ddy ~= 0 then local c = map[pk(cx + ddx, cy + ddy)] if c and not c.solid and not c.monster then count = count + 1 end end end end return count end -- Cast Foxfire, possibly stepping first to find a better position. -- Uses next_map to predict next-turn state (player + all monsters moved) -- and evaluates foxfire cell quality on the predicted map. function zap_foxfire() if not can_zap("Foxfire") then return end local map = get_map() -- Helper: extract hostile targets from a (possibly mutated) map local function get_targets(m) local t = {} for _, cell in pairs(m) do if cell.monster and is_hostile(cell.monster) then t[#t + 1] = { x = cell.x, y = cell.y } end end return t end -- Check any hostile targets exist (visible or invisible) local any_hostile = false for _, cell in ipairs(get_monsters()) do if is_hostile(cell.monster) then any_hostile = true; break end end if not any_hostile then -- Check for invisible monsters on the map for _, cell in pairs(map) do if cell.invisible_monster then any_hostile = true; break end end end if not any_hostile then crawl.mpr("No visible monsters; use Z to cast.") return end -- Evaluate current position. -- Foxfires spawn NOW (current map for cell availability) but fly on -- future turns (predicted map for path reachability and target positions). -- Require 2+ good cells AND good/total ratio > 2/3 to cast in place. local nmap_here = next_map(map, 0, 0) local targets_here = get_targets(nmap_here) -- If no visible targets but invisible monster detected, just cast directly if #targets_here == 0 then local letter = spells.letter("Foxfire") crawl.sendkeys("z" .. letter) return end local cur_ff = count_foxfire_cells(0, 0, 0, 0, targets_here, map, nmap_here) local cur_open = count_open_adjacent(0, 0, map) if cur_ff >= 2 and (cur_open == 0 or cur_ff / cur_open > 2/3) then local letter = spells.letter("Foxfire") crawl.sendkeys("z" .. letter) return end -- Diagnostic: show which cells are good/bad local diag = {} for ddx = -1, 1 do for ddy = -1, 1 do if ddx ~= 0 or ddy ~= 0 then local ax, ay = ddx, ddy local ak = pk(ax, ay) local ac = map[ak] if ac and not ac.solid and not ac.monster then local good = false for _, t in ipairs(targets_here) do if foxfire_can_reach(ax, ay, t.x, t.y, 0, 0, nmap_here) then good = true; break end end diag[#diag + 1] = "(" .. ax .. "," .. ay .. ")=" .. (good and "good" or "bad") end end end end crawl.mpr("Foxfire cells: " .. table.concat(diag, " ")) -- Only consider repositioning if no monsters can melee us this turn. -- Ranged attackers can already hit us, so stepping doesn't make it worse. local atk = attackers() local melee_atk = {} for _, c in ipairs(atk) do local dist = cheb(c.x, c.y) local reach = c.monster:reach_range() or 1 if dist <= reach then melee_atk[#melee_atk + 1] = c end end if #melee_atk > 0 then local names = {} for _, c in ipairs(melee_atk) do names[#names + 1] = c.monster:name() end if cur_ff >= 2 then crawl.mpr(math.floor(cur_ff / cur_open * 100) .. "% good foxfire cells is too few") else crawl.mpr("Can't reposition for Foxfire: " .. table.concat(names, ", ") .. " can melee.") end return end -- Current position not good enough. Evaluate each adjacent step. local best_step = nil local best_ff = 0 local best_open = -1 for ddx = -1, 1 do for ddy = -1, 1 do if ddx ~= 0 or ddy ~= 0 then local sx, sy = ddx, ddy local sc = map[pk(sx, sy)] -- Must be traversable, unoccupied, and still see a target if sc and sc.traversable and not sc.monster then local nmap_step = next_map(map, sx, sy) local targets_step = get_targets(nmap_step) -- Must see at least one target from new position local sees_target = false for _, t in ipairs(targets_step) do if view.cell_see_cell(sx, sy, t.x, t.y) then sees_target = true; break end end local ff = sees_target and count_foxfire_cells(sx, sy, sx, sy, targets_step, nmap_step) or 0 local open = count_open_adjacent(sx, sy, nmap_step) if ff >= 2 and (open == 0 or ff / open > 2/3) then if ff > best_ff or (ff == best_ff and open > best_open) then best_step = { x = sx, y = sy } best_ff = ff best_open = open end end end end end end if best_step then crawl.mpr("Moving to improve foxfire effectiveness.") local step_keys = dir_key(best_step.x, best_step.y) crawl.do_commands({ "CMD_MOVE_" .. step_keys }) elseif cur_ff >= 2 then crawl.mpr(math.floor(cur_ff / cur_open * 100) .. "% good foxfire cells is too few") else crawl.mpr("No good position for Foxfire (need 2 open cells with path).") end end function zap(letter) -- Find spell name for this letter local names = you.spells() local letters = you.spell_letters() local spell_name = nil for i = 1, #letters do if letters[i] == letter then spell_name = names[i] break end end if not spell_name then return end if not can_zap(spell_name) then if spells.memorised(spell_name) and you.mp() < spells.mana_cost(spell_name) then crawl.mpr("Not enough MP to cast " .. spell_name .. ".") end return end -- Foxfire: use smart positioning logic if spell_name == "Foxfire" then zap_foxfire(); return end -- Non-targeted spells (self-buffs etc) if not spells.target(spell_name) and not spells.dir_or_target(spell_name) then crawl.sendkeys("z" .. letter) return end -- For blast spells, find optimal target to maximize monsters hit if blast_spells[spell_name] then local tx, ty = zap_blast(spell_name) if tx then local keys = walk_keys_to(tx, ty) crawl.sendkeys("z" .. letter .. "r" .. keys .. "f") return else -- No safe blast target found; don't fall through to auto-target crawl.mpr("No safe " .. spell_name .. " target (would hit player or allies).") return end end -- For bolt spells with 2+ visible hostile monsters, find optimal target if bolt_spells[spell_name] ~= nil then -- Collect visible hostile monsters local monsters_by_key = {} local monster_count = 0 for _, cell in ipairs(get_monsters()) do if is_hostile(cell.monster) then monsters_by_key[pk(cell.x, cell.y)] = cell.monster monster_count = monster_count + 1 end end if monster_count >= 2 then local tx, ty = best_bolt_target(spell_name, monsters_by_key) if tx then -- Cast spell, reset cursor to player with 'r', walk to target, fire local keys = walk_keys_to(tx, ty) crawl.sendkeys("z" .. letter .. "r" .. keys .. "f") return end end end -- Default: auto-target nearest monster crawl.sendkeys("z" .. letter .. "f") end function zap_a() zap("a") end function zap_b() zap("b") end function zap_c() zap("c") end function zap_d() zap("d") end -- NOTE: There's no good Lua API for hiding spells. The you.hidden_spells -- attribute exists in C++ but has no Lua binding. Spell hiding must be -- done manually via the memorize menu (M key, then Tab to hide mode). -- wanted god to join wanted_god = "vehumet" -- state for pending autojoin travel pending_autojoin = nil -- track declined temple prompts per level to avoid nagging autojoin_declined_temple = nil -- vi-key direction lookup: [dx+2][dy+2] -> key local vi_key = { [1] = { [1] = "y", [2] = "h", [3] = "b" }, [2] = { [1] = "k", [3] = "j" }, [3] = { [1] = "u", [2] = "l", [3] = "n" } } -- Generate vi-keys string to walk from (0,0) to (x,y) function walk_keys_to(x, y) local keys = "" while x ~= 0 or y ~= 0 do local dx = x > 0 and 1 or (x < 0 and -1 or 0) local dy = y > 0 and 1 or (y < 0 and -1 or 0) local k = vi_key[dx + 2] and vi_key[dx + 2][dy + 2] if not k then break end keys = keys .. k x = x - dx; y = y - dy end return keys end function autojoin() -- Debug -- crawl.mpr("autojoin called, god=" .. you.god()) if you.god() ~= "No God" then pending_autojoin = nil return false end -- handle pending travel to temple/altar if pending_autojoin then local feat = view.feature_at(0, 0) if pending_autojoin.type == "temple" then if feat and feat:lower() == "enter_temple" then pending_autojoin = nil crawl.sendkeys(">") return true end elseif pending_autojoin.type == "altar" then if feat and feat:lower():find("altar_" .. wanted_god) then pending_autojoin = nil -- Press > to open god description, then J to join crawl.sendkeys(">J") return true end end -- still traveling return false end -- check if standing on an altar local feat = view.feature_at(0, 0) if feat and feat:lower():find("altar_") then local altar_god = feat:lower():gsub("altar_", "") if altar_god == wanted_god then if crawl.yesno("Join " .. altar_god .. "?", true, "n") then -- Press > to open god description, then J to join crawl.sendkeys(">J") return true end end return false end -- check if standing on temple entrance (always ask, even if declined scan) if feat and feat:lower() == "enter_temple" then if crawl.yesno("Enter the Temple?", true, "n") then crawl.sendkeys(">") return true end return false end local level_name = you.branch() .. ":" .. you.depth() -- scan visible cells for temple or wanted altar -- Use view.feature_at() which returns nil for unseen cells for x = -8, 8 do for y = -8, 8 do if x ~= 0 or y ~= 0 then local cell_feat = view.feature_at(x, y) if cell_feat then local cell_feat_lower = cell_feat:lower() -- check for wanted god's altar if cell_feat_lower:find("altar_" .. wanted_god) then if crawl.yesno("Found altar of " .. wanted_god .. ". Go pray?", true, "n") then pending_autojoin = { type = "altar", x = x, y = y } -- Walk toward visible altar using vi-keys local keys = walk_keys_to(x, y) if keys ~= "" then crawl.sendkeys(keys) end return true end return false end -- check for temple entrance if cell_feat_lower == "enter_temple" and autojoin_declined_temple ~= level_name then if crawl.yesno("Found Temple entrance. Go there?", true, "n") then pending_autojoin = { type = "temple", x = x, y = y } -- Walk toward visible temple using vi-keys local keys = walk_keys_to(x, y) if keys ~= "" then crawl.sendkeys(keys) end return true end autojoin_declined_temple = level_name return false end end end end end return false end function autocheckshop() local feat = view.feature_at(0, 0) if not feat or feat:lower() ~= "enter_shop" then return false end crawl.mpr("You have " .. you.gold() .. " gold.") local shop_items = items.shop_inventory() if shop_items then for _, itc in ipairs(shop_items) do local it = itc.item or itc[1] local cost = itc.price or itc[2] if it and cost then -- report artefacts and branded items if it.artefact then crawl.mpr("ARTEFACT: " .. it.name() .. " (" .. cost .. " gold)") elseif it.branded or it.ego() then crawl.mpr("Branded: " .. it.name() .. " (" .. cost .. " gold)") end end end end return false end function autoequip() -- if we have a pending equip action, check if it completed if pending_autoequip then local item = items.inslot(pending_autoequip.slot) if item and item.equipped then pending_autoequip = nil return false end -- still waiting for equip to complete, don't prompt again return false end -- check for unequipped weapons (if better than current) local current_weapon = items.equipped_at("weapon") for _, it in ipairs(items.inventory()) do if not it.equipped and it.class(true) == "weapon" and not is_dangerous(it) then if is_better_weapon(it) then local insc = it.inscription or "" if insc:find("noask") then -- skip weapons inscribed with "noask" elseif crawl.yesno("Wield " .. it.name() .. "?", true, "n") then pending_autoequip = { slot = it.slot } crawl.sendkeys("w" .. items.index_to_letter(it.slot)) return true else it.inscribe("noask", true) end end end end -- check for unequipped armor (for empty slots) for _, it in ipairs(items.inventory()) do if not it.equipped and it.class(true) == "armour" and not is_dangerous(it) then local subtype = it.subtype and it.subtype() or nil if subtype ~= "offhand" then -- skip orbs local slot, armor_type = get_armor_slot(it.name()) if slot and has_open_armor_slot(slot) then if crawl.yesno("Wear " .. it.name() .. "?", true, "n") then pending_autoequip = { slot = it.slot } crawl.sendkeys("W" .. items.index_to_letter(it.slot)) return true end end end end end -- check for unequipped jewellery for _, it in ipairs(items.inventory()) do if not it.equipped and it.class(true) == "jewellery" and not is_dangerous(it) then local subtype = it.subtype() local name_lower = it.name():lower() -- check if it's a ring or amulet (subtype may be nil for unidentified items) local is_amulet = (subtype and subtype:lower():find("amulet")) or name_lower:find("amulet") local is_ring = (subtype and not subtype:lower():find("amulet")) or name_lower:find("ring") if is_amulet then -- use slot_is_available to handle extra amulet slots (e.g., Justicar's Regalia) if items.slot_is_available("amulet") then if crawl.yesno("Equip " .. it.name() .. "?", true, "n") then pending_autoequip = { slot = it.slot } crawl.sendkeys("P" .. items.index_to_letter(it.slot)) return true end end elseif is_ring then -- use slot_is_available to handle Octopodes (8 rings), artefacts granting -- extra slots (Finger Amulet +1, Crown of Vainglory +2), and species that -- can't wear jewellery (MUT_NO_JEWELLERY) if items.slot_is_available("ring") then if crawl.yesno("Equip " .. it.name() .. "?", true, "n") then pending_autoequip = { slot = it.slot } crawl.sendkeys("P" .. items.index_to_letter(it.slot)) return true end end end end end -- check for unequipped shield if shield skill >= 4 if you.skill("Shields") >= 4 then for _, it in ipairs(items.inventory()) do if not it.equipped and it.class(true) == "armour" and not is_dangerous(it) then local subtype = it.subtype() if subtype and (subtype:lower():find("buckler") or subtype:lower():find("shield") or subtype:lower():find("orb")) then if not items.equipped_at("shield") then if crawl.yesno("Equip " .. it.name() .. "?", true, "n") then pending_autoequip = { slot = it.slot } crawl.sendkeys("W" .. items.index_to_letter(it.slot)) return true end end end end end end return false end function automemorise() -- Offer to forget upgraded spells via amnesia scroll for old_spell, upgrade in pairs(spell_upgrades) do if spells.memorised(old_spell) and spells.memorised(upgrade) and not asked_forget[old_spell] then -- Find an amnesia scroll in inventory local amnesia_slot = nil for _, it in ipairs(items.inventory()) do if it.class(true) == "scroll" and it.name():lower():find("amnesia") then amnesia_slot = it.slot break end end if amnesia_slot then asked_forget[old_spell] = true if crawl.yesno("Forget " .. old_spell .. " (replaced by " .. upgrade .. ")?", true, "n") then local spell_letter = spells.letter(old_spell) crawl.sendkeys("r" .. items.index_to_letter(amnesia_slot) .. spell_letter) return true end end end end -- Check spells available to memorize for _, spell in ipairs(you.mem_spells()) do if is_wanted_spell(spell) and not spells.memorised(spell) then -- check XL >= spell level if you.xl() >= spells.level(spell) then -- check we have enough spell levels if you.spell_levels() >= spells.level(spell) then -- check failure rate < 50% if spells.fail(spell) < 50 then if crawl.yesno("Memorise " .. spell .. "?", true, "n") then you.memorise(spell) return true end end end end end end return false end -- Autopickup function for special items (darts, javelins, unidentified staves) -- Returns true to pick up, nil to let other autopickup rules decide -- Items to never pick up (return false to block autopickup) local never_pickup = { ["amulet of faith"] = true, ["staff of air"] = true, ["staff of necromancy"] = true, ["amulet of guardian spirit"] = true } function is_wanted(it) if not it then return nil end -- Block explicitly unwanted items local name = it.name():lower() for pattern, _ in pairs(never_pickup) do if name:find(pattern, 1, true) then return false end end local dominated = it.class(true) local dominated_subtype = it.subtype and it.subtype() or nil -- Pick up darts and javelins (throwables we want) if dominated == "missile" then if dominated_subtype == "dart" or dominated_subtype == "javelin" then return true end end -- Pick up unidentified staves (could be useful magical staves) if dominated == "staff" or dominated == "magical staff" then if not item_identified(it) then return true end end -- Return nil to let other autopickup rules decide return nil end function init() -- runs when rc is first loaded ASSIST_RC_LOADED = true -- Add autopickup function for special items (appends, doesn't clear existing) add_autopickup_func(is_wanted) -- eliminate annoying "more" prompt crawl.enable_more(false) -- NOTE: Update this timestamp when modifying this file assist_version = "2026-03-15 11:10" crawl.mpr("starting assist (updated " .. assist_version .. ")") end init() }