@dxos/react-ui-gameboard 0.8.3 → 0.8.4-main.28f8d3d

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 (70) hide show
  1. package/dist/lib/browser/index.mjs +407 -381
  2. package/dist/lib/browser/index.mjs.map +4 -4
  3. package/dist/lib/browser/meta.json +1 -1
  4. package/dist/lib/node-esm/index.mjs +407 -381
  5. package/dist/lib/node-esm/index.mjs.map +4 -4
  6. package/dist/lib/node-esm/meta.json +1 -1
  7. package/dist/types/src/components/Chessboard/Chessboard.d.ts +15 -0
  8. package/dist/types/src/components/Chessboard/Chessboard.d.ts.map +1 -0
  9. package/dist/types/src/components/Chessboard/Chessboard.stories.d.ts +28 -0
  10. package/dist/types/src/components/Chessboard/Chessboard.stories.d.ts.map +1 -0
  11. package/dist/types/src/{Chessboard → components/Chessboard}/chess.d.ts +20 -7
  12. package/dist/types/src/components/Chessboard/chess.d.ts.map +1 -0
  13. package/dist/types/src/components/Chessboard/index.d.ts.map +1 -0
  14. package/dist/types/src/components/Gameboard/Gameboard.d.ts +37 -0
  15. package/dist/types/src/components/Gameboard/Gameboard.d.ts.map +1 -0
  16. package/dist/types/src/{Board → components/Gameboard}/Piece.d.ts +3 -2
  17. package/dist/types/src/components/Gameboard/Piece.d.ts.map +1 -0
  18. package/dist/types/src/components/Gameboard/Square.d.ts.map +1 -0
  19. package/dist/types/src/components/Gameboard/index.d.ts +4 -0
  20. package/dist/types/src/components/Gameboard/index.d.ts.map +1 -0
  21. package/dist/types/src/{Board → components/Gameboard}/types.d.ts +2 -1
  22. package/dist/types/src/components/Gameboard/types.d.ts.map +1 -0
  23. package/dist/types/src/components/Gameboard/util.d.ts.map +1 -0
  24. package/dist/types/src/components/index.d.ts +3 -0
  25. package/dist/types/src/components/index.d.ts.map +1 -0
  26. package/dist/types/src/index.d.ts +1 -2
  27. package/dist/types/src/index.d.ts.map +1 -1
  28. package/dist/types/tsconfig.tsbuildinfo +1 -1
  29. package/package.json +19 -14
  30. package/src/{Chessboard → components/Chessboard}/Chessboard.stories.tsx +33 -30
  31. package/src/components/Chessboard/Chessboard.tsx +191 -0
  32. package/src/{Chessboard → components/Chessboard}/chess.ts +88 -28
  33. package/src/components/Gameboard/Gameboard.tsx +139 -0
  34. package/src/{Board → components/Gameboard}/Piece.tsx +19 -20
  35. package/src/{Board → components/Gameboard}/Square.tsx +4 -4
  36. package/src/components/Gameboard/index.ts +8 -0
  37. package/src/{Board → components/Gameboard}/types.ts +3 -1
  38. package/src/components/index.ts +6 -0
  39. package/src/index.ts +1 -2
  40. package/dist/lib/node/index.cjs +0 -1039
  41. package/dist/lib/node/index.cjs.map +0 -7
  42. package/dist/lib/node/meta.json +0 -1
  43. package/dist/types/src/Board/Board.d.ts +0 -15
  44. package/dist/types/src/Board/Board.d.ts.map +0 -1
  45. package/dist/types/src/Board/Container.d.ts +0 -14
  46. package/dist/types/src/Board/Container.d.ts.map +0 -1
  47. package/dist/types/src/Board/Piece.d.ts.map +0 -1
  48. package/dist/types/src/Board/Square.d.ts.map +0 -1
  49. package/dist/types/src/Board/context.d.ts +0 -10
  50. package/dist/types/src/Board/context.d.ts.map +0 -1
  51. package/dist/types/src/Board/index.d.ts +0 -8
  52. package/dist/types/src/Board/index.d.ts.map +0 -1
  53. package/dist/types/src/Board/types.d.ts.map +0 -1
  54. package/dist/types/src/Board/util.d.ts.map +0 -1
  55. package/dist/types/src/Chessboard/Chessboard.d.ts +0 -14
  56. package/dist/types/src/Chessboard/Chessboard.d.ts.map +0 -1
  57. package/dist/types/src/Chessboard/Chessboard.stories.d.ts +0 -16
  58. package/dist/types/src/Chessboard/Chessboard.stories.d.ts.map +0 -1
  59. package/dist/types/src/Chessboard/chess.d.ts.map +0 -1
  60. package/dist/types/src/Chessboard/index.d.ts.map +0 -1
  61. package/src/Board/Board.tsx +0 -86
  62. package/src/Board/Container.tsx +0 -25
  63. package/src/Board/context.ts +0 -22
  64. package/src/Board/index.ts +0 -12
  65. package/src/Chessboard/Chessboard.tsx +0 -190
  66. /package/dist/types/src/{Chessboard → components/Chessboard}/index.d.ts +0 -0
  67. /package/dist/types/src/{Board → components/Gameboard}/Square.d.ts +0 -0
  68. /package/dist/types/src/{Board → components/Gameboard}/util.d.ts +0 -0
  69. /package/src/{Chessboard → components/Chessboard}/index.ts +0 -0
  70. /package/src/{Board → components/Gameboard}/util.ts +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dxos/react-ui-gameboard",
