--!name Pyramid Solitaire
--!icon cards
--!category Games
-- Pyramid patience: clear a pyramid of 28 cards by removing two uncovered
-- cards that add up to 13 (Jack 11, Queen 12, a King on its own). The top of
-- the waste pairs too, and the stock can be gone through three times.
-- Cards are snail.board art tiles from apps/pyramid.art
-- (tools/make_peaks_art.py): a card is two cells wide and three tall, and each
-- pyramid row sits two cells lower and one cell to the side of the row above.

local RANK = "A23456789TJQK"
local SUIT = "shdc"
local RN = { "A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K" }
local VN = { "an Ace", "a 2", "a 3", "a 4", "a 5", "a 6", "a 7", "an 8", "a 9", "a 10", "a Jack", "a Queen", "a King" }
local SN = { "spades", "hearts", "diamonds", "clubs" }
local PASSES = 3
local MAXUNDO = 60
local WASTE, STOCK = 29, 30

local function val(c) return c % 13 + 1 end
local function cname(c) return RN[c % 13 + 1] .. " of " .. SN[c // 13 + 1] end

-- Pyramid position p = r * (r - 1) / 2 + i is card i of row r (1..7); the two
-- cards lying across its bottom are p + r and p + r + 1.
local ROW, COL = {}, {}
for r = 1, 7 do
  for i = 1, r do
    local p = r * (r - 1) // 2 + i
    ROW[p], COL[p] = r, 9 - r + 2 * i
  end
end

local pyr, gone, stock, waste
local pass, moves, counted = 1, 0, false
local cur, hold = STOCK, nil
local undo = {}
local msg, holdMsg
local screen, sel = "game", 1
local st = { p = 0, w = 0, streak = 0, best = 0, fewest = 0 }
local seedN = 0

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

-- What changes in a game; the pyramid's cards themselves are fixed by the deal.
local function dyn()
  local m = {}
  for k = 0, 6 do
    local b = 0
    for j = 1, 4 do if gone[4 * k + j] then b = b + (1 << (j - 1)) end end
    m[k + 1] = string.char(65 + b)
  end
  return table.concat({ pass, moves, table.concat(m), cstr(stock), cstr(waste) }, ",")
end

local function dec(s)
  local f = {}
  for v in (s .. ","):gmatch("([^,]*),") do f[#f + 1] = v end
  local p, m = tonumber(f[1]), tonumber(f[2])
  if #f ~= 5 or #f[3] ~= 7 or not p or not m then return false end
  local g = {}
  for k = 0, 6 do
    local b = f[3]:byte(k + 1) - 65
    for j = 1, 4 do g[4 * k + j] = b & (1 << (j - 1)) ~= 0 end
  end
  pass, moves, gone, stock, waste = p, m, g, cards(f[4]), cards(f[5])
  return true
end

local function save()
  local head = table.concat({ "P1", st.p, st.w, st.streak, st.best, st.fewest,
    counted and 1 or 0, seedN, cstr(pyr), dyn() }, ";")
  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
  local function n(i) return tonumber(f[i]) or 0 end
  st.p, st.w, st.streak, st.best, st.fewest = n(2), n(3), n(4), n(5), n(6)
  counted = n(7) == 1
  seedN = n(8)
  if f[9] and #f[9] == 28 and f[10] and dec(f[10]) then
    pyr = cards(f[9])
    for u in (f[11] or ""):gmatch("[^|]+") do undo[#undo + 1] = u end
  end
end

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

local function exposed(p)
  if gone[p] then return false end
  local r = ROW[p]
  return r == 7 or gone[p + r] and gone[p + r + 1]
end

local function cardAt(q)
  if q == WASTE then return waste[#waste] end
  if q <= 28 and exposed(q) then return pyr[q] end
end

-- Every card that can be removed now: uncovered pyramid cards and the waste top.
local function avail()
  local t = {}
  for p = 1, 28 do if exposed(p) then t[#t + 1] = p end end
  if #waste > 0 then t[#t + 1] = WASTE end
  return t
end

local function partners(q)
  local v, t = val(cardAt(q)), {}
  for _, p in ipairs(avail()) do
    if p ~= q and val(cardAt(p)) + v == 13 then t[#t + 1] = p end
  end
  return t
end

local function playable(q)
  local c = cardAt(q)
  return c and (val(c) == 13 or #partners(q) > 0)
end

local function cleared()
  for p = 1, 28 do if not gone[p] then return false end end
  return true
end

local function canDraw() return #stock > 0 or #waste > 0 and pass < PASSES end

local function stuck()
  if canDraw() then return false end
  for _, q in ipairs(avail()) do if playable(q) then return false end end
  return true
end

local function left()
  local n = 0
  for p = 1, 28 do if not gone[p] then n = n + 1 end end
  return n
end

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

-- Left-to-right order on the screen: column, then row.
local function px(q)
  if q == STOCK then return 100 elseif q == WASTE then return 400 end
  return COL[q] * 100 + ROW[q]
end

local function byX(a, b) return px(a) < px(b) end

-- Where the bar can stop: the stock (while it can deal) and every card that
-- can be removed.
local function stops()
  local t = { canDraw() and STOCK or nil }
  for _, q in ipairs(avail()) do if playable(q) then t[#t + 1] = q end end
  table.sort(t, byX)
  return t
end

local function holdStops()
  local t = partners(hold)
  t[#t + 1] = hold
  table.sort(t, byX)
  return t
end

-- Puts the bar on the removable card nearest to q, or on the stock.
local function settle(q)
  if q == STOCK and canDraw() then cur = STOCK; return end
  local x, best, bd = px(q), STOCK, nil
  for _, s in ipairs(stops()) do
    local d = math.abs(px(s) - x)
    if s ~= STOCK and (not bd or d < bd) then best, bd = s, d end
  end
  cur = best
end

local function step(d)
  local t = hold and holdStops() or stops()
  local x, n = px(cur), #t
  local j
  if d > 0 then
    j = 1
    for k = n, 1, -1 do if px(t[k]) > x then j = k end end
  else
    j = n
    for k = 1, n do if px(t[k]) < x then j = k end end
  end
  cur = t[j]
end

-- Moves ------------------------------------------------------------------------

local function deal()
  local d = {}
  for i = 0, 51 do d[i + 1] = i end
  for i = 52, 2, -1 do
    local j = math.random(i)
    d[i], d[j] = d[j], d[i]
  end
  pyr, gone, stock, waste = {}, {}, {}, {}
  for p = 1, 28 do pyr[p] = d[p]; gone[p] = false end
  for i = 29, 52 do stock[#stock + 1] = d[i] end
  pass, moves, counted = 1, 0, false
  hold, undo = nil, {}
  settle(22)
  msg = "New deal: pairs that add up to 13"
end

local function newGame()
  if counted and not cleared() then st.streak = 0 end
  deal()
  save()
end

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

local function countMove()
  moves = moves + 1
  if not counted then
    counted = true
    st.p = st.p + 1
  end
end

local function take(q)
  if q == WASTE then waste[#waste] = nil else gone[q] = true end
end

local function remove(a, b)
  pushUndo()
  local ca, cb = cardAt(a), b and cardAt(b)
  take(a)
  if b then take(b) end
  countMove()
  hold = nil
  msg = "Removed " .. cname(ca) .. (cb and " and " .. cname(cb) or "")
  if cleared() then
    st.w, st.streak = st.w + 1, st.streak + 1
    if st.streak > st.best then st.best = st.streak end
    if st.fewest == 0 or moves < st.fewest then st.fewest = moves end
  else
    settle(a)
  end
  save()
end

local function drawStock()
  hold = nil
  if #stock == 0 then
    if #waste == 0 or pass >= PASSES then
      msg = #waste == 0 and "The stock is empty" or "No passes left through the stock"
      settle(cur)
      return
    end
    pushUndo()
    stock, waste = waste, {}
    pass = pass + 1
    countMove()
    cur = STOCK
    msg = "Pass " .. pass .. " of " .. PASSES .. ": waste turned over"
    save()
    return
  end
  pushUndo()
  waste[#waste + 1] = table.remove(stock, 1)
  countMove()
  if playable(WASTE) then cur = WASTE else settle(STOCK) end
  msg = "Drew " .. cname(waste[#waste]) .. ", " .. #stock .. " left"
  save()
end

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

local function pick()
  if cur == STOCK then drawStock(); return end
  local c = cardAt(cur)
  if not c or not playable(cur) then settle(cur); return end
  if val(c) == 13 then remove(cur); return end
  local t = partners(cur)
  hold = cur
  table.sort(t, byX)
  cur = t[1]
  holdMsg = "Pair " .. cname(c) .. " with " .. VN[13 - val(c)] .. ": OK removes"
end

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

local function menuItems()
  local t = { { "Back to the game", "resume" } }
  if not cleared() then
    t[#t + 1] = { #undo > 0 and "Undo last move" or "Undo (nothing to undo)", "undo" }
  end
  t[#t + 1] = { "New game", "new" }
  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 == "new" then newGame(); screen = "game"
      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 cleared() then
    if k == "ok" then newGame()
    elseif k == "top" then screen, sel = "menu", 1 end
    return
  end
  if stuck() then
    if k == "ok" then newGame()
    elseif k == "up" then doUndo()
    elseif k == "top" then screen, sel = "menu", 1 end
    return
  end
  if k == "top" then
    if hold then cur, hold = hold, nil; msg = "Put back"
    else screen, sel = "menu", 1 end
  elseif k == "left" then step(-1)
  elseif k == "right" then step(1)
  elseif k == "up" then doUndo()
  elseif k == "down" then drawStock()
  elseif k == "ok" then
    if not hold then pick()
    elseif cur == hold then hold = nil; msg = "Put back"
    else remove(hold, cur) end
  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 full(r, c, card)
  local a, s = card % 13 + 1, card // 13 + 1
  put(r, c, RANK:sub(a, a)); put(r, c + 1, SUIT:sub(s, s))
  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 empty(r, c)
  put(r, c, "x"); put(r, c + 1, "y")
  put(r + 1, c, "w"); put(r + 1, c + 1, "z")
end

local function back(r, c)
  put(r, c, "b"); put(r, c + 1, "e")
  put(r + 1, c, "b"); put(r + 1, c + 1, "e")
  put(r + 2, c, "f"); put(r + 2, c + 1, "g")
end

local function mark(q, ch)
  local r, c = 4, 1
  if q == WASTE then c = 4
  elseif q ~= STOCK then r, c = 2 * ROW[q] + 2, COL[q] end
  put(r, c, ch); put(r, c + 1, ch)
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

-- win: the whole pyramid as dealt; still: no bar (the game is over).
local function board(win, still)
  G, NR = {}, 0
  put(1, 20, " ")
  if #stock > 0 then back(1, 1) else empty(1, 1) end
  if #waste > 0 then full(1, 4, waste[#waste]) else empty(1, 4) end
  for p = 1, 28 do
    if win or not gone[p] then full(2 * ROW[p] - 1, COL[p], pyr[p]) end
  end
  if not win and not still then
    if hold and hold ~= cur then mark(hold, "!") end
    mark(cur, "^")
  end
  return spec()
end

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

local function record()
  snail.text("Won " .. st.w .. " of " .. st.p .. " (" .. pct(st.w, st.p) .. "). Streak " .. st.streak .. ", best " .. st.best .. ".")
end

function draw()
  if screen == "menu" then
    snail.title("Pyramid 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()
    snail.small("Won " .. st.w .. " of " .. st.p .. " games. Streak " .. st.streak .. ".")
    snail.hint("BACK leave OK choose UP/DN move")
    return
  end
  if screen == "stats" then
    snail.title("Statistics")
    snail.text("Played " .. st.p .. ", won " .. st.w .. " (" .. pct(st.w, st.p) .. ")")
    snail.gap()
    snail.text("Winning streak " .. st.streak .. ", best " .. st.best)
    snail.text("Fewest moves to win: " .. (st.fewest > 0 and st.fewest or "-"))
    snail.hint("BACK back OK back")
    return
  end
  if screen == "help" then
    snail.title("How to play")
    snail.text("Clear the pyramid: remove two uncovered cards that add up to 13, or a King on its own.")
    snail.text("Ace counts 1, Jack 11, Queen 12. The top card of the waste can pair too.")
    snail.text("DN deals a card to the waste. You can go through the stock three times.")
    snail.text("L/R moves the bar between cards that can go. OK picks one; the bar jumps to a partner.")
    snail.text("OK removes the pair, L/R picks another partner. UP undoes a move.")
    snail.text("BACK puts a card back, or opens the options.")
    snail.hint("BACK back OK back")
    return
  end
  snail.title("Pyramid - pass " .. pass .. " of " .. PASSES .. " - moves " .. moves)
  local n = left()
  if n == 0 then
    snail.status("You win! Cleared in " .. moves .. " moves")
    snail.board(board(true), nil, "Pyramid cleared!")
    record()
    snail.hint("BACK options OK new game")
    return
  end
  if stuck() then
    snail.status("No more moves: " .. n .. (n == 1 and " card" or " cards") .. " left")
    snail.board(board(false, true), nil, "No more moves")
    record()
    snail.hint("BACK options OK new game UP undo")
    return
  end
  snail.status(msg or hold and holdMsg or ("Pyramid " .. n .. " left, stock " .. #stock .. ", waste " .. #waste))
  snail.board(board())
  if hold then snail.hint("BACK cancel OK remove L/R move UP undo DN draw")
  else snail.hint("BACK options OK select L/R move UP undo DN draw") end
end

function start()
  snail.ink("fast")
  load()
  math.randomseed(os.time() + seedN * 7919)
  seedN = seedN + 1
  if not pyr then deal() else settle(22) end
  save()
end
