@mirasen/chessboard 1.0.5 → 1.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # @mirasen/chessboard
2
2
 
3
+ ## 1.0.7
4
+
5
+ ### Patch Changes
6
+
7
+ - 92c3a69: Fix promotion animation planning when cancelling deferred promotion moves after a previous promotion.
8
+ - 92c3a69: Fix piece suppression updates during deferred move animations.
9
+
10
+ ## 1.0.6
11
+
12
+ ### Patch Changes
13
+
14
+ - e81b3fd: Fix scheduler `flushNow()` so pending frames flush synchronously.
15
+
3
16
  ## 1.0.5
4
17
 
5
18
  ### Patch Changes
package/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  [![NPM Version](https://img.shields.io/npm/v/%40mirasen%2Fchessboard)](https://www.npmjs.com/package/@mirasen/chessboard)
2
2
  [![CI](https://github.com/mirasen-io/chessboard/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/mirasen-io/chessboard/actions/workflows/ci.yml)
3
+ [![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=mirasen-io_chessboard&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=mirasen-io_chessboard)
4
+ [![Coverage](https://sonarcloud.io/api/project_badges/measure?project=mirasen-io_chessboard&metric=coverage)](https://sonarcloud.io/summary/new_code?id=mirasen-io_chessboard)
3
5
  [![License](https://img.shields.io/npm/l/@mirasen/chessboard)](./LICENSE)
4
6
 
5
7
  # @mirasen/chessboard
@@ -37,9 +39,16 @@ The default first-party extension baseline includes rendering, events, selection
37
39
 
38
40
  That means the board already covers a meaningful baseline chess UX instead of stopping at “draw squares and pieces”.
39
41
 
42
+ ### Built-in chessboard behavior
43
+
44
+ Mirasen Chessboard provides a complete modern interaction and visual baseline out of the box: selection, deselection, reselection, drag-to-move, release targeting, legal destinations, promotion, auto-promotion, and state-diff animation. Moves are resolved on release rather than committed immediately on press, giving users a more forgiving interaction flow. Because animation is driven by board-state transitions, pieces can move, appear, disappear, promote, or return smoothly without app-level glue code.
45
+
40
46
  ## Highlights
41
47
 
42
48
  - Real built-in chess interaction
49
+ - Easy game/rules layer integration
50
+ - Works on desktop and mobile out of the box, with pointer-based mouse and touch interaction
51
+ - State-diff animation beyond simple move events
43
52
  - Extension-driven architecture
44
53
  - First-party baseline extensions
45
54
  - Explicit SVG slot ownership
@@ -2,6 +2,7 @@ import assert from '@ktarmyshov/assert';
2
2
  import { isNonEmptyPieceCode } from '../state/board/check.js';
3
3
  import { fileOf, rankOf } from '../state/board/coords.js';
4
4
  import { SQUARE_COUNT } from '../state/board/types/internal.js';
5
+ import { baseMovesEqual } from '../state/change/helpers.js';
5
6
  // ---------------------------------------------------------------------------
6
7
  // Precomputed squared distance: (dFile² + dRank²) for all square pairs.
7
8
  // Max value = 7² + 7² = 98, fits in Uint8. Index: a * 64 + b.
@@ -55,7 +56,7 @@ function buildExcludeForDrop(lastMove) {
55
56
  function buildEffectiveAnimationPlanningSnapshot(state) {
56
57
  const request = state.change.deferredUIMoveRequest;
57
58
  if (request === null)
58
- return state; // nothing to do
59
+ return { ...state, lastMoveSource: 'state' }; // nothing to do
59
60
  const newPieces = new Uint8Array(state.board.pieces);
60
61
  const from = request.sourceSquare;
61
62
  const to = request.destination.to;
@@ -73,40 +74,41 @@ function buildEffectiveAnimationPlanningSnapshot(state) {
73
74
  piece: pieceCode
74
75
  };
75
76
  const newChange = {
77
+ ...state.change,
76
78
  deferredUIMoveRequest: null,
77
79
  lastMove: newLastMove
78
80
  };
79
81
  return {
80
82
  ...state,
81
83
  board: newBoard,
82
- change: newChange
84
+ change: newChange,
85
+ lastMoveSource: 'projected-deferred-ui-move'
83
86
  };
84
87
  }
85
- /**
86
- * When a promotion was already auto-resolved/auto-promoted (no deferredUIMoveRequest but
87
- * lastMove.promotedTo is set), the current board already contains the promoted
88
- * piece (e.g. queen) on the target square. For animation planning we
89
- * temporarily replace it with the original piece (pawn) so that the greedy
90
- * matching can build a proper move track from→to instead of separate fades.
91
- *
92
- * After the animation finishes the promoted piece will "snap" into view on
93
- * the next render frame.
94
- */
95
- function buildPromotionEffectiveCurrent(state) {
96
- const lastMove = state.change.lastMove;
97
- if (state.change.deferredUIMoveRequest !== null ||
88
+ function normalizePromotionEffectiveCurrent(previous, current) {
89
+ const lastMove = current.change.lastMove;
90
+ if (current.change.deferredUIMoveRequest !== null ||
98
91
  lastMove === null ||
99
92
  lastMove.promotedTo === undefined) {
100
- return state; // not an auto-resolved/auto-promoted move — nothing to do
93
+ return current;
101
94
  }
102
- const newPieces = new Uint8Array(state.board.pieces);
95
+ if (previous.lastMoveSource === 'projected-deferred-ui-move') {
96
+ const previousLastMove = previous.change.lastMove;
97
+ if (previousLastMove === null || !baseMovesEqual(previousLastMove, lastMove)) {
98
+ return current;
99
+ }
100
+ }
101
+ else if (previous.board.pieces[lastMove.from] !== lastMove.piece) {
102
+ return current;
103
+ }
104
+ const newPieces = new Uint8Array(current.board.pieces);
103
105
  newPieces[lastMove.to] = lastMove.piece;
104
106
  const newBoard = {
105
- ...state.board,
107
+ ...current.board,
106
108
  pieces: newPieces
107
109
  };
108
110
  return {
109
- ...state,
111
+ ...current,
110
112
  board: newBoard
111
113
  };
112
114
  }
@@ -231,8 +233,8 @@ export function calculateAnimationPlan(input) {
231
233
  // 2) Detect lifted-piece drop and prepare exclude spec
232
234
  const droppedMove = detectLiftedPieceDrop(effectivePrevious, effectiveCurrent);
233
235
  const exclude = droppedMove !== null ? buildExcludeForDrop(droppedMove) : EMPTY_EXCLUDE;
234
- // 3) Normalize current for auto-resolved/auto-promoted move (pawn→queen on target square)
235
- const planningCurrent = buildPromotionEffectiveCurrent(effectiveCurrent);
236
+ // 3) Normalize current promoted piece when the promoted move belongs to this transition
237
+ const planningCurrent = normalizePromotionEffectiveCurrent(effectivePrevious, effectiveCurrent);
236
238
  // 4) Calculate tracks
237
239
  const tracks = calculateTracks(effectivePrevious.board, planningCurrent.board, exclude);
238
240
  // 5) Build suppressedSquares: track-derived + excluded drop endpoints
@@ -30,14 +30,17 @@ export interface AnimationSession {
30
30
  duration: number;
31
31
  }
32
32
  export type AnimationSessionSnapshot = ReadonlyDeep<AnimationSession>;
33
- export interface AnimationPlanningSnapshot {
33
+ export interface AnimationPlanningInputSnapshot {
34
34
  readonly board: BoardStateSnapshot;
35
35
  readonly change: ChangeStateSnapshot;
36
36
  readonly interaction: InteractionStateSnapshot;
37
37
  }
38
+ export interface AnimationPlanningSnapshot extends AnimationPlanningInputSnapshot {
39
+ readonly lastMoveSource: 'state' | 'projected-deferred-ui-move';
40
+ }
38
41
  export interface AnimationPlanningInput {
39
- previous: AnimationPlanningSnapshot;
40
- current: AnimationPlanningSnapshot;
42
+ previous: AnimationPlanningInputSnapshot;
43
+ current: AnimationPlanningInputSnapshot;
41
44
  }
42
45
  export interface AnimationPlan {
43
46
  readonly tracks: readonly AnimationTrack[];
@@ -6,7 +6,7 @@ import { calculateSuppressedSquares } from './suppress.js';
6
6
  export function rendererPiecesOnUpdate(state, context, animationSuppressedSquares) {
7
7
  if (!isUpdateContextRenderable(context) ||
8
8
  !context.mutation.hasMutation({
9
- prefixes: ['state.board.', 'state.interaction.'],
9
+ prefixes: ['state.'],
10
10
  causes: ['layout.refreshGeometry']
11
11
  })) {
12
12
  return;
@@ -3,16 +3,14 @@ export function createScheduler(opts) {
3
3
  let scheduled = false;
4
4
  let runId = 0;
5
5
  let rafHandle = null;
6
- const flushCore = () => {
7
- if (!scheduled && rafHandle == null) {
6
+ const flushCore = (force = false) => {
7
+ if (!force && !scheduled && rafHandle == null) {
8
8
  // Maybe was cancelled after the rAF callback was scheduled but before it fired.
9
9
  return;
10
10
  }
11
11
  // Reset scheduled flags first to allow schedule() during render to queue the next frame.
12
12
  scheduled = false;
13
- if (rafHandle != null) {
14
- rafHandle = null;
15
- }
13
+ rafHandle = null;
16
14
  render();
17
15
  };
18
16
  return {
@@ -29,17 +27,16 @@ export function createScheduler(opts) {
29
27
  });
30
28
  },
31
29
  flushNow() {
30
+ if (!scheduled && rafHandle == null)
31
+ return;
32
32
  // Invalidate any pending rAF render immediately.
33
- if (scheduled) {
34
- runId++;
35
- scheduled = false;
36
- }
33
+ runId++;
37
34
  if (rafHandle != null) {
38
35
  const caf = globalThis.cancelAnimationFrame;
39
36
  caf(rafHandle);
40
37
  rafHandle = null;
41
38
  }
42
- flushCore();
39
+ flushCore(true);
43
40
  },
44
41
  cancel() {
45
42
  scheduled = false;
@@ -19,7 +19,7 @@ export function createInputAdapter(options) {
19
19
  internalState.container.addEventListener(type, onEventHander);
20
20
  },
21
21
  unsubscribeEvent(type) {
22
- assert(NEED_EVENT_TYPES.has(type), `Unsubscribe for event type ${type} is not supported. This is required event type for the board to function properly.`);
22
+ assert(!NEED_EVENT_TYPES.has(type), `Unsubscribe for event type ${type} is not supported. This is required event type for the board to function properly.`);
23
23
  internalState.container.removeEventListener(type, onEventHander);
24
24
  },
25
25
  destroy() {
@@ -0,0 +1,10 @@
1
+ import { FenString } from '../types/input.js';
2
+ import { ParsedPosition } from '../types/internal.js';
3
+ /**
4
+ * Parse a FEN string into a {@link ParsedPosition}.
5
+ *
6
+ * Only the first two FEN fields (piece placement + active color) are required
7
+ * for a board-only representation. The remaining fields (castling, en-passant,
8
+ * half-move clock, full-move number) are accepted but ignored.
9
+ */
10
+ export declare function parseFen(fen: FenString): ParsedPosition;
@@ -0,0 +1,94 @@
1
+ import { SQUARE_COUNT } from '../types/internal.js';
2
+ /**
3
+ * Direct FEN character → PieceCode lookup.
4
+ * Uppercase = White, lowercase = Black.
5
+ */
6
+ const FEN_PIECE_MAP = {
7
+ P: 1 /* PieceCode.WhitePawn */,
8
+ N: 2 /* PieceCode.WhiteKnight */,
9
+ B: 3 /* PieceCode.WhiteBishop */,
10
+ R: 4 /* PieceCode.WhiteRook */,
11
+ Q: 5 /* PieceCode.WhiteQueen */,
12
+ K: 6 /* PieceCode.WhiteKing */,
13
+ p: 9 /* PieceCode.BlackPawn */,
14
+ n: 10 /* PieceCode.BlackKnight */,
15
+ b: 11 /* PieceCode.BlackBishop */,
16
+ r: 12 /* PieceCode.BlackRook */,
17
+ q: 13 /* PieceCode.BlackQueen */,
18
+ k: 14 /* PieceCode.BlackKing */
19
+ };
20
+ /**
21
+ * Parse the piece-placement section of a FEN string into a Uint8Array[64].
22
+ *
23
+ * FEN ranks are ordered 8→1 (top to bottom), but our board indexing is
24
+ * a1=0, b1=1, ..., h1=7, a2=8, ..., h8=63.
25
+ * So rank 8 maps to indices 56–63 and rank 1 to indices 0–7.
26
+ */
27
+ function parsePiecePlacement(placement) {
28
+ const pieces = new Uint8Array(SQUARE_COUNT); // initialized to 0 (PieceCode.Empty)
29
+ const ranks = placement.split('/');
30
+ if (ranks.length !== 8) {
31
+ throw new Error(`Invalid FEN piece placement: expected 8 ranks separated by '/', got ${ranks.length}`);
32
+ }
33
+ for (let rankIdx = 0; rankIdx < 8; rankIdx++) {
34
+ // FEN rank index 0 = rank 8 (board row 7), index 7 = rank 1 (board row 0)
35
+ const boardRow = 7 - rankIdx;
36
+ const baseSquare = boardRow * 8;
37
+ const rankStr = ranks[rankIdx];
38
+ let file = 0;
39
+ for (let i = 0; i < rankStr.length; i++) {
40
+ const ch = rankStr[i];
41
+ if (ch >= '1' && ch <= '8') {
42
+ // Skip empty squares
43
+ file += ch.charCodeAt(0) - '0'.charCodeAt(0);
44
+ }
45
+ else {
46
+ const piece = FEN_PIECE_MAP[ch];
47
+ if (piece === undefined) {
48
+ throw new Error(`Invalid FEN piece character: '${ch}'`);
49
+ }
50
+ if (file >= 8) {
51
+ throw new Error(`Invalid FEN piece placement: too many squares in rank ${8 - rankIdx}`);
52
+ }
53
+ pieces[baseSquare + file] = piece;
54
+ file++;
55
+ }
56
+ }
57
+ if (file !== 8) {
58
+ throw new Error(`Invalid FEN piece placement: rank ${8 - rankIdx} has ${file} squares instead of 8`);
59
+ }
60
+ }
61
+ return pieces;
62
+ }
63
+ /**
64
+ * Parse the active-color field of a FEN string.
65
+ */
66
+ function parseTurn(turn) {
67
+ switch (turn) {
68
+ case 'w':
69
+ return 0 /* ColorCode.White */;
70
+ case 'b':
71
+ return 8 /* ColorCode.Black */;
72
+ default:
73
+ throw new Error(`Invalid FEN turn field: expected 'w' or 'b', got '${turn}'`);
74
+ }
75
+ }
76
+ /**
77
+ * Parse a FEN string into a {@link ParsedPosition}.
78
+ *
79
+ * Only the first two FEN fields (piece placement + active color) are required
80
+ * for a board-only representation. The remaining fields (castling, en-passant,
81
+ * half-move clock, full-move number) are accepted but ignored.
82
+ */
83
+ export function parseFen(fen) {
84
+ const parts = fen
85
+ .replace(/^\uFEFF/, '')
86
+ .trim()
87
+ .split(/\s+/);
88
+ if (parts.length < 2) {
89
+ throw new Error(`Invalid FEN string: expected at least 2 space-separated fields, got ${parts.length}`);
90
+ }
91
+ const pieces = parsePiecePlacement(parts[0]);
92
+ const turn = parseTurn(parts[1]);
93
+ return { pieces, turn };
94
+ }
@@ -1,4 +1,4 @@
1
- import { parse } from '@echecs/fen';
1
+ import { parseFen } from './fen/main.js';
2
2
  import { normalizeColor, normalizePiece, normalizeSquare } from './normalize.js';
3
3
  import { SQUARE_COUNT } from './types/internal.js';
4
4
  const START_POSITION_RECORD_STRING = {
@@ -63,18 +63,7 @@ function isPositionInputObject(input) {
63
63
  return typeof input === 'object' && input !== null && !Array.isArray(input);
64
64
  }
65
65
  function boardParsePositionInputFen(input) {
66
- const position = parse(input);
67
- if (!position) {
68
- throw new Error('Failed to parse FEN string');
69
- }
70
- const piecePositionRecord = {};
71
- for (const [square, piece] of position.board) {
72
- piecePositionRecord[square] = { role: piece.type, color: piece.color };
73
- }
74
- return {
75
- pieces: boardParsePositionInputRecord(piecePositionRecord),
76
- turn: normalizeColor(position.turn)
77
- };
66
+ return parseFen(input);
78
67
  }
79
68
  export function boardParsePosition(input) {
80
69
  if (input === 'start') {
@@ -1,7 +1,8 @@
1
- import type { MoveRequest, MoveRequestBase, MoveSnapshot } from '../board/types/internal.js';
1
+ import type { MoveBaseSnapshot, MoveRequest, MoveRequestBase, MoveSnapshot } from '../board/types/internal.js';
2
2
  import { MoveDestinationSnapshot } from '../interaction/types/internal.js';
3
3
  import { ChangeStateSnapshot } from './types/main.js';
4
4
  import { PendingUIMoveRequestSnapshot } from './types/ui-move.js';
5
+ export declare function baseMovesEqual(moveA: MoveBaseSnapshot, moveB: MoveBaseSnapshot): boolean;
5
6
  export declare function movesEqual(moveA: MoveSnapshot | null, moveB: MoveSnapshot | null): boolean;
6
7
  export declare function moveRequestBasesEqual(requestA: MoveRequestBase | null, requestB: MoveRequestBase | null): boolean;
7
8
  export declare function moveRequestsEqual(requestA: MoveRequest | null, requestB: MoveRequest | null): boolean;
@@ -1,5 +1,5 @@
1
1
  import { setsEqual } from '../../helpers/util.js';
2
- function baseMovesEqual(moveA, moveB) {
2
+ export function baseMovesEqual(moveA, moveB) {
3
3
  const diffs = [
4
4
  moveA.from !== moveB.from,
5
5
  moveA.to !== moveB.to,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@mirasen/chessboard",
3
3
  "description": "Framework-agnostic TypeScript chessboard platform with real chess interaction built in.",
4
- "version": "1.0.5",
4
+ "version": "1.0.7",
5
5
  "license": "MIT",
6
6
  "author": {
7
7
  "name": "Mirasen",
@@ -79,6 +79,7 @@
79
79
  "@eslint/js": "^10.0.1",
80
80
  "@types/node": "^25.5.0",
81
81
  "@vitest/coverage-istanbul": "^4.1.0",
82
+ "chess.js": "^1.4.0",
82
83
  "eslint": "^10.0.2",
83
84
  "eslint-config-prettier": "^10.1.2",
84
85
  "globals": "^17.4.0",
@@ -96,7 +97,6 @@
96
97
  "node": ">=20"
97
98
  },
98
99
  "dependencies": {
99
- "@echecs/fen": "^2.1.0",
100
100
  "@ktarmyshov/assert": "^0.1.9",
101
101
  "es-toolkit": "^1.45.1",
102
102
  "type-fest": "^5.5.0"