Module:Peg solitaire sequence

-- Module:Peg solitaire sequence
--
-- Renders multi-frame peg-solitaire galleries from either:
--   (1) a compact move list, or
--   (2) explicit board1=..., board2=..., ... parameters.
--
-- Depends on Template:Peg solitaire diagram already existing.

local p = {}

local function trim(s)
	if s == nil then
		return nil
	end
	s = mw.text.trim(tostring(s))
	if s == '' then
		return nil
	end
	return s
end

local function yesno(v, default)
	v = trim(v)
	if v == nil then
		return default
	end
	v = mw.ustring.lower(v)
	if v == '1' or v == 'yes' or v == 'y' or v == 'true' then
		return true
	elseif v == '0' or v == 'no' or v == 'n' or v == 'false' then
		return false
	end
	return default
end

local function mergedArgs(frame)
	local args = {}
	if frame:getParent() then
		for k, v in pairs(frame:getParent().args) do
			if trim(v) ~= nil then
				args[k] = v
			end
		end
	end
	for k, v in pairs(frame.args) do
		if trim(v) ~= nil then
			args[k] = v
		end
	end
	return args
end

local PRESETS = {
	english = table.concat({
		'--...--',
		'--...--',
		'.......',
		'...o...',
		'.......',
		'--...--',
		'--...--',
	}, '/'),

	english_full = table.concat({
		'--...--',
		'--...--',
		'.......',
		'.......',
		'.......',
		'--...--',
		'--...--',
	}, '/'),

	european = table.concat({
		'--...--',
		'-.....-',
		'.......',
		'...o...',
		'.......',
		'-.....-',
		'--...--',
	}, '/'),

	european_full = table.concat({
		'--...--',
		'-.....-',
		'.......',
		'.......',
		'.......',
		'-.....-',
		'--...--',
	}, '/'),

    triangle15 = table.concat({
      '----.----',
      '---..---',
      '--...--',
      '-..o.-',
      '.....',
    }, '/'),

    triangle15_full = table.concat({
      '----.----',
      '---..---',
      '--...--',
      '-....-',
      '.....',
    }, '/'),
}

local function buildEnglishMap()
	local m = {}
	local rows = {
		{ nil, nil, 'a', 'b', 'c', nil, nil },
		{ nil, nil, 'd', 'e', 'f', nil, nil },
		{ 'g', 'h', 'i', 'j', 'k', 'l', 'm' },
		{ 'n', 'o', 'p', 'x', 'P', 'O', 'N' },
		{ 'M', 'L', 'K', 'J', 'I', 'H', 'G' },
		{ nil, nil, 'F', 'E', 'D', nil, nil },
		{ nil, nil, 'C', 'B', 'A', nil, nil },
	}
	for r = 1, #rows do
		for c = 1, #rows[r] do
			local v = rows[r][c]
			if v then
				m[v] = { r = r, c = c }
			end
		end
	end
	return m
end

local function buildEuropeanMap()
	local m = {}
	local rows = {
		{ nil, nil, 'a', 'b', 'c', nil, nil },
		{ nil, 'y', 'd', 'e', 'f', 'z', nil },
		{ 'g', 'h', 'i', 'j', 'k', 'l', 'm' },
		{ 'n', 'o', 'p', 'x', 'P', 'O', 'N' },
		{ 'M', 'L', 'K', 'J', 'I', 'H', 'G' },
		{ nil, 'Z', 'F', 'E', 'D', 'Y', nil },
		{ nil, nil, 'C', 'B', 'A', nil, nil },
	}
	for r = 1, #rows do
		for c = 1, #rows[r] do
			local v = rows[r][c]
			if v then
				m[v] = { r = r, c = c }
			end
		end
	end
	return m
end

local function buildTriangle15NumberMap()
	local m = {}
	local n = 1

	-- These coordinates must match the actual parsed columns of:
	--   ----.----
	--   ---..---
	--   --...--
	--   -....-
	--   .....
	--
	-- So the playable cells are:
	-- row 1: col 5
	-- row 2: col 4,5
	-- row 3: col 3,4,5
	-- row 4: col 2,3,4,5
	-- row 5: col 1,2,3,4,5

	for r = 1, 5 do
		local startCol = 6 - r
		for i = 0, r - 1 do
			m[tostring(n)] = { r = r, c = startCol + i }
			n = n + 1
		end
	end

	return m
end

local NAMED_MAPS = {
	english = buildEnglishMap(),
	english_full = buildEnglishMap(),
	european = buildEuropeanMap(),
	european_full = buildEuropeanMap(),
	triangle15 = buildTriangle15NumberMap(),
	triangle15_full = buildTriangle15NumberMap(),
}

