Fix web layer

This commit is contained in:
2026-01-07 20:29:15 +01:00
parent bb3d99b011
commit 29ebb83ead
5 changed files with 279 additions and 188 deletions

375
chess.js
View File

@@ -1,5 +1,7 @@
/* this code is complete garbage but it's mostly for testing anyways */
const MoveResult = Object.freeze({
ILLEGAL: -1,
NORMAL: 0,
CHECK: 1,
REPEATS: 2,
@@ -15,65 +17,44 @@ const _moveResultName = [
"Checkmate",
];
const moveResultName = (mr) => {
if (mr === MoveResult.ILLEGAL) return "Illegal";
return _moveResultName[mr] ?? `Unknown(${mr})`;
};
const isTerminal = (mr) => (mr === MoveResult.STALEMATE || mr === MoveResult.CHECKMATE);
const Player = Object.freeze({
White: 0,
Black: 1,
None: 2,
});
const _playerName = [
"White",
"Black",
"None",
];
const Piece = Object.freeze({
Pawn: 0,
King: 1,
Queen: 2,
Empty: 0,
Pawn: 1,
Knight: 2,
Bishop: 3,
Rook: 4,
Knight: 5,
Empty: 6,
Queen: 5,
King: 6,
});
const _pieceName = [
"Pawn",
"King",
"Queen",
"Bishop",
"Rook",
"Knight",
"Empty",
];
const _pieceChar = [
'P',
'K',
'Q',
'B',
'R',
'N',
' ',
];
const _pieceUnicode = [
['', '', '', '♗', '♖', '',], // white
['', '', '', '♝', '♜', '',], // black
['' , '', '', '', '', '', ], // none
[' ', '', '', '♗', '♖', '♕', '♔',], // white
[' ', '', '', '♝', '♜', '♛', '♚',], // black
[' ', ' ', ' ', ' ', ' ', ' ', ' ',], // none
];
const indexDeserialize = (n) => ({
rank: Math.floor(n / 8), // every day we stray further from God
rank: Math.floor(n / 8),
file: (n % 8),
fileChar() { return ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h'][this.file]; },
rankChar() { return ['1', '2', '3', '4', '5', '6', '7', '8'][this.rank]; },
});
const serializedIndexFromCoord = (rank, file) => (8*rank + file);
const indexSerialize = (index) => (serializedIndexFromCoord(index.rank, index.file));
const serializedIndexFromCoord = (rank, file) => (8 * rank + file);
const indexSerialize = (index) => serializedIndexFromCoord(index.rank, index.file);
const moveDeserialize = (n) => ({
appeal: Number((n >> 24n) & 0xFFn),
@@ -83,80 +64,50 @@ const moveDeserialize = (n) => ({
});
const moveSerialize = (move) => (
(indexSerialize(move.from) & 0xFF) << 8) | ((indexSerialize(move.to) & 0xFF));
((indexSerialize(move.from) & 0xFF) << 8) | (indexSerialize(move.to) & 0xFF)
);
const squareDeserialize = (n) => ({
player: n == -1 ? Player.None : (n >> 8) & 0xFF,
piece: n == -1 ? Piece.Empty : (n & 0xFF),
playerName() {
if (this.player === Player.None) {
return "Empty";
} else {
return _playerName[this.player];
}
},
pieceName() {
if (this.player === Player.None) {
return "";
} else {
return this.playerName() + _pieceName[this.piece];
}
},
pieceUnicode() {
return _pieceUnicode[this.player][this.piece];
}
});
const squareDeserialize = (n) => {
if (n === -1) {
return {
player: Player.None,
piece: Piece.Empty,
unicode: ' ',
};
}
const WasmBoard = async (url) => {
/*
const res = await fetch(url);
if (!res.ok) {
throw new Error(`Failed to fetch ${url}: ${res.status} ${res.statusText}`);
}
const bytes = await res.arrayBuffer();
const instantiated = await WebAssembly.instantiate(bytes, {});
const instance = instantiated.instance ?? instantiated;
const exports = instance.exports;
const player = (n >> 8) & 0xFF;
const piece = n & 0xFF;
const unicode = _pieceUnicode[player][piece];
const wb_init = exports.wb_init;
const wb_move = exports.wb_move;
const wb_search = exports.wb_search;
const wb_board_at = exports.wb_board_at;
*/
const worker = new Worker("./chess-worker.js", { type: "module" });
return {
player: player,
piece: piece,
unicode: unicode,
};
};
const WasmBoard = async () => {
const worker = new Worker("./chess-worker.js?rand=" + crypto.randomUUID(), { type: "module" });
await new Promise((resolve, reject) => {
worker.addEventListener("message", (e) => {
if (e.data?.type === "ready") {
resolve();
}
if (e.data?.type === "ready") resolve();
}, { once: true });
worker.addEventListener("error", reject, { once: true });
});
const wasmCall = (method, args = []) => new Promise((resolve, reject) => {
const id = crypto.randomUUID();
const onMessage = (e) => {
if (e.data?.id !== id) {
return;
}
if (e.data?.id !== id) return;
cleanup();
if (e.data.ok) {
resolve(e.data.value);
} else {
reject(new Error(e.data.error));
}
};
const onError = (err) => {
cleanup();
reject(err);
};
const onMessageError = (e) => {
cleanup();
reject(new Error("Worker message deserialization failed (messageerror)."));
if (e.data.ok) resolve(e.data.value);
else reject(new Error(e.data.error));
};
const onError = (err) => { cleanup(); reject(err); };
const onMessageError = () => { cleanup(); reject(new Error("Worker messageerror")); };
const cleanup = () => {
worker.removeEventListener("message", onMessage);
@@ -174,96 +125,202 @@ const WasmBoard = async (url) => {
await wasmCall("wb_init", []);
return {
state: MoveResult.Normal,
state: MoveResult.NORMAL,
ongoing: function() /* nil -> bool */ {
return (this.state != MoveResult.STALEMATE
&& this.state != MoveResult.CHECKMATE);
search: async function(depth, timeout) {
return wasmCall("wb_search", [depth]).then(moveDeserialize);
},
search: async function(depth) /* Int -> Move */ {
return wasmCall("wb_search", [depth])
.then(moveDeserialize);
},
move: async function (move) /* (Move | SerializedMove) -> MoveResult */ {
move: async function(move) {
const m = (typeof move === "number") ? move : moveSerialize(move);
return wasmCall("wb_move", [m]);
const mr = await wasmCall("wb_move", [m]);
if (mr === MoveResult.NORMAL || mr === MoveResult.CHECK || mr === MoveResult.REPEATS ||
mr === MoveResult.STALEMATE || mr === MoveResult.CHECKMATE) {
this.state = mr;
return mr;
}
return MoveResult.ILLEGAL;
},
at: async function (index) /* (Index | SerializedIndex) -> Square */ {
at: async function(index) {
const i = (typeof index === "number") ? index : indexSerialize(index);
return wasmCall("wb_board_at", [i])
.then(squareDeserialize);
.then(squareDeserialize);
},
legalMoves: async function() {
const n = await wasmCall("wb_all_moves_len", []);
const out = new Uint16Array(n);
for (let i = 0; i < n; ++i) {
out[i] = await wasmCall("wb_all_moves_get", [i]);
}
return out;
},
};
};
const run = async () => {
const board = await WasmBoard("./chess-worker.js");
const board = await WasmBoard();
const nextFrame = () => new Promise(requestAnimationFrame);
const boardElem = document.getElementById("board");
const resultEl = document.getElementById("result");
const setStatus = (s) => { resultEl.textContent = s; };
const createBoardUI = async (board) => {
const boardElem = document.getElementById("board");
const squares = new Array(64);
const idxToAlg = (idx) => {
const i = indexDeserialize(idx);
return `${i.fileChar()}${i.rankChar()}`;
};
const draw = async () => {
for (let rank = 7; rank >= 0; --rank) {
for (let file = 0; file < 8; ++file) {
const idx = 8 * rank + file;
const sq = await board.at(idx);
console.log(sq);
console.log(sq.unicode);
squares[idx].textContent = sq.unicode;
}
}
};
const createBoardUI = () => {
boardElem.replaceChildren();
const filesChars = ["a","b","c","d","e","f","g","h"];
const ranksChars = ["8","7","6","5","4","3","2","1"];
const squares = new Array(64);
for (let rank = 7; rank >= 0; --rank) {
for (let file = 0; file < 8; ++file) {
const idx = 8 * rank + file;
const el = document.createElement("div");
el.className =
"square " + ((file + rank) % 2 ? "dark" : "light");
el.dataset.file = filesChars[file];
el.dataset.rank = ranksChars[rank];
const sq = await board.at(serializedIndexFromCoord(rank, file));
el.textContent = sq.pieceUnicode();
el.className = "square " + (((file + rank) % 2) ? "dark" : "light");
el.dataset.idx = String(idx);
boardElem.appendChild(el);
squares[8*rank+file] = el;
}
}
return squares;
};
const draw = async (board, squareElems) => {
for (let rank = 7; rank >= 0; --rank) {
for (let file = 0; file < 8; ++file) {
const sq = await board.at(serializedIndexFromCoord(rank, file));
squareElems[8*rank+file].textContent = sq.pieceUnicode();
squares[idx] = el;
}
}
};
const sqElems = await createBoardUI(board);
for (let i = 0; i < 200; ++i) {
await draw(board, sqElems);
await nextFrame();
createBoardUI();
await draw();
const move = await board.search(7);
let selected = null; // 0..63 or null
let inputEnabled = true;
const fromSq = await board.at(move.from);
const toSq = await board.at(move.to);
const clearSelection = () => {
if (selected !== null) squares[selected].classList.remove("blue");
selected = null;
};
const mr = board.move(move);
if (mr == MoveResult.STALEMATE || mr == MoveResult.CHECKMATE) {
const resultEl = document.getElementById("result");
resultEl.textContent = _moveResultName[board.state];
draw(board, sqElems);
await nextFrame();
break;
const legalHasFrom = (moves, fromIdx) => {
for (const mv of moves) {
if (((mv >> 8) & 0xFF) === fromIdx) return true;
}
return false;
};
const legalHasMove = (moves, fromIdx, toIdx) => {
const key = ((fromIdx & 0xFF) << 8) | (toIdx & 0xFF);
for (const mv of moves) {
if (mv === key) return true;
}
return false;
};
const engineReply = async (depth) => {
console.log("searching");
setStatus("Black thinking...");
const m = await board.search(depth, 2000);
console.log("found move", m, );
let sooner = Date.now();
const mr = await board.move(m);
await draw();
let later = Date.now();
let seconds = later - sooner;
setStatus(`Black played ${idxToAlg(indexSerialize(m.from))}-${idxToAlg(indexSerialize(m.to))} after ${seconds} seconds\nWhite to move...`);
return mr;
};
const onSquareClick = async (idx) => {
if (!inputEnabled) return;
if (isTerminal(board.state)) return;
const legalMoves = await board.legalMoves();
if (selected === null) {
const sq = await board.at(idx);
if (sq.player !== Player.White) return;
if (!legalHasFrom(legalMoves, idx)) {
clearSelection();
setStatus(`No legal moves from ${idxToAlg(idx)}.`);
return;
}
selected = idx;
squares[selected].classList.add("blue");
setStatus(`Selected ${idxToAlg(idx)}.`);
return;
}
if (idx === selected) {
clearSelection();
setStatus("");
return;
}
const clickedSq = await board.at(idx);
// user clicks another white piece: either switch selection (if it has moves) or deselect
if (clickedSq.player === Player.White) {
clearSelection();
if (!legalHasFrom(legalMoves, idx)) {
setStatus(`No legal moves from ${idxToAlg(idx)}.`);
return;
}
selected = idx;
squares[selected].classList.add("blue");
setStatus(`Selected ${idxToAlg(idx)}.`);
return;
}
const from = selected;
const to = idx;
if (!legalHasMove(legalMoves, from, to)) {
clearSelection();
setStatus(`Illegal: ${idxToAlg(from)}-${idxToAlg(to)}.`);
return;
}
clearSelection();
inputEnabled = false;
const mrWhite = await board.move({ from: indexDeserialize(from), to: indexDeserialize(to) });
await draw();
setStatus(`White played ${idxToAlg(from)}-${idxToAlg(to)}. ${moveResultName(mrWhite)}`);
if (isTerminal(mrWhite)) {
inputEnabled = false;
return;
}
const mrBlack = await engineReply(8);
if (isTerminal(mrBlack)) {
inputEnabled = false;
return;
}
inputEnabled = true;
};
for (let i = 0; i < 64; ++i) {
squares[i].addEventListener("click", () => { onSquareClick(i).catch(console.error); });
}
}
run().catch((err) => {
console.error(err);
});
setStatus("White to move.");
};
run().catch((err) => console.error(err));