local ADDON_NAME, RPAL = ...

-- ============================================================
-- RPAL Hearthstone
-- Original RPAL card pool + lightweight online tabletop engine.
-- The subsystem deliberately uses only RPAL/original card names and WoW's
-- built-in game art references; no named lore characters are used as cards.
-- ============================================================

local HS = {}
RPAL.Hearthstone = HS

HS.VERSION = 22
HS.PREFIX = "RPAL_HS"
HS.PACK_COST = 125

local function UseLiteHSCurrency()
    return RPAL.IsLiteKit and RPAL.IsLiteKit()
end
function HS.GetCurrencyName()
    return UseLiteHSCurrency() and "HS Tokens" or "Tokens"
end
function HS.GetCurrencyBalance()
    if UseLiteHSCurrency() then return RPAL.GetHSTokens and RPAL.GetHSTokens() or 0 end
    return RPAL.GetTokens and RPAL.GetTokens() or 0
end
local function SpendHSCurrency(amount)
    if UseLiteHSCurrency() then return RPAL.SpendHSTokens and RPAL.SpendHSTokens(amount) end
    return RPAL.SpendTokens and RPAL.SpendTokens(amount)
end
local function AddHSCurrency(amount,source)
    if UseLiteHSCurrency() then return RPAL.AddHSTokens and RPAL.AddHSTokens(amount,source) or 0 end
    return RPAL.AddTokens and RPAL.AddTokens(amount) or 0
end
HS.PACK_SIZE = 5
HS.MAX_HAND = 7
HS.MAX_BOARD = 5
HS.STARTING_HEALTH = 30
HS.MAX_MANA = 10
HS.MAX_HISTORY = 30

local function Clamp(v,a,b) v=tonumber(v) or 0;if v<a then return a elseif v>b then return b end;return v end
local function Copy(t)
    if type(t)~="table" then return t end
    local out={};for k,v in pairs(t) do out[k]=Copy(v) end;return out
end
local function Now() return time and time() or 0 end
local function PlayerName()
    if GetUnitName then
        local n=GetUnitName("player",true)
        if n and n~="" then return n end
    end
    local n,r=UnitName and UnitName("player")
    if n and r and r~="" then return n.."-"..r end
    return n or "Player"
end
local function DisplayName()
    return (RPAL.DisplayName and RPAL.DisplayName()) or PlayerName()
end
local function TargetName()
    if UnitExists and UnitExists("target") and UnitIsPlayer and UnitIsPlayer("target") then
        if GetUnitName then return GetUnitName("target",true) end
        return UnitName("target")
    end
end
local function PlayerNamesEqual(a,b)
    a=tostring(a or "");b=tostring(b or "")
    if a=="" or b=="" then return false end
    if a==b or a:lower()==b:lower() then return true end
    local an,ar=a:match("^([^%-]+)%-(.+)$");local bn,br=b:match("^([^%-]+)%-(.+)$")
    an=an or a;bn=bn or b
    if an:lower()~=bn:lower() then return false end
    -- WoW sometimes omits the realm for same-realm addon whispers. Only fall
    -- back to the short name when at least one side has no realm component.
    if not ar or not br then return true end
    return ar:lower()==br:lower()
end
local function ParticipantSide(match,name)
    if not match or not match.players then return nil end
    if match.players[1] and PlayerNamesEqual(match.players[1].name,name) then return 1 end
    if match.players[2] and PlayerNamesEqual(match.players[2].name,name) then return 2 end
end
local function SafeText(s,maxlen)
    s=tostring(s or ""):gsub("[|\r\n]"," "):gsub("%s+"," ")
    if #s>(maxlen or 80) then s=s:sub(1,maxlen or 80) end
    return s
