--[[ AutoSMN - 召喚士専用 自己完結型ローテーションLua 段階1(完了): スタートボタンUI + status changeによる戦闘検知 段階2(完了): 召喚獣選択UI(クリック階層方式) 段階3(今回追加): rotate_tick() の実装 - 選択済み召喚獣の中から、「最後にバフを掛け直してから最も時間が 経っている(=バフが最も早く切れている可能性が高い)召喚獣」を優先して選ぶ - 優先度が同点の場合は、チェックした順番(先にチェックしたものを優先) - 選ばれた召喚獣が今召喚中の獣と違えば、帰還→召喚→験術(support)→ 幻術(attack)→神獣の攻撃、の順で実行する(ws_link_config.jsonと同じ 「delayを付けて時間差で command を送る」方式をこのLua内で再現) - 同じ獣が連続で選ばれた場合は、帰還/召喚を省略してアビリティだけ撃ち直す 重要な注意(実装上の限界について正直に書いておきます): - このLuaは、バフの残り時間を実際のゲーム内バフタイマーから読み取っては いません(検証が難しく、誤ったバフIDを掴むと誤動作するため)。 代わりに「最後にこのLua自身がその召喚獣のバフを掛け直した時刻」を 記録し、それが一番古い召喚獣を優先する、という近似で代用しています。 アビリティごとに実際の効果時間が違うため、この優先順位は目安であり、 「本当に効果が切れているか」を正確に保証するものではありません。 - 契約の履行は「幻術(攻撃)」と「験術(支援)」でそれぞれ別枠のリキャスト (共有1分)を持つため、1回のローテーションで各カテゴリ1つずつしか 実行しません(複数チェックしていても、一覧の先頭から見つかった 最初の験術・幻術・神獣の攻撃のみを実行します)。 - アビリティ名(en)はAVATARSテーブルにサンプルとして入れていますが、 ws_link_config.jsonの実データで確認できたもの以外(タイタン以降)は カタカナ表記からの逆算です。実際に発動しない場合は、そのアビリティの `en`の綴りを修正してください。 --]] _addon.name = 'AutoSMN' _addon.author = 'AutoSMN' _addon.commands = {'smn'} _addon.version = '0.3.0' require('pack') require('lists') require('tables') require('strings') texts = require('texts') config = require('config') res = require('resources') -- ============================================================ -- 定数 -- ============================================================ -- 監視間隔は settings.rotate_interval / settings.combat_interval で変更する -- (//smn interval <秒> / //smn cinterval <秒>) local BUTTON_W, BUTTON_H = 140, 30 local ROW_W, ROW_H = 260, 18 -- ============================================================ -- 召喚獣データ -- -- kind: 'ward'(験術/支援。験術リキャスト枠を共有) / 'rage'(幻術/攻撃。 -- 幻術リキャスト枠を共有) / 'assault'(神獣の攻撃。ペットコマンドで -- 別枠のリキャスト) -- target: 'me'(自分に詠唱、効果は周囲に範囲展開) / 'bt'(現在の戦闘対象) -- en: /ja コマンドで使う英語名(Windowerの内部名照合用) -- ============================================================ AVATARS = { {id = 'carbuncle', name_jp = 'カーバンクル', name_en = 'Carbuncle', abilities = { {name = 'ルビーの煌き', en = "Glittering Ruby", kind = 'ward', duration = 180, target = 'me'}, {name = 'ルビーの癒し', en = "Healing Ruby", kind = 'ward', duration = 180, target = 'me'}, {name = 'ルビーの癒しII', en = "Healing Ruby II", kind = 'ward', duration = 180, target = 'me'}, {name = 'ルビーの贖罪', en = "Pacifying Ruby", kind = 'ward', duration = 180, target = 'me'}, {name = 'ルビーの輝き', en = "Shining Ruby", kind = 'ward', duration = 180, target = 'me'}, {name = 'ルビーの安らぎ', en = "Soothing Ruby", kind = 'ward', duration = 180, target = 'me'}, {name = 'プチメテオ', en = "Meteorite", kind = 'rage', target = 'bt'}, {name = 'ポイズンネイル', en = "Poison Nails", kind = 'rage', target = 'bt'}, {name = '神獣の攻撃', en = "Assault", kind = 'assault', target = 'bt'}, }}, {id = 'ifrit', name_jp = 'イフリート', name_en = 'Ifrit', abilities = { {name = '紅蓮の咆哮', en = "Crimson Howl", buff_name = "Warcry", kind = 'ward', duration = 180, target = 'me'}, {name = 'バーニングストライク', en = "Burning Strike", kind = 'rage', target = 'bt'}, {name = 'コンフラグストライク', en = "Conflag Strike", kind = 'rage', target = 'bt'}, {name = 'ダブルパンチ', en = "Double Punch", kind = 'rage', target = 'bt'}, {name = 'ファイアII', en = "Fire II", kind = 'rage', target = 'bt'}, {name = 'ファイアIV', en = "Fire IV", kind = 'rage', target = 'bt'}, {name = 'フレイムクラッシュ', en = "Flaming Crush", kind = 'rage', target = 'bt'}, {name = 'メテオストライク', en = "Meteor Strike", kind = 'rage', target = 'bt'}, {name = 'パンチ', en = "Punch", kind = 'rage', target = 'bt'}, {name = '神獣の攻撃', en = "Assault", kind = 'assault', target = 'bt'}, }}, {id = 'titan', name_jp = 'タイタン', name_en = 'Titan', abilities = { {name = '大地の鎧', en = "Earthen Armor", kind = 'ward', duration = 60, target = 'me'}, {name = '大地の守り', en = "Earthen Ward", buff_name = "Stoneskin", kind = 'ward', duration = 900, target = 'me'}, {name = 'クラッグスロー', en = "Crag Throw", kind = 'rage', target = 'bt'}, {name = 'ジオクラッシュ', en = "Geocrush", kind = 'rage', target = 'bt'}, {name = 'メガリススロー', en = "Megalith Throw", kind = 'rage', target = 'bt'}, {name = 'マウンテンバスター', en = "Mountain Buster", kind = 'rage', target = 'bt'}, {name = 'ロックバスター', en = "Rock Buster", kind = 'rage', target = 'bt'}, {name = 'ロックスロー', en = "Rock Throw", kind = 'rage', target = 'bt'}, {name = 'ストーンII', en = "Stone II", kind = 'rage', target = 'bt'}, {name = 'ストーンIV', en = "Stone IV", kind = 'rage', target = 'bt'}, {name = '神獣の攻撃', en = "Assault", kind = 'assault', target = 'bt'}, }}, {id = 'leviathan', name_jp = 'リヴァイアサン', name_en = 'Leviathan', abilities = { {name = 'スロウガ', en = "Slowga", buff_name = false, kind = 'ward', target = 'bt'}, -- 敵への弱体なので自分のバフとしては追跡しない {name = 'スージングカレント', en = "Soothing Current", kind = 'ward', duration = 180, target = 'me'}, {name = '湧水', en = "Spring Water", kind = 'ward', duration = 180, target = 'me'}, {name = 'タイダルロア', en = "Tidal Roar", kind = 'ward', duration = 180, target = 'me'}, {name = 'バラクーダダイブ', en = "Barracuda Dive", kind = 'rage', target = 'bt'}, {name = 'グランドフォール', en = "Grand Fall", kind = 'rage', target = 'bt'}, {name = 'スピニングダイブ', en = "Spinning Dive", kind = 'rage', target = 'bt'}, {name = 'テールウィップ', en = "Tail Whip", kind = 'rage', target = 'bt'}, {name = 'ウォータII', en = "Water II", kind = 'rage', target = 'bt'}, {name = 'ウォータIV', en = "Water IV", kind = 'rage', target = 'bt'}, {name = '神獣の攻撃', en = "Assault", kind = 'assault', target = 'bt'}, }}, {id = 'garuda', name_jp = 'ガルーダ', name_en = 'Garuda', abilities = { {name = '真空の鎧', en = "Aerial Armor", buff_name = "Blink", kind = 'ward', duration = 900, target = 'me'}, {name = '真空の具足', en = "Fleet Wind", kind = 'ward', duration = 120, target = 'me'}, {name = 'ヘイスガ', en = "Hastega", buff_name = "Haste", kind = 'ward', duration = 180, target = 'me'}, {name = 'ヘイスガII', en = "Hastega II", buff_name = "Haste", kind = 'ward', duration = 180, target = 'me'}, {name = '風の囁き', en = "Whispering Wind", kind = 'ward', duration = 180, target = 'me'}, {name = '風の守り', en = "Wind's Blessing", kind = 'ward', duration = 180, target = 'me'}, {name = 'エアロII', en = "Aero II", kind = 'rage', target = 'bt'}, {name = 'エアロIV', en = "Aero IV", kind = 'rage', target = 'bt'}, {name = 'プレデタークロー', en = "Predator Claws", kind = 'rage', target = 'bt'}, {name = 'トルネドII', en = "Tornado II", kind = 'rage', target = 'bt'}, {name = '神獣の攻撃', en = "Assault", kind = 'assault', target = 'bt'}, }}, {id = 'shiva', name_jp = 'シヴァ', name_en = 'Shiva', abilities = { {name = 'クリスタルブレシング', en = "Crystal Blessing", kind = 'ward', duration = 180, target = 'me'}, {name = 'ダイヤモンドストーム', en = "Diamond Storm", kind = 'ward', duration = 180, target = 'me'}, {name = '凍てつく鎧', en = "Frost Armor", buff_name = "Ice Spikes", kind = 'ward', duration = 180, target = 'me'}, {name = 'スリプガ', en = "Sleepga", buff_name = false, kind = 'ward', target = 'bt'}, -- 敵への弱体なので自分のバフとしては追跡しない {name = 'アクスキック', en = "Axe Kick", kind = 'rage', target = 'bt'}, {name = 'ブリザドII', en = "Blizzard II", kind = 'rage', target = 'bt'}, {name = 'ブリザドIV', en = "Blizzard IV", kind = 'rage', target = 'bt'}, {name = 'ダブルスラップ', en = "Double Slap", kind = 'rage', target = 'bt'}, {name = 'ヘヴンリーストライク', en = "Heavenly Strike", kind = 'rage', target = 'bt'}, {name = 'ラッシュ', en = "Rush", kind = 'rage', target = 'bt'}, {name = '神獣の攻撃', en = "Assault", kind = 'assault', target = 'bt'}, }}, {id = 'ramuh', name_jp = 'ラムウ', name_en = 'Ramuh', abilities = { {name = '雷電の鎧', en = "Lightning Armor", buff_name = "Shock Spikes", kind = 'ward', duration = 180, target = 'me'}, {name = '雷鼓', en = "Rolling Thunder", buff_name = "Enthunder", kind = 'ward', duration = 120, target = 'me'}, {name = 'スタンガ', en = "Shock Squall", kind = 'ward', duration = 180, target = 'me'}, {name = 'カオスストライク', en = "Chaotic Strike", kind = 'rage', target = 'bt'}, {name = 'ショックストライク', en = "Shock Strike", kind = 'rage', target = 'bt'}, {name = 'サンダーII', en = "Thunder II", kind = 'rage', target = 'bt'}, {name = 'サンダーIV', en = "Thunder IV", kind = 'rage', target = 'bt'}, {name = 'サンダースパーク', en = "Thunderspark", kind = 'rage', target = 'bt'}, {name = 'サンダーストーム', en = "Thunderstorm", kind = 'rage', target = 'bt'}, {name = 'ボルトストライク', en = "Volt Strike", kind = 'rage', target = 'bt'}, {name = '神獣の攻撃', en = "Assault", kind = 'assault', target = 'bt'}, }}, {id = 'fenrir', name_jp = 'フェンリル', name_en = 'Fenrir', abilities = { {name = '上弦の唸り', en = "Ecliptic Growl", kind = 'ward', duration = 180, target = 'me'}, {name = '下弦の咆哮', en = "Ecliptic Howl", kind = 'ward', duration = 180, target = 'me'}, {name = 'ヘヴンズハウル', en = "Heavenward Howl", kind = 'ward', duration = 180, target = 'me'}, {name = 'ルナークライ', en = "Lunar Cry", kind = 'ward', duration = 180, target = 'me'}, {name = 'ルナーロア', en = "Lunar Roar", kind = 'ward', duration = 180, target = 'me'}, {name = 'クレセントファング', en = "Crescent Fang", kind = 'rage', target = 'bt'}, {name = 'エクリプスバイト', en = "Eclipse Bite", kind = 'rage', target = 'bt'}, {name = 'インパクト', en = "Impact", kind = 'rage', target = 'bt'}, {name = 'ルナーベイ', en = "Lunar Bay", kind = 'rage', target = 'bt'}, {name = 'ムーンリットチャージ', en = "Moonlit Charge", kind = 'rage', target = 'bt'}, {name = '神獣の攻撃', en = "Assault", kind = 'assault', target = 'bt'}, }}, {id = 'diabolos', name_jp = 'ディアボロス', name_en = 'Diabolos', abilities = { {name = 'ドリームシュラウド', en = "Dream Shroud", kind = 'ward', duration = 180, target = 'me'}, {name = 'ナイトメア', en = "Nightmare", kind = 'ward', duration = 180, target = 'me'}, {name = 'ノクトシールド', en = "Noctoshield", kind = 'ward', duration = 180, target = 'me'}, {name = 'パボルノクターナス', en = "Pavor Nocturnus", kind = 'ward', duration = 180, target = 'me'}, {name = 'ソムノレンス', en = "Somnolence", kind = 'ward', duration = 180, target = 'me'}, {name = 'アルティメットテラー', en = "Ultimate Terror", kind = 'ward', duration = 180, target = 'me'}, {name = 'ブラインドサイド', en = "Blindside", kind = 'rage', target = 'bt'}, {name = 'カコデモニア', en = "Cacodemonia", kind = 'rage', target = 'bt'}, {name = 'カミサドー', en = "Camisado", kind = 'rage', target = 'bt'}, {name = 'ネザーブラスト', en = "Nether Blast", kind = 'rage', target = 'bt'}, {name = 'ナイトテラー', en = "Night Terror", kind = 'rage', target = 'bt'}, {name = '神獣の攻撃', en = "Assault", kind = 'assault', target = 'bt'}, }}, {id = 'cait_sith', name_jp = 'ケット・シー', name_en = 'Cait Sith', abilities = { {name = 'イアリーアイ', en = "Eerie Eye", kind = 'ward', duration = 180, target = 'me'}, {name = 'ミュインララバイ', en = "Mewing Lullaby", kind = 'ward', duration = 180, target = 'me'}, {name = 'レイズII', en = "Raise II", buff_name = false, kind = 'ward', target = 'me'}, -- 蘇生用。バフではないので自動ローテーションの対象外 {name = 'リレイズII', en = "Reraise II", buff_name = "Reraise", kind = 'ward', duration = 180, target = 'me'}, {name = 'ホーリーミスト', en = "Holy Mist", kind = 'rage', target = 'bt'}, {name = 'レベル?ホーリー', en = "Level ? Holy", kind = 'rage', target = 'bt'}, {name = 'リーガルガッシュ', en = "Regal Gash", kind = 'rage', target = 'bt'}, {name = 'リーガルスクラッチ', en = "Regal Scratch", kind = 'rage', target = 'bt'}, {name = '神獣の攻撃', en = "Assault", kind = 'assault', target = 'bt'}, }}, {id = 'siren', name_jp = 'セイレーン', name_en = 'Siren', abilities = { {name = '修羅のエレジー', en = "Bitter Elegy", kind = 'ward', duration = 180, target = 'me'}, {name = 'シヌーク', en = "Chinook", kind = 'ward', duration = 180, target = 'me'}, {name = '疾風の刃', en = "Katabatic Blades", kind = 'ward', duration = 180, target = 'me'}, {name = 'ルナティックボイス', en = "Lunatic Voice", kind = 'ward', duration = 180, target = 'me'}, {name = 'クロー', en = "Claw", kind = 'rage', target = 'bt'}, {name = 'ヒステリックアサルト', en = "Hysteric Assault", kind = 'rage', target = 'bt'}, {name = 'ラウンドハウス', en = "Roundhouse", kind = 'rage', target = 'bt'}, {name = 'ソニックバフェット', en = "Sonic Buffet", kind = 'rage', target = 'bt'}, {name = 'ウェルト', en = "Welt", kind = 'rage', target = 'bt'}, {name = 'ウインドブレード', en = "Wind Blade", kind = 'rage', target = 'bt'}, {name = '神獣の攻撃', en = "Assault", kind = 'assault', target = 'bt'}, }}, } -- 精霊(エレメントサイフォン用)。UIでチェックしたものを順に使う。 SPIRITS = { {id = 'fire_spirit', name_jp = '火精霊', name_en = 'Fire Spirit'}, {id = 'ice_spirit', name_jp = '氷精霊', name_en = 'Ice Spirit'}, {id = 'air_spirit', name_jp = '風精霊', name_en = 'Air Spirit'}, {id = 'earth_spirit', name_jp = '土精霊', name_en = 'Earth Spirit'}, {id = 'thunder_spirit', name_jp = '雷精霊', name_en = 'Thunder Spirit'}, {id = 'water_spirit', name_jp = '水精霊', name_en = 'Water Spirit'}, {id = 'light_spirit', name_jp = '光精霊', name_en = 'Light Spirit'}, {id = 'dark_spirit', name_jp = '闇精霊', name_en = 'Dark Spirit'}, } local SPIRITS_BY_ID = {} for _, s in ipairs(SPIRITS) do SPIRITS_BY_ID[s.id] = s end local AVATARS_BY_ID = {} local NAME_TO_ID = {} for _, a in ipairs(AVATARS) do AVATARS_BY_ID[a.id] = a -- windower.ffxi.get_mob_by_target('pet').name はクライアント言語に関わらず -- 英語名で返ってくるため、name_en をキーにする(name_jp では一致しない)。 -- 念のため name_jp も登録しておく(将来的な仕様変化への保険)。 NAME_TO_ID[a.name_en] = a.id NAME_TO_ID[a.name_jp] = a.id end -- 設定ファイル(XML)のキーとして使える文字列に変換する。 -- Windowerの設定保存はテーブルのキーをそのままXMLのタグ名にするため、 -- 空白・記号・日本語が含まれると保存されたXMLが壊れて次回読み込めなくなる。 -- 例: 'Hastega II' -> 'HastegaII' / 'Crimson Howl' -> 'CrimsonHowl' local function ability_key(ability) return (ability.en:gsub('[^%a%d]', '')) end -- ============================================================ -- 設定(保存対象) -- ============================================================ default = { active = false, debug = false, use_favor = true, -- 神獣の加護(Avatar's Favor)がかかっていない時に自動で掛けるか auto_assault = true, -- 召喚獣が何もしていない時に自動で攻撃指示を出すか auto_rage = true, -- 幻術(攻撃履行)をリキャストが明けるたびに撃つか auto_cure = true, -- 自分/パーティメンバーのHPが一定割合を下回ったらケアルを使うか cure_hp_pct = 70, -- ケアルを使うHPの割合(%) cure_spell = 'Cure IV', -- 使用するケアルの呪文名 auto_convert = true, -- MPが一定割合を下回ったらコンバートを使うか convert_mp_pct = 10, -- コンバートを使うMPの割合(%) convert_cure = 'Cure IV', -- コンバート直後に自分に掛ける回復魔法 convert_cure_count = 2, -- 上記を掛ける回数 auto_siphon = true, -- エレメントサイフォンをリキャストごとに使うか siphon_delay = 10, -- 精霊を呼んでからエレメントサイフォンを使うまでの秒数 siphon_mp_pct = 50, -- エレメントサイフォンを使うMPの割合(%)。これ以上残っていれば発動しない siphon_grace = 15, -- 戦闘開始からこの秒数が経つまではサイフォンを控える rotate_interval = 60, -- 験術(支援)のバフ切れを確認する間隔(秒) combat_interval = 2, -- 幻術・攻撃指示などを確認する間隔(秒) selected_spirits = {}, -- [spirit_id] = true/false pos = {x = 1280, y = 670}, panel_pos = {x = 1280, y = 700}, selected_avatars = {}, enabled_abilities = {}, avatar_last_cast = {}, -- [avatar_id] = os.time()(最後にバフを掛け直した時刻) avatar_check_order = {}, -- [avatar_id] = チェックした順番(小さいほど先) avatar_check_counter = 0, buff_name_override = {}, -- [アビリティキー] = 手動で指定したバフの正式名称(自動判定が誤っていた場合用) ward_last_cast = {}, -- [アビリティキー] = 最後にその験術を撃った時刻(タイマー方式の起点) duration_override = {}, -- [アビリティキー] = 手動で指定した持続時間(秒)。ゲーム内で確認した実測値用 text = { text = {size = 12, font = 'MS Gothic'}, bg = {visible = true, alpha = 200, red = 0, green = 0, blue = 0}, flags = {bold = true, draggable = false}, }, row_text = { text = {size = 11, font = 'MS Gothic'}, bg = {visible = true, alpha = 160, red = 0, green = 0, blue = 0}, flags = {bold = false, draggable = false}, }, } -- 設定ファイル名を既定の data/settings.xml から変更している。 -- 旧バージョンで壊れた settings.xml が残っていると読み込み時にXMLエラーで -- アドオンごと起動しなくなるため、別名の新しいファイルを使う。 -- 万一この設定ファイルも壊れた場合でも、アドオン自体は初期設定で起動するようにする -- (設定の保存はできなくなるが、UIやローテーションは動く)。 -- ============================================================ -- ログ(チャット表示 + ファイル書き出し) -- -- 画面のチャットログはタイミングよくスクリーンショットを撮らないと -- 後から追えないため、同じ内容を data/autosmn_log.txt にも書き出す。 -- プレイ後にこのファイルをそのまま渡してもらえば、実際に何が起きたか -- 時系列で確認できる。 -- ============================================================ local LOG_PATH_CACHED = nil local LOG_MAX_BYTES = 2 * 1024 * 1024 -- 2MB を超えたら古い分を捨てて書き直す -- 6キャラで同じアドオンフォルダを共有していると、ファイル名が固定だと -- 全員分のログが1つのファイルに混ざってしまい、「別キャラが同時に -- 別々の動きをしている」だけなのに「同じキャラでおかしな重なりが -- 起きている」ように見えてしまう。キャラ名が取得でき次第、 -- そのキャラ専用のファイル名に切り替える。 local function get_log_path() if LOG_PATH_CACHED then return LOG_PATH_CACHED end local player = windower.ffxi.get_player() local name = player and player.name if not name or name == '' then -- まだキャラ名が取れない(ロード直後など)場合の暫定パス。 -- キャッシュはせず、次回また名前取得を試みる。 return windower.addon_path .. 'data/autosmn_log_unknown.txt' end LOG_PATH_CACHED = windower.addon_path .. 'data/autosmn_log_' .. name .. '.txt' return LOG_PATH_CACHED end local function log_to_file(msg) local ok, f = pcall(io.open, get_log_path(), 'a') if ok and f then f:write(('[%s] %s\n'):format(os.date('%Y-%m-%d %H:%M:%S'), msg)) f:close() end end local function trim_log_if_needed() local path = get_log_path() local f = io.open(path, 'r') if not f then return end local size = f:seek('end') f:close() if size and size > LOG_MAX_BYTES then -- 古い前半を切り捨てて、直近分だけ残す local rf = io.open(path, 'r') local content = rf:read('*a') rf:close() local keep = content:sub(-math.floor(LOG_MAX_BYTES / 2)) local wf = io.open(path, 'w') if wf then wf:write('...(古いログを切り捨て)...\n') wf:write(keep) wf:close() end end end local function dbg(msg) windower.add_to_chat(207, msg) log_to_file(msg) end local function dbgerr(msg) windower.add_to_chat(167, msg) log_to_file('!! ' .. msg) end local ok, loaded = pcall(config.load, 'data/smn_config.xml', default) if ok then settings = loaded else settings = default settings.save = function() end dbgerr('AutoSMN: config load failed - running with defaults') end -- 過去バージョン(古い学習方式)の設定が残っていても問題ないよう、 -- 新しいバージョンで必ず使うフィールドが無ければ補っておく。 if not settings.buff_name_override then settings.buff_name_override = {} end if not settings.ward_last_cast then settings.ward_last_cast = {} end if not settings.duration_override then settings.duration_override = {} end if not settings.siphon_mp_pct then settings.siphon_mp_pct = 50 end if not settings.siphon_grace then settings.siphon_grace = 15 end if settings.auto_cure == nil then settings.auto_cure = true end trim_log_if_needed() log_to_file('=== AutoSMN loaded ===') if not settings.cure_hp_pct then settings.cure_hp_pct = 70 end if not settings.cure_spell then settings.cure_spell = 'Cure IV' end -- ============================================================ -- 内部状態 -- ============================================================ local rotation_running = false local switching_avatar = false -- 帰還→召喚→験術の一連が終わるまでtrue。サイフォンの割り込みを防ぐ local switching_avatar_deadline = 0 -- 万一クリアし忘れても、この時刻を過ぎたら自動的に解除する保険 local last_pet_command_at = 0 -- 直近で帰還/召喚コマンドを送った時刻。switching_avatarとは独立の保険 local combat_started_at = nil -- 現在の戦闘に入った時刻(サイフォンの猶予判定に使う) local next_tick = os.time() local current_view = 'avatars' local row_data = {} local current_avatar_id = nil local last_assault = 0 -- 神獣の攻撃を最後に指示した時刻(連打防止) local action_queue = {} -- {at = 実行予定時刻, fn = 実行内容, guard = 実行可否の判定} -- ============================================================ -- スケジューラ -- -- 各ステップは実行直前に guard() で条件を確認する。 -- 条件を満たしていなければ、そのステップは実行せずに後ろへ持ち越す(最大 retry_until まで)。 -- 例: 召喚獣がまだ出ていないのに履行を撃たない、リキャストが明けるまで待つ、など。 -- ============================================================ local function enqueue(delay_seconds, fn, guard, retry_seconds) local now = os.time() table.insert(action_queue, { at = now + delay_seconds, fn = fn, guard = guard, -- 通常は20秒待っても条件が整わなければ諦める。 -- 験術のように「種類に関係なく1つのリキャスト(最大60秒程度)を -- 共有している」ステップは、これより長く粘らないと、前の験術の -- リキャストが明けきる前にタイムアウトしてしまう。 retry_until = now + delay_seconds + (retry_seconds or 20), }) end local function process_queue() local now = os.time() while action_queue[1] and action_queue[1].at <= now do local item = action_queue[1] if item.guard and not item.guard() then if now >= item.retry_until then -- 条件が整わないまま時間切れ。このステップは飛ばす dbgerr('AutoSMN: step timed out waiting for guard, skipped') table.remove(action_queue, 1) else -- まだ待つ。1秒後に再判定する item.at = now + 1 end return end table.remove(action_queue, 1) item.fn() end end -- 召喚獣が実際に出ているか local function pet_ready() local pet = windower.ffxi.get_mob_by_target('pet') return pet ~= nil and pet.hpp and pet.hpp > 0 end -- 自分が行動可能か(詠唱中やイベント中は待つ) local function player_free() local player = windower.ffxi.get_player() if not player then return false end return player.status == 0 or player.status == 1 end -- 現在自分に付いているバフIDの集合 local function current_buff_set() local player = windower.ffxi.get_player() local set = {} if player and player.buffs then for _, id in ipairs(player.buffs) do set[id] = true end end return set end -- アビリティ名から、リキャスト確認に使うIDを引く。 -- IDを決め打ちすると誤ったものを掴む危険があるため、リソースから名前で照合する。 local ability_id_cache = {} local function ability_recast_id(en_name) if ability_id_cache[en_name] ~= nil then return ability_id_cache[en_name] or nil end for _, a in pairs(res.job_abilities) do if a.en == en_name then ability_id_cache[en_name] = a.recast_id return a.recast_id end end ability_id_cache[en_name] = false return nil end -- アビリティ名から、実際にゲームが送ってくる action パケットの param と -- 照合するための「アビリティID」を引く。 -- -- これが無いと、'action' イベントが「召喚獣が何か技を使った」という -- カテゴリ(13)だけで判定してしまい、戦闘中に自動発動するRage(攻撃技)や -- 神獣の攻撃(Assault)まで拾ってしまう。実際、Hastega II を撃った直後に -- 別途自動発動していた Predator Claws(Rage)の方を誤って検知していた -- ことが //smn dumpaction で確認できた。これが学習が安定しなかった -- 本当の原因だった。 local ability_pid_cache = {} local function ability_param_id(en_name) if ability_pid_cache[en_name] ~= nil then return ability_pid_cache[en_name] or nil end for id, a in pairs(res.job_abilities) do if a.en == en_name then local pid = a.id or id ability_pid_cache[en_name] = pid return pid end end ability_pid_cache[en_name] = false return nil end -- そのアビリティが今すぐ使えるか(リキャストが明けているか) local function ability_ready(en_name) local rid = ability_recast_id(en_name) if not rid then return true end -- 判定できない場合は妨げない local recasts = windower.ffxi.get_ability_recasts() if not recasts then return true end local left = recasts[rid] return left == nil or left == 0 end -- 召喚獣が今なにもしていない(攻撃していない)か local function pet_idle() local pet = windower.ffxi.get_mob_by_target('pet') if not pet then return false end return pet.status ~= 1 -- 1 = 戦闘中 end -- 文字列がすでに正しいUTF-8か判定する(ws_link.luaと同じロジック)。 -- Windowerのリソース(res.buffs等)の日本語文字列は、環境によってShift-JISの -- ままになっていることがあり、そのまま表示すると文字化けする。 local function looks_like_valid_utf8(s) if not s or s == '' then return true end local i, len = 1, #s while i <= len do local b = s:byte(i) if b < 0x80 then i = i + 1 elseif b >= 0xC2 and b <= 0xDF then local b2 = s:byte(i + 1) if not b2 or b2 < 0x80 or b2 > 0xBF then return false end i = i + 2 elseif b >= 0xE0 and b <= 0xEF then local b2, b3 = s:byte(i + 1), s:byte(i + 2) if not b2 or b2 < 0x80 or b2 > 0xBF then return false end if not b3 or b3 < 0x80 or b3 > 0xBF then return false end i = i + 3 elseif b >= 0xF0 and b <= 0xF4 then local b2, b3, b4 = s:byte(i + 1), s:byte(i + 2), s:byte(i + 3) if not b2 or b2 < 0x80 or b2 > 0xBF then return false end if not b3 or b3 < 0x80 or b3 > 0xBF then return false end if not b4 or b4 < 0x80 or b4 > 0xBF then return false end i = i + 4 else return false end end return true end -- 既にUTF-8として正しい文字列は変換しない。Shift-JISに見える(=UTF-8として -- 壊れている)場合だけ変換を試みる。変換後も壊れていれば元の文字列を返す -- (見た目が化けても、少なくとも元の情報は保持する)。 local function safe_from_sjis(s) if not s or s == '' then return s end if looks_like_valid_utf8(s) then return s end local ok, converted = pcall(windower.from_shift_jis, s) if ok and converted and converted ~= '' and looks_like_valid_utf8(converted) then return converted end return s end -- クライアントの言語に合わせたバフ名を返す(cancel.lua と同じ考え方) local client_lang = (windower.ffxi.get_info().language or 'english'):lower() local function buff_name(id) local b = res.buffs[id] if not b then return tostring(id) end local name = b[client_lang] or b.en or tostring(id) return safe_from_sjis(name) end -- ============================================================ -- 履行(験術)が付けるバフを、'action' イベントで正確に検知する -- -- 以前は「撃つ前後のバフ一覧を比較する」方式だったが、常に誰かのバフが -- 飛び交う環境では「増えたバフが1個だけ」という前提が成立せず、 -- ほとんど学習に失敗した。 -- -- 次に 0x028 パケットを自前で解析する方式を試したが、実行者を -- 「プレイヤー自身」、カテゴリを「6 = ジョブアビリティ」として -- 判定していたのが誤りだった。履行(Blood Pact)は実際には -- 「召喚獣(ペット)」が実行者、カテゴリは「13 = ペット技」として -- 記録される(ws_link.lua の event_info() で確認済み)。このため -- 判定条件が一度も成立せず、学習が全く行われていなかった。 -- -- Windower が提供する 'action' イベントは、この解析を既に済ませた -- 形(act.actor_id / act.category / act.param)で渡してくれるので、 -- 生パケットを自分で解析するより確実。ws_link.lua と同じ方式に揃える。 -- ============================================================ -- //smn dumpaction / //smn testward でONにすると、次に「その技」が発動した -- 瞬間の生データをそのままチャットに書き出す。調査用に残してある。 -- dump_expect_en が nil なら次に来た履行を何でも拾う(汎用調査用)。 -- 特定のアビリティ名が入っていれば、そのIDと一致した時だけ拾う -- (戦闘中に自動発動するRageやAssaultを誤って拾わないようにするため)。 dump_next_action = false dump_expect_en = nil -- ============================================================ -- 「アビリティは使えなかった!」の実検知 -- -- 今まで験術(Blood Pact: Ward)は、チャットコマンドを送った時点で無条件に -- 「成功した」ことにして ward_last_cast / avatar_last_cast を記録していた。 -- 実際には麻痺(Numbing Blossom等)や割り込みで弾かれることがあり、その場合は -- 本当は何も掛かっていないのに「対応済み」扱いになってしまっていた。 -- -- ここでは実際のシステムメッセージを見て、直近で送ったアビリティが本当に -- 弾かれていないかを確認できるようにする。 -- ============================================================ local last_ability_fail_at = 0 windower.register_event('incoming text', function(original) local text = safe_from_sjis(original) if text and text:find('アビリティは使えなかった', 1, true) then last_ability_fail_at = os.time() log_to_file('(system) ' .. text) end end) windower.register_event('action', function(act) if not dump_next_action then return end if not act or act.category ~= 13 then return end -- 13 = ペット技(履行含む) local pet = windower.ffxi.get_mob_by_target('pet') if not pet or act.actor_id ~= pet.id then return end -- 自分の召喚獣でなければ無視 local expect_id = dump_expect_en and ability_param_id(dump_expect_en) if not expect_id or act.param == expect_id then dump_next_action = false dbg('AutoSMN: action dump (param=' .. tostring(act.param) .. ')') for ti, tgt in ipairs(act.targets or {}) do dbg((' target[%d] id=%s'):format(ti, tostring(tgt.id))) for ai, a in ipairs(tgt.actions or {}) do local parts = {} for k, v in pairs(a) do parts[#parts + 1] = tostring(k) .. '=' .. tostring(v) end dbg((' action[%d] %s'):format(ai, table.concat(parts, ', '))) end end end end) -- その験術の効果が今も生きているか。 -- -- 以前は「撃った前後でバフが増えたかを見て、その場で学習する」方式だった。 -- しかしこの方式は、常に誰かのバフが飛び交う環境では信頼できないことが -- 分かったため、廃止した。 -- -- 代わりに、各験術が付ける効果の「正式なバフ名」を確認済みのものは -- ability.buff_name に明記し(例: ヘイスガII → "Haste")、 -- 未確認のものはアビリティ自身の名前をバフ名として扱う -- (カーバンクルやイフリートの験術は、効果自体がアビリティ名と同じ名前の -- 状態異常として実装されているケースが多いため)。 -- 実際に付いているかどうかは、都度リソースから名前で直接確認する -- (has_buff と同じ考え方)ので、推測に頼った学習は一切行わない。 -- -- buff_name = false のもの(例: レイズII)は、そもそも「効果が続く」という -- 概念がないアビリティなので、自動ローテーションの対象にしない -- (常に「生きている」扱いにして、自動で撃たれないようにする)。 -- 指定した名前のバフが自分に付いているか。 -- バフIDを直接書くと誤ったIDを掴む危険があるため、リソースから名前で照合する。 local function has_buff(buff_name) local player = windower.ffxi.get_player() if not player or not player.buffs then return false end for _, id in ipairs(player.buffs) do local b = res.buffs[id] if b and (b.en == buff_name or b.ja == buff_name) then return true end end return false end -- その験術の効果が今も生きているか。 -- -- 常に「今バフが付いているか」だけを都度確認する(受動的)方式は、 -- 常時誰かのバフが飛び交う環境では判定がぶれやすいことが分かった。 -- -- そこで、まず「持続時間の見積もり(duration, 秒)」をタイマーとして使い、 -- その時間が経つまでは無条件で「生きている」扱いにする(能動的・先回り)。 -- 見積もり時間を過ぎた頃になって初めて、念のためバフ名でも確認する。 -- こうすることで、見積もりが多少短くても(実際はスキル補正でもっと長く -- 持続していても)、無駄に掛け直すことはない。 -- -- duration が無い(=不明)アビリティは、都度のバフ名確認だけで判定する -- (これまで通りの動作)。 local function ward_active(ability_k, ab) if ab.buff_name == false then return true end local duration = settings.duration_override[ability_k] or ab.duration if duration then local last = settings.ward_last_cast[ability_k] if last and os.time() < last + duration then return true -- タイマー上、まだ生きているはずなので確認不要 end end local name = settings.buff_name_override[ability_k] or ab.buff_name or ab.en return has_buff(name) end -- ============================================================ -- スタートボタン -- ============================================================ local function button_label() return settings.active and ' [ SMN: ON ]' or ' [ SMN: OFF ]' end -- texts.new の第3引数(設定テーブル)は渡さない。 -- 渡すと全ボックスが同じテーブルに座標を保存し合い、互いの位置を上書きして -- 1箇所に重なってしまう(表示が1行しか見えない/バラバラになる原因)。 button_box = texts.new(button_label(), settings.text) button_box:pos(settings.pos.x, settings.pos.y) button_box:show() local function refresh_button() button_box:text(button_label()) end -- ============================================================ -- 選択パネル(ボタンと同じ、行ごとに個別のテキストボックス) -- ============================================================ local MAX_ROWS = 20 -- 最大表示行数(アビリティ数+固定2行に十分な余裕を持たせる) local DUR_W = 46 -- 持続時間表示欄の幅(験術の行にだけ、本体の右側に表示する) row_box = {} dur_box = {} for i = 1, MAX_ROWS do row_box[i] = texts.new('', settings.row_text) row_box[i]:pos(settings.panel_pos.x, settings.panel_pos.y + (i - 1) * ROW_H) row_box[i]:hide() dur_box[i] = texts.new('', settings.row_text) dur_box[i]:pos(settings.panel_pos.x + ROW_W, settings.panel_pos.y + (i - 1) * ROW_H) dur_box[i]:hide() end local function is_avatar_selected(id) return settings.selected_avatars[id] == true end local function is_ability_enabled(avatar_id, ability_name) local t = settings.enabled_abilities[avatar_id] return t ~= nil and t[ability_name] == true end local function toggle_avatar(id) local turning_on = not is_avatar_selected(id) settings.selected_avatars[id] = turning_on if turning_on then settings.enabled_abilities[id] = settings.enabled_abilities[id] or {} settings.avatar_check_counter = (settings.avatar_check_counter or 0) + 1 settings.avatar_check_order[id] = settings.avatar_check_counter end settings:save() end local function toggle_ability(avatar_id, ability_name) settings.enabled_abilities[avatar_id] = settings.enabled_abilities[avatar_id] or {} local t = settings.enabled_abilities[avatar_id] t[ability_name] = not (t[ability_name] == true) settings:save() end local render_view local function open_avatar(id) if not is_avatar_selected(id) then toggle_avatar(id) end current_view = id render_view() end render_view = function() row_data = {} if current_view == 'avatars' then for _, avatar in ipairs(AVATARS) do local mark = is_avatar_selected(avatar.id) and '[x]' or '[ ]' row_data[#row_data + 1] = { label = mark .. ' ' .. avatar.name_jp, onclick = function() open_avatar(avatar.id) end, } end row_data[#row_data + 1] = { label = '>> エレメントサイフォン', onclick = function() current_view = 'spirits'; render_view() end, } row_data[#row_data + 1] = { label = (settings.auto_convert and '[x]' or '[ ]') .. ' コンバート自動使用', onclick = function() settings.auto_convert = not settings.auto_convert settings:save(); render_view() end, } row_data[#row_data + 1] = { label = (settings.auto_cure and '[x]' or '[ ]') .. ' ケアル自動使用', onclick = function() settings.auto_cure = not settings.auto_cure settings:save(); render_view() end, } elseif current_view == 'spirits' then row_data[#row_data + 1] = { label = '<< 戻る', onclick = function() current_view = 'avatars'; render_view() end, } row_data[#row_data + 1] = { label = (settings.auto_siphon and '[x]' or '[ ]') .. ' サイフォンを使う', onclick = function() settings.auto_siphon = not settings.auto_siphon settings:save(); render_view() end, } for _, s in ipairs(SPIRITS) do local mark = settings.selected_spirits[s.id] and '[x]' or '[ ]' row_data[#row_data + 1] = { label = mark .. ' ' .. s.name_jp, onclick = function() settings.selected_spirits[s.id] = not settings.selected_spirits[s.id] settings:save(); render_view() end, } end else local avatar = AVATARS_BY_ID[current_view] row_data[#row_data + 1] = { label = '<< 戻る', onclick = function() current_view = 'avatars'; render_view() end, } row_data[#row_data + 1] = { label = (is_avatar_selected(avatar.id) and '[x]' or '[ ]') .. ' この召喚獣を使う', onclick = function() toggle_avatar(avatar.id); render_view() end, } for _, ability in ipairs(avatar.abilities) do local mark = is_ability_enabled(avatar.id, ability_key(ability)) and '[x]' or '[ ]' local row = { label = mark .. ' ' .. ability.name, onclick = function() toggle_ability(avatar.id, ability_key(ability)); render_view() end, } -- 験術(バフ追跡対象のもの)には、持続時間をクリックで切り替えられる -- 表示を右側に付ける。クリックするたびに 1分ずつ増え、10分を -- 超えたら「自動」(こちらが調べた推定値を使う)に戻る。 if ability.kind == 'ward' and ability.buff_name ~= false then local k = ability_key(ability) local override_sec = settings.duration_override[k] row.dur_label = override_sec and (('%d分'):format(override_sec / 60)) or (('自動%d分'):format((ability.duration or 180) / 60)) row.dur_onclick = function() local cur = settings.duration_override[k] local new_val if not cur then new_val = 60 elseif cur >= 600 then new_val = nil else new_val = cur + 60 end settings.duration_override[k] = new_val dbg(('AutoSMN dbg: dur_onclick key=%s %s -> %s') :format(k, tostring(cur), tostring(new_val))) settings:save() render_view() end end row_data[#row_data + 1] = row end if #avatar.abilities == 0 then row_data[#row_data + 1] = {label = '(アビリティ未設定)', onclick = function() end} end end for i = 1, MAX_ROWS do if row_data[i] then row_box[i]:pos(settings.panel_pos.x, settings.panel_pos.y + (i - 1) * ROW_H) row_box[i]:text(row_data[i].label) row_box[i]:show() if row_data[i].dur_label then dur_box[i]:pos(settings.panel_pos.x + ROW_W, settings.panel_pos.y + (i - 1) * ROW_H) dur_box[i]:text(row_data[i].dur_label) dur_box[i]:show() else dur_box[i]:hide() end else row_box[i]:hide() dur_box[i]:hide() end end end -- ============================================================ -- ジョブ判定 -- ============================================================ local function is_smn() local player = windower.ffxi.get_player() if not player then return false end return player.main_job == 'SMN' or player.sub_job == 'SMN' end local function hide_all() button_box:hide() for i = 1, MAX_ROWS do row_box[i]:hide() dur_box[i]:hide() end end -- 実際に召喚中のペット名から current_avatar_id を同期する(できる範囲で) local function sync_current_avatar() local pet = windower.ffxi.get_mob_by_target('pet') if pet and NAME_TO_ID[pet.name] then current_avatar_id = NAME_TO_ID[pet.name] elseif not pet then current_avatar_id = nil end end -- ============================================================ -- ローテーション本体 -- ============================================================ -- 攻撃対象を探す。 -- 召喚士は構えないため自分の が設定されないことがある。その場合は -- パーティメンバーが戦っている敵を対象にする。 local function find_target() local bt = windower.ffxi.get_mob_by_target('bt') if bt and bt.hpp and bt.hpp > 0 then return bt end -- 自分の召喚獣が既に戦っていれば、その相手 local pet = windower.ffxi.get_mob_by_target('pet') if pet and pet.target_index then local m = windower.ffxi.get_mob_by_index(pet.target_index) if m and m.hpp and m.hpp > 0 and m.valid_target then return m end end -- パーティメンバーが戦っている敵 local party = windower.ffxi.get_party() if party then for _, key in ipairs({'p0','p1','p2','p3','p4','p5'}) do local member = party[key] if member and member.mob and member.mob.status == 1 and member.mob.target_index then local m = windower.ffxi.get_mob_by_index(member.mob.target_index) if m and m.hpp and m.hpp > 0 and m.valid_target and m.spawn_type == 16 then return m end end end end return nil end local function cast_favor_and_ward(avatar_id) local avatar = AVATARS_BY_ID[avatar_id] local t = 0 -- 神獣の加護(Avatar's Favor)は召喚獣を変えても効果が続くため、 -- 召喚のたびに掛け直す必要はない。かかっていない時だけ掛ける。 if settings.use_favor and not has_buff("Avatar's Favor") then enqueue(t, function() windower.chat.input('/ja "Avatar\'s Favor" ') end, function() -- 実行直前にもう一度確認する(この間に他の手段で付いた場合は撃たない) return pet_ready() and player_free() and not has_buff("Avatar's Favor") end) t = t + 5 end -- チェック済みアビリティの中から験術(支援)を1つ選ぶ。 -- 幻術(攻撃)と神獣の攻撃は、リキャストごとに撃つ別ループで処理するため -- ここでは扱わない。 -- -- 「一番最初にチェックしたもの」を機械的に選ぶと、実際に切れているのが -- 2番目以降の験術だった場合、いつまでもそちらが撃たれない -- (常に1番目だけが撃たれ続ける)というバグがあったため、 -- 「切れている(または未学習の)もの」を優先して選ぶようにする。 local ward local ward_fallback for _, ab in ipairs(avatar.abilities) do if ab.kind == 'ward' and is_ability_enabled(avatar_id, ability_key(ab)) then if not ward_fallback then ward_fallback = ab end if not ward and not ward_active(ability_key(ab), ab) then ward = ab end end end ward = ward or ward_fallback dbg('AutoSMN: ward pick for ' .. avatar.name_en .. ' = ' .. (ward and ward.en or '(none enabled)')) -- 履行は召喚獣が出ていて、かつ自分が行動可能で、かつ験術(Blood Pact: Ward) -- 自体のリキャストが明けている時だけ実行する。 -- -- 験術は「どの技か」に関わらず1つの共有リキャストになっている -- (幻術・神獣の攻撃と同じ考え方)。今まではこの確認をしておらず、 -- リキャスト中でも構わず撃とうとして「アビリティは使えなかった!」に -- なっていた可能性が高い。 local pact_guard = function() local pr, pf = pet_ready(), player_free() local ar = (not ward) or ability_ready(ward.en) if not (pr and pf and ar) then local rid = ward and ability_recast_id(ward.en) local recasts = windower.ffxi.get_ability_recasts() local left = rid and recasts and recasts[rid] dbg(('AutoSMN dbg: ward guard pet_ready=%s player_free=%s ward_ready=%s recast_left=%s') :format(tostring(pr), tostring(pf), tostring(ar), tostring(left))) end return pr and pf and ar end if ward then enqueue(t, function() dbg('AutoSMN: >> sending ward ' .. ward.en) if ward.target == 'bt' then -- スロウガ/スリプガなど、敵を対象にする験術。 -- 召喚士は構えないため が設定されていないことがあるので、 -- Rage/Assaultと同じ探索方法で対象を決めてから撃つ。 local bt = windower.ffxi.get_mob_by_target('bt') if bt and bt.hpp and bt.hpp > 0 then windower.chat.input('/ja "' .. ward.en .. '" ') else local tgt = find_target() if tgt then windower.chat.input('/target ' .. tgt.name) enqueue(1, function() windower.chat.input('/ja "' .. ward.en .. '" ') end, function() if not player_free() then return false end local t2 = windower.ffxi.get_mob_by_target('t') return t2 and t2.hpp and t2.hpp > 0 end) end end switching_avatar = false else local send_at = os.time() windower.chat.input('/ja "' .. ward.en .. '" <' .. ward.target .. '>') -- 1.5秒待ってから「アビリティは使えなかった!」が出ていないか確認する。 -- 出ていなければ成功したとみなして記録し、出ていれば弾かれたので -- リトライする(帰還・召喚の失敗検知と同じ考え方)。 local function verify_ward(tries_left) tries_left = tries_left or 3 if last_ability_fail_at >= send_at then if tries_left > 1 then dbgerr('AutoSMN: ward ' .. ward.en .. ' failed (paralyzed?), retrying') enqueue(0, function() send_at = os.time() windower.chat.input('/ja "' .. ward.en .. '" <' .. ward.target .. '>') enqueue(1.5, function() verify_ward(tries_left - 1) end, function() return player_free() end, 20) end, pact_guard, 30) else dbgerr('AutoSMN: ward ' .. ward.en .. ' failed repeatedly, giving up this cycle') switching_avatar = false end else dbg('AutoSMN: ward ' .. ward.en .. ' confirmed sent (no failure message seen)') settings.ward_last_cast[ability_key(ward)] = os.time() settings.avatar_last_cast[avatar_id] = os.time() settings:save() switching_avatar = false end end enqueue(1.5, function() verify_ward(3) end, function() return player_free() end, 20) end end, pact_guard, 70) t = t + 7 else -- チェック済みの験術が無い(=神獣の加護だけ掛け直した等)場合は -- ここで対応済みとして記録する settings.avatar_last_cast[avatar_id] = os.time() settings:save() switching_avatar = false end end -- ============================================================ -- 召喚獣の切り替え(帰還 → 召喚)を、失敗を前提に確実にやり直す -- -- 「Numbing Blossom」等が撒く麻痺は、行動のたびに確率で失敗させる -- (アビリティなら「アビリティは使えなかった!」、魔法なら詠唱不発、 -- という形で現れる)。以前は帰還・召喚のコマンドを送ったら成功した -- ものとして次に進んでいたため、麻痺で弾かれると気づかないまま -- 「ペットが残ったまま召喚できない」「召喚できていないのに験術を -- 唱えようとしてエラーになる」という連鎖的な失敗になっていた。 -- -- ここでは、それぞれの段階で実際に成功した(ペットが消えた/ -- 目的の召喚獣に変わった)ことを確認し、失敗していれば送り直す。 -- ============================================================ local function attempt_summon(avatar_id, tries_left) local avatar = AVATARS_BY_ID[avatar_id] tries_left = tries_left or 5 dbg('AutoSMN: >> sending summon ' .. avatar.name_en) windower.chat.input('/ma "' .. avatar.name_en .. '" ') last_pet_command_at = os.time() enqueue(6, function() local pet = windower.ffxi.get_mob_by_target('pet') if pet and pet.name == avatar.name_en then current_avatar_id = avatar_id cast_favor_and_ward(avatar_id) elseif tries_left > 1 then dbgerr('AutoSMN: summon ' .. avatar.name_en .. ' failed (paralyzed?), retrying') attempt_summon(avatar_id, tries_left - 1) else dbgerr('AutoSMN: summon ' .. avatar.name_en .. ' failed repeatedly, giving up') switching_avatar = false end end, player_free) end local function attempt_release(avatar_id, tries_left) tries_left = tries_left or 5 if not pet_ready() then attempt_summon(avatar_id) return end dbg('AutoSMN: >> sending Release') windower.chat.input('/ja "Release" ') last_pet_command_at = os.time() enqueue(2, function() if not pet_ready() then attempt_summon(avatar_id) elseif tries_left > 1 then dbgerr('AutoSMN: Release failed (paralyzed?), retrying') attempt_release(avatar_id, tries_left - 1) else dbgerr('AutoSMN: Release failed repeatedly, giving up') switching_avatar = false end end, player_free) end local function fire_avatar_sequence(avatar_id) local need_switch = (current_avatar_id ~= avatar_id) switching_avatar = true switching_avatar_deadline = os.time() + 90 -- 験術リキャスト待ち(最大70秒)より余裕を持たせる dbg(('AutoSMN: fire_avatar_sequence(%s) need_switch=%s current=%s') :format(avatar_id, tostring(need_switch), tostring(current_avatar_id))) if need_switch then attempt_release(avatar_id) else cast_favor_and_ward(avatar_id) end end local function rotate_tick() if #action_queue > 0 then -- 前回のシーケンス(帰還→召喚→アビリティ)がまだ終わっていないので今回はスキップ return end if switching_avatar then return end -- 保険。切り替え中は新たな切り替えを始めない -- switching_avatar が何らかの理由で正しく機能していなかった場合の -- 二重の保険。直近数秒以内に帰還/召喚コマンドを送ったばかりなら、 -- 別の処理が今まさに何かやっている可能性が高いので新たに始めない。 if os.time() - last_pet_command_at < 3 then return end sync_current_avatar() -- 候補にするのは「チェック済みの験術のバフが切れている召喚獣」だけ。 -- 全員のバフが生きていれば何もしない(召喚し直しのMPを無駄にしない)。 local candidates = {} local any_selected = false for _, avatar in ipairs(AVATARS) do if is_avatar_selected(avatar.id) then local expired_ward, has_enabled = false, false for _, ab in ipairs(avatar.abilities) do local k = ability_key(ab) if is_ability_enabled(avatar.id, k) then has_enabled = true if ab.kind == 'ward' and not ward_active(k, ab) then expired_ward = true end end end if has_enabled then any_selected = true if expired_ward then table.insert(candidates, avatar.id) end end end end if not any_selected then dbg('AutoSMN: no ability checked on selected avatars') return end if #candidates == 0 then -- 掛け直しが必要な召喚獣がない。何もしないでMPを温存する if settings.debug then dbg('AutoSMN: all buffs active - skip') end return end -- 今まさに出している召喚獣が候補の中にいるなら、それを最優先する。 -- そうしないと、既に戦闘に使っている召喚獣より「もっと長く放置されている -- 別の召喚獣」が優先されてしまい、戦闘中に問答無用で入れ替わって -- しまう(戦い始めた瞬間に帰還して戦闘に参加できなくなる)。 local pick = nil for _, id in ipairs(candidates) do if id == current_avatar_id then pick = id break end end if not pick then table.sort(candidates, function(a, b) local la = settings.avatar_last_cast[a] or 0 local lb = settings.avatar_last_cast[b] or 0 if la ~= lb then return la < lb end return (settings.avatar_check_order[a] or 0) < (settings.avatar_check_order[b] or 0) end) pick = candidates[1] end dbg('AutoSMN: casting -> ' .. (AVATARS_BY_ID[pick] and AVATARS_BY_ID[pick].name_en or pick)) fire_avatar_sequence(pick) end -- ============================================================ -- 自動ケアル(自分 / パーティメンバー) -- ============================================================ -- 自分または、パーティメンバーの誰かのHPが一定割合を下回ったらケアルIVを撃つ。 -- 回数制限は無く、下回っている限り毎回発動する。 local function cure_tick() if not settings.auto_cure then return false end local player = windower.ffxi.get_player() if not player or not player.vitals or not player.vitals.max_hp or player.vitals.max_hp == 0 then return false end local pct = settings.cure_hp_pct or 70 -- まず自分のHPを確認する local my_pct = player.vitals.hp / player.vitals.max_hp * 100 if my_pct <= pct then windower.chat.input('/ma "' .. (settings.cure_spell or 'Cure IV') .. '" ') return true end -- 次にパーティメンバーを確認する(HPが低い順に1人だけ、毎チェックごとに1回) local party = windower.ffxi.get_party() if party then local lowest_name, lowest_pct = nil, 101 for _, key in ipairs({'p0','p1','p2','p3','p4','p5'}) do local member = party[key] if member and member.hp and member.hp > 0 and member.hpp then if member.hpp <= pct and member.hpp < lowest_pct then lowest_pct = member.hpp lowest_name = member.name end end end if lowest_name then windower.chat.input('/ma "' .. (settings.cure_spell or 'Cure IV') .. '" <' .. lowest_name .. '>') return true end end return false end -- ============================================================ -- コンバート / エレメントサイフォン -- ============================================================ -- MPが設定した割合を下回ったらコンバートし、直後に自分を回復する local function convert_tick() if not settings.auto_convert then return false end if not player_free() then return false end local player = windower.ffxi.get_player() if not player or not player.vitals then return false end local v = player.vitals if not v.max_mp or v.max_mp == 0 then return false end local pct = v.mp / v.max_mp * 100 if pct > settings.convert_mp_pct then return false end if not ability_ready('Convert') then return false end windower.chat.input('/ja "Convert" ') -- コンバートはHPとMPを入れ替えるため、直後に自分を回復する local t = 3 for _ = 1, (settings.convert_cure_count or 2) do enqueue(t, function() windower.chat.input('/ma "' .. settings.convert_cure .. '" ') end, player_free) t = t + 5 end return true end -- エレメントサイフォン。チェックした精霊を呼んでMPを吸い上げる。 -- 精霊を呼ぶと今の召喚獣は消えるため、次の周期で必要な召喚獣が呼び直される。 local last_siphon_attempt_at = 0 local function siphon_tick() if not settings.auto_siphon then return false end if not player_free() then return false end -- 召喚獣の切り替え・サイフォン自体の最中は絶対に割り込まない。 -- 万一クリアし忘れても、一定時間で自動的に解除される。 if switching_avatar then if os.time() >= switching_avatar_deadline then dbgerr('AutoSMN: switching_avatar flag stuck, force-clearing') switching_avatar = false else return false end end -- switching_avatar が何らかの理由で正しく機能していなかった場合の -- 二重の保険。直近数秒以内に帰還/召喚コマンドを送ったばかりなら、 -- 別の処理が今まさに何かやっている可能性が高いので新たに始めない。 if os.time() - last_pet_command_at < 3 then return false end -- 戦闘に入った直後は、召喚獣がまだ何もしていなくてもサイフォンを -- 優先させない。すぐ召喚獣をリリースしてしまうと、始まったばかりの -- 戦闘に参加できず「戦闘が始まらない」ように見えてしまうため。 if combat_started_at and os.time() - combat_started_at < (settings.siphon_grace or 15) then return false end -- 直前の試行(失敗も含む)から一定時間は再挑戦しない。 -- 麻痺等で毎回失敗すると、リキャストが消費されないため -- 何もガードが無いと2秒おきに際限なく再挑戦してしまい、 -- その間ずっと幻術/攻撃指示も止まる(「ぼけっとする」原因になっていた)。 if os.time() - last_siphon_attempt_at < 15 then return false end -- MPが十分残っている時に律儀に召喚獣を出し入れするのは無駄なので、 -- MP割合が一定以下になるまでは発動しない local player = windower.ffxi.get_player() if not player or not player.vitals or not player.vitals.max_mp or player.vitals.max_mp == 0 then return false end local mp_pct = player.vitals.mp / player.vitals.max_mp * 100 if mp_pct > (settings.siphon_mp_pct or 50) then return false end if not ability_ready('Elemental Siphon') then return false end -- チェック済みの精霊を、チェック順に1体選ぶ local spirit for _, s in ipairs(SPIRITS) do if settings.selected_spirits[s.id] then spirit = s break end end if not spirit then return false end last_siphon_attempt_at = os.time() switching_avatar = true switching_avatar_deadline = os.time() + 40 dbg('AutoSMN: siphon -> ' .. spirit.name_en) -- 検証はせず、素直に「帰還→精霊召喚→サイフォン→帰還」を順番に送るだけ。 -- (成否を厳密に確認する作りにしたところ、環境によっては噛み合わずに -- 固まってしまう不具合が出たため、確認処理は入れずシンプルに戻した) local t = 0 if pet_ready() then dbg('AutoSMN: >> sending Release (siphon)') enqueue(t, function() windower.chat.input('/ja "Release" ') last_pet_command_at = os.time() end, player_free) t = t + 3 end enqueue(t, function() dbg('AutoSMN: >> sending summon ' .. spirit.name_en) windower.chat.input('/ma "' .. spirit.name_en .. '" ') last_pet_command_at = os.time() end, player_free) t = t + (settings.siphon_delay or 10) enqueue(t, function() windower.chat.input('/ja "Elemental Siphon" ') end, function() return pet_ready() and player_free() end) t = t + 4 enqueue(t, function() windower.chat.input('/ja "Release" ') last_pet_command_at = os.time() current_avatar_id = nil switching_avatar = false end, player_free) return true end -- ============================================================ -- 幻術(攻撃履行)と 神獣の攻撃 の処理 -- -- 験術(支援)は60秒周期でバフ切れを見て掛け直すが、こちらは性質が違うため -- 別ループで扱う: -- 幻術 … リキャストが明けるたびにどんどん撃つ -- 神獣の攻撃 … 召喚獣が何もしていない時に攻撃を指示する -- ============================================================ local function combat_tick() if #action_queue > 0 then return end -- 召喚シーケンス実行中は邪魔しない if switching_avatar then return end -- 保険。切り替え中は何もしない if not player_free() then return end -- HPが低ければ何よりも先にケアル(コンバートはHPとMPを入れ替えるため、 -- HPが低い状態で先にコンバートすると危険なのでケアルを優先する) if cure_tick() then return end -- MPが尽きかけていれば次にコンバート if convert_tick() then return end -- 次にエレメントサイフォン(リキャストごと) if siphon_tick() then return end if not pet_ready() then return end -- 手動で召喚した場合にも追従できるよう、実際のペットから同期する sync_current_avatar() local avatar = AVATARS_BY_ID[current_avatar_id] if not avatar then return end -- 攻撃対象。自分が構えていなくてもパーティが戦っている敵を拾う local target = find_target() if not target then return end -- 対象の指定方法。 -- /ja に敵のIDを直接渡すのは受け付けられないため、 があればそれを使い、 -- 無ければ対象をターゲットしてから で実行する。 local function use_on_target(name) local bt = windower.ffxi.get_mob_by_target('bt') if bt and bt.hpp and bt.hpp > 0 then windower.chat.input('/ja "' .. name .. '" ') else windower.chat.input('/target ' .. target.name) enqueue(1, function() windower.chat.input('/ja "' .. name .. '" ') end, function() -- 実行直前にもう一度確認する。1秒待つ間に対象が倒れて -- いたら、無効な相手にコマンドを送ってエラーになるのを防ぐ if not player_free() then return false end local t = windower.ffxi.get_mob_by_target('t') return t and t.hpp and t.hpp > 0 end) end end -- 召喚獣が遊んでいるなら攻撃させる if settings.auto_assault and pet_idle() then -- 神獣の攻撃はリキャストを取得できないため、連打しないよう間隔を空ける if os.time() >= (last_assault or 0) + 10 then for _, ab in ipairs(avatar.abilities) do if ab.kind == 'assault' and is_ability_enabled(avatar.id, ability_key(ab)) then last_assault = os.time() use_on_target(ab.en) return end end end end -- 幻術はリキャストが明けていれば撃つ if settings.auto_rage then for _, ab in ipairs(avatar.abilities) do if ab.kind == 'rage' and is_ability_enabled(avatar.id, ability_key(ab)) then if ability_ready(ab.en) then use_on_target(ab.en) return end end end end end -- ============================================================ -- 戦闘状態の検知 -- -- 召喚士は「構える(Engaged)」ことをしないため、自分のstatusが1になるのを -- 待つ方式では永久に発動しない。そこで、パーティメンバーの誰か(または自分の -- 召喚獣)が戦闘中かどうかで「戦闘中」と判定する。 -- ============================================================ -- 「戦闘中」は自分自身が交戦状態(敵の名前が赤くなっている状態)かどうかで -- 判定する。以前はパーティメンバー全員(マルチボックスしている他のキャラ含む) -- の誰か1人でも交戦していれば戦闘中とみなしていたため、自分自身はまだ -- 何も攻撃していないのに、他のキャラが先に交戦しただけでローテーションが -- 動き出してしまっていた。 -- 「戦闘態勢」は赤ネーム(=戦闘対象がいる状態)で判定する。 -- 召喚士はキャラ自身が殴らず召喚獣任せのことも多く、player.status==1 -- (自分が実際に交戦中)だけを見ていると、キャラも召喚獣も特に何も -- していない(が、敵は既にターゲットしている)瞬間を拾えなかった。 local function party_in_combat() local bt = windower.ffxi.get_mob_by_target('bt') if bt and bt.hpp and bt.hpp > 0 then return true end local player = windower.ffxi.get_player() if player and player.status == 1 then return true end -- 自分の召喚獣が戦っている場合も戦闘中とみなす local pet = windower.ffxi.get_mob_by_target('pet') if pet and pet.status == 1 then return true end return false end -- 自分が戦闘不能/イベント中などの場合は止める local function self_blocked() local player = windower.ffxi.get_player() if not player then return true end return player.status > 1 and player.status < 4 end windower.register_event('status change', function(new, old) -- 戦闘不能やイベント突入時は即座に停止する if new > 1 and new < 4 then rotation_running = false switching_avatar = false action_queue = {} end end) -- ============================================================ -- メインループ -- ============================================================ local next_combat_check = 0 local next_combat_tick = os.time() -- ============================================================ -- 安全実行ラッパー -- -- 以前、想定外のエラー(例: ward_active が nil だった件)が起きた時、 -- prerender の処理がその場でエラー終了し、以後ずっと「召喚もアビリティも -- 何もしない」という状態になってしまったことがあった。 -- ここから先は、ローテーションの心臓部を pcall で必ず保護し、 -- 何かおかしくてもそのフレームだけ諦めて次のフレームでまた試す -- (=処理全体が完全に止まることはない)ようにする。 -- 同じエラーを毎フレーム表示すると読めなくなるので、直前と同じ内容なら -- 再表示しない。 -- ============================================================ local last_safe_error = nil local function safe_call(label, fn) local ok, err = pcall(fn) if not ok then if err ~= last_safe_error then last_safe_error = err dbgerr('AutoSMN: error in ' .. label .. ': ' .. tostring(err)) end end end windower.register_event('prerender', function() if not is_smn() then hide_all() return end button_box:pos(settings.pos.x, settings.pos.y) button_box:show() safe_call('render_view', render_view) safe_call('process_queue', process_queue) local curtime = os.time() -- 戦闘中かどうかを1秒ごとに判定する if curtime >= next_combat_check then next_combat_check = curtime + 1 local in_combat = settings.active and party_in_combat() and not self_blocked() if in_combat and not rotation_running then rotation_running = true combat_started_at = curtime -- next_tick はここでリセットしない。 -- 戦闘に入るたびに前倒しすると、攻撃しただけですぐ召喚シーケンスが -- 走ってしまい煩わしいため、常に rotate_interval の周期だけに従う。 if settings.debug then dbg('AutoSMN: combat detected') end elseif not in_combat and rotation_running then rotation_running = false if switching_avatar then -- 召喚→サイフォン→帰還のような一連の処理が進行中の場合は、 -- 戦闘が終わっても最後まで完了させる。action_queueは -- process_queue()自体はrotation_runningに関係なく -- 毎フレーム動き続けるので、ここで消さなければ -- 自然に最後まで走りきる。current_avatar_idやフラグも -- 完了時に処理側で正しく後始末される。 if settings.debug then dbg('AutoSMN: combat ended (sequence in progress, letting it finish)') end else action_queue = {} -- current_avatar_id はここでクリアしておく。古い召喚獣の -- 記録が残ったままだと、次の戦闘開始時に実際のペット状況と -- 食い違って正しく呼び直されないことがあったため。 -- 次のrotate_tickでsync_current_avatar()が再認識する。 current_avatar_id = nil if settings.debug then dbg('AutoSMN: combat ended') end end end end if not rotation_running then return end -- switching_avatar が立ったまま戻し忘れているケースの保険。 -- (例: 験術のリキャスト待ちが70秒経ってもリキャストが明かず、 -- 該当処理がタイムアウトで黙って消えた場合など、フラグを戻す -- コード自体を通らずに終わってしまうケースがある) -- combat_tick/rotate_tick 側のガードで止まってしまうと、この -- チェック自体に辿り着けないため、毎フレーム必ず通るここで見る。 if switching_avatar and curtime >= switching_avatar_deadline then dbgerr('AutoSMN: switching_avatar flag stuck, force-clearing') switching_avatar = false action_queue = {} end -- 幻術と攻撃指示は2秒ごとに確認する(験術の60秒周期とは独立) if curtime >= next_combat_tick then next_combat_tick = curtime + (settings.combat_interval or 2) safe_call('combat_tick', combat_tick) end if curtime >= next_tick then next_tick = curtime + (settings.rotate_interval or 60) safe_call('rotate_tick', rotate_tick) end end) -- ============================================================ -- クリック判定 -- ============================================================ local function point_in_box(x, y, bx, by, bw, bh) return x >= bx and x <= bx + bw and y >= by and y <= by + bh end -- UI領域内かどうかを判定する(押下・離す 両方のブロック判断に使う) local function hit_ui(x, y) if point_in_box(x, y, settings.pos.x, settings.pos.y, BUTTON_W, BUTTON_H) then return 'button' end for i = 1, MAX_ROWS do if row_data[i] then local by = settings.panel_pos.y + (i - 1) * ROW_H -- 持続時間欄(本体の右側)を先にチェックする。本体の当たり判定と -- 領域が隣接しているため、先に判定しないと本体側に取られてしまう。 if row_data[i].dur_label and point_in_box(x, y, settings.panel_pos.x + ROW_W, by, DUR_W, ROW_H) then return {i, 'dur'} end if point_in_box(x, y, settings.panel_pos.x, by, ROW_W, ROW_H) then return i end end end return nil end -- 押した瞬間にヒットした場所を覚えておく。 -- 離した瞬間(type=2)に座標を再計算すると、わずかな手ブレでズレて -- ヒットしなくなり、クリックがゲーム側に漏れて移動してしまうことがあるため、 -- 離した時は押した時の記憶をそのまま使ってブロックする。 local pressed_hit = nil windower.register_event('mouse', function(mtype, x, y, delta, blocked) if blocked then return end if mtype == 2 then -- 離した瞬間。押した時にUI内でヒットしていれば、座標を見直さずブロックする local was_hit = pressed_hit ~= nil pressed_hit = nil if settings.debug and was_hit then dbg('AutoSMN dbg: release blocked via press memory') end return was_hit or nil end if mtype ~= 1 then return end local hit = hit_ui(x, y) pressed_hit = hit if settings.debug then local hit_desc = type(hit) == 'table' and (('%d:dur'):format(hit[1])) or tostring(hit) dbg(('AutoSMN dbg: type=%d click(%d,%d) btn(%d,%d) panel(%d,%d) row1box(%d,%d,%d,%d) hit=%s') :format(mtype, x, y, settings.pos.x, settings.pos.y, settings.panel_pos.x, settings.panel_pos.y, settings.panel_pos.x, settings.panel_pos.y, ROW_W, ROW_H, hit_desc)) end if not hit then return end if hit == 'button' then settings.active = not settings.active refresh_button() settings:save() if not settings.active then rotation_running = false switching_avatar = false action_queue = {} end return true end if type(hit) == 'table' then -- 持続時間欄がクリックされた local ok, err = pcall(row_data[hit[1]].dur_onclick) if not ok then dbgerr('AutoSMN: error in dur_onclick: ' .. tostring(err)) end else local ok, err = pcall(row_data[hit].onclick) if not ok then dbgerr('AutoSMN: error in onclick: ' .. tostring(err)) end end return true end) -- ============================================================ -- コマンド -- ============================================================ windower.register_event('addon command', function(...) local cmd = {...} cmd[1] = cmd[1] and cmd[1]:lower() if cmd[1] == 'on' then settings.active = true elseif cmd[1] == 'off' then settings.active = false rotation_running = false switching_avatar = false action_queue = {} elseif cmd[1] == 'pos' and cmd[2] and cmd[3] then settings.pos.x = tonumber(cmd[2]) or settings.pos.x settings.pos.y = tonumber(cmd[3]) or settings.pos.y button_box:pos(settings.pos.x, settings.pos.y) elseif cmd[1] == 'panelpos' and cmd[2] and cmd[3] then settings.panel_pos.x = tonumber(cmd[2]) or settings.panel_pos.x settings.panel_pos.y = tonumber(cmd[3]) or settings.panel_pos.y for i = 1, MAX_ROWS do row_box[i]:pos(settings.panel_pos.x, settings.panel_pos.y + (i - 1) * ROW_H) dur_box[i]:pos(settings.panel_pos.x + ROW_W, settings.panel_pos.y + (i - 1) * ROW_H) end elseif cmd[1] == 'buffs' then -- チェック済みの験術と、それぞれが対応づけているバフ名の一覧を表示する -- (//smn why と似ているが、こちらはON/OFF状態に関わらず全部出す) local n = 0 for _, avatar in ipairs(AVATARS) do for _, ab in ipairs(avatar.abilities) do if ab.kind == 'ward' then local k = ability_key(ab) if ab.buff_name == false then dbg(('AutoSMN: %s / %s -> (not tracked)'):format(avatar.name_en, k)) else local name = settings.buff_name_override[k] or ab.buff_name or ab.en dbg(('AutoSMN: %s / %s -> "%s"%s'):format( avatar.name_en, k, name, settings.buff_name_override[k] and ' [manual]' or '')) end n = n + 1 end end end if n == 0 then dbg('AutoSMN: no ward abilities defined') end return elseif cmd[1] == 'forget' then -- バフ名/持続時間の手動上書きと、最後に撃った時刻の記録を全部消して -- 完全にやり直す(間違って指定した時用)。時刻の記録も消すことで、 -- 全ての験術がすぐに「未チェック」として次のローテーションで -- 確認対象になる。 settings.buff_name_override = {} settings.duration_override = {} settings.ward_last_cast = {} settings:save() dbg('AutoSMN: buff name / duration overrides and cast timers cleared') return elseif cmd[1] == 'dump' then -- ゲームのリソースから、召喚獣の履行(ブラッドパクト)を全て書き出す。 -- 出力は Windower の addons/AutoSMN/data/dump.txt に保存される。 local lines = {} local types_seen = {} for id, a in pairs(res.job_abilities) do local t = tostring(a.type) types_seen[t] = (types_seen[t] or 0) + 1 if t:lower():find('bloodpact') or t:lower():find('blood pact') then lines[#lines + 1] = ('%s\t%s\t%s\t%s\tlv%s\trecast_id=%s') :format(t, tostring(a.en), tostring(a.ja), tostring(a.element or ''), tostring(a.level or ''), tostring(a.recast_id)) end end table.sort(lines) local f = io.open(windower.addon_path .. 'data/dump.txt', 'w') if not f then dbgerr('AutoSMN: could not write data/dump.txt') return end if #lines > 0 then f:write('type\ten\tja\telement\tlevel\trecast_id\n') for _, l in ipairs(lines) do f:write(l .. '\n') end dbg(('AutoSMN: wrote %d blood pacts to data/dump.txt'):format(#lines)) else -- 想定した種別名で見つからなかった場合、種別の一覧を出して手掛かりにする f:write('no blood pact type matched. ability types found:\n') for t, n in pairs(types_seen) do f:write(('%s\t%d\n'):format(t, n)) end dbgerr('AutoSMN: no blood pacts matched - wrote type list to data/dump.txt') end f:close() return elseif cmd[1] == 'recast' then -- 現在の召喚獣のアビリティについて、リキャストが取得できているか確認する sync_current_avatar() local avatar = AVATARS_BY_ID[current_avatar_id] if not avatar then dbg('AutoSMN: no avatar out') return end local recasts = windower.ffxi.get_ability_recasts() or {} for _, ab in ipairs(avatar.abilities) do local rid = ability_recast_id(ab.en) if rid then dbg(('AutoSMN: %s recast_id=%d left=%s') :format(ab.en, rid, tostring(recasts[rid]))) else dbg(('AutoSMN: %s recast_id NOT FOUND'):format(ab.en)) end end return elseif cmd[1] == 'interval' and cmd[2] then settings.rotate_interval = tonumber(cmd[2]) or settings.rotate_interval dbg('AutoSMN: buff check interval = ' .. settings.rotate_interval .. 's') elseif cmd[1] == 'cinterval' and cmd[2] then settings.combat_interval = tonumber(cmd[2]) or settings.combat_interval dbg('AutoSMN: combat check interval = ' .. settings.combat_interval .. 's') elseif cmd[1] == 'cure' then settings.auto_cure = not settings.auto_cure dbg('AutoSMN: auto cure = ' .. tostring(settings.auto_cure)) elseif cmd[1] == 'curehp' and cmd[2] then settings.cure_hp_pct = tonumber(cmd[2]) or settings.cure_hp_pct dbg('AutoSMN: cure at HP ' .. settings.cure_hp_pct .. '%') elseif cmd[1] == 'curespell' and cmd[2] then settings.cure_spell = table.concat(cmd, ' ', 2) dbg('AutoSMN: cure spell = ' .. settings.cure_spell) elseif cmd[1] == 'convert' then settings.auto_convert = not settings.auto_convert dbg('AutoSMN: auto convert = ' .. tostring(settings.auto_convert)) elseif cmd[1] == 'convertmp' and cmd[2] then settings.convert_mp_pct = tonumber(cmd[2]) or settings.convert_mp_pct dbg('AutoSMN: convert at MP ' .. settings.convert_mp_pct .. '%') elseif cmd[1] == 'dumpaction' then -- 汎用調査用。特定の技を狙いたい場合は //smn testward を使うこと -- (Rage/Assaultが自動発動していると、そちらを先に拾ってしまう)。 dump_next_action = true dump_expect_en = nil dbg('AutoSMN: will dump the next pact action packet (any ability)') return elseif cmd[1] == 'testward' then -- 自然発動(最大60秒)を待たず、今すぐ検証用に験術を1回撃つ。 -- dumpaction も自動でONにするので、これ一発でパケットの中身を確認できる。 sync_current_avatar() local avatar_id = current_avatar_id or settings.selected_avatars[1] if not avatar_id then dbgerr('AutoSMN: no avatar selected/summoned') return end local avatar = AVATARS_BY_ID[avatar_id] local ward = nil for _, ab in ipairs(avatar.abilities) do if ab.kind == 'ward' and is_ability_enabled(avatar_id, ability_key(ab)) then ward = ab break end end if not ward then dbgerr('AutoSMN: no ward ability enabled for ' .. avatar.name_en) return end dump_next_action = true dump_expect_en = ward.en -- Rage/Assaultが同時に発動しても誤検知しないよう、この技だけを狙う dbg('AutoSMN: test-casting ' .. avatar.name_en .. ' / ' .. ward.en .. ' (buff: ' .. tostring(ward.buff_name or ward.en) .. ', duration: ' .. tostring(ward.duration) .. 's)') windower.chat.input('/ja "' .. ward.en .. '" <' .. ward.target .. '>') if ward.target ~= 'bt' then settings.ward_last_cast[ability_key(ward)] = os.time() settings:save() end return elseif cmd[1] == 'buffsnow' then -- 今自分に付いているバフをID付きで一覧表示する。 -- 静かな場面でアビリティを1つだけ撃ってから、この一覧で新しく増えた -- ものを見つけ、そのIDを //smn setbuff で指定するために使う。 local player = windower.ffxi.get_player() if not player or not player.buffs or #player.buffs == 0 then dbg('AutoSMN: no buffs active') return end for _, id in ipairs(player.buffs) do dbg(('AutoSMN: id=%d %s'):format(id, buff_name(id))) end return elseif cmd[1] == 'setbuff' and cmd[2] and cmd[3] then -- アビリティが付けるバフの「正式な名前」を手動で上書きする。 -- (自動判定が誤っていた場合の保険。//smn buffsnow で正しい名前を確認してから使う) -- 名前にスペースが含まれる場合は cmd[3] 以降を全部つなげる local name = table.concat(cmd, ' ', 3) settings.buff_name_override[cmd[2]] = name settings:save() dbg(('AutoSMN: %s -> buff name "%s" [manual]'):format(cmd[2], name)) return elseif cmd[1] == 'setduration' and cmd[2] and cmd[3] then -- 験術の持続時間(秒)を手動で上書きする。 -- 召喚魔法スキルによって基本値より長く持つため、実際にゲーム内で -- 確認した秒数をここで指定すると、こちらが優先して使われる。 local sec = tonumber(cmd[3]) if not sec then dbgerr('AutoSMN: usage //smn setduration ') return end settings.duration_override[cmd[2]] = sec settings:save() dbg(('AutoSMN: %s -> duration %ds [manual]'):format(cmd[2], sec)) return elseif cmd[1] == 'why' then -- 各召喚獣が「バフ切れ」と判定されている理由を表示する for _, avatar in ipairs(AVATARS) do if is_avatar_selected(avatar.id) then for _, ab in ipairs(avatar.abilities) do local k = ability_key(ab) if ab.kind == 'ward' and is_ability_enabled(avatar.id, k) then local state if ab.buff_name == false then state = 'not tracked (e.g. Raise-type)' else local last = settings.ward_last_cast[k] local duration = settings.duration_override[k] or ab.duration local timer_info if duration and last then local remain = (last + duration) - os.time() timer_info = remain > 0 and (' timer:%ds left%s'):format(remain, settings.duration_override[k] and '[manual]' or '') or ' timer:expired' else timer_info = '' end local name = settings.buff_name_override[k] or ab.buff_name or ab.en local checked = duration and last and os.time() < last + duration local buff_state if checked then buff_state = 'not checked yet (within timer)' else buff_state = has_buff(name) and 'ACTIVE' or 'EXPIRED' end state = ('"%s"%s%s = %s'):format(name, settings.buff_name_override[k] and ' [manual]' or '', timer_info, buff_state) end dbg(('AutoSMN: %s / %s = %s') :format(avatar.name_en, k, state)) end end end end return elseif cmd[1] == 'siphondelay' and cmd[2] then settings.siphon_delay = tonumber(cmd[2]) or settings.siphon_delay dbg('AutoSMN: siphon delay = ' .. settings.siphon_delay .. 's') elseif cmd[1] == 'siphonmp' and cmd[2] then settings.siphon_mp_pct = tonumber(cmd[2]) or settings.siphon_mp_pct dbg('AutoSMN: siphon at MP ' .. settings.siphon_mp_pct .. '%') elseif cmd[1] == 'siphongrace' and cmd[2] then settings.siphon_grace = tonumber(cmd[2]) or settings.siphon_grace dbg('AutoSMN: siphon grace period = ' .. settings.siphon_grace .. 's') elseif cmd[1] == 'siphon' then settings.auto_siphon = not settings.auto_siphon dbg('AutoSMN: auto siphon = ' .. tostring(settings.auto_siphon)) elseif cmd[1] == 'assault' then settings.auto_assault = not settings.auto_assault dbg('AutoSMN: auto assault = ' .. tostring(settings.auto_assault)) elseif cmd[1] == 'rage' then settings.auto_rage = not settings.auto_rage dbg('AutoSMN: auto rage = ' .. tostring(settings.auto_rage)) elseif cmd[1] == 'favor' then settings.use_favor = not settings.use_favor dbg('AutoSMN: Avatar\'s Favor = ' .. tostring(settings.use_favor)) elseif cmd[1] == 'debug' then settings.debug = not settings.debug dbg('AutoSMN: debug = ' .. tostring(settings.debug)) elseif cmd[1] == 'log' then dbg('AutoSMN: log file -> addons/AutoSMN/' .. get_log_path():match('data[/\\].*$')) elseif cmd[1] == 'logclear' then local f = io.open(get_log_path(), 'w') if f then f:write(('[%s] --- log cleared ---\n'):format(os.date('%Y-%m-%d %H:%M:%S'))) f:close() dbg('AutoSMN: log cleared') else dbgerr('AutoSMN: could not clear log') end elseif cmd[1] == 'save' then settings:save() dbg('AutoSMN: settings saved') return elseif cmd[1] == 'resetui' then settings.text.text.font = 'MS Gothic' settings.row_text.text.font = 'MS Gothic' button_box:font('MS Gothic') for i = 1, MAX_ROWS do row_box[i]:font('MS Gothic') dur_box[i]:font('MS Gothic') end settings:save() dbg('AutoSMN: font updated') return else dbg('AutoSMN: //smn on|off | pos | panelpos | interval | cinterval | cure | curehp | curespell | convert | convertmp | siphon | siphondelay | siphonmp | siphongrace | assault | rage | favor | buffs | buffsnow | setbuff | setduration | why | forget | dump | dumpaction | testward | recast | debug | log | logclear | save | resetui') return end refresh_button() settings:save() end) -- ============================================================ -- リセット -- ============================================================ local function reset() rotation_running = false switching_avatar = false action_queue = {} current_avatar_id = nil end windower.register_event('zone change', 'job change', 'logout', reset) render_view()