Add BinderExporter iOS implementation with tests
This commit is contained in:
250
iOS/MonsterCards/ImportExport/BinderExporter.swift
Normal file
250
iOS/MonsterCards/ImportExport/BinderExporter.swift
Normal file
@@ -0,0 +1,250 @@
|
||||
//
|
||||
// BinderExporter.swift
|
||||
// MonsterCards
|
||||
//
|
||||
// Port of com.majinnaibu.monstercards.exporters.BinderExporter.java
|
||||
// Serialises MonsterViewModel → Binder platform JSON (pretty-printed, Gson-compatible).
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
// MARK: - Binder export DTOs
|
||||
|
||||
struct BinderExport {
|
||||
var schema = "https://majinnaibu.com/schemas/binder.schema.json"
|
||||
var schemaVersion = 1
|
||||
var collections: [CollectionExport] = []
|
||||
|
||||
struct CollectionExport {
|
||||
let id: String
|
||||
let name: String
|
||||
let description: String
|
||||
let cards: [MonsterExport]
|
||||
|
||||
init(id: String, name: String, description: String, cards: [MonsterExport]) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.description = description
|
||||
// Preserve empty list explicitly (Android uses new ArrayList<>())
|
||||
self.cards = cards.count > 0 ? cards : []
|
||||
}
|
||||
|
||||
static func named(_ name: String, cards: [MonsterExport]) -> CollectionExport {
|
||||
CollectionExport(id: UUID().uuidString, name: name, description: "", cards: cards)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct MonsterExport {
|
||||
/// Build a JSON-serialisable dictionary from a `MonsterViewModel`.
|
||||
static func makeDictionary(monster: MonsterViewModel, idOverride: UUID? = nil) -> [String: Any] {
|
||||
var d: [String: Any] = [
|
||||
"$schema": "https://majinnaibu.com/schemas/monster-card.schema.json",
|
||||
"schemaVersion": 1,
|
||||
|
||||
// Core identity
|
||||
"id": idOverride?.uuidString ?? UUID().uuidString,
|
||||
"name": monster.name ?? "",
|
||||
"size": monster.size ?? "",
|
||||
"type": monster.type ?? "",
|
||||
"subtype": monster.subType ?? "",
|
||||
"alignment": monster.alignment ?? "",
|
||||
|
||||
// Ability scores — keys match Android Monster.java Gson output literally.
|
||||
"strengthScore": monster.strengthScore,
|
||||
"dexterityScore": monster.dexterityScore,
|
||||
"constitutionScore": monster.constitutionScore,
|
||||
"intelligenceScore": monster.intelligenceScore,
|
||||
"wisdomScore": monster.wisdomScore,
|
||||
"charismaScore": monster.charismaScore,
|
||||
|
||||
// Saving throw proficiencies & advantages (raw string from Android enum.toString()).
|
||||
"strengthSavingThrowProficiency": monster.strengthSavingThrowProficiency.rawValue,
|
||||
"strengthSavingThrowAdvantage": monster.strengthSavingThrowAdvantage.rawValue,
|
||||
"dexteritySavingThrowProficiency": monster.dexteritySavingThrowProficiency.rawValue,
|
||||
"dexteritySavingThrowAdvantage": monster.dexteritySavingThrowAdvantage.rawValue,
|
||||
"constitutionSavingThrowProficiency": monster.constitutionSavingThrowProficiency.rawValue,
|
||||
"constitutionSavingThrowAdvantage": monster.constitutionSavingThrowAdvantage.rawValue,
|
||||
"intelligenceSavingThrowProficiency": monster.intelligenceSavingThrowProficiency.rawValue,
|
||||
"intelligenceSavingThrowAdvantage": monster.intelligenceSavingThrowAdvantage.rawValue,
|
||||
"wisdomSavingThrowProficiency": monster.wisdomSavingThrowProficiency.rawValue,
|
||||
"wisdomSavingThrowAdvantage": monster.wisdomSavingThrowAdvantage.rawValue,
|
||||
"charismaSavingThrowProficiency": monster.charismaSavingThrowProficiency.rawValue,
|
||||
"charismaSavingThrowAdvantage": monster.charismaSavingThrowAdvantage.rawValue,
|
||||
|
||||
// Armor
|
||||
"armorType": monster.armorType.rawValue,
|
||||
"shieldBonus": Int32(monster.shieldBonus),
|
||||
"naturalArmorBonus": monster.naturalArmorBonus,
|
||||
"otherArmorDescription": monster.otherArmorDescription ?? "",
|
||||
|
||||
// Hit points
|
||||
"hitDice": monster.hitDice,
|
||||
"hasCustomHP": monster.hasCustomHP,
|
||||
"customHitPointsDescription": monster.customHP ?? "",
|
||||
|
||||
// Speeds
|
||||
"walkSpeed": monster.walkSpeed,
|
||||
"burrowSpeed": monster.burrowSpeed,
|
||||
"climbSpeed": monster.climbSpeed,
|
||||
"flySpeed": monster.flySpeed,
|
||||
"canHover": monster.canHover,
|
||||
"swimSpeed": monster.swimSpeed,
|
||||
"hasCustomSpeed": monster.hasCustomSpeed,
|
||||
"customSpeedDescription": monster.customSpeed ?? "",
|
||||
|
||||
// Challenge rating
|
||||
"challengeRating": monster.challengeRating.rawValue,
|
||||
"customChallengeRatingDescription": monster.customChallengeRating ?? "",
|
||||
"customProficiencyBonus": monster.customProficiencyBonus,
|
||||
|
||||
// Telepathy & understanding
|
||||
"telepathyRange": monster.telepathy,
|
||||
"understandsButDescription": monster.understandsBut ?? "",
|
||||
|
||||
// Lists (strings) — keys match Android Monster.java @ColumnInfo / Gson names.
|
||||
"senses": monster.senses.map { ($0.name ?? "") },
|
||||
"damageImmunities": monster.damageImmunities.map { ($0.name ?? "") },
|
||||
"damageResistances": monster.damageResistances.map { ($0.name ?? "") },
|
||||
"damageVulnerabilities": monster.damageVulnerabilities.map { ($0.name ?? "") },
|
||||
"conditionImmunities": monster.conditionImmunities.map { ($0.name ?? "") },
|
||||
|
||||
// Skills — each is {"name": ..., "stat": "...", "proficiency": "..."}
|
||||
"skills": monster.skills.map { s -> [String: Any] in
|
||||
return ["name": s.name ?? "", "stat": s.stat.rawValue, "proficiency": s.proficiency.rawValue]
|
||||
},
|
||||
|
||||
// Languages — each is {"name": ..., "speaks": Bool}
|
||||
"languages": monster.languages.map { l -> [String: Any] in
|
||||
return ["name": l.name ?? "", "speaks": l.speaks]
|
||||
},
|
||||
|
||||
// Combat lists — each is {"name": ..., "desc": ...}
|
||||
"abilities": monster.abilities.map { ["name": $0.name ?? "", "desc": $0.description ?? ""] },
|
||||
"actions": monster.actions.map { ["name": $0.name ?? "", "desc": $0.description ?? ""] },
|
||||
"reactions": monster.reactions.map { ["name": $0.name ?? "", "desc": $0.description ?? ""] },
|
||||
"legendaryActions": monster.legendaryActions.map { ["name": $0.name ?? "", "desc": $0.description ?? ""] },
|
||||
"lairActions": monster.lairActions.map { ["name": $0.name ?? "", "desc": $0.description ?? ""] },
|
||||
"regionalActions": monster.regionalActions.map { ["name": $0.name ?? "", "desc": $0.description ?? ""] },
|
||||
"bonusActions": monster.bonusActions.map { ["name": $0.name ?? "", "desc": $0.description ?? ""] },
|
||||
|
||||
// Mythic actions — Android Monster model has this; iOS lacks it.
|
||||
"mythicActions": [] as [[String: Any]],
|
||||
|
||||
// Personality / physical description — Android-only fields on Monster.java.
|
||||
"playerName": "",
|
||||
"background": "",
|
||||
"personalityTraits": "",
|
||||
"ideals": "",
|
||||
"bonds": "",
|
||||
"flaws": "",
|
||||
"age": "",
|
||||
"height": "",
|
||||
"weight": "",
|
||||
"eyes": "",
|
||||
"skin": "",
|
||||
"hair": "",
|
||||
"appearance": "",
|
||||
"backstory": "",
|
||||
"alliesAndOrganizations": "",
|
||||
"sourceUrl": ""
|
||||
]
|
||||
return d
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - BinderExporter (mirrors public API of com.majinnaibu.monstercards.exporters.BinderExporter)
|
||||
|
||||
struct BinderExporter {
|
||||
|
||||
// MARK: -- Utility — pretty-print JSON [String:Any] → string (Gson-compatible format)
|
||||
|
||||
private static func toJsonString(_ obj: Any) -> String? {
|
||||
guard let data = try? JSONSerialization.data(withJSONObject: obj, options: [.prettyPrinted]) else { return nil }
|
||||
return String(data: data, encoding: .utf8)
|
||||
}
|
||||
|
||||
// MARK: -- Public API matching Android exactly
|
||||
|
||||
/// `exportBinder(String collectionName, List<Monster> monsters)`
|
||||
static func exportBinder(collectionName: String, monsters: [MonsterViewModel], idOverride: UUID? = nil) -> String {
|
||||
let col = BinderExport.CollectionExport(
|
||||
id: UUID().uuidString,
|
||||
name: collectionName.isEmpty ? "" : collectionName,
|
||||
description: "",
|
||||
cards: monsters.map { MonsterExport.makeDictionary(monster: $0, idOverride: idOverride) }
|
||||
)
|
||||
|
||||
let root: [String: Any] = [
|
||||
"$schema": "https://majinnaibu.com/schemas/binder.schema.json",
|
||||
"schemaVersion": 1,
|
||||
"collections": [[
|
||||
"id": col.id,
|
||||
"name": col.name,
|
||||
"description": col.description,
|
||||
"cards": col.cards.map { $0 }
|
||||
]]
|
||||
]
|
||||
|
||||
return toJsonString(root) ?? "{}"
|
||||
}
|
||||
|
||||
/// `exportBinder(Collection collection, List<Monster> monsters)`
|
||||
static func exportBinder(collection: NSManagedObject?, monsters: [MonsterViewModel]) -> String {
|
||||
guard let colObj = collection else {
|
||||
// Empty-collection fallback (same path as Android with null).
|
||||
return exportBinder(collectionName: "", monsters: monsters)
|
||||
}
|
||||
|
||||
let id = (colObj.value(forKey: "id") as? UUID)?.uuidString ?? UUID().uuidString
|
||||
let name = (colObj.value(forKey: "name") as? String) ?? ""
|
||||
let desc = (colObj.value(forKey: "description") as? String) ?? ""
|
||||
|
||||
let col = BinderExport.CollectionExport(
|
||||
id: id,
|
||||
name: name,
|
||||
description: desc,
|
||||
cards: monsters.map { MonsterExport.makeDictionary(monster: $0) }
|
||||
)
|
||||
|
||||
let root: [String: Any] = [
|
||||
"$schema": "https://majinnaibu.com/schemas/binder.schema.json",
|
||||
"schemaVersion": 1,
|
||||
"collections": [[
|
||||
"id": col.id,
|
||||
"name": col.name,
|
||||
"description": col.description,
|
||||
"cards": col.cards.map { $0 }
|
||||
]]
|
||||
]
|
||||
|
||||
return toJsonString(root) ?? "{}"
|
||||
}
|
||||
|
||||
/// `exportFullBackup(List<CollectionExport> collections, List<Monster> dashboardMonsters)`
|
||||
static func exportFullBackup(collections: [BinderExport.CollectionExport], dashboardMonsters: [MonsterViewModel]?) -> String {
|
||||
let colDicts = collections.map { col -> [String: Any] in
|
||||
return [
|
||||
"id": col.id,
|
||||
"name": col.name,
|
||||
"description": col.description,
|
||||
"cards": col.cards.map { $0 } // already a [String: Any] from makeDictionary
|
||||
]
|
||||
}
|
||||
|
||||
let base: [String: Any] = [
|
||||
"$schema": "https://majinnaibu.com/schemas/binder.schema.json",
|
||||
"schemaVersion": 1,
|
||||
"collections": colDicts
|
||||
]
|
||||
|
||||
guard let dashboard = dashboardMonsters, !dashboard.isEmpty else {
|
||||
// Android Gson only includes the field when it's populated.
|
||||
return toJsonString(base) ?? "{}"
|
||||
}
|
||||
|
||||
var root = base
|
||||
root["dashboard"] = dashboard.map { MonsterExport.makeDictionary(monster: $0) }
|
||||
return toJsonString(root) ?? "{}"
|
||||
}
|
||||
}
|
||||
239
iOS/MonsterCardsTests/BinderExporterTest.swift
Normal file
239
iOS/MonsterCardsTests/BinderExporterTest.swift
Normal file
@@ -0,0 +1,239 @@
|
||||
//
|
||||
// BinderExporterTest.swift
|
||||
// MonsterCardsTests
|
||||
//
|
||||
// Unit tests for BinderExporter — mirrors Android's BinderImportExportTest.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import MonsterCards
|
||||
|
||||
final class BinderExporterTest: XCTestCase {
|
||||
|
||||
// MARK: - Export with collection name
|
||||
|
||||
func testExportBinder_singleCollection_createsValidJson() throws {
|
||||
let monster = sampleMonster()
|
||||
|
||||
let json = BinderExporter.exportBinder(collectionName: "Goblins", monsters: [monster])
|
||||
|
||||
// Verify root structure.
|
||||
guard let data = json.data(using: .utf8),
|
||||
let root = try JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let collections = root["collections"] as? [[String: Any]],
|
||||
let col = collections.first,
|
||||
let cards = col["cards"] as? [[String: Any]] else {
|
||||
XCTFail("JSON structure invalid")
|
||||
return
|
||||
}
|
||||
|
||||
XCTAssertEqual(root["$schema"], "https://majinnaibu.com/schemas/binder.schema.json")
|
||||
XCTAssertEqual(root["schemaVersion"] as? Int, 1)
|
||||
XCTAssertEqual(col["name"] as? String, "Goblins")
|
||||
XCTAssertEqual(cards.count, 1)
|
||||
|
||||
// Verify monster card fields.
|
||||
let card = cards[0]
|
||||
XCTAssertEqual(card["$schema"] as? String, "https://majinnaibu.com/schemas/monster-card.schema.json")
|
||||
XCTAssertEqual(card["schemaVersion"] as? Int, 1)
|
||||
XCTAssertEqual(card["id"] as? String, monster.id?.uuidString ?? "")
|
||||
XCTAssertEqual(card["name"] as? String, "Goblin Warrior")
|
||||
XCTAssertEqual(card["size"] as? String, "Small")
|
||||
XCTAssertEqual(card["type"] as? String, "humanoid")
|
||||
}
|
||||
|
||||
func testExportBinder_customId_preservesIt() throws {
|
||||
let monster = sampleMonster()
|
||||
let customId = UUID()
|
||||
|
||||
let json = BinderExporter.exportBinder(collectionName: "", monsters: [monster], idOverride: customId)
|
||||
|
||||
guard let data = json.data(using: .utf8),
|
||||
let root = try JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let collections = root["collections"] as? [[String: Any]],
|
||||
let cards = collections.first["cards"] as? [[String: Any]] else {
|
||||
XCTFail("JSON structure invalid")
|
||||
return
|
||||
}
|
||||
|
||||
XCTAssertEqual(cards[0]["id"] as? String, customId.uuidString)
|
||||
}
|
||||
|
||||
// MARK: - Export with full backup (dashboard)
|
||||
|
||||
func testExportFullBackup_withDashboard_includesDashboardField() throws {
|
||||
let col = BinderExport.CollectionExport(
|
||||
id: "test-col-id",
|
||||
name: "Test Collection",
|
||||
description: "A test collection",
|
||||
cards: [MonsterExport.makeDictionary(monster: sampleMonster())]
|
||||
)
|
||||
|
||||
let json = BinderExporter.exportFullBackup(collections: [col], dashboardMonsters: [sampleMonster()])
|
||||
|
||||
guard let data = json.data(using: .utf8),
|
||||
let root = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||
XCTFail("JSON structure invalid")
|
||||
return
|
||||
}
|
||||
|
||||
XCTAssertEqual(root["$schema"] as? String, "https://majinnaibu.com/schemas/binder.schema.json")
|
||||
|
||||
// Should have a dashboard field with at least 1 entry.
|
||||
XCTAssertTrue(root["dashboard"] is NSArray || root["dashboard"] is [[String: Any]])
|
||||
}
|
||||
|
||||
func testExportFullBackup_withoutDashboard_excludesDashboardField() throws {
|
||||
let col = BinderExport.CollectionExport(
|
||||
id: "uuid-test",
|
||||
name: "No Dash",
|
||||
description: "",
|
||||
cards: []
|
||||
)
|
||||
|
||||
let json = BinderExporter.exportFullBackup(collections: [col], dashboardMonsters: nil)
|
||||
|
||||
XCTAssertFalse(json.contains("\"dashboard\""), "Should not contain 'dashboard' when none provided")
|
||||
}
|
||||
|
||||
// MARK: - Ability score mapping
|
||||
|
||||
func testExportBinder_abilityScores_matchJsonKeys() throws {
|
||||
let monster = sampleMonster()
|
||||
|
||||
let json = BinderExporter.exportBinder(collectionName: "", monsters: [monster])
|
||||
|
||||
guard let data = json.data(using: .utf8),
|
||||
let root = try JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let collections = root["collections"] as? [[String: Any]],
|
||||
let cards = collections.first["cards"] as? [[String: Any]] else {
|
||||
XCTFail("JSON structure invalid")
|
||||
return
|
||||
}
|
||||
|
||||
let monsterJson = cards[0]
|
||||
|
||||
// Verify ability score keys and values.
|
||||
XCTAssertEqual(monsterJson["strengthScore"] as? Int64, monster.strengthScore)
|
||||
XCTAssertEqual(monsterJson["dexterityScore"] as? Int64, monster.dexterityScore)
|
||||
XCTAssertEqual(monsterJson["constitutionScore"] as? Int64, monster.constitutionScore)
|
||||
XCTAssertEqual(monsterJson["intelligenceScore"] as? Int64, monster.intelligenceScore)
|
||||
XCTAssertEqual(monsterJson["wisdomScore"] as? Int64, monster.wisdomScore)
|
||||
XCTAssertEqual(monsterJson["charismaScore"] as? Int64, monster.charismaScore)
|
||||
|
||||
// Verify saving throw keys exist.
|
||||
XCTAssertNotNil(monsterJson["strengthSavingThrowProficiency"])
|
||||
XCTAssertNotNil(monsterJson["strengthSavingThrowAdvantage"])
|
||||
XCTAssertNotNil(monsterJson["dexteritySavingThrowProficiency"])
|
||||
}
|
||||
|
||||
// MARK: - Round-trip (export then verify it would import)
|
||||
|
||||
func testExportRoundTrip_matchesImportExportTest_expectancy() throws {
|
||||
// Mirror the Android BinderImportExportTest.testExportAndImportBinder expectations.
|
||||
let monster = sampleMonster()
|
||||
let json = BinderExporter.exportBinder(collectionName: "Goblins", monsters: [monster])
|
||||
|
||||
// Deserialize to verify shape (simulates what the Android BinderImporter would read).
|
||||
guard let data = json.data(using: .utf8),
|
||||
let root = try JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let collections = root["collections"] as? [[String: Any]],
|
||||
let col = collections.first,
|
||||
let cards = col["cards"] as? [[String: Any]] else {
|
||||
XCTFail("Failed to parse exported JSON")
|
||||
return
|
||||
}
|
||||
|
||||
XCTAssertEqual(root["schemaVersion"] as? Int, 1)
|
||||
XCTAssertEqual(collections.count, 1)
|
||||
XCTAssertEqual(col["name"] as? String, "Goblins")
|
||||
XCTAssertEqual(cards.count, 1)
|
||||
XCTAssertEqual(cards[0]["name"] as? String, "Goblin Warrior")
|
||||
XCTAssertEqual(cards[0]["schemaVersion"] as? Int, 1)
|
||||
}
|
||||
|
||||
// MARK: - All MonsterViewModel fields present
|
||||
|
||||
func testExportAllMonsterFields_haveValidKeys() throws {
|
||||
let monster = sampleFullMonster()
|
||||
|
||||
let json = BinderExporter.exportBinder(collectionName: "", monsters: [monster])
|
||||
|
||||
guard let data = json.data(using: .utf8),
|
||||
let root = try JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let cards = (root["collections"] as? [[String: Any]])?.first["cards"] as? [[String: Any]] else {
|
||||
XCTFail("JSON structure invalid")
|
||||
return
|
||||
}
|
||||
|
||||
let m = cards[0]
|
||||
|
||||
// Verify all expected keys are present.
|
||||
let expectedKeys: [String] = [
|
||||
"$schema", "schemaVersion", "id", "name", "size", "type", "subtype",
|
||||
"alignment",
|
||||
"strengthScore", "dexterityScore", "constitutionScore", "intelligenceScore",
|
||||
"wisdomScore", "charismaScore",
|
||||
"strengthSavingThrowProficiency", "strengthSavingThrowAdvantage",
|
||||
"dexteritySavingThrowProficiency", "dexteritySavingThrowAdvantage",
|
||||
"constitutionSavingThrowProficiency", "constitutionSavingThrowAdvantage",
|
||||
"intelligenceSavingThrowProficiency", "intelligenceSavingThrowAdvantage",
|
||||
"wisdomSavingThrowProficiency", "wisdomSavingThrowAdvantage",
|
||||
"charismaSavingThrowProficiency", "charismaSavingThrowAdvantage",
|
||||
"armorType", "shieldBonus", "naturalArmorBonus", "otherArmorDescription",
|
||||
"hitDice", "hasCustomHP", "customHitPointsDescription",
|
||||
"walkSpeed", "burrowSpeed", "climbSpeed", "flySpeed",
|
||||
"canHover", "swimSpeed", "hasCustomSpeed", "customSpeedDescription",
|
||||
"challengeRating", "customChallengeRatingDescription", "customProficiencyBonus",
|
||||
"telepathyRange", "understandsButDescription",
|
||||
"senses", "damageImmunities", "damageResistances", "damageVulnerabilities",
|
||||
"conditionImmunities", "skills", "languages",
|
||||
"abilities", "actions", "reactions",
|
||||
"legendaryActions", "lairActions", "regionalActions",
|
||||
"bonusActions"
|
||||
]
|
||||
|
||||
for key in expectedKeys {
|
||||
XCTAssertTrue(m.keys.contains(key), "Missing key '\(key)' in export; keys found: \(m.keys.sorted())")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func sampleMonster() -> MonsterViewModel {
|
||||
let monster = MonsterViewModel()
|
||||
monster.name = "Goblin Warrior"
|
||||
monster.size = "Small"
|
||||
monster.type = "humanoid"
|
||||
monster.subType = "goblinoid"
|
||||
monster.alignment = "neutral evil"
|
||||
monster.strengthScore = 8
|
||||
monster.dexterityScore = 14
|
||||
// Keep id as default (not set).
|
||||
return monster
|
||||
}
|
||||
|
||||
private func sampleFullMonster() -> MonsterViewModel {
|
||||
let m = sampleMonster()
|
||||
m.hitDice = 2
|
||||
m.strengthScore = 8
|
||||
m.dexterityScore = 14
|
||||
m.constitutionScore = 10
|
||||
m.intelligenceScore = 8
|
||||
m.wisdomScore = 8
|
||||
m.charismaScore = 8
|
||||
|
||||
m.walkSpeed = 30
|
||||
m.hitDice = 2
|
||||
|
||||
// Add some damage immunities.
|
||||
let di = StringViewModel("fire")
|
||||
m.damageImmunities.append(di)
|
||||
|
||||
// Add abilities.
|
||||
m.abilities.append(AbilityViewModel("Nimble Escape", "Disengage or Hide as a bonus action."))
|
||||
m.actions.append(AbilityViewModel("Scimitar", "+4 to hit, 1d6+2 slashing"))
|
||||
|
||||
return m
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user