-- This is a Lua version of CrossCards, a combination of Scrabble and poker.
-- Converted from Modula-2 code that was written in the mid 1990s.
-- Author: Andrew Trevorrow (andrew@trevorrow.com), Jan 2017.

local glu = glu()
local cv = canvas()
local cp = require "cplus"

-- require "gplus.strict"
local gp = require "gplus"
local split = gp.split

local ORD = string.byte
local CHR = string.char

local rand = math.random

--------------------------------------------------------------------------------

local mbar              -- the menu bar
local mbarht = 28       -- height of menu bar

-- suits:
local spades    = 0
local clubs     = 1
local diamonds  = 2
local hearts    = 3
local anysuit   = 4

-- cards:
-- WARNING: some code assumes twoSpades..joker are contiguous chars
-- starting with "A", and aceHearts is just before joker
local twoSpades     = "A"
local aceSpades     = CHR(ORD(twoSpades) + 12)
local twoClubs      = CHR(ORD(aceSpades) + 1)
local aceClubs      = CHR(ORD(twoClubs) + 12)
local twoDiamonds   = CHR(ORD(aceClubs) + 1)
local aceDiamonds   = CHR(ORD(twoDiamonds) + 12)
local twoHearts     = CHR(ORD(aceDiamonds) + 1)
local aceHearts     = CHR(ORD(twoHearts) + 12)
local joker         = CHR(ORD(aceHearts) + 1)

-- square types:
-- WARNING: some code assumes this ordering
local plain     = 0
local cardx2    = 1     -- double card square
local handx2    = 2     -- double hand square
local cardx3    = 3     -- triple card square
local handx3    = 4     -- triple hand square

-- square states:
local empty     = 0
local full      = 1
local frozen    = 2
-- full is a temporary state used while the current player is moving tiles;
-- at the end of a player's turn all board squares are either empty or frozen

local numplayers    = 2         -- number of players
local maxrack       = 8         -- maximum tiles in one rack
local maxslots      = 9         -- maximum slots in one rack
local maxnamelen    = 10        -- maximum length of player names
local tilesize      = 40        -- ht and wd of big card tiles
local smallsize     = 26        -- ht and wd of small card tiles
local maxlines      = 5         -- lines in info box
local notesep       = 16        -- line separation in notepad
local notevoff      = 28        -- offset to top line
local notehoff      = 13        -- offset to start of line
local noteindent    = 8         -- left margin in notepad
local arrowboxwd    = 15        -- width of arrow boxes
local shadowsize    = 4         -- width/height of shadows
local msgwait       = 1500      -- wait 1.5 secs after a message
local gameversion   = 2         -- current version number in game files

-- WARNING: GetBonusScore assumes the following ordering of bonus values
-- and chooses the highest bonus if a hand is ambiguous
local rfbonus   = 100           -- royal flush
local sfbonus   = 70            -- straight flush
local fivebonus = 50            -- 5 of a kind
local sbonus    = 40            -- straight
local fbonus    = 30            -- flush
local fhbonus   = 20            -- full house

local paleyellow = {255,255,210,255}

--------------------------------------------------------------------------------

local function Rect(r)
    -- Create and return a table representing a rectangle.
    local t = {}
    if r then
        -- rect = Rect(r)
        t.top = r.top
        t.bottom = r.bottom
        t.left = r.left
        t.right = r.right
    else
        -- rect = Rect()
        t.top = 0
        t.bottom = 0
        t.left = 0
        t.right = 0
    end
    return t
end

-- also need some Rect-related functions:

local function OffsetRect(r, x, y)
    r.top = r.top + y
    r.bottom = r.bottom + y
    r.left = r.left + x
    r.right = r.right + x
end

local function InsetRect(r, x, y)
    r.top = r.top + y
    r.bottom = r.bottom - y
    r.left = r.left + x
    r.right = r.right - x
end

local function FillRect(r)
    local wd = r.right - r.left + 1
    local ht = r.bottom - r.top + 1
    cv.fill(r.left, r.top, wd, ht)
end

local function InvertRect(r)
    cv.copy(r.left, r.top, r.right-r.left+1, r.bottom-r.top+1, "temp")
    cv.target("temp")
    cv.replace("*#- *#- *#- *#")
    cv.target()
    cv.paste("temp", r.left, r.top)
    cv.delete("temp")
end

local function PtInRect(x, y, r)
    if x < r.left then return false end
    if x > r.right then return false end
    if y < r.top then return false end
    if y > r.bottom then return false end
    return true
end

--------------------------------------------------------------------------------

function moveinfo()
    --[[ Create and return a table representing a moveinfo record:
    RECORD
        who : CARD16;
        move : ARRAY [1..maxrack*2] OF CHAR;
        movelen : [0..maxrack*2];
        pos : ARRAY [1..maxrack] OF RECORD row, col : CARD16 END;
        posnum : [0..maxrack];
        new : ARRAY [1..maxrack] OF CHAR;
        newlen : [0..maxrack];
        score : INT32;
    END;
    --]]
    local t = {}
    t.who = 0               -- which player
    t.move = {}             -- cards used in this move
    t.movelen = 0
    t.pos = {}              -- board positions of each card
    for i = 1, maxrack do
        t.pos[i] = { row=0, col=0 }
    end
    t.posnum = 0
    t.new = {}              -- cards in new rack
    t.newlen = 0
    t.score = 0             -- score for this move
    return t
end

--------------------------------------------------------------------------------

function square()
    --[[ Create and return a table representing a square record:
    RECORD
        type : [plain..handx3];
        state : [empty..frozen];
        tilech : CHAR;
        jokerch : CHAR;
    END;
    --]]
    local t = {}
    t.type = plain
    t.state = empty
    t.tilech = ""           -- twoSpades..joker if state not empty
    t.jokerch = ""          -- twoSpades..aceHearts if state = frozen and tilech = joker
    return t
end

--------------------------------------------------------------------------------

function playerinfo()
    --[[ Create and return a table representing a playerinfo record:
    RECORD
        name : namestring;
        machine : BOOLEAN;
        skill : CARD16;
        score : INT32;
        turns : CARD16;
        rack : ARRAY [1..maxslots] OF square;
        numtiles : [0..maxrack];
    END;
    --]]
    local t = {}
    t.name = ""                 -- name of player
    t.machine = false           -- computer player?
    t.skill = 0                 -- if so then use this skill level
    t.score = 0                 -- can be -ve
    t.turns = 0                 -- number of moves
    t.rack = {}                 -- tiles in rack; >= 1 empty
    for i = 1, maxslots do
        t.rack[i] = square()
    end
    t.numtiles = 0              -- number of full squares
    return t
end

--------------------------------------------------------------------------------

-- game data:

local board = {}                -- current board (outer edges are empty sentinel squares)
for row = 0, 16 do
    board[row] = {}
    for col = 0, 16 do
        board[row][col] = square()
    end
end

local player = {}               -- info for each player
for i = 1, numplayers do
    player[i] = playerinfo()
end                                                       
local currplayer = 1            -- current player

local history = {}              -- history of the current game (array of moveinfo)
local nummoves = 0              -- number of recorded moves

local start = {}                -- starting tiles for each player
for i = 1, numplayers do
    start[i] = {}               -- ARRAY [1..maxrack] OF twoSpades..joker
end                                                       

local bag = {}                  -- bag of tiles
local tileval = {}              -- value of each card
local tilesuit = {}             -- suits for each card

local boardrect = Rect()        -- for 15x15 board
local innerleft = Rect()        -- inner rect of cards remaining box
local innerinfo = Rect()        -- inner rect of info box
local bonusrect = Rect()        -- for bonus info arrow
local statsrect = Rect()        -- for statistics arrow
local innerrack = Rect()        -- inner rect of rack
local helprect = Rect()         -- for Help button
local sortrect = Rect()         -- for Sort button
local donerect = Rect()         -- for Done/Move button

local helpbutton = nil          -- Help button
local sortbutton = nil          -- Sort button
local donebutton = nil          -- Done/Move button

local endofgame = true          -- current game has ended?
local canredo = 0               -- number of undone moves that can be redone
local baginverted = false       -- innerleft is inverted?
local winner = 0                -- winning player (0 = tie) [0..numplayers]
local passed = 0                -- consecutive passes [0..numplayers] (game ends if numplayers)

local endscore = {}             -- score adjustment at end of game
for i = 1, numplayers do
    endscore[i] = 0
end

local infostr = {}              -- message lines in info box
for i = 1, maxlines do
    infostr[i] = ""
end

local numthrown = 0             -- number of tiles thrown in bag [0..maxrack]
local throwntiles = {}          -- cards thrown in bag
local dosuitsort = false        -- sort cards into suits?

-- rects for displaying player info:
local playerrect = {}
for i = 1, numplayers do
    playerrect[i] = Rect()
end

-- for dragging tiles:
local draggedtile = ""              -- tile being dragged
local lcount, rcount = 0, 0         -- number of left/right tiles
local ltilech, rtilech = {}, {}     -- save l/r contiguous tile chars
local vertline = false              -- vertical line of tiles?
local currcursx, currcursy = 0, 0   -- current cursor position
local dragcount = 0                 -- count of dragged tiles
local whichtile = 0                 -- for swapping cards
local tileinfo = {}                 -- ditto (ARRAY [1..maxtiles] OF RECORD ch : CHAR; r : Rect END)

-- rack/board positions of dragged tiles (if from rack then dragpos[1].col = 0)
local dragpos = {}
for i = 1, maxrack do
    dragpos[i] = { row=0, col=0 }
end                                                       

local boardsquare = tilesize + 1    -- ht and wd of one board square
local racksquare = tilesize + 1     -- ht and wd of one rack square
local numdecks = 2                  -- number of decks
local numjokers = 4                 -- number of jokers
local totaltiles = 108              -- total number of tiles
local tilesleft = 0                 -- tiles left in bag
local allmachines = false           -- all players are machines?
local statspath = ""                -- path for statistics file
local startgame = false             -- start a new game?
local viewwd, viewht = 0, 0         -- current size of viewport

--------------------------------------------------------------------------------

-- WARNING: twoSpades..joker are also used as clip names (see CreateClips)
-- so avoid using any other single-character clip names

-- clip names:
local bgclip        = "bg"          -- clip with background image
local tileclip      = "tile"        -- clip with empty tile
local dragbg        = "dragbg"      -- clip with canvas image under dragged tile(s)
local dragclip      = "drag"        -- clip with dragged tile(s)

-- computer pictures:
local normalpict    = "normal"      -- clip with normal look
local happypict     = "happy"       -- clip with happy look
local sadpict       = "sad"         -- clip with sad look
local currpict      = normalpict    -- clip with current computer picture

local tinysuit = {}                 -- clip names for tiny suits
local bluesuit = {}                 -- clip names for tiny blue suits
for suit = spades, hearts do
    tinysuit[suit] = "tiny"..suit
    bluesuit[suit] = "blue"..suit
end

--------------------------------------------------------------------------------

local function goinfo()
    --[[ Create and return a table representing a goinfo record:
    RECORD
        added : [0..maxrack];
        rpos : ARRAY [1..maxrack] OF [1..15];
        cpos : ARRAY [1..maxrack] OF [1..15];
        tile : ARRAY [1..maxrack] OF [twoSpades..joker];
        blnk : ARRAY [1..maxrack] OF [twoSpades..aceHearts];
        score : INT32;
        bonus : CARD8;
        transpose : BOOLEAN;
        goodness : INT32;
    END;
    --]]
    local t = {}
    t.added = 0             -- number of tiles added to board
    t.rpos = {}             -- row position of each tile
    t.cpos = {}             -- column position of each tile
    t.tile = {}             -- card value of each tile
    t.blnk = {}             -- card value of each joker
    t.score = 0             -- score for this go
    t.bonus = 0             -- bonus score (if t.added is 5)
    t.transpose = false     -- need to swap rows and columns?
    t.goodness = 0          -- used to choose very best move
    return t
end

--------------------------------------------------------------------------------

function statsrecord()
    --[[ Create and return a table representing the header info in a stats file:
    RECORD
        totalgames : INT32;
        totaldrawn : INT32;
        totalscore : ARRAY [1..numplayers] OF INT32;
        bestgame : ARRAY [1..numplayers] OF INT32;
        worstgame : ARRAY [1..numplayers] OF INT32;
        totalmoves : ARRAY [1..numplayers] OF INT32;
        bestmove : ARRAY [1..numplayers] OF INT32;
        wins : ARRAY [1..numplayers] OF INT32;
    END;
    --]]
    local t = {}
    t.totalgames = 0        -- total games played
    t.totaldrawn = 0        -- number of drawn games
    t.totalscore = {}       -- total score for each player
    t.bestgame = {}         -- best score for each player
    t.worstgame = {}        -- worst score for each player
    t.totalmoves = {}       -- total number of moves for each player
    t.bestmove = {}         -- best score in one move for each player
    t.wins = {}             -- number of wins for each player
    return t
end

--------------------------------------------------------------------------------

-- data for computer moves:

local transposed = false            -- is board currently transposed?
local maxbest = 40                  -- remember at most this many best moves
local topbest = 4                   -- see at most this many best moves
local terrible = -666666            -- force computer to throw back tiles
local rack = {}                     -- current rack (ARRAY [1..maxrack] OF twoSpades..joker)
local ntiles = 0                    -- number of tiles in current rack
local freq = {}                     -- frequency of each tile in rack
local best = {}                     -- list of best moves (ARRAY [1..maxbest] OF goinfo)
local numbest = 0                   -- pass if 0 (0..maxbest)
local verybest = 1                  -- move with highest goodness (1..maxbest)
local helphuman = true              -- show human player best moves?
local currgo = goinfo()             -- current move under consideration
local besthand = {}                 -- best hands so far (ARRAY [1..topbest] OF handstring)
local skill = 9                     -- used to limit computer score (0..9)
local slimit = {}                   -- scoring limits for skills 0..7
local throwbacks = {}               -- consecutive ThrowBack calls for each player
local hands = {}                    -- hands seen by GetScore (ARRAY [1..maxrack+1] of handstring)
local handscreated = 0              -- number of hands seen (0..maxrack+1)

local nocard = ""                   -- empty set of cards
local everycard = ""                -- full set of cards
for card = ORD(twoSpades), ORD(joker) do
    everycard = everycard..CHR(card)
end

local xcheck = {}                   -- cross-checks
for row = 0, 16 do
    xcheck[row] = {}
    for col = 0, 16 do
        xcheck[row][col] = nocard
    end
end

local xsum = {}                     -- cross-sums
for row = 1, 15 do
    xsum[row] = {}
    for col = 1, 15 do
        xsum[row][col] = 0
    end
end

local anchorrow, anchorcol = 0, 0   -- current anchor square
local anchor = {}                   -- is board[row][col] an anchor square?
for row = 1, 15 do
    anchor[row] = {}
    for col = 1, 15 do
        anchor[row][col] = false
    end
end

--------------------------------------------------------------------------------

-- game options:

local playsounds = true             -- play various sounds?
local volume = 0.5                  -- sound volume
local showcompcards = false         -- show computer's cards?
local blinkcompmoves = true         -- blink computer's moves?
local savingstats = true            -- save statistics?
local waitformove = true            -- computer waits for Move button?
local jokerfirst = true             -- ensure one joker in first rack?
local sortvalue = true              -- sort rack cards by values?
local sortsuit = false              -- sort rack cards by suits?
local bgtheme = "wood"              -- current background theme
local bgx, bgy = 0, 0               -- location of background image
local bgwd, bght = 0, 0             -- size of background image
local fullscreen = 0                -- use fullscreen mode if 1
local autosave = true               -- automatically save unfinished game?

-- create CrossCards folder inside user-specific data directory
local pathsep = glu.getdir("app"):sub(-1)
local ccdatadir = glu.getdir("data").."CrossCards"..pathsep
glu.makedir(ccdatadir)

local unfinishedgame = ccdatadir.."unfinished-game.cc"

-- initial directory for saving games
local initdir = ccdatadir

-- user settings are stored in this file
local settingsfile = ccdatadir.."CrossCards.prefs"

--------------------------------------------------------------------------------

function ReadSettings()
    local f = io.open(settingsfile, "r")
    if f then
        while true do
            -- look for keyword=value lines created by WriteSettings
            local line = f:read("*l")
            if not line then break end
            -- NOTE: we can't use split(line,"=") because value might contain "="
            local keyword, value = line:match("([^=]+)=(.*)")
            if not value then -- ignore keyword
            elseif keyword == "player1" then player[1].name = value
            elseif keyword == "player2" then player[2].name = value
            elseif keyword == "bgtheme" then bgtheme = value
            elseif keyword == "initdir" then initdir = value
            elseif keyword == "fullscreen" then fullscreen = tonumber(value)
            elseif keyword == "volume" then volume = tonumber(value)
            elseif keyword == "playsounds" then playsounds = tostring(value) == "true"
            elseif keyword == "showcompcards" then showcompcards = tostring(value) == "true"
            elseif keyword == "blinkcompmoves" then blinkcompmoves = tostring(value) == "true"
            elseif keyword == "autosave" then autosave = tostring(value) == "true"
            end
        end
        f:close()
        glu.setoption("fullscreen", fullscreen)
    end
end

--------------------------------------------------------------------------------

function WriteSettings()
    local f = io.open(settingsfile, "w")
    if f then
        -- keywords must match those in ReadSettings, but order doesn't matter
        f:write("player1=", player[1].name, "\n")
        f:write("player2=", player[2].name, "\n")
        f:write("bgtheme=", bgtheme, "\n")
        f:write("initdir=", initdir, "\n")
        f:write("fullscreen=", tostring(fullscreen), "\n")
        f:write("volume=", tostring(volume), "\n")
        f:write("playsounds=", tostring(playsounds), "\n")
        f:write("showcompcards=", tostring(showcompcards), "\n")
        f:write("blinkcompmoves=", tostring(blinkcompmoves), "\n")
        f:write("autosave=", tostring(autosave), "\n")
        f:close()
    end
end

--------------------------------------------------------------------------------

function InitTiles()
    -- Initialize tile values and suits.
    for tile = ORD(twoSpades), ORD(aceSpades) do
        tileval[CHR(tile)] = tile - ORD(twoSpades) + 2
        tilesuit[CHR(tile)] = spades
    end
    for tile = ORD(twoClubs), ORD(aceClubs) do
        tileval[CHR(tile)] = tile - ORD(twoClubs) + 2
        tilesuit[CHR(tile)] = clubs
    end
    for tile = ORD(twoDiamonds), ORD(aceDiamonds) do
        tileval[CHR(tile)] = tile - ORD(twoDiamonds) + 2
        tilesuit[CHR(tile)] = diamonds
    end
    for tile = ORD(twoHearts), ORD(aceHearts) do
        tileval[CHR(tile)] = tile - ORD(twoHearts) + 2
        tilesuit[CHR(tile)] = hearts
    end
    tileval[joker] = 0
    tilesuit[joker] = anysuit
end

--------------------------------------------------------------------------------

function Fatal(msg)
    glu.warn(msg)
    glu.exit()
end

--------------------------------------------------------------------------------

local function AddTile(tile)
    -- Increment tilesleft and put given tile into bag.
    if tilesleft < totaltiles then
        tilesleft = tilesleft + 1
        bag[tilesleft] = tile
    else
        Fatal('Bug in AddTile: bag is full!')
    end
end

--------------------------------------------------------------------------------

local function RemoveTile(tile)
    -- Remove specified tile from bag and decrement tilesleft.
    local i = 0
    repeat i = i + 1 until (bag[i] == tile) or (i == tilesleft)
    if bag[i] ~= tile then
        Fatal('Bug in RemoveTile: requested card not in bag!')
    end
    bag[i] = bag[tilesleft]
    tilesleft = tilesleft - 1
end

--------------------------------------------------------------------------------

local function ChooseTile()
    -- Select a tile at random from bag and decrement tilesleft.
    local i = rand(1, tilesleft)
    local tile = bag[i]
    bag[i] = bag[tilesleft]
    tilesleft = tilesleft - 1
    return tile
end

--------------------------------------------------------------------------------

function InitBag()
    -- Initialize totaltiles, tilesleft and bag.
    totaltiles = numdecks * 52 + numjokers
    tilesleft = 0
    for card = ORD(twoSpades), ORD(aceHearts) do
        for c = 1, numdecks do
            AddTile(CHR(card))
        end
    end
    for c = 1, numjokers do
        AddTile(joker)
    end
end

--------------------------------------------------------------------------------

function InitBoard()
    -- Initialize special squares in board.
    local boardstr = "T..d...T...d..T.D...t...t...D...D...d.d...D..d..D...d...D..d....D.....D.....t...t...t...t...d...d.d...d..T..d...D...d..T..d...d.d...d...t...t...t...t.....D.....D....d..D...d...D..d..D...d.d...D...D...t...t...D.T..d...T...d..T"
    local pos = 0
    for r = 1, 15 do
        for c = 1, 15 do
            pos = pos + 1
            local ch = boardstr:sub(pos,pos)
            if ch == 'd' then
                board[r][c].type = cardx2
            elseif ch == 'D' then
                board[r][c].type = handx2
            elseif ch == 't' then
                board[r][c].type = cardx3
            elseif ch == 'T' then
                board[r][c].type = handx3
            else
                -- board[r][c].type = plain
            end
        end
    end
end

--------------------------------------------------------------------------------

function ClearBoard()
    for r = 1, 15 do
        for c = 1, 15 do
            board[r][c].state = empty
        end
    end
end

--------------------------------------------------------------------------------

function InitPlayers()
    -- Set currplayer and remaining player info, including racks.
    if startgame or (nummoves == 0) then
        currplayer = rand(1, numplayers)
    else
        -- must be in ReadGame
        currplayer = history[1].who
    end
    local has1joker = {}
    for i = 1, numplayers do
        has1joker[i] = false
        player[i].score = 0
        player[i].turns = 0
        if startgame then
            for j = 1, maxrack do
                player[i].rack[j].state = full
                player[i].rack[j].tilech = ChooseTile()
                if player[i].rack[j].tilech == joker then
                    if has1joker[i] then
                        -- ensure starting rack for each player has at most one joker
                        while player[i].rack[j].tilech == joker do
                            player[i].rack[j].tilech = ChooseTile()
                            AddTile(joker)
                        end
                    end
                    has1joker[i] = true
                end
            end

            if jokerfirst and (i == currplayer) and not has1joker[i] then
                -- make sure first player has one joker
                local j = rand(1, maxrack)
                AddTile(player[i].rack[j].tilech)
                player[i].rack[j].tilech = joker
                RemoveTile(joker)
            end

            for j = 1, maxrack do
                start[i][j] = player[i].rack[j].tilech
            end
        else
            -- must be in ReadGame, so set rack using start[i] for
            -- later storing in history array (in OpenGame)
            for j = 1, maxrack do
                player[i].rack[j].state = full
                player[i].rack[j].tilech = start[i][j]
                RemoveTile(player[i].rack[j].tilech)
            end
        end
        for j = maxrack + 1, maxslots do
            player[i].rack[j].state = empty
        end
        player[i].numtiles = maxrack
    end
end

--------------------------------------------------------------------------------

function InitThrowBacks()
    for i = 1, numplayers do
        throwbacks[i] = 0
    end
end

--------------------------------------------------------------------------------

function SetComputerPict()
    -- Set currpict for current computer player.
    currpict = normalpict
    -- find player with best score
    local maxscore = -999999999
    local best = 1
    for p = 1, numplayers do
        if player[p].score > maxscore then
            maxscore = player[p].score
            best = p
        end
    end
    -- see if current computer player is behind by > 50 pts
    if (currplayer ~= best) and (player[currplayer].score < maxscore - 50) then
        currpict = sadpict
    end
    -- see if current computer player is ahead by > 50 pts
    if currplayer == best then
        maxscore = -999999999
        for p = 1, numplayers do
            if (p ~= currplayer) and (player[p].score > maxscore) then
                maxscore = player[p].score
            end
        end
        -- maxscore == 2nd best score
        if player[currplayer].score > maxscore + 50 then
            currpict = happypict
        end
    end
end

--------------------------------------------------------------------------------

