@dxos/react-ui-gameboard 0.8.4-main.5acf9ea → 0.8.4-main.5ea62a8

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 (65) hide show
  1. package/dist/lib/browser/index.mjs +407 -322
  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 -322
  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.map +1 -0
  8. package/dist/types/src/components/Chessboard/Chessboard.stories.d.ts +22 -0
  9. package/dist/types/src/components/Chessboard/Chessboard.stories.d.ts.map +1 -0
  10. package/dist/types/src/{Chessboard → components/Chessboard}/chess.d.ts +27 -10
  11. package/dist/types/src/components/Chessboard/chess.d.ts.map +1 -0
  12. package/dist/types/src/components/Chessboard/chess.test.d.ts +2 -0
  13. package/dist/types/src/components/Chessboard/chess.test.d.ts.map +1 -0
  14. package/dist/types/src/components/Chessboard/index.d.ts.map +1 -0
  15. package/dist/types/src/components/Gameboard/Gameboard.d.ts +38 -0
  16. package/dist/types/src/components/Gameboard/Gameboard.d.ts.map +1 -0
  17. package/dist/types/src/{Gameboard → components/Gameboard}/Piece.d.ts +3 -2
  18. package/dist/types/src/components/Gameboard/Piece.d.ts.map +1 -0
  19. package/dist/types/src/components/Gameboard/Square.d.ts.map +1 -0
  20. package/dist/types/src/components/Gameboard/index.d.ts +4 -0
  21. package/dist/types/src/components/Gameboard/index.d.ts.map +1 -0
  22. package/dist/types/src/{Gameboard → components/Gameboard}/types.d.ts +2 -0
  23. package/dist/types/src/components/Gameboard/types.d.ts.map +1 -0
  24. package/dist/types/src/components/Gameboard/util.d.ts.map +1 -0
  25. package/dist/types/src/components/index.d.ts +3 -0
  26. package/dist/types/src/components/index.d.ts.map +1 -0
  27. package/dist/types/src/index.d.ts +1 -2
  28. package/dist/types/src/index.d.ts.map +1 -1
  29. package/dist/types/tsconfig.tsbuildinfo +1 -1
  30. package/package.json +16 -15
  31. package/src/{Chessboard → components/Chessboard}/Chessboard.stories.tsx +24 -23
  32. package/src/{Chessboard → components/Chessboard}/Chessboard.tsx +43 -41
  33. package/src/components/Chessboard/chess.test.ts +19 -0
  34. package/src/components/Chessboard/chess.ts +314 -0
  35. package/src/components/Gameboard/Gameboard.tsx +140 -0
  36. package/src/{Gameboard → components/Gameboard}/Piece.tsx +25 -22
  37. package/src/{Gameboard → components/Gameboard}/Square.tsx +4 -4
  38. package/src/{Gameboard → components/Gameboard}/index.ts +0 -3
  39. package/src/{Gameboard → components/Gameboard}/types.ts +3 -0
  40. package/src/components/index.ts +6 -0
  41. package/src/index.ts +1 -2
  42. package/dist/types/src/Chessboard/Chessboard.d.ts.map +0 -1
  43. package/dist/types/src/Chessboard/Chessboard.stories.d.ts +0 -16
  44. package/dist/types/src/Chessboard/Chessboard.stories.d.ts.map +0 -1
  45. package/dist/types/src/Chessboard/chess.d.ts.map +0 -1
  46. package/dist/types/src/Chessboard/index.d.ts.map +0 -1
  47. package/dist/types/src/Gameboard/Gameboard.d.ts +0 -23
  48. package/dist/types/src/Gameboard/Gameboard.d.ts.map +0 -1
  49. package/dist/types/src/Gameboard/Piece.d.ts.map +0 -1
  50. package/dist/types/src/Gameboard/Square.d.ts.map +0 -1
  51. package/dist/types/src/Gameboard/context.d.ts +0 -10
  52. package/dist/types/src/Gameboard/context.d.ts.map +0 -1
  53. package/dist/types/src/Gameboard/index.d.ts +0 -7
  54. package/dist/types/src/Gameboard/index.d.ts.map +0 -1
  55. package/dist/types/src/Gameboard/types.d.ts.map +0 -1
  56. package/dist/types/src/Gameboard/util.d.ts.map +0 -1
  57. package/src/Chessboard/chess.ts +0 -213
  58. package/src/Gameboard/Gameboard.tsx +0 -103
  59. package/src/Gameboard/context.ts +0 -22
  60. /package/dist/types/src/{Chessboard → components/Chessboard}/Chessboard.d.ts +0 -0
  61. /package/dist/types/src/{Chessboard → components/Chessboard}/index.d.ts +0 -0
  62. /package/dist/types/src/{Gameboard → components/Gameboard}/Square.d.ts +0 -0
  63. /package/dist/types/src/{Gameboard → components/Gameboard}/util.d.ts +0 -0
  64. /package/src/{Chessboard → components/Chessboard}/index.ts +0 -0
  65. /package/src/{Gameboard → 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.4-main.5acf9ea",
3
+ "version": "0.8.4-main.5ea62a8",
4
4
  "description": "Game board.",
5
5
  "homepage": "https://dxos.org",
6
6
  "bugs": "https://github.com/dxos/dxos/issues",
@@ -9,10 +9,10 @@
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
- "node": "./dist/lib/node-esm/index.mjs",
15
- "source": "./src/index.ts"
15
+ "node": "./dist/lib/node-esm/index.mjs"
16
16
  }
