--!name Blackjack
--!icon cards
--!category Games
-- Blackjack against the dealer from a six-deck shoe: dealer stands on soft
-- 17, blackjack pays 3:2, double on any two cards, split once. Chips carry
-- over between sessions. Cards are snail.board art tiles from
-- apps/blackjack.art (tools/make_cards_art.py).

local RANK = "A23456789TJQK"
local PIPL, PIPR = "shdc", "SHDC"
local RN = { "A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K" }
local BETS = { 10, 20, 50, 100, 200, 500, 1000, 2000, 5000 }
local DECKS, START = 6, 1000

local bank, bet = START, 50
local shoe, pos = "", 1
local phase = "bet" -- bet (between hands), play, reveal (dealer turning cards)
local dealer, hands, act = {}, {}, 1
local shown = 0 -- dealer cards face up while revealing
local banner, result, preBank
local msg
local screen, sel = "game", 1
local coach = true
local st = { hands = 0, won = 0, lost = 0, push = 0, bj = 0, big = 0, peak = START, refill = 0 }
local STK = { "hands", "won", "lost", "push", "bj", "big", "peak", "refill" }

local function rank(c) return c % 13 end
local function value(c) local r = c % 13 return r == 0 and 11 or math.min(r + 1, 10) end

local function total(cs)
  local t, soft = 0, 0
  for _, c in ipairs(cs) do
    t = t + value(c)
    if rank(c) == 0 then soft = soft + 1 end
  end
  while t > 21 and soft > 0 do t = t - 10; soft = soft - 1 end
  return t, soft > 0
end

local function natural(h) return #h.c == 2 and not h.split and total(h.c) == 21 end

local function fmt(n)
  local s = tostring(math.abs(n))
  while true do
    local k
    s, k = s:gsub("^(%d+)(%d%d%d)", "%1,%2")
    if k == 0 then break end
  end
  return (n < 0 and "-" or "") .. s
end

-- Shoe -------------------------------------------------------------------------

