diff --git a/src/lib/chess.js b/src/lib/chess.js new file mode 100644 index 0000000..40f3edc --- /dev/null +++ b/src/lib/chess.js @@ -0,0 +1,344 @@ +export const PIECES = { + wK: '/icons/wK.svg', wQ: '/icons/wQ.svg', wR: '/icons/wR.svg', wB: '/icons/wB.svg', wN: '/icons/wN.svg', wP: '/icons/wP.svg', + bK: '/icons/bK.svg', bQ: '/icons/bQ.svg', bR: '/icons/bR.svg', bB: '/icons/bB.svg', bN: '/icons/bN.svg', bP: '/icons/bP.svg' +}; + +export const INIT = [ + ['bR','bN','bB','bQ','bK','bB','bN','bR'], + ['bP','bP','bP','bP','bP','bP','bP','bP'], + [null,null,null,null,null,null,null,null], + [null,null,null,null,null,null,null,null], + [null,null,null,null,null,null,null,null], + [null,null,null,null,null,null,null,null], + ['wP','wP','wP','wP','wP','wP','wP','wP'], + ['wR','wN','wB','wQ','wK','wB','wN','wR'], +]; + +export function deepClone(b) { + return b.map(r => [...r]); +} + +export function createInitialPieceIds() { + let ids = []; + let count = 0; + for (let r = 0; r < 8; r++) { + let row = []; + for (let c = 0; c < 8; c++) { + if (INIT[r][c]) { + row.push(`piece-${INIT[r][c]}-${count++}`); + } else { + row.push(null); + } + } + ids.push(row); + } + return ids; +} + +export function buildTree(moves) { + let root = { move: null, children: [], note: null }; + let cur = root; + for (let m of moves) { + let node = { move: m.san, children: [], note: m.note || null }; + cur.children.push(node); + cur = node; + } + return root; +} + +export function walkTree(tree, moves) { + let cur = tree; + for (let m of moves) { + let child = cur.children.find(c => c.move === m); + if (!child) return null; + cur = child; + } + return cur; +} + +export function getNoteForHistory(historyPrefix, customNotes, currentRep, currentRepertoireObj) { + let line = historyPrefix.join(' '); + + if (customNotes[currentRep] && customNotes[currentRep][line] !== undefined) { + return customNotes[currentRep][line]; + } + for (let op of currentRepertoireObj.openings) { + let opMoves = op.pgn.split(' ').filter(Boolean); + if (opMoves.slice(0, historyPrefix.length).join(' ') === line) { + let node = walkTree(op.tree, historyPrefix); + if (node && node.note) { + return node.note; + } + } + } + return null; +} + +export function buildTreePreservingNotes(historyMoves, oldTree, customNotes, currentRep, currentRepertoireObj) { + let root = { move: null, children: [], note: null }; + let cur = root; + + for (let i = 0; i < historyMoves.length; i++) { + let m = historyMoves[i]; + let prefix = historyMoves.slice(0, i + 1); + + let existingNote = null; + if (oldTree) { + let node = walkTree(oldTree, prefix); + if (node && node.note) { + existingNote = node.note; + } + } + + if (!existingNote) { + existingNote = getNoteForHistory(prefix, customNotes, currentRep, currentRepertoireObj); + } + + let node = { move: m, children: [], note: existingNote }; + cur.children.push(node); + cur = node; + } + return root; +} + +export function getBoardSignature(b, t) { + let pieces = []; + for (let r = 0; r < 8; r++) { + for (let c = 0; c < 8; c++) { + pieces.push(b[r][c] || '.'); + } + } + return pieces.join(',') + ' ' + t; +} + +export function getMoves(board, r, c) { + let p = board[r][c]; + if (!p) return []; + let color = p[0], type = p[1]; + let moves = []; + function inBounds(r,c){return r>=0&&r<8&&c>=0&&c<8;} + function friendly(r,c){return inBounds(r,c)&&board[r][c]&&board[r][c][0]===color;} + function enemy(r,c){return inBounds(r,c)&&board[r][c]&&board[r][c][0]!==color;} + function empty(r,c){return inBounds(r,c)&&!board[r][c];} + function slide(dirs){for(let [dr,dc] of dirs){let nr=r+dr,nc=c+dc;while(inBounds(nr,nc)){if(friendly(nr,nc))break;moves.push([nr,nc]);if(board[nr][nc])break;nr+=dr;nc+=dc;}}} + function jump(drs){for(let [dr,dc] of drs){let nr=r+dr,nc=c+dc;if(inBounds(nr,nc)&&!friendly(nr,nc))moves.push([nr,nc]);}} + if(type==='R')slide([[1,0],[-1,0],[0,1],[0,-1]]); + else if(type==='B')slide([[1,1],[1,-1],[-1,1],[-1,-1]]); + else if(type==='Q')slide([[1,0],[-1,0],[0,1],[0,-1],[1,1],[1,-1],[-1,1],[-1,-1]]); + else if(type==='N')jump([[2,1],[2,-1],[-2,1],[-2,-1],[1,2],[1,-2],[-1,2],[-1,-2]]); + else if(type==='K')jump([[1,0],[-1,0],[0,1],[0,-1],[1,1],[1,-1],[-1,1],[-1,-1]]); + else if(type==='P'){ + let dir=color==='w'?-1:1, start=color==='w'?6:1; + if(empty(r+dir,c)){moves.push([r+dir,c]);if(r===start&&empty(r+2*dir,c))moves.push([r+2*dir,c]);} + if(enemy(r+dir,c-1))moves.push([r+dir,c-1]); + if(enemy(r+dir,c+1))moves.push([r+dir,c+1]); + } + return moves; +} + +export function moveToSAN(board, fr, fc, tr, tc) { + let p = board[fr][fc]; + let type = p[1]; + let files='abcdefgh', target=files[tc]+(8-tr); + let capture = board[tr][tc] ? 'x':''; + if(type==='P') return capture ? files[fc]+'x'+target : target; + if(type==='K') return 'K'+capture+target; + return type+capture+target; +} + +export function playMoveOnBoard(b, t, san) { + for (let r = 0; r < 8; r++) { + for (let c = 0; c < 8; c++) { + let p = b[r][c]; + if (!p || p[0] !== t) continue; + let moves = getMoves(b, r, c); + for (let [tr, tc] of moves) { + if (moveToSAN(b, r, c, tr, tc) === san) { + b[tr][tc] = p; + b[r][c] = null; + if (p[1] === 'P' && (tr === 0 || tr === 7)) { + b[tr][tc] = p[0] + 'Q'; + } + return true; + } + } + } + } + return false; +} + +export function getBoardSignaturesForOpening(op) { + let tempBoard = deepClone(INIT); + let tempTurn = 'w'; + let signatures = []; + + signatures.push({ + signature: getBoardSignature(tempBoard, tempTurn), + history: [] + }); + + let moves = op.pgn.split(' ').filter(Boolean); + for (let i = 0; i < moves.length; i++) { + let san = moves[i]; + let moved = playMoveOnBoard(tempBoard, tempTurn, san); + if (!moved) break; + tempTurn = tempTurn === 'w' ? 'b' : 'w'; + signatures.push({ + signature: getBoardSignature(tempBoard, tempTurn), + history: moves.slice(0, i + 1) + }); + } + return signatures; +} + +export function getLegalMovesForTurn(board, turn) { + let legal = []; + for (let r = 0; r < 8; r++) { + for (let c = 0; c < 8; c++) { + let p = board[r][c]; + if (!p || p[0] !== turn) continue; + let moves = getMoves(board, r, c); + for (let [tr, tc] of moves) { + let san = moveToSAN(board, r, c, tr, tc); + legal.push({ + san: san, + fr: r, + fc: c, + tr: tr, + tc: tc + }); + } + } + } + return legal; +} + +export function canonicalize(str) { + let text = str.toLowerCase().trim(); + + // Replace spoken numbers + text = text + .replace(/\bone\b/g, '1') + .replace(/\btwo\b/g, '2') + .replace(/\bthree\b/g, '3') + .replace(/\bfour\b/g, '4') + .replace(/\bfor\b/g, '4') + .replace(/\bfive\b/g, '5') + .replace(/\bsix\b/g, '6') + .replace(/\bseven\b/g, '7') + .replace(/\beight\b/g, '8') + .replace(/\bate\b/g, '8'); + + // Replace spoken letters/files + text = text + .replace(/\bsee\b/g, 'c') + .replace(/\bsea\b/g, 'c') + .replace(/\bbe\b/g, 'b') + .replace(/\bbee\b/g, 'b') + .replace(/\bgee\b/g, 'g') + .replace(/\beff\b/g, 'f') + .replace(/\baye\b/g, 'a') + .replace(/\bay\b/g, 'a'); + + // Replace misheard words + text = text + .replace(/\bbefore\b/g, 'b4') + .replace(/\bdetour\b/g, 'd4') + .replace(/\bdeeply\b/g, 'd3') + .replace(/\bdefended\b/g, 'd5'); + + // Standard piece mapping + text = text + .replace(/knight/g, 'n') + .replace(/night/g, 'n') + .replace(/bishop/g, 'b') + .replace(/rook/g, 'r') + .replace(/queen/g, 'q') + .replace(/king/g, 'k') + .replace(/pawn/g, ''); + + // Replace e.g. "d to" -> "d2", then strip remaining "to" + text = text.replace(/([a-h])\s+to\b/g, '$12'); + + // Movement/capture words + text = text + .replace(/\bto\b/g, '') + .replace(/\bthe\b/g, '') + .replace(/\bmove\b/g, '') + .replace(/\bplay\b/g, '') + .replace(/\bsquare\b/g, '') + .replace(/takes/g, 'x') + .replace(/take/g, 'x') + .replace(/captures/g, 'x') + .replace(/capture/g, 'x'); + + // Strip spaces and all non-alphanumeric characters + return text.replace(/[^a-z0-9]/g, ''); +} + +export function getSpokenPatternsForMove(san, fr, fc, tr, tc) { + let clean = san.replace(/[+#]/g, ''); + let canon = canonicalize(clean); + let patterns = [canon]; + + // Add coordinate patterns + let files = 'abcdefgh'; + let startSquare = files[fc] + (8 - fr); + let targetSquare = files[tc] + (8 - tr); + patterns.push(startSquare + targetSquare); // e.g. "e2e4" + + if (clean === 'O-O') { + patterns.push('oo', 'castle', 'castles', 'shortcastle', 'kingsidecastle', 'castlekingside'); + } else if (clean === 'O-O-O') { + patterns.push('ooo', 'longcastle', 'queensidecastle', 'castlequeenside'); + } + + if (clean.includes('=')) { + patterns.push(canonicalize(clean.replace('=', ''))); + } + + if (clean.includes('x')) { + patterns.push(canonicalize(clean.replace('x', ''))); + } + + return patterns; +} + +export function determineParentId(pgnStr, openings) { + let moves = pgnStr.split(' ').filter(Boolean); + let longestMatchLength = 0; + let bestParentId = null; + + for (let op of openings) { + let opMoves = op.pgn.split(' ').filter(Boolean); + if (opMoves.length > 0 && opMoves.length < moves.length) { + let isPrefix = true; + for (let i = 0; i < opMoves.length; i++) { + if (opMoves[i] !== moves[i]) { + isPrefix = false; + break; + } + } + if (isPrefix && opMoves.length > longestMatchLength) { + longestMatchLength = opMoves.length; + bestParentId = op.id; + } + } + } + return bestParentId; +} + +export function validateAndReplayMoves(movesArray) { + let tempBoard = deepClone(INIT); + let tempTurn = 'w'; + + for (let i = 0; i < movesArray.length; i++) { + let san = movesArray[i]; + let moved = playMoveOnBoard(tempBoard, tempTurn, san); + if (!moved) { + return { valid: false, error: `Move ${i+1} (${san}) is illegal in this position.` }; + } + tempTurn = tempTurn === 'w' ? 'b' : 'w'; + } + return { valid: true }; +} diff --git a/src/lib/index.js b/src/lib/index.js index 856f2b6..004ff81 100644 --- a/src/lib/index.js +++ b/src/lib/index.js @@ -1 +1 @@ -// place files you want to import through the `$lib` alias in this folder. +export * from './chess.js'; diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 0f93bcf..501e354 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,153 +1,25 @@ - + Chess Opening Repertoire Builder @@ -1376,12 +1042,15 @@
-
- -
+ +
+
+ + - -
+ +
+ -
- +
+
-
- +
+
- {currentTab} + {currentTab} {#if history.length > 0} -
- +
+
- {repStatus.name.replace(' Cont...', '')} + {repStatus.name.replace(' Cont...', '')} {/if} +
-
- - -
+ +
+ +
{#if drillMode} @@ -1570,46 +1245,66 @@
- - -
- - - - -
+ +
+ {#if history.length === 0} + No moves played yet + {:else} + {#each Array(Math.ceil(history.length / 2)) as _, turnIndex} + {@const wIdx = turnIndex * 2} + {@const wSan = history[wIdx]} + {@const wPath = history.slice(0, wIdx + 1).join(' ')} + {@const wTag = moveTags[currentRep] && moveTags[currentRep][wPath]} + {@const bIdx = turnIndex * 2 + 1} + {@const bSan = history[bIdx]} + {@const bPath = bIdx < history.length ? history.slice(0, bIdx + 1).join(' ') : ''} + {@const bTag = bIdx < history.length && moveTags[currentRep] && moveTags[currentRep][bPath]} +
+ + + + + {#if bIdx < history.length} + + {/if} +
+ {/each} + {/if} +
+ {#if voiceStatusMsg}
{voiceStatusMsg} @@ -1617,8 +1312,44 @@ {/if}
- - + +
+ + + + +
{/if} -
- {#if history.length === 0} -

Make a move on the board to start building your opening line...

- {:else} - {#each history as san, i} - {@const pathAtMove = history.slice(0, i + 1).join(' ')} - {@const tagAtMove = moveTags[currentRep] && moveTags[currentRep][pathAtMove]} - {#if i % 2 === 0} - {i / 2 + 1}. - {/if} -
- - - -
- {/each} - {/if} -
+
@@ -1828,7 +1520,7 @@ {#each branches as b, i} {@const moveNum = Math.floor((history.length) / 2) + 1} {@const isBlackMove = (history.length) % 2 === 1} - {@const branchNote = getNoteForHistory([...history, b.move])} + {@const branchNote = getNoteForHistory([...history, b.move], customNotes, currentRep, currentRepertoireObj)} {@const hash = b.move.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0)} {@const gamesCount = (hash * 17) % 950 + 10} {@const successRate = 45 + (hash % 25)} @@ -1836,7 +1528,7 @@ {@const currentTag = moveTags[currentRep] && moveTags[currentRep][movePathStr]}
@@ -1849,6 +1541,7 @@ history.push(b.move); history = history; }} + oncontextmenu={(e) => openContextMenu(e, movePathStr)} >
@@ -1879,39 +1572,9 @@
- -
- {gamesCount} games - - - + +
+ {gamesCount} games
@@ -1931,38 +1594,50 @@ {/if} -
-
- Position Note - - {#if history.length > 0} - After {lineStr} - {:else} - — - {/if} - + {#if showNoteEdit} +
+
+
+ Position Note + + {#if history.length > 0} + After {lineStr} + {:else} + — + {/if} + +
+ +
+ + + +
- - - - -
+ {/if} {#if transpositions.length > 0} @@ -1990,72 +1665,52 @@
{/if} - -
-
- Repertoire Status -
- {#if history.length === 0} - No moves played - {:else if repStatus.isExact} - - In Repertoire - - {repStatus.name} - {:else if repStatus.isContinuation} - - {repStatus.parentName} Cont... - - {:else} - - Not in Repertoire - - {/if} -
-
- - {#if history.length > 0 || activeOpening} -
- {#if activeOpening} - - {/if} + + {#if history.length > 0 || activeOpening} +
+ {#if activeOpening && lineStr !== activeOpening.pgn} + + {:else if repStatus.isContinuation} + + {/if} + + {#if history.length > 0 && !repStatus.isExact} + + {/if} - {#if activeOpening && lineStr !== activeOpening.pgn} - - {:else if repStatus.isContinuation} - - {/if} - - {#if history.length > 0 && !repStatus.isExact} - - {/if} -
- {/if} -
+ {#if activeOpening} + + {/if} +
+ {/if} {:else if currentTab === 'openings'}
@@ -2267,3 +1922,44 @@
+ +{#if contextMenu.show} +
e.stopPropagation()} + role="menu" + tabindex="-1" + > + + + + {#if moveTags[currentRep] && moveTags[currentRep][contextMenu.movePathStr]} + + {/if} +
+{/if}