3
- "version": "0.8.3",
3
+ "version": "0.8.4-main.28f8d3d",
4
4
  "description": "Game board.",
5
5
  "homepage": "https://dxos.org",
6
6
  "bugs": "https://github.com/dxos/dxos/issues",
@@ -9,12 +9,16 @@
9
9
  "type": "module",
10
10
  "exports": {
11
11
  ".": {
12
+ "source": "./src/index.ts",
12
13
  "types": "./dist/types/src/index.d.ts",
13
14
  "browser": "./dist/lib/browser/index.mjs",
14
15
  "node": "./dist/lib/node-esm/index.mjs"
15
16
  }
16
17
  },
17
18
  "types": "dist/types/src/index.d.ts",
19
+ "typesVersions": {
20
+ "*": {}
21
+ },
18
22
  "files": [
19
23
  "dist",
20
24
  "src"
@@ -24,13 +28,14 @@
24
28
  "@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.0.3",
25
29
  "@preact-signals/safe-react": "^0.9.0",
26
30
  "@preact/signals-core": "^1.9.0",
27
- "chess.js": "^1.0.0",
31
+ "@radix-ui/react-context": "1.1.1",
32
+ "chess.js": "^1.4.0",
28
33
  "react-resize-detector": "^11.0.1",
29
- "@dxos/debug": "0.8.3",
30
- "@dxos/invariant": "0.8.3",
31
- "@dxos/log": "0.8.3",
32
- "@dxos/node-std": "0.8.3",
33
- "@dxos/util": "0.8.3"
34
+ "@dxos/debug": "0.8.4-main.28f8d3d",
35
+ "@dxos/invariant": "0.8.4-main.28f8d3d",
36
+ "@dxos/node-std": "0.8.4-main.28f8d3d",
37
+ "@dxos/log": "0.8.4-main.28f8d3d",
38
+ "@dxos/util": "0.8.4-main.28f8d3d"
34
39
  },
35
40
  "devDependencies": {
36
41
  "@svgr/cli": "^8.1.0",
@@ -41,15 +46,15 @@
41
46
  "react": "~18.2.0",
42
47
  "react-dom": "~18.2.0",
43
48
  "vite": "5.4.7",
44
- "@dxos/react-ui": "0.8.3",
45
- "@dxos/storybook-utils": "0.8.3",
46
- "@dxos/react-ui-theme": "0.8.3"
49
+ "@dxos/react-ui": "0.8.4-main.28f8d3d",
50
+ "@dxos/storybook-utils": "0.8.4-main.28f8d3d",
51
+ "@dxos/react-ui-theme": "0.8.4-main.28f8d3d"
47
52
  },
48
53
  "peerDependencies": {
49
54
  "react": "~18.2.0",
50
55
  "react-dom": "~18.2.0",
51
- "@dxos/react-ui": "0.8.3",
52
- "@dxos/react-ui-theme": "0.8.3"
56
+ "@dxos/react-ui": "0.8.4-main.28f8d3d",
57
+ "@dxos/react-ui-theme": "0.8.4-main.28f8d3d"
53
58
  },
