小丑牌 Balatro 卡牌定位工具

The Soul 的搜索增强版,帮助快速定位目标牌和位置。内置 蓝图、头脑、可乐的搜索,其他自行调用函数找到 `window.findCardInAnte(底注, 英文牌名)`

// ==UserScript==
// @name          小丑牌 Balatro 卡牌定位工具
// @namespace     balatro-soul-util
// @version       0.0.6
// @description   The Soul 的搜索增强版,帮助快速定位目标牌和位置。内置 蓝图、头脑、可乐的搜索,其他自行调用函数找到 `window.findCardInAnte(底注, 英文牌名)`
// @author        liam61
// @match         mathisfun0.github.io/The-Soul/
// @license       MIT
// @grant         unsafeWindow
// @grant         GM_getValue
// @grant         GM.getValue
// @grant         GM_setValue
// @grant         GM.setValue
// @charset       UTF-8
// @github        https://github.com/liam61/the-soul-card-util

// ==/UserScript==

;(function (global) {
  const minAnte = 1
  const maxAnte = 40
  const presetCardsMap = {
    FlushFive: ['Blueprint', 'Brainstorm', 'Diet Cola', 'Showman', 'The Idol', 'Sock and Buskin'],
    同花五条流: ['Blueprint', 'Brainstorm', 'Diet Cola', 'Showman', 'The Idol', 'Sock and Buskin'],

    HighCard: ['Blueprint', 'Brainstorm', 'Diet Cola', 'Showman', 'Baron', 'Mime'],
    高牌流: ['Blueprint', 'Brainstorm', 'Diet Cola', 'Showman', 'Baron', 'Mime'],

    // Juggler, Troubadour
    LiamsFlushFive: [
      'Blueprint',
      'Brainstorm',
      'Diet Cola',
      { card: 'Scary Face', range: [1, 10] },
      { card: 'Hanging Chad', range: [1, 18] },
      { card: 'Photograph', range: [1, 18] },

      { card: 'Midas Mask', range: [6, 9] },
      { card: 'Golden Ticket', range: [8, 33] },
      { card: 'Seance', range: [8, 10] },

      { card: 'Showman', range: [10, 33] },
      { card: 'Sock and Buskin', range: [16, 33] },
      { card: 'The Idol', range: [18, 33] },
    ],
  }

  /**
   *
   * @param {number} ante 底注
   * @param {string | string[]} targets 目标牌,可以是单张牌,也可以是多张牌数组。可选参数
   * @param {object} config 可选参数
   * @param {'index' | 'card'} config.orderBy 排序方式,index: 按照位置顺序,card: 按照牌的顺序
   * @param {'FlushFive' | 'HighCard'} config.preset 预设的牌组,FlushFive: 同花五条流,HighCard: 高牌流
   * @param {2 | 3 | 4} config.stockSize 牌库大小,默认 3
   */
  const findCardsInAnte = (ante, targets = [], config) => {
    if (typeof ante !== 'number') {
      console.error('[ante error]: should be a number')
      alert('[ante error]: 底注应该是一个数字')
    }

    // 兼容未传 targets,TODO: 考虑其他优雅传参
    if (targets && typeof targets === 'object' && !Array.isArray(targets) && config == null) {
      config = targets
      targets = []
    }

    if (!(Array.isArray(targets) || typeof targets === 'string' || targets == null)) {
      console.error('[targets error]: should be an array or string')
      alert('[targets error]: 目标牌应该是一个数组或字符串')
    }

    if (!(typeof config === 'object' || config == null)) {
      console.error('[config error]: should be an object')
      alert('[config error]: 配置选项应该是一个对象')
    }

    const { orderBy = 'index', preset = 'FlushFive', stockSize = 3 } = config || {}

    // ==Utils==
    const parsePresetCard = (item) => {
      if (typeof item === 'object' && Array.isArray(item?.range)) {
        return {
          card: item.card,
          min: ~~+item.range[0] || minAnte,
          max: ~~+item.range[1] || maxAnte,
        }
      }

      if (typeof item === 'string') {
        return {
          card: item,
          min: minAnte,
          max: maxAnte,
        }
      }

      return {}
    }

    /**
     *
     * @param {number} ante
     * @param {( string | { card: string, range: [number, number] } )} target
     */
    const findIndexOfCard = (ante, target) => {
      const { card, min, max } = parsePresetCard(target)

      if (typeof card !== 'string' || !(min <= ante && ante <= max)) return []

      const anteEl = document.querySelector('#scrollingContainer').children[ante - 1]
      if (!anteEl) return []

      const cardListEl = anteEl.querySelector('.scrollable')

      const arr = [...cardListEl.children].reduce((arr, cardEl, index) => {
        const el = cardEl.querySelector('div')

        if (el.textContent.includes(card)) {
          arr.push({ card, index })
        }
        return arr
      }, [])

      return arr
    }

    const print = (targetCardList) => {
      const msgArr = targetCardList.map(({ card, index }) => {
        const realIndex = index + 1
        const order = realIndex % stockSize

        return `[ante ${ante}] ${card.padEnd(20, ' ')}\tindex: ${realIndex}, round: ${Math.ceil(
          realIndex / stockSize
        )}, order: ${order != 0 ? order : stockSize}`
      })

      if (targetCardList.length) {
        console.log(msgArr.join('\n'))
        console.log('\n')
      }
    }
    // ==/Utils==

    // ==Output==
    const finalTargets = targets?.length
      ? Array.isArray(targets)
        ? targets
        : [targets]
      : presetCardsMap[preset]
    // TODO: optimize algorithm
    const targetLists = finalTargets.map((target) => findIndexOfCard(ante, target))

    let results = []
    if (orderBy === 'card') {
      results = targetLists.sort((list1, list2) => list1[0]?.index - list2[0]?.index)
      results.forEach(print)
    } else if (orderBy === 'index') {
      results = targetLists
        .reduce((arr, item) => {
          arr.push(...item)
          return arr
        }, [])
        .sort((item1, item2) => item1.index - item2.index)
      print(results)
    }

    return results
  }
  // ==/Output==

  // ==Export==
  global.findCardsInAnte = findCardsInAnte
  global.findCardsInAllAntes = (targets, config) => {
    return Array.from({ length: maxAnte }, (_, i) => i + 1).map((ante) =>
      findCardsInAnte(ante, targets, config)
    )
  }
  // ==/Export==

  global.findCardsInAllAntes({ preset: 'LiamsFlushFive' })
})(typeof unsafeWindow !== 'undefined' ? unsafeWindow : window)