17
17
  },
18
18
  "types": "dist/types/src/index.d.ts",
@@ -28,13 +28,14 @@
28
28
  "@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.0.3",
29
29
  "@preact-signals/safe-react": "^0.9.0",
30
30
  "@preact/signals-core": "^1.9.0",
31
- "chess.js": "^1.0.0",
31
+ "@radix-ui/react-context": "1.1.1",
32
+ "chess.js": "^1.4.0",
32
33
  "react-resize-detector": "^11.0.1",
33
- "@dxos/debug": "0.8.4-main.5acf9ea",
34
- "@dxos/invariant": "0.8.4-main.5acf9ea",
35
- "@dxos/log": "0.8.4-main.5acf9ea",
36
- "@dxos/node-std": "0.8.4-main.5acf9ea",
37
- "@dxos/util": "0.8.4-main.5acf9ea"
34
+ "@dxos/debug": "0.8.4-main.5ea62a8",
35
+ "@dxos/invariant": "0.8.4-main.5ea62a8",
36
+ "@dxos/log": "0.8.4-main.5ea62a8",
37
+ "@dxos/node-std": "0.8.4-main.5ea62a8",
38
+ "@dxos/util": "0.8.4-main.5ea62a8"
38
39
  },
39
40
  "devDependencies": {
40
41
  "@svgr/cli": "^8.1.0",
@@ -44,16 +45,16 @@
44
45
  "lodash.defaultsdeep": "^4.6.1",
45
46
  "react": "~18.2.0",
46
47
  "react-dom": "~18.2.0",
47
- "vite": "5.4.7",
48
- "@dxos/react-ui": "0.8.4-main.5acf9ea",
49
- "@dxos/react-ui-theme": "0.8.4-main.5acf9ea",
50
- "@dxos/storybook-utils": "0.8.4-main.5acf9ea"
48
+ "vite": "7.1.1",
49
+ "@dxos/react-ui-theme": "0.8.4-main.5ea62a8",
50
+ "@dxos/react-ui": "0.8.4-main.5ea62a8",
51
+ "@dxos/storybook-utils": "0.8.4-main.5ea62a8"
51
52
  },
