@dxos/react-ui-gameboard 0.8.4-main.fd6878d → 0.8.4-main.fffef41

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.
Files changed (29) hide show
  1. package/dist/lib/browser/index.mjs +133 -98
  2. package/dist/lib/browser/index.mjs.map +3 -3
  3. package/dist/lib/browser/meta.json +1 -1
  4. package/dist/lib/node-esm/index.mjs +133 -98
  5. package/dist/lib/node-esm/index.mjs.map +3 -3
  6. package/dist/lib/node-esm/meta.json +1 -1
  7. package/dist/types/src/components/Chessboard/Chessboard.d.ts +9 -4
  8. package/dist/types/src/components/Chessboard/Chessboard.d.ts.map +1 -1
  9. package/dist/types/src/components/Chessboard/Chessboard.stories.d.ts +20 -18
  10. package/dist/types/src/components/Chessboard/Chessboard.stories.d.ts.map +1 -1
  11. package/dist/types/src/components/Chessboard/chess.d.ts +12 -6
  12. package/dist/types/src/components/Chessboard/chess.d.ts.map +1 -1
  13. package/dist/types/src/components/Chessboard/chess.test.d.ts +2 -0
  14. package/dist/types/src/components/Chessboard/chess.test.d.ts.map +1 -0
  15. package/dist/types/src/components/Gameboard/Gameboard.d.ts +3 -2
  16. package/dist/types/src/components/Gameboard/Gameboard.d.ts.map +1 -1
  17. package/dist/types/src/components/Gameboard/Piece.d.ts +1 -1
  18. package/dist/types/src/components/Gameboard/Piece.d.ts.map +1 -1
  19. package/dist/types/src/components/Gameboard/types.d.ts +1 -0
  20. package/dist/types/src/components/Gameboard/types.d.ts.map +1 -1
  21. package/dist/types/tsconfig.tsbuildinfo +1 -1
  22. package/package.json +19 -19
  23. package/src/components/Chessboard/Chessboard.stories.tsx +13 -15
  24. package/src/components/Chessboard/Chessboard.tsx +28 -21
  25. package/src/components/Chessboard/chess.test.ts +19 -0
  26. package/src/components/Chessboard/chess.ts +103 -54
  27. package/src/components/Gameboard/Gameboard.tsx +2 -1
  28. package/src/components/Gameboard/Piece.tsx +8 -5
  29. package/src/components/Gameboard/types.ts +1 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dxos/react-ui-gameboard",
3
- "version": "0.8.4-main.fd6878d",
3
+ "version": "0.8.4-main.fffef41",
4
4
  "description": "Game board.",
5
5
  "homepage": "https://dxos.org",
6
6
  "bugs": "https://github.com/dxos/dxos/issues",
@@ -27,34 +27,34 @@
27
27
  "@atlaskit/pragmatic-drag-and-drop": "^1.4.0",
28
28
  "@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.0.3",
29
29
  "@preact-signals/safe-react": "^0.9.0",
30
- "@preact/signals-core": "^1.9.0",
30
+ "@preact/signals-core": "^1.12.1",
31
31
  "@radix-ui/react-context": "1.1.1",
32
32
  "chess.js": "^1.4.0",
33
33
  "react-resize-detector": "^11.0.1",
34
- "@dxos/invariant": "0.8.4-main.fd6878d",
35
- "@dxos/debug": "0.8.4-main.fd6878d",
36
- "@dxos/log": "0.8.4-main.fd6878d",
37
- "@dxos/node-std": "0.8.4-main.fd6878d",
38
- "@dxos/util": "0.8.4-main.fd6878d"
34
+ "@dxos/invariant": "0.8.4-main.fffef41",
35
+ "@dxos/debug": "0.8.4-main.fffef41",
36
+ "@dxos/log": "0.8.4-main.fffef41",
37
+ "@dxos/node-std": "0.8.4-main.fffef41",
38
+ "@dxos/util": "0.8.4-main.fffef41"
39
39
  },
