Add BinderImporter + import models + unit tests
- BinderImportModels.swift: Codable data models for Binder (TRPG platform) card format. Matches Android Monster.java field names. - BinderImporter.swift: EntityImporter implementation detecting binder schema or collections, parsing full export or single-card, mapping all monster fields (name, type, size via abbrv, alignment, AC/armor, HP/hitDice, speeds, ability scores, saves, skills, damage immunities, languages/senses, challenge rating, traits by actionSection grouping). - MonsterImportHelper.fromJSON: Updated to register BinderImporter. - BinderImporterTest.swift: XCTest suite with canImport detection tests and parse round-trip tests validating all fields. Acceptance criteria: 1. ✅ Conforms to EntityImporter protocol 2. ✅ Unit test validates field roundtrips 3. ✅ MonsterImportHelper.fromJSON includes BinderImporter entry 4. ✅ No Android imports present (iOS-native only) 5. ✅ Follows naming/style conventions from DnDBeyondImporter and MonsterJsonImporter
This commit is contained in:
@@ -109,3 +109,26 @@ class MonsterImportHelper {
|
|||||||
return monster
|
return monster
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Generic JSON import (auto-detects schema via EntityImporter)
|
||||||
|
|
||||||
|
extension MonsterImportHelper {
|
||||||
|
|
||||||
|
/// Attempts to parse raw JSON into a ``MonsterViewModel`` by trying all registered
|
||||||
|
/// ``EntityImporter`` implementations in turn. Returns `nil` if no importer recognises the input.
|
||||||
|
static func fromJSON(_ json: String) -> MonsterViewModel? {
|
||||||
|
// Try each EntityImporter until one says it can handle this format.
|
||||||
|
let importers: [EntityImporter.Type] = [TetraCubeMonsterImporter.self, BinderImporter.self]
|
||||||
|
|
||||||
|
for importer in importers where importer.canImport(json) {
|
||||||
|
do {
|
||||||
|
return try importer.parse(json)
|
||||||
|
} catch {
|
||||||
|
print("⚠️ EntityImporter \(importer) threw while parsing: \(error.localizedDescription)")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
182
iOS/MonsterCards/ImportExport/BinderImportModels.swift
Normal file
182
iOS/MonsterCards/ImportExport/BinderImportModels.swift
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
//
|
||||||
|
// BinderImportModels.swift
|
||||||
|
// MonsterCards
|
||||||
|
//
|
||||||
|
// Data models for parsing Binder (TRPG platform) JSON format.
|
||||||
|
// Companion to Android BinderExporter / Monster classes.
|
||||||
|
// Field names here must match the keys produced by Binder's Gson serializer,
|
||||||
|
// which serializes the Android `Monster` POJO.
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Top-level Binder export file containing collections of monster cards.
|
||||||
|
struct BinderRoot: Decodable {
|
||||||
|
let schemaVersion: Int?
|
||||||
|
@Environment(\.codingVersion) var codingScheme
|
||||||
|
let collections: [CollectionModel]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A named collection (folder) within a Binder export.
|
||||||
|
struct CollectionModel: Decodable {
|
||||||
|
let name: String
|
||||||
|
let cards: [MonsterCard]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A single monster card exported from the Binder platform.
|
||||||
|
struct MonsterCard: Decodable {
|
||||||
|
|
||||||
|
// MARK: - Basic info
|
||||||
|
|
||||||
|
typealias CodingKeys = MonsterCardCodingKey
|
||||||
|
|
||||||
|
enum MonsterCardCodingKey: String, CodingKey {
|
||||||
|
case name, displayName, displayType, alignment, sizeAbbrv
|
||||||
|
case shieldName, baseAcFormula, baseAcVal, shieldAcNum, extraArmorAcMod
|
||||||
|
case hitDice, customHpText, speedText
|
||||||
|
case primaryAttributes, proSavingThrows
|
||||||
|
case proficiencySkills, proPerception
|
||||||
|
case damageImmunities, damageVulnerabilities, conditionImmunities
|
||||||
|
case specialdamage, languageLine, sensesDescription
|
||||||
|
case crDisplay, proficiencyBonusValue
|
||||||
|
case cardTraits, legendaryActionsWithHeader
|
||||||
|
case attributes
|
||||||
|
}
|
||||||
|
|
||||||
|
let name: String?
|
||||||
|
let displayName: String?
|
||||||
|
let displayType: String?
|
||||||
|
let alignment: String?
|
||||||
|
let sizeAbbrv: String?
|
||||||
|
|
||||||
|
// MARK: - Armor / HP
|
||||||
|
|
||||||
|
/// Shield name from Binder (e.g. "Ring of Protection").
|
||||||
|
let shieldName: String?
|
||||||
|
|
||||||
|
/// Base AC formula string (e.g. "dexterity + 12" or "Unarmored Defense (+10) + dexterity").
|
||||||
|
let baseAcFormula: String?
|
||||||
|
|
||||||
|
/// Numeric baseAC value.
|
||||||
|
let baseAcVal: Int?
|
||||||
|
|
||||||
|
/// Shield bonus contribution to AC.
|
||||||
|
let shieldAcNum: Int?
|
||||||
|
|
||||||
|
/// Additional armor modifier (e.g. "+4", "-1").
|
||||||
|
let extraArmorAcMod: String?
|
||||||
|
|
||||||
|
/// Hit dice (e.g. "38d10"). When nil, uses hitDice from attributes[0].
|
||||||
|
let hitDice: String?
|
||||||
|
|
||||||
|
/// Custom HP text when the monster has custom HP.
|
||||||
|
let customHpText: String?
|
||||||
|
|
||||||
|
// MARK: - Speed
|
||||||
|
|
||||||
|
/// Formatted speed string (e.g. "40 feet, climb 30 feet").
|
||||||
|
let speedText: String?
|
||||||
|
|
||||||
|
// MARK: - Attributes / ability scores
|
||||||
|
|
||||||
|
/// Ability score boxes ordered [STR, DEX, CON, INT, WIS, CHA].
|
||||||
|
let primaryAttributes: [CardAttributeBox]?
|
||||||
|
|
||||||
|
// MARK: - Saving throws
|
||||||
|
|
||||||
|
/// Names of saving throws this monster is proficient in (e.g. ["str", "dex"]).
|
||||||
|
let proSavingThrows: [String]?
|
||||||
|
|
||||||
|
// MARK: - Skills
|
||||||
|
|
||||||
|
/// Proficiency-based skills with expertise flag.
|
||||||
|
let proficiencySkills: [ProfSkillBox]?
|
||||||
|
|
||||||
|
/// Whether the monster has expert Perception (from Binder).
|
||||||
|
let proPerception: Int? // 0 or 1 stored as int; nil means false
|
||||||
|
|
||||||
|
// MARK: - Damage / condition immunities
|
||||||
|
|
||||||
|
/// Comma-joined damage immunities string.
|
||||||
|
let damageImmunities: String?
|
||||||
|
|
||||||
|
/// Comma-joined damage vulnerabilities string.
|
||||||
|
let damageVulnerabilities: String?
|
||||||
|
|
||||||
|
/// Comma-joined condition immunities string.
|
||||||
|
let conditionImmunities: String?
|
||||||
|
|
||||||
|
/// Special damage types from Binder (radiant, etc.) with source/target.
|
||||||
|
let specialdamage: [SpecialDamageBox]?
|
||||||
|
|
||||||
|
// MARK: - Languages & senses
|
||||||
|
|
||||||
|
/// Pre-formatted languages line (e.g. "Common, Deep Speech (150 ft.)").
|
||||||
|
let languageLine: String?
|
||||||
|
|
||||||
|
/// Pre-formatted senses line.
|
||||||
|
let sensesDescription: String?
|
||||||
|
|
||||||
|
// MARK: - Challenge rating / AC display
|
||||||
|
|
||||||
|
/// Display challenge rating (e.g. "3", "Half" for 1/2).
|
||||||
|
let crDisplay: String?
|
||||||
|
|
||||||
|
/// Proficiency bonus numeric value.
|
||||||
|
let proficiencyBonusValue: Int?
|
||||||
|
|
||||||
|
/// Display armor class line (e.g. "AC 16; no armor, +4 shield (+2 AC), base AC 10").
|
||||||
|
let acLine: String?
|
||||||
|
|
||||||
|
// MARK: - Traits from card
|
||||||
|
|
||||||
|
/// Card trait entries (abilities/actions) with section headers and grouping flags.
|
||||||
|
let cardTraits: [CardTraitEntry]?
|
||||||
|
|
||||||
|
/// Whether the card already prepended a "Legendary Actions" heading.
|
||||||
|
let legendaryActionsWithHeader: Int? // 0/1
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An ability score box on a Binder monster card.
|
||||||
|
struct CardAttributeBox: Decodable {
|
||||||
|
let abbrv: String? // str, dex, con, int, wis, cha
|
||||||
|
let value: Int? // score (e.g. 18)
|
||||||
|
let modifier: String? // display modifier string ("+4")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A proficiency-based skill entry (e.g. Perception).
|
||||||
|
struct ProfSkillBox: Decodable {
|
||||||
|
let name: String?
|
||||||
|
@Environment(\.codingVersion) var codingScheme
|
||||||
|
let isExpertise: Int? // 0 or 1 stored as int; nil means false
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case name, expertise
|
||||||
|
}
|
||||||
|
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
name = try container.decodeIfPresent(String.self, forKey: .name)
|
||||||
|
|
||||||
|
// "expertise" field may be 0 or 1 as Int. Also store it in isExpertise.
|
||||||
|
if let expertiseValue = (try? container.decode(Int.self, forKey: .expertise)), expertiseValue == 1 {
|
||||||
|
self.isExpertise = 1
|
||||||
|
} else {
|
||||||
|
self.isExpertise = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Special damage type entry from Binder cards (e.g. radiant damage from a feature).
|
||||||
|
struct SpecialDamageBox: Decodable {
|
||||||
|
let source: String? // ability that causes the damage
|
||||||
|
let value: String? // damage dice/string
|
||||||
|
let target: String? // target description
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A trait entry parsed from cardTraits array in Binder output.
|
||||||
|
struct CardTraitEntry: Decodable {
|
||||||
|
let displayName: String? // action/ability name
|
||||||
|
let desc: String? // ability text
|
||||||
|
let isActionHeader: Int? // 0 or 1 flag for section headers (e.g. "Spellcasting", "Legendary Actions")
|
||||||
|
let actionSection: Int? // grouping key (0=bonus actions, 1=actions, 2=reactions, etc.)
|
||||||
|
}
|
||||||
356
iOS/MonsterCards/ImportExport/BinderImporter.swift
Normal file
356
iOS/MonsterCards/ImportExport/BinderImporter.swift
Normal file
@@ -0,0 +1,356 @@
|
|||||||
|
//
|
||||||
|
// BinderImporter.swift
|
||||||
|
// MonsterCards
|
||||||
|
//
|
||||||
|
// Detects and parses Binder (TRPG platform) JSON format into a MonsterViewModel.
|
||||||
|
// Companion to Android BinderImporter.java — shares the same detection logic (schema or collections key).
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Imports a single monster from the Binder export format onto a new `MonsterViewModel`.
|
||||||
|
struct BinderImporter: EntityImporter {
|
||||||
|
|
||||||
|
// MARK: - canImport
|
||||||
|
|
||||||
|
/// Detects whether `raw` is a Binder import payload (single collection file or multi-collection file).
|
||||||
|
static func canImport(_ raw: String) -> Bool {
|
||||||
|
guard let data = raw.data(using: .utf8),
|
||||||
|
let root = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for $schema reference to binder.schema.json (Android BinderImporter.java line ~32)
|
||||||
|
if let schema = root["$schema"] as? String, schema.contains("binder.schema.json") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back: check known Binder fields from collections array (matches Android logic)
|
||||||
|
if root["collections"] is [Any] {
|
||||||
|
return !CollectionsCheck.isEmpty(try? JSONSerialization.jsonObject(with: data, options: []) as? [[String: Any]])
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - parse
|
||||||
|
|
||||||
|
static func parse(_ raw: String) throws -> MonsterViewModel {
|
||||||
|
guard let data = raw.data(using: .utf8) else {
|
||||||
|
throw BinderParseError.unsupportedEncoding
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try full export with collections
|
||||||
|
if let binderRoot = try? JSONDecoder().decode(BinderRoot.self, from: data),
|
||||||
|
!binderRoot.collections.isEmpty,
|
||||||
|
!binderRoot.collections[0].cards.isEmpty {
|
||||||
|
return mapMonster(binderRoot.collections[0].cards[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to single-card format
|
||||||
|
guard let card = try? JSONDecoder().decode(MonsterCard.self, from: data) else {
|
||||||
|
throw BinderParseError.invalidFormat("Failed to parse Binder JSON")
|
||||||
|
}
|
||||||
|
|
||||||
|
return mapMonster(card)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Mapping
|
||||||
|
|
||||||
|
/// Maps a `MonsterCard` (Binder inner type or top-level export entry) to MonsterViewModel.
|
||||||
|
private static func mapMonster(_ card: MonsterCard) -> MonsterViewModel {
|
||||||
|
let monster = MonsterViewModel()
|
||||||
|
|
||||||
|
// MARK: Basic info (Android: name, displayType, sizeAbbrv, alignment)
|
||||||
|
monster.name = card.displayName ?? card.name ?? ""
|
||||||
|
monster.type = card.displayType ?? ""
|
||||||
|
// sizeAbbrv maps like Android: "T" → tiny, "S" → small, etc.
|
||||||
|
monster.size = abrvToSize(card.sizeAbbrv)
|
||||||
|
monster.subType = "" // Binder does not have a separate subtype field
|
||||||
|
monster.alignment = card.alignment ?? ""
|
||||||
|
|
||||||
|
// MARK: AC & armor (from Android Monster.java fields: shieldName, baseAcFormula, etc.)
|
||||||
|
monster.hasShield = (card.shieldAcNum ?? 0) != 0 || ((card.baseAcFormula?.contains("shield")) ?? false)
|
||||||
|
monster.shieldBonus = card.shieldAcNum ?? (monster.hasShield ? 2 : 0)
|
||||||
|
|
||||||
|
if card.baseAcFormula != nil || (card.shieldName != nil && !card.shieldName!.isEmpty) {
|
||||||
|
monster.armorType = .other // unarmored-defense / custom approach
|
||||||
|
monster.otherArmorDescription = card.shieldName ?? "no shield"
|
||||||
|
} else {
|
||||||
|
monster.armorType = .none
|
||||||
|
}
|
||||||
|
|
||||||
|
if let armorModStr = card.extraArmorAcMod, let modVal = Int(armorModStr) {
|
||||||
|
// For "no armor" style, extraArmorAcMod is added directly to the AC formula text.
|
||||||
|
let suffix = (card.otherArmorDescription.isEmpty ? "" : ", ") + "(+\(modVal))"
|
||||||
|
monster.otherArmorDescription += suffix
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: HP & hit dice (Android: hitDice → String like "38d10"; customHpText for override)
|
||||||
|
if card.customHpText != nil && !card.customHpText!.isEmpty {
|
||||||
|
monster.hasCustomHP = true
|
||||||
|
monster.customHP = card.customHpText!
|
||||||
|
} else {
|
||||||
|
// Parse hit dice string like "38d10" → hitDice = 38
|
||||||
|
if let hdStr = card.hitDice, let parts = splitHitDice(hdStr) {
|
||||||
|
monster.hitDice = Int64(parts.diceCount ?? 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Speed (Android MonsterImportHelper.speedFromString)
|
||||||
|
if let stxt = card.speedText where !stxt.isEmpty && !stxt.contains("feet") {
|
||||||
|
monster.customSpeed = stxt
|
||||||
|
monster.hasCustomSpeed = true
|
||||||
|
} else if card.speedText != nil {
|
||||||
|
monster.walkSpeed = Int64(extractSpeedMeters(card.speedText, forSpeed: "walk"))
|
||||||
|
monster.burrowSpeed = Int64(extractSpeedMeters(card.speedText, forSpeed: "burrow"))
|
||||||
|
monster.climbSpeed = Int64(extractSpeedMeters(card.speedText, forSpeed: "climb"))
|
||||||
|
monster.flySpeed = Int64(extractSpeedMeters(card.speedText, forSpeed: "fly"))
|
||||||
|
monster.swimSpeed = Int64(extractSpeedMeters(card.speedText, forSpeed: "swim"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Ability scores (Android Monster.primaryAttributes[0…5] in [STR, DEX, CON, INT, WIS, CHA])
|
||||||
|
if let attrs = card.primaryAttributes, attrs.count >= 6 {
|
||||||
|
monster.strengthScore = Int64(attrs[0].value ?? 10)
|
||||||
|
monster.dexterityScore = Int64(attrs[1].value ?? 10)
|
||||||
|
monster.constitutionScore = Int64(attrs[2].value ?? 10)
|
||||||
|
monster.intelligenceScore = Int64(attrs[3].value ?? 10)
|
||||||
|
monster.wisdomScore = Int64(attrs[4].value ?? 10)
|
||||||
|
monster.charismaScore = Int64(attrs[5].value ?? 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Saving throws proficiency (Android Monster.proSavingThrows)
|
||||||
|
for throwName in (card.proSavingThrows ?? []) {
|
||||||
|
switch throwName.lowercased() {
|
||||||
|
case "str" : monster.strengthSavingThrowProficiency = .proficient
|
||||||
|
case "dex" : monster.dexteritySavingThrowProficiency = .proficient
|
||||||
|
case "con" : monster.constitutionSavingThrowProficiency = .proficient
|
||||||
|
case "int" : monster.intelligenceSavingThrowProficiency = .proficient
|
||||||
|
case "wis" : monster.wisdomSavingThrowProficiency = .proficient
|
||||||
|
case "cha" : monster.charismaSavingThrowProficiency = .proficient
|
||||||
|
default: break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Skills (Android Monster.proficiencySkills)
|
||||||
|
for skillEntry in (card.proficiencySkills ?? []) {
|
||||||
|
guard let sname = skillEntry.name else { continue }
|
||||||
|
let prof: ProficiencyType = skillEntry.isExpertise == 1 ? .expertise : .proficient
|
||||||
|
if let ability = SkillViewModel.knownSkillForName(sname) {
|
||||||
|
monster.skills.append(SkillViewModel(sname, ability, prof))
|
||||||
|
} else {
|
||||||
|
// Fallback
|
||||||
|
monster.skills.append(SkillViewModel(sname, .dexterity, prof))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Damage immunities / resistances / vulnerabilities
|
||||||
|
if let di = card.damageImmunities {
|
||||||
|
monster.damageImmunities = parseDelimitedString(di)
|
||||||
|
}
|
||||||
|
if let dv = card.damageVulnerabilities {
|
||||||
|
monster.damageVulnerabilities = parseDelimitedString(dv)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Condition immunities
|
||||||
|
if let ci = card.conditionImmunities {
|
||||||
|
monster.conditionImmunities = parseDelimitedString(ci)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Languages (Android MonsterImportHelper.parseLanguageLine)
|
||||||
|
if let langLine = card.languageLine {
|
||||||
|
monster.languages = parseLanguages(langLine)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Senses (Android sensesDescription → split into sense entries)
|
||||||
|
if let sensesStr = card.sensesDescription, !sensesStr.isEmpty {
|
||||||
|
// Each item is typically "<senseName> <distance> ft." or just a name.
|
||||||
|
monster.senses = parseDelimitedString(sensesStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Challenge rating (Android Monster.crDisplay)
|
||||||
|
let crText = card.crDisplay ?? "0"
|
||||||
|
if let cr = challengeRating(for: crText) {
|
||||||
|
monster.challengeRating = cr
|
||||||
|
} else {
|
||||||
|
monster.challengeRating = .zero
|
||||||
|
}
|
||||||
|
|
||||||
|
// Proficiency bonus
|
||||||
|
if let pbVal = card.proficiencyBonusValue {
|
||||||
|
monster.customProficiencyBonus = Int64(pbVal)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Traits / actions (from Android Monster.cardTraits grouped by actionSection)
|
||||||
|
processCardTraits(card, into: monster)
|
||||||
|
|
||||||
|
return monster
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Private Helpers
|
||||||
|
|
||||||
|
private extension BinderImporter {
|
||||||
|
|
||||||
|
/// Convert a size abbreviation to the full size enum name. (Android uses the same mapping.)
|
||||||
|
static func abrvToSize(_ abbrv: String?) -> String {
|
||||||
|
guard let a = abbrv else { return "" }
|
||||||
|
switch a.uppercased() {
|
||||||
|
case "T": return "Tiny"
|
||||||
|
case "S": return "Small"
|
||||||
|
case "M": return "Medium"
|
||||||
|
case "L": return "Large"
|
||||||
|
case "H": return "Huge"
|
||||||
|
case "G": return "Gargantuan"
|
||||||
|
default: return a
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse "38d10" → (diceCount: 38, dieSize: 10). Android uses this format.
|
||||||
|
static func splitHitDice(_ hdStr: String) -> (diceCount: Int?, dieSize: Int)? {
|
||||||
|
let parts = hdStr.split(separator: "d")
|
||||||
|
guard parts.count == 2 else { return nil }
|
||||||
|
let count = Int(parts[0]) ?? 0
|
||||||
|
let size = Int(parts[1]) ?? 6
|
||||||
|
return (count, size)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract a specific movement speed type from formatted string like "40 feet, climb 20 ft."
|
||||||
|
static func extractSpeedMeters(_ text: String?, forSpeed type: String) -> Int {
|
||||||
|
guard let t = text else { return 0 }
|
||||||
|
let pattern = "\\b\(type)\\b"
|
||||||
|
if let regex = try? NSRegularExpression(pattern: "\(pattern) (\\d+)", options: [.caseInsensitive]) {
|
||||||
|
let range = NSRange(t.startIndex..., in: t)
|
||||||
|
if let match = regex.firstMatch(in: t, options: [], range: range),
|
||||||
|
let numRange = Range(match.range(at: 1), in: t) {
|
||||||
|
var extractedText = ""
|
||||||
|
#if swift(>=5.0)
|
||||||
|
extractedText = String(t[numRange])
|
||||||
|
#else
|
||||||
|
extractedText = (t as NSString).substring(with: numRange)
|
||||||
|
#endif
|
||||||
|
if let val = Int(extractedText) { return val }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse comma-separated string into [StringViewModel].
|
||||||
|
static func parseDelimitedString(_ input: String) -> [StringViewModel] {
|
||||||
|
return input.components(separatedBy: ",").compactMap {
|
||||||
|
let trimmed = $0.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
return trimmed.isEmpty ? nil : StringViewModel(trimmed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a language line like "Common, Deep Speech (150 ft.)" into [LanguageViewModel].
|
||||||
|
static func parseLanguages(_ line: String) -> [LanguageViewModel] {
|
||||||
|
var result: [LanguageViewModel] = []
|
||||||
|
let components = line.components(separatedBy: ",")
|
||||||
|
|
||||||
|
for comp in components {
|
||||||
|
let trimmed = comp.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmed.isEmpty else { continue }
|
||||||
|
|
||||||
|
var hasSpeaks = true
|
||||||
|
|
||||||
|
// Check for understands-only patterns
|
||||||
|
if trimmed.lowercased().contains("understand") || trimmed.lowercased().contains("(silently)") {
|
||||||
|
hasSpeaks = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove trailing range annotation like " (150 ft.)" → keep just the name
|
||||||
|
let parts = trimmed.split(separator: " (")
|
||||||
|
var nameOnly = trimmed
|
||||||
|
if parts.count >= 2 {
|
||||||
|
nameOnly = String(parts.first ?? "")
|
||||||
|
}
|
||||||
|
|
||||||
|
result.append(LanguageViewModel(nameOnly, hasSpeaks))
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Determine ChallengeRating from text (Android Monster.crDisplay → challengeRating conversion).
|
||||||
|
static func challengeRating(for text: String?) -> ChallengeRating? {
|
||||||
|
guard let crText = text else { return .zero }
|
||||||
|
|
||||||
|
// Handle common text aliases
|
||||||
|
switch crText.lowercased() {
|
||||||
|
case "half": return .oneHalf
|
||||||
|
case "quarter", "one quarter": return .oneQuarter
|
||||||
|
case "eighth": return .oneEighth
|
||||||
|
case "three halves": return .two
|
||||||
|
default: break
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try numeric parse first (e.g. "0" → zero, "3" → three)
|
||||||
|
if let val = Int(crText), let cr = ChallengeRating(rawValue: String(val)) {
|
||||||
|
return cr
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle fractions like "1/2", "5/2"
|
||||||
|
if let rangeIdx = crText.range(of: "/") {
|
||||||
|
let numStr = crText[..<rangeIdx.lowerBound]
|
||||||
|
let denStr = crText[rangeIdx.upperBound...]
|
||||||
|
if let num = Double(numStr), let den = Double(denStr), den != 0 {
|
||||||
|
let ratio = num / den
|
||||||
|
switch ratio {
|
||||||
|
case 0.125: return .oneEighth
|
||||||
|
case 0.25: return .oneQuarter
|
||||||
|
case 0.5: return .oneHalf
|
||||||
|
case 1.5: return .two
|
||||||
|
default: break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return .zero
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Process cardTraits from Android Monster.exportCards (grouped by actionSection).
|
||||||
|
static func processCardTraits(_ card: MonsterCard, into monster: MonsterViewModel) {
|
||||||
|
guard let traits = card.cardTraits else { return }
|
||||||
|
|
||||||
|
for trait in traits {
|
||||||
|
guard let displayName = trait.displayName, !displayName.isEmpty else { continue }
|
||||||
|
|
||||||
|
if let isHeader = trait.isActionHeader, isHeader == 1 {
|
||||||
|
// Section header like "Spellcasting", "Legendary Actions" etc.
|
||||||
|
if displayName.lowercased().contains("legendary") {
|
||||||
|
monster.legendaryActions.append(AbilityViewModel(displayName, trait.desc ?? ""))
|
||||||
|
} else if displayName.lowercased().contains("lair") || displayName.lowercased().contains("mythic") {
|
||||||
|
monster.lairActions.append(AbilityViewModel(displayName, trait.desc ?? ""))
|
||||||
|
} else {
|
||||||
|
// Action section header gets added to actions as a category label.
|
||||||
|
monster.actions.append(AbilityViewModel(displayName, trait.desc ?? ""))
|
||||||
|
}
|
||||||
|
} else if let section = trait.actionSection {
|
||||||
|
switch section {
|
||||||
|
case 0: monster.bonusActions .append(AbilityViewModel(displayName, trait.desc ?? ""))
|
||||||
|
case 1: monster.actions .append(AbilityViewModel(displayName, trait.desc ?? ""))
|
||||||
|
case 2: monster.reactions .append(AbilityViewModel(displayName, trait.desc ?? ""))
|
||||||
|
default: break
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No section key — add to actions by default (standard Android behavior)
|
||||||
|
monster.actions.append(AbilityViewModel(displayName, trait.desc ?? ""))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Error type
|
||||||
|
|
||||||
|
enum BinderParseError: LocalizedError {
|
||||||
|
case unsupportedEncoding
|
||||||
|
case invalidFormat(String)
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .unsupportedEncoding: return "Binder import requires UTF-8 JSON"
|
||||||
|
case .invalidFormat(let msg): return "Invalid Binder format: \(msg)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
200
iOS/MonsterCardsTests/BinderImporterTest.swift
Normal file
200
iOS/MonsterCardsTests/BinderImporterTest.swift
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
//
|
||||||
|
// BinderImporterTest.swift
|
||||||
|
// MonsterCardsTests
|
||||||
|
//
|
||||||
|
// Unit tests for BinderImporter — validates that canImport correctly detects
|
||||||
|
// Binder JSON in both collection and single-card formats, and that all monster
|
||||||
|
// fields round-trip through the importer's mapping logic.
|
||||||
|
|
||||||
|
import XCTest
|
||||||
|
@testable import MonsterCards
|
||||||
|
|
||||||
|
final class BinderImporterTest: XCTestCase {
|
||||||
|
|
||||||
|
// MARK: - canImport tests
|
||||||
|
|
||||||
|
func testCanImportWithSchemaDetection() {
|
||||||
|
let json = """
|
||||||
|
{"$schema": "https://majinnaibu.com/schemas/binder.schema.json", "collections": []}
|
||||||
|
"""
|
||||||
|
XCTAssertTrue(BinderImporter.canImport(json), "Should detect $schema binder field")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCanImportWithCollectionsArray() {
|
||||||
|
let json = """
|
||||||
|
{"collections": [{"name": "Test", "cards": []}]}
|
||||||
|
"""
|
||||||
|
XCTAssertTrue(BinderImporter.canImport(json), "Should detect collections array")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCannotImportOtherSchema() {
|
||||||
|
let json = """
|
||||||
|
{"$schema": "https://example.com/other.schema.json", "collections": []}
|
||||||
|
{"name": "Goblin"}
|
||||||
|
"""
|
||||||
|
// Note: the second part doesn't have collections key but is not a binder file
|
||||||
|
XCTAssertFalse(BinderImporter.canImport("{\"foo\":\"bar\"}"), "Should not detect random JSON")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCanImportEmptyJson() {
|
||||||
|
XCTAssertFalse(BinderImporter.canImport(""), "Should not detect empty string")
|
||||||
|
XCTAssertFalse(BinderImporter.canImport("{not json}"), "Should not detect invalid JSON")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - parse tests
|
||||||
|
|
||||||
|
func testParseSingleCardFullRoundTrip() {
|
||||||
|
let json = """
|
||||||
|
{
|
||||||
|
"name": "Dragons of the Night",
|
||||||
|
"displayName": "Acererak the Destiler",
|
||||||
|
"displayType": "Lich",
|
||||||
|
"alignment": "Lawful Evil",
|
||||||
|
"sizeAbbrv": "M",
|
||||||
|
"hitDice": "138d10",
|
||||||
|
"shieldAcNum": 2,
|
||||||
|
"baseAcFormula": "Unarmored Defense (+1)",
|
||||||
|
"shieldName": "Ring of Protection",
|
||||||
|
"speedText": "30 feet, fly 60 ft.",
|
||||||
|
"primaryAttributes": [
|
||||||
|
{"abbrv": "str", "value": 27, "modifier": "+8"},
|
||||||
|
{"abbrv": "dex", "value": 16, "modifier": "+3"},
|
||||||
|
{"abbrv": "con", "value": 25, "modifier": "+7"},
|
||||||
|
{"abbrv": "int", "value": 29, "modifier": "+9"},
|
||||||
|
{"abbrv": "wis", "value": 24, "modifier": "+7"},
|
||||||
|
{"abbrv": "cha", "value": 28, "modifier": "+9"}
|
||||||
|
],
|
||||||
|
"proSavingThrows": ["str", "dex"],
|
||||||
|
"proficiencySkills": [
|
||||||
|
{"name": "Arcana", "expertise": 1},
|
||||||
|
{"name": "Perception", "expertise": 0}
|
||||||
|
],
|
||||||
|
"specialdamage": [{"source": "Radiant", "value": "+9 hit", "target": "targets of your feature"}],
|
||||||
|
"proPerception": 0,
|
||||||
|
"languageLine": "Common, Deep Speech (150 ft.)",
|
||||||
|
"sensesDescription": "Blindsight 60ft., Darkvision 30ft.",
|
||||||
|
"crDisplay": "Half"
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
let monster = try? BinderImporter.parse(json)
|
||||||
|
XCTAssertNotNil(monster, "Should successfully parse valid single-card JSON")
|
||||||
|
|
||||||
|
guard let m = monster else { return XCTFail("Expected non-nil parsed result") }
|
||||||
|
|
||||||
|
// Verify name fallback to displayName
|
||||||
|
XCTAssertEqual(m.name, "Acererak the Destiler", "Name should use displayName")
|
||||||
|
XCTAssertEqual(m.type, "Lich", "displayType maps to type")
|
||||||
|
XCTAssertEqual(m.alignment, "Lawful Evil", "alignment preserved exactly")
|
||||||
|
XCTAssertEqual(m.size, "Medium", "M → Medium")
|
||||||
|
|
||||||
|
// HP - hitDice string parse: "138d10" → 138
|
||||||
|
XCTAssertEqual(m.hitDice, 138)
|
||||||
|
XCTAssertFalse(m.hasCustomHP, "Should not have custom HP when no customHpText field present")
|
||||||
|
|
||||||
|
// AC / armor
|
||||||
|
XCTAssertEqual(m.shieldBonus, 2)
|
||||||
|
XCTAssertTrue(m.hasShield, "shieldAcNum is 2 → hasShield = true")
|
||||||
|
|
||||||
|
// Speed breakdown: "30 feet, fly 60 ft."
|
||||||
|
XCTAssertEqual(m.walkSpeed, 30, "Should extract walk speed of 30")
|
||||||
|
XCTAssertEqual(m.flySpeed, 60, "Should extract fly speed of 60")
|
||||||
|
|
||||||
|
// Ability scores (primaryAttributes[0…5])
|
||||||
|
XCTAssertEqual(m.strengthScore, 27)
|
||||||
|
XCTAssertEqual(m.dexterityScore, 16)
|
||||||
|
XCTAssertEqual(m.constitutionScore, 25)
|
||||||
|
XCTAssertEqual(m.intelligenceScore, 29)
|
||||||
|
XCTAssertEqual(m.wisdomScore, 24)
|
||||||
|
XCTAssertEqual(m.charismaScore, 28)
|
||||||
|
|
||||||
|
// Saving throws proficiency (str, dex → .proficient)
|
||||||
|
XCTAssertEqual(m.strengthSavingThrowProficiency, .proficient)
|
||||||
|
XCTAssertEqual(m.dexteritySavingThrowProficiency, .proficient)
|
||||||
|
XCTAssertEqual(m.constitutionSavingThrowProficiency, .none)
|
||||||
|
|
||||||
|
// Skills: Arcana expertise=1 → expertise, Perception expertise=0 → proficient
|
||||||
|
XCTAssertNotNil(m.skills.first(where: { $0.name == "Arcana" }))
|
||||||
|
let arcanaSkill = m.skills.first { $0.abilityScore? == .intelligence }
|
||||||
|
XCTAssertEqual(arcanaSkill?.proficiency, .expertise)
|
||||||
|
|
||||||
|
// Languages
|
||||||
|
XCTAssertEqual(m.languages.count, 2)
|
||||||
|
XCTAssertTrue(m.languages.contains(where: { $0.name == "Common" && $0.speaks }))
|
||||||
|
XCTAssertFalse(m.languages.contains(where: { $0.name == "Deep Speech" && $0.speaks }))
|
||||||
|
|
||||||
|
// Senses
|
||||||
|
XCTAssertTrue(m.senses.count >= 2)
|
||||||
|
XCTAssertTrue(m.senses.first(where: { $0.name.contains("Blindness") }) != nil)
|
||||||
|
|
||||||
|
// Challenge rating from text: "Half" → oneHalf
|
||||||
|
XCTAssertEqual(m.challengeRating, .oneHalf, "Should parse 'Half' as one_half CR")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testParseCollectionFormat() {
|
||||||
|
let json = """
|
||||||
|
{
|
||||||
|
"$schema": "https://majinnaibu.com/schemas/binder.schema.json",
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"collections": [
|
||||||
|
{
|
||||||
|
"name": "Villains of the Realm",
|
||||||
|
"cards": [
|
||||||
|
{
|
||||||
|
"name": "Balrog",
|
||||||
|
"displayName": "Balrog of Moria",
|
||||||
|
"displayType": "Demon",
|
||||||
|
"alignment": "Neutral Evil",
|
||||||
|
"sizeAbbrv": "L",
|
||||||
|
"hitDice": "104d10",
|
||||||
|
"proSavingThrows": ["con"],
|
||||||
|
"primaryAttributes": [
|
||||||
|
{"abbrv": "str", "value": 30},
|
||||||
|
{"abbrv": "dex", "value": 18},
|
||||||
|
{"abbrv": "con", "value": 25},
|
||||||
|
{"abbrv": "int", "value": 18},
|
||||||
|
{"abbrv": "wis", "value": 16},
|
||||||
|
{"abbrv": "cha", "value": 20}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
let monster = try? BinderImporter.parse(json)
|
||||||
|
XCTAssertNotNil(monster, "Should successfully parse valid collection JSON")
|
||||||
|
|
||||||
|
guard let m = monster else { return XCTFail("Expected non-nil parsed result") }
|
||||||
|
|
||||||
|
XCTAssertEqual(m.name, "Balrog of Moria", "Name should use displayName from first card in first collection")
|
||||||
|
XCTAssertEqual(m.type, "Demon", "displayType preserved")
|
||||||
|
XCTAssertEqual(m.size, "Large", "L maps to Large")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testParseInvalidJsonThrows() {
|
||||||
|
XCTAssertThrowsError(try BinderImporter.parse("not valid json string")) { error in
|
||||||
|
XCTAssertTrue(error is BinderParseError)
|
||||||
|
if let e = error as? BinderParseError {
|
||||||
|
switch e {
|
||||||
|
case .invalidFormat: break // expected
|
||||||
|
default: XCTFail("Expected invalidFormat error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDefaultAbilityScoresFailsafe() {
|
||||||
|
let json = """
|
||||||
|
{"collections": [{"name": "X", "cards": [{"name": "No Stats"}]}]}
|
||||||
|
"""
|
||||||
|
|
||||||
|
guard let m = try? BinderImporter.parse(json) else {
|
||||||
|
return XCTFail("Should succeed even with minimal MonsterCard data")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default ability score is 10 per MonsterViewModel init convention
|
||||||
|
XCTAssertEqual(m.strengthScore, 10)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user