-- lordbot.lua - plays one LORD 4.06 day for Kalgoorlie Retro BBS in SyncTERM-Lua. -- -- A port of the Python LORD bot that runs nightly on the BBS. Start it once -- you're in LORD - at the "Enter the realm" menu or in the Town Square: press -- F12, choose Load Script > lordbot.lua, then Run Script. It plays the day the -- same way the Python bot does (forest fights, master challenge, gear upgrades, -- the bard and flirting, bank, Inn room) and stops when LORD exits back to the -- BBS. Press F12 > Stop Script at any time to stop it and take over. -- -- The character comes from the dialing entry's username (Wooshy = Gelai, -- Claudette = Claudette); for anyone else it asks. A day's log goes in -- lordbot--.log next to this script (in the script folder), and -- Olivia's rescue-quest hint is kept in lordbot--state.lua there (only -- for the LORD session she gave it in - see the start of the run). -- -- Don't run it for a character the cron bot has already played today, or -- while that bot is running: two copies of LORD must never write player.dat -- at the same time. local PROFILES = { wooshy = {name = 'Gelai', gender = 'M'}, claudette = {name = 'Claudette', gender = 'F'}, } local WEAPONS = {'Stick', 'Dagger', 'Short Sword', 'Long Sword', 'Huge Axe', 'Bone Cruncher', 'Twin Swords', 'Power Axe', "Able's Sword", "Wan's Weapon", 'Spear Of Gold', 'Crystal Shard', "Niras's Teeth", 'Blood Sword', 'Death Sword'} local ARMOURS = {'Coat', 'Heavy Coat', 'Leather Vest', 'Bronze Armour', 'Iron Armour', 'Graphite Armour', "Erdrick's Armour", 'Armour Of Death', "Able's Armour", 'Full Body Armour', 'Blood Armour', 'Magic Protection', "Belar's Mail", 'Golden Armour', 'Armour Of Lore'} local PRICES = {200, 1000, 3000, 10000, 30000, 100000, 150000, 200000, 400000, 1000000, 4000000, 10000000, 40000000, 100000000, 400000000} -- Forest/town events: (option text on screen, key to press, note). Chosen to be -- safe rather than greedy - anything risky is declined or walked away from. local EVENTS = { {[[\(A\)sk for a blessing]], 'T', 'fairies: try to catch one (a pocket fairy can save you in a fight)'}, -- A if one is already in the pocket {[[\(I\)nvestigate further]], 'I', 'headless woman: investigate (Olivia gives rescue hints)'}, {[[\(B\)e pleasured by Olivia]], 'T', 'Olivia: turn her down'}, {[[\(G\)et inside her head]], 'G', 'Olivia: get inside her head (ask for information)'}, {[[\(B\)oot her a distance]], '\r', 'Olivia head: take default'}, {[[\(P\)lay some "head ball"]], '\r', 'Olivia head: take default'}, {[[\(H\)op Like Crazy]], 'A', 'apologise'}, {[[\(L\)eave It Be]], 'L', 'mystic hut door: leave it be'}, {[[\(M\)umble Apologies]], 'M', 'guild: mumble apologies and leave'}, {[[\(T\)ell the Dwarf]], 'T', 'dwarf gambler: decline'}, {[[\(I\)gnore the]], 'S', 'rescue quest: save them (Olivia gave a hint)'}, -- I if no hint {[[\(V\)itality]], 'S', 'brew: strength'}, {[[\(S\)mile hugely]], 'S', 'smile'}, {[[\(Y\)ell Something To Your Enemy]], 'A', 'attack'}, {[[Do you take the old man\?]], 'Y', 'old man: take him to the Inn (charm)'}, {[[\(2\) Release Him]], '1', 'dungeon judgement: guilty (decapitate)'}, {[[\(G\)o back to the forest]], 'G', 'horse seller: no thanks, back to the forest'}, } -- Flirting with Violet: highest move whose charm requirement we meet. Carry Her -- Upstairs at 50+ charm; propose at 100+ (she only marries at 100). local FLIRTS = {{100, 'M', 'propose marriage'}, {50, 'C', 'carry her upstairs'}, {16, 'G', 'grab her backside'}, {8, 'S', 'sit her on your lap'}, {4, 'P', 'peck her on the lips'}, {2, 'K', 'kiss her hand'}, {0, 'W', 'wink'}} -- Flirting with Seth Able (female characters, from the bard's menu). local SETH_FLIRTS = {{100, 'M', 'marry him'}, {50, 'C', 'completely seduce the bard'}, {16, 'K', 'kiss the bard soundly'}, {8, 'A', 'ask him to buy you a drink'}, {4, 'D', 'drop hanky'}, {2, 'F', 'flutter eyelashes'}, {0, 'W', 'wink'}} -- Rescue quest castles and their menu keys. local CASTLES = {['Castle Coldrake'] = 'C', ['Fortress Liddux'] = 'F', ['Gannon Keep'] = 'G', ['Penyon Manor'] = 'P', ["Dema's Lair"] = 'D'} local CASTLE_NAMES = {'Castle Coldrake', 'Fortress Liddux', 'Gannon Keep', 'Penyon Manor', "Dema's Lair"} local OLIVIA_HINT = [[(?:evil Duke of\.*|town crier at)\s*(Castle Coldrake|Fortress Liddux|Gannon Keep|Penyon Manor|Dema's Lair)]] -- Strength of each forest monster (LORD 4.06 / lord.js monster table, as in LORD FP's -- tables.inc). A monster hits for up to its strength minus your defence. local MONSTER_STR = { ["Small Thief"] = 6, ["Rude Boy"] = 3, ["Old Man"] = 5, ["Large Green Rat"] = 3, ["Wild Boar"] = 10, ["Ugly Old Hag"] = 6, ["Large Mosquito"] = 2, ["Bran The Warrior"] = 12, ["Evil Wretch"] = 7, ["Small Bear"] = 9, ["Small Troll"] = 6, ["Green Python"] = 13, ["Gath The Barbarian"] = 12, ["Evil Wood Nymph"] = 15, ["Fedrick The Limping Baboon"] = 8, ["Wild Man"] = 13, ["Brorandia The Viking"] = 21, ["Huge Bald Man"] = 19, ["Senile Senior Citizen"] = 13, ["Membrain Man"] = 10, ["Bent River Dryad"] = 12, ["Rock Man"] = 87, ["Lazy Bum"] = 19, ["Two Headed Rotwieler"] = 18, ["Purple Monchichi"] = 14, ["Bone"] = 27, ["Red Neck"] = 19, ["Winged Demon Of Death"] = 42, ["Black Owl"] = 28, ["Muscled Midget"] = 26, ["Headbanger Of The West"] = 23, ["Morbid Walker"] = 28, ["Magical Evil Gnome"] = 89, ["Death Dog"] = 36, ["Weak Orc"] = 27, ["Dark Elf"] = 43, ["Evil Hobbit"] = 35, ["Short Goblin"] = 34, ["Huge Black Bear"] = 67, ["Rabid Wolf"] = 45, ["Young Wizard"] = 64, ["Mud Man"] = 56, ["Death Jester"] = 34, ["Pandion Knight"] = 64, ["Jabba"] = 61, ["Manoken Sloth"] = 54, ["Trojan Warrior"] = 73, ["Misfit The Ugly"] = 75, ["George Of The Jungle"] = 56, ["Silent Death"] = 113, ["Bald Medusa"] = 78, ["Black Alligator"] = 65, ["Clancy, Son Of Emporor Len"] = 52, ["Black Sorcerer"] = 86, ["Iron Warrior"] = 100, ["Black Soul"] = 112, ["Gold Man"] = 86, ["Screaming Zombie"] = 98, ["Satans Helper"] = 112, ["Wild Stallion"] = 78, ["Belar"] = 120, ["Empty Armour"] = 67, ["Raging Lion"] = 98, ["Huge Stone Warrior"] = 112, ["Emporer Len"] = 210, ["Night Hawk"] = 220, ["Charging Rhinoceros"] = 187, ["Goblin Pygmy"] = 165, ["Goliath"] = 243, ["Angry Liontaur"] = 187, ["Fallen Angel"] = 154, ["Wicked Wombat"] = 198, ["Massive Dinosaur"] = 200, ["Swiss Butcher"] = 230, ["Death Gnome"] = 356, ["Screeching Witch"] = 300, ["Rundorig"] = 330, ["Wheeler"] = 250, ["Death Knight"] = 287, ["Werewolf"] = 230, ["Fire Ork"] = 267, ["Wans Beast"] = 193, ["Lord Mathese"] = 245, ["King Vidion"] = 400, ["Baby Dragon"] = 176, ["Pink Elephant"] = 434, ["Gwendolens Nightmare"] = 490, ["Flying Cobra"] = 400, ["Rentakis Pet"] = 556, ["Ernest Brown"] = 432, ["Scallian Rap"] = 601, ["Apeman"] = 498, ["Hemo-Glob"] = 212, ["FrankenMoose"] = 455, ["Earth Shaker"] = 767, ["Gollums Wrath"] = 621, ["Toraks Son, Korak"] = 921, ["Brand The Wanderer"] = 643, ["The Grimest Reaper"] = 878, ["Death Dealer"] = 765, ["Tiger Of The Deep Jungle"] = 587, ["Sweet Looking Little Girl"] = 989, ["Floating Evil Eye"] = 776, ["Slock"] = 744, ["Adult Gold Dragon"] = 565, ["Kill Joy"] = 988, ["Gorma The Leper"] = 1132, ["Shogun Warrior"] = 1143, ["Apparently Weak Old Woman"] = 1543, ["Ables Creature"] = 985, ["White Bear Of Lore"] = 1344, ["Mountain"] = 1544, ["Sheena The Shapechanger"] = 1463, ["ShadowStormWarrior"] = 1655, ["Madman"] = 1265, ["Vegetable Creature"] = 111, ["Cyclops Warrior"] = 1744, ["Corinthian Giant"] = 2400, ["The Screaming Eunich"] = 1488, ["Black Warlock"] = 1366, ["Kal Torak"] = 876, ["The Mighty Shadow"] = 1633, ["Black Unicorn"] = 1899, ["Mutated Black Widow"] = 2575, ["Humongous Black Wyre"] = 1166, ["The Wizard Of Darkness"] = 1497, ["Great Ogre Of The North"] = 1800, } -- Gold carried in the forest is lost if you die (Claudette lost ~600k to King -- Vidion, 2026-10-31), so bank it part way through: whenever we carry more than -- bank_at(level) on top of what the healer may need. About five fights' worth: -- 2k at level 2, 128k at 8, 256k at 9. local function bank_at(level) return 1000 << ((level or 1) - 1) end -- Healing costs 5 gold a hitpoint per level; keep enough for two full heals. local function heal_reserve(st) return 10 * (st.level or 1) * (st.maxhp or 20) end -- Keys tried in turn when a screen will not change. local ESCAPE_KEYS = {'\r', ' ', 'C', 'N', 'R', 'L', '\27'} local DEADLINE = now() + 25 * 60 -- hard stop -- ---------------------------------------------------------------- helpers local HERE = (script_path or ''):match('^(.*[/\\])') or '' local syncterm_log = log -- CP437 characters 128-255 as Unicode, so the log file reads properly local CP437 = { 0xC7,0xFC,0xE9,0xE2,0xE4,0xE0,0xE5,0xE7,0xEA,0xEB,0xE8,0xEF,0xEE,0xEC,0xC4,0xC5, 0xC9,0xE6,0xC6,0xF4,0xF6,0xF2,0xFB,0xF9,0xFF,0xD6,0xDC,0xA2,0xA3,0xA5,0x20A7,0x192, 0xE1,0xED,0xF3,0xFA,0xF1,0xD1,0xAA,0xBA,0xBF,0x2310,0xAC,0xBD,0xBC,0xA1,0xAB,0xBB, 0x2591,0x2592,0x2593,0x2502,0x2524,0x2561,0x2562,0x2556,0x2555,0x2563,0x2551,0x2557,0x255D,0x255C,0x255B,0x2510, 0x2514,0x2534,0x252C,0x251C,0x2500,0x253C,0x255E,0x255F,0x255A,0x2554,0x2569,0x2566,0x2560,0x2550,0x256C,0x2567, 0x2568,0x2564,0x2565,0x2559,0x2558,0x2552,0x2553,0x256B,0x256A,0x2518,0x250C,0x2588,0x2584,0x258C,0x2590,0x2580, 0x3B1,0xDF,0x393,0x3C0,0x3A3,0x3C3,0xB5,0x3C4,0x3A6,0x398,0x3A9,0x3B4,0x221E,0x3C6,0x3B5,0x2229, 0x2261,0xB1,0x2265,0x2264,0x2320,0x2321,0xF7,0x2248,0xB0,0x2219,0xB7,0x221A,0x207F,0xB2,0x25A0,0xA0, } local function utf8_text(s) return (s:gsub('[\128-\255]', function(c) return utf8.char(CP437[c:byte() - 127]) end)) end local logf local function log(...) local parts = {} for i = 1, select('#', ...) do parts[#parts + 1] = tostring((select(i, ...))) end local msg = table.concat(parts, ' ') if logf then logf:write(os.date('%H:%M:%S '), utf8_text(msg), '\n'); logf:flush() end syncterm_log(msg) end local function num(s) return tonumber((s:gsub(',', ''))) end local function has(s, text) return s:find(text, 1, true) ~= nil end local function re(s, pat) return regex.find(s, pat) ~= nil end local function rmatch(s, pat) return regex.match(s, pat) end -- captures, or nil -- every capture of a one-group regex, in order local function findall(s, pat) local out, init = {}, 1 while true do local a, b, cap = regex.find(s, pat, init) if not a then return out end out[#out + 1] = cap init = b >= a and b + 1 or a + 1 end end local function lines(s) local t = {} for l in (s .. '\n'):gmatch('(.-)\n') do t[#t + 1] = l end return t end local function last_line(s) local t = lines(s); return t[#t] or '' end local function last_lines(s, n) local t = lines(s) return table.concat(t, '\n', math.max(1, #t - n + 1), #t) end local function index_of(list, name) -- 0-based like the Python lists, -1 if missing -- the stats screen drops some apostrophes ("Erdricks Armour", 2026-10-22) if not name then return -1 end local key = function(n) return (n:gsub("'", ''):lower()) end for i, v in ipairs(list) do if key(v) == key(name) then return i - 1 end end return -1 end local STATE local function load_state() local f = loadfile(STATE) local ok, d = pcall(f or function() end) return (ok and type(d) == 'table') and d or {} end local function save_state(d) local f = assert(io.open(STATE .. '.tmp', 'w')) f:write('return {\n') for k, v in pairs(d) do f:write(string.format(' [%q] = %q,\n', k, v)) end f:write('}\n'); f:close() -- Windows won't rename over an existing file (Linux replaces it) if not os.rename(STATE .. '.tmp', STATE) then os.remove(STATE) assert(os.rename(STATE .. '.tmp', STATE)) end end -- ---------------------------------------------------------------- the screen -- Wait for the BBS to answer: at least `wait` seconds, and on until output -- has stopped for 0.6 seconds (at most 8 seconds altogether). local function pump(wait) wait = wait or 1.2 local start = now() quiet(0.6, 8) local more = wait - (now() - start) if more > 0 then sleep(more) quiet(0.6, math.max(0.6, 8 - (now() - start))) end clear() -- we read the screen, not the stream; don't let it pile up end local t = {} function t.send(keys, wait) if now() > DEADLINE then error('deadline reached', 0) end send(keys); pump(wait) end t.pump = pump function t.text() local _, rows = screen_size() local out = {} for r = 1, rows or 25 do out[#out + 1] = screen(r) or '' end return table.concat(out, '\n') end function t.tail(n) n = n or 6 local keep = {} for _, l in ipairs(lines(t.text())) do if l:find('%S') then keep[#keep + 1] = l end end return table.concat(keep, '\n', math.max(1, #keep - n + 1), #keep) end -- ---------------------------------------------------------------- game logic -- Pick (weapon, armour) indexes to own after shopping. Armour comes first: -- buy the best armour the budget allows, then spend what is left on the best -- weapon, but never a weapon ahead of the armour tier. Current gear sells -- back for half its price. local function plan_gear(wi, ai, budget) local top = #PRICES - 1 local function refund(i) return i >= 0 and PRICES[i + 1] // 2 or 0 end local function cost(cur, new) return new > cur and PRICES[new + 1] - refund(cur) or 0 end local ta = ai for t = top, ai + 1, -1 do if cost(ai, t) <= budget then ta = t; break end end local left = budget - cost(ai, ta) local tw = wi for t = ta, wi + 1, -1 do if cost(wi, t) <= left then tw = t; break end end return tw, ta end -- Screen text below the last prompt we already answered (e.g. 'Well? [I] : I'). -- Turgon's (Q)uestion Master. Not ready: "You need about N more experience before you -- will be as good as I am." Ready: each master has his own line (LORD 4.06 / lord.js -- needstr1), after " looks at your carefully and says:" in lord.js / LORD FP. local MASTER_READY = { 'and says', 'muscles are getting bigger', 'getting pretty good with', 'learned everything i can teach', 'becoming a very skilled warrior', 'know how to handle your shaft', 'getting the hang of it', 'ready to be tested on the battle field', 'really know how to use your', 'as good as i am now', 'truly the best warrior', } -- true = the master says we are ready, false = not yet, nil = answer not recognised local function master_answer(text) local i = text:find('Questioning Your Master', 1, true) local q = (i and text:sub(i) or text):lower() if q:find('more experience before', 1, true) then return false end for _, p in ipairs(MASTER_READY) do if q:find(p, 1, true) then return true end end return nil end local function fresh(text) local ls = lines(text) for i = #ls, 1, -1 do if re(ls[i], [[(\]|\?|:)\s*:?\s*[A-Za-z0-9]\s*$]]) and re(ls[i], [[:\s*[A-Za-z0-9]\s*$]]) then return table.concat(ls, '\n', i + 1, #ls) end end return text end local PROF, FEMALE, LOVER -- Only one fairy fits in the pocket. Gained: the catch. Lost: it saves you in -- a fight, escapes at the bank, or gets loose with Olivia. local function last_index(text, m) local at, i = -1, text:find(m, 1, true) while i do at = i; i = text:find(m, i + 1, true) end return at end local function track_fairy(st, text) local got, lost = last_index(text, 'A FAIRY!'), -1 for _, m in ipairs({'BUZZING IN YOUR POUCH', 'ESCAPES FROM YOUR POCKET', 'Your fairy is loose'}) do lost = math.max(lost, last_index(text, m)) end if got > lost and not st.fairy then st.fairy = true; log('fairy caught - in the pocket now') elseif lost > got and st.fairy then st.fairy = false; log('fairy gone from the pocket') end end -- Player fights (Slaughter Other Players), only on days with a fairy in the pocket: -- it saves us once if a fight goes wrong. The bots never attack each other. local SPARE = {gelai = true, claudette = true} -- Player Rankings line: "F D Claudette 38,628 6 D Alive On" local RANK_LINE = [[^([F ]) (.) (\S.*?)\s+([\d,]+)\s+(\d+)\s.*?(Alive|Dead)]] -- Add the warriors on a Player Rankings screen to st.pvp_roster. local function read_rankings(st, text) st.pvp_roster = st.pvp_roster or {} for _, l in ipairs(lines(text)) do local _, _, name, exp, lvl, status = rmatch(l, RANK_LINE) if name then st.pvp_roster[name] = {level = tonumber(lvl), exp = num(exp), alive = status == 'Alive', on = re(l, [[Alive\s+On\s*$]])} end end end local function want_pvp(st) return st.fairy and (st.pfights or 0) > 0 and not st.pvp_done and not st.bailed and not st.done end local function contains(list, v) for _, x in ipairs(list or {}) do if x == v then return true end end return false end -- Richest-in-experience warrior we may attack: alive, not online, level -1..+1 of ours. local function pvp_target(st) local lvl, best, best_exp = st.level or 1, nil, -1 for n, p in pairs(st.pvp_roster or {}) do if n:lower() ~= PROF.name:lower() and not SPARE[n:lower()] and not contains(st.pvp_tried, n) and p.alive and not p.on and p.level >= lvl - 1 and p.level <= lvl + 1 and p.exp > best_exp then best, best_exp = n, p.exp end end return best end local function read_stats(st) t.send('V', 2); local sv = t.text() local lvl, _, mx = rmatch(sv, [[Level\s+: (\d+)\s+HitPoints\s+: (\d+) of (\d+)]]) if lvl then st.level, st.maxhp = tonumber(lvl), tonumber(mx) end local d = rmatch(sv, [[Defensive Strength\s*: ([\d,]+)]]) if d then st.def = num(d) end local c = rmatch(sv, [[Charm\s+: (\d+)]]) if c then st.charm = tonumber(c) end st.fairy = has(sv, 'fairy in your pocket') local pf = rmatch(sv, [[Player Fights Left : (\d+)]]) if pf then st.pfights = tonumber(pf) end log('fairy in pocket:', st.fairy and 'yes' or 'no', '| player fights left:', st.pfights) local parts = {} for _, l in ipairs(lines(sv)) do if re(l, [[Experience|Level|Gold In|Weapon|Armour |Charm]]) then parts[#parts + 1] = (l:gsub('^%s+', ''):gsub('%s+$', ''):gsub('%s+', ' ')) end end log('stats:', table.concat(parts, ' | ')) t.send(' ') end local function pick_flirt(list, charm) for _, f in ipairs(list) do if charm >= f[1] then return f end end end local function main() local st = {phase = 'forest', master = false, shopped = false, banked = false, inn_cost = nil, withdrawn = false, gap = nil, exp_gain = 0, level = nil, done = false, lover_married = nil, morning_inn = false, bard = false, sang = false, violet = false} local same, prev, stuck, after_done = 0, nil, 0, 0 local function bail(why, skip_master) -- stop adventuring, but still bank the gold and sleep at the Inn if not st.bailed then log('BAILING OUT:', why); st.bailed = true; st.phase = 'town' end if skip_master then st.master = true end end pump(1) for step = 1, 2000 do if now() > DEADLINE then error('deadline reached', 0) end local s, last = t.text(), t.tail() track_fairy(st, fresh(t.tail(14))) if st.pvp_target and not st.pvp_done then if has(t.tail(6), 'too tired to look for that warrior') then st.pfights = 0 end if has(s, 'ONLINE BATTLE') then log('player fight turned into an online duel - no more player fights today'); st.pvp_done = true end local killed = rmatch(t.tail(14), [[You have killed (.+?)!]]) st.pvp_killed = st.pvp_killed or {} if killed and st.pvp_fight and killed:lower() == st.pvp_target:lower() and not contains(st.pvp_killed, st.pvp_target) then table.insert(st.pvp_killed, st.pvp_target); st.pvp_gold = true log('player fight: killed', st.pvp_target) end end local lastl = last_line(last) if last == prev then same = same + 1 else same = 0 end prev = last if now() > DEADLINE - 8 * 60 and not st.done then bail('running out of time', true) end -- killed (forest, player fight, dragon): LORD shows the death screens and -- then exits, so the day is over if not st.dead and (has(t.tail(14), 'You have been killed by') or has(t.tail(14), 'defeated on your way to glory')) then st.dead = true; st.done = true local by = rmatch(t.tail(14), [[You have been killed by (.+?)\.]]) log('KILLED' .. (by and ' by ' .. by or '') .. ' - the day is over') end -- the day is over once LORD hands us back to the BBS. Until then only page -- through LORD's own screens: the BBS's menus are not ours to press keys in -- (on another board a stray E once started a second LORD door) if st.done then after_done = after_done + 1 if has(s, 'RETURNING TO THE MUNDANE') or has(s, 'External Programs') or has(last, '[Hit a key]') or has(last, '\254 Main \254') or has(last, 'Your choice, warrior?') or after_done > 15 then log('LORD has finished for today - stopping here'); return true end if has(last:lower(), '') then t.send(' ') elseif has(lastl, 'Quit game?') then t.send('Y') else pump(2) end goto continue end if same >= 6 then local k = ESCAPE_KEYS[stuck % #ESCAPE_KEYS + 1]; stuck = stuck + 1 log(string.format('STUCK (try %d, sending %q) on screen:\n', stuck, k) .. t.tail(12)) if stuck == #ESCAPE_KEYS then bail('stuck on an unknown screen') end t.send(k); same = 0; goto continue end if re(lastl, [[\[(Yes|No)\] *(No|Yes)?\s*$]]) or re(last, [=[\? \[(Yes|No)\]]=]) then t.send('N'); goto continue end -- ---- new character (first run only): alias, gender, childhood/class ---- if has(s, 'What would you like as an alias') and re(lastl, [[(?i)Name:\s*$]]) then if st.named then error('LORD asked for the alias twice - name refused?\n' .. t.tail(12), 0) end st.named = true; log('creating LORD character', PROF.name); t.send(PROF.name .. '\r', 2); goto continue end if has(s, 'currently goes by that name') then error('LORD name already taken:\n' .. t.tail(12), 0) end if has(lastl, 'And your gender?') then -- sent before the catch-all "[M]" default handler, which would make her male if not st.gender_sent then st.gender_sent = true; log('gender:', PROF.gender); t.send(PROF.gender, 2) else t.send('\r', 2) end -- line input: confirm what we typed goto continue end if has(lastl, 'Pick one. (K,D,L)') then log('class: Death Knight'); t.send('K', 2); goto continue end if has(s, 'CONJUGALITY LIST') then local married = re(s, FEMALE and [[is the property of Seth Able]] or [[belongs to Violet|Violet belongs to]]) if married or st.lover_married == nil then if st.lover_married ~= married then log(LOVER, 'is', married and 'married' or 'single') end st.lover_married = married end end do -- Olivia's hint: "...the evil Duke of.. was it?" or "...a town crier at ." local castle = rmatch(t.tail(20), OLIVIA_HINT) if castle and CASTLES[castle] then local mem = load_state() if mem.rescue_hint ~= castle then mem.rescue_hint = castle; save_state(mem); log('rescue hint from Olivia remembered:', castle) end end end if has(last:lower(), '') then if st.pvp_listing then read_rankings(st, t.text()) end -- experience won since the master last said how much is needed (a line counted -- twice only means an early, free question) local tl = t.tail(14) if tl ~= st.counted_tail then st.counted_tail = tl for _, x in ipairs(findall(tl, [[You receive [\d,]+ gold, and ([\d,]+) experience]])) do st.exp_gain = st.exp_gain + num(x) end end for _, l in ipairs(lines(t.tail(14))) do if re(l, [[(?i)killed|receive|You find|level|defeated|master|dead|died|lost|fair|HIT POINTS]]) then log(' ' .. l:match('^%s*(.-)%s*$')) end end t.send(' '); goto continue end if has(last, 'Your choice, warrior?') then -- enter the realm once per run: seeing this menu again means LORD has exited if st.entered then log('back at the LORD start menu - stopping here'); return true end st.entered = true; t.send('E', 3); goto continue end if has(last, '(C)ontinue') and has(last, 'happenings') then t.send('C', 3); goto continue end do -- the forest local hp, mx, fights, gold = rmatch(s, [[HitPoints: \((\d+) of (\d+)\)\s+Fights: (\d+) Gold: ([\d,]+)]]) if has(last, 'The Forest') and hp then hp, mx, fights, gold = tonumber(hp), tonumber(mx), tonumber(fights), num(gold) st.heal = false -- back in the forest: any healer visit is over if (fights == 0 or st.phase ~= 'forest' or st.trip) and hp < mx and gold > 0 and st.heal_tried_hp ~= hp then st.heal_tried_hp = hp log(string.format('healing before leaving the forest (hp %d/%d)', hp, mx)); st.heal = true; t.send('H') elseif fights == 0 or st.phase ~= 'forest' then st.phase = 'town'; st.trip = nil; log(string.format('forest done: hp %d/%d gold %d', hp, mx, gold)); t.send('R') elseif st.trip then t.send('R') -- off to the bank or the master elseif hp < mx and gold > 0 and st.heal_tried_hp ~= hp then -- heal after every fight that cost any hitpoints (user, 2026-10-26); if the -- healer couldn't afford even one point, don't try again until hp changes st.heal_tried_hp = hp; st.heal = true; t.send('H') elseif hp < mx * 0.4 then bail(string.format('hp %d/%d and no gold to heal', hp, mx)); t.send('R') else st.heal_tried_hp = nil -- a new fight: healing may work again if gold > heal_reserve(st) + bank_at(st.level) then st.trip = 'bank'; log(string.format('carrying %d gold - off to the bank', gold)) -- enough experience for the master (user, 2026-10-31): go now, not at the end of the day elseif not st.master and st.gap and st.exp_gain > st.gap then st.trip = 'master'; log(string.format('about %d experience won, %d needed - off to the master', st.exp_gain, st.gap)) else t.send('L') end end goto continue end end if has(s, '(A)ttack') and has(s, 'Your Hitpoints') and has(last, 'Your command') then local hps, ehp = findall(s, [[Your Hitpoints : (\d+)]]), findall(s, [['s Hitpoints : (\d+)]]) local hp = #hps > 0 and tonumber(hps[#hps]) or 99 local e = #ehp > 0 and tonumber(ehp[#ehp]) or 0 local pvp = st.pvp_fight -- target's level in a player fight -- how hard can this foe hit? Its strength minus our defence (monster table), or -- the biggest hit it has landed, whichever is more. Run while two such hits would -- not kill us: a fixed 30% let King Vidion kill Claudette (2026-10-31). local fm = findall(s, [[(?m)^\s*(?!Your )(.+?)'s Hitpoints : \d]]) local foe = #fm > 0 and fm[#fm]:match('^%s*(.-)%s*$') or '?' if foe ~= st.foe then st.foe, st.big_hit, st.no_skill = foe, 0, false end -- "Your honor stops you": the game redraws the whole fight menu after it, so the -- line soon scrolls out of the tail - remember it for the rest of this fight if has(s, 'Your honor stops you') then st.no_skill = true end st.counted_tail = nil -- count this fight's winnings once for _, d in ipairs(findall(t.tail(14), [[hits with (?:its|his|her) .+? for ([\d,]+) damage]])) do st.big_hit = math.max(st.big_hit, num(d)) end local maxhit = math.max((MONSTER_STR[foe] or 0) - (st.def or 0), st.big_hit, 0) if st.bailed and not st.arena then t.send('R') -- in a player fight the pocket fairy saves us, so only run once it is gone elseif not st.arena and not (pvp and st.fairy) and e > 8 and (hp <= math.max(5, (st.maxhp or 20) * 0.3) or hp <= 2 * maxhit) then log(string.format(' running from %s (hp %d, it has %d, hits up to about %d)', foe, hp, e, maxhit)); t.send('R') -- no skills against the master, or on a player of our level or lower ("Your honor stops you") elseif not st.arena and not st.no_skill and re(s, [=[\(D\)eath Knight Attack \([1-9]]=]) and e >= 12 and (not pvp or pvp > (st.level or 99)) then t.send('D') else t.send('A') end goto continue end -- the healer: heal all once, then leave. LORD FP's prompt is "The Healers (H,C,R)" and it stays -- in the hut after a part heal (not enough gold for all of it), so read the screen, don't type ahead if (has(s, 'Healers Hut') or has(last, '(H,C,R)')) and has(last, 'Your command') then if st.heal then st.heal = false; t.send('H') else t.send('R') end goto continue end if has(last, 'DarkCloak Tavern') or has(last, 'Dark Cloak') and has(last, 'Your command') then t.send('R'); goto continue end if has(last, 'Town Square') and has(last, 'Your command') then st.arena = false if st.level == nil then read_stats(st); goto continue end if st.lover_married == nil then -- Conjugality list st.conj_tries = (st.conj_tries or 0) + 1 if st.conj_tries > 2 then log('could not read Conjugality list - not flirting'); st.lover_married = true; goto continue end t.send('C', 2); goto continue end if not st.morning_inn then t.send('I'); goto continue end -- bard + flirting before the forest if st.trip == 'bank' then t.send('Y'); goto continue end -- bank the forest gold -- the master: ask in the morning how much experience is needed, go again as soon -- as the forest has given that much (and at the end of the day), and before the -- fight buy better armour if we can afford it (user, 2026-10-31) if not st.master and (st.gap == nil or st.master_ready or st.trip == 'master' or st.phase ~= 'forest') then if not st.master_ready then t.send('T'); goto continue end -- question him (free) if not st.withdrawn then t.send('Y'); goto continue end -- bank: take it all out if st.inn_cost == 'check' then t.send('I'); goto continue end -- keep the price of the room if not st.armour_done then st.armour_only = true; st.plan = nil; t.send('A'); goto continue end if not st.pre_banked then t.send('Y'); goto continue end -- put the rest back t.send('T'); goto continue -- fight end if st.phase == 'forest' then t.send('F'); goto continue end if not st.withdrawn then t.send('Y'); goto continue end -- bank: withdraw all if not st.shopped then st.shop = 'K'; t.send('K'); goto continue end if not st.banked then t.send('Y'); goto continue end -- bank: deposit all if want_pvp(st) then t.send('S', 2); goto continue end -- player fights, gold already banked if st.pvp_gold then st.pvp_gold = false; st.banked = false; t.send('Y'); goto continue end -- bank the winnings if st.no_room then st.done = true; t.send('Q'); goto continue end -- quit to the fields st.want_room = true; t.send('I'); goto continue end if has(last, "Turgon's Warrior Training") and has(last, 'Your command') then if st.master then t.send('R'); goto continue end if not st.master_ready then -- ask first: challenge only when ready if st.asked then st.asked = false; t.send('R'); goto continue end t.send('Q', 2); local ans = master_answer(t.text()); st.asked = true if st.trip == 'master' then st.trip = nil end if ans == nil then log('master: answer not recognised - not challenging:\n' .. t.tail(12)); st.master = true elseif not ans then local g = rmatch(t.text():gsub('%s+', ' '), [[need about ([\d,]+) more experience]]) st.gap, st.exp_gain = g and num(g) or 1e12, 0 log(string.format('master: not ready yet (%s more experience needed)', g or 'unknown')) if st.phase ~= 'forest' then st.master = true end -- end of the day: no more tries else st.master_ready = true; log('master says ready - armour first, then the challenge') end goto continue end if not st.pre_banked then t.send('R'); goto continue end -- armour first st.master = true; st.arena = true; st.level = nil; log('challenging the master'); t.send('A', 2) st.withdrawn = false; st.plan = nil -- end of the day shops afresh goto continue end if has(last, 'The Bank') and has(last, 'Your command') then local h, b = rmatch(s, [[Gold In Hand: ([\d,]+)\s+Gold In Bank: ([\d,]+)]]) if not h then t.send('R'); goto continue end local hand, bank = num(h), num(b) if st.trip == 'bank' or (st.armour_done and not st.pre_banked and not st.master) then -- part way through the day: bank it, but keep enough for the healer local keep = heal_reserve(st); local amt = hand - keep if amt > 1 then t.send('D'); t.send(string.format('%d\r', amt)) end log(string.format('banked %d (kept %d for the healer)', math.max(amt, 0), math.min(hand, keep))) if st.trip == 'bank' then st.trip = nil else st.pre_banked = true end t.send('R'); goto continue end if not st.withdrawn then if bank > 0 then t.send('W'); t.send('1\r') end st.withdrawn = true if st.inn_cost == nil then st.inn_cost = 'check'; t.send('R'); t.send('I'); goto continue end t.send('R'); goto continue end if not st.banked and st.shopped then -- bank everything, then take out exactly the price of the room local total = hand + bank if hand > 0 then t.send('D'); t.send('1\r') end -- 1 = deposit ALL st.banked = true if total >= st.inn_cost then t.send('W'); t.send(st.inn_cost .. '\r') log(string.format('banked all (%d deposited), withdrew %d for the room', hand, st.inn_cost)) else st.no_room = true log(string.format('banked all (%d deposited); %d is not enough for the room - sleeping in the fields', hand, total)) end end t.send('R'); goto continue end do local price = rmatch(s, [[That'll be ([\d,]+) gold]]) if price and has(last, 'Do you agree') then if st.inn_cost == 'check' then st.inn_cost = num(price); log('inn costs', st.inn_cost); t.send('N'); t.send('R') else log('renting room'); st.done = true; t.send('Y', 4) end goto continue end end if has(s, 'The Red Dragon Inn') and has(last, 'Your command') then if st.inn_cost == 'check' or st.want_room then t.send('G'); goto continue end if st.level == nil then read_stats(st); goto continue end -- women flirt with Seth from the bard's menu, so check Conjugality before seeing him if FEMALE and st.lover_married == nil then t.send('R'); goto continue end if not st.bard then st.bard = true; log('asking Seth Able to sing'); t.send('H'); goto continue end if st.lover_married == nil then t.send('R'); goto continue end -- check Conjugality first if not st.violet and st.lover_married == false and has(s, '(F)lirt with Violet') then st.violet = true; t.send('F'); goto continue end st.morning_inn = true; t.send('R'); goto continue end -- Seth Able's flirt menu (female characters). Checked before the bard's -- own menu below, whose "Seth Able" prompts can still be on screen. if FEMALE and st.violet and not st.flirted and re(lastl, [[Your choice\?.*:\s*$]]) then if has(t.tail(14), '(C)ompletly Seduce') then st.flirted = true local ch = st.charm or 0 local f = pick_flirt(SETH_FLIRTS, ch) log(string.format('flirting with Seth Able (charm %d): %s', ch, f[3])); t.send(f[2], 2); goto continue end st.seth_menu = (st.seth_menu or 0) + 1 if st.seth_menu <= 2 then t.send('?', 2); goto continue end -- menu not shown - ask for it log('could not find the Seth Able flirt menu - not flirting'); st.flirted = true; t.send('N', 2); goto continue end if (has(t.tail(10), '(A)sk Seth Able to sing') or has(last, 'Seth Able looks at you')) and re(last, [[:\s*$]]) then if not st.sang then st.sang = true; t.send('A', 2); goto continue end if FEMALE and not st.violet and st.lover_married == false then st.violet = true; t.send('F', 2); goto continue end t.send('R'); goto continue end if has(t.tail(14), '(C)arry Her Upstairs') and re(last, [[:\s*$]]) and not st.flirted then st.flirted = true local ch = st.charm or 0 local f = pick_flirt(FLIRTS, ch) log(string.format('flirting with Violet (charm %d): %s', ch, f[3])); t.send(f[2], 2); goto continue end if has(last, "King Arthur's Weapons") or has(last, 'Abduls Armour') or has(last, 'Abdul') and has(last, 'Your command') then local weapon = has(last, 'King Arthur') if weapon or not st.plan then t.send('Y', 2); local sv = t.text(); t.send(' ') -- stats screen: gold and current gear local g = rmatch(sv, [[Gold In Hand : ([\d,]+)]]); local gold = g and num(g) or 0 local wn, an = rmatch(sv, [[Weapon\s+: (.+?)\s{2,}]]), rmatch(sv, [[Armour\s+: (.+?)\s{2,}]]) st.gear = {wn, an} local budget = gold - (type(st.inn_cost) == 'number' and st.inn_cost or 0) local tw, ta = plan_gear(index_of(WEAPONS, wn), index_of(ARMOURS, an), budget) st.plan = {tw, ta} log(string.format('gear plan: %s / %s -> %s / %s (budget %d)', tostring(wn), tostring(an), tw >= 0 and WEAPONS[tw + 1] or tostring(wn), ta >= 0 and ARMOURS[ta + 1] or tostring(an), budget)) end local names, cur, target if weapon then names, cur, target = WEAPONS, st.gear[1], st.plan[1] else names, cur, target = ARMOURS, st.gear[2], st.plan[2] end local idx = index_of(names, cur) local bare = cur == nil or cur == 'Nothing!' or cur == 'Fists' or cur == 'Nothing' if idx < 0 and not bare then -- never trade in something we cannot place on the price list log(string.format('WARNING: unknown %s %q - not shopping here', weapon and 'weapon' or 'armour', cur)); target = -1 end if target > idx then log('upgrading', tostring(cur), '->', names[target + 1]) if not bare then t.send('S'); t.send('Y'); t.send(' ') end t.send('B'); t.send((target + 1) .. '\r'); t.send('Y'); t.send(' ') end if weapon then t.send('R'); t.send('A'); goto continue end if st.armour_only then -- before the master: armour only st.armour_only = false; st.armour_done = true; st.plan = nil if target > idx then st.level = nil end -- re-read the stats: new defence t.send('R'); goto continue end st.shopped = true; t.send('R'); goto continue end if has(s, 'Slaughter Other Players') and has(last, '(S,L,E,W,R)') and has(last, 'Your command') then st.pvp_fight = nil if not want_pvp(st) then if not st.pvp_done and st.pvp_listed then log('player fights over:', not st.fairy and 'fairy used' or (st.pfights or 0) < 1 and 'no fights left' or 'stopping') end st.pvp_done = true; t.send('R'); goto continue end if not st.pvp_listed then st.pvp_listed = true; st.pvp_listing = true; t.send('L', 2) read_rankings(st, t.text()); st.pvp_listing = false local seen = {} for n, p in pairs(st.pvp_roster) do seen[#seen + 1] = string.format('%s (level %d%s%s)', n, p.level, p.alive and '' or ', dead', p.on and ', online' or '') end log('warriors in the field:', #seen > 0 and table.concat(seen, ', ') or 'none'); goto continue end st.pvp_target = pvp_target(st) if not st.pvp_target then log('player fights: nobody left to attack'); st.pvp_done = true; t.send('R'); goto continue end st.pvp_tried = st.pvp_tried or {}; table.insert(st.pvp_tried, st.pvp_target) log('player fight: looking for', st.pvp_target); t.send('S', 2); goto continue end if st.pvp_target and has(t.tail(6), 'Who would you like to attack') and re(lastl, [[NAME:\s*$]]) then t.send(st.pvp_target .. '\r', 2); goto continue end do local meant = rmatch(lastl, [[You mean "(.+?)"\?]]) if meant and st.pvp_target then t.send(meant:lower() == st.pvp_target:lower() and 'Y' or 'N', 2); goto continue end local who = rmatch(lastl, [[Attack (.+?) \[Y\] ?:\s*$]]) if who and st.pvp_target then if who:lower() ~= st.pvp_target:lower() then log(' not attacking', who); t.send('N', 2); goto continue end st.pvp_fight = st.pvp_roster[st.pvp_target].level; st.pfights = st.pfights - 1 log(' attacking', st.pvp_target); t.send('Y', 2); goto continue end end if has(last, 'Where do we go now') then local mem = load_state(); local hint = mem.rescue_hint; mem.rescue_hint = nil local castle = CASTLES[hint or ''] and hint or CASTLE_NAMES[math.random(#CASTLE_NAMES)] log('rescue quest: going to ' .. castle, hint and "(Olivia's hint)" or '(no hint - guessing)') save_state(mem); t.send(CASTLES[castle], 2); goto continue end do local fr = fresh(t.tail(14)) for _, ev in ipairs(EVENTS) do if re(fr, ev[1]) then local key = ev[2] if #key > 1 then local i = math.random(#key); key = key:sub(i, i) end local note = ev[3] if ev[1]:find('blessing', 1, true) and st.fairy then key, note = 'A', 'fairies: one already in the pocket - ask for a blessing' end if ev[1]:find('gnore the', 1, true) and not load_state().rescue_hint then key, note = 'I', 'rescue quest: no hint from Olivia - ignore it' end log('event:', note, string.format('-> %q', key)); t.send(key, 2); goto continue end end end -- LORD prompts with a default like "[Y] :" -> accept the default if re(lastl, [[\[[A-Z]\] ?:?\s*$]]) then log('default prompt:', last_lines(last, 2)); t.send('\r'); goto continue end pump(2) ::continue:: end log('gave up after 2000 steps') end -- ---------------------------------------------------------------- start math.randomseed(os.time()) local who = (bbs.user or ''):lower() PROF = PROFILES[who] if not PROF then local name = input('LORD character name for this BBS user') if not name or name == '' then return end PROF = {name = name, gender = confirm('Is ' .. name .. ' female?') and 'F' or 'M'} end FEMALE = PROF.gender == 'F' LOVER = FEMALE and 'Seth Able' or 'Violet' STATE = HERE .. 'lordbot-' .. PROF.name:lower() .. '-state.lua' local LOGFILE = HERE .. 'lordbot-' .. PROF.name:lower() .. '-' .. os.date('%Y-%m-%d') .. '.log' logf = io.open(LOGFILE, 'a') log('==== start (SyncTERM)', PROF.name) -- Olivia's hint names where the prisoner is in THIS LORD session only: LORD picks -- the castle at random each time it starts (and again after a rescue). A hint -- kept from an earlier run sends you to the wrong castle - and a Holl. do local mem = load_state() if mem.rescue_hint then log('forgetting old rescue hint (' .. mem.rescue_hint .. '): it only holds for the LORD session it was given in') mem.rescue_hint = nil; save_state(mem) end end local ok, err = pcall(main) if ok then log('session end') message('LORD day finished for ' .. PROF.name .. '. Log: ' .. LOGFILE) else log('ERROR', tostring(err)) if logf then logf:close() end error(err, 0) end if logf then logf:close() end