local function splitRows(raw)
	raw = raw:gsub('\r\n', '\n'):gsub('\r', '\n')
	local rows = {}
	if raw:find('/') then
		for row in mw.text.gsplit(raw, '/', true) do
			table.insert(rows, row)
		end
	else
		for row in mw.text.gsplit(raw, '\n', false) do
			if trim(row) ~= nil then
				table.insert(rows, row)
			end
		end
	end
	return rows
end

local function parseBoard(raw)
	local rows = splitRows(raw)
	local cells = {}
	local maxCols = 0

	for r, row in ipairs(rows) do
		cells[r] = {}
		for ch in mw.ustring.gmatch(row, '.') do
			if ch ~= ' ' and ch ~= '\t' and ch ~= '|' and ch ~= ',' then
				if ch == '.' or ch == '·' or ch == '1' then
					table.insert(cells[r], 'peg')
				elseif ch == 'o' or ch == '0' then
					table.insert(cells[r], 'hole')
				else
					table.insert(cells[r], 'void')
				end
			end
		end
		if #cells[r] > maxCols then
			maxCols = #cells[r]
		end
	end

	for r = 1, #cells do
		while #cells[r] < maxCols do
			table.insert(cells[r], 'void')
		end
	end

	return {
		rows = #cells,
		cols = maxCols,
		cells = cells,
	}
end

local function cloneBoard(board)
	local out = {
		rows = board.rows,
		cols = board.cols,
		cells = {},
	}
	for r = 1, board.rows do
		out.cells[r] = {}
		for c = 1, board.cols do
			out.cells[r][c] = board.cells[r][c]
		end
	end
	return out
end

local function boardToRaw(board)
	local rows = {}
	for r = 1, board.rows do
		local t = {}
		for c = 1, board.cols do
			local s = board.cells[r][c]
			if s == 'peg' then
				table.insert(t, '.')
			elseif s == 'hole' then
				table.insert(t, 'o')
			else
				table.insert(t, '-')
			end
		end
		table.insert(rows, table.concat(t))
	end
	return table.concat(rows, '/')
end

local function coord0(r, c)
	return tostring(r - 1) .. ':' .. tostring(c - 1)
end

local function splitList(s)
	local out = {}
	s = trim(s)
	if not s then
		return out
	end
	s = s:gsub('\n', ';')
	for item in mw.text.gsplit(s, ';', true) do
		item = trim(item)
		if item then
			table.insert(out, item)
		end
	end
	return out
end

local function applyStateList(board, list, state, namedMap)
	for _, token in ipairs(splitList(list)) do
		local r, c
		local named = namedMap[token]
		if named then
			r, c = named.r, named.c
		else
			local r0, c0 = token:match('^(%-?%d+)%s*:%s*(%-?%d+)$')
			if r0 and c0 then
				r, c = tonumber(r0) + 1, tonumber(c0) + 1
			end
		end
		if r and c and board.cells[r] and board.cells[r][c] and board.cells[r][c] ~= 'void' then
			board.cells[r][c] = state
		end
	end
end

local function parseOneCoord(token, namedMap)
	token = trim(token)
	if not token then
		return nil, nil
	end

	local named = namedMap[token]
	if named then
		return named.r, named.c
	end

	local r0, c0 = token:match('^(%-?%d+)%s*:%s*(%-?%d+)$')
	if r0 and c0 then
		return tonumber(r0) + 1, tonumber(c0) + 1
	end

	local r1, c1 = token:match('^[Rr](%d+)[Cc](%d+)$')
	if r1 and c1 then
		return tonumber(r1), tonumber(c1)
	end

	local letters, digits = token:match('^([A-Za-z]+)(%d+)$')
	if letters and digits then
		local col = 0
		letters = mw.ustring.upper(letters)
		for ch in mw.ustring.gmatch(letters, '.') do
			col = col * 26 + (mw.ustring.byte(ch) - 64)
		end
		return tonumber(digits), col
	end

	local r2, c2 = token:match('^(%d+)%s*,%s*(%d+)$')
	if r2 and c2 then
		return tonumber(r2), tonumber(c2)
	end

	return nil, nil
end

local function getGeometry(preset)
	if preset and preset:match('^triangle15') then
		return 'triangle'
	end
	return 'orthogonal'
end

