TremblingMoeNew / StarRailWarpObserve

星穹铁道跃迁观测工具(Star Rail Warp Observe, STARWO), a Honkai: Star Rail warp(gacha) log export tool
https://starwo.dodocotales.cc
MIT License
56 stars 1 forks source link

关于 GachaMetaData #2

Closed ShikiSuen closed 3 months ago

ShikiSuen commented 3 months ago

这个 issue 假定该仓库的主人是 UIGF 群的 Haruhikage。如果不是的话,那这个 issue 可能跑题了。

image

生成 Gacha Metadata 资料库呢,图的就是一个新鲜。Dimbreath 的资料库是最新鲜的,但也是最原始的资料。 我这边用 Swift 搞了个 GachaMetaDB JSON 生成脚本,估计你能用上。

生成的内容范例(节选):


{
  "1001" : {
    "id" : 1001,
    "l10nMap" : {
      "de-de" : "7. März",
      "en-us" : "March 7th",
      "es-es" : "Siete de Marzo",
      "fr-fr" : "March 7th",
      "id-id" : "March 7th",
      "ja-jp" : "三月なのか",
      "ko-kr" : "Mar. 7th",
      "pt-pt" : "7 de Março",
      "ru-ru" : "Март 7",
      "th-th" : "March 7th",
      "vi-vn" : "March 7th",
      "zh-cn" : "三月七",
      "zh-tw" : "三月七"
    },
    "nameTextMapHash" : -531793651,
    "rank" : 4
  },
  "24004" : {
    "id" : 24004,
    "l10nMap" : {
      "de-de" : "Unermüdliche Berechnung",
      "en-us" : "Eternal Calculus",
      "es-es" : "Cálculo interminable",
      "fr-fr" : "Calcul éternel",
      "id-id" : "Eternal Calculus",
      "ja-jp" : "絶え間ない演算",
      "ko-kr" : "멈추지 않는 연산",
      "pt-pt" : "Cálculo Ininterrupto",
      "ru-ru" : "Беспрерывные вычисления",
      "th-th" : "Eternal Calculus",
      "vi-vn" : "Ngoại Suy Vô Tận",
      "zh-cn" : "不息的演算",
      "zh-tw" : "不息的演算"
    },
    "nameTextMapHash" : 330597463,
    "rank" : 5
  }
}

原始脚本,直接在终端机里面跑 然后用 pipeline 输出到 JSON 即可

// (c) 2023 and onwards Pizza Studio (GPL v3.0 License).
// ====================
// This code is released under the GPL v3.0 License (SPDX-License-Identifier: GPL-3.0)

import Foundation

// MARK: - ProtagonistDetector

public enum ProtagonistDetector {
    case ofCaelus
    case ofStelle

    // MARK: Lifecycle

    public init?(rawValue: Int) {
        switch rawValue {
        case 8001, 8003, 8005, 8007, 8009, 8011, 8013, 8015: self = .ofCaelus
        case 8002, 8004, 8006, 8008, 8010, 8012, 8014, 8016: self = .ofStelle
        default: return nil
        }
    }

    public init?(against target: GachaItemMeta) {
        guard let map = target.l10nMap,
              map.description.contains(#"{NICKNAME}"#)
        else { return nil }
        self = (target.id % 2 == 0) ? .ofStelle : .ofCaelus
    }
}

// MARK: - GachaDictLang

public enum GachaDictLang: String, CaseIterable, Sendable, Identifiable {
    case langCHS
    case langCHT
    case langDE
    case langEN
    case langES
    case langFR
    case langID
    case langJP
    case langKR
    case langPT
    case langRU
    case langTH
    case langVI

    // MARK: Public

    public static let tableStelle = [
        "de-de": "Stella",
        "en-us": "Stelle",
        "es-es": "Estela",
        "fr-fr": "Stelle",
        "id-id": "Stelle",
        "ja-jp": "星",
        "ko-kr": "스텔레",
        "pt-pt": "Stelle",
        "ru-ru": "Стелла",
        "th-th": "Stelle",
        "vi-vn": "Stelle",
        "zh-cn": "星",
        "zh-tw": "星",
    ]