54
59
  "publishConfig": {
55
60
  "access": "public"
@@ -57,7 +62,7 @@
57
62
  "scripts": {
58
63
  "gen:pieces": "pnpm gen:pieces:chess",
59
64
  "gen:pieces:chess": "pnpm gen:pieces:chess:alpha && pnpm gen:pieces:chess:cburnett",
60
- "gen:pieces:chess:alpha": "pnpm svgr --typescript --filename-case=camel --out-dir ./src/gen/pieces/chess/alpha ./assets/pieces/chess/alpha",
61
- "gen:pieces:chess:cburnett": "pnpm svgr --typescript --filename-case=camel --out-dir ./src/gen/pieces/chess/cburnett ./assets/pieces/chess/cburnett"
65
+ "gen:pieces:chess:alpha": "pnpm svgr --typescript --filename-case=camel --out-dir ./src/gen/pieces/chess/alpha ./assets/pieces/chess/alpha > /dev/null",
66
+ "gen:pieces:chess:cburnett": "pnpm svgr --typescript --filename-case=camel --out-dir ./src/gen/pieces/chess/cburnett ./assets/pieces/chess/cburnett > /dev/null"
62
67
  }
63
68
  }
@@ -4,29 +4,30 @@
4
4
 
5
5
  import '@dxos-theme';
6
6
 
7
- import type { Meta, StoryObj } from '@storybook/react';
7
+ import type { Meta, StoryObj } from '@storybook/react-vite';
8
8
  import React, { useCallback, useEffect, useMemo, useState } from 'react';
9
9
 
10
- import { log } from '@dxos/log';
11
10
  import { Button, Toolbar } from '@dxos/react-ui';
12
11
  import { withLayout, withTheme } from '@dxos/storybook-utils';
13
12
 
14
- import { Chessboard, type ChessboardProps } from './Chessboard';
13
+ import { Gameboard, type GameboardRootProps, type Move, type Player } from '../Gameboard';
14
+
15
15
  import { ChessModel } from './chess';
16
- import { Board, type BoardRootProps, type Player, type Move } from '../Board';
16
+ import { Chessboard, type ChessboardProps } from './Chessboard';
17
17
 
