--!name Spider Solitaire
--!icon cards
--!category Games
-- Spider patience with two decks: one, two or four suits. Cards are
-- snail.board art tiles from apps/spider.art (tools/make_cards_art.py); a card
-- is two cells wide, so the ten columns fill the twenty-cell grid.

local RANK = "A23456789TJQK"
local SUIT, HELD = "shdc", "SHDC"
local RN = { "A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K" }
local MAXE = 20 -- card strips a column may show (23 rows with the body and bar)
local MAXUNDO = 40

local function rank(c) return c % 13 end
local function suit(c) return c // 13 end

local tab, down, stock, done
local moves, suits, counted = 0, 1, false
local cur, hold, holdN = 1, nil, 1
local undo = {}
local msg, holdMsg
local screen, sel = "game", 1
local st = { 0, 0, 0, 0, 0, 0, 0, 0, 0 } -- played, won, best score for 1, 2, 4 suits
local pref = 1
local seedN = 0

local function lvl(n) return n == 1 and 0 or (n == 2 and 3 or 6) end
local function score() return 500 - moves + 100 * #done end
local function won() return #done == 8 end

-- State <-> string -----------------------------------------------------------

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, a)
  local t = {}
  for i = a or 1, #s do t[#t + 1] = s:byte(i) - 65 end
  return t
end