    public static let tableCaelus = [
        "de-de": "Caelus",
        "en-us": "Caelus",
        "es-es": "Caelus",
        "fr-fr": "Caelus",
        "id-id": "Caelus",
        "ja-jp": "穹",
        "ko-kr": "카일루스",
        "pt-pt": "Caelus",
        "ru-ru": "Келус",
        "th-th": "Caelus",
        "vi-vn": "Caelus",
        "zh-cn": "穹",
        "zh-tw": "穹",
    ]

    public var id: String { langID }

    public var langID: String {
        switch self {
        case .langCHS: "zh-cn"
        case .langCHT: "zh-tw"
        case .langDE: "de-de"
        case .langEN: "en-us"
        case .langES: "es-es"
        case .langFR: "fr-fr"
        case .langID: "id-id"
        case .langJP: "ja-jp"
        case .langKR: "ko-kr"
        case .langPT: "pt-pt"
        case .langRU: "ru-ru"
        case .langTH: "th-th"
        case .langVI: "vi-vn"
        }
    }

    public var filename: String {
        rawValue.replacingOccurrences(of: "lang", with: "TextMap").appending(".json")
    }

    public var url: URL! {
        URL(string: """
        https://raw.githubusercontent.com/Dimbreath/StarRailData/master/TextMap/\(filename)
        """)
    }
}

// MARK: - QualityType

public enum QualityType: String, Codable {
    case v5sp = "QUALITY_ORANGE_SP"
    case v5 = "QUALITY_ORANGE"
    case v4 = "QUALITY_PURPLE"
    case v3 = "QUALITY_BLUE"
    case v2 = "QUALITY_GREEN"
    case v1 = "QUALITY_GRAY"

    // MARK: Internal

    var asrank: Int {
        switch self {
        case .v5, .v5sp: return 5
        case .v4: return 4
        case .v3: return 3
        case .v2: return 2
        case .v1: return 1
        }
    }
}

// MARK: - NameHashUnit

public struct NameHashUnit: Codable {
    public enum CodingKeys: String, CodingKey {
        case hash = "Hash"
    }

    public let hash: Int
}

// MARK: - GachaItemMeta

public class GachaItemMeta: Codable {
    // MARK: Lifecycle

    public init(id: Int, rank: Int, nameTextMapHash: Int) {
        self.id = id
        self.rank = rank
        self.nameTextMapHash = nameTextMapHash
    }

    // MARK: Public

    public let id: Int
    public let rank: Int
    public let nameTextMapHash: Int
    public var l10nMap: [String: String]?

    public var isCharacter: Bool {
        id <= 9999
    }
}

// MARK: - RawItemFetchModelProtocol

public protocol RawItemFetchModelProtocol {
    var id: Int { get }
    var nameTextMapHash: Int { get }
    var rarity: Int { get }
}

// MARK: - AvatarRawItem

public class AvatarRawItem: Codable, RawItemFetchModelProtocol {
    // MARK: Lifecycle

    public required init(from decoder: any Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        self.id = try container.decode(Int.self, forKey: .id)
        self.nameTextMapHash = (try container.decode(NameHashUnit.self, forKey: .nameTextMapHash)).hash
        let rawRarityText = try container.decode(String.self, forKey: .rarity)
        self.rarity = Int(rawRarityText.last?.description ?? "3") ?? 3
    }

    // MARK: Public

    public enum CodingKeys: String, CodingKey {
        case id = "AvatarID"
        case nameTextMapHash = "AvatarName"
        case rarity = "Rarity"
    }

    public let id: Int
    public let nameTextMapHash: Int
    public let rarity: Int
}

// MARK: - WeaponRawItem

public class WeaponRawItem: Codable, RawItemFetchModelProtocol {
    // MARK: Lifecycle

    public required init(from decoder: any Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        self.id = try container.decode(Int.self, forKey: .id)
        self.nameTextMapHash = (try container.decode(NameHashUnit.self, forKey: .nameTextMapHash)).hash
        let rawRarityText = try container.decode(String.self, forKey: .rarity)
        self.rarity = Int(rawRarityText.last?.description ?? "3") ?? 3
    }

    // MARK: Public

    public enum CodingKeys: String, CodingKey {
        case id = "EquipmentID"
        case nameTextMapHash = "EquipmentName"
        case rarity = "Rarity"
    }

