--!name Golf Solitaire
--!icon cards
--!category Games
-- Golf patience: seven columns of five cards, all face up. Play the bottom
-- card of a column onto the pile when it is one rank above or below the top
-- card. No wrapping: only a 2 goes on an Ace and nothing goes on a King. One
-- pass through the stock. Score = cards left in the columns (lower is better);
-- clearing them all scores minus the cards left in the stock.
-- Cards are snail.board art tiles from apps/golf.art (tools/make_peaks_art.py).

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 = 0
local FAN = 8

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

local stock, pile, tab, orig
local moves, counted = 0, false
local cur = STOCK
local undo = {}
local msg
local screen, sel = "game", 1
-- played, cleared, best score (99: none yet), total score of played games
local st = { p = 0, w = 0, best = 99, total = 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

local function enc()
  local p = { moves, cstr(stock), cstr(pile) }
  for i = 1, 7 do p[#p + 1] = 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
  local m = tonumber(f[1])
  if #f ~= 10 or not m or #f[3] == 0 then return false end
  local n, tb = #f[2] + #f[3], {}
  for i = 1, 7 do tb[i] = cards(f[3 + i]); n = n + #tb[i] end
  if n ~= 52 then return false end
  moves, stock, pile, tab = m, cards(f[2]), cards(f[3]), tb
  return true
end

local function save()
  local head = table.concat({ "G1", st.p, st.w, st.best, st.total, counted and 1 or 0, seedN, orig, 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) ~= "G1;" 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.total = n(2), n(3), n(4), n(5)
  counted = n(6) == 1
  seedN = n(7)
  if f[8] and #f[8] == 35 and f[9] and dec(f[9]) then
    orig = f[8]
    for u in (f[10] or ""):gmatch("[^|]+") do undo[#undo + 1] = u end
  else
    stock = nil
  end
end

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

local function canPlay(c)
  local t = rank(pile[#pile])
  return t ~= 12 and math.abs(rank(c) - t) == 1
end

local function playable(i)
  local t = tab[i]
  return #t > 0 and canPlay(t[#t])
end

local function left()
  local n = 0
  for i = 1, 7 do n = n + #tab[i] end
  return n
end

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

local function score()
  local n = left()
  return n > 0 and n or -#stock
end

local function stuck()
  if #stock > 0 or won() then return false end
  for i = 1, 7 do if playable(i) 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.total end
  local s = score()
  return st.p + 1, st.w + (won() and 1 or 0), math.min(st.best, s), st.total + s
end

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

-- Where the bar can stop: the stock (until it runs out), then each column
-- whose card can go.
local function stops()
  local t = { #stock > 0 and STOCK or nil }
  for i = 1, 7 do if playable(i) then t[#t + 1] = i end end
  return t
end

-- Puts the bar on the playable column nearest to column i, or on the stock.
local function settle(i)
  local best, bd = STOCK, nil
  for _, s in ipairs(stops()) do
    local d = math.abs(s - i)
    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 n, j = #t, nil
  if d > 0 then
    j = 1
    for k = n, 1, -1 do if t[k] > cur then j = k end end
  else
    j = n
    for k = 1, n do if t[k] < cur 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
  tab = {}
  for i = 1, 7 do
    tab[i] = {}
    for j = 1, 5 do tab[i][j] = d[(i - 1) * 5 + j] end
  end
  orig = cstr(d):sub(1, 35)
  pile = { d[36] }
  stock = {}
  for i = 37, 52 do stock[#stock + 1] = d[i] end
  moves, counted, undo = 0, false, {}
  settle(1)
  msg = "New deal: play one up or one down"
end

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

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

local function countMove()
  moves = moves + 1
  counted = true
end

local function play(i)
  pushUndo()
  local c = table.remove(tab[i])
  pile[#pile + 1] = c
  countMove()
  msg = "Played " .. cname(c) .. ", " .. left() .. " to go"
  settle(i)
  save()
end

local function drawStock()
  if #stock == 0 then msg = "The stock is empty"; settle(cur); return end
  pushUndo()
  pile[#pile + 1] = table.remove(stock, 1)
  countMove()
  msg = "Drew " .. cname(pile[#pile]) .. ", " .. #stock .. " left"
  settle(1)
  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 1 or 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(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 strip(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))
end

local function full(r, c, card)
  strip(r, c, card)
  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 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

-- cols: the columns to show (the deal itself on the win screen); bar: draw the cursor.
local function board(cols, bar)
  G, NR = {}, 0
  put(1, 20, " ")
  if #stock > 0 then
    put(1, 1, "b"); put(1, 2, "e"); put(2, 1, "b"); put(2, 2, "e"); put(3, 1, "f"); put(3, 2, "g")
  else
    empty(1, 1)
  end
  local k = math.min(FAN, #pile)
  for j = 1, k - 1 do
    local a = rank(pile[#pile - k + j]) + 1
    put(1, 3 + j, RANK:sub(a, a)); put(2, 3 + j, "m"); put(3, 3 + j, "u")
  end
  full(1, 3 + k, pile[#pile])
  if bar and cur == STOCK then put(4, 1, "^"); put(4, 2, "^") end
  for i = 1, 7 do
    local x, t = 3 * i - 2, cols[i]
    if #t == 0 then
      empty(5, x)
    else
      for j = 1, #t - 1 do strip(4 + j, x, t[j]) end
      full(4 + #t, x, t[#t])
      if bar and cur == i then put(7 + #t, x, "^"); put(7 + #t, x + 1, "^") end
    end
  end
  return spec()
end

-- The seven columns as dealt, for the win screen.
local function dealt()
  local cols = {}
  for i = 1, 7 do cols[i] = cards(orig:sub(5 * i - 4, 5 * i)) end
  return cols
end

local function record(s)
  local p, w, b, tot = tally()
  local line = "Played " .. p .. ", cleared " .. w
  if p > 0 then line = line .. ". Best " .. b .. ", average " .. string.format("%.1f", tot / p) end
  snail.text(line .. ".")
  if counted and (st.best == 99 or s < st.best) then snail.text("A new best score!") end
end

function draw()
  if screen == "menu" then
    snail.title("Golf 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("Played " .. st.p .. ", cleared " .. st.w .. (st.p > 0 and ". Best score " .. st.best .. "." or "."))
    snail.hint("BACK leave OK choose UP/DN move")
    return
  end
  if screen == "stats" then
    snail.title("Statistics")
    snail.text("Games played " .. st.p .. ", columns cleared " .. st.w)
    if st.p > 0 then
      snail.text("Best score " .. st.best .. ", average " .. string.format("%.1f", st.total / st.p))
    end
    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 seven columns onto the pile next to the stock.")
    snail.text("A column's bottom card goes on the pile if it is one rank higher or lower.")
    snail.text("Suits don't count. No wrapping: only a 2 goes on an Ace, nothing on a King.")
    snail.text("DN turns a stock card onto the pile. The stock is used once.")
    snail.text("Score: cards left in the columns, lower is better. Clear them all: minus the stock left.")
    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("Golf - stock " .. #stock .. " - moves " .. moves)
  local s = score()
  if won() then
    snail.status("You win! Score " .. s .. " in " .. moves .. " moves")
    snail.board(board(dealt(), false), nil, "All columns cleared!")
    record(s)
    snail.hint("BACK options OK new game")
    return
  end
  if stuck() then
    snail.status("No more moves: score " .. s)
    snail.board(board(tab, false), nil, "Game over: score " .. s)
    record(s)
    snail.hint("BACK options OK new game UP undo")
    return
  end
  snail.status(msg or ("Score " .. s .. " (cards left), stock " .. #stock))
  snail.board(board(tab, 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 stock then deal() else settle(1) end
  save()
end