40
40
  "devDependencies": {
41
41
  "@svgr/cli": "^8.1.0",
42
42
  "@types/lodash.defaultsdeep": "^4.6.6",
43
- "@types/react": "~18.2.0",
44
- "@types/react-dom": "~18.2.0",
43
+ "@types/react": "~19.2.2",
44
+ "@types/react-dom": "~19.2.2",
45
45
  "lodash.defaultsdeep": "^4.6.1",
46
- "react": "~18.2.0",
47
- "react-dom": "~18.2.0",
48
- "vite": "5.4.7",
49
- "@dxos/react-ui": "0.8.4-main.fd6878d",
50
- "@dxos/react-ui-theme": "0.8.4-main.fd6878d",
51
- "@dxos/storybook-utils": "0.8.4-main.fd6878d"
46
+ "react": "~19.2.0",
47
+ "react-dom": "~19.2.0",
48
+ "vite": "7.1.9",
49
+ "@dxos/react-ui-theme": "0.8.4-main.fffef41",
50
+ "@dxos/react-ui": "0.8.4-main.fffef41",
51
+ "@dxos/storybook-utils": "0.8.4-main.fffef41"
52
52
  },
53
53
  "peerDependencies": {
54
- "react": "~18.2.0",
55
- "react-dom": "~18.2.0",
56
- "@dxos/react-ui": "0.8.4-main.fd6878d",
57
- "@dxos/react-ui-theme": "0.8.4-main.fd6878d"
54
+ "react": "^19.0.0",
55
+ "react-dom": "^19.0.0",
56
+ "@dxos/react-ui": "0.8.4-main.fffef41",
57
+ "@dxos/react-ui-theme": "0.8.4-main.fffef41"
58
58
  },
59
59
  "publishConfig": {
60
60
  "access": "public"
@@ -2,13 +2,11 @@
2
2
  // Copyright 2024 DXOS.org
3
3
  //
4
4
 
5
- import '@dxos-theme';
6
-
7
- import type { Meta, StoryObj } from '@storybook/react-vite';
5
+ import { type Meta, type StoryObj } from '@storybook/react-vite';
8
6
  import React, { useCallback, useEffect, useMemo, useState } from 'react';
9
7
 
10
8
  import { Button, Toolbar } from '@dxos/react-ui';
11
- import { withLayout, withTheme } from '@dxos/storybook-utils';
9
+ import { withLayout, withTheme } from '@dxos/react-ui/testing';
12
10
 
13
11
  import { Gameboard, type GameboardRootProps, type Move, type Player } from '../Gameboard';
14
12
 
@@ -64,7 +62,7 @@ const GridStory = () => {
64
62
  }, []);
65
63
 