local function midpointOrthogonal(r1, c1, r2, c2)
	if r1 == r2 and math.abs(c2 - c1) == 2 then
		return r1, (c1 + c2) / 2
	end
	if c1 == c2 and math.abs(r2 - r1) == 2 then
		return (r1 + r2) / 2, c1
	end
	return nil, nil
end

local function midpointTriangle(r1, c1, r2, c2)
	local dr = r2 - r1
	local dc = c2 - c1

	-- horizontal
	if dr == 0 and math.abs(dc) == 2 then
		return r1, (c1 + c2) / 2
	end

	-- down-right / up-left in rendered coordinates
	if dr == 2 and dc == 0 then
		return r1 + 1, c1
	end
	if dr == -2 and dc == 0 then
		return r1 - 1, c1
	end

	-- down-left / up-right in rendered coordinates
	if dr == 2 and dc == -2 then
		return r1 + 1, c1 - 1
	end
	if dr == -2 and dc == 2 then
		return r1 - 1, c1 + 1
	end

	return nil, nil
end

local function midpointFor(geometry, r1, c1, r2, c2)
	if geometry == 'triangle' then
		return midpointTriangle(r1, c1, r2, c2)
	end
	return midpointOrthogonal(r1, c1, r2, c2)
end

local function parseMoveChain(token, namedMap)
	local out = {}
	for part in mw.text.gsplit(token, '-', true) do
		part = trim(part)
		if part then
			local r, c = parseOneCoord(part, namedMap)
			if not r or not c then
				error('Unrecognized coordinate in move token: ' .. token)
			end
			table.insert(out, { r = r, c = c, text = part })
		end
	end
	if #out < 2 then
		error('Move token must contain at least two positions: ' .. token)
	end
	return out
end

local function removeCoordFromList(list, coord)
	local out = {}
	for _, v in ipairs(list) do
		if v ~= coord then
			table.insert(out, v)
		end
	end
	return out
end

local function applyMoveChain(board, chain, geometry)
	local out = cloneBoard(board)
	local marks = {
		from = {},
		over = {},
		to = nil,
	}

	for i = 1, #chain - 1 do
		local s = chain[i]
		local d = chain[i + 1]

		local mr, mc = midpointFor(geometry, s.r, s.c, d.r, d.c)
		if not mr or not mc then
			error(string.format('Illegal jump geometry from %s to %s', s.text, d.text))
		end

		if not out.cells[s.r] or not out.cells[s.r][s.c] or out.cells[s.r][s.c] ~= 'peg' then
			error('Source is not a peg at ' .. s.text)
		end
		if not out.cells[mr] or not out.cells[mr][mc] or out.cells[mr][mc] ~= 'peg' then
			error('Jumped cell is not a peg between ' .. s.text .. ' and ' .. d.text)
		end
		if not out.cells[d.r] or not out.cells[d.r][d.c] or out.cells[d.r][d.c] ~= 'hole' then
			error('Destination is not a hole at ' .. d.text)
		end

		out.cells[s.r][s.c] = 'hole'
		out.cells[mr][mc] = 'hole'
		out.cells[d.r][d.c] = 'peg'

		table.insert(marks.from, coord0(s.r, s.c))
		table.insert(marks.over, coord0(mr, mc))
		marks.to = coord0(d.r, d.c)
	end
	
	marks.from = removeCoordFromList(marks.from, marks.to)
	marks.over = removeCoordFromList(marks.over, marks.to)

	return out, marks
end

local function expandDiagram(frame, args)
	return frame:expandTemplate{
		title = 'Peg solitaire diagram',
		args = args
	}
end

local function parseChunkedMoves(raw)
	local chunks = {}
	raw = raw:gsub('\r\n', '\n'):gsub('\r', '\n')

	for chunkText in mw.text.gsplit(raw, '/', true) do
		local chunk = {}
		for token in chunkText:gmatch('[^,%s]+') do
			token = trim(token)
			if token then
				table.insert(chunk, token)
			end
		end
		if #chunk > 0 then
			table.insert(chunks, chunk)
		end
	end

	return chunks
end