18
- type RenderProps = Pick<ChessboardProps, 'orientation' | 'showLabels' | 'debug'> & {
19
- fen: string;
18
+ type DefaultStoryProps = Pick<ChessboardProps, 'orientation' | 'showLabels' | 'debug'> & {
19
+ pgn?: string;
20
20
  };
21
21
 
22
- const DefaultStory = ({ fen, orientation: _orientation, ...props }: RenderProps) => {
23
- const model = useMemo(() => new ChessModel(fen), [fen]);
22
+ const DefaultStory = ({ orientation: _orientation, pgn, ...props }: DefaultStoryProps) => {
23
+ const model = useMemo(() => new ChessModel(pgn), [pgn]);
24
24
  const [orientation, setOrientation] = useState<Player | undefined>(_orientation);
25
25
 
26
- const handleDrop = useCallback<NonNullable<BoardRootProps['onDrop']>>(
26
+ const handleDrop = useCallback<NonNullable<GameboardRootProps<ChessModel>['onDrop']>>(
27
27
  (move: Move) => {
28
- log.info('handleDrop', { move });
29
- return model.makeMove(move);
28
+ const result = model.makeMove(move);
29
+ console.log(model.pgn);
30
+ return result;
30
31
  },
31
32
  [model],
32
33
  );
@@ -34,7 +35,7 @@ const DefaultStory = ({ fen, orientation: _orientation, ...props }: RenderProps)
34
35
  return (
35
36
  <div className='flex flex-col grow gap-2 overflow-hidden'>
36
37
  <Toolbar.Root>
37
- <Button onClick={() => model.initialize()}>Reset</Button>
38
+ <Button onClick={() => model.update()}>Reset</Button>
38
39
  <Button onClick={() => model.makeRandomMove()}>Move</Button>
39
40
  <div className='grow'></div>
40
41
  <Button
@@ -43,14 +44,16 @@ const DefaultStory = ({ fen, orientation: _orientation, ...props }: RenderProps)
43
44
  Toggle
44
45
  </Button>
45
46
  </Toolbar.Root>
46
- <Board.Root model={model} onDrop={handleDrop}>
47
- <Chessboard orientation={orientation} {...props} />
48
- </Board.Root>
47
+ <Gameboard.Root model={model} onDrop={handleDrop}>
48
+ <Gameboard.Content grow contain>
49
+ <Chessboard orientation={orientation} {...props} />
50
+ </Gameboard.Content>
51
+ </Gameboard.Root>
49
52
  </div>
50
53
  );
51
54
  };
52
55
 
53
- const Grid = (props: RenderProps) => {
56
+ const GridStory = () => {
54
57
  const models = useMemo(() => Array.from({ length: 9 }).map(() => new ChessModel()), []);
55
58
  useEffect(() => {
56
59
  const i = setInterval(() => {
@@ -65,9 +68,9 @@ const Grid = (props: RenderProps) => {
65
68
  <div className='grid grid-cols-3 gap-2'>
66
69
  {models.map((model, i) => (
67
70
  <div key={i} className='aspect-square'>
68
- <Board.Root model={model}>
71
+ <Gameboard.Root model={model}>
69
72
  <Chessboard />
70
- </Board.Root>
73
+ </Gameboard.Root>
71
74
  </div>
72
75
  ))}
73
76
  </div>
@@ -86,23 +89,23 @@ export default meta;
86
89
 
87
90
  type Story = StoryObj<typeof DefaultStory>;
88
91
 
89
- export const Default: Story = {};
92
+ export const Default = {} satisfies Story;
90
93
 
91
- export const Promotion: Story = {
94
+ export const Promotion = {
92
95
  args: {
93
- fen: '4k3/7P/8/8/8/8/1p6/4K3 w - - 0 1',
96
+ 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 *',
94
97
  },
95
- };
98
+ } satisfies Story;
96
99
 
97
- export const Debug: Story = {
100
+ export const Debug = {
98
101
  args: {
99
- debug: true,
100
- showLabels: true,
102
+ 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 *',
101
103
  orientation: 'black',
102
- fen: 'q3k1nr/1pp1nQpp/3p4/1P2p3/4P3/B1PP1b2/B5PP/5K2 b k - 0 17',
104
+ showLabels: true,
105
+ debug: true,
103
106
  },
104
- };
107
+ } satisfies Story;
105
108
 
106
- export const Nine: Story = {
107
- render: Grid,
108
- };
109
+ export const Grid = {
110
+ render: GridStory,
111
+ } satisfies Story;
@@ -0,0 +1,191 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import React, { Fragment, type PropsWithChildren, memo, useEffect, useMemo, useRef, useState } from 'react';
6
+ import { useResizeDetector } from 'react-resize-detector';
7
+
8
+ import { type ThemedClassName, useTrackProps } from '@dxos/react-ui';
9
+ import { mx } from '@dxos/react-ui-theme';
10
+ import { isNotFalsy } from '@dxos/util';
11
+
12
+ import {
13
+ type DOMRectBounds,
14
+ Gameboard,
15
+ type Location,
16
+ type PieceRecord,
17
+ type Player,
18
+ getRelativeBounds,
19
+ locationToString,
20
+ useGameboardContext,
21
+ } from '../Gameboard';
22
+
23
+ import { type ChessModel, type ChessPiece, ChessPieces, boardStyles, getSquareColor, locationToPos } from './chess';
24
+
25
+ export type ChessboardProps = ThemedClassName<
26
+ PropsWithChildren<{
27
+ orientation?: Player;
28
+ showLabels?: boolean;
29
+ debug?: boolean;
30
+ rows?: number;
31
+ cols?: number;
32
+ }>
33
+ >;
34
+
35
+ /**
36
+ * Chessboard layout.
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 });
42
+ const { model, promoting, onPromotion } = useGameboardContext<ChessModel>(Chessboard.displayName!);
43
+
44
+ const locations = useMemo<Location[]>(() => {
45
+ return Array.from({ length: rows }, (_, i) => (orientation === 'black' ? i : rows - 1 - i)).flatMap((row) =>
46
+ Array.from({ length: cols }).map((_, col) => [row, col] as Location),
47
+ );
48
+ }, [orientation, rows, cols]);
49
+
50
+ // Use DOM grid layout to position squares.
51
+ const layout = useMemo(() => {
52
+ return locations.map((location) => {
53
+ return (
54
+ <div
55
+ key={locationToString(location)}
56
+ {...{
57
+ ['data-location' as const]: locationToString(location),
58
+ }}
59
+ />
60
+ );
61
+ });
62
+ }, [locations]);
63
+
64
+ // Build map of square locations to bounds.
65
+ const [grid, setGrid] = useState<Record<string, DOMRectBounds>>({});
66
+ const gridRef = useRef<HTMLDivElement>(null);
67
+ useEffect(() => {
68
+ setGrid(
69
+ locations.reduce(
70
+ (acc, location) => {
71
+ const square = getSquareLocation(gridRef.current!, location)!;
72
+ const bounds = getRelativeBounds(gridRef.current!, square);
73
+ return { ...acc, [locationToString(location)]: bounds };
74
+ },
75
+ {} as Record<string, DOMRectBounds>,
76
+ ),
77
+ );
78
+ }, [locations, width, height]);
79
+
80
+ // Get the bounds of each square and piece.
81
+ const positions = useMemo<{ piece: PieceRecord; bounds: DOMRectBounds }[]>(() => {
82
+ if (!gridRef.current) {
83
+ return [];
84
+ }
85
+
86
+ return Object.values(model?.pieces.value ?? {})
87
+ .map((piece) => {
88
+ if (piece.id === promoting?.id) {
89
+ return null;
90
+ }
91
+
92
+ const bounds = grid[locationToString(piece.location)];
93
+ return { piece, bounds };
94
+ })
95
+ .filter(isNotFalsy);
96
+ }, [grid, model?.pieces.value, promoting]);
97
+
98
+ return (
99
+ <div ref={containerRef} className={mx('relative', classNames)}>
100
+ <div ref={gridRef} className='grid grid-rows-8 grid-cols-8 aspect-square select-none'>
101
+ {layout}
102
+ </div>
103
+ <div>
104
+ {locations.map((location) => (
105
+ <Gameboard.Square
106
+ key={locationToString(location)}
107
+ location={location}
108
+ label={showLabels ? locationToPos(location) : undefined}
109
+ bounds={grid[locationToString(location)]}
110
+ classNames={getSquareColor(location)}
111
+ />
112
+ ))}
113
+ </div>
114
+ <div className={mx(promoting && 'opacity-50')}>
115
+ {positions.map(({ bounds, piece }) => (
116
+ <Gameboard.Piece
117
+ key={piece.id}
118
+ piece={piece}
119
+ bounds={bounds}
120
+ label={debug ? piece.id : undefined}
121
+ orientation={orientation}
122
+ Component={ChessPieces[piece.type as ChessPiece]}
123
+ />
124
+ ))}
125
+ </div>
126
+ {promoting && (
127
+ <PromotionSelector
128
+ grid={grid}
129
+ piece={promoting}
130
+ onSelect={(piece) => {
131
+ onPromotion({
132
+ from: Object.values(model!.pieces.value).find((p) => p.id === promoting.id)!.location,
133
+ to: piece.location,
134
+ piece: promoting.type,
135
+ promotion: piece.type,
136
+ });
137
+ }}
138
+ />
139
+ )}
140
+ </div>
141
+ );
142
+ },
143
+ );
144
+
145
+ Chessboard.displayName = 'Chessboard';
146
+
147
+ const getSquareLocation = (container: HTMLElement, location: Location): HTMLElement | null => {
148
+ return container.querySelector(`[data-location="${locationToString(location)}"]`);
149
+ };
150
+
151
+ const PromotionSelector = ({
152
+ grid,
153
+ piece,
154
+ onSelect,
155
+ }: {
156
+ grid: Record<string, DOMRectBounds>;
157
+ piece: PieceRecord;
158
+ onSelect: (piece: PieceRecord) => void;
159
+ }) => {
160
+ const positions = ['Q', 'N', 'R', 'B'].map((pieceType, i) => {
161
+ const location = [piece.location[0] + (piece.location[0] === 0 ? i : -i), piece.location[1]] as Location;
162
+ return {
163
+ piece: {
164
+ id: `promotion-${pieceType}`,
165
+ type: (piece.side === 'black' ? 'B' : 'W') + pieceType,
166
+ side: piece.side,
167
+ location,
168
+ },
169
+ bounds: grid[locationToString(location)],
170
+ };
171
+ });
172
+
173
+ const handleSelect = (selected: PieceRecord) => {
174
+ onSelect({ ...piece, type: selected.type });
175
+ };
176
+
177
+ return (
178
+ <>
179
+ {positions.map(({ piece, bounds }) => (
180
+ <Gameboard.Piece
181
+ key={piece.id}
182
+ piece={piece}
183
+ bounds={bounds}
184
+ Component={ChessPieces[piece.type as ChessPiece]}
185
+ classNames={mx('border-2 border-neutral-700 rounded-full', boardStyles.promotion)}
186
+ onClick={() => handleSelect(piece)}
187
+ />
188
+ ))}
189
+ </>
190
+ );
191
+ };
@@ -3,21 +3,21 @@
3
3
  //
4
4
 
5
5
  import { type ReadonlySignal, signal } from '@preact/signals-core';
6
- import { Chess, validateFen } from 'chess.js';
6
+ import { Chess as ChessJS } from 'chess.js';
7
7
  import { type FC, type SVGProps } from 'react';
8
8
 
9
9
  import { log } from '@dxos/log';
10
10
 
11
+ import * as Alpha from '../../gen/pieces/chess/alpha';
11
12
  import {
12
- type Move,
13
+ type GameboardModel,
13
14
  type Location,
15
+ type Move,
14
16
  type PieceMap,
15
- locationToString,
16
17
  type PieceType,
17
- type BoardModel,
18
18
  type Player,
19
- } from '../Board';
20
- import * as Alpha from '../gen/pieces/chess/alpha';
19
+ locationToString,
20
+ } from '../Gameboard';
21
21
 
22
22
  export type ChessPiece = 'BK' | 'BQ' | 'BR' | 'BB' | 'BN' | 'BP' | 'WK' | 'WQ' | 'WR' | 'WB' | 'WN' | 'WP';
23
23
 
@@ -62,18 +62,30 @@ export const getSquareColor = ([row, col]: Location) => {
62
62
  return (col + row) % 2 === 0 ? boardStyles.black : boardStyles.white;
63
63
  };
64
64
 
65
+ export const createChess = (pgn?: string) => {
66
+ const chess = new ChessJS();
67
+ if (pgn) {
68
+ try {
69
+ chess.loadPgn(pgn);
70
+ } catch {
71
+ log.warn(pgn);
72
+ }
73
+ }
74
+
75
+ return chess;
76
+ };
77
+
65
78
  /**
66
79
  * Attempt move.
67
80
  */
68
- const makeMove = (game: Chess, move: Move): Chess | null => {
81
+ const tryMove = (chess: ChessJS, move: Move): ChessJS | null => {
69
82
  const from = locationToPos(move.from);
70
83
  const to = locationToPos(move.to);
71
84
  try {
72
- log('makeMove', { move });
73
85
  const promotion = move.promotion ? move.promotion[1].toLowerCase() : 'q';
74
- game.move({ from, to, promotion }, { strict: false });
75
- return game;
76
- } catch (err) {
86
+ chess.move({ from, to, promotion }, { strict: false });
87
+ return chess;
88
+ } catch {
77
89
  // Ignore.
78
90
  return null;
79
91
  }
@@ -82,38 +94,71 @@ const makeMove = (game: Chess, move: Move): Chess | null => {
82
94
  /**
83
95
  * Chess model.
84
96
  */
85
- export class ChessModel implements BoardModel<ChessPiece> {
86
- private _game!: Chess;
97
+ export class ChessModel implements GameboardModel<ChessPiece> {
98
+ private readonly _chess = new ChessJS();
87
99
  private readonly _pieces = signal<PieceMap<ChessPiece>>({});
88
100
 
89
- constructor(fen?: string) {
90
- this.initialize(fen);
101
+ constructor(pgn?: string) {
102
+ this.update(pgn);
91
103
  }
92
104
 
93
105
  get turn(): Player {
94
- return this._game.turn() === 'w' ? 'white' : 'black';
106
+ return this._chess.turn() === 'w' ? 'white' : 'black';
95
107
  }
96
108
 
97
109
  get pieces(): ReadonlySignal<PieceMap<ChessPiece>> {
98
110
  return this._pieces;
99
111
  }
100
112
 
101
- get game(): Chess {
102
- return this._game;
113
+ get game(): ChessJS {
114
+ return this._chess;
115
+ }
116
+
117
+ /**
118
+ * PGN with headers.
119
+ *
120
+ * [Event "?"]
121
+ * [Site "?"]
122
+ * [Date "2025.08.05"]
123
+ * [Round "?"]
124
+ * [White "?"]
125
+ * [Black "?"]
126
+ * [Result "*"]
127
+ */
128
+ // TODO(burdon): Update headers.
129
+ get pgn(): string {
130
+ return this._chess.pgn();
103
131
  }
104
132
 
105
133
  get fen(): string {
106
- return this._game.fen();
134
+ return this._chess.fen();
107
135
  }
108
136
 
109
- initialize(fen?: string): void {
110
- this._pieces.value = {};
111
- this._game = new Chess(fen ? (validateFen(fen).ok ? fen : undefined) : undefined);
137
+ update(pgn = ''): void {
138
+ const previous = this._chess.history();
139
+ try {
140
+ this._chess.loadPgn(pgn);
141
+ // TODO(burdon): Get from TS.
142
+ // TODO(burdon): Update if not set.
143
+ this._chess.setHeader('Date', createDate());
144
+ this._chess.setHeader('Site', 'dxos.org');
145
+ // TODO(burdon): Update player keys.
146
+ // this._chess.setHeader('White', 'White');
147
+ // this._chess.setHeader('Black', 'Black');
148
+ } catch {
149
+ // Ignore.
150
+ }
151
+
152
+ const current = this._chess.history();
153
+ if (!isValidNextMove(previous, current)) {
154
+ this._pieces.value = {};
155
+ }
156
+
112
157
  this._update();
113
158
  }
114
159
 
115
160
  isValidMove(move: Move): boolean {
116
- return makeMove(new Chess(this._game.fen()), move) !== null;
161
+ return tryMove(new ChessJS(this._chess.fen()), move) !== null;
117
162
  }
118
163
 
119
164
  canPromote(move: Move): boolean {
@@ -123,24 +168,23 @@ export class ChessModel implements BoardModel<ChessPiece> {
123
168
  }
124
169
 
125
170
  makeMove(move: Move): boolean {
126
- const game = makeMove(this._game, move);
171
+ const game = tryMove(this._chess, move);
127
172
  if (!game) {
128
173
  return false;
129
174
  }
130
175
 
131
- this._game = game;
132
176
  this._update();
133
177
  return true;
134
178
  }
135
179
 
136
180
  makeRandomMove(): boolean {
137
- const moves = this._game.moves();
181
+ const moves = this._chess.moves();
138
182
  if (!moves.length) {
139
183
  return false;
140
184
  }
141
185
 
142
186
  const move = moves[Math.floor(Math.random() * moves.length)];
143
- this._game.move(move);
187
+ this._chess.move(move);
144
188
  this._update();
145
189
  return true;
146
190
  }
@@ -150,7 +194,7 @@ export class ChessModel implements BoardModel<ChessPiece> {
150
194
  */
151
195
  private _update(): void {
152
196
  const pieces: PieceMap<ChessPiece> = {};
153
- this._game.board().flatMap((row) =>
197
+ this._chess.board().flatMap((row) =>
154
198
  row.forEach((record) => {
155
199
  if (!record) {
156
200
  return;
@@ -172,6 +216,20 @@ export class ChessModel implements BoardModel<ChessPiece> {
172
216
  }
173
217
  }
174
218
 
219
+ const isValidNextMove = (previous: string[], current: string[]) => {
220
+ if (current.length > previous.length + 1) {
221
+ return false;
222
+ }
223
+
224
+ for (let i = 0; i < previous.length; i++) {
225
+ if (previous[i] !== current[i]) {
226
+ return false;
227
+ }
228
+ }
229
+
230
+ return true;
231
+ };
232
+
175
233
  /**
176
234
  * Preserve the original piece objects (and IDs).
177
235
  */
@@ -211,3 +269,5 @@ export const mapPieces = <T extends PieceType>(before: PieceMap<T>, after: Piece
211
269
 
212
270
  return after;
213
271
  };
272
+
273
+ const createDate = (date = new Date()) => date.toISOString().slice(0, 10).replace(/-/g, '.'); // e.g., "2025.08.05"