diff --git a/Chess/Chess viewer/main.lua b/Chess/Chess viewer/main.lua new file mode 100644 index 0000000..7781325 --- /dev/null +++ b/Chess/Chess viewer/main.lua @@ -0,0 +1,492 @@ +-- Creates a chess diagram with arrows and a play button in order to show an entire game. + +-- Based on [[Module:Chessboard]], inspired by [[mw:Extension:Chessbrowser]] + +-- This might be cleaner if it worked directly on the PGN notation instead of the converted form. +-- A future todo might be to have some sort of changing caption that shows what the current move is. This might improve accessibility as it would give screen readers something to read. + + +local p = {} +local yesno = require('Module:Yesno') + +local cfg, nrows, ncols +cfg = {} + +-- From Module:Chessboard/chess +function cfg.dims() + return 8, 8 +end + +function cfg.letters() + return {'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h'} +end + +-- color from [[:c:Category:Chessboard480]] +-- emerald, olivine, pearl, ruby, sapphire, steel, tourmaline, wood +function cfg.image_board(size, color) + local colorstr = (color and color ~='' and '-' .. color) or '' + return string.format( '[[File:Chessboard480%s.svg|%dx%dpx|link=|class=notpageimage]]', colorstr, 8 * size, 8 * size ) +end + +function cfg.image_square( pc, row, col, size ) + local colornames = { l = 'white', d = 'black', u = 'unknown color' } + local piecenames = { + p = 'pawn', + r = 'rook', + n = 'knight', + b = 'bishop', + q = 'queen', + k = 'king', + a = 'archbishop', + c = 'chancellor', + z = 'champion', + w = 'wizard', + t = 'fool', + M = 'mann', + h = 'upside-down pawn', + m = 'upside-down rook', + B = 'upside-down bishop', + N = 'upside-down knight', + f = 'upside-down king', + g = 'upside-down queen', + e = 'elephant', + s = 'boat', + G = 'giraffe', + U = 'unicorn', + Z = 'zebra' + } + local symnames = { + xx = 'black cross', + ox = 'white cross', + xo = 'black circle', + oo = 'white circle', + ul = 'up-left arrow', + ua = 'up arrow', + ur = 'up-right arrow', + la = 'left arrow', + ra = 'right arrow', + dl = 'down-left arrow', + da = 'down arrow', + dr = 'down-right arrow', + lr = 'left-right arrow', + ud = 'up-down arrow', + db = 'up-right and down-left arrow', + dw = 'up-left and down-right arrow', + x0 = 'zero', + x1 = 'one', + x2 = 'two', + x3 = 'three', + x4 = 'four', + x5 = 'five', + x6 = 'six', + x7 = 'seven', + x8 = 'eight', + x9 = 'nine' + } + local colchar = {'a','b','c','d','e','f','g','h'} + local color = mw.ustring.gsub( pc, '^.*(%w)(%w).*$', '%2' ) or '' + local piece = mw.ustring.gsub( pc, '^.*(%w)(%w).*$', '%1' ) or '' + --local alt = colchar[col] .. row .. ' ' + local alt = '' + + if colornames[color] and piecenames[piece] then + alt = alt .. colornames[color] .. ' ' .. piecenames[piece] + else + alt = alt .. ( symnames[piece .. color] or piece .. ' ' .. color ) + end + + return string.format( '[[File:Chess %s%st45.svg|%dx%dpx|alt=%s|%s|link=|class=notpageimage|top]]', piece, color, size, size, alt, alt ) + +end + +-- End Module:Chessboard/chess +-- start Module:Chessboard + + +local function innerboard(definedPieces, pieceInfo, size, color, rev, ply) + pattern = cfg.pattern or '%w%w' + local root = mw.html.create('div') + root:addClass('chess-pieces notheme') + :css('position', 'relative') + :wikitext(cfg.image_board(size, color)) + + for piece in pairs( definedPieces ) do + if piece:match( pattern ) then + local img = cfg.image_square(piece:match(pattern), row, col, size ) + local curPos = pieceInfo[ply][piece] or {-1,-1} + --local class = piece:match( "^[nN]" ) and 'knight' or 'nonknight' + -- Consensus seems to be make knights move in a normal way instead of an L-Shape + local class = 'nonknight' + root:tag('div') + :css('top', 'calc(var(--calculator-top_piece' .. piece .. ',' .. curPos[1] .. ')*1px)' ) + :css('left', 'calc(var(--calculator-left_piece' .. piece .. ',' .. curPos[2] .. ')*1px)') + :css( 'opacity', 'calc(min(var(--calculator-top_piece' .. piece .. ',' .. curPos[1] .. '),0) + 1)' ) + :addClass( class ) + :addClass( 'calculator-field' ) + :addClass( curPos[1] == -1 and "calculator-value-false" or "calculator-value-true" ) + :attr( "data-calculator-type", "passthru" ) + :attr( "data-calculator-formula", "ifgreaterorequal(top_piece" .. piece .. ",0)" ) + :wikitext(img) + end + end + + return tostring(root) +end + +local function getPositions(args, size, rev) + pattern = cfg.pattern or '%w%w' + local pieceInfo = {} + local definedPieces = {} + + local oldLayout = {} + local oldPieceNames = {} + local newPieceNames = {} + for moveIndex, move in ipairs( args ) do +--mw.log( "Doing move number " .. moveIndex ) + pieceInfo[moveIndex] = {} + local layout = convertFenToArgs( move ) + local checkPieceLoc = function ( layout, row, trow, tcol, allowOverride, registerPiece, realPieceName ) + local col = rev and ( 1 + ncols - tcol ) or tcol + local piece = layout[ncols * ( nrows - row ) + col + 2] or '' + local pieceName = piece:match( pattern ) + if pieceName then + while true do + if realPieceName then + pieceName = realPieceName + end + -- If there is a promotion you might suddenly have multiple of the same piece. + if pieceInfo[moveIndex][pieceName] or (not(allowOverride) and pieceInfo[moveIndex][pieceName] == false) then + pieceName = pieceName .. '_' + else + break + end + end + if registerPiece then +--mw.log( "placing " .. pieceName ) + definedPieces[pieceName] = true + pieceInfo[moveIndex][pieceName] = { ( trow - 1 ) * size, ( tcol - 1 ) * size } + newPieceNames[ (( trow - 1 ) * size) .. (( tcol - 1 ) * size)] = pieceName + else +--mw.log( "Marking " .. pieceName .. " as used but so far skipped" ) + pieceInfo[moveIndex][pieceName] = false + end + end + end + local availablePieces = {} + for trow = 1,nrows do + local row = rev and trow or ( 1 + nrows - trow ) + for tcol = 1,ncols do + -- Hacky, but first do this for pieces that haven't moved, then for pieces that have. + + local col = rev and ( 1 + ncols - tcol ) or tcol + local samePiece = (layout[ncols * ( nrows - row ) + col + 2] or '') == (oldLayout[ncols * ( nrows - row ) + col + 2] or '') + local realPiece = oldPieceNames[ (( trow - 1 ) * size) .. (( tcol - 1 ) * size)] + local originalPiece = (oldLayout[ncols * ( nrows - row ) + col + 2] or ''):match(pattern) + if samePiece then + checkPieceLoc( oldLayout, row, trow, tcol, false, true, realPiece ) + --mw.log( "first round. doing " .. trow .. "x" .. tcol .. " for " .. (layout[ncols * ( nrows - row ) + col + 2] or '') ) + else + checkPieceLoc( oldLayout, row, trow, tcol, false, false, realPiece ) + if originalPiece and realPiece then + availablePieces[originalPiece] = realPiece --realPiece + end + --mw.log( "first round skip. doing " .. trow .. "x" .. tcol .. " for " .. (layout[ncols * ( nrows - row ) + col + 2] or '') ) + end + end + end + for trow = 1,nrows do + local row = rev and trow or ( 1 + nrows - trow ) + for tcol = 1,ncols do + -- Hacky, now do this for moved pieces + + local col = rev and ( 1 + ncols - tcol ) or tcol + local samePiece = (layout[ncols * ( nrows - row ) + col + 2] or '') == (oldLayout[ncols * ( nrows - row ) + col + 2] or '') + if not(samePiece) then + --mw.log( "Second round. doing " .. trow .. "x" .. tcol .. " for " .. (layout[ncols * ( nrows - row ) + col + 2] or '') ) + + local originalPiece = (layout[ncols * ( nrows - row ) + col + 2] or ''):match(pattern) + checkPieceLoc( layout, row, trow, tcol, true, true, availablePieces[originalPiece] ) + end + end + end + oldLayout = layout + oldPieceNames = newPieceNames + end + + return definedPieces, pieceInfo +end + + +function chessboard(definedPieces, pieceInfo, size, color, rev, letters, numbers, header, footer, align, clear, ply) + function letters_row( rev, num_lt, num_rt ) + local letters = cfg.letters() + local root = mw.html.create('') + if num_lt then + root:tag('td') + end + for k = 1,ncols do + root:tag('td') + :css('height', '18px') + :css('width', size .. 'px') + :addClass( 'boardlabel' ) + :wikitext(rev and letters[1+ncols-k] or letters[k]) + end + if num_rt then + root:tag('td') + end + return tostring(root) + end + + local letters_top = letters:match( 'both' ) or letters:match( 'top' ) + local letters_bottom = letters:match( 'both' ) or letters:match( 'bottom' ) + local numbers_left = numbers:match( 'both' ) or numbers:match( 'left' ) + local numbers_right = numbers:match( 'both' ) or numbers:match( 'right' ) + local width = ncols * size + 2 + if ( numbers_left ) then width = width + 18 end + if ( numbers_right ) then width = width + 18 end + + local root = mw.html.create('div') + :addClass('chessboard') + :addClass( 'calculator-container' ) + :addClass('thumb') + :addClass('noviewer') + :addClass(align) + if( header and header ~= '' ) then + root:tag('div') + :addClass('center') + :css('line-height', '130%') + :css('margin', '0 auto') + :css('max-width', (width + ncols) .. 'px') + :wikitext(header) + end + local div = root:tag('div') + :addClass('thumbinner') + :css('width', width .. 'px') + local b = div:tag('table') + :attr('cellpadding', '0') + :attr('cellspacing', '0') + :addClass( 'calculator-field' ) + :attr( 'data-calculator-type', 'passthru' ) + :attr( 'data-calculator-formula', 'rotate' ) + + if ( letters_top ) then + b:tag('tr') + :addClass( 'boardlabel' ) + :wikitext(letters_row( rev, numbers_left, numbers_right )) + end + local tablerow = b:tag('tr') + if ( numbers_left ) then + tablerow:tag('td') + :css('width', '18px') + :css('height', size .. 'px') + :addClass( 'boardlabel' ) + :wikitext(rev and 1 or nrows) + end + local td = tablerow:tag('td') + :attr('colspan', ncols) + :attr('rowspan', nrows) + :wikitext(innerboard(definedPieces, pieceInfo, size, color, rev, ply)) + + if ( numbers_right ) then + tablerow:tag('td') + :css('width', '18px') + :css('height', size .. 'px') + :addClass( 'boardlabel' ) + :wikitext(rev and 1 or nrows) + end + if ( numbers_left or numbers_right ) then + for trow = 2, nrows do + local idx = rev and trow or ( 1 + nrows - trow ) + tablerow = b:tag('tr') + if ( numbers_left ) then + tablerow:tag('td') + :css('height', size .. 'px') + :addClass( 'boardlabel' ) + :wikitext(idx) + end + if ( numbers_right ) then + tablerow:tag('td') + :css('height', size .. 'px') + :addClass( 'boardlabel' ) + :wikitext(idx) + end + end + end + if ( letters_bottom ) then + b:tag('tr') + :wikitext(letters_row( rev, numbers_left, numbers_right )) + end + + if footer and mw.text.trim(footer)~='' then + div:tag('div') + :addClass('thumbcaption') + :wikitext(footer) + end + + return tostring(root) .. + mw.getCurrentFrame():extensionTag( 'templatestyles', '', { src = 'Module:Chessboard/styles.css' } ) +end + +function convertFenToArgs( fen ) + -- converts FEN notation to 64 entry array of positions, offset by 2 + local res = { ' ', ' ' } + -- Loop over rows, which are delimited by / + for srow in string.gmatch( "/" .. fen, "/%w+" ) do + -- Loop over all letters and numbers in the row + for piece in srow:gmatch( "%w" ) do + if piece:match( "%d" ) then -- if a digit + for k=1,piece do + table.insert(res,' ') + end + else -- not a digit + local color = piece:match( '%u' ) and 'l' or 'd' + piece = piece:lower() + table.insert( res, piece .. color ) + end + end + end + + return res +end + +function convertArgsToFen( args, offset ) + function nullOrWhitespace( s ) return not s or s:match( '^%s*(.-)%s*$' ) == '' end + function piece( s ) + return nullOrWhitespace( s ) and 1 + or s:gsub( '%s*(%a)(%a)%s*', function( a, b ) return b == 'l' and a:upper() or a end ) + end + + local res = '' + offset = offset or 0 + for row = 1, 8 do + for file = 1, 8 do + res = res .. piece( args[8*(row - 1) + file + offset] ) + end + if row < 8 then res = res .. '/' end + end + return mw.ustring.gsub(res, '1+', function( s ) return #s end ) +end + +-- Based on Module:Pgn analyzePgn() +-- Currently this assumes the full game is recorded and the move numbers are in order +local function getCaptionTable(pgn) + pgn = string.gsub(pgn, '%[(.*)%]', '') + local moves = {} + local steps = mw.text.split(pgn, '%s*%d+%.%s*') + for _, step in ipairs(steps) do + if mw.ustring.len(mw.text.trim(step)) then + ssteps = mw.text.split(step, '%s+') + for _, sstep in ipairs(ssteps) do + if sstep and not mw.ustring.match(sstep, '^%s*$') then table.insert(moves, sstep) end + end + end + end + return moves +end + +local function makeMoveCaption(pgn, ply, initial, frame) + local moves = getCaptionTable(pgn) + -- \194\160 = nbsp. Want to make sure height is consistent. + local mapping = { ['\194\160'] = "default" } + local default = initial or '\194\160' + for i, mv in pairs( moves ) do + local text = '' + text = text .. math.floor((i+1)/2) + if (i+1)%2 == 0 then + text = text .. '. ' + else + text = text .. '… ' + end + text = text .. mv + mapping[text] = i+1 + if i+1 == ply and initial == nil then + default = text + end + end + local jsonMapping = mw.text.jsonEncode( mapping ) + -- Not sure what is best for screen readers here as these aren't words. This is probably sub-par + -- Probably best would be to convert to human "Rook to whatever" and put that off-screen + return frame:preprocess( '
{{calculator|style=speak-as: spell-out|type=plain|default=' .. default .. '|mapping=' .. jsonMapping .. '|formula=move}}
' ) +end + + + +local function getPieceMoves( definedPieces, pieceInfo ) + local ret = '' + for piece in pairs( definedPieces ) do + local curTop = -1 + local curLeft = -1 + local switchTop = 'switch(move,' + local switchLeft = 'switch(move,' + for moveNumber, move in ipairs( pieceInfo ) do + if (move[piece] and move[piece][1] or -1) ~= curTop then + switchTop = switchTop .. (moveNumber-1) .. ',' .. curTop .. ',' + curTop = (move[piece] and move[piece][1] or -1) + end + if move[piece] and move[piece][2] or -1 ~= curLeft then + switchLeft = switchLeft .. (moveNumber-1) .. ',' .. curLeft .. ',' + curLeft = (move[piece] and move[piece][2] or -1) + end + end + switchTop = switchTop .. curTop .. ')' + switchLeft = switchLeft .. curLeft .. ')' + ret = ret .. '{{calculator|type=hidden|formula=' .. switchTop .. '|id=top_piece' .. piece ..'}}' + ret = ret .. '{{calculator|type=hidden|formula=' .. switchLeft .. '|id=left_piece' .. piece .. '}}' + end + return ret +end + +function p.board(frame) + local args = frame.args + local pargs = frame:getParent().args + local style = args.style or pargs.style or 'Chess' + nrows, ncols = cfg.dims() + + local size = args.size or pargs.size or '26' + local color = args.color or pargs.color or nil + local reverse = ( args.reverse or pargs.reverse or '' ):lower() == "true" + local letters = ( args.letters or pargs.letters or 'both' ):lower() + local numbers = ( args.numbers or pargs.numbers or 'both' ):lower() + local header = args[2] or pargs[2] or '' + local footer = args[nrows*ncols + 3] or pargs[nrows*ncols + 3] or '' + local align = ( args[1] or pargs[1] or 'tright' ):lower() + local clear = args.clear or pargs.clear or ( align:match('tright') and 'right' ) or 'none' + local ply = tonumber(args.ply or pargs.ply or 1) + + local pgn = args.pgn or pargs.pgn + local moves, metadata + local definedPieces, pieceInfo + + size = mw.ustring.match( size, '[%d]+' ) or '26' -- remove px from size + + assert( pgn, "pgn argument required" ) + local pgnModule = require('Module:Pgn') + metadata, moves = pgnModule.main(pgn) + definedPieces, pieceInfo = getPositions( moves, size, reverse ) + ply = math.max( 1, math.min( ply, #moves ) ) + + align = args.align or pargs.align or 'tright' + clear = args.clear or pargs.clear or ( align:match('tright') and 'right' ) or 'none' + header = args.header or pargs.header or '' + footer = '' + if yesno(args.show_move or pargs.show_move) or false then + footer = makeMoveCaption(pgn, ply, args.initial_caption or pargs.initial_caption, frame) + end + footer = footer .. (args.footer or pargs.footer or '') + footer = footer .. frame:extensionTag( 'templatestyles', '', { src = 'Module:Chess_viewer/styles.css' } ) + if yesno(args.animate or pargs.animate or true) then + align = align .. ' animate' + end + header = header .. frame:preprocess( '' + ) + footer = footer .. frame:preprocess( getPieceMoves( definedPieces, pieceInfo ) ) + return chessboard( definedPieces, pieceInfo, size, color, reverse, letters, numbers, header, footer, align, clear, ply ) +end + +return p diff --git a/Chess/Chess viewer/styles.css b/Chess/Chess viewer/styles.css new file mode 100644 index 0000000..fdbfbab --- /dev/null +++ b/Chess/Chess viewer/styles.css @@ -0,0 +1,21 @@ +.chessboard.animate .nonknight.calculator-value-true { + transition: left 0.1s ease, top 0.1s ease, opacity 0.05s 0.1s ease-in +} +.chessboard.animate .knight.calculator-value-true { + transition: left 0.05s ease-in, top 0.05s 0.05s ease-out, opacity 0.05s 0.1s ease-in +} + +.chessboard.animate .knight, .chessboard.animate .nonknight { + transition: left 0s 0.2s, top 0s 0.2s, opacity 0.1s ease-out +} + +.chessboard.animate table { + transition: transform 0.2s +} + +.chessboard table.calculator-value-true { + transform: rotate(180deg) +} +.chessboard table.calculator-value-true td .chess-pieces img, .chessboard table.calculator-value-true td.boardlabel { + transform: rotate(180deg) +} diff --git a/Chess/Chessboard/chess.lua b/Chess/Chessboard/chess.lua new file mode 100644 index 0000000..21dc85d --- /dev/null +++ b/Chess/Chessboard/chess.lua @@ -0,0 +1,85 @@ +local p = {} + +function p.dims() + return 8, 8 +end + +function p.letters() + return {'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h'} +end + +function p.image_board(size) + return string.format( '[[File:Chessboard480.svg|%dx%dpx|link=|class=notpageimage]]', 8 * size, 8 * size ) +end + +function p.image_square( pc, row, col, size ) + local colornames = { l = 'white', d = 'black', u = 'unknown color' } + local piecenames = { + p = 'pawn', + r = 'rook', + n = 'knight', + b = 'bishop', + q = 'queen', + k = 'king', + a = 'archbishop', + c = 'chancellor', + z = 'champion', + w = 'wizard', + t = 'fool', + M = 'mann', + h = 'upside-down pawn', + m = 'upside-down rook', + B = 'upside-down bishop', + N = 'upside-down knight', + f = 'upside-down king', + g = 'upside-down queen', + e = 'elephant', + s = 'boat', + G = 'giraffe', + U = 'unicorn', + Z = 'zebra' + } + local symnames = { + xx = 'black cross', + ox = 'white cross', + xo = 'black circle', + oo = 'white circle', + ul = 'up-left arrow', + ua = 'up arrow', + ur = 'up-right arrow', + la = 'left arrow', + ra = 'right arrow', + dl = 'down-left arrow', + da = 'down arrow', + dr = 'down-right arrow', + lr = 'left-right arrow', + ud = 'up-down arrow', + db = 'up-right and down-left arrow', + dw = 'up-left and down-right arrow', + x0 = 'zero', + x1 = 'one', + x2 = 'two', + x3 = 'three', + x4 = 'four', + x5 = 'five', + x6 = 'six', + x7 = 'seven', + x8 = 'eight', + x9 = 'nine' + } + local colchar = {'a','b','c','d','e','f','g','h'} + local color = mw.ustring.gsub( pc, '^.*(%w)(%w).*$', '%2' ) or '' + local piece = mw.ustring.gsub( pc, '^.*(%w)(%w).*$', '%1' ) or '' + local alt = colchar[col] .. row .. ' ' + + if colornames[color] and piecenames[piece] then + alt = alt .. colornames[color] .. ' ' .. piecenames[piece] + else + alt = alt .. ( symnames[piece .. color] or piece .. ' ' .. color ) + end + + return string.format( '[[File:Chess %s%st45.svg|%dx%dpx|alt=%s|%s|link=|class=notpageimage|top]]', piece, color, size, size, alt, alt ) + +end + +return p diff --git a/Chess/Chessboard/main.lua b/Chess/Chessboard/main.lua new file mode 100644 index 0000000..32e73c8 --- /dev/null +++ b/Chess/Chessboard/main.lua @@ -0,0 +1,208 @@ +local p = {} + +local cfg, nrows, ncols + +local function innerboard(args, size, rev) + pattern = cfg.pattern or '%w%w' + local root = mw.html.create('div') + root:addClass('chess-pieces notheme') + :css('position', 'relative') + :wikitext(cfg.image_board(size)) + + for trow = 1,nrows do + local row = rev and trow or ( 1 + nrows - trow ) + for tcol = 1,ncols do + local col = rev and ( 1 + ncols - tcol ) or tcol + local piece = args[ncols * ( nrows - row ) + col + 2] or '' + if piece:match( pattern ) then + local img = cfg.image_square(piece:match(pattern), row, col, size ) + root:tag('div') + :css('top', tostring(( trow - 1 ) * size) .. 'px') + :css('left', tostring(( tcol - 1 ) * size) .. 'px') + :wikitext(img) + end + end + end + + return tostring(root) +end + +function chessboard(args, size, rev, letters, numbers, header, footer, align, clear) + function letters_row( rev, num_lt, num_rt ) + local letters = cfg.letters() + local root = mw.html.create('') + if num_lt then + root:tag('td') + end + for k = 1,ncols do + root:tag('td') + :css('height', '18px') + :css('width', size .. 'px') + :wikitext(rev and letters[1+ncols-k] or letters[k]) + end + if num_rt then + root:tag('td') + end + return tostring(root) + end + + local letters_top = letters:match( 'both' ) or letters:match( 'top' ) + local letters_bottom = letters:match( 'both' ) or letters:match( 'bottom' ) + local numbers_left = numbers:match( 'both' ) or numbers:match( 'left' ) + local numbers_right = numbers:match( 'both' ) or numbers:match( 'right' ) + local width = ncols * size + 2 + if ( numbers_left ) then width = width + 18 end + if ( numbers_right ) then width = width + 18 end + + local root = mw.html.create('div') + :addClass('chessboard') + :addClass('thumb') + :addClass('noviewer') + :addClass(align) + if( header and header ~= '' ) then + root:tag('div') + :addClass('center') + :css('line-height', '130%') + :css('margin', '0 auto') + :css('max-width', (width + ncols) .. 'px') + :wikitext(header) + end + local div = root:tag('div') + :addClass('thumbinner') + :css('width', width .. 'px') + local b = div:tag('table') + :attr('cellpadding', '0') + :attr('cellspacing', '0') + + if ( letters_top ) then + b:tag('tr') + :wikitext(letters_row( rev, numbers_left, numbers_right )) + end + local tablerow = b:tag('tr') + if ( numbers_left ) then + tablerow:tag('td') + :css('width', '18px') + :css('height', size .. 'px') + :wikitext(rev and 1 or nrows) + end + local td = tablerow:tag('td') + :attr('colspan', ncols) + :attr('rowspan', nrows) + :wikitext(innerboard(args, size, rev)) + + if ( numbers_right ) then + tablerow:tag('td') + :css('width', '18px') + :css('height', size .. 'px') + :wikitext(rev and 1 or nrows) + end + if ( numbers_left or numbers_right ) then + for trow = 2, nrows do + local idx = rev and trow or ( 1 + nrows - trow ) + tablerow = b:tag('tr') + if ( numbers_left ) then + tablerow:tag('td') + :css('height', size .. 'px') + :wikitext(idx) + end + if ( numbers_right ) then + tablerow:tag('td') + :css('height', size .. 'px') + :wikitext(idx) + end + end + end + if ( letters_bottom ) then + b:tag('tr') + :wikitext(letters_row( rev, numbers_left, numbers_right )) + end + + if footer and mw.text.trim(footer)~='' then + div:tag('div') + :addClass('thumbcaption') + :wikitext(footer) + end + + return tostring(root) .. + mw.getCurrentFrame():extensionTag( 'templatestyles', '', { src = 'Module:Chessboard/styles.css' } ) +end + +function convertFenToArgs( fen ) + -- converts FEN notation to 64 entry array of positions, offset by 2 + local res = { ' ', ' ' } + -- Loop over rows, which are delimited by / + for srow in string.gmatch( "/" .. fen, "/%w+" ) do + -- Loop over all letters and numbers in the row + for piece in srow:gmatch( "%w" ) do + if piece:match( "%d" ) then -- if a digit + for k=1,piece do + table.insert(res,' ') + end + else -- not a digit + local color = piece:match( '%u' ) and 'l' or 'd' + piece = piece:lower() + table.insert( res, piece .. color ) + end + end + end + + return res +end + +function convertArgsToFen( args, offset ) + function nullOrWhitespace( s ) return not s or s:match( '^%s*(.-)%s*$' ) == '' end + function piece( s ) + return nullOrWhitespace( s ) and 1 + or s:gsub( '%s*(%a)(%a)%s*', function( a, b ) return b == 'l' and a:upper() or a end ) + end + + local res = '' + offset = offset or 0 + for row = 1, 8 do + for file = 1, 8 do + res = res .. piece( args[8*(row - 1) + file + offset] ) + end + if row < 8 then res = res .. '/' end + end + return mw.ustring.gsub(res, '1+', function( s ) return #s end ) +end + +function p.board(frame) + local args = frame.args + local pargs = frame:getParent().args + local style = args.style or pargs.style or 'Chess' + cfg = require('Module:Chessboard/' .. style) + nrows, ncols = cfg.dims() + + local size = args.size or pargs.size or '26' + local reverse = ( args.reverse or pargs.reverse or '' ):lower() == "true" + local letters = ( args.letters or pargs.letters or 'both' ):lower() + local numbers = ( args.numbers or pargs.numbers or 'both' ):lower() + local header = args[2] or pargs[2] or '' + local footer = args[nrows*ncols + 3] or pargs[nrows*ncols + 3] or '' + local align = ( args[1] or pargs[1] or 'tright' ):lower() + local clear = args.clear or pargs.clear or ( align:match('tright') and 'right' ) or 'none' + local fen = args.fen or pargs.fen + local pgn = args.pgn or pargs.pgn + + size = mw.ustring.match( size, '[%d]+' ) or '26' -- remove px from size + if (pgn) then + local pgnModule = require('Module:Pgn') + metadata, moves = pgnModule.main(pgn) + fen = moves[#moves] + end + if (fen) then + align = args.align or pargs.align or 'tright' + clear = args.clear or pargs.clear or ( align:match('tright') and 'right' ) or 'none' + header = args.header or pargs.header or '' + footer = args.footer or pargs.footer or '' + return chessboard( convertFenToArgs( fen ), size, reverse, letters, numbers, header, footer, align, clear ) + end + if args[3] then + return chessboard(args, size, reverse, letters, numbers, header, footer, align, clear) + else + return chessboard(pargs, size, reverse, letters, numbers, header, footer, align, clear) + end +end + +return p diff --git a/Chess/Chessboard/mxn.lua b/Chess/Chessboard/mxn.lua new file mode 100644 index 0000000..710d622 --- /dev/null +++ b/Chess/Chessboard/mxn.lua @@ -0,0 +1,206 @@ +local p = {} + +function chessboard(args, size, rows, cols, rev, trans, lightdark, altprefix, letters, numbers, header, footer, align, clear) + function colchar( col ) + return (col <= 26) and ( "abcdefghijklmnopqrstuvwxyz" ):sub( col, col ) + or ( "abcdefghijklmnopqrstuvwxyz" ):sub( math.floor((col-1)/26), math.floor((col-1)/26) ) + .. ( "abcdefghijklmnopqrstuvwxyz" ):sub( col-math.floor((col-1)/26)*26, col-math.floor((col-1)/26)*26) + end + function image_square( pc, row, col, size, t, flip, altprefix ) + local colornames = { l = 'white', d = 'black', u = 'unknown color' } + local piecenames = { + p = 'pawn', + r = 'rook', + n = 'knight', + b = 'bishop', + q = 'queen', + k = 'king', + a = 'princess', + c = 'empress', + z = 'champion', + w = 'wizard', + t = 'fool', + h = 'upside-down pawn', + m = 'upside-down rook', + s = 'upside-down knight', + f = 'upside-down king', + e = 'upside-down bishop', + g = 'upside-down queen', + G = 'giraffe', + U = 'unicorn', + Z = 'zebra' + } + local symnames = { + xx = 'black cross', + ox = 'white cross', + xo = 'black circle', + oo = 'white circle', + ul = 'up-left arrow', + ua = 'up arrow', + ur = 'up-right arrow', + la = 'left arrow', + ra = 'right arrow', + dl = 'down-left arrow', + da = 'down arrow', + dr = 'down-right arrow', + lr = 'left-right arrow', + ud = 'up-down arrow', + db = 'up-right and down-left arrow', + dw = 'up-left and down-right arrow', + x0 = 'zero', + x1 = 'one', + x2 = 'two', + x3 = 'three', + x4 = 'four', + x5 = 'five', + x6 = 'six', + x7 = 'seven', + x8 = 'eight', + x9 = 'nine' + } + local color = mw.ustring.gsub( pc, '^.*(%w)(%w).*$', '%2' ) or '' + local piece = mw.ustring.gsub( pc, '^.*(%w)(%w).*$', '%1' ) or '' + local alt = altprefix .. colchar( col ) .. row .. ' ' + if ( colornames[color] and piecenames[piece] ) then + alt = alt .. colornames[color] .. ' ' .. piecenames[piece] + else + alt = alt .. ( symnames[piece .. color] or piece .. ' ' .. color ) + end + local ld = t and 't' or ((((row + col + flip) % 2) == 0) and 'd' or 'l') + + return string.format( '[[File:Chess %s%s%s45.svg|%dx%dpx|alt=%s|%s]]', piece, color, ld, size, size, alt, alt ) + end + + function letters_row( rev, num_lt, num_rt, cols ) + local res = '' .. ( num_lt and '' or '' ) .. '' + for k = 1, cols do + res = res .. colchar(rev and (cols - k + 1) or k) .. '' + end + res = res .. '' .. ( num_lt and '' or '' ) .. '' + return res + end + local letters_tp = letters:match('both') or letters:match('top') + local letters_bt = letters:match('both') or letters:match('bottom') + local numbers_lt = numbers:match('both') or numbers:match('left') + local numbers_rt = numbers:match('both') or numbers:match('right') + local width = cols * size + 2 + local flip = lightdark and 1 or 0 + if ( numbers_lt ) then width = width + 18 end + if ( numbers_rt ) then width = width + 18 end + + local b = '' + local caption = '' + + if ( letters_tp ) then b = b .. letters_row(rev, numbers_lt, numbers_rt, cols) .. '\n' end + for trow = 1,rows do + local row = rev and trow or (rows - trow + 1) + b = b .. '' + if ( numbers_lt ) then b = b .. '' .. row .. '' end + for tcol = 1,cols do + local col = rev and (cols - tcol + 1) or tcol + local idx = cols*(rows - row) + col + 2 + if (args[idx] == nil) then args[idx] = ' ' end + local img = image_square(args[idx]:match('%w%w') or '', row, col, size, trans, flip, altprefix ) + local bg = (((trow + tcol + flip) % 2) == 0) and '#ffce9e' or '#d18b47' + b = b .. '' .. img .. '' + end + if ( numbers_rt ) then b = b .. '' .. row .. '' end + b = b .. '' + end + if ( letters_bt ) then b = b .. letters_row(rev, numbers_lt, numbers_rt, cols) .. '\n' end + + if not footer:match('^%s*$') then + caption = '
' .. footer .. '
\n' + end + b = '\n' .. b .. '\n
' + + if noframe then + return b + else + return '
' + .. '
' .. header .. '
' .. '\n
\n' + .. b .. '\n' .. caption .. '
' + end + +end + +function convertFenToArgs( fen ) + -- converts FEN notation to an array of positions, offset by 2 + local res = {' ', ' '} + -- Loop over rows, which are delimited by / + for srow in string.gmatch("/" .. fen, "/%w+") do + srow = srow:gsub("/","") -- clean up row + -- Loop over all letters and numbers in the row + -- Since Lua regexes do not have the | operator, we have + -- to spell things out + local index = 1 + local piece = "" -- Piece can also be empty squares + local place = 0 + local pstart = 0 + local pend = 0 + local length = srow:len() + while index <= length do + -- Look for a number. Can have multiple digits + pstart, pend = srow:find("%d+", index) + if pstart == index then + piece = srow:sub(pstart, pend) + index = pend + 1 + for k=1,tonumber(piece) do + table.insert(res,' ') + end + else + -- If number not found, look for a letter (piece on board) + pstart = srow:find("%a",index) + if pstart == index then + piece = srow:sub(pstart, pstart) + index = pstart + 1 + -- l: White (light); d: Black (dark) + local color = piece:match( '%u' ) and 'l' or 'd' + piece = piece:lower() + table.insert(res, piece .. color) + else + index = length + 1 -- Break loop + end + end + end + end + return res +end + +function p.board(frame) + local args = frame.args + local pargs = frame:getParent().args + local size = (args.size or pargs.size) or '26' + local reverse = (args.reverse or pargs.reverse or '' ):lower() == "true" + local trans = (args.transparent or pargs.transparent or '' ):lower() == "true" + local lightdark = (args.lightdark or pargs.lightdark or '' ):lower() == "swap" + local altprefix = args.altprefix or pargs.altprefix or '' + local rows = args.rows or pargs.rows or 8 + local cols = args.cols or pargs.cols or 8 + local letters = ( args.letters or pargs.letters or 'both' ):lower() + local numbers = ( args.numbers or pargs.numbers or 'both' ):lower() + local header = mw.ustring.gsub( args[2] or pargs[2] or '', '^%s*(.-)%s*$', '%1' ) + local footer = args[3 + rows*cols] or pargs[3 + rows*cols] or '' + local align = ( args[1] or pargs[1] or 'tright' ):lower() + local clear = ( args.clear or pargs.clear ) or ( align:match('tright') and 'right' or 'none' ) + local noframe = (args.noframe or pargs.noframe or ''):lower() == "true" + local fen = args.fen or pargs.fen + + size = mw.ustring.match(size, '[%d]+') or '26' -- remove px from size + if (fen) then + align = ( args.align or pargs.align or 'tright' ):lower() + clear = ( args.clear or pargs.clear ) or ( align:match('tright') and 'right' or 'none' ) + header = args.header or pargs.header or '' + footer = args.footer or pargs.footer or '' + return chessboard(convertFenToArgs( fen ), size, rows, cols, reverse, trans, lightdark, altprefix, letters, numbers, header, footer, align, clear, noframe) + end + if args[3] then + return chessboard(args, size, rows, cols, reverse, trans, lightdark, altprefix, letters, numbers, header, footer, align, clear, noframe) + else + return chessboard(pargs, size, rows, cols, reverse, trans, lightdark, altprefix, letters, numbers, header, footer, align, clear, noframe) + end + +end + +return p diff --git a/Chess/Chessboard/styles.css b/Chess/Chessboard/styles.css new file mode 100644 index 0000000..23629be --- /dev/null +++ b/Chess/Chessboard/styles.css @@ -0,0 +1,26 @@ +.chessboard table { + font-size: 88%; + border: 1px #c8ccd1 solid; + padding: 0; + margin: auto; +} + +.chessboard table tr { + vertical-align: middle; +} + +.chessboard table td { + padding: 0; + vertical-align: inherit; + text-align: center; +} + +/* td containing the chess pieces should not center aligned, causes issues in Timeless */ +.chessboard table td[rowspan] { + text-align: unset; +} + +.chessboard .chess-pieces div:not(.pcs-image-wrapper) { + position: absolute; + z-index: 3; +} diff --git a/Chess/Pgn.lua b/Chess/Pgn.lua new file mode 100644 index 0000000..c423c83 --- /dev/null +++ b/Chess/Pgn.lua @@ -0,0 +1,468 @@ +--[[ +the purpose of this module is to provide pgn analysis local functionality +main local function, called pgn2fen: +input: either algebraic notation or full pgn of a single game +output: +* 1 table of positions (using FEN notation), one per each move of the game +* 1 lua table with pgn metadata (if present) + + +purpose: + using this local , we can create utility local functions to be used by templates. + the utility local function will work something like so: + it receives (in addition to the pgn, of course) list of moves and captions, and some wikicode in "nowiki" tag. + per each move, it will replace the token FEN with the fen of the move, and the token COMMENT with the comment (if any) of the move. + it will then parse the wikicode, return all the parser results concataneted. + others may fund other ways to use it. + +the logic: +the analysis part copies freely from the javascipt "pgn" program. + +main object: "board": 0-based table(one dimensional array) of 64 squares (0-63), + each square is either empty or contains the letter of the charToFile, e.g., "pP" is pawn. + +utility local functions +index to row/col +row/col to index +disambig(file, row): if file is number, return it, otherwise return rowtoindex(). +create(fen): returns ready board +generateFen(board) - selbverständlich + +pieceAt(coords): returns the piece at row/col +findPieces(piece): returns list of all squares containing specific piece ("black king", "white rook" etc). +roadIsClear(start/end row/column): start and end _must_ be on the same row, same column, or a diagonal. will error if not. + returns true if all the squares between start and end are clear. +canMove(source, dest, capture): boolean (capture is usually reduntant, except for en passant) +promote(coordinate, designation, color) +move(color, algebraic notation): finds out which piece should move, error if no piece or more than one piece found, + and execute the move. + +rawPgnAnalysis(input) +gets a pgn or algebraic notation, returns a table withthe metadata, and a second table with the algebraic notation individual moves + +main: +-- metadata, notations := rawPgnAnalysis(input) +-- result := empty table +-- startFen := metadata.fen || default; results += startFen +-- board := create(startFen) +-- loop through notations +----- pass board, color and notation, get modified board +----- results += generateFen() +-- return result + +the "meat" is the "canMove. however, as it turns out, it is not that difficult. +the only complexity is with pawns, both because they are asymmetrical, and irregular. brute force (as elegantly as possible) + +other pieces are a breeze. color does not matter. calc da := abs(delta raw), db := abs(delta column) +piece | rule +Knight: da * db - 2 = 0 +Rook: da * db = 0 +Bishop: da - db = 0 +King db | db = 1 (bitwise or) +Queen da * db * (da - db) = 0 + + +move: +find out which piece. find all of them on the board. ask each if it can execute the move, and count "yes". + there should be only one yes (some execptions to be handled). execute the move. + + + +]] + +local BLACK = "black" +local WHITE = "white" + +local PAWN = "P" +local ROOK = "R" +local KNIGHT = "N" +local BISHOP = "B" +local QUEEN = "Q" +local KING = "K" + +local KINGSIDE = 7 +local QUEENSIDE = 12 + +local DEFAULT_BOARD = 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR' + +local bit32 = bit32 or require('bit32') + +--[[ following lines require when running locally - uncomment. +mw = mw or { + ustring = string, + text = { + ['split'] = local function(s, pattern) + local res = {} + while true do + local start, finish = s:find(pattern) + if finish and finish > 1 then + local frag = s:sub(1, start - 1) + table.insert(res, frag) + s = s:sub(finish + 1) + else + break + end + end + if #s then table.insert(res, s) end + return res + end, + ['trim'] = local function(t) + t = type(t) == 'string' and t:gsub('^%s+', '') + t = t:gsub('%s+$', '') + return t + end + } +} +]] + +-- in lua 5.3, unpack is not a first class citizen anymore, but - assign table.unpack +local unpack = unpack or table.unpack + +local function apply(f, ...) + res = {} + targ = {...} + for ind = 1, #targ do + res[ind] = f(targ[ind]) + end + return unpack(res) +end + +local function empty(s) + return not s or mw.text.trim(s) == '' +end + +local function falseIfEmpty(s) + return not empty(s) and s +end + +local function charToFile(ch) + return falseIfEmpty(ch) and string.byte(ch) - string.byte('a') +end + +local function charToRow(ch) + return falseIfEmpty(ch) and tonumber(ch) - 1 +end + +local function indexToCoords(index) + return index % 8, math.floor(index / 8) +end + +local function coordsToIndex(file, row) + return row * 8 + file +end + +local function charToPiece(letter) + local piece = mw.ustring.upper(letter) + return piece, piece == letter and WHITE or BLACK +end + +local function pieceToChar(piece, color) + return color == WHITE and piece or mw.ustring.lower(piece) +end + +local function ambigToIndex(file, row) + if row == nil then return file end + return coordsToIndex(file, row) +end + +local function enPasantRow(color) + return color == WHITE and 5 or 2 +end + + + +local function sign(a) + return a < 0 and -1 + or a > 0 and 1 + or 0 +end + +local function pieceAt(board, fileOrInd, row) -- called with 2 params, fileOrInd is the index, otherwise it's the file. + local letter = board[ambigToIndex(fileOrInd, row)] + if not letter then return end + return charToPiece(letter) +end + +local function findPieces(board, piece, color) + local result = {} + local lookFor = pieceToChar(piece, color) + for index = 0, 63 do + local letter = board[index] + if letter == lookFor then table.insert(result, index) end + end + return result +end + +local function roadIsClear(board, ind1, ind2) + if ind1 == ind2 then error('call to roadIsClear with identical indices', ind1) end + local file1, row1 = indexToCoords(ind1) + local file2, row2 = indexToCoords(ind2) + if (file1 - file2) * (row1 - row2) * (math.abs(row1 - row2) - math.abs(file1 - file2)) ~= 0 then + error('sent two indices to roadIsClear which are not same row, col, or diagonal: ', ind1, ind2) + end + local hdelta = sign(file2 - file1) + local vdelta = sign(row2 - row1) + local row, file = row1 + vdelta, file1 + hdelta + while row ~= row2 or file ~= file2 do + if pieceAt(board, file, row) then return false end + row = row + vdelta + file = file + hdelta + end + return true +end + +local function pawnCanMove(board, color, startFile, startRow, file, row, capture) + local hor, ver = file - startFile, row - startRow + local absVer = math.abs(ver) + if capture then + local ok = hor * hor == 1 and ( + color == WHITE and ver == 1 or + color == BLACK and ver == - 1 + ) + + local enpassant = ok and + row == enPasantRow(color) and + pieceAt(board, file, row) == nil + return ok, enpassant + else + if hor ~= 0 then return false end + end + if absVer == 2 then + if not roadIsClear(board, coordsToIndex(startFile, startRow), coordsToIndex(file, row)) then return false end + return color == WHITE and startRow == 1 and ver == 2 or + color == BLACK and startRow == 6 and ver == -2 + end + return color == WHITE and ver == 1 or color == BLACK and ver == -1 +end + +local function canMove(board, start, dest, capture, verbose) + local startFile, startRow = indexToCoords(start) + local file, row = indexToCoords(dest) + local piece, color = pieceAt(board, startFile, startRow) + if piece == PAWN then return pawnCanMove(board, color, startFile, startRow, file, row, capture) end + local dx, dy = math.abs(startFile - file), math.abs(startRow - row) + return piece == KNIGHT and dx * dy == 2 + or piece == KING and bit32.bor(dx, dy) == 1 + or ( + piece == ROOK and dx * dy == 0 + or piece == BISHOP and dx == dy + or piece == QUEEN and dx * dy * (dx - dy) == 0 + ) and roadIsClear(board, start, dest, verbose) +end + +local function exposed(board, color) -- only test for queen, rook, bishop. + local king = findPieces(board, KING, color)[1] + for ind = 1, 63 do + local letter = board[ind] + if letter then + local _, pcolor = charToPiece(letter) + if pcolor ~= color and canMove(board, ind, king, true) then + return true + end + end + end +end + +local function clone(orig) + local res = {} + for k, v in pairs(orig) do res[k] = v end + return res +end + +local function place(board, piece, color, file, row) -- in case of chess960, we have to search + board[ambigToIndex(file, row)] = pieceToChar(piece, color) + return board +end + +local function clear(board, file, row) + board[ambigToIndex(file, row)] = nil + return board +end + +local function doCastle(board, color, side) + local row = color == WHITE and 0 or 7 + local startFile, step = 0, 1 + local kingDestFile, rookDestFile = 2, 3 + local king = findPieces(board, KING, color)[1] + local rook + if side == KINGSIDE then + startFile, step = 7, -1 + kingDestFile, rookDestFile = 6, 5 + end + for file = startFile, 7 - startFile, step do + local piece = pieceAt(board, file, row) + if piece == ROOK then + rook = coordsToIndex(file, row) + break + end + end + board = clear(board, king) + board = clear(board, rook) + board = place(board, KING, color, kingDestFile, row) + board = place(board, ROOK, color, rookDestFile, row) + return board +end + +local function doEnPassant(board, pawn, file, row) + local _, color = pieceAt(board, pawn) + board = clear(board, pawn) + board = place(board, PAWN, color, file, row) + if row == 5 then board = clear(board, file, 4) end + if row == 2 then board = clear(board, file, 3) end + return board +end + +local function generateFen(board) + local res = '' + local offset = 0 + for row = 7, 0, -1 do + for file = 0, 7 do + piece = board[coordsToIndex(file, row)] + res = res .. (piece or '1') + end + if row > 0 then res = res .. '/' end + end + return mw.ustring.gsub(res, '1+', function( s ) return #s end ) +end + +local function findCandidate(board, piece, color, oldFile, oldRow, file, row, capture, notation) + local enpassant = {} + local candidates, newCands = findPieces(board, piece, color), {} -- all black pawns or white kings etc. + if oldFile or oldRow then + local newCands = {} + for _, cand in ipairs(candidates) do + local file, row = indexToCoords(cand) + if file == oldFile then table.insert(newCands, cand) end + if row == oldRow then table.insert(newCands, cand) end + end + candidates, newCands = newCands, {} + end + local dest = coordsToIndex(file, row) + for _, candidate in ipairs(candidates) do + local can + can, enpassant[candidate] = canMove(board, candidate, dest, capture) + if can then table.insert(newCands, candidate) end + end + + candidates, newCands = newCands, {} + if #candidates == 1 then return candidates[1], enpassant[candidates[1]] end + if #candidates == 0 then + error('could not find a piece that can execute ' .. notation) + end + -- we have more than one candidate. this means that all but one of them can't really move, b/c it will expose the king + -- test for it by creating a new board with this candidate removed, and see if the king became exposed + for _, candidate in ipairs(candidates) do + local cloneBoard = clone(board) -- first, clone the board + cloneBoard = clear(cloneBoard, candidate) -- now, remove the piece + if not exposed(cloneBoard, color) then table.insert(newCands, candidate) end + end + candidates, newCands = newCands, {} + if #candidates == 1 then return candidates[1] end + error(mw.ustring.format('too many (%d, expected 1) pieces can execute %s at board %s', #candidates, notation, generateFen(board))) +end + +local function move(board, notation, color) + local endGame = {['1-0']=true, ['0-1']=true, ['1/2-1/2']=true, ['*']=true} + + local cleanNotation = mw.ustring.gsub(notation, '[!?+# ]', '') + + if cleanNotation == 'O-O' then + return doCastle(board, color, KINGSIDE) + end + if cleanNotation == 'O-O-O' then + return doCastle(board, color, QUEENSIDE) + end + if endGame[cleanNotation] then + return board, true + end + + local pattern = '([RNBKQ]?)([a-h]?)([1-8]?)(x?)([a-h])([1-8])(=?[RNBKQ]?)' + local _, _, piece, oldFile, oldRow, isCapture, file, row, promotion = mw.ustring.find(cleanNotation, pattern) + oldFile, file = apply(charToFile, oldFile, file) + oldRow, row = apply(charToRow, oldRow, row) + piece = falseIfEmpty(piece) or PAWN + promotion = falseIfEmpty(promotion) + isCapture = falseIfEmpty(isCapture) + local candidate, enpassant = findCandidate(board, piece, color, oldFile, oldRow, file, row, isCapture, notation) -- findCandidates should panic if # != 1 + if enpassant then + return doEnPassant(board, candidate, file, row) + end + board[coordsToIndex(file, row)] = promotion and pieceToChar(promotion:sub(-1), color) or board[candidate] + board = clear(board, candidate) + return board +end + +local function create( fen ) + -- converts FEN notation to 64 entry array of positions. copied from enwiki Module:Chessboard (in some distant past i prolly wrote it) + local res = {} + local row = 8 + -- Loop over rows, which are delimited by / + for srow in string.gmatch( "/" .. fen, "/%w+" ) do + srow = srow:sub(2) + row = row - 1 + local ind = row * 8 + -- Loop over all letters and numbers in the row + for piece in srow:gmatch( "%w" ) do + if piece:match( "%d" ) then -- if a digit + ind = ind + piece + else -- not a digit + res[ind] = piece + ind = ind + 1 + end + end + end + return res +end + +local function processMeta(grossMeta) + res = {} + -- process grossMEta here + for item in mw.ustring.gmatch(grossMeta or '', '%[([^%]]*)%]') do + key, val = item:match('([^"]+)"([^"]*)"') + if key and val then + res[mw.text.trim(key)] = mw.text.trim(val) -- add mw.text.trim() + else + error('strange item detected: ' .. item .. #items) -- error later + end + end + return res +end + +local function analyzePgn(pgn) + local grossMeta = pgn:match('%[(.*)%]') -- first open to to last bracket + pgn = string.gsub(pgn, '%[(.*)%]', '') + local steps = mw.text.split(pgn, '%s*%d+%.%s*') + local moves = {} + for _, step in ipairs(steps) do + if mw.ustring.len(mw.text.trim(step)) then + ssteps = mw.text.split(step, '%s+') + for _, sstep in ipairs(ssteps) do + if sstep and not mw.ustring.match(sstep, '^%s*$') then table.insert(moves, sstep) end + end + end + end + return processMeta(grossMeta), moves +end + +local function pgn2fen(pgn) + local metadata, notationList = analyzePgn(pgn) + local fen = metadata.fen or DEFAULT_BOARD + local board = create(fen) + local res = {fen} + local colors = {BLACK, WHITE} + for step, notation in ipairs(notationList) do + local color = colors[step % 2 + 1] + board = move(board, notation, color) + local fen = generateFen(board) + table.insert(res, fen) + end + return res, metadata +end + +return { + pgn2fen = pgn2fen, + main = function(pgn) + local res, metadata = pgn2fen(pgn) + return metadata, res + end, +}