end
local function CSV(list)
    local out={};for _,v in ipairs(list or {}) do out[#out+1]=tostring(v) end;return table.concat(out,",")
end
local function ParseCSV(text)
    local out={};for v in tostring(text or ""):gmatch("[^,]+") do local n=tonumber(v);if n then out[#out+1]=n end end;return out
end
local function Split(text,sep)
    sep=sep or "|"
    local out={}
    local patt="(.-)"..sep:gsub("(%W)","%%%1")
    for part in (tostring(text or "")..sep):gmatch(patt) do out[#out+1]=part end
    return out
end

HS.RARITY = {
    Common={0.72,0.72,0.72},
    Rare={0.25,0.62,1.00},
    Epic={0.72,0.34,0.95},
    Legendary={1.00,0.58,0.12},
}
HS.DUPLICATE_REFUND={Common=6,Rare=18,Epic=45,Legendary=100}
HS.MAX_COPIES={Common=2,Rare=2,Epic=2,Legendary=1}

-- Hearthstone table cosmetics are intentionally self-contained.  Arena styles
-- color only the owner's half of the shared table; card backs are used for the
-- physical deck piles and pack opening sequence.
HS.ARENAS={
 {id="tavern",name="Tavern Hearth",price=0,rail={.19,.075,.045},field={.36,.22,.12},accent={.96,.66,.27},ornament="royal"},
 {id="crimson",name="Scarlet Hall",price=450,rail={.30,.045,.052},field={.30,.12,.10},accent={1,.40,.22},ornament="ember"},
 {id="arcane",name="Algeth'ar Academy",price=600,rail={.075,.075,.24},field={.14,.13,.29},accent={.43,.70,1},ornament="arcane"},
 {id="frost",name="Frostwolf Lodge",price=600,rail={.055,.14,.22},field={.12,.22,.27},accent={.48,.86,1},ornament="frost"},
 {id="verdant",name="Verdant Glade",price=700,rail={.045,.18,.11},field={.11,.25,.15},accent={.48,.93,.52},ornament="kaldorei"},
 {id="storm",name="Stormwind Keep",price=780,rail={.075,.12,.19},field={.12,.18,.24},accent={.57,.77,1},ornament="royal"},
 {id="moon",name="Moonglade Lodge",price=850,rail={.11,.075,.19},field={.18,.13,.25},accent={.74,.60,1},ornament="kaldorei"},
 {id="zodiac",name="Celestial Archive",price=950,rail={.16,.105,.035},field={.22,.18,.10},accent={1,.79,.32},ornament="royal"},
}
HS.ARENA_BY_ID={};for _,a in ipairs(HS.ARENAS) do HS.ARENA_BY_ID[a.id]=a end
HS.CARD_BACKS={
 {id="classic",name="Classic Sigil",price=0,base={.16,.32,.62},edge={.94,.68,.25},glow={.22,.70,1}},
 {id="ember",name="Ember Crest",price=240,base={.42,.10,.055},edge={1,.54,.20},glow={1,.24,.08}},
 {id="arcane",name="Arcane Star",price=300,base={.19,.11,.47},edge={.68,.50,1},glow={.43,.72,1}},
 {id="frost",name="Frostbound",price=300,base={.08,.31,.45},edge={.62,.90,1},glow={.50,.93,1}},
 {id="royal",name="Royal Ledger",price=380,base={.28,.16,.05},edge={1,.79,.30},glow={1,.67,.18}},
 {id="bloodmoon",name="Bloodmoon Seal",price=420,base={.35,.035,.055},edge={.92,.30,.30},glow={1,.12,.18}},
 {id="verdant",name="Verdant Knot",price=420,base={.055,.25,.12},edge={.48,.91,.48},glow={.34,1,.48}},
 {id="voidglass",name="Voidglass",price=480,base={.095,.045,.20},edge={.62,.34,.92},glow={.56,.18,1}},
 {id="sunforge",name="Sunforged",price=520,base={.40,.22,.035},edge={1,.78,.22},glow={1,.56,.08}},
 {id="stormsteel",name="Stormsteel",price=520,base={.075,.18,.28},edge={.55,.78,1},glow={.35,.70,1}},
 {id="gravebound",name="Gravebound",price=560,base={.09,.12,.10},edge={.48,.72,.56},glow={.30,.88,.55}},
 {id="rose",name="Rosegold Vow",price=600,base={.32,.10,.16},edge={1,.60,.66},glow={1,.36,.56}},
 {id="nightveil",name="Nightveil Moon",price=620,base={.08,.075,.23},edge={.62,.58,1},glow={.44,.48,1}},
 {id="runic",name="Runic Tablet",price=650,base={.16,.19,.22},edge={.76,.78,.70},glow={.64,.86,.82}},
 {id="tidesage",name="Tidesage",price=680,base={.035,.22,.30},edge={.38,.88,.95},glow={.24,.92,1}},
 {id="sandgold",name="Sandgold Relic",price=700,base={.34,.22,.09},edge={.96,.76,.38},glow={1,.72,.28}},
 {id="starfall",name="Starfall Archive",price=760,base={.09,.11,.28},edge={.66,.72,1},glow={.54,.74,1}},
 {id="dreamweave",name="Dreamwoven",price=800,base={.10,.24,.19},edge={.56,.88,.70},glow={.42,1,.72}},
}
HS.CARD_BACK_BY_ID={};for _,b in ipairs(HS.CARD_BACKS) do HS.CARD_BACK_BY_ID[b.id]=b end

-- Original RPAL cards. Artwork is assigned exclusively from WoW's built-in spell/item icon library at runtime.
HS.CARDS={
 {id=1,name="Tavern Brawler",rarity="Common",type="Creature",cost=1,attack=1,health=2,text="",icon=1032478,iconSource="browser:macro-spell:1813",iconLocked=true},
 {id=2,name="Dockside Lookout",rarity="Common",type="Creature",cost=1,attack=2,health=1,text=""},
 {id=3,name="Light's Zealot",rarity="Common",type="Creature",cost=2,attack=2,health=2,text="Deploy: Restore 1 Health to your hero.",battlecry="healHero",amount=1,icon=135871,iconSource="browser:macro-spell:425",iconLocked=true},
 {id=4,name="Shieldbearer",rarity="Common",type="Creature",cost=2,attack=1,health=4,text="Taunt. Enemies must attack this first.",taunt=true},
 {id=5,name="Ashamed Knight",rarity="Common",type="Creature",cost=2,attack=3,health=2,text="",icon=463447,iconSource="spell:103420",iconLocked=true},
 {id=6,name="Roadside Mystic",rarity="Common",type="Creature",cost=3,attack=2,health=3,text="Deploy: Draw a card.",battlecry="draw",amount=1},
 {id=7,name="Caravan Guard",rarity="Common",type="Creature",cost=3,attack=3,health=4,text=""},
 {id=8,name="Pit Fighter",rarity="Common",type="Creature",cost=4,attack=4,health=5,text=""},
 {id=9,name="Wandering Beast",rarity="Common",type="Creature",cost=4,attack=5,health=4,text=""},
 {id=10,name="Veteran Mercenary",rarity="Common",type="Creature",cost=5,attack=5,health=6,text=""},
 {id=11,name="Harbor Bruiser",rarity="Rare",type="Creature",cost=3,attack=4,health=3,text=""},
 {id=12,name="Arcane Apprentice",rarity="Rare",type="Creature",cost=3,attack=3,health=3,text="Deploy: Deal 1 damage to the enemy hero.",battlecry="damageEnemyHero",amount=1},
 {id=13,name="Full Steal Protector",rarity="Rare",type="Creature",cost=4,attack=3,health=6,text="Taunt.",taunt=true,icon=132110,iconSource="browser:macro-spell:22",iconLocked=true},
 {id=14,name="Swift Cheetah",rarity="Rare",type="Creature",cost=4,attack=4,health=3,text="Rush. Can attack immediately.",charge=true,icon=132185,iconSource="spell:16974",iconLocked=true},
 {id=15,name="Graveyard Watcher",rarity="Rare",type="Creature",cost=5,attack=4,health=7,text="Taunt.",taunt=true,icon=135965,iconSource="browser:macro-spell:519",iconLocked=true},
 {id=16,name="Orb Gobbler",rarity="Rare",type="Creature",cost=5,attack=5,health=5,text="Deploy: Restore 2 Health to your hero.",battlecry="healHero",amount=2,icon=134334,iconSource="spell:17619",iconLocked=true},
 {id=17,name="Frostbound Spirit",rarity="Rare",type="Creature",cost=6,attack=6,health=5,text="Deploy: Deal 2 damage to the enemy hero.",battlecry="damageEnemyHero",amount=2,icon=1387355,iconSource="browser:macro-spell:2050",iconLocked=true},
 {id=18,name="Zapgineer",rarity="Rare",type="Creature",cost=6,attack=5,health=6,text="Deploy: Deal 2 damage to a random enemy creature.",battlecry="damageRandomEnemy",amount=2,icon=4620673,iconSource="spell:4036",iconLocked=true},
 {id=19,name="Sentient Dust",rarity="Epic",type="Creature",cost=4,attack=5,health=3,text="Rush. Can attack immediately.",charge=true,icon=132858,iconSource="spell:78979",iconLocked=true},
 {id=20,name="Runebound Guardian",rarity="Epic",type="Creature",cost=6,attack=5,health=8,text="Taunt.",taunt=true},
 {id=21,name="Master-at-Arms",rarity="Epic",type="Creature",cost=7,attack=7,health=7,text="Deploy: Give your other creatures +1 Attack.",battlecry="buffAllAttack",amount=1},
 {id=22,name="Crown's Champion",rarity="Epic",type="Creature",cost=8,attack=8,health=8,text="",icon=255140,iconSource="spell:62594",iconLocked=true},
 {id=23,name="Felguard",rarity="Legendary",type="Creature",cost=9,attack=9,health=10,text="Taunt. ",taunt=true,icon=1388064,iconSource="browser:macro-spell:2052",iconLocked=true},
 {id=24,name="Tavern Legend",rarity="Legendary",type="Creature",cost=8,attack=7,health=9,text="Deploy: Draw 2 cards and restore 2 Health.",battlecry="legend",amount=2},
 {id=25,name="Cheap Shot",rarity="Common",type="Action",cost=1,text="Deal 2 damage to an enemy.",effect="damage",amount=2,target="enemy"},
 {id=26,name="Quick Healing Phial",rarity="Common",type="Action",cost=1,text="Restore 2 Health to a friendly target.",effect="heal",target="friendly",amount=2,icon=134829,iconSource="spell:439",iconLocked=true},
 {id=27,name="Sudden Insight",rarity="Common",type="Action",cost=2,text="Draw 2 cards.",effect="draw",amount=2,icon=1386545,iconSource="browser:macro-spell:2040",iconLocked=true},
 {id=28,name="Rally",rarity="Common",type="Action",cost=2,text="Give a friendly creature +1/+1.",effect="buff",attack=1,health=1,target="friendlyCreature"},
 {id=29,name="Thunderclap",rarity="Rare",type="Action",cost=3,text="Deal 4 damage to an enemy.",effect="damage",target="enemy",amount=4,icon=132326,iconSource="spell:2442",iconLocked=true},
 {id=30,name="Fortify",rarity="Rare",type="Action",cost=3,text="Give a friendly creature +3 Health and Taunt.",effect="fortify",health=3,target="friendlyCreature"},
 {id=31,name="Lucky Bracer",rarity="Rare",type="Action",cost=4,text="Restore 6 Health to your hero.",effect="healHero",amount=6,icon=132601,iconSource="item:32582",iconLocked=true},
 {id=32,name="Scattershot",rarity="Rare",type="Action",cost=4,text="Deal 1 damage to all enemy creatures.",effect="aoeEnemy",amount=1,icon=1035043,iconSource="browser:macro-spell:1850",iconLocked=true},
 {id=33,name="Arcane Burst",rarity="Epic",type="Action",cost=5,text="Deal 6 damage to an enemy.",effect="damage",amount=6,target="enemy"},
 {id=34,name="Frozen Hearth",rarity="Epic",type="Action",cost=5,attack=1,health=2,text="Give all friendly creatures +1/+2.",effect="buffAll",icon=134131,iconSource="spell:37536",iconLocked=true},
 {id=35,name="Desperate Measures",rarity="Epic",type="Action",cost=6,text="Draw 3 cards and restore 3 Health.",effect="drawHeal",amount=3},
 {id=36,name="Key to Victory",rarity="Legendary",type="Action",cost=7,text="Deal 8 damage to the enemy hero.",effect="damageEnemyHero",amount=8,icon=348554,iconSource="spell:92069",iconLocked=true},
 {id=37,name="Rusted Serrated Dirk",rarity="Common",type="Equipment",cost=2,attack=2,durability=2,text="Equip a 2/2 weapon.",icon=135313,iconSource="item:18832",iconLocked=true},
 {id=38,name="Guard's Mace",rarity="Rare",type="Equipment",cost=3,attack=3,durability=2,text="Equip a 3/2 weapon.",icon=1001623,iconSource="browser:macro-item:9520",iconLocked=true},
 {id=39,name="Spellforged Blade",rarity="Epic",type="Equipment",cost=5,attack=4,durability=3,text="Equip a 4/3 weapon.",icon=1031326,iconSource="browser:macro-item:10062",iconLocked=true},
 {id=40,name="Captain's Greatblade",rarity="Legendary",type="Equipment",cost=7,attack=6,durability=3,text="Equip a 6/3 weapon.",icon=1064261,iconSource="browser:macro-item:10501",iconLocked=true},
 {id=41,name="Murloc with a Turtleback",rarity="Common",type="Creature",cost=1,attack=1,health=2,text="Taunt.",taunt=true,icon=134967,iconSource="spell:25606",iconLocked=true},
 {id=42,name="Northern Berserker",rarity="Common",type="Creature",cost=2,attack=2,health=3,text="Deploy: Gain 1 Armor.",battlecry="armor",amount=1,icon=236324,iconSource="spell:37795",iconLocked=true},
 {id=43,name="Quel'dorei Spellblade",rarity="Common",type="Creature",cost=2,attack=3,health=2,text="",icon=135272,iconSource="spell:20786",iconLocked=true},
 {id=44,name="Orc Battlecryer",rarity="Common",type="Creature",cost=3,attack=3,health=4,text="Deploy: Give a random ally +1 Attack.",battlecry="buffRandomAlly",attackBuff=1,healthBuff=0,icon=1035050,iconSource="browser:macro-spell:1857",iconLocked=true},
 {id=45,name="Ogre with a Mace",rarity="Rare",type="Creature",cost=3,attack=4,health=3,text="Rush.",charge=true,icon=133532,iconSource="spell:50234",iconLocked=true},
 {id=46,name="Shieldbearer Vrykul",rarity="Rare",type="Creature",cost=4,attack=3,health=6,text="Taunt. Deploy: Gain 2 Armor.",taunt=true,battlecry="armor",amount=2,icon=132359,iconSource="spell:84608",iconLocked=true},
 {id=47,name="Horde Banner Carrier",rarity="Rare",type="Creature",cost=5,attack=5,health=5,text="Deploy: Give your other creatures +1 Attack.",battlecry="buffAllAttack",amount=1,icon=136003,iconSource="spell:47917",iconLocked=true},
 {id=48,name="Oathbound Paladin",rarity="Epic",type="Creature",cost=6,attack=5,health=8,text="Taunt. Deploy: Give your other creatures +1 Health.",taunt=true,battlecry="buffAll",attackBuff=0,healthBuff=1,icon=135964,iconSource="spell:1022",iconLocked=true},
 {id=49,name="Witch Doctor",rarity="Legendary",type="Creature",cost=8,attack=7,health=9,text="Deploy: Give your other creatures +1/+2 and gain 3 Armor.",battlecry="commander",amount=3,attackBuff=1,healthBuff=2,icon=135441,iconSource="spell:20211",iconLocked=true},
 {id=50,name="Endure",rarity="Common",type="Action",cost=1,text="Gain 3 Armor.",effect="armor",amount=3,icon=132937,iconSource="spell:18173",iconLocked=true},
 {id=51,name="Lordaeron Standard",rarity="Common",type="Action",cost=2,text="Give all friendly creatures +1 Attack.",effect="buffAllAttack",amount=1,icon=132483,iconSource="spell:22996",iconLocked=true},
 {id=52,name="Reinforce Armor",rarity="Rare",type="Action",cost=3,health=4,text="Give a friendly creature +4 Health and Taunt.",effect="fortify",target="friendlyCreature"},
 {id=53,name="Veteran's Orders",rarity="Rare",type="Action",cost=4,text="Draw 2 cards and gain 2 Armor.",effect="armorDraw",amount=2},
 {id=54,name="Frozen Hearth",rarity="Epic",type="Action",cost=6,attack=1,health=3,text="Give all friendly creatures +1/+3.",effect="buffAll",icon=134131,iconSource="spell:37536",iconLocked=true},
 {id=55,name="Forged Longsword",rarity="Rare",type="Equipment",cost=3,attack=3,durability=2,text="Equip a 3/2 weapon. Gain 1 Armor.",equip="armor",amount=1,icon=1064234,iconSource="browser:macro-item:10500",iconLocked=true},
 {id=56,name="Bastion Hammer",rarity="Epic",type="Equipment",cost=5,attack=4,durability=3,text="Equip a 4/3 weapon. Gain 2 Armor.",equip="armor",amount=2},
 {id=57,name="Pandaren Arcane Student",rarity="Common",type="Creature",cost=2,attack=1,health=1,text="Deploy: Draw a card.",battlecry="draw",amount=1,icon=133282,iconSource="spell:15646",iconLocked=true},
 {id=58,name="Ebon Hold's Adept",rarity="Common",type="Creature",cost=2,attack=2,health=3,text="",icon=1100041,iconSource="browser:macro-item:10818",iconLocked=true},
 {id=59,name="Sapphire Adept",rarity="Common",type="Creature",cost=2,attack=2,health=2,text="Deploy: Deal 1 damage to the enemy hero.",battlecry="damageEnemyHero",amount=1},
 {id=60,name="Public Enemy",rarity="Common",type="Creature",cost=3,attack=3,health=3,text="Taunt. Deploy: Draw a card.",taunt=true,battlecry="draw",amount=1,icon=1029588,iconSource="browser:macro-spell:1779",iconLocked=true},
 {id=61,name="Groovekeeper",rarity="Rare",type="Creature",cost=3,attack=2,health=5,text="Taunt.",taunt=true,icon=458254,iconSource="spell:91226",iconLocked=true},
 {id=62,name="Astral Lecturer",rarity="Rare",type="Creature",cost=4,attack=4,health=4,text="Deploy: Deal 2 damage to a random enemy creature.",battlecry="damageRandomEnemy",amount=2},
 {id=63,name="Manaforged Savant",rarity="Rare",type="Creature",cost=5,attack=4,health=5,text="Deploy: Draw 2 cards.",battlecry="draw",amount=2,icon=1029587,iconSource="browser:macro-spell:1778",iconLocked=true},
 {id=64,name="Tundra Saber",rarity="Epic",type="Creature",cost=6,attack=5,health=6,text="Deploy: Deal 2 damage to all enemy creatures.",battlecry="damageAllEnemy",amount=2,icon=132130,iconSource="browser:macro-spell:42",iconLocked=true},
 {id=65,name="Grand Thaumaturge",rarity="Legendary",type="Creature",cost=8,attack=7,health=8,text="Deploy: Draw 2 cards and deal 3 damage to the enemy hero.",battlecry="arcaneLegend",amount=3,icon=132123,iconSource="browser:macro-spell:35",iconLocked=true},
 {id=66,name="Arcane Stars",rarity="Common",type="Action",cost=1,text="Deal 2 damage to all enemy creatures.",effect="damageAllEnemy",amount=2,icon=135753,iconSource="spell:13901",iconLocked=true},
 {id=67,name="X Marks the Spot",rarity="Common",type="Action",cost=2,text="Draw 2 cards.",effect="draw",amount=2,icon=134269,iconSource="spell:45853",iconLocked=true},
 {id=68,name="Prismatic Bolt",rarity="Rare",type="Action",cost=3,text="Deal 4 damage to an enemy.",effect="damage",target="enemy",amount=4,icon=135730,iconSource="browser:macro-spell:284",iconLocked=true},
 {id=69,name="Shado-Pan Sword",rarity="Rare",type="Action",cost=4,text="Draw 2 cards and restore 2 Health.",effect="drawHeal",amount=2,icon=647629,iconSource="item:89606",iconLocked=true},
 {id=70,name="Cometfall",rarity="Epic",type="Action",cost=6,text="Deal 3 damage to all enemy creatures.",effect="aoeEnemy",amount=3,icon=135734,iconSource="browser:macro-spell:288",iconLocked=true},
 {id=71,name="Necrotic Touch",rarity="Rare",type="Equipment",cost=3,attack=2,durability=3,text="Equip a 2/3 weapon. Draw a card.",equip="draw",amount=1,icon=135474,iconSource="item:20410",iconLocked=true},
 {id=72,name="Obelisk Caller",rarity="Epic",type="Equipment",cost=5,attack=4,durability=2,text="Equip a 4/2 weapon. Deploy: Deal 2 damage to the enemy hero.",equip="damageEnemyHero",amount=2,icon=134922,iconSource="item:52723",iconLocked=true},
 {id=73,name="Durotan Grunt",rarity="Common",type="Creature",cost=1,attack=1,health=2,text="Rush.",charge=true,icon=236452,iconSource="spell:68338",iconLocked=true},
 {id=74,name="Seducer",rarity="Common",type="Creature",cost=2,attack=3,health=2,text="Taunt.",taunt=true,icon=136220,iconSource="spell:712",iconLocked=true},
 {id=75,name="Swordsman",rarity="Common",type="Creature",cost=2,attack=2,health=3,text="Deploy: Deal 1 damage to a random enemy creature.",battlecry="damageRandomEnemy",amount=1,icon=135274,iconSource="spell:7038",iconLocked=true},
 {id=76,name="Mesmerizing Groove Tree",rarity="Common",type="Creature",cost=3,attack=0,health=8,text="Taunt.",taunt=true,icon=136060,iconSource="spell:30884",iconLocked=true},
 {id=77,name="Ohn'aran Spirit",rarity="Rare",type="Creature",cost=3,attack=4,health=3,text="Rush.",charge=true,icon=132280,iconSource="spell:7913",iconLocked=true},
 {id=78,name="Grove Stalker",rarity="Rare",type="Creature",cost=4,attack=4,health=4,text="Deploy: Give a random ally +1/+1.",battlecry="buffRandomAlly",attackBuff=1,healthBuff=1},
 {id=79,name="Man with Crown of Thorns",rarity="Rare",type="Creature",cost=5,attack=5,health=5,text="Deploy: Restore 3 Health to your hero.",battlecry="healHero",amount=3,icon=135905,iconSource="browser:macro-spell:459",iconLocked=true},
 {id=80,name="Moonclaw Predator",rarity="Epic",type="Creature",cost=6,attack=6,health=5,text="Rush. Lifesteal.",charge=true,lifesteal=true},
 {id=81,name="Mogu'shan Legend",rarity="Legendary",type="Creature",cost=8,attack=7,health=10,text="Taunt. Deploy: Give your other creatures +1/+1.",taunt=true,battlecry="buffAll",attackBuff=1,healthBuff=1,icon=253399,iconSource="spell:64411",iconLocked=true},
 {id=82,name="Rot Breath",rarity="Common",type="Action",cost=1,text="Deal 2 damage to an enemy creature.",effect="damage",target="enemyCreature",amount=2,icon=135867,iconSource="browser:macro-spell:421",iconLocked=true},
 {id=83,name="Pack Instinct",rarity="Common",type="Action",cost=2,attack=2,health=1,text="Give a friendly creature +2/+1.",effect="buff",target="friendlyCreature",icon=135879,iconSource="browser:macro-spell:433",iconLocked=true},
 {id=84,name="Chain Heal",rarity="Rare",type="Action",cost=3,text="Restore 2 Health to all friendly creatures and your hero.",effect="healAll",amount=2,icon=136043,iconSource="spell:27624",iconLocked=true},
 {id=85,name="Arc of Fire",rarity="Rare",type="Action",cost=4,text="Deal 2 damage to all enemy creatures.",effect="aoeEnemy",amount=2,icon=1032475,iconSource="browser:macro-spell:1810",iconLocked=true},
 {id=86,name="Overclock",rarity="Epic",type="Action",cost=5,text="Give all friendly creatures +2 Attack.",effect="buffAllAttack",amount=2,icon=136243,iconSource="spell:1",iconLocked=true},
 {id=87,name="Hunter's Hatchet",rarity="Rare",type="Equipment",cost=3,attack=3,durability=2,text="Equip a 3/2 weapon.",icon=1001619,iconSource="browser:macro-item:9516",iconLocked=true},
 {id=88,name="Thornwood Spear",rarity="Epic",type="Equipment",cost=5,attack=5,durability=2,text="Equip a 5/2 weapon. Deploy: Restore 2 Health to your hero.",equip="healHero",amount=2,icon=1028611,iconSource="browser:macro-item:9949",iconLocked=true},
 {id=89,name="Falcon",rarity="Common",type="Creature",cost=1,attack=2,health=1,text="",icon=1029584,iconSource="browser:macro-spell:1775",iconLocked=true},
 {id=90,name="Coinpurse Spy",rarity="Common",type="Creature",cost=2,attack=2,health=2,text="Deploy: Draw a card.",battlecry="draw",amount=1},
 {id=91,name="Rooftop Duelist",rarity="Common",type="Creature",cost=2,attack=3,health=2,text="Rush.",charge=true},
 {id=92,name="Troll Warrior",rarity="Common",type="Creature",cost=3,attack=4,health=3,text="",icon=132168,iconSource="spell:20782",iconLocked=true},
 {id=93,name="Cutter",rarity="Rare",type="Creature",cost=3,attack=3,health=4,text="Deploy: Deal 1 damage to a random enemy creature.",battlecry="damageRandomEnemy",amount=1,icon=1001616,iconSource="browser:macro-item:9513",iconLocked=true},
 {id=94,name="Night Ledger",rarity="Rare",type="Creature",cost=4,attack=3,health=4,text="Deploy: Draw 2 cards.",battlecry="draw",amount=2},
 {id=95,name="Silent Collector",rarity="Rare",type="Creature",cost=5,attack=3,health=5,text="Deadly.",deadly=true},
 {id=96,name="Blackcoat Mastermind",rarity="Epic",type="Creature",cost=6,attack=5,health=6,text="Deploy: Deal 3 damage to a random enemy creature and draw a card.",battlecry="shadowMaster",amount=3},
 {id=97,name="Kingpin of Knives",rarity="Legendary",type="Creature",cost=8,attack=6,health=7,text="Rush. Deadly. Deploy: Draw a card.",charge=true,deadly=true,battlecry="draw",amount=1},
 {id=98,name="Fel-infused Dagger",rarity="Common",type="Action",cost=1,text="Deal 1 damage to an enemy creature and exhaust it.",effect="dirty",target="enemyCreature",amount=1,icon=135694,iconSource="spell:72333",iconLocked=true},
 {id=99,name="Quick Getaway",rarity="Common",type="Action",cost=2,text="Draw a card and restore 2 Health.",effect="drawHeal",amount=1,healAmount=2},
 {id=100,name="Marked Coin",rarity="Rare",type="Action",cost=3,text="Draw 2 cards.",effect="draw",amount=2},
 {id=101,name="Crown's Justice",rarity="Rare",type="Action",cost=4,text="Deal 5 damage to an enemy creature.",effect="damage",target="enemyCreature",amount=5,icon=463448,iconSource="spell:113244",iconLocked=true},
 {id=102,name="Perfect Heist",rarity="Epic",type="Action",cost=6,text="Draw 3 cards and gain 3 Armor.",effect="armorDraw",amount=3,icon=1003597,iconSource="browser:macro-item:9551",iconLocked=true},
 {id=103,name="Heavy Papers",rarity="Rare",type="Equipment",cost=3,attack=3,durability=2,text="Equip a 3/2 weapon.",icon=237018,iconSource="item:46348",iconLocked=true},
 {id=104,name="Tax Papers",rarity="Epic",type="Equipment",cost=5,attack=4,durability=3,text="Equip a 4/3 weapon. Draw a card.",equip="draw",amount=1,icon=237020,iconSource="item:84584",iconLocked=true},
 {id=105,name="Crypt Lantern",rarity="Common",type="Creature",cost=1,attack=1,health=2,text="Lifesteal.",lifesteal=true},
 {id=106,name="Bonepicker",rarity="Common",type="Creature",cost=2,attack=3,health=2,text="",icon=1029009,iconSource="browser:macro-spell:1768",iconLocked=true},
 {id=107,name="Ravencrest Felguard",rarity="Common",type="Creature",cost=2,attack=2,health=4,text="Taunt.",taunt=true,icon=136142,iconSource="spell:3107",iconLocked=true},
 {id=108,name="Pale Warden",rarity="Common",type="Creature",cost=3,attack=3,health=3,text="Deploy: Restore 2 Health to your hero.",battlecry="healHero",amount=2},
 {id=109,name="Mechagnome Bounty Hunter",rarity="Rare",type="Creature",cost=3,attack=4,health=3,text="Lifesteal.",lifesteal=true,icon=133878,iconSource="spell:34630",iconLocked=true},
 {id=110,name="Ossuary Shade",rarity="Rare",type="Creature",cost=4,attack=4,health=5,text="Taunt.",taunt=true,icon=1045852,iconSource="browser:macro-spell:1878",iconLocked=true},
 {id=111,name="Grave Bell Keeper",rarity="Rare",type="Creature",cost=5,attack=4,health=6,text="Deploy: Deal 2 damage to all enemy creatures.",battlecry="damageAllEnemy",amount=2},
 {id=112,name="Nature's Champion",rarity="Epic",type="Creature",cost=6,attack=6,health=7,text="Lifesteal. Taunt.",taunt=true,lifesteal=true,icon=456570,iconSource="spell:93341",iconLocked=true},
 {id=113,name="Troll Bloodluster",rarity="Legendary",type="Creature",cost=8,attack=7,health=8,text="Deploy: Deal 3 damage to all enemy creatures. Lifesteal.",lifesteal=true,battlecry="damageAllEnemy",amount=3,icon=136012,iconSource="spell:2825",iconLocked=true},
 {id=114,name="Chill of the Crypt",rarity="Common",type="Action",cost=1,text="Deal 2 damage to an enemy.",effect="damage",target="enemy",amount=2,icon=1020342,iconSource="browser:macro-spell:1752",iconLocked=true},
 {id=115,name="Borrowed Time",rarity="Common",type="Action",cost=2,text="Restore 4 Health to your hero.",effect="healHero",amount=4},
 {id=116,name="Grave Dust",rarity="Rare",type="Action",cost=3,text="Deal 1 damage to all creatures.",effect="damageAll",amount=1},
 {id=117,name="Funeral March",rarity="Rare",type="Action",cost=4,attack=1,health=2,text="Give all friendly creatures +1/+2.",effect="buffAll",icon=1022945,iconSource="browser:macro-spell:1755",iconLocked=true},
 {id=118,name="Open the Tomb",rarity="Epic",type="Action",cost=6,text="Deal 3 damage to all enemy creatures and restore 3 Health.",effect="aoeHeal",amount=3},
 {id=119,name="Graveiron Axe",rarity="Rare",type="Equipment",cost=3,attack=3,durability=2,text="Equip a 3/2 weapon. Restore 1 Health.",equip="healHero",amount=1},
 {id=120,name="Mourning Scythe",rarity="Epic",type="Equipment",cost=5,attack=5,durability=2,text="Equip a 5/2 weapon. Gain 2 Armor.",equip="armor",amount=2},
 {id=121,name="Spark Tinkerer",rarity="Common",type="Creature",cost=1,attack=1,health=2,text="Deploy: Deal 1 damage to the enemy hero.",battlecry="damageEnemyHero",amount=1},
 {id=122,name="Coalhand Scrapper",rarity="Common",type="Creature",cost=2,attack=3,health=2,text="",icon=1769069,iconSource="browser:macro-spell:2185",iconLocked=true},
 {id=123,name="Ren'dorei Riftwalker",rarity="Common",type="Creature",cost=2,attack=2,health=3,text="Rush.",charge=true,icon=1724001,iconSource="browser:macro-spell:2179",iconLocked=true},
 {id=124,name="Cinder Mechanic",rarity="Common",type="Creature",cost=3,attack=3,health=4,text="Deploy: Deal 1 damage to a random enemy creature.",battlecry="damageRandomEnemy",amount=1},
 {id=125,name="Drust Spirit",rarity="Rare",type="Creature",cost=3,attack=4,health=3,text="Deploy: Deal 2 damage to the enemy hero.",battlecry="damageEnemyHero",amount=2,icon=2085307,iconSource="browser:macro-spell:2215",iconLocked=true},
 {id=126,name="Moon's Panther",rarity="Rare",type="Creature",cost=4,attack=4,health=5,text="Rush. Deploy: Deal 2 damage to a random enemy creature.",charge=true,battlecry="damageRandomEnemy",amount=2,icon=132252,iconSource="spell:8875",iconLocked=true},
 {id=127,name="Molten Enforcer",rarity="Rare",type="Creature",cost=5,attack=6,health=4,text="Rush.",charge=true,icon=236215,iconSource="browser:macro-spell:854",iconLocked=true},
 {id=128,name="Frenized Cutthroat",rarity="Epic",type="Creature",cost=6,attack=5,health=7,text="Deploy: Deal 2 damage to all enemy creatures.",battlecry="damageAllEnemy",amount=2,icon=236281,iconSource="browser:macro-spell:920",iconLocked=true},
 {id=129,name="Legion's Champion",rarity="Legendary",type="Creature",cost=8,attack=8,health=7,text="Deploy: Deal 4 damage to the enemy hero and 2 to all enemy creatures.",battlecry="foundry",amount=4,aoe=2,icon=236293,iconSource="browser:macro-spell:932",iconLocked=true},
 {id=130,name="Quick Slash",rarity="Common",type="Action",cost=1,text="Deal 2 damage to an enemy.",effect="damage",target="enemy",amount=2,icon=132369,iconSource="spell:75",iconLocked=true},
 {id=131,name="Acid Bottle",rarity="Common",type="Action",cost=2,text="Deal 1 damage to all enemy creatures.",effect="aoeEnemy",amount=1,icon=236885,iconSource="browser:macro-item:3362",iconLocked=true},
 {id=132,name="Overpressure",rarity="Rare",type="Action",cost=3,text="Deal 4 damage to an enemy.",effect="damage",target="enemy",amount=4,icon=236306,iconSource="browser:macro-spell:945",iconLocked=true},
 {id=133,name="Chain Detonation",rarity="Rare",type="Action",cost=4,text="Deal 2 damage to all creatures.",effect="damageAll",amount=2},
 {id=134,name="Shadow Technique",rarity="Epic",type="Action",cost=6,text="Deal 7 damage to the enemy hero.",effect="damageEnemyHero",amount=7,icon=236279,iconSource="spell:68939",iconLocked=true},
 {id=135,name="Cinder Cleaver",rarity="Rare",type="Equipment",cost=3,attack=3,durability=2,text="Equip a 3/2 weapon. Deal 1 damage to the enemy hero.",equip="damageEnemyHero",amount=1},
 {id=136,name="Hardwood Hammer",rarity="Epic",type="Equipment",cost=5,attack=5,durability=2,text="Equip a 5/2 weapon. Deal 2 damage to the enemy hero.",equip="damageEnemyHero",amount=2,icon=1059110,iconSource="browser:macro-item:10440",iconLocked=true},
 {id=137,name="Aiding Spirit",rarity="Common",type="Creature",cost=1,attack=1,health=2,text="Deploy: Restore 1 Health to your hero.",battlecry="healHero",amount=1,icon=1022944,iconSource="browser:macro-spell:1754",iconLocked=true},
 {id=138,name="River Apothecary",rarity="Common",type="Creature",cost=2,attack=2,health=3,text="Deploy: Restore 2 Health to your hero.",battlecry="healHero",amount=2},
 {id=139,name="Mechagnome Houndbot 5000",rarity="Common",type="Creature",cost=2,attack=2,health=4,text="Taunt.",taunt=true,icon=1113014,iconSource="browser:macro-spell:1893",iconLocked=true},
 {id=140,name="Tidepool Seer",rarity="Common",type="Creature",cost=3,attack=3,health=3,text="Deploy: Draw a card.",battlecry="draw",amount=1},
 {id=141,name="Blast Architect",rarity="Rare",type="Creature",cost=3,attack=3,health=5,text="Taunt. Deploy: Gain 1 Armor.",taunt=true,battlecry="armor",amount=1,icon=135626,iconSource="spell:35370",iconLocked=true},
 {id=142,name="Pearl Medic",rarity="Rare",type="Creature",cost=4,attack=4,health=4,text="Deploy: Restore 3 Health to your hero.",battlecry="healHero",amount=3},
 {id=143,name="Raincaller Sage",rarity="Rare",type="Creature",cost=5,attack=4,health=6,text="Deploy: Restore 2 Health to your hero and friendly creatures.",battlecry="healAll",amount=2,icon=135738,iconSource="browser:macro-spell:292",iconLocked=true},
 {id=144,name="Deepwater Templar",rarity="Epic",type="Creature",cost=6,attack=5,health=8,text="Taunt. Lifesteal.",taunt=true,lifesteal=true},
 {id=145,name="Surge Totem",rarity="Legendary",type="Creature",cost=8,attack=6,health=10,text="Deploy: Restore 5 Health to your hero and give your other creatures +2 Health.",battlecry="tideLegend",amount=5,healthBuff=2,icon=538569,iconSource="spell:108280",iconLocked=true},
 {id=146,name="Soothing Prayer",rarity="Common",type="Action",cost=1,text="Restore 3 Health to a friendly target.",effect="heal",target="friendly",amount=3,icon=135943,iconSource="spell:596",iconLocked=true},
 {id=147,name="Tidal Blessing",rarity="Common",type="Action",cost=2,attack=1,health=2,text="Give a friendly creature +1/+2.",effect="buff",target="friendlyCreature",icon=135777,iconSource="browser:macro-spell:331",iconLocked=true},
 {id=148,name="Cleansing Wave",rarity="Rare",type="Action",cost=3,text="Restore 2 Health to all friendly creatures and your hero.",effect="healAll",amount=2,icon=136007,iconSource="browser:macro-spell:561",iconLocked=true},
 {id=149,name="Deluge",rarity="Rare",type="Action",cost=4,text="Deal 2 damage to all enemy creatures.",effect="aoeEnemy",amount=2,icon=136008,iconSource="browser:macro-spell:562",iconLocked=true},
 {id=150,name="Regrowth",rarity="Epic",type="Action",cost=5,text="Restore 8 Health to your hero and draw a card.",effect="healDraw",amount=8,draw=1,icon=136074,iconSource="browser:macro-spell:628",iconLocked=true},
 {id=151,name="Coral Mace",rarity="Rare",type="Equipment",cost=3,attack=3,durability=2,text="Equip a 3/2 weapon. Restore 2 Health.",equip="healHero",amount=2},
 {id=152,name="Tideshaper Trident",rarity="Epic",type="Equipment",cost=5,attack=4,durability=3,text="Equip a 4/3 weapon. Restore 3 Health.",equip="healHero",amount=3,icon=136061,iconSource="browser:macro-spell:615",iconLocked=true},
 {id=153,name="Dawn Novice",rarity="Common",type="Creature",cost=1,attack=1,health=3,text=""},
 {id=154,name="Sunlit Page",rarity="Common",type="Creature",cost=2,attack=2,health=2,text="Deploy: Draw a card.",battlecry="draw",amount=1},
 {id=155,name="Starward Sentinel",rarity="Common",type="Creature",cost=2,attack=2,health=4,text="Taunt.",taunt=true},
 {id=156,name="Emotional Support Sheep",rarity="Common",type="Creature",cost=2,attack=1,health=4,text="Deploy: Restore 2 Health to your hero.",battlecry="healHero",amount=2,icon=136071,iconSource="browser:macro-spell:625",iconLocked=true},
 {id=157,name="Stormwind Patrol",rarity="Rare",type="Creature",cost=3,attack=4,health=3,text="Rush.",charge=true,icon=134474,iconSource="item:15198",iconLocked=true},
 {id=158,name="Halo Keeper",rarity="Rare",type="Creature",cost=4,attack=3,health=6,text="Deploy: Give a random ally +1/+2.",battlecry="buffRandomAlly",attackBuff=1,healthBuff=2,icon=135968,iconSource="browser:macro-spell:522",iconLocked=true},
 {id=159,name="Radiant Exemplar",rarity="Rare",type="Creature",cost=5,attack=5,health=6,text="Deploy: Gain 3 Armor.",battlecry="armor",amount=3},
 {id=160,name="Comet Herald",rarity="Epic",type="Creature",cost=6,attack=6,health=6,text="Deploy: Give your other creatures +1/+1.",battlecry="buffAll",attackBuff=1,healthBuff=1,icon=135982,iconSource="browser:macro-spell:536",iconLocked=true},
 {id=161,name="Crowned Luminary",rarity="Legendary",type="Creature",cost=8,attack=7,health=9,text="Taunt. Deploy: Draw 2 cards, restore 3 Health, and gain 3 Armor.",taunt=true,battlecry="luminary",amount=3,icon=135976,iconSource="browser:macro-spell:530",iconLocked=true},
 {id=162,name="Glimmering Grace",rarity="Common",type="Action",cost=1,text="Restore 3 Health to a friendly target.",effect="heal",target="friendly",amount=3,icon=135980,iconSource="browser:macro-spell:534",iconLocked=true},
 {id=163,name="Storm Above Your Head",rarity="Common",type="Action",cost=2,text="Deal 3 damage to an enemy creature.",effect="damage",target="enemyCreature",amount=3,icon=136107,iconSource="browser:macro-spell:661",iconLocked=true},
 {id=164,name="Radiant Muster",rarity="Rare",type="Action",cost=3,attack=1,health=1,text="Give all friendly creatures +1/+1.",effect="buffAll",icon=135972,iconSource="browser:macro-spell:526",iconLocked=true},
 {id=165,name="Light's Measure",rarity="Rare",type="Action",cost=4,text="Draw 2 cards and gain 2 Armor.",effect="armorDraw",amount=2,icon=135984,iconSource="browser:macro-spell:538",iconLocked=true},
 {id=166,name="Final Benediction",rarity="Epic",type="Action",cost=6,health=2,text="Restore 6 Health to your hero and give all friendly creatures +2 Health.",effect="benediction",amount=6,icon=135967,iconSource="browser:macro-spell:521",iconLocked=true},
 {id=167,name="Sunforged Saber",rarity="Rare",type="Equipment",cost=3,attack=3,durability=2,text="Equip a 3/2 weapon. Gain 1 Armor.",equip="armor",amount=1},
 {id=168,name="Blessed Spikes",rarity="Epic",type="Equipment",cost=5,attack=4,durability=3,text="Equip a 4/3 weapon. Draw a card and restore 2 Health.",equip="drawHeal",amount=2,icon=135210,iconSource="item:36552",iconLocked=true},
 }

-- The second player receives this non-collectible token directly in hand. It
-- lives outside HS.CARDS so it never appears in packs, collection progress,
-- Quick Fill, or starter-deck ownership calculations.
HS.MAGICAL_COIN_ID=9999
local MAGICAL_COIN_CARD={
    id=HS.MAGICAL_COIN_ID,name="Magical Coin",rarity="Common",type="Action",cost=0,
    text="Gain 1 temporary Mana this turn.",effect="gainMana",amount=1,
    icon="Interface\\Icons\\INV_Misc_Coin_01",iconSource="token",iconLocked=true,tokenOnly=true,
}
local function RegisterSpecialCards()
    HS.CARD_BY_ID=HS.CARD_BY_ID or {}
    HS.CARD_BY_ID[HS.MAGICAL_COIN_ID]=MAGICAL_COIN_CARD
end

-- Card art policy: cards may only use Blizzard/WoW spell or item icons.
-- v2.8 no longer walks the first valid texture IDs and assigns them blindly.
-- Instead we build a live catalogue from spell/item names and score each card
-- against that catalogue.  The result remains 100% Blizzard art while names
-- such as "Field Medic", "Arcane Apprentice" or "Sunforged Saber" now prefer
-- healing, arcane and weapon imagery respectively.  Texture IDs are globally
-- de-duplicated so a card icon is never reused by another card.
local function ResolveSpellIcon(spellID)
    local fn=C_Spell and C_Spell.GetSpellTexture
    if fn then local ok,tex=pcall(fn,spellID);if ok and tex then return tex end end
    if GetSpellTexture then local ok,tex=pcall(GetSpellTexture,spellID);if ok and tex then return tex end end
end
local function ResolveSpellName(spellID)
    local fn=C_Spell and C_Spell.GetSpellName
    if fn then local ok,name=pcall(fn,spellID);if ok and name and name~="" then return name end end
    if GetSpellInfo then local ok,name=pcall(GetSpellInfo,spellID);if ok and name and name~="" then return name end end
end
local function ResolveItemIcon(itemID)
    local fn=C_Item and C_Item.GetItemIconByID
    if fn then local ok,tex=pcall(fn,itemID);if ok and tex then return tex end end
    if GetItemIcon then local ok,tex=pcall(GetItemIcon,itemID);if ok and tex then return tex end end
end
local function ResolveItemName(itemID)
    local fn=C_Item and C_Item.GetItemNameByID
    if fn then local ok,name=pcall(fn,itemID);if ok and name and name~="" then return name end end
    local info=C_Item and C_Item.GetItemInfo
    if info then local ok,name=pcall(info,itemID);if ok and name and name~="" then return name end end
    if GetItemInfo then local ok,name=pcall(GetItemInfo,itemID);if ok and name and name~="" then return name end end
end
local function IsUsableGameIcon(tex)
    if tex==nil or tex==0 or tex==134400 then return false end -- question-mark placeholder
    local s=tostring(tex)
    if s=="" or s:find("INV_Misc_QuestionMark",1,true) then return false end
    if s:find("Interface\\AddOns\\ODYN",1,true) then return false end
    return true
end

local ICON_STOP={
    ["the"]=true,["a"]=true,["an"]=true,["of"]=true,["to"]=true,["and"]=true,["or"]=true,["your"]=true,["this"]=true,["that"]=true,
    ["card"]=true,["cards"]=true,["creature"]=true,["action"]=true,["equipment"]=true,["deal"]=true,["deals"]=true,["give"]=true,["gain"]=true,
    ["restore"]=true,["draw"]=true,["enemy"]=true,["friendly"]=true,["health"]=true,["attack"]=true,["mana"]=true,["hero"]=true,["deploy"]=true,
    ["one"]=true,["two"]=true,["three"]=true,["all"]=true,["other"]=true,["random"]=true,["can"]=true,["immediately"]=true,["first"]=true,
}
local ICON_SYNONYMS={
    brawler={"brawl","fist","punch","fight","combat","strike"}, lookout={"scout","watch","vision","sight","eye"}, dockside={"dock","harbor","sea","tide"},
    medic={"heal","healing","bandage","renew","mend"}, shieldbearer={"shield","guard","protect","defense"}, shield={"shield","guard","protect","barrier"},
    cutpurse={"rogue","steal","pickpocket","dagger","shadow"}, mystic={"mystic","magic","spirit","arcane"}, caravan={"wagon","travel","guard","road"},
    fighter={"fight","battle","strike","warrior"}, mercenary={"mercenary","soldier","blade","warrior"}, bruiser={"bruiser","smash","fist","brawl"},
    arcane={"arcane","mana","magic","spell"}, apprentice={"apprentice","student","novice","magic"}, ironhide={"iron","armor","shield","fortify"},
    protector={"protect","guardian","shield","defense"}, duelist={"duel","sword","blade","parry"}, graveyard={"grave","death","undead","bone"},
    watcher={"watch","eye","sentry","vision"}, alchemist={"alchemy","potion","flask","elixir"}, stormbound={"storm","lightning","thunder","wind"},
    engineer={"engineer","bomb","powder","fire","machine"}, blackpowder={"gunpowder","bomb","explosive","fire"}, moonlit={"moon","lunar","night","shadow"},
    runebound={"rune","runic","ward","magic"}, guardian={"guardian","guard","shield","ward"}, master={"master","veteran","champion"}, arms={"weapon","sword","blade","arms"},
    champion={"champion","victory","warrior","battle"}, colossus={"colossus","giant","titan","stone"}, ancient={"ancient","elder","titan","nature"},
    tavern={"tavern","brew","ale","brawl"}, legend={"legend","glory","heroic","champion"}, cheap={"quick","dirty","rogue"}, shot={"shot","arrow","gun","projectile"},
    bandage={"bandage","heal","mend"}, insight={"insight","knowledge","mind","arcane"}, rally={"rally","banner","battle","shout"}, blow={"blow","strike","smash"},
    fortify={"fortify","armor","shield","wall"}, chance={"resurrect","renew","hope","life"}, scattershot={"scatter","shot","gun","arrow"}, fireball={"fire","flame","burn"},
    frost={"frost","ice","cold","snow"}, ember={"ember","fire","flame","burn"}, flame={"flame","fire","burn"}, wild={"wild","nature","beast","leaf"},
    hunt={"hunt","hunter","arrow","beast"}, shadow={"shadow","dark","rogue","void"}, grave={"grave","death","undead","bone"}, tide={"tide","water","sea","wave"},
    celestial={"celestial","star","astral","holy","light"}, astral={"astral","star","arcane","celestial"}, sun={"sun","holy","light","fire"}, moon={"moon","lunar","night"},
    verdant={"nature","leaf","vine","green"}, storm={"storm","lightning","thunder","wind"}, iron={"iron","metal","armor"}, steel={"steel","metal","blade"},
    blade={"blade","sword","slash"}, saber={"saber","sword","blade"}, sword={"sword","blade","slash"}, mace={"mace","hammer","smash"}, hammer={"hammer","mace","smash"},
    axe={"axe","cleave","chop"}, staff={"staff","magic","spell"}, trident={"trident","spear","water"}, bow={"bow","arrow","shot"}, dagger={"dagger","knife","rogue"},
    rifle={"rifle","gun","shot"}, pistol={"pistol","gun","shot"}, cannon={"cannon","bomb","shot"}, bomb={"bomb","explosive","fire"}, poison={"poison","toxin","venom"},
    deadly={"death","skull","poison","execute"}, lifesteal={"blood","drain","vampiric","leech"}, taunt={"taunt","challenge","shield","guard"}, rush={"charge","rush","dash","speed"},
    heal={"heal","healing","renew","holy","mend"}, armor={"armor","shield","fortify","defense"}, draw={"book","knowledge","insight","scroll"},
    blood={"blood","vampiric","red"}, bone={"bone","skull","undead"}, wolf={"wolf","beast","fang"}, beast={"beast","claw","fang"}, dragon={"dragon","drake","flame"},
    serpent={"serpent","snake","venom"}, eagle={"eagle","bird","wing"}, raven={"raven","bird","shadow"}, bear={"bear","beast","claw"}, lion={"lion","beast","roar"},
    academy={"academy","book","knowledge","arcane"}, scholar={"scholar","book","knowledge"}, oracle={"oracle","vision","eye","prophecy"}, seer={"seer","vision","eye"},
    priest={"priest","holy","heal"}, knight={"knight","sword","shield"}, paladin={"paladin","holy","shield"}, rogue={"rogue","dagger","shadow"}, ranger={"ranger","bow","arrow"},
    sailor={"sailor","sea","ship","tide"}, pirate={"pirate","saber","ship"}, captain={"captain","ship","command"}, warden={"warden","guard","shield"},
}
local function AddToken(set,word,weight)
    word=tostring(word or ""):lower():gsub("[^%a]","")
    if word=="" or #word<3 or ICON_STOP[word] then return end
    local old=set[word] or 0;if weight>old then set[word]=weight end
    local ex=ICON_SYNONYMS[word]
    if ex then for _,v in ipairs(ex) do if (set[v] or 0)<(weight*.72) then set[v]=weight*.72 end end end
end
local ICON_EFFECT_TOKENS={
    damage={"strike","blast","bolt","shot"},aoeEnemy={"storm","blast","nova"},heal={"heal","renew","mend"},healAll={"healing","wave","renew"},healDraw={"heal","knowledge","renew"},
    draw={"book","knowledge","insight"},armor={"shield","armor","guard"},armorDraw={"shield","knowledge"},buff={"blessing","power","might"},buffAll={"rally","banner","blessing"},
    buffRandomAlly={"blessing","ally","power"},benediction={"holy","blessing","heal"},summon={"summon","call"},dirty={"rogue","poison","dagger"},
    damageEnemyHero={"strike","blast"},drawHeal={"heal","book","staff"},tideLegend={"tide","water","heal"},luminary={"holy","light","crown"},
}
local function CardIconTokens(card)
    local set={}
    for word in tostring(card.name or ""):gmatch("[%a]+") do AddToken(set,word,11) end
    for word in tostring(card.text or ""):gmatch("[%a]+") do AddToken(set,word,3.6) end
    if card.type=="Equipment" then AddToken(set,"weapon",9);AddToken(set,"blade",5) end
    if card.type=="Action" then AddToken(set,"spell",3.5) end
    if card.type=="Creature" then AddToken(set,"combat",1.4) end
    if card.taunt then AddToken(set,"taunt",7);AddToken(set,"shield",5) end
    if card.charge then AddToken(set,"rush",7);AddToken(set,"charge",5) end
    if card.deadly then AddToken(set,"deadly",8);AddToken(set,"poison",5) end
    if card.lifesteal then AddToken(set,"lifesteal",8);AddToken(set,"drain",5) end
    local effect=card.effect or card.battlecry or card.equip
    local extras=ICON_EFFECT_TOKENS[effect]
    if extras then for _,word in ipairs(extras) do AddToken(set,word,5.5) end end
    if card.target=="enemyCreature" or card.target=="enemy" then AddToken(set,"target",1.2) end
    return set
end
local function BuildNamedIconPool(kind,iconResolver,nameResolver,maxID,limit,usedTexture)
    local out={}
    for id=1,maxID do
        local tex=iconResolver(id)
        if IsUsableGameIcon(tex) then
            local key=tostring(tex)
            if not usedTexture[key] then
                local name=nameResolver(id)
                if name and name~="" then
                    usedTexture[key]=true
                    out[#out+1]={tex=tex,name=tostring(name):lower(),kind=kind,id=id}
                    if #out>=limit then break end
                end
            end
        end
    end
    return out
end
local function BuildUniqueIconPool(resolver,needed,maxID,used)
    local out={}
    for id=1,maxID do
        local tex=resolver(id)
        if IsUsableGameIcon(tex) then local key=tostring(tex);if not used[key] then used[key]=true;out[#out+1]=tex;if #out>=needed then break end end end
    end
    return out
end
local function ScoreIconCandidate(card,tokens,cand)
    local score,hits=0,0;local name=cand.name or ""
    for token,weight in pairs(tokens) do
        if name:find(token,1,true) then score=score+weight;hits=hits+1 end
    end
    -- Multiple semantic hits beat a generic one-word resemblance.  This is
    -- important for names such as "Frostwolf Protector" or "Sunforged Saber".
    if hits>=2 then score=score+7+(hits-2)*2 end
    if card.type=="Equipment" then score=score+(cand.kind=="item" and 5 or -3)
    elseif card.type=="Action" then score=score+(cand.kind=="spell" and 2.2 or 0)
    else score=score+(cand.kind=="spell" and .8 or 0) end
    return score,hits
end
local function AssignBuiltInCardIcons()
    local candidateSeen={}
    -- Scan live Blizzard spell/item data, then choose a unique icon whose
    -- associated name best matches each RPAL card's name, rules and type.
    -- Equipment is resolved first so swords/staves/maces keep actual item art.
    local spells=BuildNamedIconPool("spell",ResolveSpellIcon,ResolveSpellName,120000,3600,candidateSeen)
    local items=BuildNamedIconPool("item",ResolveItemIcon,ResolveItemName,90000,1400,candidateSeen)
    local candidates={}
    for _,v in ipairs(spells) do candidates[#candidates+1]=v end
    for _,v in ipairs(items) do candidates[#candidates+1]=v end

    local assigned={}
    local order={}
    for _,card in ipairs(HS.CARDS) do order[#order+1]=card end
    table.sort(order,function(a,b)
        if (a.type=="Equipment")~=(b.type=="Equipment") then return a.type=="Equipment" end
        return (a.id or 0)<(b.id or 0)
    end)

    for _,card in ipairs(order) do
        if not card.iconLocked then
        local tokens=CardIconTokens(card)
        local best,bestScore,bestHits
        for _,cand in ipairs(candidates) do
            local key=tostring(cand.tex)
            local typeOK=(card.type~="Equipment" or cand.kind=="item")
            if typeOK and not assigned[key] then
                local score,hits=ScoreIconCandidate(card,tokens,cand)
                if not best or score>bestScore then best,bestScore,bestHits=cand,score,hits end
            end
        end
        if best and bestScore and (bestHits or 0)>0 then
            card.icon=best.tex
            card.iconSource=best.kind..":"..tostring(best.id)
            assigned[tostring(best.tex)]=true
        end
        end
    end

    -- Defensive fallback for uncached or stripped-down clients.
    -- It still only uses Blizzard item/spell icon textures and preserves global
    -- uniqueness wherever the client exposes enough icons.
    local fallbackUsed={}
    for key in pairs(assigned) do fallbackUsed[key]=true end
    local fallbackItems=BuildUniqueIconPool(ResolveItemIcon,#HS.CARDS,50000,fallbackUsed)
    local fallbackSpells=BuildUniqueIconPool(ResolveSpellIcon,#HS.CARDS,70000,fallbackUsed)
    local ii,si=1,1
    for _,card in ipairs(HS.CARDS) do
        if not card.icon then
            local tex
            if card.type=="Equipment" then
                tex=fallbackItems[ii]
                ii=ii+(tex and 1 or 0)
            end
            if not tex then
                tex=fallbackSpells[si]
                si=si+(tex and 1 or 0)
            end
            if not tex then
                tex=fallbackItems[ii]
                ii=ii+(tex and 1 or 0)
            end
            card.icon=tex or "Interface\\Icons\\INV_Misc_QuestionMark"
        end
    end
end

-- The semantic catalogue is intentionally cached in SavedVariables.  The first
-- v2.8 load builds a card->Blizzard texture map; later logins restore the same
-- name-matched art instantly instead of rescanning thousands of client
-- spell/item records every session.
local CARD_ICON_MAP_VERSION=2
local cardIconsAssigned=false
local function ApplyCachedCardIcons(s)
    local map=s and s.cardIconMap
    if not s or tonumber(s.cardIconMapVersion)~=CARD_ICON_MAP_VERSION or type(map)~="table" then return false end
    local autoUsed={}
    for _,card in ipairs(HS.CARDS) do
        local tex
        if card.iconLocked and IsUsableGameIcon(card.icon) then tex=card.icon else local entry=map[card.id] or map[tostring(card.id)];tex=type(entry)=="table" and entry.tex or entry end
        if not IsUsableGameIcon(tex) then return false end
        if not card.iconLocked then if autoUsed[tostring(tex)] then return false end;autoUsed[tostring(tex)]=true end
    end
    for _,card in ipairs(HS.CARDS) do
        if not card.iconLocked then local entry=map[card.id] or map[tostring(card.id)];card.icon=type(entry)=="table" and entry.tex or entry;card.iconSource=type(entry)=="table" and entry.source or "cache" end
    end
    cardIconsAssigned=true
    return true
end
local function EnsureCardIcons(s,force)
    if cardIconsAssigned and not force then return true end
    if not force and ApplyCachedCardIcons(s) then return true end
    for _,card in ipairs(HS.CARDS) do if not card.iconLocked then card.icon=nil;card.iconSource=nil end end
    AssignBuiltInCardIcons()
    cardIconsAssigned=true
    if s then
        s.cardIconMapVersion=CARD_ICON_MAP_VERSION;s.cardIconMap={}
        for _,card in ipairs(HS.CARDS) do s.cardIconMap[card.id]={tex=card.icon,source=card.iconSource} end
    end
    return true
end

HS.CARD_BY_ID={};for _,c in ipairs(HS.CARDS) do HS.CARD_BY_ID[c.id]=c end
RegisterSpecialCards()

HS.HERO_ABILITIES={
 {id="second_wind",name="Second Wind",cost=2,icon="Interface\\Icons\\Spell_Holy_Renew",text="Restore 4 Health to your hero.",effect="healHero",amount=4},
 {id="arcane_spark",name="Arcane Spark",cost=2,icon="Interface\\Icons\\Spell_Arcane_Arcane01",text="Deal 1 damage to an enemy character.",effect="damage",amount=1,target="enemy"},
 {id="battle_focus",name="Battle Focus",cost=2,icon="Interface\\Icons\\Ability_Warrior_BattleShout",text="Give a friendly creature +1 Attack and +1 Health.",effect="buff",attack=1,health=1,target="friendlyCreature"},
 {id="field_dressing",name="Field Dressing",cost=2,icon="Interface\\Icons\\INV_Misc_Bandage_12",text="Restore 4 Health to a friendly creature.",effect="heal",amount=4,target="friendlyCreature"},
 {id="guard_stance",name="Guard Stance",cost=2,icon="Interface\\Icons\\Ability_Warrior_DefensiveStance",text="Gain 2 Armor.",effect="armor",amount=2},
 {id="quick_study",name="Quick Study",cost=3,icon="Interface\\Icons\\INV_Misc_Book_11",text="Draw a card.",effect="draw",amount=1},
 {id="rallying_cry",name="Rallying Cry",cost=3,icon="Interface\\Icons\\Spell_Holy_GreaterBlessingofKings",text="Give all your creatures +1 Attack.",effect="buffAllAttack",amount=1},
 {id="dirty_trick",name="Dirty Trick",cost=3,icon="Interface\\Icons\\Ability_Rogue_Feint",text="Deal 1 damage to an enemy creature and exhaust it this turn.",effect="dirty",amount=1,target="enemyCreature"},
}
HS.ABILITY_BY_ID={};for _,a in ipairs(HS.HERO_ABILITIES) do HS.ABILITY_BY_ID[a.id]=a end

local LEGACY_STARTER_IDS={1,2,3,4,5,6,7,8,9,10}
-- Four existing Common actions replace four duplicate creatures. No new
-- collectible action is introduced by this starter-deck balance pass.
local STARTER_COLLECTION_IDS={1,2,3,4,5,6,7,8,25,26,27,28}
local STARTER_DECK_IDS={1,1,2,2,3,3,4,4,5,5,6,6,7,7,8,8,25,26,27,28}

local function IsLegacyStarterDeck(cards)
    if type(cards)~="table" or #cards~=20 then return false end
    local counts={}
    for _,id in ipairs(cards) do counts[tonumber(id)]=(counts[tonumber(id)] or 0)+1 end
    for _,id in ipairs(LEGACY_STARTER_IDS) do if counts[id]~=2 then return false end;counts[id]=nil end
    return next(counts)==nil
end

local function EnsureState()
    if type(RPAL_DB)~="table" then RPAL_DB={} end
    RPAL_DB.hearthstone=type(RPAL_DB.hearthstone)=="table" and RPAL_DB.hearthstone or {}
    local s=RPAL_DB.hearthstone
    s.version=HS.VERSION
    if not s.ownedPackMigrationV1 then
        -- Pack Tokens were an implementation detail in the first Fall draft.
        -- Convert each one into a real owned pack and retire the token balance.
        s.ownedPacks=math.max(0,math.floor(tonumber(s.ownedPacks) or 0))+math.max(0,math.floor(tonumber(s.packTokens) or 0))
        s.ownedPackMigrationV1=true
    end
    s.ownedPacks=math.max(0,math.floor(tonumber(s.ownedPacks) or 0))
    s.packTokens=nil
    s.collection=type(s.collection)=="table" and s.collection or {}
    s.decks=type(s.decks)=="table" and s.decks or {}
    s.deckOrder=type(s.deckOrder)=="table" and s.deckOrder or {}
    -- Repair older deck indexes before the Collection UI reads them.  Some
    -- legacy saves had deck tables that were not present in deckOrder, and
    -- nextDeckID could lag behind an existing deckN id, causing a newly-made
    -- deck to overwrite an old one and appear to vanish.
    do
        local clean,seen,maxSerial={},{},tonumber(s.nextDeckID) or 0
        for _,id in ipairs(s.deckOrder) do
            if type(id)=="string" and s.decks[id] and not seen[id] then
                clean[#clean+1]=id;seen[id]=true
                local n=tonumber(id:match("^deck(%d+)$"));if n and n>maxSerial then maxSerial=n end
            end
        end
        for id,d in pairs(s.decks) do
            if type(id)=="string" and type(d)=="table" then
                d.id=d.id or id
                local n=tonumber(id:match("^deck(%d+)$"));if n and n>maxSerial then maxSerial=n end
                if not seen[id] then clean[#clean+1]=id;seen[id]=true end
            end
        end
        s.deckOrder=clean;s.nextDeckID=maxSerial
    end
    s.history=type(s.history)=="table" and s.history or {}
    s.settledMatches=type(s.settledMatches)=="table" and s.settledMatches or {}
    s.rewardedMatches=type(s.rewardedMatches)=="table" and s.rewardedMatches or {}
    s.historyMatchIDs=type(s.historyMatchIDs)=="table" and s.historyMatchIDs or {}
    s.pendingStakes=type(s.pendingStakes)=="table" and s.pendingStakes or {}
    s.pausedMatches=type(s.pausedMatches)=="table" and s.pausedMatches or {}
    s.unlockedArenas=type(s.unlockedArenas)=="table" and s.unlockedArenas or {tavern=true}
    s.unlockedCardBacks=type(s.unlockedCardBacks)=="table" and s.unlockedCardBacks or {classic=true}
    s.unlockedArenas.tavern=true;s.unlockedCardBacks.classic=true
    s.activeArena=(HS.ARENA_BY_ID[s.activeArena] and s.unlockedArenas[s.activeArena]) and s.activeArena or "tavern"
    s.activeCardBack=(HS.CARD_BACK_BY_ID[s.activeCardBack] and s.unlockedCardBacks[s.activeCardBack]) and s.activeCardBack or "classic"
    s.activeAbility=HS.ABILITY_BY_ID[s.activeAbility] and s.activeAbility or "second_wind"
    local pd=tostring(s.practiceDifficulty or "normal"):lower();if pd~="easy" and pd~="normal" and pd~="hard" then pd="normal" end;s.practiceDifficulty=pd
    if not s.starterGranted then
        for _,id in ipairs(STARTER_COLLECTION_IDS) do s.collection[id]=math.max(tonumber(s.collection[id]) or 0,2) end
        s.starterGranted=true
    end
    if not s.starterActionsV1 then
        -- Existing users keep hand-edited starter decks. Only the exact shipped
        -- legacy list is migrated; everyone still receives the four action
        -- cards in their collection so they can opt in manually.
        for _,id in ipairs({25,26,27,28}) do s.collection[id]=math.max(tonumber(s.collection[id]) or 0,2) end
        if s.decks.starter and IsLegacyStarterDeck(s.decks.starter.cards) then s.decks.starter.cards=Copy(STARTER_DECK_IDS) end
        s.starterActionsV1=true
    end
    local hasDeck=false;for _,id in ipairs(s.deckOrder) do if s.decks[id] then hasDeck=true break end end
    if not hasDeck then
        local deck={id="starter",name="Tavern Starter",cards=Copy(STARTER_DECK_IDS)}
        s.decks[deck.id]=deck;s.deckOrder={deck.id};s.activeDeck=deck.id
    end
    if not s.decks[s.activeDeck] then
        if s.lastActiveDeck and s.decks[s.lastActiveDeck] then s.activeDeck=s.lastActiveDeck else s.activeDeck=s.deckOrder[1] end
    end
    if s.activeDeck then s.lastActiveDeck=s.activeDeck end
    EnsureCardIcons(s,false)
    return s
end
HS.EnsureState=EnsureState

function HS.GetState() return EnsureState() end
function HS.GetOwnedPackCount() return math.max(0,math.floor(tonumber(EnsureState().ownedPacks) or 0)) end
function HS.AddOwnedPacks(amount,source)
    amount=math.max(0,math.floor(tonumber(amount) or 0));if amount<=0 then return 0 end
    local s=EnsureState();s.ownedPacks=(tonumber(s.ownedPacks) or 0)+amount
    if RPAL.OnHSOwnedPacksChanged then pcall(RPAL.OnHSOwnedPacksChanged,s.ownedPacks,amount,source) end
    return amount
end
function HS.SpendOwnedPack()
    local s=EnsureState()
    if (tonumber(s.ownedPacks) or 0)<=0 then return false end
    s.ownedPacks=math.max(0,(tonumber(s.ownedPacks) or 0)-1)
    if RPAL.OnHSOwnedPacksChanged then pcall(RPAL.OnHSOwnedPacksChanged,s.ownedPacks,-1,"open") end
    return true
end
RPAL.GetHearthstoneOwnedPacks=HS.GetOwnedPackCount
RPAL.AddHearthstonePacks=HS.AddOwnedPacks
RPAL.SpendHearthstonePack=HS.SpendOwnedPack
function HS.GetCard(id) if not cardIconsAssigned then EnsureState() end;return HS.CARD_BY_ID[tonumber(id)] end
function HS.RebuildCardIcons() local s=EnsureState();s.cardIconMap=nil;s.cardIconMapVersion=nil;cardIconsAssigned=false;return EnsureCardIcons(s,true) end
function HS.GetCollectionCount(id) return math.max(0,math.floor(tonumber(EnsureState().collection[tonumber(id)]) or 0)) end
function HS.GetActiveDeck() local s=EnsureState();return s.decks[s.activeDeck] end
function HS.GetActiveAbility() return HS.ABILITY_BY_ID[EnsureState().activeAbility] or HS.HERO_ABILITIES[1] end
function HS.SetActiveAbility(id) if HS.ABILITY_BY_ID[id] then EnsureState().activeAbility=id;return true end return false end
HS.PRACTICE_DIFFICULTIES={easy="Easy",normal="Normal",hard="Hard"}
function HS.GetPracticeDifficulty() return EnsureState().practiceDifficulty or "normal" end
function HS.SetPracticeDifficulty(id) id=tostring(id or "normal"):lower();if not HS.PRACTICE_DIFFICULTIES[id] then return false,"Unknown practice difficulty." end;EnsureState().practiceDifficulty=id;return true end
function HS.SetActiveDeck(id)
    local s=EnsureState()
    if type(id)=="table" then id=id.id end
    id=tostring(id or "")
    local deck=s.decks[id]
    if not deck then return false,"Deck not found." end
    deck.id=deck.id or id
    s.activeDeck=id
    s.lastActiveDeck=id
    return true,"Active deck: "..tostring(deck.name or id)
end
function HS.GetArenaDefinition(id) return HS.ARENA_BY_ID[id] or HS.ARENA_BY_ID.tavern end
function HS.GetCardBackDefinition(id) return HS.CARD_BACK_BY_ID[id] or HS.CARD_BACK_BY_ID.classic end
function HS.GetActiveArenaID() return EnsureState().activeArena or "tavern" end
function HS.GetActiveCardBackID() return EnsureState().activeCardBack or "classic" end
function HS.IsArenaUnlocked(id) return EnsureState().unlockedArenas[id] and true or false end
function HS.IsCardBackUnlocked(id) return EnsureState().unlockedCardBacks[id] and true or false end
function HS.SetActiveArena(id)
    local s=EnsureState();if HS.ARENA_BY_ID[id] and s.unlockedArenas[id] then s.activeArena=id;return true end;return false,"Arena is locked."
end
function HS.SetActiveCardBack(id)
    local s=EnsureState();if HS.CARD_BACK_BY_ID[id] and s.unlockedCardBacks[id] then s.activeCardBack=id;return true end;return false,"Card back is locked."
end
function HS.UnlockArena(id)
    local s=EnsureState();local def=HS.ARENA_BY_ID[id];if not def then return false,"Unknown arena." end
    if s.unlockedArenas[id] then return true,"Already unlocked." end
    if (def.price or 0)>0 and not SpendHSCurrency(def.price) then return false,"You need "..tostring(def.price).." "..HS.GetCurrencyName().."." end
    s.unlockedArenas[id]=true;s.activeArena=id;return true,"Arena unlocked."
end
function HS.UnlockCardBack(id)
    local s=EnsureState();local def=HS.CARD_BACK_BY_ID[id];if not def then return false,"Unknown card back." end
    if s.unlockedCardBacks[id] then return true,"Already unlocked." end
    if (def.price or 0)>0 and not SpendHSCurrency(def.price) then return false,"You need "..tostring(def.price).." "..HS.GetCurrencyName().."." end
    s.unlockedCardBacks[id]=true;s.activeCardBack=id;return true,"Card back unlocked."
end

local function DeckCounts(cards)
    local c={};for _,id in ipairs(cards or {}) do id=tonumber(id);if id then c[id]=(c[id] or 0)+1 end end;return c
end
function HS.ValidateDeck(deck,ignoreCollection)
    if type(deck)~="table" then return false,"Choose a deck first." end
    if #(deck.cards or {})~=20 then return false,"Decks require exactly 20 cards." end
    local counts=DeckCounts(deck.cards)
    local state=EnsureState()
    for id,count in pairs(counts) do
        local card=HS.GetCard(id);if not card then return false,"Deck contains an unknown card." end
        local cap=HS.MAX_COPIES[card.rarity] or 2
        if count>cap then return false,card.name.." exceeds the copy limit." end
        if not ignoreCollection and count>(tonumber(state.collection[id]) or 0) then return false,"You do not own enough copies of "..card.name.."." end
    end
    return true
end
function HS.CreateDeck(name)
    local s=EnsureState()
    local serial=tonumber(s.nextDeckID) or 0
    local id
    repeat serial=serial+1;id="deck"..tostring(serial) until not s.decks[id]
    s.nextDeckID=serial
    local deck={id=id,name=SafeText(name~="" and name or "New Deck",32),cards={}}
    s.decks[id]=deck;s.deckOrder[#s.deckOrder+1]=id
    if not s.activeDeck or not s.decks[s.activeDeck] then s.activeDeck=id end
    return deck
end
function HS.RenameDeck(id,name) local d=EnsureState().decks[id];if not d then return false end;d.name=SafeText(name,32);return true end
function HS.DeleteDeck(id)
    local s=EnsureState();if id=="starter" and #s.deckOrder<=1 then return false,"Keep at least one deck." end
    s.decks[id]=nil;for i=#s.deckOrder,1,-1 do if s.deckOrder[i]==id then table.remove(s.deckOrder,i) end end
    if s.activeDeck==id then s.activeDeck=s.deckOrder[1] end;return true
end
function HS.AddCardToDeck(deckID,cardID)
    local s=EnsureState();local d=s.decks[deckID];local c=HS.GetCard(cardID);if not d or not c then return false,"Unknown card." end
    if #d.cards>=20 then return false,"Deck is full." end
    local owned=tonumber(s.collection[c.id]) or 0;local used=0;for _,id in ipairs(d.cards) do if id==c.id then used=used+1 end end
    if used>=owned then return false,"You do not own another copy." end
    if used>=(HS.MAX_COPIES[c.rarity] or 2) then return false,"Copy limit reached." end
    d.cards[#d.cards+1]=c.id;return true
end
function HS.RemoveCardFromDeck(deckID,index)
    local d=EnsureState().decks[deckID];index=tonumber(index);if d and index and d.cards[index] then table.remove(d.cards,index);return true end;return false
end
function HS.ClearDeck(deckID)
    local d=EnsureState().decks[deckID];if not d then return false,"Unknown deck." end;d.cards={};return true
end
function HS.AutoFillDeck(deckID)
    local s=EnsureState();local d=s.decks[deckID];if not d then return false,"Unknown deck." end
    local counts=DeckCounts(d.cards)
    local candidates={}
    for _,c in ipairs(HS.CARDS) do
        local owned=math.max(0,tonumber(s.collection[c.id]) or 0)
        local used=counts[c.id] or 0
        local cap=math.min(owned,HS.MAX_COPIES[c.rarity] or 2)
        if used<cap then candidates[#candidates+1]=c end
    end
    table.sort(candidates,function(a,b) if (a.cost or 0)==(b.cost or 0) then return tostring(a.name)<tostring(b.name) end return (a.cost or 0)<(b.cost or 0) end)
    local cursor=1
    while #d.cards<20 and #candidates>0 do
        local progressed=false
        for _,c in ipairs(candidates) do
            if #d.cards>=20 then break end
            local used=counts[c.id] or 0;local owned=math.max(0,tonumber(s.collection[c.id]) or 0);local cap=math.min(owned,HS.MAX_COPIES[c.rarity] or 2)
            if used<cap then d.cards[#d.cards+1]=c.id;counts[c.id]=used+1;progressed=true end
        end
        if not progressed then break end
        cursor=cursor+1;if cursor>3 then break end
    end
    return true,#d.cards
end
local function SanitizeDecks()
    local s=EnsureState()
    for _,d in pairs(s.decks) do
        local available=Copy(s.collection);local out={}
        for _,id in ipairs(d.cards or {}) do
            if (tonumber(available[id]) or 0)>0 and HS.GetCard(id) then out[#out+1]=id;available[id]=(tonumber(available[id]) or 0)-1 end
        end
        d.cards=out
    end
end

function HS.AddCard(cardID,source)
    local s=EnsureState();local card=HS.GetCard(cardID);if not card then return false,0 end
    local owned=math.max(0,math.floor(tonumber(s.collection[card.id]) or 0));local cap=HS.MAX_COPIES[card.rarity] or 2
    if owned>=cap then
        local refund=HS.DUPLICATE_REFUND[card.rarity] or 5
        AddHSCurrency(refund,"duplicate_refund")
        return false,refund
    end
    s.collection[card.id]=owned+1;return true,0
end
function HS.RemoveCard(cardID)
    local s=EnsureState();cardID=tonumber(cardID);if (tonumber(s.collection[cardID]) or 0)<=0 then return false end
    s.collection[cardID]=s.collection[cardID]-1;SanitizeDecks();return true
end

local function RollRarity(guaranteed)
    local r=math.random()*100
    if guaranteed then
        if r<2 then return "Legendary" elseif r<12 then return "Epic" else return "Rare" end
    end
    if r<2 then return "Legendary" elseif r<8 then return "Epic" elseif r<30 then return "Rare" else return "Common" end
end
local function RandomCardOfRarity(rarity)
    local pool={};for _,c in ipairs(HS.CARDS) do if c.rarity==rarity then pool[#pool+1]=c end end
    return pool[math.random(1,#pool)]
end
function HS.OpenPack()
    if not HS.SpendOwnedPack() then return false,"You do not own a pack. Buy one for "..HS.PACK_COST.." "..HS.GetCurrencyName().." first." end
    local results={}
    for i=1,HS.PACK_SIZE do
        local card=RandomCardOfRarity(RollRarity(i==HS.PACK_SIZE))
        local added,refund=HS.AddCard(card.id,"pack")
        results[#results+1]={card=card,duplicate=not added,refund=refund or 0}
    end
    local s=EnsureState();s.packsOpened=(tonumber(s.packsOpened) or 0)+1
    return true,results
end

function HS.BuyPack()
    if not SpendHSCurrency(HS.PACK_COST) then return false,"You need "..HS.PACK_COST.." "..HS.GetCurrencyName().." to buy a pack." end
    HS.AddOwnedPacks(1,"purchase")
    return true,HS.GetOwnedPackCount()
end

function HS.GetStakeIDs() return EnsureState().pendingStakes end
function HS.GetStakeCount(cardID)
    local n=0;cardID=tonumber(cardID);for _,id in ipairs(EnsureState().pendingStakes) do if id==cardID then n=n+1 end end;return n
end
function HS.AdjustStake(cardID,delta)
    cardID=tonumber(cardID);delta=math.floor(tonumber(delta) or 0);if not cardID or delta==0 then return false end
    local s=EnsureState();local current=HS.GetStakeCount(cardID);local owned=HS.GetCollectionCount(cardID)
    if delta>0 then
        if current>=owned then return false,"You cannot stake more copies than you own." end
        s.pendingStakes[#s.pendingStakes+1]=cardID;return true,current+1
    end
    for i=#s.pendingStakes,1,-1 do if s.pendingStakes[i]==cardID then table.remove(s.pendingStakes,i);return true,current-1 end end
    return false,"That card is not staked."
end
function HS.ToggleStake(cardID)
    if HS.GetStakeCount(cardID)>0 then return HS.AdjustStake(cardID,-1) end
    return HS.AdjustStake(cardID,1)
end
function HS.ClearStakes() EnsureState().pendingStakes={} end

-- -------------------- match engine --------------------
local matches={}
local activeMatch
local pendingChallenges={}
local spectators={}
local uidSerial=0
local function NextUID() uidSerial=uidSerial+1;return uidSerial end
local function Shuffle(list)
    for i=#list,2,-1 do local j=math.random(1,i);list[i],list[j]=list[j],list[i] end;return list
end
local function ThemeAccentHex()
    local p=RPAL.GetCurrentThemePalette and RPAL.GetCurrentThemePalette() or nil
    local c=p and (p.gold or p.accent) or {0.35,0.85,1}
    local function h(v) return string.format("%02X",math.max(0,math.min(255,math.floor((tonumber(v) or 0)*255+0.5)))) end
    return h(c[1])..h(c[2])..h(c[3])
end
local function CurrentAuraName()
    local cosmetics=RPAL.GetCosmeticSelection and RPAL.GetCosmeticSelection() or nil
    return SafeText(cosmetics and cosmetics.profileAura or "None",24)
end
local function CurrentArenaID() return EnsureState().activeArena or "tavern" end
local function CurrentCardBackID() return EnsureState().activeCardBack or "classic" end
local function NewPlayer(name,display,deck,ability,stakes,accent,aura,arena,cardBack)
    local list=Copy(deck.cards or {});Shuffle(list)
    return {name=name,display=display or name,hp=HS.STARTING_HEALTH,armor=0,mana=0,maxMana=0,deck=list,hand={},board={},ability=ability or "second_wind",abilityUsed=false,weapon=nil,heroAttacked=false,stakes=Copy(stakes or {}),accent=accent or ThemeAccentHex(),aura=SafeText(aura or "None",24),arena=HS.ARENA_BY_ID[arena] and arena or "tavern",cardBack=HS.CARD_BACK_BY_ID[cardBack] and cardBack or "classic"}
end
local function Draw(match,side,count)
    local p=match.players[side];for _=1,(count or 1) do
        if #p.deck==0 then p.fatigue=(p.fatigue or 0)+1;local dmg=1;if p.armor>0 then local soak=math.min(p.armor,dmg);p.armor=p.armor-soak;dmg=dmg-soak end;p.hp=p.hp-dmg
        else local id=table.remove(p.deck);if #p.hand<HS.MAX_HAND then p.hand[#p.hand+1]=id end end
    end
end
local function DamageHero(p,amount)
    amount=math.max(0,math.floor(tonumber(amount) or 0));if p.armor>0 then local soak=math.min(p.armor,amount);p.armor=p.armor-soak;amount=amount-soak end;p.hp=p.hp-amount
end
local function HealHero(p,amount) p.hp=math.min(HS.STARTING_HEALTH,p.hp+math.max(0,tonumber(amount) or 0)) end
local function CleanupBoard(match)
    for side=1,2 do local b=match.players[side].board;for i=#b,1,-1 do if (b[i].hp or 0)<=0 then table.remove(b,i) end end end
end
local function Opp(side) return side==1 and 2 or 1 end
local function HasTaunt(p) for _,u in ipairs(p.board or {}) do if u.taunt and u.hp>0 then return true end end return false end
local function FindUnitOnBoard(p,uid) for i,u in ipairs(p.board or {}) do if tonumber(u.uid)==tonumber(uid) then return u,i end end end
local function CheckWinner(match)
    if match.finished then return true end
    if match.players[1].hp<=0 or match.players[2].hp<=0 then
        local winner=match.players[1].hp<=0 and 2 or 1
        if match.players[1].hp<=0 and match.players[2].hp<=0 then winner=0 end
        HS.FinishMatch(match,winner,"lethal");return true
    end
end
local function Log(match,text) match.log=match.log or {};match.log[#match.log+1]=SafeText(text,160);if #match.log>80 then table.remove(match.log,1) end end
local function RecordAction(match,kind,side,attacker,targetKind,targetID)
    match.lastActionSeq=(tonumber(match.lastActionSeq) or 0)+1
    match.lastAction={kind=kind,side=side,attacker=tonumber(attacker) or 0,targetKind=targetKind or "",targetID=tonumber(targetID) or 0,seq=match.lastActionSeq}
end
local function StartTurn(match,side,first)
    match.turnSide=side;match.turn=(tonumber(match.turn) or 0)+1
    local p=match.players[side];p.maxMana=math.min(HS.MAX_MANA,(p.maxMana or 0)+1);p.mana=p.maxMana;p.abilityUsed=false;p.heroAttacked=false
    for _,u in ipairs(p.board) do u.canAttack=true;u.exhausted=false end
    if not first then Draw(match,side,1) end
    Log(match,p.display.." begins turn "..match.turn..".")
end
local function NewMatch(id,p1,p2,hostName,mode)
    local match={id=id,host=hostName,players={p1,p2},turn=0,turnSide=1,log={},mode=mode or "online",seq=0,startedAt=Now(),spectators={},paused=false,pausedAt=0}
    Draw(match,1,3);Draw(match,2,4)
    if #p2.hand<HS.MAX_HAND then p2.hand[#p2.hand+1]=HS.MAGICAL_COIN_ID end
    StartTurn(match,1,true);return match
end

local function ApplyGenericCardEffect(match,side,key,data,targetKind,targetID,sourceUnit)
    key=tostring(key or "")
    if key=="" or key=="none" then return true,true end
    local p=match.players[side];local enemy=match.players[Opp(side)];data=data or {}
    local amount=tonumber(data.amount) or 0
    local function TargetOwnerAndUnit()
        if targetKind=="enemyCreature" then return enemy,FindUnitOnBoard(enemy,targetID)
        elseif targetKind=="friendlyCreature" then return p,FindUnitOnBoard(p,targetID) end
    end
    local function ResolveTarget()
        if targetKind=="enemyHero" then return "hero",enemy
        elseif targetKind=="friendlyHero" then return "hero",p
        elseif targetKind=="enemyCreature" then return "unit",FindUnitOnBoard(enemy,targetID)
        elseif targetKind=="friendlyCreature" then return "unit",FindUnitOnBoard(p,targetID) end
    end
    local function BuffBoard(a,h,skip)
        for _,u in ipairs(p.board) do if u~=skip then u.attack=u.attack+(a or 0);u.maxhp=u.maxhp+(h or 0);u.hp=u.hp+(h or 0) end end
    end
    local function HealFriendly(v)
        HealHero(p,v);for _,u in ipairs(p.board) do u.hp=math.min(u.maxhp,u.hp+v) end
    end
    local function AddToHand(cardID,count)
        cardID=tonumber(cardID);local c=cardID and HS.GetCard(cardID);if not c then return false,"CARD ID does not exist." end
        count=math.max(1,math.min(10,tonumber(count) or 1));local added=0
        for i=1,count do if #p.hand>=HS.MAX_HAND then break end;p.hand[#p.hand+1]=cardID;added=added+1 end
        return true,added
    end
    local function Summon(cardID,count)
        cardID=tonumber(cardID);local c=cardID and HS.GetCard(cardID);if not c or c.type~="Creature" then return false,"CARD ID must be a Creature." end
        count=math.max(1,math.min(HS.MAX_BOARD,tonumber(count) or 1));local made=0
        for i=1,count do
            if #p.board>=HS.MAX_BOARD then break end
            local u={uid=NextUID(),card=c.id,attack=c.attack,hp=c.health,maxhp=c.health,taunt=c.taunt and true or false,canAttack=c.charge and true or false}
            p.board[#p.board+1]=u;made=made+1
        end
        return true,made
    end

    if key=="draw" then Draw(match,side,math.max(1,amount));return true,true
    elseif key=="healHero" then HealHero(p,amount);return true,true
    elseif key=="damageEnemyHero" then DamageHero(enemy,amount);return true,true
    elseif key=="armor" then p.armor=(p.armor or 0)+amount;return true,true
    elseif key=="damageRandomEnemy" then if #enemy.board>0 then local u=enemy.board[math.random(1,#enemy.board)];u.hp=u.hp-amount end;return true,true
    elseif key=="destroyRandomEnemy" then if #enemy.board>0 then enemy.board[math.random(1,#enemy.board)].hp=0 end;return true,true
    elseif key=="damageAllEnemy" or key=="aoeEnemy" then for _,u in ipairs(enemy.board) do u.hp=u.hp-amount end;return true,true
    elseif key=="damageEnemySide" then DamageHero(enemy,amount);for _,u in ipairs(enemy.board) do u.hp=u.hp-amount end;return true,true
    elseif key=="damageAll" then for _,u in ipairs(p.board) do u.hp=u.hp-amount end;for _,u in ipairs(enemy.board) do u.hp=u.hp-amount end;return true,true
    elseif key=="healAll" then HealFriendly(amount);return true,true
    elseif key=="buffAllAttack" then BuffBoard(amount,0,sourceUnit);return true,true
    elseif key=="buffRandomAlly" then
        local pool={};for _,u in ipairs(p.board) do if u~=sourceUnit then pool[#pool+1]=u end end
        if #pool>0 then local u=pool[math.random(1,#pool)];u.attack=u.attack+(data.attackBuff or 0);u.maxhp=u.maxhp+(data.healthBuff or 0);u.hp=u.hp+(data.healthBuff or 0) end;return true,true
    elseif key=="buffAll" then BuffBoard(data.attackBuff or data.attack or 0,data.healthBuff or data.health or 0,sourceUnit);return true,true
    elseif key=="drawHeal" then Draw(match,side,math.max(1,tonumber(data.draw) or amount or 1));HealHero(p,tonumber(data.healAmount) or amount);return true,true
    elseif key=="armorDraw" then p.armor=(p.armor or 0)+amount;Draw(match,side,math.max(1,tonumber(data.draw) or 1));return true,true
    elseif key=="healDraw" then HealHero(p,amount);Draw(match,side,math.max(1,tonumber(data.draw) or 1));return true,true
    elseif key=="aoeHeal" then for _,u in ipairs(enemy.board) do u.hp=u.hp-amount end;HealHero(p,tonumber(data.healAmount) or amount);return true,true
    elseif key=="benediction" then HealHero(p,amount);BuffBoard(0,data.healthBuff or data.health or 0,nil);return true,true
    elseif key=="gainMana" then p.mana=math.min(HS.MAX_MANA,(p.mana or 0)+amount);return true,true
    elseif key=="gainMaxMana" then local gain=math.max(0,amount);p.maxMana=math.min(HS.MAX_MANA,(p.maxMana or 0)+gain);p.mana=math.min(HS.MAX_MANA,(p.mana or 0)+gain);return true,true
    elseif key=="weaponBuff" then if not p.weapon then return true,false,"You need an equipped weapon." end;p.weapon.attack=math.max(0,(p.weapon.attack or 0)+(data.attackBuff or 0));p.weapon.durability=math.max(1,(p.weapon.durability or 0)+math.max(0,amount));return true,true
    elseif key=="summonCard" then local ok,err=Summon(data.cardID,data.count);return true,ok,err
    elseif key=="addCardToHand" then local ok,err=AddToHand(data.cardID,data.count);return true,ok,err
    elseif key=="shuffleCard" then
        local cid=tonumber(data.cardID);if not cid or not HS.GetCard(cid) then return true,false,"CARD ID does not exist." end
        local count=math.max(1,math.min(10,tonumber(data.count) or 1));for i=1,count do local at=math.random(1,#p.deck+1);table.insert(p.deck,at,cid) end;return true,true
    elseif key=="randomMissiles" then
        local hits=math.max(1,math.min(20,tonumber(data.count) or 3));local dmg=math.max(0,amount)
        for i=1,hits do local pool={{kind="hero",obj=enemy}};for _,u in ipairs(enemy.board) do if u.hp>0 then pool[#pool+1]={kind="unit",obj=u} end end;local pick=pool[math.random(1,#pool)];if pick.kind=="hero" then DamageHero(enemy,dmg) else pick.obj.hp=pick.obj.hp-dmg end end;return true,true
    elseif key=="buffSelf" then if sourceUnit then sourceUnit.attack=sourceUnit.attack+(data.attackBuff or 0);sourceUnit.maxhp=sourceUnit.maxhp+(data.healthBuff or 0);sourceUnit.hp=sourceUnit.hp+(data.healthBuff or 0) end;return true,true
    elseif key=="armorPerAlly" then p.armor=(p.armor or 0)+math.max(0,amount)*#p.board;return true,true end

    local kind,target=ResolveTarget()
    if key=="damage" then if not target then return true,false,"Choose a valid target." end;if kind=="hero" then DamageHero(target,amount) else target.hp=target.hp-amount end;return true,true
    elseif key=="heal" then if not target then return true,false,"Choose a valid target." end;if kind=="hero" then HealHero(target,amount) else target.hp=math.min(target.maxhp,target.hp+amount) end;return true,true
    elseif key=="buff" then if kind~="unit" then return true,false,"Choose a creature." end;target.attack=target.attack+(data.attackBuff or data.attack or 0);target.maxhp=target.maxhp+(data.healthBuff or data.health or 0);target.hp=target.hp+(data.healthBuff or data.health or 0);return true,true
    elseif key=="fortify" then if kind~="unit" then return true,false,"Choose a creature." end;target.maxhp=target.maxhp+(data.healthBuff or data.health or 0);target.hp=target.hp+(data.healthBuff or data.health or 0);target.taunt=true;return true,true
    elseif key=="dirty" then if kind~="unit" then return true,false,"Choose an enemy creature." end;target.hp=target.hp-amount;target.canAttack=false;target.exhausted=true;return true,true
    elseif key=="destroy" then if kind~="unit" then return true,false,"Choose a creature." end;target.hp=0;return true,true
    elseif key=="execute" then if kind~="unit" then return true,false,"Choose an enemy creature." end;if (target.hp or 0)>amount then return true,false,"That creature has too much Health." end;target.hp=0;return true,true
    elseif key=="setStats" then if kind~="unit" then return true,false,"Choose a creature." end;target.attack=math.max(0,tonumber(data.attackBuff) or 0);target.maxhp=math.max(1,tonumber(data.healthBuff) or 1);target.hp=target.maxhp;return true,true
    elseif key=="swapStats" then if kind~="unit" then return true,false,"Choose a creature." end;local oldA=math.max(0,target.attack or 0);local oldH=math.max(1,target.hp or 1);target.attack=oldH;target.maxhp=math.max(1,oldA);target.hp=target.maxhp;return true,true
    elseif key=="copyToHand" then if kind~="unit" then return true,false,"Choose a creature." end;local ok,err=AddToHand(target.card,1);return true,ok,err
    elseif key=="bounce" then
        local owner,u=TargetOwnerAndUnit();if not owner or not u then return true,false,"Choose a creature." end;if #owner.hand>=HS.MAX_HAND then return true,false,"That hand is full." end
        for i=#owner.board,1,-1 do if owner.board[i]==u then table.remove(owner.board,i);break end end;owner.hand[#owner.hand+1]=u.card;return true,true
    elseif key=="steal" then
        if targetKind~="enemyCreature" then return true,false,"Choose an enemy creature." end;local u=FindUnitOnBoard(enemy,targetID);if not u then return true,false,"Choose an enemy creature." end;if #p.board>=HS.MAX_BOARD then return true,false,"Your board is full." end
        for i=#enemy.board,1,-1 do if enemy.board[i]==u then table.remove(enemy.board,i);break end end;u.canAttack=false;u.exhausted=true;p.board[#p.board+1]=u;return true,true
    end
    return false,nil
end

local function ApplyBattlecry(match,side,unit,card,targetKind,targetID)
    local handled,ok,err=ApplyGenericCardEffect(match,side,card.battlecry,card,targetKind,targetID,unit)
    if handled then return ok,err end
    local p=match.players[side];local enemy=match.players[Opp(side)]
    local function BuffAll(a,h,skipUnit)
        for _,u in ipairs(p.board) do if u~=skipUnit then u.attack=u.attack+(a or 0);u.maxhp=u.maxhp+(h or 0);u.hp=u.hp+(h or 0) end end
    end
    if card.battlecry=="commander" then BuffAll(card.attackBuff or 0,card.healthBuff or 0,unit);p.armor=(p.armor or 0)+(card.amount or 0)
    elseif card.battlecry=="arcaneLegend" then Draw(match,side,2);DamageHero(enemy,card.amount or 0)
    elseif card.battlecry=="shadowMaster" then if #enemy.board>0 then local u=enemy.board[math.random(1,#enemy.board)];u.hp=u.hp-(card.amount or 0) end;Draw(match,side,1)
    elseif card.battlecry=="foundry" then DamageHero(enemy,card.amount or 0);for _,u in ipairs(enemy.board) do u.hp=u.hp-(card.aoe or 0) end
    elseif card.battlecry=="tideLegend" then HealHero(p,card.amount or 0);BuffAll(0,card.healthBuff or 0,unit)
    elseif card.battlecry=="luminary" then Draw(match,side,2);HealHero(p,card.amount or 0);p.armor=(p.armor or 0)+(card.amount or 0)
    elseif card.battlecry=="legend" then Draw(match,side,2);HealHero(p,2)
    elseif card.battlecry and card.battlecry~="" then return false,"Unknown Deploy effect: "..tostring(card.battlecry) end
    return true
end
local function ApplyEffect(match,side,effect,targetKind,targetID,source)
    local key=effect and effect.effect
    local handled,ok,err=ApplyGenericCardEffect(match,side,key,effect,targetKind,targetID,nil)
    if handled then return ok,err end
    return false,"Unknown scripted effect: "..tostring(key or "none")
end

local function ValidateTargetSpec(side,card,targetKind)
    local target=card.target;if not target then return true end
    if target=="enemy" then return targetKind=="enemyHero" or targetKind=="enemyCreature"
    elseif target=="friendly" then return targetKind=="friendlyHero" or targetKind=="friendlyCreature"
    elseif target=="friendlyCreature" then return targetKind=="friendlyCreature"
    elseif target=="enemyCreature" then return targetKind=="enemyCreature"
    elseif target=="anyCreature" then return targetKind=="enemyCreature" or targetKind=="friendlyCreature"
    elseif target=="enemyHero" then return targetKind=="enemyHero"
    elseif target=="friendlyHero" then return targetKind=="friendlyHero"
    elseif target=="any" then return targetKind=="enemyHero" or targetKind=="friendlyHero" or targetKind=="enemyCreature" or targetKind=="friendlyCreature"
    elseif target=="none" or target=="" then return true end
    return false
end

-- Creature placement is an ordering choice rather than a fixed left-to-right slot.
-- The renderer always centers the row, while P<n>; tells the engine where in that
-- centered composition the new creature should be inserted.  Legacy clients that
-- send no placement continue to append normally.
local function ParsePlayPayload(raw)
    raw=tostring(raw or "")
    local pos,target=raw:match("^P(%d+);(.*)$")
    if pos then return tonumber(pos),target or "" end
    return nil,raw
end
local function ClampBoardPosition(pos,count)
    count=math.max(1,math.floor(tonumber(count) or 1))
    pos=math.floor(tonumber(pos) or count)
    if pos<1 then pos=1 elseif pos>count then pos=count end
    return pos
end

function HS.ProcessAction(match,side,action,a,b)
    if not match or match.finished then return false,"That match is over." end
    side=tonumber(side);if side~=1 and side~=2 then return false,"Invalid player." end
    local p=match.players[side];local enemy=match.players[Opp(side)]
    if match.paused and action~="concede" and action~="ragequit" then return false,"That match is paused." end
    if action~="concede" and action~="ragequit" and side~=match.turnSide then return false,"It is not your turn." end
    if action=="end" then StartTurn(match,Opp(side),false);CheckWinner(match);return true
    elseif action=="concede" then HS.FinishMatch(match,Opp(side),"concede");return true
    elseif action=="ragequit" then match.rageQuitSide=side;HS.FinishMatch(match,Opp(side),"ragequit");return true
    elseif action=="play" then
        local handIndex=tonumber(a);local insertPos,targetSpec=ParsePlayPayload(b)
        local cardID=p.hand[handIndex];local card=HS.GetCard(cardID);if not card then return false,"That card is no longer in your hand." end
        if p.mana<card.cost then return false,"Not enough Mana." end
        local targetKind,targetID=targetSpec:match("^(%a+):?(%d*)$");if targetID=="" then targetID=nil end
        if card.target and not ValidateTargetSpec(side,card,targetKind) then return false,"Choose a valid target." end
        if card.type=="Creature" then
            if #p.board>=HS.MAX_BOARD then return false,"Your board is full." end
            p.mana=p.mana-card.cost;table.remove(p.hand,handIndex)
            local u={uid=NextUID(),card=card.id,attack=card.attack,hp=card.health,maxhp=card.health,taunt=card.taunt and true or false,canAttack=card.charge and true or false}
            insertPos=ClampBoardPosition(insertPos,#p.board+1)
            table.insert(p.board,insertPos,u);local effectOK,effectErr=ApplyBattlecry(match,side,u,card,targetKind,targetID);if effectOK==false and effectErr then Log(match,card.name.." Deploy: "..effectErr) end;Log(match,p.display.." played "..card.name..".")
        elseif card.type=="Equipment" then
            p.mana=p.mana-card.cost;table.remove(p.hand,handIndex);p.weapon={card=card.id,name=card.name,attack=card.attack,durability=card.durability};p.heroAttacked=false
            if card.equip then local handled,effectOK,effectErr=ApplyGenericCardEffect(match,side,card.equip,card,targetKind,targetID,nil);if handled and effectOK==false and effectErr then Log(match,card.name.." Equip: "..effectErr) end end
            Log(match,p.display.." equipped "..card.name..".")
        else
            local ok,err=ApplyEffect(match,side,card,targetKind,targetID,card);if not ok then return false,err end
            p.mana=p.mana-card.cost;table.remove(p.hand,handIndex);Log(match,p.display.." used "..card.name..".")
        end
        match.lastPlayedSeq=(tonumber(match.lastPlayedSeq) or 0)+1
        match.lastPlayed={card=card.id,side=side,seq=match.lastPlayedSeq}
        CleanupBoard(match);CheckWinner(match);return true
    elseif action=="move" then
        return false,"Deployed card order is locked."
    elseif action=="attack" then
        local attackerUID=tonumber(a);local targetSpec=tostring(b or "")
        local attacker=FindUnitOnBoard(p,attackerUID);if not attacker or not attacker.canAttack then return false,"That creature cannot attack." end
        local targetKind,targetID=targetSpec:match("^(%a+):?(%d*)$");targetID=tonumber(targetID)
        if HasTaunt(enemy) then
            if targetKind~="enemyCreature" then return false,"A Taunt creature blocks the way." end
            local target=FindUnitOnBoard(enemy,targetID);if not target or not target.taunt then return false,"You must attack a Taunt creature." end
        end
        local attackerCard=HS.GetCard(attacker.card);local dealt=0
        if targetKind=="enemyHero" then dealt=math.max(0,attacker.attack or 0);DamageHero(enemy,dealt)
        elseif targetKind=="enemyCreature" then
            local target=FindUnitOnBoard(enemy,targetID);if not target then return false,"Invalid target." end
            dealt=math.min(math.max(0,attacker.attack or 0),math.max(0,target.hp or 0))
            if attackerCard and attackerCard.deadly and (attacker.attack or 0)>0 then target.hp=0 else target.hp=target.hp-attacker.attack end
            local targetCard=HS.GetCard(target.card)
            if targetCard and targetCard.deadly and (target.attack or 0)>0 then attacker.hp=0 else attacker.hp=attacker.hp-target.attack end
        else return false,"Choose an enemy target." end
        if attackerCard and attackerCard.lifesteal and dealt>0 then HealHero(p,dealt) end
        attacker.canAttack=false;RecordAction(match,"attack",side,attackerUID,targetKind,targetID);CleanupBoard(match);Log(match,p.display.." attacked.");CheckWinner(match);return true
    elseif action=="heroattack" then
        if not p.weapon or p.heroAttacked then return false,"Your hero cannot attack." end
        local targetSpec=tostring(a or "");local targetKind,targetID=targetSpec:match("^(%a+):?(%d*)$");targetID=tonumber(targetID)
        if HasTaunt(enemy) then
            if targetKind~="enemyCreature" then return false,"A Taunt creature blocks the way." end
            local target=FindUnitOnBoard(enemy,targetID);if not target or not target.taunt then return false,"You must attack a Taunt creature." end
        end
        local damage=p.weapon.attack or 0
        if targetKind=="enemyHero" then DamageHero(enemy,damage)
        elseif targetKind=="enemyCreature" then local target=FindUnitOnBoard(enemy,targetID);if not target then return false,"Invalid target." end;target.hp=target.hp-damage;DamageHero(p,target.attack or 0)
        else return false,"Choose an enemy target." end
        p.heroAttacked=true;p.weapon.durability=p.weapon.durability-1;if p.weapon.durability<=0 then p.weapon=nil end
        RecordAction(match,"heroattack",side,0,targetKind,targetID);CleanupBoard(match);Log(match,p.display.." attacked with a weapon.");CheckWinner(match);return true
    elseif action=="ability" then
        if p.abilityUsed then return false,"Hero Ability already used this turn." end
        local ability=HS.ABILITY_BY_ID[p.ability];if not ability then return false,"No Hero Ability selected." end
        if p.mana<ability.cost then return false,"Not enough Mana." end
        local targetSpec=tostring(a or "");local targetKind,targetID=targetSpec:match("^(%a+):?(%d*)$");if targetID=="" then targetID=nil end
        if ability.target and not ValidateTargetSpec(side,ability,targetKind) then return false,"Choose a valid target." end
        local ok,err=ApplyEffect(match,side,ability,targetKind,targetID,ability);if not ok then return false,err end
        p.mana=p.mana-ability.cost;p.abilityUsed=true;CleanupBoard(match);CheckWinner(match);Log(match,p.display.." used "..ability.name..".");return true
    end
    return false,"Unknown action."
end

-- -------------------- transport --------------------
local incomingChunks={}
local lastAppliedStateSeq={}
local pendingSpectateTarget=nil
local function SendRaw(target,msg)
    if not target or target=="" or not C_ChatInfo or not C_ChatInfo.SendAddonMessage then return false end
    if RPAL.SendAddonMessageSafe then return RPAL.SendAddonMessageSafe(HS.PREFIX,msg,"WHISPER",target,true) end
    local ok,res=pcall(C_ChatInfo.SendAddonMessage,HS.PREFIX,msg,"WHISPER",target);return ok and (res==nil or res==true or tonumber(res)==0)
end
local EMOTE_KEYS={THANKS=true,WOW=true,WELL_PLAYED=true,OOPS=true,GREETINGS=true,THREATEN=true}
local EMOTE_TEXT={THANKS="Thanks",WOW="Wow",WELL_PLAYED="Well Played",OOPS="Oops",GREETINGS="Greetings"}
local THREAT_LINES={
    "Your last draw won't save you.",
    "One more turn. That's all you have.",
    "Count your cards. Then count your chances.",
    "I've seen stronger plays from empty chairs.",
    "The table will remember this.",
    "I hope you brought a second deck.",
    "You should have folded two turns ago.",
    "Enjoy the calm. It won't last."
}
function HS.GetEmoteText(key)
    key=tostring(key or ""):upper();if not EMOTE_KEYS[key] then return tostring(key or "") end
    if key=="THREATEN" then return THREAT_LINES[math.random(1,#THREAT_LINES)] end
    return EMOTE_TEXT[key] or key
end
function HS.SendEmote(key,textValue)
    key=tostring(key or ""):upper();if not EMOTE_KEYS[key] then return false,"Unknown emote." end
    local m=activeMatch;if not m or m.finished then return false,"No active match." end
    local me=PlayerName();local side=ParticipantSide(m,me);if not side then return false,"You are spectating." end
    local phrase=SafeText(textValue and tostring(textValue) or HS.GetEmoteText(key),72):gsub("|","/")
    if m.mode=="practice" then if RPAL.HearthstoneReceiveEmote then RPAL.HearthstoneReceiveEmote(me,key,phrase) end;return true end
    local other=m.players[side==1 and 2 or 1];if not other or other.bot then return false,"No opponent to receive that emote." end
    return SendRaw(other.name,table.concat({"EMO",m.id,key,phrase},"|"))
end
local function Hex(s) return (tostring(s or ""):gsub('.',function(c)return string.format('%02X',string.byte(c))end)) end
local function Unhex(s) return (tostring(s or ""):gsub('%x%x',function(cc)return string.char(tonumber(cc,16))end)) end
local function EncPlayer(p,includeHand)
    local board={};for _,u in ipairs(p.board or {}) do board[#board+1]=table.concat({u.uid,u.card,u.attack,u.hp,u.maxhp,u.taunt and 1 or 0,u.canAttack and 1 or 0},"~") end
    local weapon=p.weapon and table.concat({p.weapon.card,p.weapon.attack,p.weapon.durability},"~") or ""
    return table.concat({Hex(p.name),Hex(p.display),p.hp,p.armor or 0,p.mana,p.maxMana,p.ability,p.abilityUsed and 1 or 0,#(p.deck or {}),includeHand and CSV(p.hand) or tostring(#(p.hand or {})),table.concat(board,";"),weapon,p.heroAttacked and 1 or 0,CSV(p.stakes or {}),p.accent or "58D8FF",Hex(p.aura or "None"),SafeText(p.arena or "tavern",16),SafeText(p.cardBack or "classic",16)},"^")
end
local function DecodePlayer(s,handVisible)
    local f=Split(s,"^")
    local p={name=Unhex(f[1]),display=Unhex(f[2]),hp=tonumber(f[3]) or 0,armor=tonumber(f[4]) or 0,mana=tonumber(f[5]) or 0,maxMana=tonumber(f[6]) or 0,ability=f[7],abilityUsed=f[8]=="1",deckCount=tonumber(f[9]) or 0,board={},heroAttacked=f[13]=="1"}
    if handVisible then p.hand=ParseCSV(f[10]) else p.handCount=tonumber(f[10]) or 0;p.hand={} end
    for unitText in tostring(f[11] or ""):gmatch("[^;]+") do local u=Split(unitText,"~");p.board[#p.board+1]={uid=tonumber(u[1]),card=tonumber(u[2]),attack=tonumber(u[3]),hp=tonumber(u[4]),maxhp=tonumber(u[5]),taunt=u[6]=="1",canAttack=u[7]=="1"} end
    if f[12] and f[12]~="" then local w=Split(f[12],"~");p.weapon={card=tonumber(w[1]),attack=tonumber(w[2]),durability=tonumber(w[3])} end
    p.stakes=ParseCSV(f[14]);p.accent=f[15] or "58D8FF";p.aura=Unhex(f[16] or "")
    if p.aura=="" then p.aura="None" end
    p.arena=HS.ARENA_BY_ID[f[17]] and f[17] or "tavern";p.cardBack=HS.CARD_BACK_BY_ID[f[18]] and f[18] or "classic"
    return p
end
local function SerializeFor(match,viewerSide,spectator)
    local p1=EncPlayer(match.players[1],viewerSide==1 and not spectator);local p2=EncPlayer(match.players[2],viewerSide==2 and not spectator)
    local lp=match.lastPlayed or {};local la=match.lastAction or {}
    local action=table.concat({SafeText(la.kind or "",12),tonumber(la.side) or 0,tonumber(la.attacker) or 0,SafeText(la.targetKind or "",20),tonumber(la.targetID) or 0,tonumber(la.seq) or 0},"~")
    -- State packets are sent after every action. Sending the entire 80-line
    -- history every time needlessly multiplies addon-message traffic and can
    -- starve the important turn/hand update. Keep only the recent wire log.
    local wireLog={};local sourceLog=match.log or {};local first=math.max(1,#sourceLog-15)
    for i=first,#sourceLog do wireLog[#wireLog+1]=sourceLog[i] end
    return table.concat({match.id,match.turn,match.turnSide,viewerSide or 0,spectator and 1 or 0,p1,p2,Hex(table.concat(wireLog,"\n")),match.finished and 1 or 0,match.winner or -1,Hex(match.finishReason or ""),tonumber(lp.card) or 0,tonumber(lp.side) or 0,tonumber(lp.seq) or 0,tonumber(match.startedAt) or 0,tonumber(match.durationSec) or 0,tonumber(match.rewardTokens) or 0,Hex(action),match.paused and 1 or 0,tonumber(match.pausedAt) or 0,Hex(match.mode or "online")},"|")
end
local function DeserializeState(payload)
    local f=Split(payload,"|")
    if #f<10 then return nil end
    local viewer=tonumber(f[4]) or 0;local spectator=f[5]=="1"
    local lpCard,lpSide,lpSeq=tonumber(f[12]) or 0,tonumber(f[13]) or 0,tonumber(f[14]) or 0
    local actionText=Unhex(f[18] or "");local af=Split(actionText,"~");local actionSeq=tonumber(af[6]) or 0
    local lastAction=actionSeq>0 and {kind=af[1] or "",side=tonumber(af[2]) or 0,attacker=tonumber(af[3]) or 0,targetKind=af[4] or "",targetID=tonumber(af[5]) or 0,seq=actionSeq} or nil
    local mode=Unhex(f[21] or "");if mode=="" then mode="online" end
    return {id=f[1],turn=tonumber(f[2]) or 0,turnSide=tonumber(f[3]) or 1,viewerSide=viewer,spectator=spectator,players={DecodePlayer(f[6],viewer==1 and not spectator),DecodePlayer(f[7],viewer==2 and not spectator)},logText=Unhex(f[8]),finished=f[9]=="1",winner=tonumber(f[10]),finishReason=Unhex(f[11] or ""),lastPlayed=(lpCard>0 and {card=lpCard,side=lpSide,seq=lpSeq} or nil),lastPlayedSeq=lpSeq,startedAt=tonumber(f[15]) or 0,durationSec=tonumber(f[16]) or 0,rewardTokens=tonumber(f[17]) or 0,lastAction=lastAction,lastActionSeq=actionSeq,paused=f[19]=="1",pausedAt=tonumber(f[20]) or 0,mode=mode}
end
local function SendStateTo(match,target,side,isSpectator)
    local payload=SerializeFor(match,side,isSpectator);match.seq=(match.seq or 0)+1;local hex=Hex(payload);local size=170;local count=math.ceil(#hex/size)
    for i=1,count do SendRaw(target,table.concat({"ST",match.id,match.seq,i,count,hex:sub((i-1)*size+1,i*size)},"|")) end
end
local function SendStateToSpectators(match)
    local targets={}
    for name in pairs(match.spectators or {}) do targets[#targets+1]=name end
    if #targets==0 then return 0 end

    -- Every spectator receives the same hidden-hand snapshot. Serialize and
    -- hex-encode it once per game state so a crowded table scales linearly in
    -- transport only, rather than repeating all table-building work per viewer.
    local payload=SerializeFor(match,0,true)
    match.seq=(match.seq or 0)+1
    local seq=match.seq;local hex=Hex(payload);local size=170;local count=math.ceil(#hex/size)
    for _,target in ipairs(targets) do
        for i=1,count do SendRaw(target,table.concat({"ST",match.id,seq,i,count,hex:sub((i-1)*size+1,i*size)},"|")) end
    end
    return #targets
end
local function BroadcastState(match)
    local me=PlayerName()
    if match.mode~="practice" then
        for side=1,2 do local p=match.players[side];if not PlayerNamesEqual(p.name,me) and not p.bot then SendStateTo(match,p.name,side,false) end end
    end
    -- Practice/bot games are locally authoritative too. The old early return
    -- refreshed only the host UI, so their spectators received the first table
    -- snapshot and then no turns. Always fan out to every registered viewer.
    SendStateToSpectators(match)
    if RPAL.RefreshHearthstoneMatchUI then local myside=ParticipantSide(match,me) or 0;RPAL.RefreshHearthstoneMatchUI(match,myside) end
end
HS.BroadcastState=BroadcastState

local function RecordHistory(match,winner,reason)
    local s=EnsureState();if s.historyMatchIDs[match.id] then return end;s.historyMatchIDs[match.id]=true
    local me=PlayerName();local myside=ParticipantSide(match,me) or 0
    local result=winner==0 and "Draw" or (myside==winner and "Win" or (myside>0 and "Loss" or "Observed"))
    table.insert(s.history,1,{at=Now(),opponent=myside==1 and match.players[2].display or match.players[1].display,result=result,reason=reason or "match",turns=match.turn or 0})
    while #s.history>HS.MAX_HISTORY do table.remove(s.history) end
end
local function SettleStakes(match,winner)
    if not winner or winner==0 then return end
    local s=EnsureState();if s.settledMatches[match.id] then return end
    local me=PlayerName();local myside=ParticipantSide(match,me) or 0;if myside==0 then return end
    s.settledMatches[match.id]=true
    if myside==winner then
        local loser=match.players[Opp(winner)];for _,id in ipairs(loser.stakes or {}) do HS.AddCard(id,"stake") end
    else
        for _,id in ipairs(match.players[myside].stakes or {}) do HS.RemoveCard(id) end
    end
end
local function CalculateVictoryReward(match)
    local elapsed=math.max(0,math.floor(tonumber(match and match.durationSec) or 0))
    if elapsed<=0 then elapsed=math.max(0,Now()-(tonumber(match and match.startedAt) or Now())) end
    -- 10 currency for a quick win, +1 per 30 seconds played, capped at 50. Lite Kit pays HS Tokens; Full Kit pays Tokens.
    return math.min(50,10+math.floor(elapsed/30)),elapsed
end
local function AwardVictoryTokens(match,winner)
    if not match or not winner or winner<=0 or match.mode=="practice" then return end
    local s=EnsureState();if s.rewardedMatches[match.id] then return end
    local me=PlayerName();local myside=ParticipantSide(match,me) or 0
    if myside==0 then return end
    if not match.rewardTokens or match.rewardTokens<=0 then local reward,elapsed=CalculateVictoryReward(match);match.rewardTokens=reward;match.durationSec=elapsed end
    s.rewardedMatches[match.id]=true
    if myside==winner then AddHSCurrency(match.rewardTokens or 0,"match_victory") end
end
local RemovePausedRecord
function HS.FinishMatch(match,winner,reason)
    if not match or match.finished then return end
    local reward,elapsed=CalculateVictoryReward(match)
    match.finished=true;match.paused=false;match.winner=winner;match.finishReason=reason or "finished";match.durationSec=elapsed;match.rewardTokens=(winner and winner>0 and match.mode~="practice") and reward or 0
    RemovePausedRecord(match.id)
    Log(match,winner==0 and "The match ended in a draw." or (match.players[winner].display.." won the match."))
    SettleStakes(match,winner);AwardVictoryTokens(match,winner);RecordHistory(match,winner,reason);BroadcastState(match)
    if match.mode~="practice" then
        local me=PlayerName();for side=1,2 do local p=match.players[side];if not PlayerNamesEqual(p.name,me) and not p.bot then SendRaw(p.name,table.concat({"FN",match.id,winner,reason or "finished",match.durationSec or 0,match.rewardTokens or 0},"|")) end end
    end
end

local function OpponentNameFor(match,name)
    local side=ParticipantSide(match,name)
    local other=side and match.players[Opp(side)] or nil
    return other and other.name or nil
end
local function MaxMatchUID(match)
    local n=0
    for side=1,2 do for _,u in ipairs((match.players[side] and match.players[side].board) or {}) do n=math.max(n,tonumber(u.uid) or 0) end end
    return n
end
RemovePausedRecord=function(matchID)
    local s=EnsureState();if not matchID then return end
    s.pausedMatches[tostring(matchID)]=nil
end
local function SavePausedRecord(match,viewerSide)
    if not match or not match.id then return nil end
    local me=PlayerName();local side=viewerSide or ParticipantSide(match,me) or match.viewerSide or 0
    local opponent=OpponentNameFor(match,me)
    local s=EnsureState();s.pausedMatches=s.pausedMatches or {}
    -- Keep one clean paused record per opponent to avoid repeatedly offering stale tables.
    if opponent then
        for id,rec in pairs(s.pausedMatches) do if type(rec)=="table" and rec.opponent==opponent and tostring(id)~=tostring(match.id) then s.pausedMatches[id]=nil end end
    end
    local rec={id=tostring(match.id),opponent=opponent,pausedAt=Now(),viewerSide=side,host=match.host,mode=match.mode or "online",full=(match.mode=="practice" or PlayerNamesEqual(match.host,me)) and true or false,match=Copy(match)}
    rec.match.paused=true;rec.match.pausedAt=rec.pausedAt
    s.pausedMatches[rec.id]=rec
    return rec
end
local function GetPausedRecordByID(id)
    local s=EnsureState();local rec=s.pausedMatches and s.pausedMatches[tostring(id or "")]
    return type(rec)=="table" and rec or nil
end
local function GetPausedRecordForOpponent(name)
    name=tostring(name or "");if name=="" then return nil end
    local s=EnsureState();local best
    for _,rec in pairs(s.pausedMatches or {}) do
        if type(rec)=="table" and rec.opponent==name and rec.match and not rec.match.finished then
            if not best or (tonumber(rec.pausedAt) or 0)>(tonumber(best.pausedAt) or 0) then best=rec end
        end
    end
    return best
end
function HS.GetPausedMatchForTarget(name) return GetPausedRecordForOpponent(name or TargetName()) end
function HS.GetPausedPracticeMatch()
    local s=EnsureState();local best
    for _,rec in pairs(s.pausedMatches or {}) do if type(rec)=="table" and rec.mode=="practice" and rec.match and not rec.match.finished then if not best or (rec.pausedAt or 0)>(best.pausedAt or 0) then best=rec end end end
    return best
end
function HS.DiscardPausedMatch(id) RemovePausedRecord(id);return true end
local function ReactivatePausedRecord(rec)
    if not rec or not rec.match then return false,"Paused match data is unavailable." end
    if not rec.full then return false,"Only the original match host has the full hidden table state." end
    local match=Copy(rec.match);match.paused=false;match.pausedAt=0;match.finished=false
    uidSerial=math.max(uidSerial,MaxMatchUID(match));activeMatch=match;matches[match.id]=match;RemovePausedRecord(match.id)
    return true,match
end
function HS.ResumePracticeMatch()
    local rec=HS.GetPausedPracticeMatch();if not rec then return false,"No paused practice match was found." end
    local ok,match=ReactivatePausedRecord(rec);if not ok then return false,match end
    if RPAL.OpenHearthstoneMatch then RPAL.OpenHearthstoneMatch(match,1) end
    if match.turnSide==2 then HS.RunPracticeBot(match) end
    return true,"Practice match resumed."
end
local function AuthoritativePause(match,requester)
    if not match or match.finished then return false,"No active match to pause." end
    match.paused=true;match.pausedAt=Now();Log(match,(requester or "A player").." paused the match.")
    local me=PlayerName();local side=ParticipantSide(match,me) or match.viewerSide or 0;SavePausedRecord(match,side);BroadcastState(match)
    if RPAL.OnHearthstoneMatchPaused then RPAL.OnHearthstoneMatchPaused(match,requester) end
    return true,"Match paused."
end
function HS.PauseMatch()
    local match=activeMatch;if not match or match.finished then return false,"No active match to pause." end
    if match.paused then return false,"That match is already paused." end
    local me=PlayerName();local side=ParticipantSide(match,me);if not side then return false,"Spectators cannot pause a match." end
    if match.mode=="practice" or PlayerNamesEqual(match.host,me) then return AuthoritativePause(match,match.players[side].display) end
    local ok=SendRaw(match.host,"PRQ|"..tostring(match.id));return ok,ok and "Pause request sent." or "Could not send pause request."
end
function HS.RageQuit() return HS.LocalAction("ragequit") end

function HS.LocalAction(action,a,b)
    local match=activeMatch;if not match then return false,"No Unofficial Hearthstone match is active." end
    if match.paused and action~="concede" and action~="ragequit" then return false,"That match is paused." end
    local me=PlayerName();local side=ParticipantSide(match,me) or (match.mode=="dm" and 1) or nil
    if match.mode=="practice" then side=1 end
    if not side then return false,"You are spectating." end
    if PlayerNamesEqual(match.host,me) or match.mode=="practice" or match.mode=="dm" then
        local ok,err=HS.ProcessAction(match,side,action,a,b)
        if ok then
            BroadcastState(match)
            -- End Turn changes ownership, mana and the newly drawn hand card.
            -- Repeat that critical snapshot once after the initial burst so a
            -- throttled whisper cannot leave the other client on the old turn.
            if action=="end" and match.mode~="practice" and C_Timer and C_Timer.After then
                local id=tostring(match.id);local expectedTurn=tonumber(match.turn) or 0
                C_Timer.After(0.45,function()if activeMatch and tostring(activeMatch.id)==id and not activeMatch.finished and (tonumber(activeMatch.turn) or 0)==expectedTurn then BroadcastState(activeMatch) end end)
            end
            if match.mode=="practice" and not match.finished and match.turnSide==2 then HS.RunPracticeBot(match) end
        end
        return ok,err
    end
    local beforeSeq=tonumber(match._networkSeq) or 0
    local sent=SendRaw(match.host,table.concat({"ACN",match.id,action,tostring(a or ""),tostring(b or "")},"|"))
    if sent and C_Timer and C_Timer.After then
        -- If the authoritative state packet was throttled/lost, explicitly ask
        -- the host for a fresh snapshot. This is especially important for End
        -- Turn because the next player's draw/turn ownership live in that state.
        local matchID=tostring(match.id);local host=tostring(match.host or "")
        local function RetrySync()
            local current=activeMatch
            if current and tostring(current.id)==matchID and not current.finished and (tonumber(current._networkSeq) or 0)<=beforeSeq and host~="" then
                SendRaw(host,"RQ|"..matchID.."|P")
            end
        end
        C_Timer.After(0.9,RetrySync);C_Timer.After(2.2,RetrySync)
    end
    return sent,sent and nil or "Could not send the action to the match host."
end

local function CurrentDeckPayload()
    local d=HS.GetActiveDeck();local ok,err=HS.ValidateDeck(d);if not ok then return nil,err end
    return CSV(d.cards)
end
local function SendChallengeToTarget(target,dmMode,resumeChoice)
    if not target or target=="" then return false,"Target another ODYN player first." end
    if target==PlayerName() then return false,"Choose another player." end
    local deckPayload,err=CurrentDeckPayload();if not deckPayload then return false,err end
    local s=EnsureState();local id=tostring(Now())..tostring(math.random(1000,9999));local mode=dmMode and "dm" or "pvp"
    local heroName=dmMode and SafeText(s.dmHeroName or "Tavern Challenger",40) or DisplayName()
    local paused=(not dmMode) and GetPausedRecordForOpponent(target) or nil
    local resumeID=(resumeChoice=="resume" and paused and paused.id) or ""
    if resumeChoice=="new" and paused then RemovePausedRecord(paused.id) end
    pendingChallenges[id]={outgoing=true,target=target,mode=mode,deck=Copy(HS.GetActiveDeck()),ability=s.activeAbility,stakes=Copy(s.pendingStakes),heroName=heroName,accent=ThemeAccentHex(),aura=CurrentAuraName(),arena=CurrentArenaID(),cardBack=CurrentCardBackID(),resumeMatchID=resumeID~="" and resumeID or nil}
    local ok=SendRaw(target,table.concat({"CH",id,mode,Hex(heroName),s.activeAbility,CSV(s.pendingStakes),Hex(HS.GetActiveDeck().name),ThemeAccentHex(),Hex(CurrentAuraName()),CurrentArenaID(),CurrentCardBackID(),resumeID},"|"))
    if not ok then pendingChallenges[id]=nil;return false,"Could not send the challenge." end
    return true,resumeID~="" and ("Resume invitation sent to "..target..".") or ("Challenge sent to "..target..".")
end
function HS.ChallengeTarget(dmMode,resumeChoice)
    local target=TargetName();if not target or target=="" then return false,"Target another ODYN player first." end
    local paused=(not dmMode) and GetPausedRecordForOpponent(target) or nil
    if paused and resumeChoice==nil and RPAL.ShowHearthstoneResumeInviteChoice then
        RPAL.ShowHearthstoneResumeInviteChoice(target,paused,dmMode)
        return true,"Paused match found — choose Resume or New Match."
    end
    return SendChallengeToTarget(target,dmMode,resumeChoice)
end
function HS.SendChallengeToTarget(target,dmMode,resumeChoice) return SendChallengeToTarget(target,dmMode,resumeChoice) end
function HS.AcceptChallenge(id)
    local ch=pendingChallenges[id];if not ch or ch.outgoing then return false,"That challenge expired." end
    local deckPayload,err=CurrentDeckPayload();if not deckPayload then return false,err end
    local s=EnsureState()
    -- Accepting a fresh challenge from the same opponent explicitly replaces any
    -- locally paused table with them.  Do this even when the incoming CH packet
    -- did not carry a resume id, otherwise the receiver could keep a stale
    -- paused match and get offered it again on the next invitation.
    local oldPaused=GetPausedRecordForOpponent(ch.sender)
    if oldPaused then RemovePausedRecord(oldPaused.id) end
    if ch.localPausedMatchID then RemovePausedRecord(ch.localPausedMatchID) end
    local sent=SendRaw(ch.sender,table.concat({"OK",id,s.activeAbility,CSV(s.pendingStakes),deckPayload,Hex(DisplayName()),ThemeAccentHex(),Hex(CurrentAuraName()),CurrentArenaID(),CurrentCardBackID()},"|"))
    if not sent then return false,"Could not send the challenge acceptance." end
    pendingChallenges[id]=nil
    return true
end
function HS.AcceptResumeChallenge(id)
    local ch=pendingChallenges[id];if not ch or ch.outgoing then return false,"That challenge expired." end
    local rec=ch.localPausedMatchID and GetPausedRecordByID(ch.localPausedMatchID) or nil
    if not rec then return false,"No matching paused match exists on this client." end
    if rec.full then
        local ok,match=ReactivatePausedRecord(rec);if not ok then return false,match end
        pendingChallenges[id]=nil;SendRaw(ch.sender,"RA|"..id.."|"..tostring(match.id));SendStateTo(match,ch.sender,ParticipantSide(match,ch.sender) or 2,false)
        if RPAL.OpenHearthstoneMatch then RPAL.OpenHearthstoneMatch(match,ParticipantSide(match,PlayerName()) or 1) end
        return true,"Paused match resumed."
    end
    local ok=SendRaw(ch.sender,"ROK|"..id.."|"..tostring(rec.id));if ok then pendingChallenges[id]=nil end
    return ok,ok and "Resume approved." or "Could not approve resume."
end
function HS.DeclineChallenge(id) local ch=pendingChallenges[id];if ch then SendRaw(ch.sender,"NO|"..id);pendingChallenges[id]=nil end end
function HS.Concede() return HS.LocalAction("concede") end
function HS.ResumePausedMatch(id) local rec=GetPausedRecordByID(id);local ok,match=ReactivatePausedRecord(rec);if not ok then return false,match end;if RPAL.OpenHearthstoneMatch then RPAL.OpenHearthstoneMatch(match,ParticipantSide(match,PlayerName()) or match.viewerSide or 1) end;return true,"Match resumed." end
function HS.RequestSpectateTarget()
    local target=TargetName();if not target then return false,"Target a player in an Unofficial Hearthstone match." end
    pendingSpectateTarget=target
    local ok=SendRaw(target,"SQ|"..PlayerName())
    if ok and C_Timer and C_Timer.After then
        local requested=target
        local function Retry()
            if pendingSpectateTarget and PlayerNamesEqual(pendingSpectateTarget,requested) and not (activeMatch and activeMatch.spectator) then
                SendRaw(requested,"SQ|"..PlayerName())
            end
        end
        C_Timer.After(1.2,Retry);C_Timer.After(2.8,Retry)
    end
    return ok,ok and "Spectate request sent." or "Could not send the spectate request."
end
function HS.LeaveSpectate()
    local match=activeMatch
    if not match or ParticipantSide(match,PlayerName()) then return false,"You are not spectating a match." end
    if match.host and match.host~="" then SendRaw(match.host,"SX|"..tostring(match.id)) end
    pendingSpectateTarget=nil
    activeMatch=nil
    return true,"Left spectator mode."
end

function HS.RunPracticeBot(match)
    if not match or match.finished or match.turnSide~=2 then return end
    local difficulty=tostring(match.botDifficulty or (match.players and match.players[2] and match.players[2].botDifficulty) or "normal"):lower()
    local easy=difficulty=="easy";local hard=difficulty=="hard"
    local function EffectiveHP(hero) return math.max(0,(tonumber(hero.hp) or 0)+(tonumber(hero.armor) or 0)) end
    local function UnitValue(u)
        if not u then return -999 end
        local c=HS.GetCard(u.card);local v=(tonumber(u.attack) or 0)*1.45+(tonumber(u.hp) or 0)
        if u.taunt then v=v+2.2 end
        if c and c.deadly then v=v+4 end
        if c and c.lifesteal then v=v+2 end
        return v
    end
    local function BestEnemyUnit(enemy,onlyTaunt)
        local best,bv
        for _,u in ipairs(enemy.board or {}) do
            if (not onlyTaunt or u.taunt) and (u.hp or 0)>0 then local v=UnitValue(u);if not best or v>bv then best,bv=u,v end end
        end
        return best
    end
    local function BestFriendlyUnit(p,damaged)
        local best,bv
        for _,u in ipairs(p.board or {}) do
            if not damaged or (u.hp or 0)<(u.maxhp or u.hp or 0) then
                local v=UnitValue(u);if damaged then v=v+((u.maxhp or u.hp or 0)-(u.hp or 0))*2 end
                if not best or v>bv then best,bv=u,v end
            end
        end
        return best
    end
    local function PickPlay(match)
        local p,enemy=match.players[2],match.players[1]
        local candidates={}
        for i,cardID in ipairs(p.hand or {}) do
            local c=HS.GetCard(cardID)
            if c and (c.cost or 0)<=p.mana and (c.type~="Creature" or #p.board<HS.MAX_BOARD) then
                local target,score="",(c.cost or 0)*1.4
                if c.type=="Creature" then
                    score=score+(c.attack or 0)*.75+(c.health or 0)*.58
                    if c.taunt then score=score+1.7 end;if c.charge then score=score+1.8 end;if c.deadly then score=score+2.8 end;if c.lifesteal then score=score+1.5 end
                    if c.battlecry=="draw" then score=score+math.max(0,HS.MAX_HAND-#p.hand)*.18 end
                    if c.battlecry=="damageAllEnemy" then score=score+#enemy.board*1.5 end
                    if c.battlecry=="healHero" and p.hp>=HS.STARTING_HEALTH-1 then score=score-2.5 end
                    if c.battlecry=="buffAll" or c.battlecry=="buffAllAttack" then score=score+#p.board*.8 end
                elseif c.type=="Equipment" then
                    score=score+(c.attack or 0)*(c.durability or 1)*.65
                    if p.weapon then score=score-2.0 end
                else
                    local e=c.effect
                    if e=="damage" then
                        local amount=c.amount or 0
                        if c.target=="enemyCreature" then local u=BestEnemyUnit(enemy,false);if u then target="enemyCreature:"..u.uid;score=score+UnitValue(u)+(amount>=(u.hp or 0) and 3 or 0) else score=-999 end
                        elseif c.target=="enemy" then
                            local ehp=EffectiveHP(enemy)
                            if amount>=ehp then target="enemyHero";score=1000+amount
                            else local u=BestEnemyUnit(enemy,false);if u and UnitValue(u)>3.5 then target="enemyCreature:"..u.uid;score=score+UnitValue(u)+(amount>=(u.hp or 0) and 2.5 or 0) else target="enemyHero";score=score+amount*.55 end end
                        end
                    elseif e=="dirty" then local u=BestEnemyUnit(enemy,false);if u then target="enemyCreature:"..u.uid;score=score+UnitValue(u)+2 else score=-999 end
                    elseif c.target=="friendlyCreature" then local u=BestFriendlyUnit(p,false);if u then target="friendlyCreature:"..u.uid;score=score+UnitValue(u)*.35 else score=-999 end
                    elseif c.target=="friendly" then
                        local u=BestFriendlyUnit(p,true);local missing=HS.STARTING_HEALTH-(p.hp or 0)
                        if missing>=2 or not u then target="friendlyHero";score=score+missing*.7 else target="friendlyCreature:"..u.uid;score=score+2 end
                    elseif e=="aoeEnemy" then score=score+#enemy.board*(c.amount or 1)*1.15;if #enemy.board<2 then score=score-2.5 end
                    elseif e=="buffAll" or e=="buffAllAttack" then score=score+#p.board*1.25;if #p.board==0 then score=-999 end
                    elseif e=="healHero" or e=="healDraw" or e=="drawHeal" then local missing=HS.STARTING_HEALTH-(p.hp or 0);score=score+missing*.55;if missing<=0 then score=score-2 end
                    elseif e=="draw" then score=score+math.max(0,HS.MAX_HAND-#p.hand)*.45
                    elseif e=="armor" or e=="armorDraw" then score=score+((p.hp or 0)<=15 and 3 or 1)
                    end
                end
                candidates[#candidates+1]={i=i,target=target,score=score,card=c}
            end
        end
        table.sort(candidates,function(a,b)return a.score>b.score end)
        return candidates
    end
    local function TryOnePlay(match)
        local picks=PickPlay(match)
        if easy and #picks>1 then
            -- Easy deliberately makes imperfect tempo choices instead of always
            -- taking the highest-scoring line.
            local cap=math.min(3,#picks);local pick=picks[math.random(1,cap)]
            if pick then local ok=HS.ProcessAction(match,2,"play",pick.i,pick.target);if ok then return true end end
        end
        for _,pick in ipairs(picks) do
            local ok=HS.ProcessAction(match,2,"play",pick.i,pick.target)
            if ok then return true end
        end
        return false
    end
    local function TryAbility(match)
        local p,enemy=match.players[2],match.players[1]
        if p.abilityUsed then return false end
        if easy and math.random()<.58 then return false end
        local a=HS.ABILITY_BY_ID[p.ability];if not a or p.mana<(a.cost or 0) then return false end
        local target=""
        if a.target=="enemy" then
            if (a.amount or 0)>=EffectiveHP(enemy) then target="enemyHero" else local u=BestEnemyUnit(enemy,false);target=u and ("enemyCreature:"..u.uid) or "enemyHero" end
        elseif a.target=="enemyCreature" then local u=BestEnemyUnit(enemy,false);if not u then return false end;target="enemyCreature:"..u.uid
        elseif a.target=="friendlyCreature" then local u=BestFriendlyUnit(p,a.effect=="heal");if not u then return false end;target="friendlyCreature:"..u.uid
        elseif a.target=="friendly" then local u=BestFriendlyUnit(p,true);target=((p.hp or 0)<HS.STARTING_HEALTH-2 or not u) and "friendlyHero" or ("friendlyCreature:"..u.uid)
        elseif a.effect=="healHero" and (p.hp or 0)>=HS.STARTING_HEALTH-1 then return false
        elseif a.effect=="buffAllAttack" and #p.board==0 then return false end
        return HS.ProcessAction(match,2,"ability",target) and true or false
    end
    local function ReadyDamage(p)
        local n=0;for _,u in ipairs(p.board or {}) do if u.canAttack then n=n+(u.attack or 0) end end
        if p.weapon and not p.heroAttacked then n=n+(p.weapon.attack or 0) end
        return n
    end
    local function PickAttackTarget(match,attacker)
        local enemy=match.players[1];local taunt=HasTaunt(enemy)
        if taunt then local u=BestEnemyUnit(enemy,true);return u and ("enemyCreature:"..u.uid) or nil end
        if ReadyDamage(match.players[2])>=EffectiveHP(enemy) then return "enemyHero" end
        if easy then
            if #enemy.board==0 or math.random()<.72 then return "enemyHero" end
            local u=enemy.board[math.random(1,#enemy.board)];return u and ("enemyCreature:"..u.uid) or "enemyHero"
        end
        local best,bestScore
        for _,u in ipairs(enemy.board or {}) do
            local kill=(attacker.attack or 0)>=(u.hp or 0)
            local survives=(attacker.hp or 0)>(u.attack or 0)
            local score=UnitValue(u)+(kill and 3 or 0)+(survives and 1.5 or 0)-((not kill and (u.attack or 0)>=(attacker.hp or 0)) and 2 or 0)
            if not best or score>bestScore then best,bestScore=u,score end
        end
        local threshold=hard and 3.6 or 5.2
        if best and bestScore>threshold then return "enemyCreature:"..best.uid end
        return "enemyHero"
    end
    local function TryCreatureAttack(match)
        local p=match.players[2]
        local best
        if easy then
            local ready={};for _,u in ipairs(p.board or {}) do if u.canAttack then ready[#ready+1]=u end end
            best=#ready>0 and ready[math.random(1,#ready)] or nil
        else
            for _,u in ipairs(p.board or {}) do if u.canAttack and (not best or UnitValue(u)<UnitValue(best)) then best=u end end
        end
        if not best then return false end
        local target=PickAttackTarget(match,best);if not target then best.canAttack=false;return true end
        local ok=HS.ProcessAction(match,2,"attack",best.uid,target)
        if not ok then best.canAttack=false end
        return true
    end
    local function TryHeroAttack(match)
        local p,enemy=match.players[2],match.players[1]
        if not p.weapon or p.heroAttacked then return false end
        local target
        if HasTaunt(enemy) then local u=BestEnemyUnit(enemy,true);target=u and ("enemyCreature:"..u.uid)
        elseif (p.weapon.attack or 0)>=EffectiveHP(enemy) then target="enemyHero"
        elseif easy then
            local u=(#enemy.board>0 and math.random()<.22) and enemy.board[math.random(1,#enemy.board)] or nil;target=u and ("enemyCreature:"..u.uid) or "enemyHero"
        else
            local u=BestEnemyUnit(enemy,false)
            local safety=hard and 1 or 4
            if u and (u.hp or 0)<=(p.weapon.attack or 0) and (p.hp or 0)>(u.attack or 0)+safety then target="enemyCreature:"..u.uid else target="enemyHero" end
        end
        return target and (HS.ProcessAction(match,2,"heroattack",target) and true or false) or false
    end
    local function Schedule(fn,delay)
        if C_Timer and C_Timer.After then C_Timer.After(delay or .42,fn) else fn() end
    end
    local steps=0
    local function Step()
        if not match or match.finished or match.turnSide~=2 then return end
        steps=steps+1;if steps>32 then HS.ProcessAction(match,2,"end");BroadcastState(match);return end
        local acted=TryOnePlay(match)
        if not acted then acted=TryAbility(match) end
        if not acted then acted=TryCreatureAttack(match) end
        if not acted then acted=TryHeroAttack(match) end
        if acted then BroadcastState(match);Schedule(Step,easy and .72 or (hard and .34 or .46));return end
        HS.ProcessAction(match,2,"end");BroadcastState(match)
    end
    Schedule(Step,easy and .88 or (hard and .48 or .65))
end

local function BuildPracticeDeck(difficulty)
    difficulty=tostring(difficulty or "normal"):lower()
    local ids
    if difficulty=="easy" then
        ids={1,2,3,4,5,6,7,8,9,10,25,26,27,28,29,30,41,45,50,55}
    elseif difficulty=="hard" then
        ids={4,12,14,18,19,20,21,23,24,29,32,35,41,57,66,77,92,106,161,168}
    else
        ids={1,3,4,14,29,41,45,50,55,57,61,66,68,73,77,84,92,100,106,161}
    end
    return {cards=Copy(ids)}
end
function HS.StartPracticeMatch(difficulty)
    local s=EnsureState();local oldPractice=HS.GetPausedPracticeMatch();if oldPractice then RemovePausedRecord(oldPractice.id) end;difficulty=tostring(difficulty or s.practiceDifficulty or "normal"):lower();if not HS.PRACTICE_DIFFICULTIES[difficulty] then difficulty="normal" end;s.practiceDifficulty=difficulty
    local d=HS.GetActiveDeck();local ok,err=HS.ValidateDeck(d,true);if not ok then return false,err end
    local p1=NewPlayer(PlayerName(),DisplayName(),d,s.activeAbility,{},ThemeAccentHex(),CurrentAuraName(),CurrentArenaID(),CurrentCardBackID())
    local names={easy="Tavern Novice",normal="Tavern Tactician",hard="Tavern Grandmaster"}
    local p2=NewPlayer("__BOT__",names[difficulty] or names.normal,BuildPracticeDeck(difficulty),"arcane_spark",{},"D8A24E","None","crimson","ember");p2.bot=true;p2.botDifficulty=difficulty
    activeMatch=NewMatch("PRACTICE"..tostring(Now()),p1,p2,PlayerName(),"practice");activeMatch.botDifficulty=difficulty;matches[activeMatch.id]=activeMatch
    if RPAL.OpenHearthstoneMatch then RPAL.OpenHearthstoneMatch(activeMatch,1) end;return true
end
local function HandleMessage(msg,sender)
    local kind=msg:match("^([^|]+)");if not kind then return end
    local parts=Split(msg,"|")
    if kind=="CH" then
        local id,mode,hero,ability,stakes,deckName,accent,aura=parts[2],parts[3],Unhex(parts[4]),parts[5],ParseCSV(parts[6]),Unhex(parts[7]),parts[8],Unhex(parts[9] or "")
        local arena=HS.ARENA_BY_ID[parts[10]] and parts[10] or "tavern";local cardBack=HS.CARD_BACK_BY_ID[parts[11]] and parts[11] or "classic";local requestedResume=(parts[12] and parts[12]~="") and parts[12] or nil
        local localPaused=GetPausedRecordForOpponent(sender);local localPausedID=localPaused and localPaused.id or nil
        pendingChallenges[id]={sender=sender,mode=mode,heroName=hero,ability=ability,stakes=stakes,deckName=deckName,accent=accent,aura=(aura~="" and aura or "None"),arena=arena,cardBack=cardBack,resumeMatchID=requestedResume,localPausedMatchID=(requestedResume and localPausedID==requestedResume) and localPausedID or nil}
        if RPAL.ShowHearthstoneChallenge then RPAL.ShowHearthstoneChallenge(id,pendingChallenges[id]) end
    elseif kind=="NO" then pendingChallenges[parts[2]]=nil;if RPAL.HearthstoneStatus then RPAL.HearthstoneStatus("Challenge declined.") end
    elseif kind=="OK" then
        local id=parts[2];local out=pendingChallenges[id];if not out or not out.outgoing then return end
        local guestAbility=parts[3];local guestStakes=ParseCSV(parts[4]);local guestCards=ParseCSV(parts[5]);local guestDisplay=Unhex(parts[6]);local guestAccent=parts[7];local guestAura=Unhex(parts[8] or "");if guestAura=="" then guestAura="None" end
        local guestArena=HS.ARENA_BY_ID[parts[9]] and parts[9] or "tavern";local guestCardBack=HS.CARD_BACK_BY_ID[parts[10]] and parts[10] or "classic";if #guestCards~=20 then return end
        local guestDeck={cards=guestCards};local deckOK=HS.ValidateDeck(guestDeck,true);if not deckOK then SendRaw(sender,"ER|"..id.."|"..Hex("Your deck failed validation."));return end
        local stakeCounts={};for _,sid in ipairs(guestStakes) do local card=HS.GetCard(sid);if not card then return end;stakeCounts[sid]=(stakeCounts[sid] or 0)+1;if stakeCounts[sid]>(HS.MAX_COPIES[card.rarity] or 2) then return end end
        local mode=out.mode
        local p1=NewPlayer(PlayerName(),out.heroName,out.deck,out.ability,out.stakes,out.accent,out.aura or CurrentAuraName(),out.arena or CurrentArenaID(),out.cardBack or CurrentCardBackID())
        local p2=NewPlayer(sender,guestDisplay,guestDeck,guestAbility,guestStakes,guestAccent,guestAura,guestArena,guestCardBack)
        activeMatch=NewMatch(id,p1,p2,PlayerName(),mode);matches[id]=activeMatch;pendingChallenges[id]=nil
        SendStateTo(activeMatch,sender,2,false);if RPAL.OpenHearthstoneMatch then RPAL.OpenHearthstoneMatch(activeMatch,1) end
    elseif kind=="ACN" then
        local id,action,a,b=parts[2],parts[3],parts[4],parts[5];local m=matches[id];if not m or not PlayerNamesEqual(m.host,PlayerName()) then return end
        local side=ParticipantSide(m,sender);if not side then return end
        local ok,err=HS.ProcessAction(m,side,action,a,b)
        if ok then
            BroadcastState(m)
            if action=="end" and C_Timer and C_Timer.After then
                local matchID=tostring(m.id);local expectedTurn=tonumber(m.turn) or 0
                C_Timer.After(0.45,function()local current=matches[matchID] or activeMatch;if current and tostring(current.id)==matchID and not current.finished and (tonumber(current.turn) or 0)==expectedTurn then BroadcastState(current) end end)
            end
        else SendRaw(sender,"ER|"..id.."|"..Hex(err or "Action rejected.")) end
    elseif kind=="PRQ" then
        local id=parts[2];local m=matches[id] or activeMatch
        if m and m.id==id and PlayerNamesEqual(m.host,PlayerName()) and not m.finished then
            local side=ParticipantSide(m,sender);if side then AuthoritativePause(m,m.players[side].display) end
        end
    elseif kind=="ROK" then
        local challengeID,resumeID=parts[2],parts[3];local out=pendingChallenges[challengeID]
        if out and out.outgoing and out.target==sender then
            local rec=GetPausedRecordByID(resumeID)
            if rec and rec.full then
                local ok,match=ReactivatePausedRecord(rec)
                if ok then pendingChallenges[challengeID]=nil;SendRaw(sender,"RA|"..challengeID.."|"..tostring(match.id));SendStateTo(match,sender,ParticipantSide(match,sender) or 2,false);if RPAL.OpenHearthstoneMatch then RPAL.OpenHearthstoneMatch(match,ParticipantSide(match,PlayerName()) or 1) end end
            else SendRaw(sender,"ER|"..challengeID.."|"..Hex("The original host no longer has the full paused table state.")) end
        end
    elseif kind=="RA" then
        local challengeID,matchID=parts[2],parts[3];pendingChallenges[challengeID]=nil
        if RPAL.HearthstoneStatus then RPAL.HearthstoneStatus("Resume approved — restoring paused match "..tostring(matchID)..".") end
    elseif kind=="ER" then if RPAL.HearthstoneStatus then RPAL.HearthstoneStatus(Unhex(parts[3])) end
    elseif kind=="ST" then
        local id,seq,index,count,chunk=parts[2],tonumber(parts[3]),tonumber(parts[4]),tonumber(parts[5]),parts[6]
        if not id or not seq or not index or not count or type(chunk)~="string" then return end
        if count<1 or count>160 or index<1 or index>count or #chunk>180 or not chunk:match("^%x*$") then return end
        local now=(GetTime and GetTime()) or 0
        -- A missing chunk used to leave a partial snapshot in memory forever.
        -- Clear abandoned assemblies and reset inconsistent repeats.
        for staleKey,stale in pairs(incomingChunks) do
            if now-(tonumber(stale.receivedAt) or now)>15 then incomingChunks[staleKey]=nil end
        end
        local key=sender.."|"..id.."|"..seq
        local rec=incomingChunks[key]
        if not rec or rec.count~=count then rec={count=count,chunks={},receivedAt=now};incomingChunks[key]=rec end
        rec.receivedAt=now;rec.chunks[index]=chunk
        local got=0;for i=1,count do if rec.chunks[i] then got=got+1 end end
        if got==count then
            local hex=table.concat(rec.chunks);incomingChunks[key]=nil
            local stateKey=tostring(sender).."|"..tostring(id);local lastSeq=tonumber(lastAppliedStateSeq[stateKey]) or 0
            -- Multiple full snapshots can be in flight at once. Never let a
            -- late old snapshot roll the table back after a newer turn landed.
            if seq>lastSeq then
                local state=DeserializeState(Unhex(hex))
                if state then
                    lastAppliedStateSeq[stateKey]=seq;state._networkSeq=seq;state.host=sender
                    if state.spectator then pendingSpectateTarget=nil end
                    activeMatch=state
                    if state.finished then RemovePausedRecord(state.id);SettleStakes(state,state.winner);AwardVictoryTokens(state,state.winner);RecordHistory(state,state.winner,state.finishReason~="" and state.finishReason or "match") elseif state.paused then SavePausedRecord(state,state.viewerSide) else RemovePausedRecord(state.id) end
                    if state.paused and RPAL.OnHearthstoneMatchPaused then RPAL.OnHearthstoneMatchPaused(state,sender) elseif RPAL.OpenHearthstoneMatch then RPAL.OpenHearthstoneMatch(state,state.viewerSide) end
                end
            end
        end
    elseif kind=="FN" then local id=parts[2];if activeMatch and activeMatch.id==id then activeMatch.finished=true;activeMatch.winner=tonumber(parts[3]);activeMatch.finishReason=parts[4] or activeMatch.finishReason;activeMatch.durationSec=tonumber(parts[5]) or activeMatch.durationSec;activeMatch.rewardTokens=tonumber(parts[6]) or activeMatch.rewardTokens;AwardVictoryTokens(activeMatch,activeMatch.winner);if RPAL.RefreshHearthstoneMatchUI then RPAL.RefreshHearthstoneMatchUI(activeMatch,activeMatch.viewerSide) end end
    elseif kind=="EMO" then
        local id,key=parts[2],tostring(parts[3] or ""):upper();local phrase=SafeText(parts[4] or "",72)
        if activeMatch and activeMatch.id==id and EMOTE_KEYS[key] and RPAL.HearthstoneReceiveEmote then RPAL.HearthstoneReceiveEmote(sender,key,phrase~="" and phrase or HS.GetEmoteText(key)) end
    elseif kind=="SQ" then
        -- A request can hit either participant. Guests forward it to the
        -- authoritative host; hosts immediately publish the spectator view.
        if activeMatch and PlayerNamesEqual(activeMatch.host,PlayerName()) then
            activeMatch.spectators=activeMatch.spectators or {};activeMatch.spectators[sender]=true;SendStateTo(activeMatch,sender,0,true)
        elseif activeMatch and activeMatch.host then
            SendRaw(activeMatch.host,"SF|"..sender.."|"..tostring(activeMatch.id))
        end
    elseif kind=="SF" then
        local who,id=parts[2],parts[3];local m=(id and matches[id]) or activeMatch
        if m and PlayerNamesEqual(m.host,PlayerName()) and who and who~="" then m.spectators=m.spectators or {};m.spectators[who]=true;SendStateTo(m,who,0,true) end
    elseif kind=="RQ" then
        local id,viewKind=parts[2],parts[3];local m=matches[id] or activeMatch
        if m and tostring(m.id)==tostring(id) and PlayerNamesEqual(m.host,PlayerName()) then
            local side=ParticipantSide(m,sender)
            if side then SendStateTo(m,sender,side,false)
            elseif viewKind=="S" or (m.spectators and m.spectators[sender]) then m.spectators=m.spectators or {};m.spectators[sender]=true;SendStateTo(m,sender,0,true) end
        end
    elseif kind=="SX" then
        local id=parts[2];local m=matches[id] or activeMatch
        if m and tostring(m.id)==tostring(id) and PlayerNamesEqual(m.host,PlayerName()) and m.spectators then
            for who in pairs(m.spectators) do if PlayerNamesEqual(who,sender) then m.spectators[who]=nil end end
        end
    end
end

local eventFrame=CreateFrame("Frame")
eventFrame:RegisterEvent("PLAYER_LOGIN")
eventFrame:RegisterEvent("CHAT_MSG_ADDON")
eventFrame:SetScript("OnEvent",function(_,event,...)
    if event=="PLAYER_LOGIN" then EnsureState();if C_ChatInfo and C_ChatInfo.RegisterAddonMessagePrefix then pcall(C_ChatInfo.RegisterAddonMessagePrefix,HS.PREFIX) end
    elseif event=="CHAT_MSG_ADDON" then local prefix,msg,channel,sender=...;if prefix==HS.PREFIX and channel=="WHISPER" and sender then HandleMessage(msg,sender) end end
end)

SLASH_RPALHS1="/odynhs"
SLASH_RPALHS2="/rpalhs"
SlashCmdList["RPALHS"]=function(msg)
    local cmd=tostring(msg or ""):match("^%s*(.-)%s*$"):lower()
    if cmd=="" or cmd=="open" then if RPAL.OpenHearthstone then RPAL.OpenHearthstone("play") end
    elseif cmd=="collection" then if RPAL.OpenHearthstone then RPAL.OpenHearthstone("collection") end
    elseif cmd=="packs" then if RPAL.OpenHearthstone then RPAL.OpenHearthstone("packs") end
    elseif cmd=="decks" then if RPAL.OpenHearthstone then RPAL.OpenHearthstone("decks") end
    elseif cmd=="match" or cmd=="practice" then local ok,err=HS.StartPracticeMatch();if not ok then print("|cffff6666ODYN Unofficial Hearthstone:|r "..tostring(err)) end
    else print("|cff66d9ffODYN Unofficial Hearthstone|r — /odynhs [open|collection|packs|decks|practice]") end
end
function HS.GetActiveMatch() return activeMatch end
function HS.GetPendingChallenge(id) return pendingChallenges[id] end
function HS.GetHistory() return EnsureState().history end
function HS.GetCardRarityColor(rarity) return HS.RARITY[rarity] or {1,1,1} end