local function enc()
  local p = { suits, moves, cstr(stock), cstr(done) }
  for i = 1, 10 do p[#p + 1] = down[i] .. cstr(tab[i]) end
  return table.concat(p, ",")
end

local function dec(s)
  local f = {}
  for v in (s .. ","):gmatch("([^,]*),") do f[#f + 1] = v end
  if #f < 14 then return false end
  local su, m = tonumber(f[1]), tonumber(f[2])
  local sk, dn = cards(f[3]), cards(f[4])
  local n = #sk + 13 * #dn
  local tb, dw = {}, {}
  for i = 1, 10 do
    dw[i] = tonumber(f[4 + i]:sub(1, 1)) or 0
    tb[i] = cards(f[4 + i], 2)
    n = n + #tb[i]
  end
  if n ~= 104 or not su or not m then return false end
  suits, moves, stock, done, tab, down = su, m, sk, dn, tb, dw
  return true
end

local function save()
  local head = { "P1" }
  for i = 1, 9 do head[#head + 1] = st[i] end
  for _, v in ipairs({ pref, counted and 1 or 0, seedN, enc() }) do head[#head + 1] = v end
  head = table.concat(head, ";")
  local parts, len = {}, #head + 1
  for i = #undo, 1, -1 do
    local s = undo[i]
    if len + #s + 1 > 1023 then break end
    table.insert(parts, 1, s)
    len = len + #s + 1
  end
  snail.save(head .. ";" .. table.concat(parts, "|"))
end

local function load()
  local s = snail.load()
  if type(s) ~= "string" or s:sub(1, 3) ~= "P1;" then return end
  local f = {}
  for v in (s .. ";"):gmatch("([^;]*);") do f[#f + 1] = v end
  for i = 1, 9 do st[i] = tonumber(f[i + 1]) or 0 end
  pref = tonumber(f[11]) or 1
  if pref ~= 2 and pref ~= 4 then pref = 1 end
  counted = f[12] == "1"
  seedN = tonumber(f[13]) or 0
  if f[14] and dec(f[14]) then
    for u in (f[15] or ""):gmatch("[^|]+") do undo[#undo + 1] = u end
  else
    stock = nil
  end
end

-- Dealing ----------------------------------------------------------------------

local function deal()
  local d = {}
  for k = 0, 7 do
    local s = ({ 0, 0, 0, 0, 0, 0, 0, 0 })[k + 1]
    if suits == 2 then s = k % 2 elseif suits == 4 then s = k % 4 end
    for r = 0, 12 do d[#d + 1] = s * 13 + r end
  end
  for i = #d, 2, -1 do
    local j = math.random(i)
    d[i], d[j] = d[j], d[i]
  end
  tab, down, done = {}, {}, {}
  local k = 1
  for i = 1, 10 do
    local n = i <= 4 and 6 or 5
    tab[i] = {}
    for j = 1, n do tab[i][j] = d[k]; k = k + 1 end
    down[i] = n - 1
  end
  stock = {}
  for i = k, 104 do stock[#stock + 1] = d[i] end
  moves, counted, hold, cur, undo = 0, false, nil, 1, {}
  msg = "New game, " .. suits .. (suits == 1 and " suit" or " suits") .. ". Good luck!"
end

local function newGame()
  suits = pref
  deal()
  save()
end

-- Rules ------------------------------------------------------------------------

local function flip(i)
  if down[i] >= #tab[i] and down[i] > 0 then down[i] = #tab[i] - 1 end
end

-- How many cards on top of column i run down in one suit.
local function runLen(i)
  local t, d = tab[i], down[i]
  local n = #t
  if n == 0 then return 0 end
  local k = 1
  while n - k > d do
    local a, b = t[n - k], t[n - k + 1]
    if suit(a) ~= suit(b) or rank(a) ~= rank(b) + 1 then break end
    k = k + 1
  end
  return k
end

local function fits(i, n, j)
  if j == i then return false end
  local t, u = tab[i], tab[j]
  if #u == 0 then return true end
  return rank(u[#u]) == rank(t[#t - n + 1]) + 1
end

-- How good moving n cards from i to j is (nil if it isn't legal).
local function worth(i, n, j)
  if not fits(i, n, j) then return nil end
  local t, u = tab[i], tab[j]
  local c = t[#t - n + 1]
  local below = t[#t - n]
  local whole = n == runLen(i)
  if #u == 0 then
    if not whole or #t == n then return nil end
    return 5
  end
  local s = suit(u[#u]) == suit(c) and 40 or 20
  if whole then s = s + 10 elseif s < 40 then s = s - 15 end
  if below and #t - n > down[i] and rank(below) == rank(c) + 1 and s < 40 then s = 1 end
  return s + n
end

local function bestFor(i, n)
  local bj, bs
  for k = 1, 9 do
    local j = (i - 1 + k) % 10 + 1
    local s = worth(i, n, j)
    if s and (not bs or s > bs) then bj, bs = j, s end
  end
  return bj, bs
end

local function best(i)
  local bj, bn, bs
  for n = 1, runLen(i) do
    local j, s = bestFor(i, n)
    if j and (not bs or s > bs) then bj, bn, bs = j, n, s end
  end
  return bj, bn, bs
end

local function pushUndo()
  undo[#undo + 1] = enc()
  if #undo > MAXUNDO then table.remove(undo, 1) end
end

local function countMove()
  moves = moves + 1
  if not counted then
    counted = true
    st[lvl(suits) + 1] = st[lvl(suits) + 1] + 1
  end
end

-- Takes a finished King-to-Ace run off column i.
local function collect(i)
  if runLen(i) < 13 then return false end
  local t = tab[i]
  done[#done + 1] = suit(t[#t])
  for _ = 1, 13 do t[#t] = nil end
  flip(i)
  return true
end

local function afterMove(cols)
  local got = 0
  for _, i in ipairs(cols) do if collect(i) then got = got + 1 end end
  if got > 0 then msg = got > 1 and "Two runs complete!" or "Run complete! " .. #done .. " of 8" end
  if won() then
    local b = lvl(suits)
    st[b + 2] = st[b + 2] + 1
    if score() > st[b + 3] then st[b + 3] = score() end
    hold = nil
  end
  save()
end

local function doMove(i, j, n)
  pushUndo()
  local t, u = tab[i], tab[j]
  local c = t[#t - n + 1]
  for k = #t - n + 1, #t do u[#u + 1] = t[k] end
  for _ = 1, n do t[#t] = nil end
  flip(i)
  countMove()
  msg = (n == 1 and RN[rank(c) + 1] or n .. " cards") .. " to column " .. j
  hold = nil
  cur = #t > 0 and i or j
  afterMove({ j })
end

local function dealRow()
  hold = nil
  if #stock == 0 then msg = "No deals left"; return end
  for i = 1, 10 do
    if #tab[i] == 0 then msg = "Fill every column before dealing"; return end
  end
  pushUndo()
  for i = 1, 10 do tab[i][#tab[i] + 1] = table.remove(stock) end
  countMove()
  local left = #stock // 10
  msg = "Dealt a row, " .. (left == 0 and "no" or left) .. (left == 1 and " deal" or " deals") .. " left"
  afterMove({ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 })
end

local function doUndo()
  hold = nil
  if #undo == 0 then msg = "Nothing to undo"; return end
  dec(table.remove(undo))
  msg = "Move undone"
  if #tab[cur] == 0 then cur = 1 end
  save()
end

local function hint()
  local bi, bj, bs
  for i = 1, 10 do
    local j, _, s = best(i)
    if j and #tab[j] > 0 and s > 1 and (not bs or s > bs) then bi, bj, bs = i, j, s end
  end
  if bi then
    cur = bi
    return "Try column " .. bi .. " to column " .. bj
  end
  return #stock > 0 and "Nothing obvious: deal a row" or "No moves left: try undo"
end

-- Cursor -----------------------------------------------------------------------

local function holdText()
  local t = tab[hold]
  local c = t[#t - holdN + 1]
  local what = holdN == 1 and RN[rank(c) + 1] or holdN .. " cards"
  if cur == hold then return what .. ": no place, UP/DN more/fewer" end
  return "Moving " .. what .. ": OK places"
end

local function setN(n)
  holdN = n
  if cur == hold or not fits(hold, n, cur) then cur = bestFor(hold, n) or hold end
  holdMsg = holdText()
end

local function step(d)
  if hold then
    for _ = 1, 10 do
      cur = (cur - 1 + d) % 10 + 1
      if cur == hold or fits(hold, holdN, cur) then break end
    end
    holdMsg = holdText()
    return
  end
  for _ = 1, 10 do
    cur = (cur - 1 + d) % 10 + 1
    if #tab[cur] > 0 then return end
  end
end

local function pick()
  if #tab[cur] == 0 then msg = "Empty column"; return end
  local j, n = best(cur)
  hold = cur
  holdN = n or runLen(cur)
  cur = j or bestFor(hold, holdN) or hold
  holdMsg = holdText()
end

-- Menu -------------------------------------------------------------------------

local function sname(n) return n .. (n == 1 and " suit" or " suits") end

local function menuItems()
  local t = { { "Back to the game", "resume" } }
  if not won() then
    t[#t + 1] = { #undo > 0 and "Undo last move" or "Undo (nothing to undo)", "undo" }
    t[#t + 1] = { "Hint", "hint" }
  end
  t[#t + 1] = { "New game", "new" }
  local d = "Difficulty: " .. sname(pref)
  if pref ~= suits then d = d .. " (next game)" end
  t[#t + 1] = { d, "suits" }
  t[#t + 1] = { "Statistics", "stats" }
  t[#t + 1] = { "How to play", "help" }
  return t
end

function key(k)
  if screen == "menu" then
    local items = menuItems()
    if k == "up" then sel = (sel - 2) % #items + 1
    elseif k == "down" then sel = sel % #items + 1
    elseif k == "ok" then
      local a = items[sel][2]
      if a == "resume" then screen = "game"
      elseif a == "undo" then doUndo(); screen = "game"
      elseif a == "hint" then msg = hint(); screen = "game"
      elseif a == "new" then newGame(); screen = "game"
      elseif a == "suits" then
        pref = pref == 1 and 2 or (pref == 2 and 4 or 1)
        if moves == 0 then newGame() else save() end
      else screen = a end
    end
    return
  end
  if screen ~= "game" then
    if k == "top" or k == "ok" then screen = "menu" end
    return
  end
  msg = nil
  if won() then
    if k == "ok" then newGame()
    elseif k == "top" then screen, sel = "menu", 1 end
    return
  end
  if hold then
    if k == "top" or k == "ok" and cur == hold then cur, hold = hold, nil; msg = "Put back"
    elseif k == "left" then step(-1)
    elseif k == "right" then step(1)
    elseif k == "up" then setN(math.min(holdN + 1, runLen(hold)))
    elseif k == "down" then setN(math.max(holdN - 1, 1))
    elseif k == "ok" then doMove(hold, cur, holdN) end
    return
  end
  if k == "top" then screen, sel = "menu", 1
  elseif k == "left" then step(-1)
  elseif k == "right" then step(1)
  elseif k == "up" then doUndo()
  elseif k == "down" then dealRow()
  elseif k == "ok" then pick() 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 strip(r, c, card, held)
  local a, s = rank(card) + 1, suit(card) + 1
  put(r, c, RANK:sub(a, a))
  put(r, c + 1, (held and HELD or SUIT):sub(s, s))
end

local function full(r, c, card, held)
  strip(r, c, card, held)
  put(r + 1, c, "m"); put(r + 1, c + 1, "n")
  put(r + 2, c, "u"); put(r + 2, c + 1, "v")
end

local function spec()
  local out = {}
  for r = 1, NR do
    local row, o = G[r] or {}, {}
    local last = 0
    for c = 1, 20 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

local function board()
  G, NR = {}, 0
  for i = 1, 10 do
    local x, t, d = 2 * i - 1, tab[i], down[i]
    local n = #t
    local mr
    if n == 0 then
      put(1, x, "x"); put(1, x + 1, "y"); put(2, x, "w"); put(2, x + 1, "z")
      mr = 3
    else
      local up = n - d
      local ds, hidden = d, 0
      if d + up > MAXE then
        ds = d > 0 and math.max(1, MAXE - up) or 0
        if ds + up > MAXE then hidden = ds + up - MAXE + 1 end
      end
      local r = 1
      for _ = 1, ds do put(r, x, "b"); put(r, x + 1, "e"); r = r + 1 end
      if hidden > 0 then put(r, x, "("); put(r, x + 1, ")"); r = r + 1 end
      local h = hold == i and n - holdN or n
      for j = d + 1 + hidden, n - 1 do strip(r, x, t[j], j > h); r = r + 1 end
      full(r, x, t[n], n > h)
      mr = r + 3
    end
    if cur == i then put(mr, x, "^"); put(mr, x + 1, "^") end
  end
  put(1, 20, G[1][20] or " ")
  return spec()
end

local function winBoard()
  G, NR = {}, 0
  for i = 1, 8 do
    local x, s = 2 * i + 1, done[i]
    for r = 12, 1, -1 do strip(13 - r, x, s * 13 + r) end
    full(13, x, s * 13)
  end
  put(1, 20, " ")
  return spec()
end

local function pct(w, p) return p > 0 and (w * 100 // p) .. "%" or "-" end

function draw()
  if screen == "menu" then
    snail.title("Spider Solitaire")
    local items = menuItems()
    if sel > #items then sel = #items end
    for i, it in ipairs(items) do snail.row(it[1], i == sel) end
    snail.gap()
    local b = lvl(suits)
    snail.small(sname(suits) .. ": won " .. st[b + 2] .. " of " .. st[b + 1] .. ", best score " .. st[b + 3])
    snail.hint("BACK leave OK choose UP/DN move")
    return
  end
  if screen == "stats" then
    snail.title("Statistics")
    for _, n in ipairs({ 1, 2, 4 }) do
      local b = lvl(n)
      snail.text(sname(n) .. ": won " .. st[b + 2] .. " of " .. st[b + 1] .. " (" .. pct(st[b + 2], st[b + 1]) .. ")")
      snail.small("Best score " .. (st[b + 3] > 0 and st[b + 3] or "-"))
      snail.gap()
    end
    snail.hint("BACK back OK back")
    return
  end
  if screen == "help" then
    snail.title("How to play")
    snail.text("Clear all eight runs: King down to Ace in one suit. A full run leaves the table.")
    snail.text("Put a card on any card one rank higher. Only a run of one suit moves as a group.")
    snail.text("OK picks up the cards; the bar jumps to the best place. OK puts them there.")
    snail.text("While holding, L/R picks another column and UP/DN takes more or fewer cards.")
    snail.text("DN deals a card to every column (none may be empty). UP undoes a move.")
    snail.text("Score: 500, minus one a move, plus 100 a run.")
    snail.hint("BACK back OK back")
    return
  end
  local deals = #stock // 10
  snail.title("Spider - " .. deals .. (deals == 1 and " deal" or " deals") .. " - " .. #done .. "/8 runs")
  if won() then
    snail.status("You win! Score " .. score() .. " in " .. moves .. " moves")
    snail.board(winBoard(), nil, "All eight runs!")
    local b = lvl(suits)
    snail.text(sname(suits) .. ": won " .. st[b + 2] .. " of " .. st[b + 1] .. ". Best score " .. st[b + 3] .. ".")
    snail.hint("BACK options OK new game")
    return
  end
  snail.status(msg or hold and holdMsg or ("Score " .. score() .. ", moves " .. moves .. ", " .. sname(suits)))
  snail.board(board())
  if hold then snail.hint("BACK cancel OK place L/R move UP more DN fewer")
  else snail.hint("BACK options OK select L/R move UP undo DN deal") end
end

function start()
  snail.ink("fast")
  load()
  math.randomseed(os.time() + seedN * 7919)
  seedN = seedN + 1
  if not stock then
    suits = pref
    deal()
  end
  if #tab[cur] == 0 then step(1) end
  save()
end
