Battle macros

Use skills in a specific order by pressing less buttons.

// ==UserScript==
// @name         Battle macros
// @version      2025-04-21
// @description  Use skills in a specific order by pressing less buttons.
// @author       Lulu5239
// @match        *://game.granbluefantasy.jp/*
// @match        *://gbf.game.mbga.jp/*
// @grant        GM_getValue
// @grant        GM_setValue
// @namespace https://greasyfork.org/users/1449836
// ==/UserScript==

var click = e=>e.dispatchEvent(new Event("tap",{bubbles:true, cancelable:true}))
var recordFunction; let recordable
let cancel = 0

var onPage = async ()=>{
  if(document.querySelectorAll("#macros-list").length || !document.location.hash?.startsWith("#battle") && !document.location.hash?.startsWith("#raid")){return}
  while(typeof(stage)=="undefined" || !stage?.pJsnData || !document.querySelectorAll("#tpl-prt-total-damage").length){await new Promise(ok=>setTimeout(ok,100))}
  
  document.querySelector(".cnt-raid").style.paddingBottom = "0px"
  document.querySelector(".prt-raid-log").style.pointerEvents = "none"
  cancel++
  
  let macros = GM_getValue("macros") || []
  document.querySelector(".contents").insertAdjacentHTML("beforeend",
    `<div id="macros-list"><div class="listed-macro" data-id="new">New...</div><div class="listed-macro" data-id="showAll">Show all</div><div class="listed-macro" data-id="cancel" style="display:none">Stop playing</div></div>
    <div id="macro-recording" style="display:none"><div class="listed-macro" data-id="stop"><button>End recording</button> <button>Cancel</button> <button>Add macro</button></div></div>
    <div id="macro-settings" style="display:none">
      <div class="listed-macro" style="background-color:#111">Back</div>
      <div class="listed-macro" style="text-align:center"></div>
      <div class="listed-macro">Rename</div>
      <div class="listed-macro"></div>
      <div class="listed-macro"></div>
      <div class="listed-macro">Move...</div>
      <div class="listed-macro">Speed: <select><option value="slow">Slow</option><<option value="normal">Normal</option></select></div>
      <div class="listed-macro" style="background-color:#411">Delete</div>
    </div>
    <style>
      .listed-macro {
        display:block;
        width:calc(100% - 10px);
        padding:5px;
        background-color:#222;
        color:#fff;
        margin-bottom:2px;
      }
      .listed-macro[data-playing="now"] {
        background-color:#922;
      }
      .listed-macro[data-playing="soon"] {
        background-color:#742;
      }
      .listed-macro[data-playing="original"] {
        background-color:#472;
      }
    </style>`
  )
  let list = document.querySelector("#macros-list")
  let observer = new MutationObserver(onPage)
  observer.observe(list.parentElement, {
    childList:true,
  })
  let recording = document.querySelector("#macro-recording")
  let settings = document.querySelector("#macro-settings")
  let partyHash = [stage.pJsnData.player.param.map(e=>e.pid).join(","), stage.pJsnData.summon.map(s=>s.id).join(",")].join(";")
  
  let characterByImage = url=>url.split("/").slice(-1)[0].split("_")[0]
  let playMacro = async id=>{
    let macro = macros[id]
    let line = list.querySelector(`[data-id="${id}"]`)
    line.dataset.playing = "now"
    list.querySelector(`.listed-macro[data-id="cancel"]`).style.display = null
    let actions = [...macro.actions]
    let next = {}
    let check; check = (n,rec)=>{
      if(rec && !next[n]){
        list.querySelector(`[data-id="${n}"]`).dataset.playing = "soon"
        next[n] = 1
      }else{next[n]++}
      if(rec>10){return}
      for(let action of macros[n].actions){
        if(action.type!=="macro"){continue}
        check(action.macro, rec+1)
      }
    }
    next[id] = 1
    check(id,0)
    let playing = [id]
    let wait = time=>new Promise(ok=>setTimeout(ok,time ? time : macro.speed==="slow" ? 2000 : 500))
    let myCancel = cancel
    while(actions.length){
      if(cancel>myCancel){break}
      let action = actions.splice(0,1)[0]
      if(action.type==="macro"){
        if(!macros[action.macro]){continue}
        next[action.macro]--
        list.querySelector(`[data-id="${playing.slice(-1)[0]}"]`).dataset.playing = playing.slice(-1)[0]===id ? "original" : "soon"
        list.querySelector(`[data-id="${action.macro}"]`).dataset.playing = "now"
        playing.push(action.macro)
        actions.splice(0, 0, ...macros[action.macro].actions, {type:"leaveMacro"})
      continue}
      if(action.type==="leaveMacro"){
        let last = playing.splice(-1, 1)[0]
        if(next[last]>0){
          list.querySelector(`[data-id="${last}"]`).dataset.playing = "soon"
        }else{
          list.querySelector(`[data-id="${last}"]`).removeAttribute("data-playing")
        }
        list.querySelector(`[data-id="${playing.slice(-1)[0]}"]`).dataset.playing = "now"
      continue}
      
      if(action.type==="skill"){
        let button = document.querySelectorAll(`div[ability-id="${action.ability}"]`)[0]
        if(button){
          if(document.querySelector(`.prt-command-chara[pos="${+button.getAttribute("ability-character-num")+1}"]`).style.display!=="block"){
            let back = document.querySelector(`.btn-command-back`)
            if(back.classList.contains("display-on")){
              click(back)
              await wait()
            }
            click(document.querySelector(`.btn-command-character[pos="${+button.getAttribute("ability-character-num")}"]`))
            await wait()
          }
          click(button)
          if(action.character){
            await wait()
            let character
            for(let c of document.querySelectorAll(`.pop-select-member .prt-character .btn-command-character img`)){
              if(characterByImage(c.src)===action.character){
                character = c
              }
            }
            if(character){
              click(character)
              await wait()
            }
          }
          await wait(200)
        }
      }else if(action.type==="attack"){
        let button = document.querySelectorAll(`.btn-attack-start.display-on`)[0]
        if(button){
          click(button)
          await new Promise((ok,err)=>{
            let observer = new MutationObserver(()=>{
              if(cancel>myCancel || button.classList.contains("display-on")){ok()}
            })
            observer.observe(button, {
              attributes:true,
            })
          })
        }
      }else if(action.type==="summon"){
        let back = document.querySelector(`.btn-command-back`)
        if(back.classList.contains("display-on")){
          click(back)
          await wait()
        }
        let button = document.querySelectorAll(".btn-command-summon.summon-on")[0]
        if(!button){continue}
        click(button)
        await wait()
        button = document.querySelectorAll(`.btn-summon-available.on[summon-id="${action.summon==="support" ? "supporter" : stage.pJsnData.summon.findIndex(s=>s.id===action.summon)}"]`)[0]
        if(!button){continue}
        click(button)
        await wait(200)
        click(document.querySelector(".btn-summon-use"))
        await wait()
      }else if(action.type==="calock"){
        let button = document.querySelector(".btn-lock")
        let n = action.lock!="false" ? 1 : 0
        if(button.classList.contains("lock"+(1-n))){continue}
        if(button.parentElement.style.display==="none"){
          click(document.querySelector(`.btn-command-back`))
          await wait()
        }
        click(button)
        if(macro.speed==="slow"){await wait()}
      }
    }
    list.querySelector(`[data-id="${id}"]`).removeAttribute("data-playing")
    for(let i in next){
      list.querySelector(`[data-id="${i}"]`).removeAttribute("data-playing")
    }
    if(!list.querySelectorAll("[data-playing]").length){
      list.querySelector(`.listed-macro[data-id="cancel"]`).style.backgroundColor = null
      list.querySelector(`.listed-macro[data-id="cancel"]`).style.display = "none"
    }
  }

  let moveMode; let showAll
  let createListedMacro = i=>{
    let macro = macros[i]
    list.querySelector(`.listed-macro[data-id="new"]`).insertAdjacentHTML("beforebegin", `<div class="listed-macro" data-id="${i}"><button style="padding:0px; font-size:8px; width:25px; display:inline-block">⚙️</button> <a>${macro.name}</a></div>`)
    let line = list.querySelector(`.listed-macro[data-id="${i}"]`)
    line.addEventListener("click", async ()=>{
      if(line.dataset.playing){return}
      if(moveMode!==undefined){return moveMode(line)}
      await playMacro(line.dataset.id)
    })
    line.querySelector(`button`).addEventListener("click", ev=>{
      if(moveMode!==undefined){return}
      ev.stopPropagation()
      list.style.display = "none"
      settings.style.display = null
      settings.dataset.macro = line.dataset.id
      settings.children[1].innerText = macro.name
      settings.children[3].innerText = macro.parties?.includes(partyHash) ? "Don't show for this party" : "Show for this party"
      settings.children[3].style.display = !macro.parties ? "none" : null
      settings.children[4].innerText = !macro.parties ? "Don't always show" : "Always show"
      settings.children[6].querySelector("select").value = macro.speed || "normal"
      window.scrollTo(0, window.innerHeight)
    })
  }
  let listMacros = ()=>{
    for(let i in macros){
      if(!showAll && macros[i].parties && !macros[i].parties.includes(partyHash)){continue}
      createListedMacro(i)
    }
  }
  listMacros()
  
  let skillByImage = url=>document.querySelector(`.prt-ability-list img[src="${url}"]`).parentElement
  if(!recordable){
    $(document.body).on("tap", ev=>{
      if(recordFunction){recordFunction(ev.target)}
    })
    recordable = true
  }
  list.querySelector(`.listed-macro[data-id="new"]`).addEventListener("click", ()=>{
    list.style.display = "none"
    recording.style.display = null
    recordFunction = original=>{
      let usefulParent = original
      let character
      while(usefulParent && !["lis-ability","prt-popup-body","btn-attack-start","btn-summon-use","btn-quick-summon","btn-lock"].find(c=>usefulParent.classList.contains(c))){
        if(usefulParent.classList.contains("btn-command-character")){character = usefulParent}
        usefulParent = usefulParent.parentElement
      }
      if(!usefulParent){return}
      let extra = {}
      let text
      if(usefulParent.classList.contains("btn-attack-start")){
        extra.type = "attack"
        text = "Attack"
      }else if(usefulParent.classList.contains("btn-summon-use") || usefulParent.classList.contains("btn-quick-summon")){
        extra.type = "summon"
        if(usefulParent.classList.contains("btn-quick-summon")){
          usefulParent = document.querySelector(".lis-summon.is-quick")
        }else if(usefulParent.getAttribute("summon-id")==="supporter"){
          text = "Support summon"
          extra.summon = "support"
        }else{
          usefulParent = document.querySelector(`.lis-summon[pos="${usefulParent.getAttribute("summon-id")}"]`)
        }
        if(!extra.summon){
          let summon = stage.pJsnData.summon[+usefulParent.getAttribute("pos") -1]
          text = summon.name
          extra.summon = summon.id
        }
      }else if(usefulParent.classList.contains("btn-lock")){
        extra.type = "calock"
        extra.lock = usefulParent.classList.contains("lock1")
        text = (extra.lock ? "No" : "Auto")+" charge attack"
      }else{
        extra.type = "skill"
        if(usefulParent.parentElement.classList.contains("pop-usual") && character){
          extra.character = characterByImage(character.querySelector("img.img-chara-command").src)
          usefulParent = skillByImage(usefulParent.querySelector("img.img-ability-icon").src)
        }else{
          usefulParent = usefulParent.querySelector("[ability-id]")
        }
        extra.ability = usefulParent.getAttribute("ability-id")
        text = usefulParent.getAttribute("ability-name")
      }
      let last; let p = extra.type==="skill" ? "ability" : extra.type
      for(let e of recording.querySelectorAll(`[data-type]`)){last = e}
      if(last && last.dataset.type===extra.type && extra[p]==last.dataset[p]){
        for(let k in last.dataset){last.removeAttribute("data-"+k)}
        for(let k in extra){last.dataset[k] = extra[k]}
        last.innerText = text
      }else{
        recording.insertAdjacentHTML("beforeend", `<div class="listed-macro" style="background-color:#${extra.type==="skill" ? "141" : extra.type==="attack" ? "411" : extra.type==="summon" ? "441" : extra.type==="calock" ? "531" : "0000"}" ${Object.keys(extra).map(k=>`data-${k}="${extra[k]}"`).join(" ")}>${text}</div>`)
      }
    }
  })
  list.querySelector(`.listed-macro[data-id="showAll"]`).addEventListener("click", ()=>{
    showAll = true
    list.querySelector(`.listed-macro[data-id="showAll"]`).style.display = "none"
    for(let e of list.querySelectorAll(`.listed-macro[data-id]`)){
      if(+e.dataset.id>=0){e.remove()}
    }
    listMacros()
  })
  list.querySelector(`.listed-macro[data-id="cancel"]`).addEventListener("click", ()=>{
    cancel++
    list.querySelector(`.listed-macro[data-id="cancel"]`).style.backgroundColor = "#422"
  })
  recording.querySelector(`.listed-macro[data-id="stop"]`).children[0].addEventListener("click", ()=>{
    let name = prompt("Macro name?")
    if(!name){return}
    let macro = {
      name,
      actions:[],
      parties:[partyHash],
    }
    for(let action of recording.querySelectorAll(".listed-macro[data-type]")){
      action.remove()
      if(action.dataset.type==="macro" && action.dataset.macro===undefined){continue}
      macro.actions.push({
        name:action.innerText,
        ...action.dataset,
      })
    }
    for(let a of macro.actions){
      if(a.type==="macro"){a.macro = +a.macro}
    }
    macros.push(macro)
    createListedMacro(macros.length-1)
    list.style.display = null
    recording.style.display = "none"
    GM_setValue("macros", macros)
    recordFunction = null
  })
  recording.querySelector(`.listed-macro[data-id="stop"]`).children[1].addEventListener("click", ()=>{
    for(let action of recording.querySelectorAll(".listed-macro[data-type]")){
      action.remove()
    }
    list.style.display = null
    recording.style.display = "none"
    recordFunction = null
  })
  recording.querySelector(`.listed-macro[data-id="stop"]`).children[2].addEventListener("click", ()=>{
    recording.insertAdjacentHTML("beforeend", `<div class="listed-macro" style="background-color:#437" data-type="macro"><select class="new-select-thing"><option default>Select a macro...</option>${macros.map((m,i)=>`<option value="${i}">${m.name}</option>`)}</select></div>`)
    let select = recording.querySelector(".new-select-thing")
    select.className = null
    select.addEventListener("change", ()=>{
      select.parentElement.dataset.macro = select.value
      select.parentElement.innerText = macros[+select.value].name
    })
  })

  settings.children[0].addEventListener("click", ()=>{
    settings.style.display = "none"
    list.style.display = null
  GM_setValue("macros", macros)})
  settings.children[2].addEventListener("click", ()=>{
    let name = prompt("New macro name")
    if(!name){return}
    macros[+settings.dataset.macro].name = name
    settings.children[1].innerText = name
    list.querySelector(`[data-id="${+settings.dataset.macro}"] a`).innerText = name
  GM_setValue("macros", macros)})
  settings.children[3].addEventListener("click", ()=>{
    let macro = macros[+settings.dataset.macro]
    let i = macro.parties.findIndex(p=>p===partyHash)
    if(i===-1){
      macro.parties.push(partyHash)
    }else{
      macro.parties.splice(i,1)
    }
    settings.children[3].innerText = i===-1 ? "Don't show for this party" : "Show for this party"
  GM_setValue("macros", macros)})
  settings.children[4].addEventListener("click", ()=>{
    let macro = macros[+settings.dataset.macro]
    if(macro.parties){
      delete macro.parties
    }else{
      macro.parties = [partyHash]
    }
    settings.children[3].innerText = "Don't show for this party"
    settings.children[3].style.display = !macro.parties ? "none" : null
    settings.children[4].innerText = !macro.parties ? "Don't always show" : "Always show"
  GM_setValue("macros", macros)})
  settings.children[5].addEventListener("click", ()=>{
    list.insertAdjacentHTML("afterbegin", `<div class="listed-macro" data-id="moveAfter">Move macro after...</div>`)
    let line = list.querySelector(`.listed-macro[data-id="moveAfter"]`)
    moveMode = element=>{
      let before = +settings.dataset.macro; let after = +element.dataset.id || 0
      for(let m of macros){
        for(let a of m.actions){
          if(a.type!=="macro"){continue}
          if(a.macro===before){
            a.macro = after
          }else if(a.macro<before && a.macro>=after){
            a.macro++
          }else if(a.macro>before && a.macro<=after){
            a.macro--
          }
        }
      }
      let macro = macros[before]
      macros[before] = null
      macros.splice(after>=0 ? after +1 : 0, 0, macro)
      macros.splice(macros.findIndex(m=>!m), 1)
      for(let e of list.querySelectorAll(`.listed-macro[data-id]`)){
        if(+e.dataset.id>=0){e.remove()}
      }
      listMacros()
      moveMode = undefined
      settings.style.display = null
      list.style.display = "none"
      list.querySelector(`.listed-macro[data-id="moveAfter"]`).remove()
      if(!showAll){
        list.querySelector(`.listed-macro[data-id="showAll"]`).style.display = null
      }
    GM_setValue("macros", macros)}
    list.querySelector(`.listed-macro[data-id="showAll"]`).style.display = "none"
    line.addEventListener("click", ()=>{
      moveMode(line)
    })
    settings.style.display = "none"
    list.style.display = null
  })
  settings.children[6].querySelector("select").addEventListener("change", ()=>{
    macros[+settings.dataset.macro].speed = settings.children[6].querySelector("select").value
  GM_setValue("macros", macros)})
  settings.children[7].addEventListener("click", ()=>{
    if(!confirm("Delete the macro?")){return}
    let i = +settings.dataset.macro
    list.querySelector(`[data-id="${i}"]`).remove()
    macros.splice(+settings.dataset.macro, 1)
    for(let e of list.querySelectorAll("[data-id]")){
      if(e.dataset.id>i){
        e.dataset.id = +e.dataset.id -1
      }
    }
    for(let m of macros){
      for(let a of m.actions){
        if(a.type==="macro" && a.macro>i){a.macro--}
      }
    }
    settings.style.display = "none"
    list.style.display = null
  GM_setValue("macros", macros)})
}

window.addEventListener("hashchange", onPage)
onPage()