    public let id: Int
    public let nameTextMapHash: Int
    public let rarity: Int
}

extension RawItemFetchModelProtocol {
    public func toGachaItemMeta() -> GachaItemMeta {
        .init(id: id, rank: rarity, nameTextMapHash: nameTextMapHash)
    }
}

let urlHeader = """
https://raw.githubusercontent.com/Dimbreath/StarRailData/master/ExcelOutput/
"""

let urlAvatarJSON = URL(string: urlHeader + "AvatarConfig.json")!
let urlWeaponJSON = URL(string: urlHeader + "EquipmentConfig.json")!

func fetchAvatars() async throws -> [GachaItemMeta] {
    let (data, _) = try await URLSession.shared.data(from: urlAvatarJSON)
    let response = try JSONDecoder().decode([String: AvatarRawItem].self, from: data)
    return response.map { $0.value.toGachaItemMeta() }
}

func fetchWeapons() async throws -> [GachaItemMeta] {
    let (data, _) = try await URLSession.shared.data(from: urlWeaponJSON)
    let response = try JSONDecoder().decode([String: WeaponRawItem].self, from: data)
    return response.map { $0.value.toGachaItemMeta() }
}

let items = try await withThrowingTaskGroup(of: [GachaItemMeta].self, returning: [GachaItemMeta].self) { taskGroup in
    taskGroup.addTask { try await fetchAvatars() }
    taskGroup.addTask { try await fetchWeapons() }
    var images = [GachaItemMeta]()
    for try await result in taskGroup {
        images.append(contentsOf: result)
    }
    return images
}

let neededHashIDs = Set<String>(items.map(\.nameTextMapHash.description))

// MARK: - Get translations from AnimeGameData

let dictAll = try await withThrowingTaskGroup(
    of: (subDict: [String: String], lang: GachaDictLang).self,
    returning: [String: [String: String]].self
) { taskGroup in
    GachaDictLang.allCases.forEach { locale in
        taskGroup.addTask {
            let (data, _) = try await URLSession.shared.data(from: locale.url)
            var dict = try JSONDecoder().decode([String: String].self, from: data)
            let keysToRemove = Set<String>(dict.keys).subtracting(neededHashIDs)
            keysToRemove.forEach { dict.removeValue(forKey: $0) }
            if locale == .langJP {
                dict.keys.forEach { theKey in
                    guard dict[theKey]?.contains("{RUBY") ?? false else { return }
                    if let rawStrToHandle = dict[theKey], rawStrToHandle.contains("{") {
                        dict[theKey] = rawStrToHandle.replacingOccurrences(
                            of: #"\{RUBY.*?\}"#,
                            with: "",
                            options: .regularExpression
                        )
                    }
                }
            }
            return (subDict: dict, lang: locale)
        }
    }
    var results = [String: [String: String]]()
    for try await result in taskGroup {
        results[result.lang.langID] = result.subDict
    }
    return results
}

// MARK: - Apply translations

items.forEach { currentItem in
    GachaDictLang.allCases.forEach { localeID in
        let hashKey = currentItem.nameTextMapHash.description
        guard let dict = dictAll[localeID.langID]?[hashKey] else { return }
        if currentItem.l10nMap == nil { currentItem.l10nMap = [:] }
        currentItem.l10nMap?[localeID.langID] = dict
    }
}

// MARK: - Prepare Dictionary.

var dict: [String: GachaItemMeta] = [:]

items.forEach { item in
    guard let desc = item.l10nMap?.description,
          !desc.contains("测试")
    else { return }
    let key = item.id.description
    protagonistName: switch ProtagonistDetector(against: item) {
    case .ofCaelus: item.l10nMap = GachaDictLang.tableCaelus
    case .ofStelle: item.l10nMap = GachaDictLang.tableStelle
    default: break protagonistName
    }
    dict[key] = item
}

let encoder = JSONEncoder()
encoder.outputFormatting = [.sortedKeys, .prettyPrinted]

let encoded = String(data: try encoder.encode(dict), encoding: .utf8)

print(encoded ?? "Error happened.")
NSLog("All Tasks Done.")

这个脚本还可以弄成机器人,监视 Dimbreath 的仓库变化,一有变化就立刻生成新的资料且自动开 PR。

ShikiSuen commented 3 months ago

上述内容已经整合到这个 SPM 里面了:https://github.com/pizza-studio/GachaMetaGenerator