52
53
  "peerDependencies": {
53
54
  "react": "~18.2.0",
54
55
  "react-dom": "~18.2.0",
55
- "@dxos/react-ui": "0.8.4-main.5acf9ea",
56
- "@dxos/react-ui-theme": "0.8.4-main.5acf9ea"
56
+ "@dxos/react-ui": "0.8.4-main.5ea62a8",
57
+ "@dxos/react-ui-theme": "0.8.4-main.5ea62a8"
57
58
  },
58
59
  "publishConfig": {
59
60
  "access": "public"
@@ -4,29 +4,30 @@
4
4
 
5
5
  import '@dxos-theme';
6
6
 
7
- import type { Meta, StoryObj } from '@storybook/react-vite';
7
+ import { type Meta, type 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 { Gameboard, type GameboardRootProps, type Player, type Move } from '../Gameboard';
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<GameboardRootProps['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
@@ -44,7 +45,7 @@ const DefaultStory = ({ fen, orientation: _orientation, ...props }: RenderProps)
44
45
  </Button>
45
46
  </Toolbar.Root>
46
47
  <Gameboard.Root model={model} onDrop={handleDrop}>
47
- <Gameboard.Content>
48
+ <Gameboard.Content grow contain>
48
49
  <Chessboard orientation={orientation} {...props} />
49
50
  </Gameboard.Content>
50
51
  </Gameboard.Root>
@@ -52,7 +53,7 @@ const DefaultStory = ({ fen, orientation: _orientation, ...props }: RenderProps)
52
53
  );
53
54
  };
54
55
 
55
- const Grid = (props: RenderProps) => {
56
+ const GridStory = () => {
56
57
  const models = useMemo(() => Array.from({ length: 9 }).map(() => new ChessModel()), []);
57
58
  useEffect(() => {
58
59
  const i = setInterval(() => {
@@ -77,34 +78,34 @@ const Grid = (props: RenderProps) => {
77
78
  );
78
79
  };
79
80
 
80
- const meta: Meta<typeof DefaultStory> = {
81
+ const meta = {
81
82
  title: 'ui/react-ui-gameboard/Chessboard',
82
83
  component: Chessboard,
83
84
  render: DefaultStory,
84
85
  decorators: [withTheme, withLayout({ fullscreen: true, classNames: '' })],
85
- };
86
+ } satisfies Meta<typeof Chessboard>;
86
87
 
87
88
  export default meta;
88
89
 
89
- type Story = StoryObj<typeof DefaultStory>;
90
+ type Story = StoryObj<typeof meta>;
90
91
 
91
92
  export const Default: Story = {};
92
93
 
93
94
  export const Promotion: Story = {
94
95
  args: {
95
- 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 *',
96
97
  },
97
98
  };
98
99
 
99
100
  export const Debug: Story = {
100
101
  args: {
101
- debug: true,
102
- 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 *',
103
103
  orientation: 'black',
104
- fen: 'q3k1nr/1pp1nQpp/3p4/1P2p3/4P3/B1PP1b2/B5PP/5K2 b k - 0 17',
104
+ showLabels: true,
105
+ debug: true,
105
106
  },
106
107
  };
107
108
 
108
- export const Nine: Story = {
109
- render: Grid,
109
+ export const Grid = {
110
+ render: GridStory,
110
111
  };
@@ -2,26 +2,26 @@
2
2
  // Copyright 2025 DXOS.org
3
3
  //
4
4
 
5
- import React, { type PropsWithChildren, useRef, useMemo, useEffect, useState, memo } from 'react';
5
+ import React, { Fragment, type PropsWithChildren, memo, useEffect, useMemo, useRef, useState } from 'react';
6
6
  import { useResizeDetector } from 'react-resize-detector';
7
7
 
8
8
  import { type ThemedClassName, useTrackProps } from '@dxos/react-ui';
9
9
  import { mx } from '@dxos/react-ui-theme';
10
10
  import { isNotFalsy } from '@dxos/util';
11
11
 
12
- import { boardStyles, type ChessPiece, ChessPieces, getSquareColor, locationToPos } from './chess';
13
12
  import {
14
13
  type DOMRectBounds,
14
+ Gameboard,
15
15
  type Location,
16
16
  type PieceRecord,
17
17
  type Player,
18
- Piece,
19
- Square,
20
18
  getRelativeBounds,
21
19
  locationToString,
22
- useBoardContext,
20
+ useGameboardContext,
23
21
  } from '../Gameboard';
24
22
 
23
+ import { type ChessModel, type ChessPiece, ChessPieces, boardStyles, getSquareColor, locationToPos } from './chess';
24
+
25
25
  export type ChessboardProps = ThemedClassName<
26
26
  PropsWithChildren<{
27
27
  orientation?: Player;
@@ -39,9 +39,10 @@ export const Chessboard = memo(
39
39
  ({ orientation, showLabels, debug, rows = 8, cols = 8, classNames }: ChessboardProps) => {
40
40
  useTrackProps({ orientation, showLabels, debug }, Chessboard.displayName, false);
41
41
  const { ref: containerRef, width, height } = useResizeDetector({ refreshRate: 200 });
42
- const { model, promoting, onPromotion } = useBoardContext();
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 }[]>(() => {
@@ -97,12 +98,14 @@ export const Chessboard = memo(
97
98
 
98
99
  return (
99
100
  <div ref={containerRef} className={mx('relative', 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) => (
105
- <Square
107
+ {squares.map((location) => (
108
+ <Gameboard.Square
106
109
  key={locationToString(location)}
107
110
  location={location}
108
111
  label={showLabels ? locationToPos(location) : undefined}
@@ -111,9 +114,10 @@ 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
- <Piece
120
+ <Gameboard.Piece
117
121
  key={piece.id}
118
122
  piece={piece}
119
123
  bounds={bounds}
@@ -123,22 +127,21 @@ export const Chessboard = memo(
123
127
  />
124
128
  ))}
125
129
  </div>
126
- <div>
127
- {promoting && (
128
- <PromotionSelector
129
- grid={grid}
130
- piece={promoting}
131
- onSelect={(piece) => {
132
- onPromotion({
133
- from: Object.values(model!.pieces.value).find((p) => p.id === promoting.id)!.location,
134
- to: piece.location,
135
- piece: promoting.type,
136
- promotion: piece.type,
137
- });
138
- }}
139
- />
140
- )}
141
- </div>
130
+ {/* Promotion selector. */}
131
+ {promoting && (
132
+ <PromotionSelector
133
+ grid={grid}
134
+ piece={promoting}
135
+ onSelect={(piece) => {
136
+ onPromotion({
137
+ from: Object.values(model.pieces.value).find((p) => p.id === promoting.id)!.location,
138
+ to: piece.location,
139
+ piece: promoting.type,
140
+ promotion: piece.type,
141
+ });
142
+ }}
143
+ />
144
+ )}
142
145
  </div>
143
146
  );
144
147
  },
@@ -176,19 +179,18 @@ const PromotionSelector = ({
176
179
  onSelect({ ...piece, type: selected.type });
177
180
  };
178
181
 
179
- // TODO(burdon): Circle.
180
182
  return (
181
- <div>
183
+ <>
182
184
  {positions.map(({ piece, bounds }) => (
183
- <div key={piece.id} style={bounds} onClick={() => handleSelect(piece)}>
184
- <Piece
185
- piece={piece}
186
- bounds={bounds}
187
- Component={ChessPieces[piece.type as ChessPiece]}
188
- classNames={mx('border-2 border-neutral-700 rounded-full', boardStyles.promotion)}
189
- />
190
- </div>
185
+ <Gameboard.Piece
186
+ key={piece.id}
187
+ piece={piece}
188
+ bounds={bounds}
189
+ Component={ChessPieces[piece.type as ChessPiece]}
190
+ classNames={mx('border-2 border-neutral-700 rounded-full', boardStyles.promotion)}
191
+ onClick={() => handleSelect(piece)}
192
+ />
191
193
  ))}
192
- </div>
194
+ </>
193
195
  );
194
196
  };
@@ -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
+ });
@@ -0,0 +1,314 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import { type ReadonlySignal, signal } from '@preact/signals-core';
6
+ import { Chess as ChessJS } from 'chess.js';
7
+ import { type FC, type SVGProps } from 'react';
8
+
9
+ import { invariant } from '@dxos/invariant';
10
+ import { log } from '@dxos/log';
11
+
12
+ import * as Alpha from '../../gen/pieces/chess/alpha';
13
+ import {
14
+ type GameboardModel,
15
+ type Location,
16
+ type Move,
17
+ type PieceMap,
18
+ type PieceType,
19
+ type Player,
20
+ locationToString,
21
+ } from '../Gameboard';
22
+
23
+ export type ChessPiece = 'BK' | 'BQ' | 'BR' | 'BB' | 'BN' | 'BP' | 'WK' | 'WQ' | 'WR' | 'WB' | 'WN' | 'WP';
24
+
25
+ export const ChessPieces: Record<ChessPiece, FC<SVGProps<SVGSVGElement>>> = Alpha;
26
+
27
+ export const posToLocation = (pos: string): Location => {
28
+ const col = pos.charCodeAt(0) - 'a'.charCodeAt(0);
29
+ const row = parseInt(pos[1]) - 1;
30
+ return [row, col];
31
+ };
32
+
33
+ export const locationToPos = ([row, col]: Location): string => {
34
+ return String.fromCharCode(col + 'a'.charCodeAt(0)) + (row + 1);
35
+ };
36
+
37
+ const styles = {
38
+ neutral: {
39
+ black: 'bg-neutral-50',
40
+ white: 'bg-neutral-200',
41
+ promotion: 'bg-neutral-200 hover:bg-neutral-300 opacity-70 hover:opacity-100',
42
+ },
43
+ original: {
44
+ black: 'bg-[#6C95B9]',
45
+ white: 'bg-[#CCD3DB]',
46
+ promotion: 'duration-500 bg-[#CCD3DB] opacity-70 hover:opacity-100',
47
+ },
48
+ blue: {
49
+ black: 'bg-[#608BC1]',
50
+ white: 'bg-[#CBDCEB]',
51
+ promotion: 'duration-500 bg-[#CBDCEB] opacity-70 hover:opacity-100',
52
+ },
53
+ green: {
54
+ black: 'bg-[#8EB486]',
55
+ white: 'bg-[#FDF7F4]',
56
+ promotion: 'duration-500 bg-[#FDF7F4] opacity-70 hover:opacity-100',
57
+ },
58
+ };
59
+
60
+ export const boardStyles = styles.original;
61
+
62
+ export const getSquareColor = ([row, col]: Location) => {
63
+ return (col + row) % 2 === 0 ? boardStyles.black : boardStyles.white;
64
+ };
65
+
66
+ export const createChess = (pgn?: string): ChessJS => {
67
+ const chess = new ChessJS();
68
+ if (pgn) {
69
+ try {
70
+ chess.loadPgn(pgn);
71
+ } catch {
72
+ log.warn(pgn);
73
+ }
74
+ }
75
+
76
+ return chess;
77
+ };
78
+
79
+ /**
80
+ * Chess model.
81
+ */
82
+ export class ChessModel implements GameboardModel<ChessPiece> {
83
+ private readonly _chess = new ChessJS();
84
+ private readonly _pieces = signal<PieceMap<ChessPiece>>({});
85
+ private readonly _moveIndex = signal(0);
86
+
87
+ constructor(pgn?: string) {
88
+ this.update(pgn);
89
+ }
90
+
91
+ get readonly(): boolean {
92
+ return this._moveIndex.value !== this._chess.history().length;
93
+ }
94
+
95
+ get turn(): Player {
96
+ return this._chess.turn() === 'w' ? 'white' : 'black';
97
+ }
98
+
99
+ get game(): ChessJS {
100
+ return this._chess;
101
+ }
102
+
103
+ get pieces(): ReadonlySignal<PieceMap<ChessPiece>> {
104
+ return this._pieces;
105
+ }
106
+
107
+ get moveIndex(): ReadonlySignal<number> {
108
+ return this._moveIndex;
109
+ }
110
+
111
+ /**
112
+ * PGN with headers.
113
+ *
114
+ * [Event "?"]
115
+ * [Site "?"]
116
+ * [Date "2025.08.05"]
117
+ * [Round "?"]
118
+ * [White "?"]
119
+ * [Black "?"]
120
+ * [Result "*"]
121
+ */
122
+ // TODO(burdon): Update headers.
123
+ get pgn(): string {
124
+ return this._chess.pgn();
125
+ }
126
+
127
+ setMoveIndex(index: number) {
128
+ const temp = new ChessJS();
129
+ const history = this._chess.history({ verbose: true });
130
+ for (let i = 0; i < index && i < history.length; i++) {
131
+ temp.move(history[i]);
132
+ }
133
+ this._updateBoard(temp);
134
+ }
135
+
136
+ update(pgn = ''): void {
137
+ const previous = this._chess.history();
138
+ try {
139
+ this._chess.loadPgn(pgn);
140
+ // TODO(burdon): Get from TS.
141
+ // TODO(burdon): Update if not set.
142
+ this._chess.setHeader('Date', createDate());
143
+ this._chess.setHeader('Site', 'dxos.org');
144
+ // TODO(burdon): Update player keys.
145
+ // this._chess.setHeader('White', 'White');
146
+ // this._chess.setHeader('Black', 'Black');
147
+ } catch {
148
+ // Ignore.
149
+ }
150
+
151
+ const current = this._chess.history();
152
+ if (!isValidNextMove(previous, current)) {
153
+ this._pieces.value = {};
154
+ }
155
+
156
+ this._updateBoard(this._chess);
157
+ }
158
+
159
+ isValidMove(move: Move): boolean {
160
+ return tryMove(new ChessJS(this._chess.fen()), move) !== null;
161
+ }
162
+
163
+ canPromote(move: Move): boolean {
164
+ const isPawnMove = move.piece === 'BP' || move.piece === 'WP';
165
+ const isToLastRank = move.to[0] === 0 || move.to[0] === 7;
166
+ return isPawnMove && isToLastRank;
167
+ }
168
+
169
+ makeMove(move: Move): boolean {
170
+ const game = tryMove(this._chess, move);
171
+ if (!game) {
172
+ return false;
173
+ }
174
+
175
+ this._updateBoard(this._chess);
176
+ return true;
177
+ }
178
+
179
+ makeRandomMove(): boolean {
180
+ const moves = this._chess.moves();
181
+ if (!moves.length) {
182
+ return false;
183
+ }
184
+
185
+ const move = moves[Math.floor(Math.random() * moves.length)];
186
+ this._chess.move(move);
187
+
188
+ this._updateBoard(this._chess);
189
+ return true;
190
+ }
191
+
192
+ /**
193
+ * Update pieces preserving identity.
194
+ */
195
+ private _updateBoard(chess: ChessJS): void {
196
+ this._pieces.value = createPieceMap(chess);
197
+ this._moveIndex.value = chess.history().length;
198
+ }
199
+ }
200
+
201
+ /**
202
+ * Attempt move.
203
+ */
204
+ const tryMove = (chess: ChessJS, move: Move): ChessJS | null => {
205
+ const from = locationToPos(move.from);
206
+ const to = locationToPos(move.to);
207
+ try {
208
+ const promotion = move.promotion ? move.promotion[1].toLowerCase() : 'q';
209
+ chess.move({ from, to, promotion }, { strict: false });
210
+ return chess;
211
+ } catch {
212
+ return null;
213
+ }
214
+ };
215
+
216
+ const isValidNextMove = (previous: string[], current: string[]) => {
217
+ if (current.length > previous.length + 1) {
218
+ return false;
219
+ }
220
+
221
+ for (let i = 0; i < previous.length; i++) {
222
+ if (previous[i] !== current[i]) {
223
+ return false;
224
+ }
225
+ }
226
+
227
+ return true;
228
+ };
229
+
230
+ /**
231
+ * Starting from a new game, assign piece IDs based on their starting position.
232
+ * Then iterate through the history of the provided game and update the piece map.
233
+ */
234
+ export const createPieceMap = (chess: ChessJS): PieceMap<ChessPiece> => {
235
+ const temp = new ChessJS();
236
+ let pieces = _createPieceMap(temp);
237
+ const history = chess.history({ verbose: true });
238
+ for (let i = 0; i < history.length; i++) {
239
+ const move = history[i];
240
+ temp.move(move);
241
+ pieces = _diffPieces(pieces, _createPieceMap(temp));
242
+ const test = new Set();
243
+ Object.values(pieces).forEach((piece) => {
244
+ invariant(!test.has(piece.id), 'Duplicate: ' + piece.id);
245
+ test.add(piece.id);
246
+ });
247
+ }
248
+
249
+ return pieces;
250
+ };
251
+
252
+ /**
253
+ * Create a map of pieces from the board positions; assign each piece the ID of the current square.
254
+ */
255
+ const _createPieceMap = (chess: ChessJS): PieceMap<ChessPiece> => {
256
+ const pieces: PieceMap<ChessPiece> = {};
257
+ chess.board().flatMap((row) =>
258
+ row.forEach((record) => {
259
+ if (!record) {
260
+ return;
261
+ }
262
+
263
+ const { square, type, color } = record;
264
+ const pieceType = `${color.toUpperCase()}${type.toUpperCase()}` as ChessPiece;
265
+ const location = posToLocation(square);
266
+ pieces[locationToString(location)] = {
267
+ id: `${square}-${pieceType}`,
268
+ type: pieceType,
269
+ side: color === 'w' ? 'white' : 'black',
270
+ location,
271
+ };
272
+ }),
273
+ );
274
+
275
+ return pieces;
276
+ };
277
+
278
+ /**
279
+ * Preserve the original piece objects (and IDs).
280
+ */
281
+ const _diffPieces = <T extends PieceType>(before: PieceMap<T>, after: PieceMap<T>): PieceMap<T> => {
282
+ const difference: { added: PieceMap; removed: PieceMap } = {
283
+ removed: {},
284
+ added: {},
285
+ };
286
+
287
+ // Removed.
288
+ (Object.keys(before) as Array<keyof typeof before>).forEach((square) => {
289
+ if (after[square]?.type !== before[square]?.type) {
290
+ difference.removed[square] = before[square];
291
+ }
292
+ });
293
+
294
+ // Added.
295
+ (Object.keys(after) as Array<keyof typeof after>).forEach((square) => {
296
+ if (before[square]?.type !== after[square]?.type) {
297
+ difference.added[square] = after[square];
298
+ } else {
299
+ after[square] = before[square];
300
+ }
301
+ });
302
+
303
+ // Preserve IDs.
304
+ for (const piece of Object.values(difference.added)) {
305
+ const previous = Object.values(difference.removed).find((p) => p.type === piece.type);
306
+ if (previous) {
307
+ piece.id = previous.id;
308
+ }
309
+ }
310
+
311
+ return after;
312
+ };
313
+
314
+ const createDate = (date = new Date()) => date.toISOString().slice(0, 10).replace(/-/g, '.'); // e.g., "2025.08.05"