function SortBySuit()
    -- Sort tiles in current player's rack by suits.
    local p, i, j, k, minpos, minsuit, card
    local cp = player[currplayer]
    
    p = 0
    for i = 1, maxslots do
        if cp.rack[i].state == full then p = p+1 end
    end
    if p == 0 then return end       -- rack is empty

    -- shift tiles to left end of rack
    for i = 1, p do
        if cp.rack[i].state == empty then
            j = i
            repeat j = j+1 until cp.rack[j].state ~= empty
            -- move tile from rack[j] to rack[i]
            cp.rack[j].state = empty
            card = cp.rack[j].tilech
            cp.rack[i].tilech = card
            cp.rack[i].state = full
        end
    end

    -- now sort rack[1..p] by suit using a simple bubble sort
    j = 1
    while j < p do
        minsuit = ORD(anysuit) + 1        -- joker = anysuit
        for i = j, p do
            if ORD(tilesuit[ cp.rack[i].tilech ]) < minsuit then
                minsuit = ORD(tilesuit[ cp.rack[i].tilech ])
                minpos = i
            end
        end
        if minpos > j then
            -- rack[minpos] has smallest suit, so shift j..minpos-1 right
            -- so that we keep original order (ie. don't swap)
            card = cp.rack[minpos].tilech
            for k = minpos - 1, j, -1 do
                cp.rack[k + 1].tilech = cp.rack[k].tilech
            end
            cp.rack[j].tilech = card
        end
        j = j+1
    end
end

--------------------------------------------------------------------------------

function SortByValue()
    -- Sort tiles in current player's rack by card values.
    local p, i, j, k, minpos, minval, card
    local cp = player[currplayer]
    
    p = 0
    for i = 1, maxslots do
        if cp.rack[i].state == full then p = p+1 end
    end
    if p == 0 then return end       -- rack is empty

    -- shift tiles to left end of rack
    for i = 1, p do
        if cp.rack[i].state == empty then
            j = i
            repeat j = j+1 until cp.rack[j].state ~= empty
            -- move tile from rack[j] to rack[i]
            cp.rack[j].state = empty
            card = cp.rack[j].tilech
            cp.rack[i].tilech = card
            cp.rack[i].state = full
        end
    end
    
    -- now sort rack[1..p] by value using a simple bubble sort
    j = 1
    while j < p do
        minval = 999
        for i = j, p do
            if tileval[ cp.rack[i].tilech ] < minval then
                minval = tileval[ cp.rack[i].tilech ]
                minpos = i
            end
        end
        if minpos > j then
            -- rack[minpos] has smallest value, so shift j..minpos-1 right
            -- so that we keep original order (ie. don't swap)
            card = cp.rack[minpos].tilech
            for k = minpos - 1, j, -1 do
                cp.rack[k + 1].tilech = cp.rack[k].tilech
            end
            cp.rack[j].tilech = card
        end
        j = j+1
    end
end

--------------------------------------------------------------------------------

function AutoSort()
    if sortsuit then
        SortByValue()
        SortBySuit()
    elseif sortvalue then
        SortBySuit()
        SortByValue()
    end
    dosuitsort = not sortsuit   -- for next SortTiles
end

--------------------------------------------------------------------------------

function YourMove()
    for i = 1, maxlines do
        infostr[i] = ""
    end
    if player[currplayer].machine then
        -- picture will be displayed, but set line 1 to player name
        infostr[1] = player[currplayer].name
        SetComputerPict()
    else
        infostr[1] = "Your go "..player[currplayer].name.."."
        if player[currplayer].turns == 0 then
            infostr[2] = "Click on DONE after"
            infostr[3] = "moving your cards."
        end
    end
    AutoSort()
end

--------------------------------------------------------------------------------

function Pause(msecs)
    cv.update()
    local t0 = glu.millisecs()
    while glu.millisecs() - t0 < msecs do end
end

--------------------------------------------------------------------------------

function SmallDelay()
    Pause(50)
end

--------------------------------------------------------------------------------

function DrawBackground()
    cv.paste(bgclip, bgx, bgy)
end

--------------------------------------------------------------------------------

function DrawComputerPlayer()
    cv.paste(currpict, innerinfo.left, innerinfo.top)
end

--------------------------------------------------------------------------------

local function DrawText(s, x, y)
    local wd, ht = cp.maketext(s)
    cv.blend(1)
    cp.pastetext(x, y)
    cv.blend(0)
    return wd
end

--------------------------------------------------------------------------------

local function TextWidth(s)
    local wd, ht = cp.maketext(s)
    return wd
end

--------------------------------------------------------------------------------

function ShowCard(tile, forceblue, x, y)
    local valstr = "-234567891JQKA"
    if tile == joker then
        cv.rgba(cp.blue)
        x = x + DrawText("?", x, y) + 1
    else
        local i = tileval[tile]
        local suit = tilesuit[tile]
        local ch = valstr:sub(i,i)
        if ch == '1' then ch = ch..'0' end
        
        if forceblue then
            cv.rgba(cp.blue)
        elseif suit == clubs or suit == spades then
            cv.rgba(cp.black)
        else
            -- suit is diamonds or hearts
            cv.rgba(cp.red)
        end
        x = x + DrawText(ch, x, y)

        if forceblue then
            -- draw tiny blue suit
            cv.paste(bluesuit[suit], x, y)
        else
            -- draw tiny red/black suit
            cv.paste(tinysuit[suit], x, y)
        end
        if suit == diamonds then
            x = x + 8
        else
            x = x + 10
        end
    end
    cv.rgba(cp.black)
    return x
end

--------------------------------------------------------------------------------

function DrawDownTriangle(r)
    local len = 0
    for i = 0, 4 do
        local x = r.right - 9 - i
        local y = r.bottom - 5 - i
        cv.line(x, y, x+len, y)
        len = len + 2
    end
end

--------------------------------------------------------------------------------

function DisplayTilesLeft()
    cv.rgba(paleyellow)
    FillRect(innerleft)
    cv.rgba(cp.black)

    cv.font("default", 8)

    local x = innerleft.left + 5
    local y = innerleft.top + 2
    if glu.os() == "Linux" then y = y - 2 end
    if numthrown > 0 then
        DrawText('Replace ', x, y)
        x = x + TextWidth('Replace ')
        for n = 1, numthrown do
            x = ShowCard(throwntiles[n], false, x, y)
        end
    else
        DrawText('Cards remaining:  '..tilesleft, x, y)
    end
    
    if tilesleft > 0 then
        DrawDownTriangle(innerleft)
    end
    
    if (not player[currplayer].machine) and nummoves == 0 and numthrown == 0 then
        cv.rgba(cp.white)
        DrawText('Drag cards here to replace them:', innerleft.left, innerleft.top - 20)
        cv.rgba(cp.black)
    end
end

--------------------------------------------------------------------------------

function DrawEmptyTile(x, y)
    cv.paste(tileclip, x, y)
end

--------------------------------------------------------------------------------

local function DrawCard(card, x, y)
    cv.paste(card, x, y)
end

--------------------------------------------------------------------------------

function DrawEmptySquare(row, col)
    local x = boardrect.left - bgx + (col - 1) * boardsquare
    local y = boardrect.top - bgy + (row - 1) * boardsquare
    -- copy empty square in board from bgclip into a temporary clip
    cv.target(bgclip)
    cv.copy(x, y, boardsquare, boardsquare, "temp")
    cv.target()
    x = x + bgx
    y = y + bgy
    cv.paste("temp", x, y)
    cv.delete("temp")
end

--------------------------------------------------------------------------------

local function CopyTileToBoard(card, row, col)
    local x = boardrect.left + (col - 1) * boardsquare
    local y = boardrect.top + (row - 1) * boardsquare
    DrawCard(card, x, y)
    if playsounds then
        cv.update()
        cv.sound("play", "sounds/tile.wav", volume)
        Pause(80)
    end
end

--------------------------------------------------------------------------------

function DrawEmptyRack(col)
    local x = innerrack.left - bgx + (col - 1) * racksquare + 1
    local y = innerrack.top - bgy + 1
    -- copy empty square in rack from bgclip into a temporary clip
    cv.target(bgclip)
    cv.copy(x, y, racksquare, racksquare, "temp")
    cv.target()
    x = x + bgx
    y = y + bgy
    cv.paste("temp", x, y)
    cv.delete("temp")
end

--------------------------------------------------------------------------------

function CopyTileToRack(card, col, p)
    local x = innerrack.left + (col - 1) * racksquare + 1
    local y = innerrack.top + 1
    if player[p].machine and (not showcompcards) and (not endofgame) then
        DrawEmptyTile(x, y)
    else
        DrawCard(card, x, y)
    end
    if playsounds then
        cv.update()
        cv.sound("play", "sounds/tile.wav", volume)
        Pause(80)
    end
end

--------------------------------------------------------------------------------

function DisplayRack()
    local oldplay = playsounds
    playsounds = false          -- turn off tile clicks during rack update

    -- draw cards in rack
    local cp = player[currplayer]
    for i = 1, maxslots do
        if cp.rack[i].state ~= empty then
            CopyTileToRack(cp.rack[i].tilech, i, currplayer)
        end
    end
    
    playsounds = oldplay
end

--------------------------------------------------------------------------------

function DrawSmallCard(card, x, y)
    cv.paste("small-"..card, x, y)
    x = x + smallsize + 1
    return x
end

--------------------------------------------------------------------------------

function DisplayInfo()
    -- Display current message stored in infostr lines.
    local whichplayer = 0
    if endofgame then
        if winner == 0 then
            -- tie
        elseif player[winner].machine then
            whichplayer = winner
            currpict = happypict
        else
            -- human winner == 1 or 2 so check for computer opponent
            if (winner == 1) and (player[2].machine) then whichplayer = 2 end
            if (winner == 2) and (player[1].machine) then whichplayer = 1 end
            currpict = sadpict
        end
    else
        whichplayer = currplayer
    end
    
    if (whichplayer > 0) and player[whichplayer].machine then
        DrawComputerPlayer()
        cv.rgba(cp.white)
    else
        cv.rgba(cp.black)
        FillRect(innerinfo)
        cv.rgba(cp.yellow)
    end
    
    local oldfont, oldsize = cv.font("default-bold", 10)
    if glu.os() == "Linux" then cv.font("default-bold", 9) end
    
    for i = 1, maxlines do
        if #infostr[i] > 0 then
            local x = innerinfo.left + 5
            local y = innerinfo.top + 4 + 18 * (i - 1)
            if infostr[i]:sub(1,1) == CHR(1) then
                -- rest of infostr[i] consists of a set of cards
                for c = 2, #infostr[i] do
                    x = DrawSmallCard(infostr[i]:sub(c,c), x, y)
                end
            else
                DrawText(infostr[i], x, y)
            end
        end
    end
    
    cv.font(oldfont, oldsize)
    cv.rgba(cp.black)
end

--------------------------------------------------------------------------------

function DisplayScoreTitles()
    local r = Rect(playerrect[1])
    OffsetRect(r, 0, -22)
    local x = r.left + noteindent
    local y = r.top
    
    DrawText('Players', x, y)
    x = r.right - 64 - TextWidth('Moves')
    DrawText('Moves', x, y)
    x = r.right - 20 - TextWidth('Scores')
    DrawText('Scores', x, y)
end

--------------------------------------------------------------------------------

function DisplayPlayer(p)
    local x = playerrect[p].left + noteindent
    local y = playerrect[p].top
    local turns = tostring(player[p].turns)
    local score = tostring(player[p].score)
    
    DrawText(player[p].name, x, y)

    x = playerrect[p].right - 64 - TextWidth(turns)
    DrawText(turns, x, y)

    x = playerrect[p].right - 20 - TextWidth(score)
    DrawText(score, x, y)

    -- show whose turn it is
    if p == currplayer then
        x = playerrect[p].left + 1
        y = y + 4
        -- draw small dot
        cp.fill_ellipse(x, y, 5, 5, 0, cp.black)
    end

    -- draw downwards triangle at end of playerrect
    DrawDownTriangle(playerrect[p])
end

--------------------------------------------------------------------------------

function DisplayBonus()
    local bonustitle = 'Bonus hands'
    local x = bonusrect.left - 4 - TextWidth(bonustitle)
    local y = bonusrect.top

    DrawText(bonustitle, x, y)
    
    -- draw downwards triangle in bonusrect
    DrawDownTriangle(bonusrect)
end

--------------------------------------------------------------------------------

function DisplayStats()
    local statstitle = 'Statistics'
    local x = statsrect.left - 4 - TextWidth(statstitle)
    local y = statsrect.top
    
    DrawText(statstitle, x, y)
    
    -- draw downwards triangle in statsrect
    DrawDownTriangle(statsrect)
end

--------------------------------------------------------------------------------

local function DrawCardOnJoker(row, col)
    -- Draw blue card on frozen joker tile on board.
    local r = Rect()
    r.top = boardrect.top + (row - 1) * boardsquare
    r.left = boardrect.left + (col - 1) * boardsquare
    r.bottom = r.top + boardsquare - 3  -- avoid changing bottom edge
    r.right = r.left + boardsquare - 3  -- avoid changing right edge
    
    DrawCard(board[row][col].jokerch, r.left, r.top)

    cv.copy(r.left+1, r.top+1, r.right-r.left, r.bottom-r.top, "temp")
    cv.target("temp")
    local suit = tilesuit[ board[row][col].jokerch ]
    if suit == diamonds or suit == hearts then
        -- change red components to blue
        cv.replace("*b *# *r *#")
        -- use a brighter blue (because major red component wasn't 255)
        cv.replace("*#-10 *#-10 *#+40 *#")
    else
        -- change black and grays to equivalent shades of blue
        cv.rgba(0,0,255,255)
        cv.replace("*# *# * *#")
        cv.rgba(cp.black)
    end
    cv.target()
    cv.paste("temp", r.left+1, r.top+1)
    cv.delete("temp")
end

--------------------------------------------------------------------------------

function GetBoardSquare(x, y)
    -- return which board square contains given point
    local row = (y - boardrect.top) // boardsquare + 1
    local col = (x - boardrect.left) // boardsquare + 1
    return row, col
end

--------------------------------------------------------------------------------

function DisplayBoard()
    local oldplay = playsounds
    playsounds = false          -- turn off tile clicks during board update

    for r = 1, 15 do
        for c = 1, 15 do
            local b = board[r][c]
            if b.state ~= empty then
                CopyTileToBoard(b.tilech, r, c)
                if (b.state == frozen) and (b.tilech == joker) then
                    DrawCardOnJoker(r, c)
                end
            end
        end
    end
    
    playsounds = oldplay
end

--------------------------------------------------------------------------------

function ShowButtons()
    -- first draw shadows
    local s = {0,0,0,96}
    local o = 4
    local r = 16
    cp.round_rect(helprect.left+o, helprect.top+o, helpbutton.wd, cp.buttonht+2, r, 0, s)
    if not endofgame then
        cp.round_rect(donerect.left+o, donerect.top+o, donebutton.wd, cp.buttonht+2, r, 0, s)
        if not player[currplayer].machine then
            cp.round_rect(sortrect.left+o, sortrect.top+o, sortbutton.wd, cp.buttonht+2, r, 0, s)
        end
    end
    
    -- use different button colors for each background
    if bgtheme == "aqua" then
        -- use dark blue buttons
        helpbutton.setbackcolor{20,64,255,255}
        donebutton.setbackcolor{20,64,255,255}
        sortbutton.setbackcolor{20,64,255,255}
    elseif bgtheme == "stone" then
        -- use dark gray buttons
        helpbutton.setbackcolor{96,96,96,255}
        donebutton.setbackcolor{96,96,96,255}
        sortbutton.setbackcolor{96,96,96,255}
    else
        -- bgtheme = "wood" so use brown buttons
        helpbutton.setbackcolor{150,50,10,255}
        donebutton.setbackcolor{150,50,10,255}
        sortbutton.setbackcolor{150,50,10,255}
    end

    helpbutton.show(helprect.left, helprect.top)
    
    if not endofgame then
        if player[currplayer].machine then
            donebutton.setlabel("MOVE", false)
        else
            donebutton.setlabel("DONE", false)
            sortbutton.show(sortrect.left, sortrect.top)
        end
        donebutton.show(donerect.left, donerect.top)
    end
end

--------------------------------------------------------------------------------

function HideButtons()
    helpbutton.hide()
    sortbutton.hide()
    donebutton.hide()
end

--------------------------------------------------------------------------------

function DisplayGameOver()
    -- display translucent red GAME OVER in middle of board
    cv.rgba(255,0,0,128)
    local oldfont, oldsize = cv.font("default-bold", 72)
    local textwd, textht = cp.maketext("GAME OVER")
    cv.blend(1)
    local boardwd = boardrect.right - boardrect.left + 1
    local boardht = boardrect.bottom - boardrect.top + 1
    cp.pastetext(boardrect.left+(boardwd-textwd)//2, boardrect.top+(boardht-textht)//2)
    cv.blend(0)
    cv.font(oldfont, oldsize)
    cv.rgba(cp.black)
end

--------------------------------------------------------------------------------

function DrawEverything()
    DrawMenuBar()

    -- hide all buttons before drawing background image
    HideButtons()
    DrawBackground()
    ShowButtons()
    
    -- show number of cards remaining
    DisplayTilesLeft()
    
    -- show cards in current player's rack
    DisplayRack()
    
    -- show current text in info box
    DisplayInfo()
    
    -- show text in notepad
    if glu.os() == "Linux" then
        cv.font("default", 9)
    else
        cv.font("default-bold", 9)
    end
    DisplayScoreTitles()
    for p = 1, numplayers do DisplayPlayer(p) end
    DisplayBonus()
    DisplayStats()
    
    -- show cards on board, if any
    DisplayBoard()
    
    if endofgame then DisplayGameOver() end

    cv.update()
end

--------------------------------------------------------------------------------

function CheckPlayers(startp, endp)
    -- Set info for given players, normally 1..numplayers.
    -- This is where computer players are detected and machine/skill set.
    -- Also set allmachines and statspath here.
    
    for i = startp, endp do
        player[i].machine = false
        if #player[i].name == 0 then
            player[i].name = "UNKNOWN"
        elseif #player[i].name > maxnamelen then
            player[i].name = player[i].name:sub(1,maxnamelen)
        end
        local lastch = player[i].name:sub(-1)
        if (lastch >= '0') and (lastch <= '9') then
            player[i].machine = true
            player[i].skill = ORD(lastch) - ORD('0')
        end
    end
    
    -- determine if all players are computers
    allmachines = true
    for i = 1, numplayers do
        if not player[i].machine then allmachines = false end
    end
    
    -- set statspath (the file used to save statistics)
    statspath = ccdatadir.."CC stats for "
    for i = 1, numplayers do
        statspath = statspath..player[i].name
        if i < numplayers then
            statspath = statspath.." vs "
        end
    end
end

--------------------------------------------------------------------------------

function NewGame()
    if #player[1].name == 0 then
        -- very first game
        DrawMenuBar()
        DrawBackground()
        -- ensure user sees HELP button
        ShowButtons()
        cv.update()
        
        -- very first game so prompt user for their name
        local title = "Welcome to CrossCards!"
        local msg = [[
CrossCards is a combination of Scrabble and poker
so it's very easy to learn if you know those games.
If not, hit the HELP button.

Enter your player name (up to 10 characters):]]
        local s = glu.getstring(msg, "", title)
        if s == nil or #s == 0 then
            glu.exit()
        elseif #s > maxnamelen then
            s = s:sub(1,maxnamelen)
        end
        
        player[1].name = s
        player[2].name = "Joker 9"
    end

    if nummoves > 0 and not endofgame then
        local answer = glu.savechanges("Save the current game?", 
            "Click Cancel if you don't want to start a new game.")
        if answer == "cancel" then
            return
        elseif answer == "yes" then
            SaveGame()
        elseif answer == "no" then
            -- continue
        end
    end

    -- set allmachines and statspath
    CheckPlayers(1, numplayers)
    
    -- init player racks
    for p = 1, numplayers do
        player[p].numtiles = 0
        for j = 1, maxslots do
            player[p].rack[j].state = empty
        end
    end
    
    InitBag()
    ClearBoard()
    startgame = true
    InitPlayers()
    InitThrowBacks()
    endofgame = false
    numthrown = 0
    nummoves = 0
    canredo = 0
    passed = 0
    YourMove()
    DrawEverything()
end

--------------------------------------------------------------------------------

function SortTiles()
    -- Sort tiles in current player's rack, toggling by suit or value.
    local p = 0

    if endofgame or player[currplayer].machine then return end
    
    for i = 1, maxslots do
        if player[currplayer].rack[i].state == full then p = p+1 end
    end
    -- do nothing if rack is empty
    if p == 0 then return end
    
    if playsounds then
        cv.sound("play", "sounds/sort.wav", volume)
    end
    
    if dosuitsort then
        SortByValue()
        SortBySuit()
    else
        SortBySuit()
        SortByValue()
    end
    dosuitsort = not dosuitsort
    
    DrawEverything()
end

--------------------------------------------------------------------------------

function SetMenuColor()
    -- set background color of menu bar to match theme
    if bgtheme == "aqua" then
        mbar.setbackcolor{20,64,255,255}    -- dark blue
    elseif bgtheme == "stone" then
        mbar.setbackcolor{96,96,96,255}     -- dark gray
    else -- bgtheme = "wood"
        mbar.setbackcolor{150,50,10,255}    -- dark brown
    end
    mbar.settextcolor(cp.white)
end

--------------------------------------------------------------------------------

function SetTheme(theme)
    bgtheme = theme
    
    -- load the new background image into bgclip and draw it
    cv.load("images/bg-"..bgtheme..".png", bgclip)
    cv.paste(bgclip, bgx, bgy)
    
    SetMenuColor()      -- update color of menu bar to match theme
    DrawEverything()    -- calls DrawMenuBar
end

--------------------------------------------------------------------------------

function ToggleSounds()
    playsounds = not playsounds
    mbar.tickitem(3, 5, playsounds)
end

--------------------------------------------------------------------------------

function ToggleBlinking()
    blinkcompmoves = not blinkcompmoves
    mbar.tickitem(3, 6, blinkcompmoves)
end

--------------------------------------------------------------------------------

function ToggleCompCards()
    showcompcards = not showcompcards
    DrawEverything()    -- calls DrawMenuBar
end

--------------------------------------------------------------------------------

function ToggleFullScreen()
    fullscreen = 1 - fullscreen
    glu.setoption("fullscreen", fullscreen)
    -- DrawEverything will soon be called by CheckViewSize
end

--------------------------------------------------------------------------------

function ToggleAutoSave()
    autosave = not autosave
    mbar.tickitem(3, 9, autosave)
end

--------------------------------------------------------------------------------

function DecreaseVolume()
    volume = volume - 0.1
    if volume < 0.1 then volume = 0.1 end
    cv.sound("play", "sounds/sort.wav", volume)
end

--------------------------------------------------------------------------------

function IncreaseVolume()
    volume = volume + 0.1
    if volume > 1.0 then volume = 1.0 end
    cv.sound("play", "sounds/sort.wav", volume)
end

--------------------------------------------------------------------------------

function ShowHelp()
    glu.help("help/index.html")
end

--------------------------------------------------------------------------------

local function GetTilesAdded()
    -- Return number of tiles added to board (full squares) and their limits.
    local tilesadded = 0
    local minr = 16             -- first full square will change this
    local maxr = 0              -- ditto
    local minc = 16             -- ditto
    local maxc = 0              -- ditto
    for r = 1, 15 do
        for c = 1, 15 do
            if board[r][c].state == full then
                if r < minr then minr = r end
                if r > maxr then maxr = r end
                if c < minc then minc = c end
                if c > maxc then maxc = c end
                tilesadded = tilesadded + 1
            end
        end
    end
    return tilesadded, minr, maxr, minc, maxc
end

--------------------------------------------------------------------------------

local function LegalHand(hand, handlen)
    -- Return true if given hand is legal, or if jokers make legality uncertain.
    -- Note that hand[1..handlen] == twoSpades..joker.
    
    if handlen < 2 then return false end        -- min hand == 2 cards
    if handlen > 5 then return false end        -- max hand == 5 cards

    local val = {}      -- ARRAY [1..5] OF INT16
    local suit = {}     -- ARRAY [1..5] OF suits
    
    -- set val and suit arrays; also set knownpos to first non-joker card
    local knownpos = 0
    for i = 1, handlen do
        val[i] = tileval[ hand[i] ]
        suit[i] = tilesuit[ hand[i] ]
        if (knownpos == 0) and (val[i] > 0) then knownpos = i end
    end
    if knownpos == 0 then
        -- all cards are jokers
        return true
    end
    
    if handlen == 2 then
        -- check for pair
        return (val[1] == val[2]) or (val[1] == 0) or (val[2] == 0)
    end
    
    if handlen == 3 then
        -- check for 3 of a kind
        return ((val[1] == val[2]) or (val[1] == 0) or (val[2] == 0)) and
               ((val[2] == val[3]) or (val[2] == 0) or (val[3] == 0)) and
               ((val[1] == val[3]) or (val[1] == 0) or (val[3] == 0))
    end
    
    if handlen == 4 then
        -- check for two pairs or 4 of a kind
        return ((val[1] == val[2]) or (val[1] == 0) or (val[2] == 0)) and
               ((val[3] == val[4]) or (val[3] == 0) or (val[4] == 0))
    end
    
    -- handlen == 5 so use similar code as in GetBonusScore
    local flush = true
    local fivematch = true
    local incr = (val[knownpos] >= 1 + knownpos) and        -- min == 2,3,4,5,6
                 (val[knownpos] <= 9 + knownpos)            -- max == 10,J,Q,K,A
    local decr = (val[knownpos] >= 7 - knownpos) and        -- min == 6,5,4,3,2
                 (val[knownpos] <= 15 - knownpos)           -- max == A,K,Q,J,10
    local i = knownpos + 1
    while i <= 5 do
        flush = flush and ((val[i] == 0) or (suit[i] == suit[knownpos]))
        fivematch = fivematch and ((val[i] == 0) or (val[i] == val[knownpos]))
        incr = incr and ((val[i] == 0) or (val[i] - val[knownpos] == (i - knownpos)))
        decr = decr and ((val[i] == 0) or (val[i] - val[knownpos] == -(i - knownpos)))
        i = i + 1
    end
    
    if flush then
        return true         -- royal flush, straight flush, or flush
    end
    
    if fivematch then
        return true         -- 5 of a kind
    end
    
    if incr or decr then
        return true         -- straight
    end
    
    -- check for full house (2x + 3y or 3x + 2y)
    if ((val[1] == val[2]) or (val[1] == 0) or (val[2] == 0)) and
       ((val[4] == val[5]) or (val[4] == 0) or (val[5] == 0)) then
        -- first 2 cards match and last 2 cards match,
        -- so see if middle card matches either the first 2 or the last 2
        if val[3] == 0 then
            return true                 -- middle card joker
        end
        -- match with first 2?
        if val[1] > 0 then
            if val[1] == val[3] then return true end
        elseif val[2] > 0 then
            if val[2] == val[3] then return true end
        else
            return true                 -- val[1] and val[2] both joker
        end
        -- match with last 2?
        if val[4] > 0 then
            if val[4] == val[3] then return true end
        elseif val[5] > 0 then
            if val[5] == val[3] then return true end
        else
            return true                 -- val[4] and val[5] both joker
        end
    end
    
    -- only get here if illegal 5-card hand
    return false
end

--------------------------------------------------------------------------------

local function GetBonusScore(hand)
    -- Return bonus score for given 5-card hand.
    -- Note that hand[1..5] == twoSpades..joker.  If one or more jokers are present
    -- then bonus score might be ambiguous, so we choose the highest.
    
    local val = {}      -- ARRAY [1..5] OF INT16
    local suit = {}     -- ARRAY [1..5] OF suits
    
    -- set val and suit arrays; also set knownpos to first non-joker card
    local knownpos = 0
    for i = 1, 5 do
        val[i] = tileval[ hand[i] ]
        suit[i] = tilesuit[ hand[i] ]
        if (knownpos == 0) and (val[i] > 0) then knownpos = i end
    end
    if knownpos == 0 then
        -- all cards are jokers so return highest bonus (royal flush)
        return rfbonus
    end
    
    -- check if all cards have same suit (ie. flush of some sort)
    -- and/or if all cards have same value (ie. 5 of a kind)
    -- and/or for increasing/decreasing sequence (ie. straight)
    local flush = true
    local fivematch = true
    local incr = (val[knownpos] >= 1 + knownpos) and        -- min == 2,3,4,5,6
                 (val[knownpos] <= 9 + knownpos)            -- max == 10,J,Q,K,A
    local decr = (val[knownpos] >= 7 - knownpos) and        -- min == 6,5,4,3,2
                 (val[knownpos] <= 15 - knownpos)           -- max == A,K,Q,J,10
    local i = knownpos + 1
    while i <= 5 do
        flush = flush and ((val[i] == 0) or (suit[i] == suit[knownpos]))
        fivematch = fivematch and ((val[i] == 0) or (val[i] == val[knownpos]))
        incr = incr and ((val[i] == 0) or (val[i] - val[knownpos] == (i - knownpos)))
        decr = decr and ((val[i] == 0) or (val[i] - val[knownpos] == -(i - knownpos)))
        i = i + 1
    end
    
    -- first check for royal flush or straight flush
    if flush then
        if incr then
            if val[knownpos] == 9 + knownpos then
                return rfbonus                                    -- royal flush
            else
                return sfbonus                                    -- straight flush
            end
        elseif decr then
            if val[knownpos] == 15 - knownpos then
                return rfbonus                                    -- royal flush
            else
                return sfbonus                                    -- straight flush
            end
        else
            -- 5 of a kind takes precedence over ordinary flush
            if fivematch then
                return fivebonus                                  -- 5 of a kind
            else
                return fbonus                                     -- flush
            end
        end
    end
    
    -- 5 of a kind takes precedence over straight
    if fivematch then
        return fivebonus                                          -- 5 of a kind
    end
    
    -- straight takes precedence over full house
    if incr or decr then
        return sbonus                                             -- straight
    end
    
    -- check for full house (2x + 3y or 3x + 2y)
    if ((val[1] == val[2]) or (val[1] == 0) or (val[2] == 0)) and
       ((val[4] == val[5]) or (val[4] == 0) or (val[5] == 0)) then
        -- first 2 cards match and last 2 cards match,
        -- so see if middle card matches either the first 2 or the last 2
        if val[3] == 0 then
            return fhbonus                 -- middle card joker
        end
        -- match with first 2?
        if val[1] > 0 then
            if val[1] == val[3] then return fhbonus end
        elseif val[2] > 0 then
            if val[2] == val[3] then return fhbonus end
        else
            return fhbonus                 -- val[1] and val[2] both joker
        end
        -- match with last 2?
        if val[4] > 0 then
            if val[4] == val[3] then return fhbonus end
        elseif val[5] > 0 then
            if val[5] == val[3] then return fhbonus end
        else
            return fhbonus                 -- val[4] and val[5] both joker
        end
    end
    
    -- only get here if illegal 5-card hand
    return 0
end

--------------------------------------------------------------------------------

local function TilesToHand(minr, maxr, minc, maxc, jokersfixed)
    -- Store hand represented by given tiles in hands array.
    local hand = {}
    local len = 0
    for r = minr, maxr do
        for c = minc, maxc do
            if board[r][c].state ~= empty then
                len = len + 1
                if board[r][c].tilech == joker then
                    if (board[r][c].state == full) and (not jokersfixed) then
                        -- jokerch is undefined, so put in joker for GetBonusScore
                        hand[len] = joker
                    else
                        hand[len] = board[r][c].jokerch     -- twoSpades..aceHearts
                    end
                else
                    hand[len] = board[r][c].tilech          -- twoSpades..aceHearts
                end
            end
        end
    end
    handscreated = handscreated + 1
    hands[handscreated] = hand
end

--------------------------------------------------------------------------------

local function GetHandScore(minr, maxr, minc, maxc)
    -- Calculate score of hand represented by given tiles.
    local handscore = 0
    local multfactor = 1
    for r = minr, maxr do
        for c = minc, maxc do
            local square = board[r][c]
            local cardval = tileval[square.tilech]
            if square.state == frozen then
                handscore = handscore + cardval
            elseif square.state == full then
                local t = square.type
                if t == cardx2 then
                    handscore = handscore + cardval * 2
                elseif t == handx2 then
                    handscore = handscore + cardval
                    multfactor = multfactor * 2
                elseif t == cardx3 then
                    handscore = handscore + cardval * 3
                elseif t == handx3 then
                    handscore = handscore + cardval
                    multfactor = multfactor * 3
                else
                    -- plain
                    handscore = handscore + cardval
                end
            end
        end
    end
    return handscore * multfactor
end

--------------------------------------------------------------------------------

local function GetScore(tilesadded, minr, maxr, minc, maxc, jokersfixed)
    -- Calculate total score of tiles added to board (square states == full).
    -- If the jokersfixed flag is true then jokers have been fixed but those
    -- board squares are still full (not yet frozen).
    -- Also sets global hands array and handscreated.
    
    local topr, botr, leftc, rightc, horizontal
    local currscore = 0
    local bonus = 0
    handscreated = 0
    
    -- determine slope of primary hand
    if minr ~= maxr then
        horizontal = false
    elseif minc ~= maxc then
        horizontal = true
    else
        -- minr == maxr and minc == maxc so only one tile was added
        if tilesadded ~= 1 then glu.warn('tilesadded ~= 1') end
        horizontal = (board[minr][minc-1].state == frozen) or
                     (board[minr][maxc+1].state == frozen)
        -- note that if up/down neighbour frozen as well then horizontal slope
        -- is arbitrarily selected even if the vertical hand is longer
    end
    topr = minr
    botr = maxr
    leftc = minc
    rightc = maxc
    if horizontal then
        -- check for frozen cards left and right
        while board[minr][leftc - 1].state == frozen do
            leftc = leftc - 1
        end
        while board[minr][rightc + 1].state == frozen do
            rightc = rightc + 1
        end
        currscore = currscore + GetHandScore(topr, botr, leftc, rightc)
        TilesToHand(topr, botr, leftc, rightc, jokersfixed)
    
        if tilesadded == 5 then
            -- 5 cards in hand, so check for bonus
            bonus = GetBonusScore(hands[handscreated])
            currscore = currscore + bonus
        end
    
        -- now calculate score of extra vertical hands
        for c = minc, maxc do
            if board[minr][c].state == full then
                topr = minr
                botr = minr
                while board[topr - 1][c].state == frozen do
                    topr = topr - 1
                end
                while board[botr + 1][c].state == frozen do
                    botr = botr + 1
                end
                if (topr < minr) or (botr > minr) then
                    currscore = currscore + GetHandScore(topr, botr, c, c)
                    TilesToHand(topr, botr, c, c, jokersfixed)
                end
            end
        end
    else
        -- primary hand is vertical, so check for frozen cards top & bot
        while board[topr - 1][minc].state == frozen do
            topr = topr - 1
        end
        while board[botr + 1][minc].state == frozen do
            botr = botr + 1
        end
        currscore = currscore + GetHandScore(topr, botr, leftc, rightc)
        TilesToHand(topr, botr, leftc, rightc, jokersfixed)
    
        if tilesadded == 5 then
            -- 5 cards in hand, so check for bonus
            bonus = GetBonusScore(hands[handscreated])
            currscore = currscore + bonus
        end
        
        -- now calculate score of extra horizontal hands
        for r = minr, maxr do
            if board[r][minc].state == full then
                leftc = minc
                rightc = minc
                while board[r][leftc - 1].state == frozen do
                    leftc = leftc - 1
                end
                while board[r][rightc + 1].state == frozen do
                    rightc = rightc + 1
                end
                if (leftc < minc) or (rightc > minc) then
                    currscore = currscore + GetHandScore(r, r, leftc, rightc)
                    TilesToHand(r, r, leftc, rightc, jokersfixed)
                end
            end
        end
    end
    
    return currscore, bonus
end

--------------------------------------------------------------------------------

function DrawPopUp(r)
    -- draw pop-up window
    cv.rgba(paleyellow)
    FillRect(r)
    cv.rgba(cp.black)
    
    -- draw gray outline and shadows
    cv.rgba(cp.gray)
    cv.line(r.left, r.top, r.right, r.top)
    cv.line(r.left, r.top, r.left, r.bottom)
    cv.line(r.right, r.top, r.right, r.bottom)
    cv.line(r.left, r.bottom, r.right, r.bottom)
    cv.rgba(cp.black)
    DrawShadows(r)
end

--------------------------------------------------------------------------------

function DisableCard(x, y)
    local r = Rect()
    r.left = x
    r.top = y
    r.right = r.left + smallsize - 1
    r.bottom = r.top + smallsize - 1
    cv.blend(1)
    cv.rgba(255,255,255,200)
    FillRect(r)
    cv.rgba(cp.black)
    cv.blend(0)
end

--------------------------------------------------------------------------------

function Beep()
    if playsounds then glu.beep() end
end

--------------------------------------------------------------------------------

function FixJoker(validcards, row, col)
    -- clear any "Illegal hand" message and show potential score
    -- (note that line 3 may show bonus points)
    UpdateCurrentScore()
    ChangeInfo(4, "Pick a card for the")
    ChangeInfo(5, "selected joker.")
    DrawEverything()

    -- select given joker tile in boardrect
    local selrect = Rect()
    selrect.top = boardrect.top + (row - 1) * boardsquare + 1
    selrect.left = boardrect.left + (col - 1) * boardsquare + 1
    selrect.bottom = selrect.top + boardsquare - 3
    selrect.right = selrect.left + boardsquare - 3
    InvertRect(selrect)

    -- create pop-up window for selecting a card
    local sqsize = smallsize + 1
    local r = Rect()
    r.top = innerinfo.bottom - 3
    r.bottom = r.top + sqsize * 13 + 2
    r.left = innerinfo.left + 27
    r.right = r.left + sqsize * 4 + 2
    DrawPopUp(r)
    
    -- draw cards in columns (we disable cards that cannot make a legal hand)
    local x = r.left + 2
    local y = r.top + 2
    for card = ORD(aceSpades), ORD(twoSpades), -1 do
        DrawSmallCard(CHR(card), x, y)
        if not validcards:find(CHR(card),1,true) then DisableCard(x, y) end
        y = y + sqsize
    end
    x = x + sqsize
    y = r.top + 2
    for card = ORD(aceClubs), ORD(twoClubs), -1 do
        DrawSmallCard(CHR(card), x, y)
        if not validcards:find(CHR(card),1,true) then DisableCard(x, y) end
        y = y + sqsize
    end
    x = x + sqsize
    y = r.top + 2
    for card = ORD(aceDiamonds), ORD(twoDiamonds), -1 do
        DrawSmallCard(CHR(card), x, y)
        if not validcards:find(CHR(card),1,true) then DisableCard(x, y) end
        y = y + sqsize
    end
    x = x + sqsize
    y = r.top + 2
    for card = ORD(aceHearts), ORD(twoHearts), -1 do
        DrawSmallCard(CHR(card), x, y)
        if not validcards:find(CHR(card),1,true) then DisableCard(x, y) end
        y = y + sqsize
    end

    cv.update()
    
    -- wait for user to select a card
    local selcard = ""
    while true do
        local event = glu.getevent()
        if event:find("^cclick") then
            local _, x, y, button, mods = split(event)
            x = tonumber(x)
            y = tonumber(y)
            if PtInRect(x, y, r) then
                local i = (x - r.left) // sqsize
                local j = (y - r.top) // sqsize
                if i > 3 then i = 3 end
                if j > 12 then j = 12 end
                local card = CHR(ORD(aceSpades) + i*13 - j)
                if validcards:find(card,1,true) then
                    selcard = card
                    break
                else
                    Beep()
                end
            else
                break
            end
        elseif event == "key return none" then
            break
        end
    end
    
    ClearInfo()
    if selcard == "" then
        -- no card selected
        DrawEverything()
        return false
    else
        board[row][col].jokerch = selcard
        DrawEverything()
        return true
    end
end

--------------------------------------------------------------------------------

function GetValidChoices(tilesadded, minr, maxr, minc, maxc, r, c)
    -- Return a string containing all the cards that can be used to
    -- replace the joker at board[r][c] and make a legal hand.
    local validcards = ""
    for ordch = ORD(twoSpades), ORD(aceHearts) do
        local card = CHR(ordch)
        
        board[r][c].tilech = card       -- temporarily change joker to card
        
        -- call GetScore to set hands[1]..hands[handscreated]
        local currscore, bonus = GetScore(tilesadded, minr, maxr, minc, maxc, false)
        local legal = 0
        for i = 1, handscreated do
            if LegalHand(hands[i], #hands[i]) then legal = legal + 1 end
        end
        
        board[r][c].tilech = joker      -- restore joker
        
        if legal == handscreated then
            validcards = validcards..card
        end
    end
    return validcards
end

--------------------------------------------------------------------------------

function LegalMove(tilesadded, minr, maxr, minc, maxc, showmsg)
    -- Current player has added at least one tile to the board
    -- so return true if move is legal (and fix any joker tiles if showmsg true),
    -- otherwise display a suitable error message (if showmsg) and return false.
    
    -- first move must cover middle square and add at least 2 tiles
    if board[8][8].state == empty then
        if showmsg then
            glu.warn("First move must cover the middle square.")
        end
        return false
    end
    if (board[8][8].state == full) and (tilesadded < 2) then
        if showmsg then
            glu.warn("First move must add at least 2 cards to the board.")
        end
        return false
    end
    
    -- added tiles must form a horizontal or vertical line
    if minr == maxr then
        -- horizontal line or just one tile
    elseif minc == maxc then
        -- vertical line
    else
        if showmsg then
            glu.warn("Added cards must form a horizontal or vertical line.")
        end
        return false
    end
    
    if board[8][8].state == frozen then
        -- not the first move, so at least one added tile
        -- must have a frozen U/D/L/R neighbour
        local neighbours = 0
        for r = minr, maxr do
            for c = minc, maxc do
                if board[r][c].state == full then
                    if board[r - 1][c].state == frozen then
                        neighbours = neighbours + 1
                    end
                    if board[r + 1][c].state == frozen then
                        neighbours = neighbours + 1
                    end
                    if board[r][c - 1].state == frozen then
                        neighbours = neighbours + 1
                    end
                    if board[r][c + 1].state == frozen then
                        neighbours = neighbours + 1
                    end
                end
            end
        end
        if neighbours == 0 then
            if showmsg then
                glu.warn("Added cards must be connected to an existing hand.")
            end
            return false
        end
    end
    
    -- check for empty squares
    for r = minr, maxr do
        for c = minc, maxc do
            if board[r][c].state == empty then
                if showmsg then
                    glu.warn("Added cards must form an unbroken line.")
                end
                return false
            end
        end
    end
    
    -- check for any jokers
    for r = minr, maxr do
        for c = minc, maxc do
            if (board[r][c].state == full) and (board[r][c].tilech == joker) then
                local validcards = GetValidChoices(tilesadded, minr, maxr, minc, maxc, r, c)
                if #validcards == 0 then
                    if showmsg then
                        glu.warn("Joker cannot be changed to make a legal hand.")
                    end
                    return false
                end
                if showmsg then
                    if #validcards == 1 then
                        -- set joker to the only possible card
                        board[r][c].jokerch = validcards
                    else
                        -- multiple choices for joker so let user pick a card
                        if FixJoker(validcards, r, c) then
                            -- board[r][c].jokerch has been set to a valid card
                        else
                            -- user decided to cancel
                            return false
                        end
                    end
                end
            end
        end
    end

    return true
end

--------------------------------------------------------------------------------

function ChangeCurrentPlayer()
    if currplayer == numplayers then
        currplayer = 1
    else
        currplayer = currplayer + 1
    end
end

--------------------------------------------------------------------------------

function SelectNextPlayer()
    player[currplayer].turns = player[currplayer].turns + 1
    ChangeCurrentPlayer()
    YourMove()
    DrawEverything()
end

--------------------------------------------------------------------------------

function IncNumMoves(initmove)
    nummoves = nummoves + 1
    if initmove then
        history[nummoves] = moveinfo()
    end
end

--------------------------------------------------------------------------------

function JokerCount()
    -- Return number of jokers in current player's rack.
    local j = 0
    local cp = player[currplayer]
    for c = 1, maxslots do
        if cp.rack[c].state == full and cp.rack[c].tilech == joker then
            j = j + 1
        end
    end
    return j
end

--------------------------------------------------------------------------------

function ReplaceThrownTiles()
    -- Current player wants to replace the numthrown cards stored in throwntiles.
    -- Assume tilesleft >= maxrack and no tiles have been added to board.
    
    local newrackpos = {}   -- ARRAY [1..maxrack] OF CARD16
    
    -- initialize history info for this move
    passed = 0
    IncNumMoves(true)
    canredo = 0
    local h = history[nummoves]
    h.who = currplayer
    h.move[1] = '{'
    h.movelen = numthrown + 2       -- throwing back some/all tiles
    h.move[h.movelen] = '}'         -- 2..movelen-1 set below
    h.posnum = 0
    h.newlen = 0
    h.score = 0
    
    -- choose numthrown replacement tiles and put in empty rack positions
    local c = 0
    local npos = 0
    local jcount = JokerCount()
    local cp = player[currplayer]
    for t = 1, numthrown do
        repeat
            if c == maxslots then Fatal('Bug in ReplaceThrownTiles!') end
            c = c + 1
        until cp.rack[c].state == empty
        cp.rack[c].tilech = ChooseTile()
            
        if cp.rack[c].tilech == joker and tilesleft > 0 and jcount > 0 then
            -- change joker to a non-joker (there's a small chance there might only
            -- be joker(s) left in the bag, so we try 5 times before giving up)
            local tries = 0
            repeat
                cp.rack[c].tilech = ChooseTile()
                AddTile(joker)
                tries = tries + 1
            until cp.rack[c].tilech ~= joker or tries == 5
        end
        if cp.rack[c].tilech == joker then jcount = jcount + 1 end

        cp.rack[c].state = full
        npos = npos + 1
        newrackpos[npos] = c
    end
    
    -- store new tiles in history array
    for c = 1, npos do
        h.newlen = h.newlen + 1
        h.new[h.newlen] = cp.rack[ newrackpos[c] ].tilech
    end
    
    -- record numthrown tiles in history array and return to bag
    for t = 1, numthrown do
        h.move[t + 1] = throwntiles[t]
        AddTile(throwntiles[t])
    end
    
    numthrown = 0       -- for next PutTileBack
    SelectNextPlayer()
end

--------------------------------------------------------------------------------

-- add a glu.query function???!!! (see wxYES_NO in wxutils.cpp)

function Pass()
    local msg =
[[
Do you really want to pass?

(Note that you can replace one
or more cards by dragging them
to the "Cards remaining" box.)

Click OK if you want to pass.
]]
    local function Ask()
        glu.note(msg,true)    -- add Cancel button
    end
        
    local status, err = pcall(Ask)
    if err then
        -- user hit Cancel
        glu.continue("")
        return false
    end

    return true
end

--------------------------------------------------------------------------------

function AdjustFinalScores()
    -- Subtract all players' tile values from their scores and add to currplayer's
    -- score if currplayer went out.  Remember this adjustment in endscore array
    -- for later use in ShowHistory if endofgame.
    local currscore = 0
    for p = 1, numplayers do
        endscore[p] = 0
        for c = 1, maxslots do
            if player[p].rack[c].state == full then
                local val = tileval[player[p].rack[c].tilech]
                player[p].score = player[p].score - val
                currscore = currscore + val
                endscore[p] = endscore[p] - val
            end
        end
    end
    if player[currplayer].numtiles == 0 then
        player[currplayer].score = player[currplayer].score + currscore
        endscore[currplayer] = currscore
    end
end

--------------------------------------------------------------------------------

function Congratulations()
    -- Put suitable message into infostr for display.  Also set winner here.
    local maxscore = -999999
    for p = 1, numplayers do
        if player[p].score > maxscore then maxscore = player[p].score end
    end
    infostr[1] = "Game over.  Well done"
    infostr[2] = ""
    local line = 2
    for p = 1, numplayers do
        if player[p].score == maxscore then
            if line == 2 then
                winner = p
                infostr[line] = infostr[line]..player[p].name
            else
                -- tie
                winner = 0
                infostr[line] = infostr[line].."and "..player[p].name
            end
            line = line + 1
        end
    end
    infostr[line-1] = infostr[line-1]..'!'
    for p = line, maxlines do infostr[p] = "" end
end

--------------------------------------------------------------------------------

function CheckWinner()
    if allmachines then return end
    if playsounds and (winner == 0 or not player[winner].machine) then
        -- only clap a human winner
        cv.sound("play", "sounds/clapping.wav", volume)
    end
end

--------------------------------------------------------------------------------

function GetStatsInfo(f, statsinfo)
    -- Return true if we can read info from the currently open stats file.
    local eof = "The stats file ended prematurely!"
    local line, spos, epos
    
    -- skip line with player names
    local line = f:read("*l") if line == nil then glu.warn(eof) return false end

    line = f:read("*l") if line == nil then glu.warn(eof) return false end
    spos = 15 -- skip "total games = "
    epos = line:find(" ", spos)
    statsinfo.totalgames = tonumber(line:sub(spos,epos))

    line = f:read("*l") if line == nil then glu.warn(eof) return false end
    statsinfo.totaldrawn = statsinfo.totalgames
    spos = 15
    epos = line:find(" ", spos)
    for p = 1, numplayers do
        statsinfo.wins[p] = tonumber(line:sub(spos,epos))
        statsinfo.totaldrawn = statsinfo.totaldrawn - statsinfo.wins[p]
        spos = spos + 15
        epos = line:find(" ", spos)
    end

    -- skip percentage line
    line = f:read("*l") if line == nil then glu.warn(eof) return false end

    line = f:read("*l") if line == nil then glu.warn(eof) return false end
    spos = 15
    epos = line:find(" ", spos)
    for p = 1, numplayers do
        statsinfo.totalscore[p] = tonumber(line:sub(spos,epos))
        spos = spos + 15
        epos = line:find(" ", spos)
    end

    line = f:read("*l") if line == nil then glu.warn(eof) return false end
    spos = 15
    epos = line:find(" ", spos)
    for p = 1, numplayers do
        statsinfo.bestgame[p] = tonumber(line:sub(spos,epos))
        spos = spos + 15
        epos = line:find(" ", spos)
    end

    line = f:read("*l") if line == nil then glu.warn(eof) return false end
    spos = 15
    epos = line:find(" ", spos)
    for p = 1, numplayers do
        statsinfo.worstgame[p] = tonumber(line:sub(spos,epos))
        spos = spos + 15
        epos = line:find(" ", spos)
    end

    -- skip average games
    line = f:read("*l") if line == nil then glu.warn(eof) return false end

    line = f:read("*l") if line == nil then glu.warn(eof) return false end
    spos = 15
    epos = line:find(" ", spos)
    for p = 1, numplayers do
        statsinfo.totalmoves[p] = tonumber(line:sub(spos,epos))
        spos = spos + 15
        epos = line:find(" ", spos)
    end

    line = f:read("*l") if line == nil then glu.warn(eof) return false end
    spos = 15
    epos = line:find(" ", spos)
    for p = 1, numplayers do
        statsinfo.bestmove[p] = tonumber(line:sub(spos,epos))
        spos = spos + 15
        epos = line:find(" ", spos)
    end

    return true
end

--------------------------------------------------------------------------------

function SaveStatistics()
    --[[
    Current game has just ended, so save statistics for current players.
    File format is like this:

    players     = Andy           Joker 9        .
    total games = 335 (2 drawn)                 .
    wins        = 116            217            .
    percentage  = 35.2           64.8           .
    total score = 108026         118235         .
    best game   = 490            482            .
    worst game  = 203            225            .
    ave game    = 322.6          353.2          .
    total moves = 3860           3988           .
    best move   = 120            177            .
    ave move    = 23.9           34.1           .
    --]]
    
    local colwd = 15    -- column width for player info
    
    local statsinfo = statsrecord()
    
    local f = io.open(statspath, "r")
    if not f then
        -- init statistics
        statsinfo.totalgames = 0
        statsinfo.totaldrawn = 0
        for p = 1, numplayers do
            statsinfo.totalscore[p] = 0
            statsinfo.bestgame[p] = -999999999    -- will change below
            statsinfo.worstgame[p] = 999999999    -- ditto
            statsinfo.totalmoves[p] = 0
            statsinfo.bestmove[p] = -999999999    -- ditto
            statsinfo.wins[p] = 0
        end
    elseif not GetStatsInfo(f, statsinfo) then
        f:close()
        return
    else
        f:close()
    end
    
    -- update statistics
    statsinfo.totalgames = statsinfo.totalgames + 1
    local highscore = -999999999
    local drawn
    for p = 1, numplayers do
        if player[p].score > highscore then
            highscore = player[p].score
            drawn = false
        elseif player[p].score == highscore then
            drawn = true
        end
    end
    for p = 1, numplayers do
        statsinfo.totalmoves[p] = statsinfo.totalmoves[p] + player[p].turns
        statsinfo.totalscore[p] = statsinfo.totalscore[p] + player[p].score
        if player[p].score < statsinfo.worstgame[p] then
            statsinfo.worstgame[p] = player[p].score
        end
        if player[p].score > statsinfo.bestgame[p] then
            statsinfo.bestgame[p] = player[p].score
        end
        if (not drawn) and (player[p].score == highscore) then
            statsinfo.wins[p] = statsinfo.wins[p] + 1
        end
    end
    if drawn then
        statsinfo.totaldrawn = statsinfo.totaldrawn + 1
    end
    
    -- search history array to update best moves
    for p = 1, numplayers do
        for i = 1, nummoves do
            if (history[i].who == p) and (history[i].score > statsinfo.bestmove[p]) then
                statsinfo.bestmove[p] = history[i].score
            end
        end
    end
    
    -- save new statistics
    f = io.open(statspath, "w")
    if not f then
        glu.warn("Could not save statistics in file:\n"..statspath)
        return
    end
    
    f:write("players     = ")
    for i = 1, numplayers do
        f:write(string.format("%-"..colwd.."s", player[i].name))
    end
    f:write(".\n")
    
    f:write("total games = ")
    local tg = ""..statsinfo.totalgames
    if statsinfo.totaldrawn > 0 then
        tg = tg.." ("..statsinfo.totaldrawn.." drawn)"
    end
    f:write(string.format("%-"..(numplayers * colwd).."s", tg))
    f:write(".\n")
    
    f:write("wins        = ")
    for p = 1, numplayers do
        f:write(string.format("%-"..colwd.."s", tostring(statsinfo.wins[p])))
    end
    f:write(".\n")
    
    f:write("percentage  = ")
    for p = 1, numplayers do
        local perc = string.format("%.2f", 100.0 * statsinfo.wins[p] / statsinfo.totalgames)
        f:write(string.format("%-"..colwd.."s", perc))
    end
    f:write(".\n")
    
    f:write("total score = ")
    for p = 1, numplayers do
        f:write(string.format("%-"..colwd.."s", tostring(statsinfo.totalscore[p])))
    end
    f:write(".\n")
    
    f:write("best game   = ")
    for p = 1, numplayers do
        f:write(string.format("%-"..colwd.."s", tostring(statsinfo.bestgame[p])))
    end
    f:write(".\n")
    
    f:write("worst game  = ")
    for p = 1, numplayers do
        f:write(string.format("%-"..colwd.."s", tostring(statsinfo.worstgame[p])))
    end
    f:write(".\n")
    
    f:write("ave game    = ")
    for p = 1, numplayers do
        local perc = string.format("%.2f", statsinfo.totalscore[p] / statsinfo.totalgames)
        f:write(string.format("%-"..colwd.."s", perc))
    end
    f:write(".\n")
    
    f:write("total moves = ")
    for p = 1, numplayers do
        f:write(string.format("%-"..colwd.."s", tostring(statsinfo.totalmoves[p])))
    end
    f:write(".\n")
    
    f:write("best move   = ")
    for p = 1, numplayers do
        f:write(string.format("%-"..colwd.."s", tostring(statsinfo.bestmove[p])))
    end
    f:write(".\n")
    
    f:write("ave move    = ")
    for p = 1, numplayers do
        local perc = string.format("%.2f", statsinfo.totalscore[p] / statsinfo.totalmoves[p])
        f:write(string.format("%-"..colwd.."s", perc))
    end
    f:write(".\n")
    
    f:close()
end

--------------------------------------------------------------------------------

function FinishGame()
    endofgame = true
    player[currplayer].turns = player[currplayer].turns + 1
    AdjustFinalScores()
    Congratulations()
    if savingstats then
        SaveStatistics()
    end
    CheckWinner()
    DrawEverything()
end

--------------------------------------------------------------------------------

function MessageWait()
    DisplayInfo()
    Pause(msgwait)
end

--------------------------------------------------------------------------------

function ChangeInfo(line, str)
    infostr[line] = str
end

--------------------------------------------------------------------------------

function ClearInfo()
    -- never clear line 1
    for i = 2, maxlines do
        if #infostr[i] > 0 then ChangeInfo(i, "") end
    end
end

--------------------------------------------------------------------------------

function CheckBonus(bonus)
    if bonus > 0 then
        ClearInfo()
        if player[currplayer].machine then
            currpict = happypict
        end
        
        if bonus == rfbonus then
            ChangeInfo(1,'* ROYAL FLUSH *')
        elseif bonus == sfbonus then
            ChangeInfo(1,'* STRAIGHT FLUSH *')
        elseif bonus == fivebonus then
            ChangeInfo(1,'* 5 OF A KIND *')
        elseif bonus == sbonus then
            ChangeInfo(1,'* STRAIGHT *')
        elseif bonus == fbonus then
            ChangeInfo(1,'* FLUSH *')
        elseif bonus == fhbonus then
            ChangeInfo(1,'* FULL HOUSE *')
        else
            glu.warn("Bug in CheckBonus! bonus="..bonus)
        end
        
        if playsounds then cv.sound("play", "sounds/bonus.wav", volume) end
                
        MessageWait()
    end
end

--------------------------------------------------------------------------------

function CheckForEndOfGame()
    if (player[currplayer].numtiles == 0) or (passed == numplayers) then
        endofgame = true
    end
    if endofgame then AdjustFinalScores() end
end

--------------------------------------------------------------------------------

function ChooseReplacementTiles()
    -- Choose replacement tiles, put them in empty squares in current rack and update history.
    
    local newrackpos = {}   -- ARRAY [1..maxrack] OF CARD16    
    local npos = 0
    local jcount = JokerCount()
    local cp = player[currplayer]
    for c = 1, maxslots do
        if cp.rack[c].state == empty and tilesleft > 0 and cp.numtiles < maxrack then
            cp.rack[c].tilech = ChooseTile()
            
            if cp.rack[c].tilech == joker and tilesleft > 0 and jcount > 0 then
                -- change joker to a non-joker (there's a small chance there might only
                -- be joker(s) left in the bag, so we try 5 times before giving up)
                local tries = 0
                repeat
                    cp.rack[c].tilech = ChooseTile()
                    AddTile(joker)
                    tries = tries + 1
                until cp.rack[c].tilech ~= joker or tries == 5
            end
            if cp.rack[c].tilech == joker then jcount = jcount + 1 end
            
            cp.rack[c].state = full
            cp.numtiles = cp.numtiles + 1
            npos = npos + 1
            newrackpos[npos] = c
        end
    end
    
    -- now store new tiles in history array (newlen has been set to 0)
    for c = 1, npos do
        local h = history[nummoves]
        h.newlen = h.newlen + 1
        h.new[h.newlen] = cp.rack[ newrackpos[c] ].tilech
    end
end

--------------------------------------------------------------------------------

function AnalyzeTiles()
    -- Examine tiles in rack and initialize card frequencies.
    
    for card = ORD(twoSpades), ORD(joker) do
        freq[CHR(card)] = 0
    end
    for i = 1, ntiles do
        local card = rack[i]
        freq[card] = freq[card] + 1
    end
end

--------------------------------------------------------------------------------

local function AddJoker(b, card)
    -- Because of the way we add tiles from rack to board we check to see
    -- if a matching card has already been added; if so, the joker might
    -- need to be swapped to get a better score.
    
    currgo.tile[b] = joker
    currgo.blnk[b] = card
    for i = 1, b - 1 do
        if currgo.tile[i] == card then
            local r = currgo.rpos[b]
            local c = currgo.cpos[b]
            -- board[r][c].state == full and tilech == joker
            -- board[ rpos[i] ][ cpos[i] ].state == full and tilech == card
            if (tileval[joker] < tileval[card]) and
               (board[r][c].type > board[ currgo.rpos[i] ][ currgo.cpos[i] ].type) then
                -- swap tile[i] with tile[b]
                currgo.tile[i] = joker
                currgo.blnk[i] = card
                currgo.tile[b] = card
                board[ currgo.rpos[i] ][ currgo.cpos[i] ].tilech = joker
                board[ currgo.rpos[i] ][ currgo.cpos[i] ].jokerch = card
                board[r][c].tilech = card
                b = i
                -- currgo.tile[b] == joker for later match
            end
        end
    end
end

--------------------------------------------------------------------------------

local function ComputeScore(tilesadded, minc, maxc)
    -- Return total score of tiles added to board in anchorrow.
    
    local bonus = 0
    local currscore = GetHandScore(anchorrow, anchorrow, minc, maxc)
    for c = minc, maxc do
        if (board[anchorrow][c].state == full) and
           ( (board[anchorrow - 1][c].state == frozen) or
             (board[anchorrow + 1][c].state == frozen) ) then
            -- add score of vertical hand
            local vscore = xsum[anchorrow][c]
            local ch = board[anchorrow][c].tilech
            local cardval = tileval[ch]
            local t = board[anchorrow][c].type
            if t == plain then
                vscore = vscore + cardval
            elseif t == cardx2 then
                vscore = vscore + cardval * 2
            elseif t == handx2 then
                vscore = vscore + cardval
                vscore = vscore * 2
            elseif t == cardx3 then
                vscore = vscore + cardval * 3
            elseif t == handx3 then
                vscore = vscore + cardval
                vscore = vscore * 3
            else
                Fatal('Bug in ComputeScore!')
            end
            currscore = currscore + vscore
        end
    end
    
    if tilesadded == 5 then
        -- 5 cards in horizontal hand so add bonus score
        local hand = {}
        for c = minc, maxc do
            local ch = board[anchorrow][c].tilech
            if ch == joker then ch = board[anchorrow][c].jokerch end
            hand[c - minc + 1] = ch
        end
        bonus = GetBonusScore(hand)
        currscore = currscore + bonus
    end
    
    return currscore, bonus
end

--------------------------------------------------------------------------------

local function CopyCurrentGo(b)
    -- Do a deep copy of currgo.
    b.added = currgo.added
    b.rpos = {}
    b.cpos = {}
    b.tile = {}
    b.blnk = {}
    for i = 1, b.added do
        b.rpos[i] = currgo.rpos[i]
        b.cpos[i] = currgo.cpos[i]
        b.tile[i] = currgo.tile[i]
        b.blnk[i] = currgo.blnk[i]
    end
    b.score = currgo.score
    b.bonus = currgo.bonus
    b.transpose = currgo.transpose
    b.goodness = currgo.goodness
end

--------------------------------------------------------------------------------

local function ValidMove(hand, handlen, endcol)
    -- Hand represents a valid move (with last card at board[anchorrow,endcol]).
    -- Compute score and check if move should be remembered.

    local copyrack = {}
    for i = 1, #rack do copyrack[i] = rack[i] end
    
    -- add hand to board starting at board[anchorrow,begincol]
    local begincol = endcol - handlen + 1
    currgo.transpose = transposed
    -- if transposed is true then rows and cols need to be swapped if and when move is made
    currgo.added = 0
    local i = 0
    for c = begincol, endcol do
        i = i + 1
        if board[anchorrow][c].state == empty then
            local card = hand[i]
            -- put card in board[anchorrow,c]
            board[anchorrow][c].state = full
            currgo.added = currgo.added + 1
            currgo.rpos[currgo.added] = anchorrow
            currgo.cpos[currgo.added] = c
            local j = 0
            repeat
                j = j + 1
            until (j > ntiles) or (copyrack[j] == card)
            if j > ntiles then
                -- card not in rack, so joker was used
                board[anchorrow][c].tilech = joker
                board[anchorrow][c].jokerch = card
                AddJoker(currgo.added, card)
            else
                board[anchorrow][c].tilech = card
                currgo.tile[currgo.added] = card
                -- prevent later match if joker used as this card
                copyrack[j] = ""
            end
        end
    end
    
    currgo.score, currgo.bonus = ComputeScore(currgo.added, begincol, endcol)
    -- restore board
    for i = 1, currgo.added do
        board[ anchorrow ][ currgo.cpos[i] ].state = empty
    end
    if skill < 8 then
        -- don't remember move if score is above limit for current skill
        if currgo.score > slimit[skill] then return end
    end
    
    -- See if currgo should be inserted into best array to remember move.
    -- This array is kept sorted so that numbest <= maxbest and
    -- best[1].score >= best[2].score ... >= best[numbest].score.

    local newbest = 0
    local nxt = 0
    repeat
        nxt = nxt + 1
    until (nxt > numbest) or (currgo.score > best[nxt].score)
    if nxt > numbest then
        if numbest < maxbest then
            numbest = numbest + 1
            best[numbest] = goinfo()
            CopyCurrentGo(best[numbest])    -- best[numbest] = currgo
            newbest = numbest
        else
            return                          -- numbest == maxbest, so do nothing
        end
    else
        -- currgo.score > best[nxt].score and nxt <= numbest;
        -- shift best[nxt] forwards and insert currgo at best[nxt]
        if nxt < maxbest then
            -- shift all elements forwards, starting from best[nxt]
            if numbest < maxbest then
                numbest = numbest + 1
            end
            local i = numbest
            repeat
                best[i] = best[i-1]
                i = i - 1
            until i == nxt
        end
        best[nxt] = goinfo()
        CopyCurrentGo(best[nxt])            -- best[nxt] = currgo
        newbest = nxt
    end
    
    if (newbest >= 1) and (newbest <= topbest) then
        -- update list of top best hands
        for i = topbest, newbest + 1, -1 do
            besthand[i] = besthand[i - 1]
        end
        besthand[newbest] = {}
        for i = 1, handlen do
            besthand[newbest][i] = hand[i]
        end
        for i = 1, topbest do
            if showcompcards or helphuman then
                if #besthand[i] == 0 then
                    ChangeInfo(i+1, '')
                else
                    -- prepend CHR(1) to indicate that line consists of a set of cards
                    local s = CHR(1)
                    for j = 1, #besthand[i] do s = s..besthand[i][j] end
                    ChangeInfo(i+1, s)
                end
            end
        end
    end
end

--------------------------------------------------------------------------------

local function ValidHand(hand, handlen)
    -- Return true only if given hand is allowed.
    -- Note that hand[1..handlen] == twoSpades..aceHearts (no jokers).
    local val1, val2, val3, valdiff, suit1, suit2
    
    if handlen < 2 then return false end    -- min hand == 2 cards
    if handlen > 5 then return false end    -- max hand == 5 cards
    
    val1 = tileval[ hand[1] ]               -- two == 2 ... ace == 14
    val2 = tileval[ hand[2] ]               -- ditto
    valdiff = val2 - val1
    
    if handlen == 5 then
        if valdiff == 0 then
            -- check for full house (2 of a kind + 3 of a kind, or vice versa)
            val3 = tileval[ hand[3] ]
            if (tileval[ hand[4] ] == tileval[ hand[5] ]) and
               ((val3 == val2) or (val3 == tileval[ hand[4] ])) then
                return true
            end
        end
        
        suit1 = tilesuit[ hand[1] ]         -- spades/clubs/diamonds/hearts
        suit2 = tilesuit[ hand[2] ]         -- ditto
        if suit1 == suit2 then
            -- check for flush (all cards have same suit)
            if (tilesuit[ hand[3] ] == suit1) and
               (tilesuit[ hand[4] ] == suit1) and
               (tilesuit[ hand[5] ] == suit1) then
                return true
            end
        end
        
        if (valdiff == 1) and (val1 <= 10) then
            -- check for increasing straight (max == 10,J,Q,K,A)
            if (tileval[ hand[3] ] - val2 == 1) and
               (tileval[ hand[4] ] - tileval[ hand[3] ] == 1) and
               (tileval[ hand[5] ] - tileval[ hand[4] ] == 1) then
                return true
            end
        elseif (valdiff == -1) and (val1 >= 6) then
            -- check for decreasing straight (min == 6,5,4,3,2)
            if (val2 - tileval[ hand[3] ] == 1) and
               (tileval[ hand[3] ] - tileval[ hand[4] ] == 1) and
               (tileval[ hand[4] ] - tileval[ hand[5] ] == 1) then
                return true
            end
        end
    elseif handlen == 4 then
        if (valdiff == 0) and (tileval[ hand[3] ] == tileval[ hand[4] ]) then
            -- 2 pairs or 4 of a kind
            return true
        end
    end
    
    -- only possibility remaining is 2/3/4 cards with same value
    if val1 ~= val2 then return false end
    local i = 2
    while true do
        if i == handlen then break end
        if tileval[ hand[i+1] ] == val1 then
            -- hand still ok, so continue
            i = i + 1
        else
            return false
        end
    end
    
    return true
end

--------------------------------------------------------------------------------

function AnalyzeBoard()
    -- Compute xcheck values for all empty squares in board,
    -- compute xsum values for empty squares with tile above/below,
    -- and find all anchor squares.
    
    -- set xcheck values for sentinel squares around board limits
    for c = 0, 16 do
        xcheck[ 0][c] = nocard
        xcheck[16][c] = nocard
    end
    for r = 1, 15 do
        xcheck[r][ 0] = nocard
        xcheck[r][16] = nocard
    end
    
    for r = 1, 15 do
        for c = 1, 15 do
            if board[r][c].state == empty then
                if (board[r-1][c].state == empty) and
                   (board[r+1][c].state == empty) then
                    -- no card above or below
                    xcheck[r][c] = everycard
                    anchor[r][c] = (board[r][c-1].state == frozen) or
                                   (board[r][c+1].state == frozen)
                else
                    -- extract vertical hand from board and compute xsum[r][c]
                    local ch
                    local row = r
                    while board[row-1][c].state == frozen do
                        row = row - 1
                    end
                    local handlen = 0
                    local hand = {}
                    xsum[r][c] = 0
                    while row < r do
                        ch = board[row][c].tilech
                        handlen = handlen + 1
                        hand[handlen] = ch
                        if ch == joker then
                            hand[handlen] = board[row][c].jokerch
                        end
                        row = row + 1
                        xsum[r][c] = xsum[r][c] + tileval[ch]
                    end
                    -- row == r
                    handlen = handlen + 1
                    local pos = handlen
                    row = row + 1
                    while board[row][c].state == frozen do
                        ch = board[row][c].tilech
                        handlen = handlen + 1
                        hand[handlen] = ch
                        if ch == joker then
                            hand[handlen] = board[row][c].jokerch
                        end
                        row = row + 1
                        xsum[r][c] = xsum[r][c] + tileval[ch]
                    end
                    xcheck[r][c] = nocard
                    for card = ORD(twoSpades), ORD(aceHearts) do
                        ch = CHR(card)
                        if (freq[ch] > 0) or (freq[joker] > 0) then
                            -- if adding ch to board[r][c] makes a known hand then
                            -- add ch to cross-check set
                            hand[pos] = ch
                            if ValidHand(hand, handlen) then
                                -- include card in xcheck[r][c]
                                xcheck[r][c] = xcheck[r][c]..ch
                            end
                        end
                    end
                    anchor[r][c] = xcheck[r][c] ~= nocard
                end
            else
                anchor[r][c] = false        -- r,c is frozen card
            end
        end
    end
end

--------------------------------------------------------------------------------

local function CanExtend(hand, handlen)
    -- Return true if given partial hand can be extended to the right to form a valid hand.
    
    -- handlen is >= 1
    if handlen == 1 then return true end        -- one card can always be extended
    if handlen >= 5 then return false end       -- max hand == 5 cards
    
    -- handlen is 2..4
    local val1 = tileval[ hand[1] ]             -- two == 2 ... ace == 14
    local val2 = tileval[ hand[2] ]             -- ditto
    local valdiff = val2 - val1
    
    local suit1 = tilesuit[ hand[1] ]           -- spades/clubs/diamonds/hearts
    local suit2 = tilesuit[ hand[2] ]           -- ditto
    
    if handlen == 2 then
        if suit1 == suit2 then return true end                      -- flush
        if valdiff == 0 then return true end                        -- n of a kind
        if (valdiff == 1) and (val1 <= 10) then return true end     -- inc straight
        if (valdiff == -1) and (val1 >= 6) then return true end     -- dec straight
    elseif handlen == 3 then
        if (suit1 == suit2) and (tilesuit[ hand[3] ] == suit1) then
            return true         -- flush
        end
        if valdiff == 0 then
            return true         -- n of a kind or 2+2 or 2+3
        end
        if (valdiff == 1) and (val1 <= 10) and (tileval[ hand[3] ] - val2 == 1) then
            return true         -- inc straight
        end
        if (valdiff == -1) and (val1 >= 6) and (val2 - tileval[ hand[3] ] == 1) then
            return true         -- dec straight
        end
    else -- handlen == 4
        if (suit1 == suit2) and (tilesuit[ hand[3] ] == suit1) and (tilesuit[ hand[4] ] == suit1) then
            return true         -- flush
        end
        if (valdiff == 0) and
            ( (tileval[ hand[3] ] == val1) or               -- 3+2
              (tileval[ hand[3] ] == tileval[ hand[4] ])    -- 2+3
            ) then
            return true                                     -- n of a kind or full house
        end
        if (valdiff == 1) and (val1 <= 10) and
            (tileval[ hand[3] ] - val2 == 1) and
            (tileval[ hand[4] ] - tileval[ hand[3] ] == 1) then
            return true                                     -- inc straight
        end
        if (valdiff == -1) and (val1 >= 6) and
            (val2 - tileval[ hand[3] ] == 1) and
            (tileval[ hand[3] ] - tileval[ hand[4] ] == 1) then
            return true                                     -- dec straight
        end
    end
    
    return false
end

--------------------------------------------------------------------------------

local function ExtendRight(partial, partiallen, c)
    -- Generate all possible moves beginning with partial hand and extending
    -- to the right from board[anchorrow,c].
    -- The current anchor square is board[anchorrow,anchorcol].
    
    if c > 15 then return end
    
    if board[anchorrow][c].state == empty then
        for ordch = ORD(twoSpades), ORD(aceHearts) do
            local ch = CHR(ordch)
            if (freq[ch] > 0) and xcheck[anchorrow][c]:find(ch,1,true) then
                partial[partiallen + 1] = ch
                if (board[anchorrow][c + 1].state == empty) and ValidHand(partial, partiallen + 1) then
                    ValidMove(partial, partiallen + 1, c)
                end
                if CanExtend(partial, partiallen + 1) then
                    freq[ch] = freq[ch] - 1                             -- remove ch
                    ExtendRight(partial, partiallen + 1, c + 1)
                    freq[ch] = freq[ch] + 1                             -- put ch back
                end
            elseif (freq[joker] > 0) and xcheck[anchorrow][c]:find(ch,1,true) then
                partial[partiallen + 1] = ch
                if (board[anchorrow][c + 1].state == empty) and ValidHand(partial, partiallen + 1) then
                    ValidMove(partial, partiallen + 1, c)
                end
                if CanExtend(partial, partiallen + 1) then
                    freq[joker] = freq[joker] - 1                       -- remove joker
                    ExtendRight(partial, partiallen + 1, c + 1)
                    freq[joker] = freq[joker] + 1                       -- put joker back
                end
            end
        end
    else
        -- board[anchorrow][c].state == frozen card
        local ch = board[anchorrow][c].tilech
        if ch == joker then ch = board[anchorrow][c].jokerch end
        partial[partiallen + 1] = ch
        if (board[anchorrow][c + 1].state == empty) and ValidHand(partial, partiallen + 1) then
            ValidMove(partial, partiallen + 1, c)
        end
        if CanExtend(partial, partiallen + 1) then
            ExtendRight(partial, partiallen + 1, c + 1)
        end
    end
end

--------------------------------------------------------------------------------

local function LeftPart(partial, partiallen, limit)
    -- Generate all possible left parts and call ExtendRight for each one.
    -- The current anchor square is board[anchorrow,anchorcol].
    
    ExtendRight(partial, partiallen, anchorcol)
    if limit > 0 then
        for ordch = ORD(twoSpades), ORD(aceHearts) do
            local ch = CHR(ordch)
            if freq[ch] > 0 then
                partial[partiallen + 1] = ch
                if CanExtend(partial, partiallen + 1) then
                    freq[ch] = freq[ch] - 1                             -- remove ch
                    LeftPart(partial, partiallen + 1, limit - 1)
                    freq[ch] = freq[ch] + 1                             -- put ch back
                end
            elseif freq[joker] > 0 then
                partial[partiallen + 1] = ch
                if CanExtend(partial, partiallen + 1) then
                    freq[joker] = freq[joker] - 1                       -- remove joker
                    LeftPart(partial, partiallen + 1, limit - 1)
                    freq[joker] = freq[joker] + 1                       -- put joker back
                end
            end
        end
    end
end

--------------------------------------------------------------------------------

function FindHorizontalMoves()
    -- Generate all possible horizontal moves and remember best ones.
    
    AnalyzeBoard()
    for r = 1, 15 do
        for c = 1, 15 do
            if anchor[r][c] then
                -- r,c is an anchor square: r,c is empty, at least one neighbouring
                -- square is a frozen card, and xcheck[r][c] ~= nocard
                anchorrow = r
                anchorcol = c
                if board[r][c - 1].state == frozen then
                    -- find hand == frozen cards to left of r,c
                    local col = c
                    while board[r][col - 1].state == frozen do
                        col = col - 1
                    end
                    local hand = {}
                    local handlen = 0
                    repeat
                        handlen = handlen + 1
                        hand[handlen] = board[r][col].tilech
                        if hand[handlen] == joker then
                            hand[handlen] = board[r][col].jokerch
                        end
                        col = col + 1
                    until col == c
                    if (handlen < 5) and CanExtend(hand, handlen) then
                        ExtendRight(hand, handlen, c)
                    end
                else
                    -- set k to number of empty, non-anchor squares to left of r,c
                    local k = 0
                    local col = c - 1
                    while (col >= 1) and (board[r][col].state == empty) and
                          (xcheck[r][col] == everycard) and (not anchor[r][col]) do
                        k = k + 1
                        col = col - 1
                    end
                    if k > ntiles - 1 then k = ntiles - 1 end
                    -- maximum hand is 5 cards
                    if k > 4 then k = 4 end
                    local hand = {}
                    LeftPart(hand, 0, k)
                end
            end
        end
    end
end

--------------------------------------------------------------------------------

function TransposeBoard()
    -- Transpose rows and columns in board so we don't need separate code
    -- to handle vertical moves.
    for r = 1, 14 do
        for c = r + 1, 15 do
            local temp = board[r][c]
            board[r][c] = board[c][r]
            board[c][r] = temp
        end
    end
end

--------------------------------------------------------------------------------

function FirstMove()
    -- Find best hand covering board[8][8].
    
    transposed = false      -- should be, but play safe
    AnalyzeBoard()
    anchorrow = 8
    anchorcol = 8
    LeftPart({}, 0, 4)      -- at least 4 empty squares left of middle square
end

--------------------------------------------------------------------------------

function NextMove()
    -- Search for best move where at least one tile from the rack will be placed
    -- adjacent to a frozen card on the board.
    
    transposed = false      -- should be, but play safe
    FindHorizontalMoves()
    TransposeBoard()
    transposed = true
    FindHorizontalMoves()   -- actually vertical moves
    TransposeBoard()        -- restore board
    transposed = false
end

--------------------------------------------------------------------------------

function AnalyzeBestMoves()
    -- Use heuristics to determine goodness factor for each move and set
    -- verybest to move with highest goodness.
    
    local goleft = rand(1,2) == 1
    local maxgoodness = -999999999
    for i = 1, numbest do
        best[i].goodness = best[i].score
        if skill <= 8 then
            -- just choose move with highest score
        else
            -- for skill 9 we adjust goodness according to heuristics;
            -- first set used array to store frequency of added cards
            local used = {}
            for ordch = ORD(twoSpades), ORD(joker) do
                local ch = CHR(ordch)
                used[ch] = 0
            end
            for j = 1, best[i].added do
                local ch = best[i].tile[j]
                used[ch] = used[ch] + 1
            end
            local remtiles = ntiles - best[i].added

            -- play an open game by preferring longer hands
            best[i].goodness = best[i].goodness + best[i].added

            -- big reward for using all tiles to end game
            if (remtiles == 0) and (tilesleft == 0) then
                best[i].goodness = best[i].goodness + 100
            end

            -- prefer first move to be centralized
            if board[8][8].state == empty then
                local left = best[i].cpos[1] - 1
                local right = 15 - best[i].cpos[best[i].added]
                best[i].goodness = best[i].goodness - math.abs(left - right)
            end

            -- avoid wasting joker
            if tilesleft >= maxrack then
                if used[joker] > 0 then
                    best[i].goodness = best[i].goodness - 10 * used[joker]
                end
            end

            -- in some situations it is better to throw back tiles
            if tilesleft >= maxrack + 10 then
                if (best[i].added < 4) and (best[i].score < 30) then
                    best[i].goodness = terrible
                end
                if (used[joker] > 0) and (best[i].score < 50) then
                    best[i].goodness = terrible
                end
            end
        end

        if board[8][8].state == empty then
            if (best[i].goodness == maxgoodness) and best[i].transpose then
                -- avoid vertical first move (horizontal moves are tested first)
                best[i].goodness = best[i].goodness - 2
            end
            if (not best[i].transpose) and (best[i].added == 5) and
               (best[i].cpos[1] == 8) and goleft then
                -- randomize whether first move goes left or right
                best[i].goodness = best[i].goodness - 1
            end
            if best[i].goodness > maxgoodness then
                maxgoodness = best[i].goodness
                verybest = i
            end
        else
            if best[i].goodness > maxgoodness then
                maxgoodness = best[i].goodness
                verybest = i
            end
        end
    end
end

--------------------------------------------------------------------------------

function ThrowBack(usedtiles)
    local thrown = 0
    local goodsuit = anysuit
    
    if skill == 9 then
        local suitcount = {}        -- ARRAY [spades..hearts] OF CARD16
        for s = spades, hearts do
            suitcount[s] = 0
        end
        
        -- if rack has 3 or more cards of same suit then set goodsuit
        local maxcount = 0
        for i = 1, ntiles do
            if rack[i] ~= joker then
                local s = tilesuit[rack[i]]
                suitcount[s] = suitcount[s] + 1
                if suitcount[s] > maxcount then
                    maxcount = suitcount[s]
                    if maxcount >= 3 then goodsuit = s end
                end
            end
        end
    end
    
    for i = 1, ntiles do
        if rack[i] == joker then
            -- keep joker
        else
            if skill == 9 then
                if (tileval[rack[i]] >= 10) and (goodsuit == anysuit) and (tilesleft > 10) then
                    -- keep high value card if there isn't >= 3 of one suit
                    -- and there are plenty of cards remaining
                elseif (tilesuit[rack[i]] == goodsuit) then
                    -- keep card belonging to >= 3 of one suit
                else
                    -- throw back rack[i]
                    thrown = thrown + 1
                    usedtiles[thrown] = rack[i]
                    freq[rack[i]] = freq[rack[i]] - 1
                end
            else
                -- skill <= 8 so throw back rack[i]
                thrown = thrown + 1
                usedtiles[thrown] = rack[i]
                freq[rack[i]] = freq[rack[i]] - 1
            end
        end
    end

    if not helphuman then
        -- count consecutive throw backs for this computer player
        throwbacks[currplayer] = throwbacks[currplayer] + 1
    end
end

--------------------------------------------------------------------------------

function MakeBestMove(usedtiles)
    local macscore = 0          -- if pass or throw back
    local madebonus = 0         -- ditto
    
    -- use info saved in best array to choose best move
    if numbest == 0 then
        -- no moves found
        if (tilesleft < maxrack) or (throwbacks[currplayer] > 5) then
            -- pass, so don't change throwbacks count
        else
            ThrowBack(usedtiles)
        end
    else
        AnalyzeBestMoves()
        -- make move with highest goodness;
        -- note that goodness can only be terrible if tilesleft >= maxrack
        local b = best[verybest]
        if (b.goodness == terrible) and (throwbacks[currplayer] < 3) then
            ThrowBack(usedtiles)
        else
            -- make this move
            throwbacks[currplayer] = 0      -- reset consecutive count
            macscore = b.score
            madebonus = b.bonus
            for i = 1, b.added do
                local r, c
                if b.transpose then
                    r = b.cpos[i]
                    c = b.rpos[i]
                else
                    r = b.rpos[i]
                    c = b.cpos[i]
                end
                board[r][c].state = full
                board[r][c].tilech = b.tile[i]
                if board[r][c].tilech == joker then
                    board[r][c].jokerch = b.blnk[i]
                end
                usedtiles[i] = b.tile[i]
            end
        end
    end
    
    return macscore, madebonus
end

--------------------------------------------------------------------------------

function ComputerMove(currtiles, expertise, usedtiles)
    -- The algorithm used to generate all possible moves is based on
    -- "The World's Fastest Scrabble Program" by Andrew Appel and Guy Jacobson
    -- in CACM, May 1988.

    helphuman = not player[currplayer].machine
    
    -- no scoring limit for skill 8 or 9
    skill = expertise
    if skill < 8 then
        if     skill == 0 then slimit[skill] = rand(15,35)
        elseif skill == 1 then slimit[skill] = rand(25,45)
        elseif skill == 2 then slimit[skill] = rand(35,55)
        elseif skill == 3 then slimit[skill] = rand(45,65)
        elseif skill == 4 then slimit[skill] = rand(55,75)
        elseif skill == 5 then slimit[skill] = rand(65,85)
        elseif skill == 6 then slimit[skill] = rand(75,95)
        elseif skill == 7 then slimit[skill] = rand(85,105)
        else
            Fatal('Bug in ComputerMove!')
        end
    end
    
    ntiles = #currtiles
    for i = 1, ntiles do
        rack[i] = currtiles[i]      -- copy currtiles to global rack
    end
    for i = 1, topbest do
        besthand[i] = {}
    end
    ClearInfo()
    AnalyzeTiles()                  -- init freq array
    numbest = 0                     -- no best move yet
    
    if board[8][8].state == empty then
        FirstMove()
    else
        NextMove()
    end
    
    local score, bonus = MakeBestMove(usedtiles)
    return score, bonus
end

--------------------------------------------------------------------------------

function FastPlay()
    -- Return true only if all players are machines and mouse is over MOVE button.
    if not allmachines then return false end
    local x, y = cv.getxy()
    if x >= 0 and y >= 0 then
        return PtInRect(x, y, donerect)
    else
        return false
    end
end

--------------------------------------------------------------------------------

function SadComputer(usedtiles)
    -- Computer player has passed or thrown back cards.
    ClearInfo()
    currpict = sadpict
    if #usedtiles == 0 then
        ChangeInfo(2, "Pass!")
    else
        local s = "Replace "..#usedtiles.." card"
        if #usedtiles > 1 then s = s.."s." else s = s.."." end
        ChangeInfo(2, s)
    end
    if FastPlay() then
        -- don't delay
        return
    end
    MessageWait()   -- calls DisplayInfo
end

--------------------------------------------------------------------------------

function AddToBlink(blinkrects, row, col)
    -- Append square rect (from rack or board) to blinkrects array.
    local r = Rect()
    if row == 0 then
        -- r is in rack
        r.top = innerrack.top + 1
        r.left = innerrack.left + (col - 1) * racksquare + 1
        r.bottom = r.top + racksquare - 1
        r.right = r.left + racksquare - 1
    else
        -- r is in board
        r.top = boardrect.top + (row - 1) * boardsquare + 1
        r.left = boardrect.left + (col - 1) * boardsquare + 1
        r.bottom = r.top + boardsquare - 2
        r.right = r.left + boardsquare - 2
    end
    InsetRect(r, 1, 1)
    blinkrects[#blinkrects + 1] = r
end

--------------------------------------------------------------------------------

function InvertRects(rectarray)
    for i = 1, #rectarray do
        InvertRect(rectarray[i])
    end
end

--------------------------------------------------------------------------------

function BlinkTiles(blinkrects)
    InvertRects(blinkrects)
    Pause(100)
    InvertRects(blinkrects)
    Pause(100)
    InvertRects(blinkrects)
    Pause(100)
    InvertRects(blinkrects)
    cv.update()
end

--------------------------------------------------------------------------------

function BlinkTilesLeft()
    InvertRect(innerleft)
    Pause(100)
    InvertRect(innerleft)
    Pause(100)
    InvertRect(innerleft)
    Pause(100)
    InvertRect(innerleft)
    cv.update()
end

--------------------------------------------------------------------------------

function DoMachineMove()
    local oldcursor = cv.cursor("wait")
    
    ::move_again::
    
    -- pass tiles in current rack to ComputerMove
    local currtiles = {}
    local j = 0
    for i = 1, maxslots do
        if player[currplayer].rack[i].state == full then
            j = j + 1
            currtiles[j] = player[currplayer].rack[i].tilech
        end
    end
    local usedtiles = {}
    local macscore, bonus = ComputerMove(currtiles, player[currplayer].skill, usedtiles)
    local used = #usedtiles
    IncNumMoves(true)
    canredo = 0
    
    if used == 0 then
        SadComputer(usedtiles)
        passed = passed + 1
        local h = history[nummoves]
        h.who = currplayer
        h.move[1] = '{'
        h.move[2] = '}'
        h.movelen = 2
        h.posnum = 0        -- no tiles added to board
        h.newlen = 0        -- no replacement tiles
        h.score = 0
    else
        -- ComputerMove threw back tile(s) or added full tile(s) to board
        passed = 0
        local h = history[nummoves]
        h.who = currplayer
        h.movelen = 0
        h.posnum = 0
        h.newlen = 0
        h.score = macscore
        
        -- remove used tile(s) from current rack
        local cp = player[currplayer]
        for i = 1, used do
            local c = 0
            repeat
                c = c + 1
            until (c > maxslots) or ((cp.rack[c].state == full) and (cp.rack[c].tilech == usedtiles[i]))
            if c > maxslots then
                Fatal('Bug in DoMachineMove: used tile not in rack!')
            end
            cp.rack[c].state = empty
            cp.numtiles = cp.numtiles - 1
        end
        
        DrawEverything() -- show removed tiles
        
        -- freeze tiles added to board and update history
        local blinkrects = {}
        local tilesadded = 0
        for r = 1, 15 do
            for c = 1, 15 do
                if board[r][c].state == full then
                    tilesadded = tilesadded + 1
                    board[r][c].state = frozen
                    CopyTileToBoard(board[r][c].tilech, r, c)
                    if board[r][c].tilech == joker then
                        DrawCardOnJoker(r, c)
                    end
                    if blinkcompmoves then AddToBlink(blinkrects, r, c) end
                    -- update history[nummoves]
                    h.movelen = h.movelen + 1
                    h.move[h.movelen] = board[r][c].tilech
                    if board[r][c].tilech == joker then
                        h.movelen = h.movelen + 1
                        h.move[h.movelen] = board[r][c].jokerch
                    end
                    h.posnum = h.posnum + 1
                    h.pos[h.posnum].row = r
                    h.pos[h.posnum].col = c
                end
            end
        end
        if tilesadded > 0 then
            if blinkcompmoves and not FastPlay() then BlinkTiles(blinkrects) end
            if not FastPlay() then CheckBonus(bonus) end
        else
            -- used tiles were thrown back
            SadComputer(usedtiles)
            h.move[1] = '{'
            h.movelen = used + 2
            h.move[h.movelen] = '}'
            for i = 1, used do h.move[i+1] = usedtiles[i] end
        end
        ChooseReplacementTiles()
        if tilesadded == 0 then
            -- put used tile(s) back into bag
            for i = 1, used do AddTile(usedtiles[i]) end
        end
    end
    
    player[currplayer].score = player[currplayer].score + macscore
    player[currplayer].turns = player[currplayer].turns + 1
    
    CheckForEndOfGame()
    if endofgame then
        Congratulations()
        CheckWinner()
    else
        -- select next player
        ChangeCurrentPlayer()
        YourMove()
    end
    DrawEverything()
    
    if endofgame then
        if savingstats then SaveStatistics() end
        if FastPlay() then
            -- start a new game and keep playing
            NewGame()
        end
    end
    
    if FastPlay() then goto move_again end
    
    cv.cursor(oldcursor)
end

--------------------------------------------------------------------------------

function MakeMove()
    -- Human player has finished moving tiles from rack to board or bag,
    -- or computer player has been asked to make a move.
    
    if player[currplayer].machine then
        DoMachineMove()
        return
    end
    
    -- first count number of tiles added to board and determine their limits
    local tilesadded, minr, maxr, minc, maxc = GetTilesAdded()
    -- check if tiles have been thrown back in bag
    if numthrown > 0 then
        if tilesadded == 0 then
            -- replace cards stored in throwntiles array
            ReplaceThrownTiles()
        else
            glu.warn("Some cards have been thrown back and some are on the board.")
        end
        return
    end
    
    if tilesadded == 0 then
        if (tilesleft >= maxrack) and not Pass() then return end
        -- user really wants to pass
        IncNumMoves(true)
        canredo = 0
        history[nummoves].who = currplayer
        history[nummoves].move[1] = '{'
        history[nummoves].move[2] = '}'
        history[nummoves].movelen = 2
        history[nummoves].posnum = 0        -- no tiles added to board
        history[nummoves].newlen = 0        -- no replacement tiles
        history[nummoves].score = 0
        passed = passed + 1
        if passed == numplayers then
            FinishGame()
            return
        end
    else
        -- tilesadded > 0
        if not LegalMove(tilesadded, minr, maxr, minc, maxc, true) then
            -- suitable error message displayed via glu.warn
            return
        end
        -- state of added tiles is still full but any jokers have been fixed

        local currscore, bonus = GetScore(tilesadded, minr, maxr, minc, maxc, true)

        -- GetScore has set hands array and handscreated
        for w = 1, handscreated do
            local len = #hands[w]
            if len > 5 then
                glu.warn("Hands cannot have more than 5 cards.")
                return
            elseif not ValidHand(hands[w], len) then
                Beep()
                ChangeInfo(2, "Illegal poker hand:")
                -- prepend CHR(1) to indicate that line consists of a set of cards
                local s = CHR(1)
                for i = 1, len do s = s..hands[w][i] end
                ChangeInfo(3, s)
                DrawEverything()
                return
            end
        end
    
        -- currplayer has made legal hand(s)
        IncNumMoves(true)
        canredo = 0
        history[nummoves].who = currplayer
        history[nummoves].movelen = 0
        history[nummoves].posnum = 0
        history[nummoves].newlen = 0
        history[nummoves].score = currscore
        player[currplayer].score = player[currplayer].score + currscore
        passed = 0
    
        -- freeze all full squares on board and update history
        for r = minr, maxr do
            for c = minc, maxc do
                if board[r][c].state == full then
                    board[r][c].state = frozen
                    if board[r][c].tilech == joker then
                        DrawCardOnJoker(r, c)
                    end
                    local h = history[nummoves]
                    h.movelen = h.movelen + 1
                    h.move[h.movelen] = board[r][c].tilech
                    if board[r][c].tilech == joker then
                        h.movelen = h.movelen + 1
                        h.move[h.movelen] = board[r][c].jokerch
                    end
                    h.posnum = h.posnum + 1
                    h.pos[h.posnum].row = r
                    h.pos[h.posnum].col = c
                end
            end
        end
    
        CheckBonus(bonus)
        player[currplayer].numtiles = player[currplayer].numtiles - tilesadded
        if ((player[currplayer].numtiles == 0) and (tilesleft == 0)) then
            FinishGame()
            return
        end
        ChooseReplacementTiles()
    end
    
    SelectNextPlayer()
end

--------------------------------------------------------------------------------

function UpdateCurrentScore()
    -- One or more tiles have been thrown back or added to or removed from board,
    -- so update current score in line 2 of info box.
    
    local badmove = "Score = ?"
    local tilesadded, minr, maxr, minc, maxc = GetTilesAdded()
    
    ClearInfo()
    if numthrown > 0 then
        if tilesadded == 0 then
            ChangeInfo(2, "Score = 0")
        else
            ChangeInfo(2, badmove)
        end
        return
    end
    if tilesadded == 0 then
        return
    end
    if not LegalMove(tilesadded, minr, maxr, minc, maxc, false) then
        ChangeInfo(2, badmove)
        return
    end
    
    --[[ wrong bonus can be displayed!!! (messy to fix so just ignore it)
    
                j 8h 7h 6h 5h   <- joker must be 6h (ie. flush, not straight flush)
        2 3 4 5 6
    
    --]]
    
    -- compute and display current score (jokers not yet fixed)
    local currscore, bonus = GetScore(tilesadded, minr, maxr, minc, maxc, false)
    
    -- GetScore sets hands[1]..hands[handscreated]
    for w = 1, handscreated do
        -- can't call ValidHand because hands[w] might have one or more jokers
        if not LegalHand(hands[w], #hands[w]) then
            ChangeInfo(2, badmove)
            return
        end
    end
    
    -- move is probably legal (except for uncertainty due to jokers)
    local scorestr = "Score = "..currscore
    ChangeInfo(2, scorestr)
    if bonus > 0 then
        scorestr = "("..bonus.." bonus points)"
        ChangeInfo(3, scorestr)
    end
end

--------------------------------------------------------------------------------

local function PutTileBack(card)
    -- Remember card in throwntiles array for later use.
    if numthrown >= maxrack then Fatal('Bug in PutTileBack!') end
    numthrown = numthrown + 1
    throwntiles[numthrown] = card
end

--------------------------------------------------------------------------------

function RestoreThrownTiles(count)
    -- Return throwntiles[numthrown-count+1..numthrown] to rack.
    local c = 0
    for t = numthrown-count+1, numthrown do
        repeat
            c = c + 1
            if c > maxslots then Fatal('Bug in RestoreThrownTiles!') end
        until player[currplayer].rack[c].state == empty
        player[currplayer].rack[c].state = full
        player[currplayer].rack[c].tilech = throwntiles[t]
        CopyTileToRack(throwntiles[t], c, currplayer)
    end
    numthrown = numthrown - count
end

--------------------------------------------------------------------------------

function RestoreTiles()
    -- Do not test for player[currplayer].machine here because other routines
    -- use RestoreTiles to return tiles to rack for human or machine player.
    if endofgame then return end

    -- move any full tiles on board to empty squares in rack
    local cp = player[currplayer]
    local t = 0
    for r = 1, 15 do
        for c = 1, 15 do
            if board[r][c].state == full then
                board[r][c].state = empty
                DrawEmptySquare(r, c)
                repeat
                    t = t + 1
                    if t > maxslots then Fatal('Bug in RestoreTiles!') end
                until cp.rack[t].state == empty
                cp.rack[t].state = full
                cp.rack[t].tilech = board[r][c].tilech
                CopyTileToRack(cp.rack[t].tilech, t, currplayer)
            end
        end
    end
    local restored = (t > 0) or (numthrown > 0)
    if numthrown > 0 then
        -- move all thrown tiles back to rack
        RestoreThrownTiles(numthrown)
    end
    if restored then
        UpdateCurrentScore()
        DrawEverything()
    end
end

--------------------------------------------------------------------------------

function ShowPassOrThrow(usedtiles)
    -- HelpHuman has suggested to pass or throw back cards.
    ClearInfo()
    if #usedtiles == 0 then
        ChangeInfo(2, "Pass!")
    else
        local s = "Replace "..#usedtiles.." card"
        if #usedtiles > 1 then s = s.."s." else s = s.."." end
        ChangeInfo(2, s)
    end
    MessageWait()   -- calls DisplayInfo
end

--------------------------------------------------------------------------------

function HelpHuman(skill)
    local oldcursor = cv.cursor("wait")
    
    -- make sure all player's cards are in rack, then pass to ComputerMove
    RestoreTiles()
    local currtiles = {}
    local j = 0
    for i = 1, maxslots do
        if player[currplayer].rack[i].state == full then
            j = j + 1
            currtiles[j] = player[currplayer].rack[i].tilech
        end
    end
    local usedtiles = {}
    local macscore, bonus = ComputerMove(currtiles, skill, usedtiles)
    local used = #usedtiles
    if used == 0 then
        -- ComputerMove recommends pass
        ShowPassOrThrow(usedtiles)
    else
        -- ComputerMove threw back tile(s) or added tile(s) to board;
        -- first remove used tile(s) from current rack
        local cp = player[currplayer]
        for i = 1, used do
            local c = 0
            repeat
                c = c + 1
            until (c > maxslots) or ((cp.rack[c].state == full) and (cp.rack[c].tilech == usedtiles[i]))
            if c > maxslots then
                Fatal('Bug in HelpHuman: used tile not in rack!')
            end
            cp.rack[c].state = empty
        end
        
        -- show updated rack now
        DrawEverything()

        -- now display tiles added to board (possibly none)
        local blinkrects = {}
        local tilesadded = 0
        for r = 1, 15 do
            for c = 1, 15 do
                if board[r][c].state == full then
                    tilesadded = tilesadded + 1
                    CopyTileToBoard(board[r][c].tilech, r, c)
                    if blinkcompmoves then AddToBlink(blinkrects, r, c) end
                end
            end
        end
        if tilesadded > 0 then
            if blinkcompmoves then BlinkTiles(blinkrects) end
        else
            -- used tiles were thrown back, so show them in innerleft
            for i = 1, used do
                PutTileBack(usedtiles[i])
            end
            DisplayTilesLeft()
            if blinkcompmoves then BlinkTilesLeft() end
            ShowPassOrThrow(usedtiles)
        end
    end
    
    -- show score, including any bonus points
    UpdateCurrentScore()
    DrawEverything()
    
    cv.cursor(oldcursor)
end

--------------------------------------------------------------------------------

function DoMove(m)
    -- Make the move described in history[m].
    local h = history[m]
    currplayer = h.who
    player[h.who].turns = player[h.who].turns + 1
    player[h.who].score = player[h.who].score + h.score
    if (h.move[1] == '{') and (h.move[2] == '}') then
        if passed < numplayers then
            passed = passed + 1
        end
        -- rack and bag don't change
    else
        passed = 0
        local wp = player[h.who]
        if h.move[1] == '{' then
            -- throw back tiles from rack to bag
            for i = 2, h.movelen - 1 do
                local j = 0
                repeat
                    j = j + 1
                until (j > maxslots) or ((wp.rack[j].state == full) and (wp.rack[j].tilech == h.move[i]))
                if j > maxslots then Fatal('Bug: tile not in rack') end
                wp.rack[j].state = empty
                AddTile(h.move[i])
                wp.numtiles = wp.numtiles - 1
            end
        else
            -- move tiles from rack to board
            local p = 0
            for i = 1, h.posnum do
                p = p + 1                         -- pos in move string
                local r = h.pos[i].row
                local c = h.pos[i].col
                if (r < 1) or (r > 15) or (c < 1) or (c > 15) then
                    Fatal('Bug: bad board pos')
                end
                if board[r][c].state ~= empty then
                    Fatal('Bug: square should be empty')
                end
                board[r][c].state = frozen
                board[r][c].tilech = h.move[p]
                CopyTileToBoard(h.move[p], r, c)
                if h.move[p] == joker then
                    board[r][c].jokerch = h.move[p + 1]
                    DrawCardOnJoker(r, c)
                end
                local j = 0
                repeat
                    j = j + 1
                until (j > maxslots) or ((wp.rack[j].state == full) and (wp.rack[j].tilech == h.move[p]))
                if j > maxslots then Fatal('Bug 2: tile not in rack') end
                wp.rack[j].state = empty
                wp.numtiles = wp.numtiles - 1
                if h.move[p] == joker then p = p + 1 end
            end
        end
        -- get new tiles from bag and add to rack
        local j = 0
        for i = 1, h.newlen do
            repeat
                j = j + 1
            until (wp.rack[j].state == empty) or (j == maxslots)
            if j == maxslots then Fatal('Bug: rack full') end
            wp.rack[j].state = full
            wp.rack[j].tilech = h.new[i]
            RemoveTile(h.new[i])
            wp.numtiles = wp.numtiles + 1
        end
    end
end

--------------------------------------------------------------------------------

function WriteGame(filepath)
    -- Write current game info to given file in a format recognized by ReadGame.
    
    local f = io.open(filepath, "w")
    if not f then
        glu.warn("Failed to create game file:\n"..filepath)
        return
    end
    
    -- write version number
    f:write('version='..gameversion..'\n')
    
    -- write players
    for i = 1, numplayers do
        f:write('player'..i..'='..player[i].name..'\n')
    end
    
    -- write history data
    if nummoves == 0 then
        f:write('startgame=true\n')
    else
        f:write('startgame=false\n')
        for i = 1, numplayers do
            f:write('start'..i..'=')
            for j = 1, maxrack do
                f:write(start[i][j])
            end
            f:write('\n')
        end
        f:write('moves='..nummoves..'\n')
        for i = 1, nummoves do
            local h = history[i]
            f:write(i..'='..h.who..' ')
            for j = 1, h.movelen do
                f:write(h.move[j])
            end
            for j = 1, h.posnum do
                f:write(' '..h.pos[j].row)
                f:write(' '..h.pos[j].col)
            end
            if (h.move[1] == '{') and (h.move[2] == '}') then
                -- no new tiles
            else
                if h.newlen > 0 then
                    f:write(' ')
                    for j = 1, h.newlen do
                        f:write(h.new[j])
                    end
                else
                    -- this can happen when no tiles are left
                    f:write(' ""')
                end
            end
            f:write(' '..h.score..'\n')
        end
    end
    
    f:close()
end

--------------------------------------------------------------------------------

function SaveGame()
    -- Prompt for file name and location.
    local ccpath = glu.save("Save current game", "CrossCards game (*.cc)|*.cc",
                          initdir, "saved-game.cc")
    if #ccpath > 0 then
        WriteGame(ccpath)
        -- update initdir by stripping off the file name
        initdir = ccpath:gsub("[^"..pathsep.."]+$","")
    end
end

--------------------------------------------------------------------------------

function CheckCard(card, linenum)
    if (card >= twoSpades) and (card <= joker) then
        -- ok
    else
        error('Bad card on line '..linenum..' ('..card..')')
    end
end

--------------------------------------------------------------------------------

function GetMove(line, linenum, h)
    -- Parse one line of move information.
    
    local lpos = line:find("=") + 1
    h.who = tonumber(line:sub(lpos,lpos))
    if (h.who == nil) or (h.who < 1) or (h.who > numplayers) then
        error('Bad player number on line '..linenum)
    end

    lpos = lpos + 2
    local s = line:sub(lpos, line:find(" ",lpos) - 1)
    h.movelen = #s
    if (h.movelen < 1) or (h.movelen > maxrack*2) then
        error('Bad move length on line '..linenum)
    end
    for i = 1, h.movelen do
        h.move[i] = s:sub(i,i)
    end
    
    lpos = lpos + h.movelen     -- space after h.move string
    h.newlen = 0
    h.posnum = 0
    if h.move[1] ~= '{' then
        local i = 0
        repeat
            i = i + 1
            if h.move[i] >= twoSpades and h.move[i] <= aceHearts then
                h.posnum = h.posnum + 1
            elseif h.move[i] == joker then
                h.posnum = h.posnum + 1
                i = i + 1
                if (i > h.movelen) or (h.move[i] < twoSpades) or (h.move[i] > aceHearts) then
                    error('Bad joker on line '..linenum)
                end
            else
                error('Bad move on line '..linenum)
            end
        until i == h.movelen
        
        -- h.posnum should be >= 1 and gives number of following row col pairs
        lpos = lpos + 1
        for i = 1, h.posnum do
            local r, c, endnum
            endnum = line:find(" ",lpos) - 1
            r = tonumber(line:sub(lpos,endnum))
            if (r == nil) or (r < 1) or (r > 15) then
                error('Bad row coord on line '..linenum)
            end
            
            lpos = endnum + 2
            endnum = line:find(" ",lpos) - 1
            c = tonumber(line:sub(lpos,endnum))
            if (c == nil) or (c < 1) or (c > 15) then
                error('Bad col coord on line '..linenum)
            end
            h.pos[i].row = r
            h.pos[i].col = c
            
            lpos = endnum + 2
        end
        
        -- lpos should be start of new cards
        s = line:sub(lpos, line:find(" ",lpos) - 1)
        if s == '""' then
            h.newlen = 0
            lpos = lpos + 2         -- skip ""
        else
            h.newlen = #s
            if h.newlen > h.posnum then
                error('Bad new cards on line '..linenum..' ('..s..')')
            end
            for i = 1, h.newlen do
                h.new[i] = s:sub(i,i)
                CheckCard(h.new[i], linenum)
            end
        end
        lpos = lpos + h.newlen      -- space after h.new string

    else
        -- h.move[1] == '{'
        if h.move[h.movelen] ~= '}' then
            error('} expected on line '..linenum)
        end
        if h.movelen > 2 then
            for i = 2, h.movelen - 1 do CheckCard(h.move[i], linenum) end
            lpos = lpos + 1
            s = line:sub(lpos, line:find(" ",lpos) - 1)
            if s == '""' then
                h.newlen = 0
                lpos = lpos + 2         -- skip ""
            else
                h.newlen = #s
                if h.newlen ~= h.movelen - 2 then
                    error('Bad replacement cards on line '..linenum..' ('..s..')')
                end
                for i = 1, h.newlen do
                    h.new[i] = s:sub(i,i)
                    CheckCard(h.new[i], linenum)
                end
            end
            lpos = lpos + h.newlen      -- space after h.new string
        else
            -- player passed
            h.newlen = 0
        end
    end
    
    lpos = lpos + 1
    h.score = tonumber(line:sub(lpos,-1))
    if h.score == nil then
        error('Bad score on line '..linenum)
    end
end

--------------------------------------------------------------------------------

function ReadGame(filepath)
    local f = io.open(filepath, "r")
    if not f then
        glu.warn("Failed to open game file:\n"..filepath)
        return false
    end
    
    local function ParseLines()
        local eof = "The game file ended prematurely!"
        local line = f:read("*l")
        if line == nil then error(eof) end
        local linenum = 1
        
        -- get and check version number
        local version = tonumber(line:sub(9,-1))    -- skip "version="
        if version ~= gameversion then
            error("Unexpected version number in game file!")
        end
        
        -- get player names
        for i = 1, numplayers do
            line = f:read("*l")
            if line == nil then error(eof) end
            linenum = linenum + 1
            if not line:find("^player") then
                error("Expected a player name on line "..linenum..".")
            end
            player[i].name = line:sub(9,-1)    -- skip "playerN="
        end
        
        -- get history data
        line = f:read("*l")
        if line == nil then error(eof) end
        linenum = linenum + 1
        startgame = line:sub(11,-1) == "true"     -- skip "startgame="
        if startgame then
            nummoves = 0
            -- ignore rest of file
        else
            for i = 1, numplayers do
                line = f:read("*l")
                if line == nil then error(eof) end
                linenum = linenum + 1
                local lpos = 8                      -- skip "startN="
                for j = 1, maxrack do
                    start[i][j] = line:sub(lpos,lpos)
                    lpos = lpos + 1
                end
            end
            
            line = f:read("*l")
            if line == nil then error(eof) end
            linenum = linenum + 1
            nummoves = tonumber(line:sub(7,-1))     -- skip "moves="
            
            for i = 1, nummoves do
                line = f:read("*l")
                if line == nil then error(eof) end
                linenum = linenum + 1
                history[i] = moveinfo()
                GetMove(line, linenum, history[i])
            end
        end
    end -- ParseLines
    
    local status, err = pcall(ParseLines)
    
    f:close()
    
    if err then
        -- replace "*: msg" with "filepath:\nmsg"
        local p = err:find(": ")
        if p then err = filepath..":\n"..err:sub(p+2) end
        glu.warn(err)
        return false
    else
        -- game file is hunky dory
        return true
    end
end

--------------------------------------------------------------------------------

function OpenGame(ccpath)
    if nummoves > 0 and not endofgame then
        local answer = glu.savechanges("Save the current game?", 
            "Click Cancel if you don't want to open another game.")
        if answer == "cancel" then
            return
        elseif answer == "yes" then
            SaveGame()
        elseif answer == "no" then
            -- continue
        end
    end

    if ccpath == nil then
        -- prompt user for an existing game file
        ccpath = glu.open("Open a CrossCards game", "CrossCards game (*.cc)|*.cc", initdir, "")
        if #ccpath == 0 then return end

        -- update initdir by stripping off the file name
        initdir = ccpath:gsub("[^"..pathsep.."]+$","")
    end
    
    if not ReadGame(ccpath) then return end
    
    -- currplayer and other globals have been set
    local oldcursor = cv.cursor("wait")
    InitBag()
    ClearBoard()
    CheckPlayers(1, numplayers)
    InitPlayers()
    InitThrowBacks()
    ClearInfo()
    numthrown = 0
    canredo = 0
    passed = 0
    endofgame = false
    DrawEverything()
    local oldplay = playsounds
    playsounds = false
    if nummoves == 0 then
        YourMove()
    else
        -- use history array to restore game
        for i = 1, nummoves do
            DoMove(i)
        end
        CheckForEndOfGame()
        if endofgame then
            Congratulations()
        else
            ChangeCurrentPlayer()
            YourMove()
        end
    end
    playsounds = oldplay
    DrawEverything()
    cv.cursor(oldcursor)
end

--------------------------------------------------------------------------------

function RedoMove()
    if endofgame or (canredo == 0) then return end
    
    local oldplay = playsounds
    playsounds = false
    
    -- restore any tiles added to board or bag by current player
    RestoreTiles()
    IncNumMoves(false)      -- don't init moveinfo
    canredo = canredo - 1    
    DoMove(nummoves)
    CheckForEndOfGame()
    if endofgame then
        Congratulations()
    else
        ChangeCurrentPlayer()
        YourMove()
    end
    
    playsounds = oldplay
    DrawEverything()
end

--------------------------------------------------------------------------------

function UndoMove()
    if nummoves < 1 then return end
    if not endofgame then
        -- restore any tiles added to board or bag by current player
        RestoreTiles()
    end
    
    -- undo last move represented by history[nummoves]
    local h = history[nummoves]
    currplayer = h.who
    if endofgame then
        endofgame = false
        -- readjust player scores using endscore array
        for p = 1, numplayers do
            player[p].score = player[p].score - endscore[p]
        end
    end
    player[h.who].turns = player[h.who].turns - 1
    player[h.who].score = player[h.who].score - h.score
    if (h.move[1] == '{') and (h.move[2] == '}') then
        if passed > 0 then
            passed = passed - 1
        end
    else
        local wp = player[h.who]
        -- put new tiles in rack back into bag
        for i = 1, h.newlen do
            local j = 0
            repeat
                j = j + 1
            until (j > maxslots) or ((wp.rack[j].state == full) and (wp.rack[j].tilech == h.new[i]))
            if j > maxslots then Fatal('Bug: new tile not in rack') end
            wp.rack[j].state = empty
            AddTile(h.new[i])
            wp.numtiles = wp.numtiles - 1
        end
        if h.move[1] == '{' then
            -- restore tiles that were thrown back
            local j = 0
            for i = 2, h.movelen - 1 do
                repeat
                    j = j + 1
                until (wp.rack[j].state == empty) or (j == maxslots)
                if j == maxslots then Fatal('Bug: rack full') end
                wp.rack[j].state = full
                wp.rack[j].tilech = h.move[i]
                RemoveTile(h.move[i])
                wp.numtiles = wp.numtiles + 1
            end
            if wp.numtiles ~= maxrack then Fatal('Bug: numtiles ~= maxrack') end
        else
            -- put tiles added to board back into empty rack squares
            local j = 0
            for i = 1, h.posnum do
                local r = h.pos[i].row
                local c = h.pos[i].col
                if board[r][c].state ~= frozen then
                    Fatal('Bug: square should be frozen')
                end
                board[r][c].state = empty
                repeat
                    j = j + 1
                until (wp.rack[j].state == empty) or (j == maxslots)
                if j == maxslots then Fatal('Bug: rack is full') end
                wp.rack[j].state = full
                wp.rack[j].tilech = board[r][c].tilech
                wp.numtiles = wp.numtiles + 1
            end
        end
    end
    nummoves = nummoves - 1
    canredo = canredo + 1
    YourMove()
    DrawEverything()
end

--------------------------------------------------------------------------------

function ShowLastMove()
    if nummoves == 0 then return end
    local hindex = nummoves
    local h = history[hindex]
    if (h.move[1] == '{') and (h.move[2] == '}') then
        glu.note(player[h.who].name.." passed.")
    elseif h.move[1] == '{' then
        if h.movelen - 2 == 1 then
            glu.note(player[h.who].name.." replaced 1 card.")
        else
            glu.note(player[h.who].name.." replaced "..(h.movelen - 2).." cards.")
        end
    else
        -- blink tiles that were used
        local blinkrects = {}
        for i = 1, h.posnum do
            AddToBlink(blinkrects, h.pos[i].row, h.pos[i].col)
        end
        BlinkTiles(blinkrects)
    end
end

--------------------------------------------------------------------------------

function ShowMatchingCards(row, col)
    -- Blink all cards that match value of card in rack (if row == 0) or in board.
    
    if player[currplayer].machine and (not showcompcards) and (not endofgame) then
        return
    end
    
    local currval
    if row == 0 then
        -- clicked card is in rack
        currval = tileval[ player[currplayer].rack[col].tilech ]
    else
        -- clicked card is in board
        currval = tileval[ board[row][col].tilech ]
    end
    
    local blinkrects = {}
    for c = 1, maxslots do
        if (player[currplayer].rack[c].state == full) and
           (tileval[player[currplayer].rack[c].tilech] == currval) then
            -- matching card in rack
            AddToBlink(blinkrects, 0, c)
        end
    end
    for r = 1, 15 do
        for c = 1, 15 do
            if (board[r][c].state ~= empty) and
               (tileval[board[r][c].tilech] == currval) then
                -- matching card in board
                AddToBlink(blinkrects, r, c)
            end
        end
    end
    BlinkTiles(blinkrects)
end

--------------------------------------------------------------------------------

function RestoreRackTiles(oldch, oldsq)
    -- Return dragged rack tiles back to their original rack locations.
    player[currplayer].rack[oldsq].state = full
    player[currplayer].rack[oldsq].tilech = oldch
    CopyTileToRack(oldch, oldsq, currplayer)
    for i = 1, lcount do
        player[currplayer].rack[oldsq - i].state = full
        player[currplayer].rack[oldsq - i].tilech = ltilech[i]
        CopyTileToRack(ltilech[i], oldsq - i, currplayer)
    end
    for i = 1, rcount do
        player[currplayer].rack[oldsq + i].state = full
        player[currplayer].rack[oldsq + i].tilech = rtilech[i]
        CopyTileToRack(rtilech[i], oldsq + i, currplayer)
    end
end

--------------------------------------------------------------------------------

function PlaceExtraTilesInRack(newsq)
    -- rack[newsq].state == full so try to place l/r tiles in empty squares
    -- to l/r of newsq so that relative position is maintained.
    
    local j = newsq
    for i = 1, lcount do
        repeat j = j - 1 until (j < 1) or (player[currplayer].rack[j].state == empty)
        if j < 1 then
            j = maxslots + 1
            repeat j = j - 1 until player[currplayer].rack[j].state == empty
        end
        player[currplayer].rack[j].state = full
        player[currplayer].rack[j].tilech = ltilech[i]
        CopyTileToRack(ltilech[i], j, currplayer)
    end
    j = newsq
    for i = 1, rcount do
        repeat j = j + 1 until (j > maxslots) or (player[currplayer].rack[j].state == empty)
        if j > maxslots then
            j = 0
            repeat j = j + 1 until player[currplayer].rack[j].state == empty
        end
        player[currplayer].rack[j].state = full
        player[currplayer].rack[j].tilech = rtilech[i]
        CopyTileToRack(rtilech[i], j, currplayer)
    end
end

--------------------------------------------------------------------------------

function PlaceExtraTilesOnBoard(row, col, vertical)
    -- Try to place l/r tiles in empty squares to l/r of board[row,col]
    -- or u/d if vertical where board[row,col].state == full.
    -- Any tiles that cannot fit are put back in the current rack.
    
    local r, c, rinc, cinc
    local j = 0
    if vertical then
        rinc = 1
        cinc = 0
    else
        rinc = 0
        cinc = 1
    end
    r = row
    c = col
    for i = 1, lcount do
        repeat
            r = r - rinc
            c = c - cinc
        until (r < 1) or (c < 1) or (board[r][c].state == empty)
        if (r < 1) or (c < 1) then
            -- put tile back in rack
            repeat j = j + 1 until player[currplayer].rack[j].state == empty
            player[currplayer].rack[j].state = full
            player[currplayer].rack[j].tilech = ltilech[i]
            CopyTileToRack(ltilech[i], j, currplayer)
        else
            board[r][c].state = full
            board[r][c].tilech = ltilech[i]
            CopyTileToBoard(ltilech[i], r, c)
        end
    end
    r = row
    c = col
    for i = 1, rcount do
        repeat
            r = r + rinc
            c = c + cinc
        until (r > 15) or (c > 15) or (board[r][c].state == empty)
        if (r > 15) or (c > 15) then
            -- put tile back in rack
            repeat j = j + 1 until player[currplayer].rack[j].state == empty
            player[currplayer].rack[j].state = full
            player[currplayer].rack[j].tilech = rtilech[i]
            CopyTileToRack(rtilech[i], j, currplayer)
        else
            board[r][c].state = full
            board[r][c].tilech = rtilech[i]
            CopyTileToBoard(rtilech[i], r, c)
        end
    end
end

--------------------------------------------------------------------------------

function MoveTilesInRack(card, newsq)
    -- Tile has been dropped on full square in rack, so shift other tiles to
    -- make room for new tile.
    
    local emptyleft, emptyright
    local cp = player[currplayer]
    
    -- search right for closest empty square
    emptyright = newsq
    repeat
        emptyright = emptyright + 1
    until (emptyright > maxslots) or (cp.rack[emptyright].state == empty)     
    if emptyright > maxslots then
        -- no empty squares to right so set emptyright to prevent right shift
        emptyright = 666
    end

    -- search left for closest empty square
    emptyleft = newsq
    repeat
        emptyleft = emptyleft - 1
    until (emptyleft < 1) or (cp.rack[emptyleft].state == empty)
    if emptyleft < 1 then
        -- no empty squares to left so set emptyleft to prevent left shift
        emptyleft = -666
        if emptyright > maxslots then Fatal('Bug in MoveTilesInRack: no empty squares?!') end
    end
    
    -- shift left or right to closest empty square
    if (newsq - emptyleft) < (emptyright - newsq) then
        -- shift tiles from emptyleft+1..newsq one square to the left
        for i = emptyleft + 1, newsq do
            DrawEmptyRack(i)
            SmallDelay()
            cp.rack[i - 1].state = full
            cp.rack[i - 1].tilech = cp.rack[i].tilech
            CopyTileToRack(cp.rack[i - 1].tilech, i - 1, currplayer)
        end
    else
        -- shift tiles from newsq..emptyright-1 one square to the right
        for i = emptyright - 1, newsq, -1 do
            DrawEmptyRack(i)
            SmallDelay()
            cp.rack[i + 1].state = full
            cp.rack[i + 1].tilech = cp.rack[i].tilech
            CopyTileToRack(cp.rack[i + 1].tilech, i + 1, currplayer)
        end
    end
    
    -- now move in tile
    cp.rack[newsq].state = full
    cp.rack[newsq].tilech = card
    CopyTileToRack(cp.rack[newsq].tilech, newsq, currplayer)
end

--------------------------------------------------------------------------------

function MoveTilesOnBoard(card, row, col)
    -- Tile has been dropped on full square on board.
    -- Return true if we can shift tiles to make room for new tile.
    
    local fullleft, fullright, fullup, fulldown
    local emptyleft, emptyright, emptyup, emptydown, incrow, inccol, r, c, ch
    
    -- find first empty squares up/down/left/right from row,col
    -- and count full squares up/down/left/right from row,col
    -- until edge of board or a frozen square is reached
    emptyup = 0
    emptydown = 0
    emptyleft = 0
    emptyright = 0
    
    fullup = 0
    fulldown = 0
    fullleft = 0
    fullright = 0
    
    r = row
    while (r > 1) and (board[r-1][col].state ~= frozen) do
        r = r - 1
        if board[r][col].state == full then
            fullup = fullup + 1                     -- count full squares up
        elseif emptyup == 0 then
            emptyup = r                             -- remember first empty square up
        end
    end    
    
    r = row
    while (r < 15) and (board[r+1][col].state ~= frozen) do
        r = r + 1
        if board[r][col].state == full then
            fulldown = fulldown + 1                 -- count full squares down
        elseif emptydown == 0 then
            emptydown = r                           -- remember first empty square down
        end
    end    
    
    c = col
    while (c > 1) and (board[row][c-1].state ~= frozen) do
        c = c - 1
        if board[row][c].state == full then
            fullleft = fullleft + 1                 -- count full squares left
        elseif emptyleft == 0 then
            emptyleft = c                           -- remember first empty square left
        end
    end    
    
    c = col
    while (c < 15) and (board[row][c+1].state ~= frozen) do
        c = c + 1
        if board[row][c].state == full then
            fullright = fullright + 1               -- count full squares right
        elseif emptyright == 0 then
            emptyright = c                          -- remember first empty square right
        end
    end    
    
    if emptyup + emptydown + emptyleft + emptyright == 0 then
        -- shifting not possible
        return false
    end
    
    -- determine which direction to shift; this depends on where most of
    -- the full squares are, and the nearest empty square
    r = row
    c = col
    incrow = 0
    inccol = 0
    if (((fullleft + fullright) >= (fullup + fulldown)) and (emptyleft + emptyright > 0)) or
        (((fullleft + fullright) < (fullup + fulldown)) and (emptyup + emptydown == 0)) then
        -- shift left/right
        if emptyleft == 0 then
            c = emptyright - 1
            inccol =  1             -- forced right
        elseif emptyright == 0 then
            c = emptyleft + 1
            inccol = -1             -- forced left
        else
            -- shift left/right to nearest empty square
            if (col - emptyleft) > (emptyright - col) then
                c = emptyright - 1
                inccol =  1
            elseif (col - emptyleft) < (emptyright - col) then
                c = emptyleft + 1
                inccol = -1
            else
                -- equidistant so shift towards most full squares
                if fullright >= fullleft then
                    c = emptyright - 1
                    inccol =  1
                else
                    c = emptyleft + 1
                    inccol = -1
                end
            end
        end
    else
        -- shift up/down
        if emptyup == 0 then
            r = emptydown - 1
            incrow =  1             -- forced down
        elseif emptydown == 0 then
            r = emptyup + 1
            incrow = -1             -- forced up
        else
            -- shift up/down to nearest empty square
            if (row - emptyup) > (emptydown - row) then
                r = emptydown - 1
                incrow =  1
            elseif (row - emptyup) < (emptydown - row) then
                r = emptyup + 1
                incrow = -1
            else
                -- equidistant so shift towards most full squares
                if fulldown >= fullup then
                    r = emptydown - 1
                    incrow =  1
                else
                    r = emptyup + 1
                    incrow = -1
                end
            end
        end
    end
    
    -- do the shift
    while true do
        DrawEmptySquare(r, c)
        SmallDelay()
        ch = board[r][c].tilech
        board[r + incrow][c + inccol].state = full
        board[r + incrow][c + inccol].tilech = ch
        CopyTileToBoard(ch, r + incrow, c + inccol)
        if (r == row) and (c == col) then break end
        r = r - incrow
        c = c - inccol
    end
    
    -- board[row][col].state == full
    board[row][col].tilech = card
    CopyTileToBoard(card, row, col)
    return true
end

--------------------------------------------------------------------------------

function InitDraggedTiles(destrect)
    -- Draw dragged tile(s) into given rect.
    local r = Rect(destrect)
    
    -- draw any tiles left of or above draggedtile
    for i = lcount, 1, -1 do
        DrawCard(ltilech[i], r.left, r.top)
        if vertline then
            r.top = r.top + boardsquare
        else
            r.left = r.left + boardsquare
        end
    end
    
    -- draw clicked tile
    DrawCard(draggedtile, r.left, r.top)
    
    -- draw any tiles right of or below draggedtile
    for i = 1, rcount do
        if vertline then
            r.top = r.top + boardsquare
        else
            r.left = r.left + boardsquare
        end
        DrawCard(rtilech[i], r.left, r.top)
    end
end

--------------------------------------------------------------------------------

function DrawDraggedTiles(x, y)
    if vertline then
        if lcount > 0 then
            -- shift y up so that clicked tile is under cursor
            y = y - lcount * boardsquare
        end
    else
        if lcount > 0 then
            -- shift x left so that clicked tile is under cursor
            x = x - lcount * boardsquare
        end
    end
    
    -- put middle of dragged tile under cursor
    x = x - (boardsquare // 2)
    y = y - (boardsquare // 2)
    
    cv.blend(1)
    cv.paste(dragclip, x, y)
    cv.blend(0)
    cv.update()
end

--------------------------------------------------------------------------------

function StartDragging(x, y, tile)
    -- Prepare to drag clicked tile (from rack or board).
    currcursx = x
    currcursy = y
    draggedtile = tile
    
    -- create clip with current canvas
    cv.copy(0, 0, 0, 0, dragbg)
    
    -- create clip to display dragged tiles
    local r = Rect()
    r.top = boardrect.top
    r.left = boardrect.left
    if vertline then
        r.bottom = r.top + boardsquare * dragcount - 1
        r.right = r.left + boardsquare - 1
    else
        r.bottom = r.top + boardsquare - 1
        r.right = r.left + boardsquare * dragcount - 1
    end

    local shadowrect = Rect(r)
    OffsetRect(shadowrect, shadowsize,  shadowsize)
    
    -- add space for shadow
    r.right = r.right + shadowsize
    r.bottom = r.bottom + shadowsize
    
    cv.rgba(0,0,0,0)
    FillRect(r)
    cv.rgba(cp.black)

    -- draw shadow under tile(s)
    cv.rgba(0,0,0,64)
    FillRect(shadowrect)
    cv.rgba(cp.black)
    
    InitDraggedTiles(r)
    
    local w = r.right - r.left + 1
    local h = r.bottom - r.top + 1
    cv.copy(r.left, r.top, w, h, dragclip)
    cv.paste(dragbg, 0, 0)
    
    DrawDraggedTiles(x, y)
end

--------------------------------------------------------------------------------

local function CheckTilesLeft(x, y)
    -- Check if cursor is over innerleft.
    if tilesleft >= maxrack then
        local overbag = PtInRect(x, y, innerleft)
        if overbag and (not baginverted) then
            InvertRect(innerleft)
            cv.copy(0, 0, 0, 0, dragbg)
            baginverted = true
        elseif not overbag and baginverted then
            InvertRect(innerleft)
            cv.copy(0, 0, 0, 0, dragbg)
            baginverted = false
        end
    end
end

--------------------------------------------------------------------------------

local function CheckSelection(x, y)
    -- Invert selected card and set whichtile (index into tileinfo if > 0).
    local n = 0
    while true do
        n = n + 1
        if PtInRect(x, y, tileinfo[n].r) then
            if whichtile ~= n then
                if whichtile > 0 then
                    InvertRect(tileinfo[whichtile].r)
                end
                whichtile = n
                InvertRect(tileinfo[whichtile].r)
                cv.copy(0, 0, 0, 0, dragbg)
            end
            break
        end
        if n == totaltiles then
            -- cursor not over card
            if whichtile > 0 then
                InvertRect(tileinfo[whichtile].r)
                cv.copy(0, 0, 0, 0, dragbg)
                whichtile = 0
            end
            break
        end
    end
end

--------------------------------------------------------------------------------

local function DragTile(newx, newy, CheckInversion)
    if (newx == currcursx) and (newy == currcursy) then
        -- cursor hasn't moved, so do nothing
        return
    end
    
    -- restore correct background image
    cv.paste(dragbg, 0, 0)
    
    -- call routine that might invert one or more rectangles in current port
    if CheckInversion ~= nil then
        CheckInversion(newx, newy)  -- calls CheckTilesLeft or CheckSelection
    end
    
    DrawDraggedTiles(newx, newy)
    
    -- update current cursor location
    currcursx = newx
    currcursy = newy
end

--------------------------------------------------------------------------------

function StopDragging()
    -- Restore canvas background and delete any clips used while dragging tile(s).
    cv.paste(dragbg, 0, 0)
    cv.delete(dragbg)
    cv.delete(dragclip)
end

--------------------------------------------------------------------------------

function DrawShadows(r)
    -- Draw shadows at bottom right edges.
    local x = r.left
    local y = r.top
    local wd = r.right - r.left + 1
    local ht = r.bottom - r.top + 1
    local r2 = Rect(r)
    r2.top = y + ht
    r2.bottom = r2.top + shadowsize
    r2.left = x + shadowsize
    r2.right = r2.left + wd
    cv.blend(1)
    cv.rgba(0,0,0,64)
    FillRect(r2)
    r2.top = y + shadowsize
    r2.bottom = r2.top + ht - shadowsize - 1
    r2.left = x + wd
    r2.right = r2.left + shadowsize
    FillRect(r2)
    cv.rgba(cp.black)
    cv.blend(0)
end

--------------------------------------------------------------------------------

function SwapRackCard(pos, x, y)
    -- Let user swap card in rack with one from bag.
    local rackch = player[currplayer].rack[pos].tilech
    local rows = numdecks * 4 + 1
    local cols = 13
    
    -- create pop-up window just below rack
    local r = Rect()
    r.top = innerrack.bottom + 3
    r.bottom = r.top + rows * boardsquare + 2
    r.left = innerrack.left - 2 * boardsquare + 1
    r.right = r.left + cols * boardsquare + 2
    DrawPopUp(r)
    
    -- display cards in bag, leaving gaps where cards have been taken out;
    -- also initialize tileinfo array for later use
    local t = 0
    for ordch = ORD(twoSpades), ORD(joker) do
        local card = CHR(ordch)
        local suit = tilesuit[card]
        local value = tileval[card]
        local i = 0
        local count = numdecks
        if card == joker then count = numjokers end
        for n = 1, count do
            t = t + 1
            tileinfo[t] = {}
            repeat i = i + 1 until (i > tilesleft) or (card == bag[i])
            if i <= tilesleft then
                -- card is in bag
                local x, y
                if card == joker then
                    -- show jokers along bottom row
                    x = (n - 1) * boardsquare + 2
                    y = numdecks * 4 * boardsquare + 2
                else
                    -- value == 2..14 and suit == 0..3
                    x = (value - 2) * boardsquare + 2
                    y = (numdecks * suit + n - 1) * boardsquare + 2
                end
                DrawCard(card, r.left+x, r.top+y)
                tileinfo[t].r = Rect()
                tileinfo[t].r.top = r.top+y
                tileinfo[t].r.left = r.left+x
                tileinfo[t].r.bottom = tileinfo[t].r.top + boardsquare - 2
                tileinfo[t].r.right = tileinfo[t].r.left + boardsquare - 2
                tileinfo[t].ch = card
            else
                -- card not in bag
                tileinfo[t].r = Rect()    -- top,bottom,left,right == 0
            end
        end
    end
    
    -- draw empty rack at position of clicked card
    DrawEmptyRack(pos)
    
    -- save position of clicked card
    dragcount = 1
    dragpos[1].row = pos
    dragpos[1].col = 0
    
    -- let user select new card from those left in bag
    lcount = 0
    rcount = 0
    
    StartDragging(x, y, rackch)
    whichtile = 0                         -- tested and set in CheckSelection
    while true do
        local event = glu.getevent()
        local x, y = cv.getxy()
        if x >= 0 and y >= 0 then
            DragTile(x, y, CheckSelection)
        end
        if event:find("^mup") then break end
    end
    StopDragging()
    
    if (whichtile > 0) and (rackch ~= tileinfo[whichtile].ch) then
        -- replace rackch with selected card;
        -- first we must update some history for the current player
        local n = nummoves + 1
        while true do
            if n > numplayers then
                n = n - numplayers
            else
                -- rackch must be in start array
                for i = 1, maxrack do
                    if start[currplayer][i] == rackch then
                        start[currplayer][i] = tileinfo[whichtile].ch
                        goto found
                    end
                end
                Fatal('Bug: rackch not found!')
            end
            -- search new array for first rackch
            for i = 1, history[n].newlen do
                if history[n].new[i] == rackch then
                    history[n].new[i] = tileinfo[whichtile].ch
                    goto found
                end
            end
        end
        ::found::
        canredo = 0
        -- now update bag and rack
        RemoveTile(tileinfo[whichtile].ch)
        AddTile(rackch)
        rackch = tileinfo[whichtile].ch
        player[currplayer].rack[pos].tilech = rackch
    end
    
    CopyTileToRack(rackch, pos, currplayer)
    DrawEverything()
end

--------------------------------------------------------------------------------

function DragRackTile(oldsq, x, y, modifiers)
    local oldch = player[currplayer].rack[oldsq].tilech
    local vertical = modifiers:find("alt") ~= nil
    local cp = player[currplayer]
    
    dragcount = 1
    dragpos[1].row = oldsq
    dragpos[1].col = 0
    lcount = 0
    rcount = 0
    if modifiers:find("shift") then
        -- determine how many contiguous cards are left and right of oldsq
        local i = oldsq - 1
        while (i >= 1) and (cp.rack[i].state == full) do
            lcount = lcount + 1
            ltilech[lcount] = cp.rack[i].tilech
            dragcount = dragcount + 1
            dragpos[dragcount].row = i
            cp.rack[i].state = empty
            i = i - 1
        end
        i = oldsq + 1
        while (i <= maxslots) and (cp.rack[i].state == full) do
            rcount = rcount + 1
            rtilech[rcount] = cp.rack[i].tilech
            dragcount = dragcount + 1
            dragpos[dragcount].row = i
            cp.rack[i].state = empty
            i = i + 1
        end
    end
    cp.rack[oldsq].state = empty
    -- all rack squares that had a dragged tile are now empty
    
    -- erase dragged tile(s)
    DrawEverything()
    
    if lcount + rcount > 0 then
        vertline = vertical
    end
    
    StartDragging(x, y, oldch)
    baginverted = false               -- tested and set in CheckTilesLeft
    while true do
        local event = glu.getevent()
        x, y = cv.getxy()
        if x >= 0 and y >= 0 then
            DragTile(x, y, CheckTilesLeft)
        end
        if event:find("^mup") then break end
    end
    StopDragging()
    
    -- move tile(s) depending on final location
    if baginverted then
        for i = lcount, 1, -1 do
            PutTileBack(ltilech[i])
        end
        PutTileBack(oldch)
        for i = 1, rcount do
            PutTileBack(rtilech[i])
        end
        UpdateCurrentScore()
    
    elseif PtInRect(x, y, innerrack) then
        -- tile(s) dropped on rack
        local newsq = (x - innerrack.left) // racksquare + 1
        if newsq > maxslots then newsq = maxslots end
        if newsq == oldsq then
            -- tile(s) not moved to new location
            RestoreRackTiles(oldch, oldsq)
        elseif cp.rack[newsq].state == empty then
            -- move tile(s) to empty square(s)
            cp.rack[newsq].state = full
            cp.rack[newsq].tilech = oldch
            CopyTileToRack(cp.rack[newsq].tilech, newsq, currplayer)
            if lcount + rcount > 0 then PlaceExtraTilesInRack(newsq) end
        elseif math.abs(newsq - oldsq) == 1 then
            -- just swap neighbouring tiles
            cp.rack[oldsq].state = full
            cp.rack[oldsq].tilech = cp.rack[newsq].tilech
            CopyTileToRack(cp.rack[oldsq].tilech, oldsq, currplayer)
            cp.rack[newsq].tilech = oldch
            CopyTileToRack(cp.rack[newsq].tilech, newsq, currplayer)
            if lcount + rcount > 0 then PlaceExtraTilesInRack(newsq) end
        else
            -- tile dropped on full square in rack, so shift tiles
            MoveTilesInRack(oldch, newsq)
            if lcount + rcount > 0 then PlaceExtraTilesInRack(newsq) end
        end
    
    elseif PtInRect(x, y, boardrect) then
        -- tile(s) dropped on board
        local row, col = GetBoardSquare(x, y)
        if board[row][col].state ~= frozen then
            if board[row][col].state == empty then
                -- move tile from rack to empty square on board
                board[row][col].state = full
                board[row][col].tilech = oldch
                CopyTileToBoard(oldch, row, col)
                if lcount + rcount > 0 then
                    PlaceExtraTilesOnBoard(row, col, vertical)
                end
                UpdateCurrentScore()
            else
                -- row,col == full so shift tiles on full squares if possible
                if MoveTilesOnBoard(oldch, row, col) then
                    if lcount + rcount > 0 then
                        PlaceExtraTilesOnBoard(row, col, vertical)
                    end
                    UpdateCurrentScore()
                else
                    -- could not shift any tiles
                    RestoreRackTiles(oldch, oldsq)
                end
            end
        else
            -- tile not dropped on empty/full board square
            RestoreRackTiles(oldch, oldsq)
        end
    
    else
        -- tile not dropped on rack or board
        RestoreRackTiles(oldch, oldsq)
    end
    
    DrawEverything()
end

--------------------------------------------------------------------------------

function ClickInRack(x, y, button, modifiers)
    if endofgame then
        -- switch to next player's rack
        ChangeCurrentPlayer()
        DrawEverything()
        return
    end

    -- calculate which square was clicked
    local c = (x - innerrack.left) // racksquare + 1
    if c > maxslots then c = maxslots end
    
    if modifiers:find("alt") and modifiers:find("ctrl") then
        if tilesleft > 0 then
            -- let user swap card
            if player[currplayer].rack[c].state == full then
                SwapRackCard(c, x, y)
            end
        end
        return
    end
    
    if modifiers == "alt" then
        if player[currplayer].rack[c].state == full then
            ShowMatchingCards(0, c)
        end
        return
    end
    
    if player[currplayer].machine then
        return
    end
    
    if player[currplayer].rack[c].state == full then
        if modifiers == "ctrl" or button == "right" then
            if tilesleft < maxrack then
                glu.warn("Not enough cards left.")
            else
                -- return tile to bag
                player[currplayer].rack[c].state = empty
                PutTileBack(player[currplayer].rack[c].tilech)
                UpdateCurrentScore()
                DrawEverything()
            end
        else
            DragRackTile(c, x, y, modifiers)
        end
    end
end

--------------------------------------------------------------------------------

function WaitForUser()
    local t0 = glu.millisecs()
    cv.update()
    while true do
        local event = glu.getevent()
        if event:find("^cclick") then break end
        if event:find("^mup") and glu.millisecs() - t0 > 500 then break end
    end
    DrawEverything()
end

--------------------------------------------------------------------------------

function ShowTiles()
    -- Show all cards remaining in bag.
    
    local cols = 13
    local textht = 16
    local rows = numdecks * 4 + 1
    local r = Rect()
    r.top = innerleft.bottom
    r.bottom = r.top + (rows * boardsquare + 1) + 1
    r.left = bgx + 20
    r.right = r.left + (cols * boardsquare + 1) + 1
    DrawPopUp(r)

    -- display cards in bag, leaving gaps where cards have been taken out
    for ordch = ORD(twoSpades), ORD(joker) do
        local card = CHR(ordch)
        local suit = tilesuit[card]
        local value = tileval[card]
        local i = 0
        local count = numdecks
        if card == joker then count = numjokers end
        for n = 1, count do
            repeat i = i + 1 until (i > tilesleft) or (card == bag[i])
            if i <= tilesleft then
                -- card is in bag
                local x, y
                if card == joker then
                    -- show jokers along bottom row
                    x = (n - 1) * boardsquare + 2
                    y = numdecks * 4 * boardsquare + 2
                else
                    -- value == 2..14 and suit == 0..3
                    x = (value - 2) * boardsquare + 2
                    y = (numdecks * suit + n - 1) * boardsquare + 2
                end
                DrawCard(card, r.left+x, r.top+y)
            end
        end
    end
    
    WaitForUser()
end

--------------------------------------------------------------------------------

function ShowHistory(p)
    -- Show move history for given player (p == 1..numplayers).

    cv.font("mono", 10)
    local charwd, lineht = cp.maketext("X")
    local textindent = 6
    local hchars = 40       -- normal character width for history window
    
    -- determine how many moves in history array are for player p
    local pmoves = 0
    for h = 1, nummoves + canredo do
        if history[h].who == p then pmoves = pmoves + 1 end
    end
    if endofgame then
        -- add extra line at bottom
        pmoves = pmoves + 1
    end

    local r = Rect()
    r.top = playerrect[p].bottom
    r.bottom = r.top + (pmoves + 2) * lineht + 8
    r.left = bgx + 20
    r.right = r.left + hchars * charwd + 2 * textindent
    DrawPopUp(r)
    
    -- display history info for player p
    local x = r.left + textindent
    local y = r.top + 5
    DrawText('History for '..player[p].name..'.', x, y)
    y = y + lineht
    if pmoves == 0 then
        DrawText('No moves.', x, y)
    else
        DrawText('Move  Cards used', x, y)
        x = x + (hchars - 5) * charwd
        DrawText('Score', x, y)
        for line = 1, pmoves do
            -- show line of history info for player p
            local numstr
            local i = 0
            local h = 0
            while true do
                h = h + 1
                if h > nummoves + canredo then
                    -- line must be at bottom
                    x = r.left + textindent
                    y = r.top + 5 + (line+1) * lineht
                    if endofgame then
                        DrawText('End of game:', x, y)
                        numstr = tostring(endscore[p])
                        if endscore[p] > 0 then
                            numstr = '+'..numstr
                        end
                        x = r.left + textindent + (hchars - #numstr) * charwd
                        DrawText(numstr, x, y)
                    end
                    break
                end
                if history[h].who == p then
                    i = i + 1
                    if i == line then
                        numstr = tostring(h)
                        x = r.left + textindent
                        y = r.top + 5 + (line+1) * lineht
                        DrawText(numstr, x, y)
                        if (canredo > 0) and (h > nummoves) then
                            x = r.left + textindent + 4 * charwd
                            if h == nummoves + 1 then
                                DrawText('*', x, y)
                            else
                                DrawText('+', x, y)
                            end
                        end
                        x = r.left + textindent + 6 * charwd
                        if history[h].move[1] == '{' then
                            if history[h].move[2] == '}' then
                                DrawText('passed', x, y)
                            else
                                x = x + DrawText('replaced ', x, y)
                                for m = 2, history[h].movelen-1 do
                                    x = ShowCard(history[h].move[m], false, x, y) + 2
                                end
                            end
                        else
                            -- show tiles used
                            for m = 1, history[h].movelen do
                                if history[h].move[m] == joker then
                                    if m > 1 then
                                        x = x + 2
                                    end
                                else
                                    if (m > 1) and (history[h].move[m-1] ~= joker) then
                                        x = x + 2
                                    end
                                    x = ShowCard(history[h].move[m], (m > 1) and (history[h].move[m-1] == joker), x, y)
                                end
                            end
                        end
                        numstr = tostring(history[h].score)
                        x = r.left + textindent + (hchars - #numstr) * charwd
                        DrawText(numstr, x, y)
                        break
                    end
                end
            end -- while
        end
    end

    WaitForUser()
end

--------------------------------------------------------------------------------

function ShowBonusInfo()
    -- get bonus info
    local x = bgx + 20
    local y = bonusrect.bottom + 1
    local wd, ht = cv.load("images/bonuses.png", "temp")
    cv.paste("temp", x, y)
    cv.delete("temp")
    
    local r = Rect()
    r.top = y
    r.left = x
    r.bottom = y + ht - 1
    r.right = x + wd - 1
    DrawShadows(r)
    
    -- draw text within rect
    x = r.left + 12
    y = r.top + 13
    local ygap = 26
    local col2 = r.left + 110
    local col3 = r.left + 245
    DrawText("Make a hand using 5 cards to score bonus points:", x, y)
    DrawText("Hand", x, y+ygap)
    DrawText("Bonus", col2, y+ygap)
    DrawText("Examples", col3, y+ygap)

    DrawText("Royal flush", x,    y+ygap*2)
    DrawText("Straight flush", x, y+ygap*3)
    DrawText("Five of a kind", x, y+ygap*4)
    DrawText("Straight", x,       y+ygap*5)
    DrawText("Flush", x,          y+ygap*6)
    DrawText("Full house", x,     y+ygap*7)

    DrawText("100", col2, y+ygap*2)
    DrawText("70",  col2, y+ygap*3)
    DrawText("50",  col2, y+ygap*4)
    DrawText("40",  col2, y+ygap*5)
    DrawText("30",  col2, y+ygap*6)
    DrawText("20",  col2, y+ygap*7)
    
    col3 = col3 + 22
    for i = 2, 7 do
        DrawText("or", col3, y+ygap*i)
    end
    
    WaitForUser()
end

--------------------------------------------------------------------------------

function ShowStats()
    local f = io.open(statspath, "r")
    if not f then
        glu.note("There are no statistics for the current players.\n"..
                 "You need to play at least one game.")
        return
    end
    local statsinfo = statsrecord()
    if not GetStatsInfo(f, statsinfo) then
        f:close()
        return
    end
    f:close()

    cv.font("mono", 10)
    local charwd, lineht = cp.maketext("X")
    local fieldwd = 16 * charwd
    local textindent = 6
    
    local r = Rect()
    r.top = statsrect.bottom
    r.bottom = r.top + 8 * lineht + 8
    r.left = bgx + 20
    r.right = r.left + textindent + fieldwd * (numplayers+1)
    DrawPopUp(r)
    
    local function DisplayLine(linenum, field, str)
        local x = r.left + textindent + (field - 1) * fieldwd
        local y = r.top + 5 + lineht * (linenum - 1)
        DrawText(str, x, y)
    end

    --[[ show statsinfo info in a format like this:
        Statistics for: Andy            Joker 9
          Games played: 446 (2 drawn) 
             Games won: 111 (25.dd%)    333 (75.dd%)
            Best total: 481             512
           Worst total: 202             278
         Average total: 320.dd          360.dd
             Best move: 95              120
          Average move: 23.dd           28.dd
    --]]
    DisplayLine(1,1,'Statistics for:')
    for p = 1, numplayers do
        DisplayLine(1,p+1,player[p].name)
    end

    DisplayLine(2,1,'  Games played:')
    DisplayLine(3,1,'          Wins:')
    DisplayLine(4,1,'    Best total:')
    DisplayLine(5,1,'   Worst total:')
    DisplayLine(6,1,' Average total:')
    DisplayLine(7,1,'     Best move:')
    DisplayLine(8,1,'  Average move:')
    
    local str = ''..statsinfo.totalgames
    if statsinfo.totaldrawn > 0 then
        str = str..' ('..statsinfo.totaldrawn..' drawn)'
    end
    DisplayLine(2,2,str)

    for p = 1, numplayers do
        str = string.format(" (%.2f%%)", 100.0 * statsinfo.wins[p] / statsinfo.totalgames)
        DisplayLine(3,p+1,''..statsinfo.wins[p]..str)
    end
    
    for p = 1, numplayers do
        DisplayLine(4,p+1,''..statsinfo.bestgame[p])
    end
    
    for p = 1, numplayers do
        DisplayLine(5,p+1,''..statsinfo.worstgame[p])
    end
    
    for p = 1, numplayers do
        DisplayLine(6,p+1,string.format("%.2f", statsinfo.totalscore[p] / statsinfo.totalgames))
    end
    
    for p = 1, numplayers do
        DisplayLine(7,p+1,''..statsinfo.bestmove[p])
    end
    
    for p = 1, numplayers do
        DisplayLine(8,p+1,string.format("%.2f", statsinfo.totalscore[p] / statsinfo.totalmoves[p]))
    end
    
    WaitForUser()
end

--------------------------------------------------------------------------------

function RestoreOneTile(r, c)
    -- Move tile at board[r][c] back to rack.
    board[r][c].state = empty
    DrawEmptySquare(r, c)
    local t = 0
    repeat
        t = t + 1
        if t > maxslots then Fatal('Bug in RestoreOneTile!') end
    until player[currplayer].rack[t].state == empty
    player[currplayer].rack[t].state = full
    player[currplayer].rack[t].tilech = board[r][c].tilech
    CopyTileToRack(player[currplayer].rack[t].tilech, t, currplayer)
    UpdateCurrentScore()
    DrawEverything()
end

--------------------------------------------------------------------------------

function ReturnDraggedTiles(oldch)
    local t = 0
    repeat
        t = t + 1
        if t > maxslots then Fatal('Bug in ReturnDraggedTiles!') end
    until player[currplayer].rack[t].state == empty
    player[currplayer].rack[t].state = full
    player[currplayer].rack[t].tilech = oldch
    CopyTileToRack(player[currplayer].rack[t].tilech, t, currplayer)
    if lcount + rcount > 0 then PlaceExtraTilesInRack(t) end
end

--------------------------------------------------------------------------------

function RestoreBoardTiles(oldch, row, col, vertical)
    -- Restore dragged tiles to original board locations.
    local r, c, rinc, cinc
    
    -- return all dragged tiles to rack
    for i = 1, dragcount do
        if board[ dragpos[i].row ][ dragpos[i].col ].state ~= empty then
            ReturnDraggedTiles(oldch)
            return
        end
    end
    
    -- board[row][col].state is empty
    board[row][col].state = full
    CopyTileToBoard(oldch, row, col)
    
    -- place l/r tiles in original empty squares on board
    if vertical then
        rinc = 1
        cinc = 0
    else
        rinc = 0
        cinc = 1
    end
    r = row
    c = col
    for i = 1, lcount do
        repeat
            r = r - rinc
            c = c - cinc
        until (r < 1) or (c < 1) or (board[r][c].state == empty)
        if (r < 1) or (c < 1) then
            Fatal('Bug 1: could not find empty square')
        else
            board[r][c].state = full
            board[r][c].tilech = ltilech[i]
            CopyTileToBoard(ltilech[i], r, c)
        end
    end
    r = row
    c = col
    for i = 1, rcount do
        repeat
            r = r + rinc
            c = c + cinc
        until (r > 15) or (c > 15) or (board[r][c].state == empty)
        if (r > 15) or (c > 15) then
            Fatal('Bug 2: could not find empty square')
        else
            board[r][c].state = full
            board[r][c].tilech = rtilech[i]
            CopyTileToBoard(rtilech[i], r, c)
        end
    end
end

--------------------------------------------------------------------------------

function FindExtraTilesOnBoard(row, col, vertical)
    -- Find contiguous line of full squares l/r or u/d from board[row][col]
    -- and set l/rcount and l/rtilech arrays.
    
    local r, c, rinc, cinc
    if vertical then
        rinc = 1
        cinc = 0
    else
        rinc = 0
        cinc = 1
    end
    r = row - rinc
    c = col - cinc
    while (r >= 1) and (c >= 1) and (board[r][c].state ~= empty) do
        if board[r][c].state == full then
            lcount = lcount + 1
            ltilech[lcount] = board[r][c].tilech
            dragcount = dragcount + 1
            dragpos[dragcount].row = r
            dragpos[dragcount].col = c
            board[r][c].state = empty
        end
        r = r - rinc
        c = c - cinc
    end
    r = row + rinc
    c = col + cinc
    while (r <= 15) and (c <= 15) and (board[r][c].state ~= empty) do
        if board[r][c].state == full then
            rcount = rcount + 1
            rtilech[rcount] = board[r][c].tilech
            dragcount = dragcount + 1
            dragpos[dragcount].row = r
            dragpos[dragcount].col = c
            board[r][c].state = empty
        end
        r = r + rinc
        c = c + cinc
    end
end

--------------------------------------------------------------------------------

function DragBoardTile(row, col, x, y, modifiers)
    local oldch = board[row][col].tilech
    local vertical = modifiers:find("alt") ~= nil
    local vline = vertical
    
    dragcount = 1
    dragpos[1].row = row
    dragpos[1].col = col
    
    lcount = 0
    rcount = 0
    if modifiers:find("shift") then
        FindExtraTilesOnBoard(row, col, vertical)
        if lcount + rcount == 0 then
            -- try opposite direction
            FindExtraTilesOnBoard(row, col, not vertical)
            vline = not vertical
        end
    end
    board[row][col].state = empty
    -- all board squares that had a dragged tile are now empty
    
    -- erase dragged tile(s)
    DrawEverything()
    
    if lcount + rcount > 0 then
        vertline = vertical
    end
    
    StartDragging(x, y, oldch)
    baginverted = false               -- tested and set in CheckTilesLeft
    while true do
        local event = glu.getevent()
        x, y = cv.getxy()
        if x >= 0 and y >= 0 then
            DragTile(x, y, CheckTilesLeft)
        end
        if event:find("^mup") then break end
    end
    StopDragging()
    -- board[row][col].state is empty
    
    -- move tile(s) depending on final location
    if baginverted then
        for i = lcount, 1, -1 do
            PutTileBack(ltilech[i])
        end
        PutTileBack(oldch)
        for i = 1, rcount do
            PutTileBack(rtilech[i])
        end
        UpdateCurrentScore()
    
    elseif PtInRect(x, y, innerrack) then
        -- tile(s) dropped on rack
        local newsq = (x - innerrack.left) // racksquare + 1
        if newsq > maxslots then newsq = maxslots end
        if player[currplayer].rack[newsq].state == empty then
            -- move tile from board to empty square in rack
            player[currplayer].rack[newsq].state = full
            player[currplayer].rack[newsq].tilech = oldch
            CopyTileToRack(player[currplayer].rack[newsq].tilech, newsq, currplayer)
            if lcount + rcount > 0 then PlaceExtraTilesInRack(newsq) end
        else
            -- tile dropped on full square in rack
            MoveTilesInRack(oldch, newsq)
            if lcount + rcount > 0 then PlaceExtraTilesInRack(newsq) end
        end
        UpdateCurrentScore()
    
    elseif PtInRect(x, y, boardrect) then
        -- tile(s) dropped on board
        local newrow, newcol = GetBoardSquare(x, y)
        if board[newrow][newcol].state ~= frozen then
            if board[newrow][newcol].state == empty then
                -- move tile to empty square
                board[newrow][newcol].state = full
                board[newrow][newcol].tilech = oldch
                CopyTileToBoard(oldch, newrow, newcol)
                if lcount + rcount > 0 then
                    PlaceExtraTilesOnBoard(newrow, newcol, vertical)
                end
                UpdateCurrentScore()
            else
                -- board[newrow][newcol].state == full (so can't be row,col)
                if (board[row][col].state == empty) and
                    ( ((newrow == row) and (math.abs(newcol - col) == 1)) or
                      ((newcol == col) and (math.abs(newrow - row) == 1))
                    ) then
                    -- just swap neighbouring tiles
                    board[row][col].state = full
                    board[row][col].tilech = board[newrow][newcol].tilech
                    CopyTileToBoard(board[row][col].tilech, row, col)
                    board[newrow][newcol].tilech = oldch
                    CopyTileToBoard(oldch, newrow, newcol)
                    if lcount + rcount > 0 then
                        PlaceExtraTilesOnBoard(newrow, newcol, vertical)
                    end
                    UpdateCurrentScore()
                else
                    -- shift tiles if possible
                    if MoveTilesOnBoard(oldch, newrow, newcol) then
                        if lcount + rcount > 0 then
                            PlaceExtraTilesOnBoard(newrow, newcol, vertical)
                        end
                        UpdateCurrentScore()
                    else
                        -- could not shift any tiles
                        RestoreBoardTiles(oldch, row, col, vline)
                    end
                end
            end
        else
            -- tile dropped on frozen square
            RestoreBoardTiles(oldch, row, col, vline)
        end
    
    else
        -- tile not dropped on rack or board
        RestoreBoardTiles(oldch, row, col, vline)
    end
    
    DrawEverything()
end

--------------------------------------------------------------------------------

function ChangePlayerName(p)
    -- Return true if name and/or skill for player[p] is changed.
    
    local newname = player[p].name
    local function GetNewName()
        local msg = [[
Change the name for this player.
Must be no more than 10 characters.

If the name ends with a digit then
moves will be made by the computer
where the digit is the skill level
(0 for novice, 9 for expert).]]
        return glu.getstring(msg, newname, "Change Player Name")
    end

    while true do
        ::try_again::
        
        newname = GetNewName()
        if newname == nil then
            -- user hit Cancel
            return false
        end
    
        if #newname > maxnamelen then
            glu.warn("The new name is too long (must be no more than 10 characters).")
            goto try_again
        elseif #newname == 0 then
            glu.warn("The new name must have at least 1 character.")
            goto try_again
        end
        
        break
    end    
    
    if newname ~= player[p].name then
        player[p].name = newname
        CheckPlayers(p, p)
        return true
    else
        return false
    end
end

--------------------------------------------------------------------------------

function HandleKey(event)
    if event == "key h none" then
        ShowHelp()
    
    elseif event == "key return none" then
        if endofgame then
            -- switch to next player's rack
            ChangeCurrentPlayer()
            DrawEverything()
        else
            MakeMove()
        end
    
    elseif event == "key z none" then
        UndoMove()
    
    elseif event == "key z shift" then
        RedoMove()
    
    elseif event == "key l none" then
        ShowLastMove()
    
    elseif event == "key space none" then
        SortTiles()
    
    elseif event == "key n none" then
        NewGame()
    
    elseif event == "key s none" then
        SaveGame()
    
    elseif event == "key o none" then
        OpenGame()
    
    elseif event == "key b none" then
        ToggleBlinking()
    
    elseif event == "key c none" then
        ToggleCompCards()
    
    elseif event == "key p none" then
        ToggleSounds()
    
    elseif event == "key f none" then
        ToggleFullScreen()
    
    elseif event == "key q none" then
        glu.exit()

    elseif event == "key 8 none" or event == "key 9 none" then
        if not (endofgame or player[currplayer].machine) then
            local _, ch, _ = split(event)
            HelpHuman(ORD(ch) - ORD('0'))
        end
    
    elseif event == "key [ none" then
        DecreaseVolume()
    
    elseif event == "key ] none" then
        IncreaseVolume()
    
    elseif event == "key delete none" and not (endofgame or player[currplayer].machine) then
        -- move the last card back to the rack
        local tilesadded, minr, maxr, minc, maxc = GetTilesAdded()
        if tilesadded == 1 then
            -- move single tile back to rack
            RestoreOneTile(maxr, maxc)
        elseif tilesadded >= 2 then
            -- move last tile added (probably) back to rack
            if minr == maxr then
                -- added tiles form horizontal line
                RestoreOneTile(minr, maxc)
            elseif minc == maxc then
                -- added tiles form vertical line
                RestoreOneTile(maxr, minc)
            else
                -- tiles not in straight line, so delete closest to bottom right
                for row = maxr, minr, -1 do
                    for col = maxc, minc, -1 do
                        if board[row][col].state == full then
                            RestoreOneTile(row, col)
                            return
                        end
                    end
                end
            end
        end
    else
        -- pass key event back to Glu (eg. cmd-Q)
        glu.doevent(event)
    end
end

--------------------------------------------------------------------------------

function HandleClick(event)
    -- Process a mouse click in canvas.
    
    local _, x, y, button, mods = split(event)
    x = tonumber(x)
    y = tonumber(y)
    
    -- check for click in rack
    if PtInRect(x, y, innerrack) then
        ClickInRack(x, y, button, mods)
        return
    end
    
    -- check for option-click in card in board
    if mods == "alt" and PtInRect(x, y, boardrect) then
        local r, c = GetBoardSquare(x, y)
        if board[r][c].state ~= empty then
            ShowMatchingCards(r, c)
        end
        return
    end
    
    -- check for click in stats indicator
    if PtInRect(x, y, statsrect) then
        ShowStats()
        return
    end
    
    -- check for click in bonus info arrow
    if PtInRect(x, y, bonusrect) then
        ShowBonusInfo()
        return
    end
    
    -- check for click in arrow at end of innerleft
    if PtInRect(x, y, innerleft) and (x > innerleft.right - arrowboxwd) then
        if tilesleft > 0 then
            ShowTiles()
        end
        return
    end
    
    -- check for click in player rect
    for p = 1, numplayers do
        if PtInRect(x, y, playerrect[p]) then
            if (x > playerrect[p].right - arrowboxwd) then
                -- click in pop-up region at end of player rect
                ShowHistory(p)
            elseif ChangePlayerName(p) then
                -- info for player[p] has been updated
                RestoreTiles()
                if endofgame then
                    Congratulations()           -- update message
                elseif currplayer == p then
                    YourMove()                  -- ditto
                end
                DrawEverything()
            end
            return
        end
    end
    
    if endofgame or player[currplayer].machine then
        return
    end
    
    if (numthrown > 0) and PtInRect(x, y, innerleft) then
        -- move last thrown tile back to rack
        RestoreThrownTiles(1)
        UpdateCurrentScore()
        DrawEverything()
    elseif PtInRect(x, y, boardrect) then
        local r, c = GetBoardSquare(x, y)
        if board[r][c].state == full then
            if mods == "ctrl" or button == "right" then
                -- return tile to rack
                RestoreOneTile(r, c)
            else
                DragBoardTile(r, c, x, y, mods)
            end
        end
    end
end

--------------------------------------------------------------------------------

function CreateClips()
    local wd = tilesize
    local ht = tilesize
    
    -- get empty tile
    cv.load("images/tile.png", tileclip)
    
    -- get cards
    cv.load("images/cards-big.png", "temp")
    cv.paste("temp", 0, 0)
    cv.delete("temp")
    local x, y = 0, 0
    for card = ORD(twoSpades), ORD(aceSpades) do
        cv.copy(x, y, wd, ht, CHR(card))
        x = x + wd
    end
    x, y = 0, y + ht
    for card = ORD(twoClubs), ORD(aceClubs) do
        cv.copy(x, y, wd, ht, CHR(card))
        x = x + wd
    end
    x, y = 0, y + ht
    for card = ORD(twoDiamonds), ORD(aceDiamonds) do
        cv.copy(x, y, wd, ht, CHR(card))
        x = x + wd
    end
    x, y = 0, y + ht
    for card = ORD(twoHearts), ORD(aceHearts) do
        cv.copy(x, y, wd, ht, CHR(card))
        x = x + wd
    end
    x, y = 0, y + ht
    cv.copy(x, y, wd, ht, joker)
    
    -- get small cards
    wd = smallsize
    ht = smallsize
    cv.load("images/cards-small.png", "temp")
    cv.paste("temp", 0, 0)
    cv.delete("temp")
    x, y = 0, 0
    for card = ORD(twoSpades), ORD(aceSpades) do
        cv.copy(x, y, wd, ht, "small-"..CHR(card))
        x = x + wd
    end
    x, y = 0, y + ht
    for card = ORD(twoClubs), ORD(aceClubs) do
        cv.copy(x, y, wd, ht, "small-"..CHR(card))
        x = x + wd
    end
    x, y = 0, y + ht
    for card = ORD(twoDiamonds), ORD(aceDiamonds) do
        cv.copy(x, y, wd, ht, "small-"..CHR(card))
        x = x + wd
    end
    x, y = 0, y + ht
    for card = ORD(twoHearts), ORD(aceHearts) do
        cv.copy(x, y, wd, ht, "small-"..CHR(card))
        x = x + wd
    end
    x, y = 0, y + ht
    cv.copy(x, y, wd, ht, "small-"..joker)
    
    -- get tiny suits
    cv.load("images/tiny-club.png", tinysuit[clubs])
    cv.load("images/tiny-diamond.png", tinysuit[diamonds])
    cv.load("images/tiny-heart.png", tinysuit[hearts])
    cv.load("images/tiny-spade.png", tinysuit[spades])
    
    -- get tiny blue suits
    cv.load("images/tiny-club-blue.png", bluesuit[clubs])
    cv.load("images/tiny-diamond-blue.png", bluesuit[diamonds])
    cv.load("images/tiny-heart-blue.png", bluesuit[hearts])
    cv.load("images/tiny-spade-blue.png", bluesuit[spades])
    
    -- get computer pictures
    cv.load("images/joker-normal.png", normalpict)
    cv.load("images/joker-happy.png", happypict)
    cv.load("images/joker-sad.png", sadpict)
end

--------------------------------------------------------------------------------

function CreateButtons()
    cp.buttonht = 30            -- height of buttons
    cp.textgap = 20             -- horizontal gap between edge of button and its label
    cp.radius = 15              -- curvature of button corners
    cp.border = 2               -- thickness of button border
    cp.borderrgba = cp.white    -- color for button border
    cp.textrgba = cp.white      -- color for button labels

    -- button labels are all uppercase so need to be lowered to look centered
    if glu.os() == "Linux" then
        cp.textfont = "default-bold"
        cp.textfontsize = 12
        cp.yoffset = 1
    else
        cp.yoffset = 1
    end

    helpbutton = cp.button("HELP", ShowHelp)
    sortbutton = cp.button("SORT", SortTiles)
    donebutton = cp.button("MOVE", MakeMove)
end

--------------------------------------------------------------------------------

function CreateRectangles()
    -- Initialize various rectangles.

    -- set boardrect near bottom and middled between notepad and right edge
    local boardhtwd = 15 * boardsquare
    boardrect.bottom = bgy + bght - 11
    boardrect.top = boardrect.bottom - boardhtwd + 1
    boardrect.right = bgx + bgwd - 45
    boardrect.left = boardrect.right - boardhtwd + 1

    -- set innerleft to inner part of cards remaining box
    innerleft.top = bgy + 32
    innerleft.left = bgx + 50
    innerleft.bottom = innerleft.top + 15
    innerleft.right = innerleft.left + 178

    -- set innerinfo to inner part of info box
    innerinfo.top = bgy + 93
    innerinfo.left = bgx + 57
    innerinfo.bottom = innerinfo.top + 101
    innerinfo.right = innerinfo.left + 164
    -- NOTE: dimensions of images/joker-*.png are 165 x 102
    
    -- set player rects inside notepad
    for p = 1, numplayers do
        playerrect[p].top = bgy + 233 + notevoff + notesep * (p - 1)
        playerrect[p].left = bgx + 44 + notehoff
        playerrect[p].bottom = playerrect[p].top + notesep - 2
        playerrect[p].right = playerrect[p].left + 174
    end
    
    -- set statsrect for showing statistics in pop-up window
    statsrect = Rect(playerrect[1])
    OffsetRect(statsrect, 0, notesep * 7)
    statsrect.left = statsrect.right - arrowboxwd - 1
    
    -- set bonusrect for showing bonus info in pop-up window
    bonusrect = Rect(statsrect)
    OffsetRect(bonusrect, 0, -notesep)
    
    -- set innerrack for drawing tiles within rack
    innerrack.top = bgy + 19
    innerrack.left = bgx + 407
    innerrack.bottom = innerrack.top + racksquare + 1
    innerrack.right = innerrack.left + maxslots * racksquare + 1
        
    -- put Help button under CrossCards logo
    helprect.top = boardrect.bottom - 75
    helprect.bottom = helprect.top + helpbutton.ht - 1
    helprect.left = bgx + (boardrect.left - bgx - helpbutton.wd) // 2
    helprect.right = helprect.left + helpbutton.wd - 1
        
    -- put Sort button to right of rack
    sortrect.top = innerrack.top + (racksquare - sortbutton.ht) // 2
    sortrect.bottom = sortrect.top + sortbutton.ht - 1
    sortrect.left = innerrack.right + 31
    sortrect.right = sortrect.left + sortbutton.wd - 1
    
    -- put Done/Move button to left of rack
    donerect.top = innerrack.top + (racksquare - donebutton.ht) // 2
    donerect.bottom = donerect.top + donebutton.ht - 1
    donerect.left = innerrack.left - 30 - donebutton.wd
    donerect.right = donerect.left + donebutton.wd - 1
end

--------------------------------------------------------------------------------

function CreateMenuBar()
    cp.textshadowx = 2
    cp.textshadowy = 2

    -- create the menu bar
    mbar = cp.menubar()
    
    -- set color of menu bar to match background theme
    SetMenuColor()
    
    -- add some menus
    mbar.addmenu("File")
    mbar.addmenu("Cards")
    mbar.addmenu("Options")
    
    -- add items to File menu
    mbar.additem(1, "New Game", NewGame)
    mbar.additem(1, "Open Game...", OpenGame)
    mbar.additem(1, "Save Game...", SaveGame)
    mbar.additem(1, "---", nil)
    mbar.additem(1, "Quit", glu.exit)
    
    -- add items to Cards menu
    mbar.additem(2, "Undo Move", UndoMove)
    mbar.additem(2, "Redo Move", RedoMove)
    mbar.additem(2, "---", nil)
    mbar.additem(2, "Return Cards to Rack", RestoreTiles)
    mbar.additem(2, "---", nil)
    mbar.additem(2, "Highest Scoring Move", HelpHuman, {8})
    mbar.additem(2, "Best Strategic Move", HelpHuman, {9})
    mbar.additem(2, "Show Last Move", ShowLastMove)

    -- add items to Options menu
    mbar.additem(3, "Aqua Theme", SetTheme, {"aqua"})
    mbar.additem(3, "Stone Theme", SetTheme, {"stone"})
    mbar.additem(3, "Wood Theme", SetTheme, {"wood"})
    mbar.additem(3, "---", nil)
    mbar.additem(3, "Play Sounds", ToggleSounds)
    mbar.additem(3, "Blink Computer's Moves", ToggleBlinking)
    mbar.additem(3, "Show Computer's Cards", ToggleCompCards)
    mbar.additem(3, "Full Screen", ToggleFullScreen)
    mbar.additem(3, "Auto Save", ToggleAutoSave)
end

--------------------------------------------------------------------------------

function DrawMenuBar()
    mbar.enableitem(2, 1, nummoves > 0)
    mbar.enableitem(2, 2, canredo > 0)
    mbar.enableitem(2, 4, not (endofgame or player[currplayer].machine))
    mbar.enableitem(2, 6, not (endofgame or player[currplayer].machine))
    mbar.enableitem(2, 7, not (endofgame or player[currplayer].machine))
    mbar.enableitem(2, 8, nummoves > 0)
    
    mbar.radioitem(3, 1, bgtheme == "aqua")
    mbar.radioitem(3, 2, bgtheme == "stone")
    mbar.radioitem(3, 3, bgtheme == "wood")
    
    mbar.tickitem(3, 5, playsounds)
    mbar.tickitem(3, 6, blinkcompmoves)
    mbar.tickitem(3, 7, showcompcards)
    mbar.tickitem(3, 8, fullscreen == 1)
    mbar.tickitem(3, 9, autosave)
    
    -- draw menu bar above background image
    mbar.show(bgx, bgy - mbarht, bgwd, mbarht)
end

--------------------------------------------------------------------------------

function CreateCanvas()
    -- use the size of the background image to create the canvas
    cv.create(1, 1)
    bgwd, bght = cv.load("images/bg-"..bgtheme..".png", bgclip)
    
    if fullscreen == 1 then
        viewwd, viewht = glu.getview()
        -- put background image and menu bar in middle of screen
        bgx = (viewwd - bgwd) // 2
        bgy = (viewht - (bght + mbarht)) // 2 + mbarht
    else
        -- set size of viewport to size of background image and menu bar
        glu.setview(bgwd, bght + mbarht)
        viewwd, viewht = bgwd, bght + mbarht
        -- set location of background image
        bgx = 0
        bgy = mbarht
    end
    
    -- resize canvas so it covers entire viewport
    cv.resize(viewwd, viewht)
    
    -- load images and store in clips
    CreateClips()
    
    if fullscreen == 1 then
        cv.rgba(cp.black)
        cv.fill()
    end
    
    -- draw the background image
    cv.paste(bgclip, bgx, bgy)
end

--------------------------------------------------------------------------------

function CheckViewSize()
    -- check if user has resized the viewport
    local newwd, newht = glu.getview()
    if viewwd ~= newwd or viewht ~= newht then
        viewwd, viewht = newwd, newht
        
        -- resize canvas so it covers entire viewport
        -- but is never smaller than background image plus menu bar
        if viewwd < bgwd or viewht < (bght + mbarht) then
            if viewwd < bgwd then viewwd = bgwd end
            if viewht < (bght + mbarht) then viewht = bght + mbarht end
            glu.setview(viewwd, viewht)
        end
        
        cv.resize(viewwd, viewht)
        
        -- user might have hit escape key to exit fullscreen mode
        fullscreen = glu.getoption("fullscreen")
        
        -- move background image to middle of viewport (if larger)
        bgx = (viewwd - bgwd) // 2
        bgy = (viewht - (bght + mbarht)) // 2 + mbarht
        
        -- update various rectangles that depend on bgx and bgy
        CreateRectangles()
        
        HideButtons()
        
        cv.rgba(cp.black)
        cv.fill()
        DrawEverything()
    end
end

--------------------------------------------------------------------------------

function OpenUnfinishedGame()
    if autosave then 
        -- if unfinishedgame exists then open it
        local f = io.open(unfinishedgame, "r")
        if f then
            f:close()
            OpenGame(unfinishedgame)
        end
    end
end

--------------------------------------------------------------------------------

function SaveUnfinishedGame()
    if autosave and nummoves > 0 and not endofgame then
        -- save unfinished game so we can reload it in next session
        WriteGame(unfinishedgame)
    else
        os.remove(unfinishedgame)
    end
end

--------------------------------------------------------------------------------

function EventLoop()
    -- tell Glu we can open .cc files via "file" events
    glu.filetypes(".cc")
    while true do
        local event = cp.process(glu.getevent())
        if #event > 0 then
            if event:find("^key") then
                HandleKey(event)
            elseif event:find("^cclick") then
                HandleClick(event)
            elseif event:find("^file") then
                OpenGame(event:sub(6))
            end
        else
            glu.sleep(5)
            CheckViewSize()
        end
    end
end

--------------------------------------------------------------------------------

function Main()
    CreateCanvas()
    InitTiles()
    InitBoard()
    CreateMenuBar()
    CreateButtons()
    CreateRectangles()
    NewGame()
    OpenUnfinishedGame()
    EventLoop()
end

--------------------------------------------------------------------------------

glu.settitle("CrossCards")
ReadSettings()

status, err = xpcall(Main, gp.trace)
if err then glu.continue(err) end
-- the following code is always executed

glu.check(false)
SaveUnfinishedGame()
WriteSettings()
cv.delete()