local function renderMoveMode(frame, args)
	local preset = trim(args.preset) or 'english'
	local namedMap = NAMED_MAPS[preset] or {}
	local geometry = getGeometry(preset)

	local rawBoard = trim(args.board) or PRESETS[preset]
	if not rawBoard then
		error('Unknown preset: ' .. tostring(preset))
	end

	local board = parseBoard(rawBoard)

	applyStateList(board, args.vacancy or args.empty, 'hole', namedMap)
	applyStateList(board, args.fill, 'peg', namedMap)

	local chunks = parseChunkedMoves(assert(trim(args.moves), 'Missing moves= parameter'))
	local root = mw.html.create('div')
		:addClass('pegsol-seq-root')

	local includeStart = yesno(args.include_start, true)
	local startLabel = trim(args.start_label)
	local showLabels = yesno(args.show_move_labels, true)

	local firstChunk = true
	for _, chunk in ipairs(chunks) do
		local row = root:tag('div')
			:addClass('pegsol-seq-row')

		if firstChunk and includeStart then
			row:wikitext(expandDiagram(frame, {
				board = boardToRaw(board),
				label = startLabel,
				cell = trim(args.cell),
				gap = trim(args.gap),
				layout = geometry == 'triangle' and 'triangle' or nil,
			}))
		end

		for _, token in ipairs(chunk) do
			local chain = parseMoveChain(token, namedMap)
			local nextBoard, marks = applyMoveChain(board, chain, geometry)

			row:wikitext(expandDiagram(frame, {
				board = boardToRaw(nextBoard),
				label = showLabels and token or nil,
				from = table.concat(marks.from, ';'),
				over = table.concat(marks.over, ';'),
				to = marks.to,
				cell = trim(args.cell),
				gap = trim(args.gap),
				layout = geometry == 'triangle' and 'triangle' or nil,
			}))

			board = nextBoard
		end

		firstChunk = false
	end

	if trim(args.caption) and not yesno(args._suppresscaption, false) then
		root:tag('div')
			:addClass('pegsol-seq-caption')
			:wikitext(args.caption)
	end

	return tostring(root)
end

local function parseBreakSet(s)
	local out = {}
	s = trim(s)
	if not s then
		return out
	end
	for token in s:gmatch('[^,%s;]+') do
		local n = tonumber(token)
		if n then
			out[n] = true
		end
	end
	return out
end

local function renderExplicitMode(frame, args)
	local root = mw.html.create('div')
		:addClass('pegsol-seq-root')

	local breaks = parseBreakSet(args.breaks)
	local row = root:tag('div')
		:addClass('pegsol-seq-row')

	local i = 1
	while true do
		local boardArg = trim(args['board' .. tostring(i)])
		if not boardArg then
			break
		end

		if i > 1 and breaks[i - 1] then
			row = root:tag('div')
				:addClass('pegsol-seq-row')
		end

		row:wikitext(expandDiagram(frame, {
			board = boardArg,
			label = trim(args['label' .. tostring(i)]),
			from = trim(args['from' .. tostring(i)]),
			over = trim(args['over' .. tostring(i)]),
			to = trim(args['to' .. tostring(i)]),
			cell = trim(args.cell),
			gap = trim(args.gap),
			layout = trim(args['layout' .. tostring(i)]) or trim(args.layout),
		}))

		i = i + 1
	end

	if trim(args.caption) and not yesno(args._suppresscaption, false) then
		root:tag('div')
			:addClass('pegsol-seq-caption')
			:wikitext(args.caption)
	end

	return tostring(root)
end

function p.main(frame)
	local ok, result = pcall(function()
		local args = mergedArgs(frame)
		if trim(args.moves) then
			return renderMoveMode(frame, args)
		end
		if trim(args.board1) then
			return renderExplicitMode(frame, args)
		end
		error('Supply either moves=... or board1=..., board2=..., ...')
	end)

	if ok then
		return result
	end

	return tostring(
		mw.html.create('strong')
			:addClass('error')
			:wikitext('Peg solitaire sequence error: ' .. tostring(result))
	)
end

return p

Content Disclaimer

Informasi ini disarikan dari Wikipedia dan disajikan kembali untuk tujuan edukasi. Konten tersedia di bawah lisensi CC BY-SA 3.0. Kami tidak bertanggung jawab atas ketidakakuratan data yang bersumber dari kontribusi publik tersebut.

  1. The information displayed on this website is sourced in part or in whole from Wikipedia and has been adapted for the purpose of restating it. We strive to provide accurate and relevant information, however:
  2. There is no guarantee of absolute accuracy. Wikipedia is an open, collaborative project that can be edited by anyone, so information is subject to change.
  3. It is not intended to constitute professional advice. The content displayed is for informational and educational purposes only. For important decisions (e.g., medical, legal, or financial), please consult a professional.
  4. Content copyright. Wikipedia is licensed under the Creative Commons Attribution-ShareAlike License (CC BY-SA). This means that content may be reused with appropriate attribution and shared under a similar license.
  5. Responsible use. Any risk arising from the use of information from this website is entirely the responsibility of the user.