--!name TriPeaks Solitaire
--!icon cards
--!category Games
-- TriPeaks patience: three overlapping peaks of 28 cards. Play an uncovered
-- card onto the pile when it is one rank above or below the top card (King
-- and Ace wrap); a face-down card turns up once nothing lies on it. Each card
-- in a run scores one more than the last, a stock card ends the run, and
-- clearing a peak scores a bonus. One pass through the stock.
-- Cards are snail.board art tiles from apps/tripeaks.art
-- (tools/make_peaks_art.py): a card is two cells wide and three tall, each row
-- 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 SN = { "spades", "hearts", "diamonds", "clubs" }
local MAXUNDO = 60
local STOCK = 29
local FAN = 8
local PEAK, ALL3, SPARE = 15, 30, 5

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

-- Positions 1-3 are the peak tops, 4-9 the next row, 10-18 and 19-28 the two
-- full rows. LV is the row (1..4), COL the board column of the card's left
-- cell; ON[p] lists the cards lying on p (one row down, one column aside).
local LV, COL, ON = {}, {}, {}
for p = 1, 28 do
  if p <= 3 then LV[p], COL[p] = 1, 6 * p - 2
  elseif p <= 9 then
    local j = p - 4
    LV[p], COL[p] = 2, 3 + 6 * (j // 2) + 2 * (j % 2)
  elseif p <= 18 then LV[p], COL[p] = 3, 2 * (p - 10) + 2
  else LV[p], COL[p] = 4, 2 * (p - 19) + 1 end
end
for p = 1, 28 do
  ON[p] = {}
  for q = 1, 28 do
    if LV[q] == LV[p] + 1 and math.abs(COL[q] - COL[p]) == 1 then ON[p][#ON[p] + 1] = q end
  end
end

local tp, gone, stock, waste
local moves, score, run, longest, counted = 0, 0, 0, 0, false
local cur = STOCK
local undo = {}
local msg
local screen, sel = "game", 1
-- played, won, best score, longest run
local st = { p = 0, w = 0, best = 0, run = 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 peaks' cards themselves are fixed by the deal.
local function enc()
  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({ moves, score, run, longest, 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 n = {}
  for i = 1, 4 do n[i] = tonumber(f[i]) end
  if #f ~= 7 or #f[5] ~= 7 or #f[7] == 0 or not (n[1] and n[2] and n[3] and n[4]) then return false end
  local g = {}
  for k = 0, 6 do
    local b = f[5]:byte(k + 1) - 65
    for j = 1, 4 do g[4 * k + j] = b & (1 << (j - 1)) ~= 0 end
  end
  moves, score, run, longest = n[1], n[2], n[3], n[4]
  gone, stock, waste = g, cards(f[6]), cards(f[7])
  return true
end

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

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

local function exposed(p)
  if gone[p] then return false end
  for _, q in ipairs(ON[p]) do if not gone[q] then return false end end
  return true
end

local function playable(p)
  if not exposed(p) then return false end
  local d = math.abs(rank(tp[p]) - rank(waste[#waste]))
  return d == 1 or d == 12
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

local function won() return left() == 0 end

local function stuck()
  if #stock > 0 or won() then return false end
  for p = 1, 28 do if playable(p) then return false end end
  return true
end

local function over() return won() or stuck() end

-- The statistics with this game counted, as they will be once the next deal starts.
local function tally()
  if not counted then return st.p, st.w, st.best, st.run end
  return st.p + 1, st.w + (won() and 1 or 0), math.max(st.best, score), math.max(st.run, longest)
end

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

-- Left-to-right order on the screen: the stock, then column and row.
local function px(q)
  if q == STOCK then return 0 end
  return COL[q] * 10 + LV[q]
end

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

-- Where the bar can stop: the stock (until it runs out) and every card that
-- can go on the pile.
local function stops()
  local t = { #stock > 0 and STOCK or nil }
  for p = 1, 28 do if playable(p) then t[#t + 1] = p end end
  table.sort(t, byX)
  return t
end

-- Puts the bar on the playable card nearest to screen position x, or the stock.
local function settle(x)
  local best, bd = 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 = 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
  tp, gone, stock = {}, {}, {}
  for p = 1, 28 do tp[p] = d[p]; gone[p] = false end
  waste = { d[29] }
  for i = 30, 52 do stock[#stock + 1] = d[i] end
  moves, score, run, longest, counted, undo = 0, 0, 0, 0, false, {}
  settle(0)
  msg = "New deal: play one up or one down"
end

local function newGame()
  if counted then st.p, st.w, st.best, st.run = tally() end
  deal()
  save()
end

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

local function play(p)
  pushUndo()
  local c = tp[p]
  gone[p] = true
  waste[#waste + 1] = c
  moves, counted = moves + 1, true
  run = run + 1
  if run > longest then longest = run end
  local pts = run
  msg = "Played " .. cname(c) .. ", run " .. run
  if LV[p] == 1 then
    pts = pts + (gone[1] and gone[2] and gone[3] and ALL3 or PEAK)
    msg = "Peak cleared! Run " .. run
  end
  if won() then pts = pts + SPARE * #stock end
  score = score + pts
  msg = msg .. ", +" .. pts
  settle(px(p))
  save()
end

local function drawStock()
  if #stock == 0 then msg = "The stock is empty"; settle(px(cur)); return end
  pushUndo()
  waste[#waste + 1] = table.remove(stock, 1)
  moves, counted, run = moves + 1, true, 0
  msg = "Drew " .. cname(waste[#waste]) .. ", " .. #stock .. " left"
  settle(0)
  save()
end

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

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

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" }
  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 k == "top" then screen, sel = "menu", 1; return end
  if over() then
    if k == "ok" then newGame()
    elseif k == "up" and not won() then doUndo() end
    return
  end
  if 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 cur == STOCK then drawStock()
    elseif playable(cur) then play(cur)
    else settle(px(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 = rank(card) + 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 back(r, c)
  put(r, c, "b"); put(r, c + 1, "e")
  put(r + 1, c, "k"); put(r + 1, c + 1, "l")
  put(r + 2, c, "f"); put(r + 2, c + 1, "g")
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: every card of the deal face up, with room for the banner between the
-- peaks and the stock; bar: draw the cursor.
local function board(win, bar)
  G, NR = {}, 0
  put(1, 20, " ")
  for p = 1, 28 do
    local r = 2 * LV[p] - 1
    if win or exposed(p) then full(r, COL[p], tp[p])
    elseif not gone[p] then back(r, COL[p]) end
  end
  local sr = win and 14 or 12
  if win then put(22, 1, " ") end
  if #stock > 0 then back(sr, 1)
  else
    put(sr, 1, "x"); put(sr, 2, "y"); put(sr + 1, 1, "w"); put(sr + 1, 2, "z")
  end
  local k = math.min(FAN, #waste)
  for j = 1, k - 1 do
    local a = rank(waste[#waste - k + j]) + 1
    put(sr, 3 + j, RANK:sub(a, a)); put(sr + 1, 3 + j, "m"); put(sr + 2, 3 + j, "u")
  end
  full(sr, 3 + k, waste[#waste])
  if bar then
    local r, c = 15, 1
    if cur ~= STOCK then r, c = 2 * LV[cur] + 2, COL[cur] end
    put(r, c, "^"); put(r, c + 1, "^")
  end
  return spec()
end

local function record()
  local p, w, b, r = tally()
  snail.text("Won " .. w .. " of " .. p .. ". Best score " .. b .. ", longest run " .. r .. ".")
  if counted and score > st.best then snail.text("A new best score!") end
end

function draw()
  if screen == "menu" then
    snail.title("TriPeaks 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 .. ". Best score " .. st.best .. ".")
    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 .. (st.p > 0 and " (" .. st.w * 100 // st.p .. "%)" or ""))
    snail.text("Best score " .. st.best .. ", longest run " .. st.run)
    snail.gap()
    snail.small("A game counts when you start the next one.")
    snail.hint("BACK back OK back")
    return
  end
  if screen == "help" then
    snail.title("How to play")
    snail.text("Clear the three peaks. An uncovered card goes on the pile if it is one rank higher or lower.")
    snail.text("Suits don't count, and it wraps: a King and an Ace go on each other.")
    snail.text("A face-down card turns up when both cards on it are gone.")
    snail.text("DN turns a stock card onto the pile and ends your run. The stock is used once.")
    snail.text("Each card in a run scores one more than the last. A peak top adds 15, the last one 30.")
    snail.text("Clear all three and every card left in the stock adds 5.")
    snail.text("L/R moves the bar between cards that go. OK plays one. UP undoes a move.")
    snail.hint("BACK back OK back")
    return
  end
  snail.title("TriPeaks - score " .. score .. " - run " .. run)
  if won() then
    snail.status("You win! Score " .. score .. " in " .. moves .. " moves")
    snail.board(board(true, false), nil, "All three peaks cleared!")
    record()
    snail.hint("BACK options OK new game")
    return
  end
  if stuck() then
    local n = left()
    snail.status("No more moves: score " .. score)
    snail.board(board(false, false), nil, "Game over: " .. n .. (n == 1 and " card" or " cards") .. " left")
    record()
    snail.hint("BACK options OK new game UP undo")
    return
  end
  snail.status(msg or ("Stock " .. #stock .. ", " .. left() .. " cards in the peaks"))
  snail.board(board(false, true))
  snail.hint("BACK options OK play L/R move UP undo DN draw")
end

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