local ADDON_NAME, RPAL = ...
if type(RPAL) ~= "table" then return end

-- ============================================================
-- ODYN Board Games — Chess + Checkers
-- Lightweight authoritative 1v1 board-game engine with practice bot.
-- The visual layer lives in BoardGamesUI.lua; this file owns rules, state,
-- customisation persistence and addon-message synchronisation.
-- ============================================================

local BG = {}
RPAL.BoardGames = BG

BG.VERSION = 2
BG.PREFIX = "RPAL_BG"
BG.MAX_HISTORY = 24

BG.GAMES = {
    chess={id="chess",name="Chess",subtitle="ODYN STRATEGY TABLE"},
    checkers={id="checkers",name="Checkers",subtitle="ODYN DRAUGHTS TABLE"},
}

-- Every player brings one table-half theme.  BoardGamesUI deliberately applies
-- the local player's palette to the near four ranks and the opponent palette
-- to the far four ranks, preserving the Hearthstone split-arena idea.
BG.THEMES = {
    royal={id="royal",name="Royal Hall",rail={.08,.12,.20},light={.67,.70,.72},dark={.20,.28,.39},accent={.64,.79,1.00},alt={rail={.24,.10,.05},light={.82,.65,.48},dark={.42,.20,.10},accent={1.00,.68,.32}}},
    arcane={id="arcane",name="Arcane Observatory",rail={.10,.07,.24},light={.58,.51,.77},dark={.21,.15,.39},accent={.65,.50,1.00},alt={rail={.04,.18,.20},light={.50,.78,.76},dark={.10,.36,.38},accent={.36,1.00,.91}}},
    verdant={id="verdant",name="Verdant Lodge",rail={.05,.17,.11},light={.62,.70,.57},dark={.17,.34,.22},accent={.48,.92,.58},alt={rail={.23,.12,.04},light={.78,.67,.43},dark={.40,.24,.08},accent={.95,.75,.27}}},
    frost={id="frost",name="Frostbound Table",rail={.05,.16,.23},light={.67,.78,.82},dark={.17,.34,.42},accent={.52,.89,1.00},alt={rail={.19,.06,.22},light={.72,.58,.77},dark={.36,.15,.42},accent={.89,.52,1.00}}},
    ember={id="ember",name="Ember Court",rail={.25,.07,.035},light={.74,.58,.43},dark={.39,.15,.08},accent={1.00,.52,.24},alt={rail={.04,.11,.24},light={.50,.65,.82},dark={.10,.24,.42},accent={.38,.67,1.00}}},
    grave={id="grave",name="Gravewood",rail={.09,.12,.10},light={.55,.58,.50},dark={.21,.27,.22},accent={.54,.78,.61},alt={rail={.16,.10,.18},light={.70,.62,.70},dark={.31,.20,.34},accent={.80,.61,.85}}},
}
BG.THEME_ORDER={"royal","arcane","verdant","frost","ember","grave"}

-- Piece packs use the supplied board-game art. Every pack has a paired
-- contrast colour used automatically for the opponent when two selections
-- would otherwise be identical or visually too close.
BG.PIECE_SETS = {
    classic={id="classic",name="Classic Ivory",assetColor="white",altAssetColor="black",color={.90,.90,.86},base={.88,.85,.76},edge={.35,.28,.18},ink={.13,.11,.08},glow={.92,.72,.35},altBase={.14,.15,.18},altEdge={.68,.72,.78},altInk={.96,.97,1}},
    obsidian={id="obsidian",name="Obsidian",assetColor="black",altAssetColor="white",color={.15,.15,.17},base={.16,.17,.20},edge={.56,.61,.68},ink={.92,.94,.98},glow={.46,.67,1.00},altBase={.90,.89,.84},altEdge={.38,.32,.24},altInk={.11,.10,.08}},
    arcane={id="arcane",name="Arcane Glass",assetColor="purple",altAssetColor="yellow",color={.50,.30,.80},base={.24,.16,.47},edge={.68,.54,1.00},ink={.94,.89,1.00},glow={.55,.37,1.00},altBase={.62,.48,.08},altEdge={1.00,.87,.29},altInk={1,.98,.82}},
    ember={id="ember",name="Emberforged",assetColor="red",altAssetColor="blue",color={.75,.18,.10},base={.43,.12,.06},edge={1.00,.56,.25},ink={1.00,.88,.67},glow={1.00,.31,.10},altBase={.10,.29,.50},altEdge={.50,.79,1.00},altInk={.90,.97,1}},
    frost={id="frost",name="Frostglass",assetColor="blue",altAssetColor="orange",color={.18,.45,.75},base={.12,.31,.40},edge={.61,.91,1.00},ink={.91,.99,1.00},glow={.42,.88,1.00},altBase={.48,.23,.06},altEdge={1.00,.67,.28},altInk={1,.94,.79}},
    verdant={id="verdant",name="Verdant Stone",assetColor="green",altAssetColor="purple",color={.18,.60,.28},base={.10,.31,.18},edge={.51,.88,.57},ink={.91,1.00,.92},glow={.33,.94,.47},altBase={.31,.17,.48},altEdge={.77,.58,1.00},altInk={.97,.91,1}},
}
BG.PIECE_SET_ORDER={"classic","obsidian","arcane","ember","frost","verdant"}
BG.PRACTICE_DIFFICULTIES={easy=true,normal=true,hard=true}