66
64
  return (
67
- <div className='h-full aspect-square mx-auto'>
65
+ <div className='bs-full aspect-square mx-auto'>
68
66
  <div className='grid grid-cols-3 gap-2'>
69
67
  {models.map((model, i) => (
70
68
  <div key={i} className='aspect-square'>
@@ -78,34 +76,34 @@ const GridStory = () => {
78
76
  );
79
77
  };
80
78
 
81
- const meta: Meta<typeof DefaultStory> = {
79
+ const meta = {
82
80
  title: 'ui/react-ui-gameboard/Chessboard',
83
81
  component: Chessboard,
84
82
  render: DefaultStory,
85
- decorators: [withTheme, withLayout({ fullscreen: true, classNames: '' })],
86
- };
83
+ decorators: [withTheme, withLayout({ container: 'column' })],
84
+ } satisfies Meta<typeof Chessboard>;
87
85
 
88
86
  export default meta;
89
87
 
90
- type Story = StoryObj<typeof DefaultStory>;
88
+ type Story = StoryObj<typeof meta>;
91
89
 
92
- export const Default = {} satisfies Story;
90
+ export const Default: Story = {};
93
91
 
94
- export const Promotion = {
92
+ export const Promotion: Story = {
95
93
  args: {
96
94
  pgn: '1. e4 e5 2. Nf3 Nc6 3. Bc4 Bc5 4. c3 Nf6 5. d4 exd4 6. cxd4 Bb4+ 7. Nc3 d5 8. exd5 Nxd5 9. O-O Be6 10. Qb3 Na5 11. Qa4+ c6 12. Bxd5 Bxc3 13. Bxe6 fxe6 14. d5 Qg5 15. dxe6 Kf8 16. e7+ Kg8 *',
97
95
  },
98
- } satisfies Story;
96
+ };
99
97
 
100
- export const Debug = {
98
+ export const Debug: Story = {
101
99
  args: {
102
100
  pgn: '1. e4 e5 2. Nf3 Nc6 3. Bc4 Bc5 4. c3 Nf6 5. d4 exd4 6. cxd4 Bb4+ 7. Nc3 d5 8. exd5 Nxd5 9. O-O Be6 10. Qb3 Na5 11. Qa4+ c6 12. Bxd5 Bxc3 13. Bxe6 fxe6 *',
103
101
  orientation: 'black',
104
102
  showLabels: true,
105
103
  debug: true,
106
104
  },
107
- } satisfies Story;
105
+ };
108
106
 
109
107
  export const Grid = {
110
108
  render: GridStory,
111
- } satisfies Story;
109
+ };
@@ -2,12 +2,12 @@
2
2
  // Copyright 2025 DXOS.org
3
3
  //
4
4
 
5
- import React, { Fragment, type PropsWithChildren, memo, useEffect, useMemo, useRef, useState } from 'react';
5
+ import React, { type PropsWithChildren, forwardRef, memo, useEffect, useMemo, useRef, useState } from 'react';
6
6
  import { useResizeDetector } from 'react-resize-detector';
7
7
 
8
- import { type ThemedClassName, useTrackProps } from '@dxos/react-ui';
8
+ import { type ThemedClassName, useForwardedRef } from '@dxos/react-ui';
9
9
  import { mx } from '@dxos/react-ui-theme';
10
- import { isNotFalsy } from '@dxos/util';
10
+ import { isNonNullable } from '@dxos/util';
11
11
 
12
12
  import {
13
13
  type DOMRectBounds,
@@ -35,13 +35,14 @@ export type ChessboardProps = ThemedClassName<
35
35
  /**
36
36
  * Chessboard layout.
37
37
  */
38
- export const Chessboard = memo(
39
- ({ orientation, showLabels, debug, rows = 8, cols = 8, classNames }: ChessboardProps) => {
40
- useTrackProps({ orientation, showLabels, debug }, Chessboard.displayName, false);
41
- const { ref: containerRef, width, height } = useResizeDetector({ refreshRate: 200 });
38
+ const ChessboardComponent = forwardRef<HTMLDivElement, ChessboardProps>(
39
+ ({ classNames, orientation, showLabels, debug, rows = 8, cols = 8 }, forwardedRef) => {
40
+ const targetRef = useForwardedRef(forwardedRef);
41
+ const { width, height } = useResizeDetector({ targetRef, refreshRate: 200 });
42
42
  const { model, promoting, onPromotion } = useGameboardContext<ChessModel>(Chessboard.displayName!);
43
43
 
44
- const locations = useMemo<Location[]>(() => {
44
+ // Board squares.
45
+ const squares = useMemo<Location[]>(() => {
45
46
  return Array.from({ length: rows }, (_, i) => (orientation === 'black' ? i : rows - 1 - i)).flatMap((row) =>
46
47
  Array.from({ length: cols }).map((_, col) => [row, col] as Location),
47
48
  );
@@ -49,7 +50,7 @@ export const Chessboard = memo(
49
50
 
50
51
  // Use DOM grid layout to position squares.
51
52
  const layout = useMemo(() => {
52
- return locations.map((location) => {
53
+ return squares.map((location) => {
53
54
  return (
54
55
  <div
55
56
  key={locationToString(location)}
@@ -59,14 +60,14 @@ export const Chessboard = memo(
59
60
  />
60
61
  );
61
62
  });
62
- }, [locations]);
63
+ }, [squares]);
63
64
 
64
65
  // Build map of square locations to bounds.
65
66
  const [grid, setGrid] = useState<Record<string, DOMRectBounds>>({});
66
67
  const gridRef = useRef<HTMLDivElement>(null);
67
68
  useEffect(() => {
68
69
  setGrid(
69
- locations.reduce(
70
+ squares.reduce(
70
71
  (acc, location) => {
71
72
  const square = getSquareLocation(gridRef.current!, location)!;
72
73
  const bounds = getRelativeBounds(gridRef.current!, square);
@@ -75,7 +76,7 @@ export const Chessboard = memo(
75
76
  {} as Record<string, DOMRectBounds>,
76
77
  ),
77
78
  );
78
- }, [locations, width, height]);
79
+ }, [squares, width, height]);
79
80
 
80
81
  // Get the bounds of each square and piece.
81
82
  const positions = useMemo<{ piece: PieceRecord; bounds: DOMRectBounds }[]>(() => {
@@ -92,16 +93,18 @@ export const Chessboard = memo(
92
93
  const bounds = grid[locationToString(piece.location)];
93
94
  return { piece, bounds };
94
95
  })
95
- .filter(isNotFalsy);
96
+ .filter(isNonNullable);
96
97
  }, [grid, model?.pieces.value, promoting]);
97
98
 
98
99
  return (
99
- <div ref={containerRef} className={mx('relative', classNames)}>
100
+ <div ref={targetRef} tabIndex={0} className={mx('relative outline-none', classNames)}>
101
+ {/* DOM Layout. */}
100
102
  <div ref={gridRef} className='grid grid-rows-8 grid-cols-8 aspect-square select-none'>
101
103
  {layout}
102
104
  </div>
105
+ {/* Squares. */}
103
106
  <div>
104
- {locations.map((location) => (
107
+ {squares.map((location) => (
105
108
  <Gameboard.Square
106
109
  key={locationToString(location)}
107
110
  location={location}
@@ -111,6 +114,7 @@ export const Chessboard = memo(
111
114
  />
112
115
  ))}
113
116
  </div>
117
+ {/* Pieces. */}
114
118
  <div className={mx(promoting && 'opacity-50')}>
115
119
  {positions.map(({ bounds, piece }) => (
116
120
  <Gameboard.Piece
@@ -123,13 +127,14 @@ export const Chessboard = memo(
123
127
  />
124
128
  ))}
125
129
  </div>
130
+ {/* Promotion selector. */}
126
131
  {promoting && (
127
132
  <PromotionSelector
128
133
  grid={grid}
129
134
  piece={promoting}
130
135
  onSelect={(piece) => {
131
136
  onPromotion({
132
- from: Object.values(model!.pieces.value).find((p) => p.id === promoting.id)!.location,
137
+ from: Object.values(model.pieces.value).find((p) => p.id === promoting.id)!.location,
133
138
  to: piece.location,
134
139
  piece: promoting.type,
135
140
  promotion: piece.type,
@@ -142,11 +147,9 @@ export const Chessboard = memo(
142
147
  },
143
148
  );
144
149
 
145
- Chessboard.displayName = 'Chessboard';
150
+ ChessboardComponent.displayName = 'Chessboard';
146
151
 
147
- const getSquareLocation = (container: HTMLElement, location: Location): HTMLElement | null => {
148
- return container.querySelector(`[data-location="${locationToString(location)}"]`);
149
- };
152
+ export const Chessboard = memo(ChessboardComponent);
150
153
 
151
154
  const PromotionSelector = ({
152
155
  grid,
@@ -179,13 +182,17 @@ const PromotionSelector = ({
179
182
  {positions.map(({ piece, bounds }) => (
180
183
  <Gameboard.Piece
181
184
  key={piece.id}
185
+ classNames={mx('border-2 border-neutral-700 rounded-full', boardStyles.promotion)}
182
186
  piece={piece}
183
187
  bounds={bounds}
184
188
  Component={ChessPieces[piece.type as ChessPiece]}
185
- classNames={mx('border-2 border-neutral-700 rounded-full', boardStyles.promotion)}
186
189
  onClick={() => handleSelect(piece)}
187
190
  />
188
191
  ))}
189
192
  </>
190
193
  );
191
194
  };
195
+
196
+ const getSquareLocation = (container: HTMLElement, location: Location): HTMLElement | null => {
197
+ return container.querySelector(`[data-location="${locationToString(location)}"]`);
198
+ };
@@ -0,0 +1,19 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import { Chess as ChessJS } from 'chess.js';
6
+ import { describe, it } from 'vitest';
7
+
8
+ import { createPieceMap } from './chess';
9
+
10
+ describe('ChessModel', () => {
11
+ it('should update pieces', ({ expect }) => {
12
+ const chess = new ChessJS();
13
+ chess.loadPgn(
14
+ '1. e4 e5 2. Nf3 Nc6 3. Bc4 Bc5 4. c3 Nf6 5. d4 exd4 6. cxd4 Bb4+ 7. Nc3 d5 8. exd5 Nxd5 9. O-O Be6 10. Qb3 Na5 11. Qa4+ c6 12. Bxd5 Bxc3 13. Bxe6 fxe6 *',
15
+ );
16
+ const pieces = createPieceMap(chess);
17
+ expect(pieces).to.exist;
18
+ });
19
+ });
@@ -6,6 +6,7 @@ import { type ReadonlySignal, signal } from '@preact/signals-core';
6
6
  import { Chess as ChessJS } from 'chess.js';
7
7
  import { type FC, type SVGProps } from 'react';
8
8
 
9
+ import { invariant } from '@dxos/invariant';
9
10
  import { log } from '@dxos/log';
10
11
 
11
12
  import * as Alpha from '../../gen/pieces/chess/alpha';
@@ -33,6 +34,10 @@ export const locationToPos = ([row, col]: Location): string => {
33
34
  return String.fromCharCode(col + 'a'.charCodeAt(0)) + (row + 1);
34
35
  };
35
36
 
37
+ export const getRawPgn = (pgn: string) => {
38
+ return pgn.replace(/\[.*?\]/g, '').trim();
39
+ };
40
+
36
41
  const styles = {
37
42
  neutral: {
38
43
  black: 'bg-neutral-50',
@@ -62,7 +67,7 @@ export const getSquareColor = ([row, col]: Location) => {
62
67
  return (col + row) % 2 === 0 ? boardStyles.black : boardStyles.white;
63
68
  };
64
69
 
65
- export const createChess = (pgn?: string) => {
70
+ export const createChess = (pgn?: string): ChessJS => {
66
71
  const chess = new ChessJS();
67
72
  if (pgn) {
68
73
  try {
@@ -75,43 +80,40 @@ export const createChess = (pgn?: string) => {
75
80
  return chess;
76
81
  };
77
82
 
78
- /**
79
- * Attempt move.
80
- */
81
- const tryMove = (chess: ChessJS, move: Move): ChessJS | null => {
82
- const from = locationToPos(move.from);
83
- const to = locationToPos(move.to);
84
- try {
85
- const promotion = move.promotion ? move.promotion[1].toLowerCase() : 'q';
86
- chess.move({ from, to, promotion }, { strict: false });
87
- return chess;
88
- } catch {
89
- // Ignore.
90
- return null;
91
- }
92
- };
93
-
94
83
  /**
95
84
  * Chess model.
96
85
  */
97
86
  export class ChessModel implements GameboardModel<ChessPiece> {
98
87
  private readonly _chess = new ChessJS();
99
88
  private readonly _pieces = signal<PieceMap<ChessPiece>>({});
89
+ private readonly _moveIndex = signal(0);
100
90
 
101
91
  constructor(pgn?: string) {
102
92
  this.update(pgn);
103
93
  }
104
94
 
95
+ get readonly(): boolean {
96
+ return this._moveIndex.value !== this._chess.history().length;
97
+ }
98
+
105
99
  get turn(): Player {
106
100
  return this._chess.turn() === 'w' ? 'white' : 'black';
107
101
  }
108
102
 
103
+ get game(): ChessJS {
104
+ return this._chess;
105
+ }
106
+
109
107
  get pieces(): ReadonlySignal<PieceMap<ChessPiece>> {
110
108
  return this._pieces;
111
109
  }
112
110
 
113
- get game(): ChessJS {
114
- return this._chess;
111
+ get moveIndex(): ReadonlySignal<number> {
112
+ return this._moveIndex;
113
+ }
114
+
115
+ get fen() {
116
+ return this._chess.fen();
115
117
  }
116
118
 
117
119
  /**
@@ -127,11 +129,16 @@ export class ChessModel implements GameboardModel<ChessPiece> {
127
129
  */
128
130
  // TODO(burdon): Update headers.
129
131
  get pgn(): string {
130
- return this._chess.pgn();
132
+ return getRawPgn(this._chess.pgn());
131
133
  }
132
134
 
133
- get fen(): string {
134
- return this._chess.fen();
135
+ setMoveIndex(index: number) {
136
+ const temp = new ChessJS();
137
+ const history = this._chess.history({ verbose: true });
138
+ for (let i = 0; i < index && i < history.length; i++) {
139
+ temp.move(history[i]);
140
+ }
141
+ this._updateBoard(temp);
135
142
  }
136
143
 
137
144
  update(pgn = ''): void {
@@ -154,7 +161,7 @@ export class ChessModel implements GameboardModel<ChessPiece> {
154
161
  this._pieces.value = {};
155
162
  }
156
163
 
157
- this._update();
164
+ this._updateBoard(this._chess);
158
165
  }
159
166
 
160
167
  isValidMove(move: Move): boolean {
@@ -173,7 +180,7 @@ export class ChessModel implements GameboardModel<ChessPiece> {
173
180
  return false;
174
181
  }
175
182
 
176
- this._update();
183
+ this._updateBoard(this._chess);
177
184
  return true;
178
185
  }
179
186
 
@@ -185,37 +192,35 @@ export class ChessModel implements GameboardModel<ChessPiece> {
185
192
 
186
193
  const move = moves[Math.floor(Math.random() * moves.length)];
187
194
  this._chess.move(move);
188
- this._update();
195
+
196
+ this._updateBoard(this._chess);
189
197
  return true;
190
198
  }
191
199
 
192
200
  /**
193
201
  * Update pieces preserving identity.
194
202
  */
195
- private _update(): void {
196
- const pieces: PieceMap<ChessPiece> = {};
197
- this._chess.board().flatMap((row) =>
198
- row.forEach((record) => {
199
- if (!record) {
200
- return;
201
- }
202
-
203
- const { square, type, color } = record;
204
- const pieceType = `${color.toUpperCase()}${type.toUpperCase()}` as ChessPiece;
205
- const location = posToLocation(square);
206
- pieces[locationToString(location)] = {
207
- id: `${square}-${pieceType}`,
208
- type: pieceType,
209
- side: color === 'w' ? 'white' : 'black',
210
- location,
211
- };
212
- }),
213
- );
214
-
215
- this._pieces.value = mapPieces(this._pieces.value, pieces);
203
+ private _updateBoard(chess: ChessJS): void {
204
+ this._pieces.value = createPieceMap(chess);
205
+ this._moveIndex.value = chess.history().length;
216
206
  }
217
207
  }
218
208
 
209
+ /**
210
+ * Attempt move.
211
+ */
212
+ const tryMove = (chess: ChessJS, move: Move): ChessJS | null => {
213
+ const from = locationToPos(move.from);
214
+ const to = locationToPos(move.to);
215
+ try {
216
+ const promotion = move.promotion ? move.promotion[1].toLowerCase() : 'q';
217
+ chess.move({ from, to, promotion }, { strict: false });
218
+ return chess;
219
+ } catch {
220
+ return null;
221
+ }
222
+ };
223
+
219
224
  const isValidNextMove = (previous: string[], current: string[]) => {
220
225
  if (current.length > previous.length + 1) {
221
226
  return false;
@@ -230,21 +235,71 @@ const isValidNextMove = (previous: string[], current: string[]) => {
230
235
  return true;
231
236
  };
232
237
 
238
+ /**
239
+ * Starting from a new game, assign piece IDs based on their starting position.
240
+ * Then iterate through the history of the provided game and update the piece map.
241
+ */
242
+ export const createPieceMap = (chess: ChessJS): PieceMap<ChessPiece> => {
243
+ const temp = new ChessJS();
244
+ let pieces = _createPieceMap(temp);
245
+ const history = chess.history({ verbose: true });
246
+ for (let i = 0; i < history.length; i++) {
247
+ const move = history[i];
248
+ temp.move(move);
249
+ pieces = _diffPieces(pieces, _createPieceMap(temp));
250
+ const test = new Set();
251
+ Object.values(pieces).forEach((piece) => {
252
+ invariant(!test.has(piece.id), 'Duplicate: ' + piece.id);
253
+ test.add(piece.id);
254
+ });
255
+ }
256
+
257
+ return pieces;
258
+ };
259
+
260
+ /**
261
+ * Create a map of pieces from the board positions; assign each piece the ID of the current square.
262
+ */
263
+ const _createPieceMap = (chess: ChessJS): PieceMap<ChessPiece> => {
264
+ const pieces: PieceMap<ChessPiece> = {};
265
+ chess.board().flatMap((row) =>
266
+ row.forEach((record) => {
267
+ if (!record) {
268
+ return;
269
+ }
270
+
271
+ const { square, type, color } = record;
272
+ const pieceType = `${color.toUpperCase()}${type.toUpperCase()}` as ChessPiece;
273
+ const location = posToLocation(square);
274
+ pieces[locationToString(location)] = {
275
+ id: `${square}-${pieceType}`,
276
+ type: pieceType,
277
+ side: color === 'w' ? 'white' : 'black',
278
+ location,
279
+ };
280
+ }),
281
+ );
282
+
283
+ return pieces;
284
+ };
285
+
233
286
  /**
234
287
  * Preserve the original piece objects (and IDs).
235
288
  */
236
- export const mapPieces = <T extends PieceType>(before: PieceMap<T>, after: PieceMap<T>): PieceMap<T> => {
289
+ const _diffPieces = <T extends PieceType>(before: PieceMap<T>, after: PieceMap<T>): PieceMap<T> => {
237
290
  const difference: { added: PieceMap; removed: PieceMap } = {
238
291
  removed: {},
239
292
  added: {},
240
293
  };
241
294
 
295
+ // Removed.
242
296
  (Object.keys(before) as Array<keyof typeof before>).forEach((square) => {
243
297
  if (after[square]?.type !== before[square]?.type) {
244
298
  difference.removed[square] = before[square];
245
299
  }
246
300
  });
247
301
 
302
+ // Added.
248
303
  (Object.keys(after) as Array<keyof typeof after>).forEach((square) => {
249
304
  if (before[square]?.type !== after[square]?.type) {
250
305
  difference.added[square] = after[square];
@@ -253,6 +308,7 @@ export const mapPieces = <T extends PieceType>(before: PieceMap<T>, after: Piece
253
308
  }
254
309
  });
255
310
 
311
+ // Preserve IDs.
256
312
  for (const piece of Object.values(difference.added)) {
257
313
  const previous = Object.values(difference.removed).find((p) => p.type === piece.type);
258
314
  if (previous) {
@@ -260,13 +316,6 @@ export const mapPieces = <T extends PieceType>(before: PieceMap<T>, after: Piece
260
316
  }
261
317
  }
262
318
 
263
- log('delta', {
264
- before: Object.keys(before).length,
265
- after: Object.keys(after).length,
266
- removed: Object.keys(difference.removed).length,
267
- added: Object.keys(difference.added).length,
268
- });
269
-
270
319
  return after;
271
320
  };
272
321
 
@@ -33,13 +33,14 @@ const useGameboardContext = <M extends GameboardModel>(consumerName: string): Ga
33
33
 
34
34
  type GameboardRootProps<M extends GameboardModel> = PropsWithChildren<{
35
35
  model?: M;
36
+ moveNumber?: number;
36
37
  onDrop?: (move: Move) => boolean;
37
38
  }>;
38
39
 
39
40
  /**
40
41
  * Generic board container.
41
42
  */
42
- const GameboardRoot = <M extends GameboardModel>({ children, model, onDrop }: GameboardRootProps<M>) => {
43
+ const GameboardRoot = <M extends GameboardModel>({ children, model, moveNumber, onDrop }: GameboardRootProps<M>) => {
43
44
  const [dragging, setDragging] = useState(false);
44
45
  const [promoting, setPromoting] = useState<PieceRecord | undefined>();
45
46
 
@@ -10,7 +10,7 @@ import { createPortal } from 'react-dom';
10
10
 
11
11
  import { invariant } from '@dxos/invariant';
12
12
  import { log } from '@dxos/log';
13
- import { type ThemedClassName, useDynamicRef, useTrackProps } from '@dxos/react-ui';
13
+ import { type ThemedClassName, useDynamicRef } from '@dxos/react-ui';
14
14
  import { mx } from '@dxos/react-ui-theme';
15
15
 
16
16
  import { useGameboardContext } from './Gameboard';
@@ -26,8 +26,7 @@ export type PieceProps = ThemedClassName<{
26
26
  onClick?: () => void;
27
27
  }>;
28
28
 
29
- export const Piece = memo(({ classNames, Component, piece, orientation, bounds, label, onClick }: PieceProps) => {
30
- useTrackProps({ classNames, Component, piece, orientation, bounds, label }, Piece.displayName, false);
29
+ export const Piece = memo(({ classNames, Component, piece, bounds, label, onClick }: PieceProps) => {
31
30
  const { model, dragging: isDragging, promoting } = useGameboardContext(Piece.displayName!);
32
31
  const promotingRef = useDynamicRef(promoting);
33
32
  const [dragging, setDragging] = useState(false);
@@ -38,6 +37,10 @@ export const Piece = memo(({ classNames, Component, piece, orientation, bounds,
38
37
 
39
38
  const ref = useRef<HTMLDivElement>(null);
40
39
  useEffect(() => {
40
+ if (!model) {
41
+ return;
42
+ }
43
+
41
44
  const el = ref.current;
42
45
  invariant(el);
43
46
 
@@ -60,7 +63,7 @@ export const Piece = memo(({ classNames, Component, piece, orientation, bounds,
60
63
  nativeSetDragImage,
61
64
  });
62
65
  },
63
- canDrag: () => !promotingRef.current && model?.turn === piece.side,
66
+ canDrag: () => !promotingRef.current && !model.readonly && model.turn === piece.side,
64
67
  onDragStart: () => setDragging(true),
65
68
  onDrop: ({ location: { current } }) => {
66
69
  // TODO(burdon): Create wrapper function to catch errors.
@@ -87,7 +90,7 @@ export const Piece = memo(({ classNames, Component, piece, orientation, bounds,
87
90
 
88
91
  // Check if piece moved.
89
92
  if (!current.location || !isEqualLocation(current.location, piece.location)) {
90
- ref.current.style.transition = 'top 400ms ease-out, left 400ms ease-out';
93
+ ref.current.style.transition = 'top 250ms ease-out, left 250ms ease-out';
91
94
  ref.current.style.top = bounds.top + 'px';
92
95
  ref.current.style.left = bounds.left + 'px';
93
96
  setCurrent({ location: piece.location, bounds });
@@ -52,6 +52,7 @@ export const isEqualLocation = (l1: Location, l2: Location): boolean => l1[0] ==
52
52
  * Generic board model.
53
53
  */
54
54
  export interface GameboardModel<T extends PieceType = PieceType> {
55
+ readonly: boolean;
55
56
  turn: Player;
56
57
  /** @reactive */
57
58
  pieces: ReadonlySignal<PieceMap<T>>;