local function shuffle()
  local d = {}
  for k = 1, DECKS do
    for c = 0, 51 do d[#d + 1] = c end
  end
  for i = #d, 2, -1 do
    local j = math.random(i)
    d[i], d[j] = d[j], d[i]
  end
  for i = 1, #d do d[i] = string.char(65 + d[i]) end
  shoe, pos = table.concat(d), 1
end

local function draw1()
  if pos > #shoe then shuffle() end
  local c = shoe:byte(pos) - 65
  pos = pos + 1
  return c
end

local function left() return #shoe - pos + 1 end

-- Save -------------------------------------------------------------------------

local function cstr(t)
  local o = {}
  for i = 1, #t do o[i] = string.char(65 + t[i]) end
  return table.concat(o)
end

local function cards(s)
  local t = {}
  for i = 1, #s do t[i] = s:byte(i) - 65 end
  return t
end

local function save()
  local h = {}
  for i, x in ipairs(hands) do
    h[i] = x.bet .. "." .. cstr(x.c) .. "." .. (x.done and "d" or "") .. (x.dbl and "x" or "") .. (x.split and "s" or "")
  end
  local p = { "B1", bank, bet, coach and 1 or 0 }
  for _, k in ipairs(STK) do p[#p + 1] = st[k] end
  for _, v in ipairs({ phase == "play" and "play" or "bet", act, cstr(dealer), table.concat(h, "/"),
    banner or "", result or "", shoe:sub(pos) }) do p[#p + 1] = v end
  snail.save(table.concat(p, ";"))
end

local function load()
  local s = snail.load()
  if type(s) ~= "string" or s:sub(1, 3) ~= "B1;" then return false end
  local f = {}
  for v in (s .. ";"):gmatch("([^;]*);") do f[#f + 1] = v end
  if #f < 19 then return false end
  bank, bet = tonumber(f[2]) or START, tonumber(f[3]) or 50
  coach = f[4] == "1"
  for i, k in ipairs(STK) do st[k] = tonumber(f[4 + i]) or 0 end
  phase, act = f[13] == "play" and "play" or "bet", tonumber(f[14]) or 1
  dealer, hands = cards(f[15]), {}
  for x in f[16]:gmatch("[^/]+") do
    local b, c, fl = x:match("^(%d+)%.([^.]*)%.(%l*)$")
    if b then
      hands[#hands + 1] = { bet = tonumber(b), c = cards(c), done = fl:find("d") ~= nil,
        dbl = fl:find("x") ~= nil, split = fl:find("s") ~= nil }
    end
  end
  banner = f[17] ~= "" and f[17] or nil
  result = f[18] ~= "" and f[18] or nil
  shoe, pos = f[19], 1
  if phase == "play" and (#hands == 0 or #dealer < 2) then phase = "bet" end
  return #shoe > 0
end

-- Play -------------------------------------------------------------------------

local function betList()
  local t = {}
  for _, b in ipairs(BETS) do if b <= bank then t[#t + 1] = b end end
  return t
end

local function fitBet()
  local t = betList()
  if #t == 0 then return end
  local best = t[1]
  for _, b in ipairs(t) do if b <= bet then best = b end end
  bet = best
end

local function settle()
  local back, staked, w, l, p = 0, 0, 0, 0, 0
  local dt = total(dealer)
  local dbj = #dealer == 2 and dt == 21
  local lines = {}
  for i, h in ipairs(hands) do
    local t = total(h.c)
    staked = staked + h.bet
    local r
    if t > 21 then r = "bust"
    elseif natural(h) and not dbj then r = "bj"
    elseif dbj and not natural(h) then r = "lose"
    elseif natural(h) then r = "push"
    elseif dt > 21 or t > dt then r = "win"
    elseif t == dt then r = "push"
    else r = "lose" end
    if r == "bj" then back = back + h.bet + h.bet * 3 // 2; w = w + 1; st.bj = st.bj + 1
    elseif r == "win" then back = back + 2 * h.bet; w = w + 1
    elseif r == "push" then back = back + h.bet; p = p + 1
    else l = l + 1 end
    h.res = r
    lines[i] = r
  end
  preBank = bank
  bank = bank + back
  local net = back - staked
  st.hands = st.hands + #hands
  st.won, st.lost, st.push = st.won + w, st.lost + l, st.push + p
  if net > st.big then st.big = net end
  if bank > st.peak then st.peak = bank end
  local money = net > 0 and "+" .. fmt(net) or (net < 0 and fmt(net) or "even")
  if #hands > 1 then
    banner = "Won " .. w .. ", lost " .. l .. (p > 0 and ", push " .. p or "") .. ": " .. money
    result = "Dealer " .. (dt > 21 and "busts with " .. dt or "has " .. dt)
  else
    local r, t = lines[1], total(hands[1].c)
    if r == "bj" then banner, result = "Blackjack! " .. money, "Blackjack pays 3 to 2"
    elseif r == "bust" then banner, result = "Bust " .. money, "You went over with " .. t
    elseif r == "push" then banner, result = "Push", (dbj and "Both have blackjack" or "Both have " .. t)
    elseif r == "win" then
      banner = (dt > 21 and "Dealer busts! " or "You win ") .. money
      result = dt > 21 and "Dealer went over with " .. dt or "Your " .. t .. " beats " .. dt
    else
      banner = "Dealer wins " .. money
      result = dbj and "Dealer has blackjack" or "Dealer's " .. dt .. " beats your " .. t
    end
  end
  fitBet()
  save()
end

-- The player is done: the dealer draws (all at once, so the result is saved
-- straight away), then turns the cards over one by one on the tick.
local function dealerTurn()
  local live = false
  for _, h in ipairs(hands) do if total(h.c) <= 21 then live = true end end
  if live then
    while total(dealer) < 17 do dealer[#dealer + 1] = draw1() end
  end
  settle()
  shown = 2
  if #dealer > 2 then
    phase = "reveal"
    snail.tick(700)
  else
    phase = "bet"
  end
end

local function nextHand()
  while act <= #hands and hands[act].done do act = act + 1 end
  if act > #hands then dealerTurn() end
end

local function finishHand(h)
  h.done = true
  nextHand()
end

local function deal()
  if left() < 52 * DECKS // 4 then
    shuffle()
    msg = "A fresh shoe is shuffled"
  end
  fitBet()
  bank = bank - bet
  local a, b, c, d = draw1(), draw1(), draw1(), draw1()
  hands = { { bet = bet, c = { a, c } } }
  dealer = { b, d }
  act, banner, result = 1, nil, nil
  local up = value(dealer[1])
  if natural(hands[1]) or (up >= 10 and total(dealer) == 21) then
    hands[1].done = true
    preBank = bank
    settle()
    shown = 2
    phase = "bet"
    return
  end
  phase = "play"
  save()
end

local function canDouble(h)
  return #h.c == 2 and bank >= h.bet and not (h.split and rank(h.c[1]) == 0)
end

local function canSplit(h)
  return #hands == 1 and #h.c == 2 and value(h.c[1]) == value(h.c[2]) and bank >= h.bet
end

local function hit()
  local h = hands[act]
  h.c[#h.c + 1] = draw1()
  local t = total(h.c)
  if t >= 21 then finishHand(h) end
  save()
end

local function double()
  local h = hands[act]
  bank = bank - h.bet
  h.bet, h.dbl = h.bet * 2, true
  h.c[#h.c + 1] = draw1()
  finishHand(h)
  save()
end

local function split()
  local h = hands[1]
  bank = bank - h.bet
  local a, b = h.c[1], h.c[2]
  hands = { { bet = h.bet, c = { a, draw1() }, split = true }, { bet = h.bet, c = { b, draw1() }, split = true } }
  act = 1
  for _, x in ipairs(hands) do
    if rank(a) == 0 or total(x.c) == 21 then x.done = true end
  end
  nextHand()
  save()
end

-- Coach: basic strategy for six decks, dealer stands on soft 17, double after
-- split. One letter per dealer up card 2..10, A: H hit, S stand, D double (or
-- hit), d double (or stand), P split.
local HARD = { [9] = "HDDDDHHHHH", [10] = "DDDDDDDDHH", [11] = "DDDDDDDDDH", [12] = "HHSSSHHHHH" }
local SOFT = { [13] = "HHHDDHHHHH", [14] = "HHHDDHHHHH", [15] = "HHDDDHHHHH", [16] = "HHDDDHHHHH",
  [17] = "HDDDDHHHHH", [18] = "SddddSSHHH" }
local PAIR = { [2] = "PPPPPPHHHH", [3] = "PPPPPPHHHH", [4] = "HHHPPHHHHH", [6] = "PPPPPHHHHH",
  [7] = "PPPPPPHHHH", [8] = "PPPPPPPPPP", [9] = "PPPPPSPPSS", [11] = "PPPPPPPPPP" }

local function advice(h)
  local up = value(dealer[1])
  local i = up == 11 and 10 or up - 1
  local t, soft = total(h.c)
  local a
  if canSplit(h) and PAIR[value(h.c[1])] then a = PAIR[value(h.c[1])]:sub(i, i) end
  if not a or a ~= "P" then
    local row
    if soft then row = SOFT[t] or (t >= 19 and "S" or "H")
    elseif t >= 17 then row = "S"
    elseif t >= 13 then row = "SSSSSHHHHH"
    else row = HARD[t] or "H" end
    a = #row == 1 and row or row:sub(i, i)
  end
  if a == "D" then a = canDouble(h) and "D" or "H"
  elseif a == "d" then a = canDouble(h) and "D" or "S" end
  return ({ H = "hit", S = "stand", D = "double", P = "split" })[a]
end

-- Keys -------------------------------------------------------------------------

local function endReveal()
  shown = #dealer
  phase = "bet"
  snail.tick(0)
end

local MENU = { "Back to the table", "Coach", "Statistics", "How to play", "Start over with 1,000" }

function key(k)
  if screen == "menu" then
    if k == "up" then sel = (sel - 2) % #MENU + 1
    elseif k == "down" then sel = sel % #MENU + 1
    elseif k == "ok" then
      if sel == 1 then screen = "game"
      elseif sel == 2 then coach = not coach; save()
      elseif sel == 3 then screen = "stats"
      elseif sel == 4 then screen = "help"
      elseif phase ~= "play" then
        bank, bet, banner, result, hands, dealer = START, 50, nil, nil, {}, {}
        st.refill = st.refill + 1
        msg = "Fresh stack of 1,000 chips"
        save()
        screen = "game"
      end
    end
    return
  end
  if screen ~= "game" then
    if k == "top" or k == "ok" then screen = "menu" end
    return
  end
  msg = nil
  if phase == "reveal" then
    if k == "ok" or k == "top" then endReveal() end
    if k == "top" then screen, sel = "menu", 1 end
    return
  end
  if k == "top" then screen, sel = "menu", 1; return end
  if phase == "bet" then
    if bank < BETS[1] then
      if k == "ok" then
        bank = START
        st.refill = st.refill + 1
        bet = 50
        msg = "Here are 1,000 fresh chips"
        save()
      end
      return
    end
    local t = betList()
    local i = 1
    for j, b in ipairs(t) do if b == bet then i = j end end
    if k == "left" and i > 1 then bet = t[i - 1]; save()
    elseif k == "right" and i < #t then bet = t[i + 1]; save()
    elseif k == "ok" then deal() end
    return
  end
  local h = hands[act]
  if k == "left" then hit()
  elseif k == "right" then finishHand(h); save()
  elseif k == "ok" and canDouble(h) then double()
  elseif k == "down" and canSplit(h) then split() end
end

function tick()
  if phase ~= "reveal" then snail.tick(0); return end
  shown = shown + 1
  if shown >= #dealer then endReveal() end
end

-- Drawing ----------------------------------------------------------------------

local G, NR

local function put(r, c, ch)
  local row = G[r]
  if not row then row = {}; G[r] = row end
  row[c] = ch
  if r > NR then NR = r end
end

local function card(r, x, c, hidden, narrow)
  if hidden then
    put(r, x, "b"); put(r + 1, x, "f"); put(r + 2, x, "i")
    if not narrow then put(r, x + 1, "e"); put(r + 1, x + 1, "g"); put(r + 2, x + 1, "j") end
    return
  end
  local a, s = rank(c) + 1, c // 13 + 1
  put(r, x, RANK:sub(a, a)); put(r + 1, x, PIPL:sub(s, s)); put(r + 2, x, "u")
  if not narrow then put(r, x + 1, "+"); put(r + 1, x + 1, PIPR:sub(s, s)); put(r + 2, x + 1, "v") end
end

-- Lays a hand out in w columns from x0: side by side when it fits, else fanned.
local function handAt(r, x0, w, cs, hideFrom)
  local n = #cs
  if 2 * n <= w then
    local x = x0 + (w - 2 * n) // 2
    for j = 1, n do card(r, x + 2 * (j - 1), cs[j], j >= hideFrom) end
    return x, x + 2 * n - 1
  end
  local first = math.max(1, n - w + 2)
  local m = n - first + 1
  local x = x0 + (w - m - 1) // 2
  for j = first, n do card(r, x + j - first, cs[j], j >= hideFrom, j < n) end
  return x, x + m
end

local function spec(W)
  local out = {}
  for r = 1, NR do
    local row, o = G[r] or {}, {}
    local last = 0
    for c = 1, W do if row[c] then last = c end end
    for c = 1, last do o[c] = row[c] or " " end
    out[r] = table.concat(o)
  end
  return table.concat(out, "/")
end

-- One hand: an 8-cell grid, so the 32 px tiles draw twice as big. A split
-- needs room for two hands side by side: 16 cells at tile size.
local function board()
  G, NR = {}, 0
  local W = #hands > 1 and 16 or 8
  local pr = W == 8 and 5 or 7
  put(4, W, " ")
  if #dealer == 0 then
    handAt(1, 1, W, { 0, 0 }, 1)
    handAt(pr, 1, W, { 0, 0 }, 1)
    return spec(W)
  end
  local hide = phase == "play" and 2 or (phase == "reveal" and shown + 1 or 99)
  local dc = dealer
  if phase == "reveal" then
    dc = {}
    for i = 1, shown do dc[i] = dealer[i] end
  end
  handAt(1, 1, W, dc, hide)
  if #hands == 1 then
    handAt(pr, 1, W, hands[1].c, 99)
  else
    for i, h in ipairs(hands) do
      local a, b = handAt(7, 1 + 8 * (i - 1), 8, h.c, 99)
      if phase == "play" and i == act then
        for x = a, b do put(10, x, "^") end
      end
    end
  end
  return spec(W)
end

local function handLabel(h)
  local t, soft = total(h.c)
  if natural(h) then return "blackjack" end
  if t > 21 then return t .. ", bust" end
  return (soft and t < 21 and "soft " or "") .. t
end

local function pct(n) return st.hands > 0 and (n * 100 // st.hands) .. "%" or "-" end

function draw()
  if screen == "menu" then
    snail.title("Blackjack")
    for i, m in ipairs(MENU) do
      if i == 2 then m = "Coach tips: " .. (coach and "on" or "off") end
      if i == 5 and phase == "play" then m = m .. " (after this hand)" end
      snail.row(m, i == sel)
    end
    snail.gap()
    snail.small("Bank " .. fmt(bank) .. ". Best ever " .. fmt(st.peak) .. ".")
    snail.hint("BACK leave OK choose UP/DN move")
    return
  end
  if screen == "stats" then
    snail.title("Statistics")
    snail.text("Hands played: " .. st.hands)
    snail.text("Won " .. st.won .. " (" .. pct(st.won) .. "), lost " .. st.lost .. " (" .. pct(st.lost) .. "), pushed " .. st.push)
    snail.text("Blackjacks: " .. st.bj)
    snail.gap()
    snail.text("Biggest win: " .. fmt(st.big))
    snail.text("Best bank: " .. fmt(st.peak))
    snail.text("Fresh stacks taken: " .. st.refill)
    snail.hint("BACK back OK back")
    return
  end
  if screen == "help" then
    snail.title("How to play")
    snail.text("Get closer to 21 than the dealer without going over. Aces count 1 or 11, faces 10.")
    snail.text("Choose a bet with L/R and deal with OK. L hits, R stands.")
    snail.text("OK doubles: twice the bet, one more card. DN splits a pair into two hands.")
    snail.text("Blackjack pays 3 to 2. The dealer draws to 16 and stands on all 17s.")
    snail.text("Six decks, shuffled when a quarter is left. Run out of chips and you get 1,000 more.")
    snail.hint("BACK back OK back")
    return
  end
  local shownBank = phase == "reveal" and preBank or bank
  snail.title("Blackjack - bank " .. fmt(shownBank))
  local status
  if msg then status = msg
  elseif phase == "play" then
    local h = hands[act]
    local can = { "hit", "stand" }
    if canDouble(h) then can[#can + 1] = "double" end
    if canSplit(h) then can[#can + 1] = "split" end
    status = (#hands > 1 and "Hand " .. act .. " of 2: " or "Your turn: ")
      .. table.concat(can, ", ", 1, #can - 1) .. " or " .. can[#can]
  elseif phase == "reveal" then status = "Dealer's turn..."
  elseif bank < BETS[1] then status = "Out of chips! OK for 1,000 more"
  elseif result then status = result
  else status = "Place your bet" end
  snail.status(status)
  if #dealer == 0 then snail.small("Dealer")
  elseif phase == "play" then snail.small("Dealer shows " .. RN[rank(dealer[1]) + 1])
  else
    local dc = dealer
    if phase == "reveal" then dc = { table.unpack(dealer, 1, shown) } end
    snail.small("Dealer: " .. handLabel({ c = dc }))
  end
  snail.board(board(), nil, phase == "bet" and banner or nil)
  if #hands == 1 then snail.small("You: " .. handLabel(hands[1]) .. (hands[1].dbl and " (doubled)" or ""))
  elseif #hands == 2 then
    local p = {}
    for i, h in ipairs(hands) do p[i] = "Hand " .. i .. ": " .. handLabel(h) end
    snail.small(table.concat(p, "    "))
  else snail.small("You") end
  snail.gap()
  local stake = 0
  for _, x in ipairs(hands) do stake = stake + x.bet end
  if phase == "play" then
    local h = hands[act]
    snail.text("Bet " .. fmt(stake))
    if coach then snail.small("Coach: " .. advice(h)) end
    local s = "BACK options"
    if canDouble(h) then s = s .. " OK double" end
    s = s .. " L hit R stand"
    if canSplit(h) then s = s .. " DN split" end
    snail.hint(s)
  elseif phase == "reveal" then
    snail.text("Bet " .. fmt(stake))
    snail.hint("BACK options OK skip")
  elseif bank < BETS[1] then
    snail.text("Bank empty")
    snail.hint("BACK options OK refill")
  else
    snail.text("Bet  < " .. fmt(bet) .. " >")
    snail.small("Shoe: " .. left() .. " of " .. 52 * DECKS .. " cards left")
    snail.hint("BACK options OK deal L/R bet")
  end
end

function start()
  snail.ink("fast")
  snail.tick(0)
  math.randomseed(os.time())
  if not load() then shuffle() end
  fitBet()
  save()
end