local activeMatch
local matches={}
local pendingChallenges={}

local function Copy(t)
    if type(t)~="table" then return t end
    local o={};for k,v in pairs(t) do o[k]=Copy(v) end;return o
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 NamesEqual(a,b)
    a=tostring(a or "");b=tostring(b or "")
    if a=="" or b=="" then return false end
    if 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
    if not ar or not br then return true end
    return ar:lower()==br:lower()
end
local function ParticipantSide(m,name)
    if not m or not m.players then return nil end
    if m.players[1] and NamesEqual(m.players[1].name,name) then return 1 end
    if m.players[2] and NamesEqual(m.players[2].name,name) then return 2 end
end
local function Hex(s)
    s=tostring(s or "")
    return (s:gsub(".",function(c)return string.format("%02X",string.byte(c))end))
end
local function Unhex(s)
    s=tostring(s or "");if #s%2~=0 or not s:match("^%x*$") then return "" end
    return (s:gsub("%x%x",function(cc)return string.char(tonumber(cc,16) or 32)end))
end
local function Split(text)
    local out={};for p in (tostring(text or "").."|"):gmatch("(.-)|") do out[#out+1]=p end;return out
end
local function SafeText(s,n)
    s=tostring(s or ""):gsub("[|\r\n]"," "):gsub("%s+"," ")
    if #s>(n or 64) then s=s:sub(1,n or 64) end
    return s
end

local function EnsureState()
    RPAL_CHAR_DB=type(RPAL_CHAR_DB)=="table" and RPAL_CHAR_DB or {}
    local s=RPAL_CHAR_DB.boardGames
    if type(s)~="table" then s={};RPAL_CHAR_DB.boardGames=s end
    s.version=BG.VERSION
    s.chess=type(s.chess)=="table" and s.chess or {}
    s.checkers=type(s.checkers)=="table" and s.checkers or {}
    for _,game in ipairs({"chess","checkers"}) do
        local g=s[game]
        if not BG.THEMES[g.theme] then g.theme=(game=="chess" and "royal" or "ember") end
        if not BG.PIECE_SETS[g.pieceSet] then g.pieceSet=(game=="chess" and "classic" or "obsidian") end
        if not BG.PRACTICE_DIFFICULTIES[g.difficulty] then g.difficulty="normal" end
        g.history=type(g.history)=="table" and g.history or {}
    end
    return s
end

function BG.GetState() return EnsureState() end
function BG.GetGameState(game) local s=EnsureState();return s[game] end
function BG.GetTheme(id) return BG.THEMES[id] or BG.THEMES.royal end
function BG.GetPieceSet(id) return BG.PIECE_SETS[id] or BG.PIECE_SETS.classic end

local function ColorDistance(a,b)
    if type(a)~="table" or type(b)~="table" then return 1 end
    local dr=(tonumber(a[1]) or 0)-(tonumber(b[1]) or 0)
    local dg=(tonumber(a[2]) or 0)-(tonumber(b[2]) or 0)
    local db=(tonumber(a[3]) or 0)-(tonumber(b[3]) or 0)
    return math.sqrt((dr*dr+dg*dg+db*db)/3)
end
function BG.ThemeNeedsContrast(aID,bID)
    if tostring(aID or "")==tostring(bID or "") then return true end
    local a,b=BG.GetTheme(aID),BG.GetTheme(bID)
    local score=(ColorDistance(a.rail,b.rail)+ColorDistance(a.light,b.light)+ColorDistance(a.dark,b.dark)+ColorDistance(a.accent,b.accent))/4
    return score<.14
end
function BG.PieceNeedsContrast(aID,bID)
    if tostring(aID or "")==tostring(bID or "") then return true end
    return ColorDistance(BG.GetPieceSet(aID).color,BG.GetPieceSet(bID).color)<.24
end
function BG.GetThemeVisual(id,useAlt)
    local t=BG.GetTheme(id)
    if useAlt and type(t.alt)=="table" then return t.alt end
    return t
end
function BG.GetPieceVisual(id,useAlt)
    local s=BG.GetPieceSet(id)
    return {
        color=(useAlt and s.altAssetColor) or s.assetColor or "white",
        base=(useAlt and s.altBase) or s.base,
        edge=(useAlt and s.altEdge) or s.edge,
        ink=(useAlt and s.altInk) or s.ink,
        alternate=useAlt and true or false,
    }
end
function BG.GetActiveMatch() return activeMatch end
function BG.SetTheme(game,id)
    if not BG.GAMES[game] or not BG.THEMES[id] then return false,"Unknown table theme." end
    BG.GetGameState(game).theme=id;return true
end
function BG.SetPieceSet(game,id)
    if not BG.GAMES[game] or not BG.PIECE_SETS[id] then return false,"Unknown piece set." end
    BG.GetGameState(game).pieceSet=id;return true
end
function BG.SetPracticeDifficulty(game,diff)
    diff=tostring(diff or "normal"):lower();if not BG.PRACTICE_DIFFICULTIES[diff] then return false,"Unknown difficulty." end
    BG.GetGameState(game).difficulty=diff;return true
end

local function PushHistory(m,result)
    if not m or m._historyWritten then return end
    m._historyWritten=true
    local me=ParticipantSide(m,PlayerName()) or m.viewerSide or 1
    local opp=(me==1 and 2 or 1)
    local mine=m.players and m.players[me];local theirs=m.players and m.players[opp]
    local outcome="Draw"
    if m.winner==me then outcome="Victory" elseif m.winner and m.winner~=0 then outcome="Defeat" end
    if result and result~="" then outcome=result end
    local h=BG.GetGameState(m.game).history
    table.insert(h,1,{at=Now(),opponent=(theirs and theirs.display) or "Opponent",outcome=outcome,mode=m.mode or "online",moves=tonumber(m.moveCount) or 0})
    while #h>BG.MAX_HISTORY do table.remove(h) end
end

local function RowCol(i) i=tonumber(i) or 1;return math.floor((i-1)/8)+1,((i-1)%8)+1 end
local function Idx(r,c) if r<1 or r>8 or c<1 or c>8 then return nil end;return (r-1)*8+c end
local function SideOfChess(p)
    if type(p)~="string" or p=="." then return nil end
    return p:match("%u") and 1 or 2
end
local function TypeOfChess(p) return type(p)=="string" and p:upper() or nil end
local CHESS_VALUE={P=1,N=3,B=3.2,R=5,Q=9,K=100}

local function InitialChessBoard()
    local b={};for i=1,64 do b[i]="." end
    local top={"r","n","b","q","k","b","n","r"};local bot={"R","N","B","Q","K","B","N","R"}
    for c=1,8 do b[Idx(1,c)]=top[c];b[Idx(2,c)]="p";b[Idx(7,c)]="P";b[Idx(8,c)]=bot[c] end
    return b
end
local function InitialCheckersBoard()
    local b={};for i=1,64 do b[i]="." end
    for r=1,3 do for c=1,8 do if (r+c)%2==1 then b[Idx(r,c)]="o" end end end
    for r=6,8 do for c=1,8 do if (r+c)%2==1 then b[Idx(r,c)]="x" end end end
    return b
end

local function ChessAttacked(board,target,bySide)
    local tr,tc=RowCol(target)
    for i,p in ipairs(board) do
        if SideOfChess(p)==bySide then
            local r,c=RowCol(i);local typ=TypeOfChess(p)
            if typ=="P" then
                local dr=(bySide==1 and -1 or 1)
                if r+dr==tr and math.abs(c-tc)==1 then return true end
            elseif typ=="N" then
                local dr,dc=math.abs(r-tr),math.abs(c-tc);if (dr==2 and dc==1) or (dr==1 and dc==2) then return true end
            elseif typ=="K" then
                if math.max(math.abs(r-tr),math.abs(c-tc))==1 then return true end
            else
                local rr,cc=tr-r,tc-c;local sr=(rr==0 and 0 or (rr>0 and 1 or -1));local sc=(cc==0 and 0 or (cc>0 and 1 or -1))
                local diag=math.abs(rr)==math.abs(cc);local straight=(rr==0 or cc==0)
                local allowed=(typ=="B" and diag) or (typ=="R" and straight) or (typ=="Q" and (diag or straight))
                if allowed then
                    local cr,ccur=r+sr,c+sc;local blocked=false
                    while cr~=tr or ccur~=tc do if board[Idx(cr,ccur)]~="." then blocked=true;break end;cr=cr+sr;ccur=ccur+sc end
                    if not blocked then return true end
                end
            end
        end
    end
    return false
end
local function FindKing(board,side)
    local k=(side==1 and "K" or "k");for i,p in ipairs(board) do if p==k then return i end end
end
local function ChessInCheck(board,side)
    local k=FindKing(board,side);if not k then return true end
    return ChessAttacked(board,k,side==1 and 2 or 1)
end

local function ChessPseudoMoves(m,side,fromOnly,includeCastle)
    local board=m.board;local moves={}
    local function Add(from,to,special)
        if not to then return end;local target=board[to]
        if target=="." or SideOfChess(target)~=side then moves[#moves+1]={from=from,to=to,special=special,capture=(target~="." and target or nil)} end
    end
    for i,p in ipairs(board) do
        if SideOfChess(p)==side and (not fromOnly or i==fromOnly) then
            local r,c=RowCol(i);local typ=TypeOfChess(p)
            if typ=="P" then
                local dr=(side==1 and -1 or 1);local start=(side==1 and 7 or 2);local promo=(side==1 and 1 or 8)
                local one=Idx(r+dr,c)
                if one and board[one]=="." then Add(i,one,(r+dr==promo) and "promote" or nil);local two=Idx(r+2*dr,c);if r==start and two and board[two]=="." then Add(i,two) end end
                for _,dc in ipairs({-1,1}) do
                    local to=Idx(r+dr,c+dc)
                    if to and board[to]~="." and SideOfChess(board[to])~=side then Add(i,to,(r+dr==promo) and "promote" or nil)
                    elseif to and board[to]=="." and m.enPassant==to then Add(i,to,"enpassant") end
                end
            elseif typ=="N" then
                for _,d in ipairs({{-2,-1},{-2,1},{-1,-2},{-1,2},{1,-2},{1,2},{2,-1},{2,1}}) do Add(i,Idx(r+d[1],c+d[2])) end
            elseif typ=="K" then
                for dr=-1,1 do for dc=-1,1 do if dr~=0 or dc~=0 then Add(i,Idx(r+dr,c+dc)) end end end
                if includeCastle and m.castle and m.castle[side] and not ChessInCheck(board,side) then
                    local home=(side==1 and 8 or 1);local enemy=side==1 and 2 or 1
                    if r==home and c==5 then
                        if m.castle[side].k and board[Idx(home,6)]=="." and board[Idx(home,7)]=="." and TypeOfChess(board[Idx(home,8)])=="R" and SideOfChess(board[Idx(home,8)])==side and not ChessAttacked(board,Idx(home,6),enemy) and not ChessAttacked(board,Idx(home,7),enemy) then Add(i,Idx(home,7),"castleK") end
                        if m.castle[side].q and board[Idx(home,4)]=="." and board[Idx(home,3)]=="." and board[Idx(home,2)]=="." and TypeOfChess(board[Idx(home,1)])=="R" and SideOfChess(board[Idx(home,1)])==side and not ChessAttacked(board,Idx(home,4),enemy) and not ChessAttacked(board,Idx(home,3),enemy) then Add(i,Idx(home,3),"castleQ") end
                    end
                end
            else
                local dirs={}
                if typ=="B" or typ=="Q" then dirs[#dirs+1]={-1,-1};dirs[#dirs+1]={-1,1};dirs[#dirs+1]={1,-1};dirs[#dirs+1]={1,1} end
                if typ=="R" or typ=="Q" then dirs[#dirs+1]={-1,0};dirs[#dirs+1]={1,0};dirs[#dirs+1]={0,-1};dirs[#dirs+1]={0,1} end
                for _,d in ipairs(dirs) do
                    local rr,cc=r+d[1],c+d[2]
                    while rr>=1 and rr<=8 and cc>=1 and cc<=8 do
                        local to=Idx(rr,cc);local target=board[to]
                        if target=="." then Add(i,to) else if SideOfChess(target)~=side then Add(i,to) end;break end
                        rr=rr+d[1];cc=cc+d[2]
                    end
                end
            end
        end
    end
    return moves
end
local function ApplyChessRaw(m,move,simulation)
    local b=m.board;local p=b[move.from];local side=SideOfChess(p);local typ=TypeOfChess(p);local fr,fc=RowCol(move.from);local tr,tc=RowCol(move.to)
    b[move.to]=p;b[move.from]="."
    if move.special=="enpassant" then local captured=Idx(tr-(side==1 and -1 or 1),tc);if captured then b[captured]="." end end
    if move.special=="promote" then b[move.to]=(side==1 and "Q" or "q") end
    if move.special=="castleK" then local home=(side==1 and 8 or 1);b[Idx(home,6)]=b[Idx(home,8)];b[Idx(home,8)]="." end
    if move.special=="castleQ" then local home=(side==1 and 8 or 1);b[Idx(home,4)]=b[Idx(home,1)];b[Idx(home,1)]="." end
    local newEP=nil;if typ=="P" and math.abs(tr-fr)==2 then newEP=Idx(math.floor((tr+fr)/2),fc) end;m.enPassant=newEP
    if not simulation and m.castle then
        if typ=="K" then m.castle[side].k=false;m.castle[side].q=false end
        if typ=="R" then local home=(side==1 and 8 or 1);if fr==home and fc==1 then m.castle[side].q=false elseif fr==home and fc==8 then m.castle[side].k=false end end
        -- Capturing an unmoved rook also removes that castle right.
        local other=side==1 and 2 or 1;local oh=(other==1 and 8 or 1)
        if tr==oh and tc==1 then m.castle[other].q=false elseif tr==oh and tc==8 then m.castle[other].k=false end
    end
end
local function ChessLegalMoves(m,side,fromOnly)
    local out={}
    for _,mv in ipairs(ChessPseudoMoves(m,side,fromOnly,true)) do
        local sim={board=Copy(m.board),castle=Copy(m.castle),enPassant=m.enPassant};ApplyChessRaw(sim,mv,true)
        if not ChessInCheck(sim.board,side) then out[#out+1]=mv end
    end
    return out
end

local function CheckersSide(p)
    if p=="x" or p=="X" then return 1 elseif p=="o" or p=="O" then return 2 end
end
local function CheckersKing(p) return p=="X" or p=="O" end
local function CheckersCapturesFrom(m,from)
    local b=m.board;local p=b[from];local side=CheckersSide(p);if not side then return {} end
    local r,c=RowCol(from);local dirs={}
    if CheckersKing(p) or side==1 then dirs[#dirs+1]={-1,-1};dirs[#dirs+1]={-1,1} end
    if CheckersKing(p) or side==2 then dirs[#dirs+1]={1,-1};dirs[#dirs+1]={1,1} end
    local out={}
    for _,d in ipairs(dirs) do
        local mid=Idx(r+d[1],c+d[2]);local to=Idx(r+2*d[1],c+2*d[2])
        if mid and to and b[to]=="." and CheckersSide(b[mid]) and CheckersSide(b[mid])~=side then out[#out+1]={from=from,to=to,capture=mid} end
    end
    return out
end
local function CheckersLegalMoves(m,side,fromOnly)
    if m.forcedFrom and side==m.turnSide then fromOnly=m.forcedFrom end

    -- Captures are mandatory across the entire side, not just for the piece
    -- the player happened to click.  Build the global capture set first and
    -- only then filter it to fromOnly for selection/highlight queries.
    local allCaptures={}
    for i,p in ipairs(m.board) do
        if CheckersSide(p)==side then
            local list=CheckersCapturesFrom(m,i)
            for _,mv in ipairs(list) do allCaptures[#allCaptures+1]=mv end
        end
    end
    if #allCaptures>0 then
        if not fromOnly then return allCaptures end
        local filtered={}
        for _,mv in ipairs(allCaptures) do if mv.from==fromOnly then filtered[#filtered+1]=mv end end
        return filtered
    end

    if m.forcedFrom then return {} end
    local out={}
    for i,p in ipairs(m.board) do
        if CheckersSide(p)==side and (not fromOnly or i==fromOnly) then
            local r,c=RowCol(i);local dirs={}
            if CheckersKing(p) or side==1 then dirs[#dirs+1]={-1,-1};dirs[#dirs+1]={-1,1} end
            if CheckersKing(p) or side==2 then dirs[#dirs+1]={1,-1};dirs[#dirs+1]={1,1} end
            for _,d in ipairs(dirs) do local to=Idx(r+d[1],c+d[2]);if to and m.board[to]=="." then out[#out+1]={from=i,to=to} end end
        end
    end
    return out
end

function BG.GetLegalMoves(m,side,fromOnly)
    if not m or m.finished then return {} end
    side=tonumber(side) or m.turnSide
    if m.game=="chess" then return ChessLegalMoves(m,side,fromOnly) end
    return CheckersLegalMoves(m,side,fromOnly)
end

local function Finish(m,winner,reason)
    if not m or m.finished then return end
    m.finished=true;m.winner=winner or 0;m.finishReason=reason or "Game over";m.endedAt=Now();if not m._simulation then PushHistory(m) end
end
local function EvaluateEnd(m)
    if m.game=="chess" then
        local next=m.turnSide;local legal=ChessLegalMoves(m,next)
        if #legal==0 then
            if ChessInCheck(m.board,next) then Finish(m,next==1 and 2 or 1,"Checkmate") else Finish(m,0,"Stalemate") end
        end
    else
        local c1,c2=0,0;for _,p in ipairs(m.board) do local s=CheckersSide(p);if s==1 then c1=c1+1 elseif s==2 then c2=c2+1 end end
        if c1==0 then Finish(m,2,"No pieces remaining") elseif c2==0 then Finish(m,1,"No pieces remaining") elseif #CheckersLegalMoves(m,m.turnSide)==0 then Finish(m,m.turnSide==1 and 2 or 1,"No legal moves") end
    end
end

function BG.ProcessMove(m,side,from,to)
    if not m or m.finished then return false,"That game is over." end
    side=tonumber(side);from=tonumber(from);to=tonumber(to)
    if side~=m.turnSide then return false,"It is not your turn." end
    if not from or not to then return false,"Invalid move." end
    local chosen
    for _,mv in ipairs(BG.GetLegalMoves(m,side,from)) do if mv.to==to then chosen=mv;break end end
    if not chosen then return false,"That move is not legal." end
    if m.game=="chess" then
        ApplyChessRaw(m,chosen,false);m.forcedFrom=nil;m.turnSide=(side==1 and 2 or 1)
        m.lastMove={from=from,to=to};m.moveCount=(m.moveCount or 0)+1
    else
        local p=m.board[from];m.board[to]=p;m.board[from]="."
        if chosen.capture then m.board[chosen.capture]="." end
        local tr=select(1,RowCol(to));if side==1 and tr==1 and p=="x" then m.board[to]="X" elseif side==2 and tr==8 and p=="o" then m.board[to]="O" end
        m.lastMove={from=from,to=to};m.moveCount=(m.moveCount or 0)+1
        if chosen.capture and #CheckersCapturesFrom(m,to)>0 then m.forcedFrom=to else m.forcedFrom=nil;m.turnSide=(side==1 and 2 or 1) end
    end
    if not m._skipEndEval then EvaluateEnd(m) end
    return true
end

local function NewPlayer(name,display,game,theme,pieceSet,bot,diff)
    return {name=name,display=SafeText(display,42),theme=BG.THEMES[theme] and theme or (game=="chess" and "royal" or "ember"),pieceSet=BG.PIECE_SETS[pieceSet] and pieceSet or "classic",bot=bot and true or false,botDifficulty=diff}
end
local function NewMatch(id,game,p1,p2,host,mode)
    local m={id=id,game=game,players={[1]=p1,[2]=p2},host=host,mode=mode or "online",turnSide=1,moveCount=0,startedAt=Now(),finished=false,viewerSide=ParticipantSide({players={[1]=p1,[2]=p2}},PlayerName()) or 1}
    m.board=(game=="chess") and InitialChessBoard() or InitialCheckersBoard()
    if game=="chess" then m.castle={[1]={k=true,q=true},[2]={k=true,q=true}};m.enPassant=nil end
    return m
end

local function BoardString(m) return table.concat(m.board or {}) end
local function DecodeBoard(s)
    if type(s)~="string" or #s~=64 then return nil end
    local b={};for i=1,64 do b[i]=s:sub(i,i) end;return b
end
local function CastleString(m)
    if m.game~="chess" then return "-" end
    local c=m.castle or {};local out="";if c[1] and c[1].k then out=out.."K" end;if c[1] and c[1].q then out=out.."Q" end;if c[2] and c[2].k then out=out.."k" end;if c[2] and c[2].q then out=out.."q" end;return out~="" and out or "-"
end
local function ParseCastle(s)
    s=tostring(s or "-");return {[1]={k=s:find("K",1,true)~=nil,q=s:find("Q",1,true)~=nil},[2]={k=s:find("k",1,true)~=nil,q=s:find("q",1,true)~=nil}}
end

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(BG.PREFIX,msg,"WHISPER",target,true) end
    local ok,res=pcall(C_ChatInfo.SendAddonMessage,BG.PREFIX,msg,"WHISPER",target);return ok and (res==nil or res==true or tonumber(res)==0)
end
local function SendState(m,target,viewerSide)
    if not m or not target then return end
    local p1,p2=m.players[1],m.players[2]
    local msg=table.concat({"ST",m.id,m.game,tostring(m.turnSide or 1),BoardString(m),CastleString(m),tostring(m.enPassant or 0),tostring(m.forcedFrom or 0),tostring(m.finished and 1 or 0),tostring(m.winner or 0),Hex(m.finishReason or ""),tostring(viewerSide or ParticipantSide(m,target) or 0),p1.theme,p1.pieceSet,p2.theme,p2.pieceSet,tostring(m.moveCount or 0)},"|")
    SendRaw(target,msg)
end
local function BroadcastState(m)
    if not m or m.mode=="practice" then return end
    for side=1,2 do local p=m.players[side];if p and not NamesEqual(p.name,PlayerName()) then SendState(m,p.name,side) end end
end

local function MaterialScore(m,side)
    if m.game=="chess" then
        local score=0;for _,p in ipairs(m.board) do local s=SideOfChess(p);if s then local v=CHESS_VALUE[TypeOfChess(p)] or 0;score=score+(s==side and v or -v) end end;return score
    end
    local score=0;for _,p in ipairs(m.board) do local s=CheckersSide(p);if s then local v=CheckersKing(p) and 2.4 or 1;score=score+(s==side and v or -v) end end;return score
end
local function SimulateMove(m,side,mv)
    local sim=Copy(m);sim.finished=false;sim._simulation=true;sim._skipEndEval=true
    BG.ProcessMove(sim,side,mv.from,mv.to)
    return sim
end
local function ChooseBotMove(m,side,diff)
    local legal=BG.GetLegalMoves(m,side);if #legal==0 then return nil end
    if diff=="easy" then return legal[math.random(1,#legal)] end
    local best,bestScore
    for _,mv in ipairs(legal) do
        local sim=SimulateMove(m,side,mv);local score=MaterialScore(sim,side)
        local tr,tc=RowCol(mv.to);score=score+(3.5-math.abs(4.5-tr))*.025+(3.5-math.abs(4.5-tc))*.025
        if mv.capture then
            if m.game=="chess" then score=score+(CHESS_VALUE[TypeOfChess(mv.capture)] or 0)*.42 else score=score+.7 end
        end
        if m.game=="checkers" then
            local p=sim.board[mv.to];if CheckersKing(p) then score=score+.32 end
            if sim.forcedFrom==mv.to then score=score+.55 end
        elseif m.game=="chess" then
            if ChessInCheck(sim.board,side==1 and 2 or 1) then score=score+.28 end
            if mv.special=="castleK" or mv.special=="castleQ" then score=score+.35 end
        end
        if diff=="hard" and not sim.finished then
            local enemy=side==1 and 2 or 1;local replies=BG.GetLegalMoves(sim,enemy);local worst=0
            if #replies==0 then score=score+45 end
            for _,rv in ipairs(replies) do local after=SimulateMove(sim,enemy,rv);local delta=MaterialScore(after,side)-MaterialScore(sim,side);if delta<worst then worst=delta end end
            score=score+worst*.72
        end
        score=score+math.random()*0.015
        if not best or score>bestScore then best,bestScore=mv,score end
    end
    return best
end
local function ScheduleBot(m)
    if not m or m.finished then return end
    local p=m.players[m.turnSide];if not p or not p.bot then return end
    local function Step()
        if activeMatch~=m or m.finished or not m.players[m.turnSide] or not m.players[m.turnSide].bot then return end
        local side=m.turnSide;local bot=m.players[side];local mv=ChooseBotMove(m,side,bot.botDifficulty or "normal")
        if not mv then EvaluateEnd(m);if RPAL.RefreshBoardGameMatchUI then RPAL.RefreshBoardGameMatchUI(m,m.viewerSide) end;return end
        BG.ProcessMove(m,side,mv.from,mv.to)
        if RPAL.RefreshBoardGameMatchUI then RPAL.RefreshBoardGameMatchUI(m,m.viewerSide) end
        if not m.finished and m.turnSide==side and m.forcedFrom then
            if C_Timer and C_Timer.After then C_Timer.After(.34,Step) else Step() end
        end
    end
    if C_Timer and C_Timer.After then C_Timer.After((p.botDifficulty=="easy") and .72 or .48,Step) else Step() end
end

function BG.TryMove(from,to)
    local m=activeMatch;if not m then return false,"No active board game." end
    local side=ParticipantSide(m,PlayerName()) or m.viewerSide
    if not side or side==0 then return false,"Spectators cannot move pieces." end
    if m.players[side] and m.players[side].bot then return false,"That side belongs to the bot." end
    if not NamesEqual(m.host,PlayerName()) and m.mode~="practice" then
        SendRaw(m.host,table.concat({"MV",m.id,tostring(from),tostring(to)},"|"));return true
    end
    local ok,err=BG.ProcessMove(m,side,from,to)
    if ok then BroadcastState(m);if RPAL.RefreshBoardGameMatchUI then RPAL.RefreshBoardGameMatchUI(m,side) end;ScheduleBot(m) end
    return ok,err
end

function BG.StartPractice(game,diff)
    if not BG.GAMES[game] then return false,"Unknown board game." end
    if activeMatch and not activeMatch.finished then return false,"Finish or forfeit your current board game first." end
    local gs=BG.GetGameState(game);diff=tostring(diff or gs.difficulty or "normal"):lower();if not BG.PRACTICE_DIFFICULTIES[diff] then diff="normal" end;gs.difficulty=diff
    local p1=NewPlayer(PlayerName(),DisplayName(),game,gs.theme,gs.pieceSet,false)
    local botTheme=({easy="verdant",normal="royal",hard="arcane"})[diff];local botSet=({easy="classic",normal="obsidian",hard="arcane"})[diff]
    local botName=(game=="chess" and ({easy="Novice Strategist",normal="Table Tactician",hard="Grandmaster Construct"})[diff]) or ({easy="Tavern Regular",normal="Draughts Veteran",hard="Crowned Automaton"})[diff]
    local p2=NewPlayer("__ODYN_BOT__",botName,game,botTheme,botSet,true,diff)
    local m=NewMatch("BG"..tostring(Now())..tostring(math.random(100,999)),game,p1,p2,PlayerName(),"practice");activeMatch=m;matches[m.id]=m
    if RPAL.OpenBoardGameMatch then RPAL.OpenBoardGameMatch(m,1) end
    return true
end

function BG.ChallengeTarget(game)
    if not BG.GAMES[game] then return false,"Unknown board game." end
    if activeMatch and not activeMatch.finished then return false,"Finish or forfeit your current board game first." end
    local target=TargetName();if not target then return false,"Target another player first." end
    if NamesEqual(target,PlayerName()) then return false,"You cannot challenge yourself." end
    if RPAL.H and RPAL.H.SocialIsPresent and not RPAL.H.SocialIsPresent(target) then return false,"That player does not appear to have ODYN." end
    local gs=BG.GetGameState(game);local id="BG"..tostring(Now())..tostring(math.random(100,999))
    pendingChallenges[id]={id=id,outgoing=true,target=target,game=game,theme=gs.theme,pieceSet=gs.pieceSet,display=DisplayName()}
    local sent=SendRaw(target,table.concat({"CH",id,game,Hex(DisplayName()),gs.theme,gs.pieceSet},"|"))
    if not sent then pendingChallenges[id]=nil;return false,"Could not send the challenge." end
    return true,"Challenge sent to "..tostring(target).."."
end

function BG.AcceptChallenge(id)
    local ch=pendingChallenges[id];if not ch or ch.outgoing then return false,"Challenge expired." end
    if activeMatch and not activeMatch.finished then return false,"Finish or forfeit your current board game first." end
    local gs=BG.GetGameState(ch.game)
    local sent=SendRaw(ch.sender,table.concat({"OK",id,gs.theme,gs.pieceSet,Hex(DisplayName())},"|"))
    if not sent then return false,"Could not send the challenge acceptance." end
    ch.accepted=true;return true
end
function BG.DeclineChallenge(id)
    local ch=pendingChallenges[id];if not ch or ch.outgoing then return false end;SendRaw(ch.sender,"NO|"..id);pendingChallenges[id]=nil;return true
end

function BG.Forfeit()
    local m=activeMatch;if not m or m.finished then return false,"No active game." end
    local side=ParticipantSide(m,PlayerName()) or m.viewerSide;if not side or side==0 then return false,"Spectators cannot forfeit." end
    if NamesEqual(m.host,PlayerName()) or m.mode=="practice" then Finish(m,side==1 and 2 or 1,"Forfeit");BroadcastState(m);if RPAL.RefreshBoardGameMatchUI then RPAL.RefreshBoardGameMatchUI(m,side) end
    else SendRaw(m.host,"FF|"..m.id) end
    return true
end

function BG.GetPendingChallenge(id) return pendingChallenges[id] end

local function HandleMessage(msg,sender)
    local p=Split(msg);local kind=p[1]
    if kind=="CH" then
        local id,game=p[2],p[3];if not id or not BG.GAMES[game] then return end
        local theme=BG.THEMES[p[5]] and p[5] or (game=="chess" and "royal" or "ember");local pieces=BG.PIECE_SETS[p[6]] and p[6] or "classic"
        pendingChallenges[id]={id=id,sender=sender,game=game,display=Unhex(p[4]),theme=theme,pieceSet=pieces}
        if RPAL.ShowBoardGameChallenge then RPAL.ShowBoardGameChallenge(id,pendingChallenges[id]) end
    elseif kind=="NO" then
        local ch=pendingChallenges[p[2]];if ch and ch.outgoing then pendingChallenges[p[2]]=nil;if RPAL.BoardGameStatus then RPAL.BoardGameStatus("Challenge declined.") end end
    elseif kind=="OK" then
        local id=p[2];local ch=pendingChallenges[id];if not ch or not ch.outgoing or not NamesEqual(ch.target,sender) then return end
        local game=ch.game;local p1=NewPlayer(PlayerName(),ch.display,game,ch.theme,ch.pieceSet,false);local p2=NewPlayer(sender,Unhex(p[5]),game,p[3],p[4],false)
        local m=NewMatch(id,game,p1,p2,PlayerName(),"online");activeMatch=m;matches[id]=m;pendingChallenges[id]=nil;SendState(m,sender,2)
        if RPAL.OpenBoardGameMatch then RPAL.OpenBoardGameMatch(m,1) end
    elseif kind=="MV" then
        local id,from,to=p[2],tonumber(p[3]),tonumber(p[4]);local m=matches[id] or activeMatch
        if not m or tostring(m.id)~=tostring(id) or not NamesEqual(m.host,PlayerName()) then return end
        local side=ParticipantSide(m,sender);if not side then return end
        local ok,err=BG.ProcessMove(m,side,from,to);if ok then BroadcastState(m);if RPAL.RefreshBoardGameMatchUI then RPAL.RefreshBoardGameMatchUI(m,ParticipantSide(m,PlayerName()) or 1) end else SendRaw(sender,"ER|"..id.."|"..Hex(err or "Move rejected.")) end
    elseif kind=="FF" then
        local id=p[2];local m=matches[id] or activeMatch;if not m or tostring(m.id)~=tostring(id) or not NamesEqual(m.host,PlayerName()) then return end
        local side=ParticipantSide(m,sender);if side then Finish(m,side==1 and 2 or 1,"Forfeit");BroadcastState(m);if RPAL.RefreshBoardGameMatchUI then RPAL.RefreshBoardGameMatchUI(m,ParticipantSide(m,PlayerName()) or 1) end end
    elseif kind=="ER" then if RPAL.BoardGameStatus then RPAL.BoardGameStatus(Unhex(p[3])) end
    elseif kind=="ST" then
        local id,game=p[2],p[3];local board=DecodeBoard(p[5]);if not id or not BG.GAMES[game] or not board then return end
        local old=(activeMatch and tostring(activeMatch.id)==tostring(id)) and activeMatch or nil
        local m=old or {id=id,game=game,players={[1]={},[2]={}},host=sender,mode="online"}
        m.board=board;m.turnSide=tonumber(p[4]) or 1;m.castle=(game=="chess") and ParseCastle(p[6]) or nil;m.enPassant=(tonumber(p[7]) or 0);if m.enPassant==0 then m.enPassant=nil end;m.forcedFrom=(tonumber(p[8]) or 0);if m.forcedFrom==0 then m.forcedFrom=nil end
        m.finished=p[9]=="1";m.winner=tonumber(p[10]) or 0;m.finishReason=Unhex(p[11]);m.viewerSide=tonumber(p[12]) or 2;m.moveCount=tonumber(p[17]) or 0;m.host=sender
        local accepted=pendingChallenges[id]
        local hostDisplay=(old and old.players[1] and old.players[1].display) or (accepted and accepted.display) or sender
        local localDisplay=(old and old.players[2] and old.players[2].display) or DisplayName()
        m.players[1]={name=(old and old.players[1] and old.players[1].name) or sender,display=hostDisplay,theme=BG.THEMES[p[13]] and p[13] or "royal",pieceSet=BG.PIECE_SETS[p[14]] and p[14] or "classic"}
        m.players[2]={name=PlayerName(),display=localDisplay,theme=BG.THEMES[p[15]] and p[15] or "royal",pieceSet=BG.PIECE_SETS[p[16]] and p[16] or "classic"}
        pendingChallenges[id]=nil;activeMatch=m;matches[id]=m;if m.finished then PushHistory(m) end
        if RPAL.OpenBoardGameMatch then RPAL.OpenBoardGameMatch(m,m.viewerSide) 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,BG.PREFIX) end
    elseif event=="CHAT_MSG_ADDON" then local prefix,msg,channel,sender=...;if prefix==BG.PREFIX and channel=="WHISPER" and sender then HandleMessage(msg,sender) end end
end)

-- Compact slash shortcut for opening board games and practice matches.
SLASH_ODYNBOARDGAMES1="/odynboard"
SlashCmdList.ODYNBOARDGAMES=function(msg)
    local game,diff=tostring(msg or ""):lower():match("^(%S+)%s*(%S*)")
    if game=="chess" or game=="checkers" then if diff=="easy" or diff=="normal" or diff=="hard" then BG.StartPractice(game,diff) elseif RPAL.OpenBoardGame then RPAL.OpenBoardGame(game,"play") end
    elseif RPAL.OpenMiniGames then RPAL.OpenMiniGames() end
end
